Initial commit

This commit is contained in:
Robert Nasarek 2026-06-25 09:11:23 +02:00
commit 05c65aad4d
155 changed files with 93617 additions and 0 deletions

View file

@ -0,0 +1,466 @@
import { core } from "../core.js";
import { toastHelper } from "../viewer-utils.js";
import { t } from "../i18n-utils.js";
export function attachEmbedConfigurator(Viewer) {
Object.assign(Viewer, {
isEmbedModeActive() {
return this.embedConfiguratorPanel?.hidden === false;
},
isEmbedMode() {
const params = new URLSearchParams(window.location.search);
const embedParam = params.get("embed");
return window.location.pathname.endsWith("/embed.html") || embedParam === "1" || embedParam === "true";
},
getEmbedPageUrl() {
const embedUrl = new URL("embed.html", import.meta.url);
embedUrl.search = "";
embedUrl.hash = "";
return embedUrl;
},
async copyTextToClipboard(value) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
const tempInput = document.createElement("textarea");
tempInput.value = value;
tempInput.setAttribute("readonly", "true");
tempInput.style.position = "absolute";
tempInput.style.left = "-9999px";
document.body.appendChild(tempInput);
tempInput.select();
document.execCommand("copy");
tempInput.remove();
},
getCurrentEmbedOptions({ includeCamera = false } = {}) {
const entityId = core.CONFIG?.entity?.id;
const modelPath = core.fileObject?.originalPath || core.container?.getAttribute("3d") || "";
const options = {
model: modelPath || null,
id: entityId || null,
theme: this.currentTheme === "light" ? "light" : null,
autorotate: core.controls?.autoRotate === true,
autorotateSpeed: Number.isFinite(core.controls?.autoRotateSpeed) ? core.controls.autoRotateSpeed : null,
disableInteraction: this.urlOptions?.disableInteraction === true,
hideUi: this.urlOptions?.hideUi === true,
hideMetadata: this.urlOptions?.hideMetadata === true,
presentationMode: core.PRESENTATION_MODE === true,
sandboxMode: core.SANDBOX_MODE === true,
cameraPosition: null,
cameraTarget: null,
fov: null,
};
if (includeCamera) {
options.cameraPosition = this.formatVector3Param(core.camera?.position);
options.cameraTarget = this.formatVector3Param(core.controls?.target);
options.fov = Number.isFinite(core.camera?.fov) ? core.camera.fov : null;
}
return options;
},
applyEmbedOptionsToInputs(options = {}) {
console.log(this.embedConfigInputs);
if (!this.embedConfigInputs) return;
this.embedConfigInputs.model.value = options.model ?? "";
this.embedConfigInputs.id.value = options.id ?? "";
this.embedConfigInputs.theme.value = options.theme === "light" ? "light" : "dark";
this.embedConfigInputs.autorotate.checked = options.autorotate === true;
this.embedConfigInputs.autorotateSpeed.value = Number.isFinite(options.autorotateSpeed) ? String(options.autorotateSpeed) : "";
this.embedConfigInputs.disableInteraction.checked = options.disableInteraction === true;
this.embedConfigInputs.hideUi.checked = options.hideUi === true;
this.embedConfigInputs.hideMetadata.checked = options.hideMetadata === true;
this.embedConfigInputs.presentationMode.checked = options.presentationMode === true;
this.embedConfigInputs.camPos.value = options.cameraPosition ?? "";
this.embedConfigInputs.camTarget.value = options.cameraTarget ?? "";
this.embedConfigInputs.fov.value = Number.isFinite(options.fov) ? String(options.fov) : "";
console.log(
"fillConfiguratorWithCurrentCamera",
this.embedConfigInputs.camPos.value
);
},
setEmbedInputError(input, hasError, message = "") {
if (!input) return;
input.classList.toggle("embed-input-invalid", hasError);
if (hasError && message) {
input.setAttribute("title", message);
input.setAttribute("aria-invalid", "true");
} else {
input.removeAttribute("title");
input.removeAttribute("aria-invalid");
}
},
validateEmbedInputFields() {
if (!this.embedConfigInputs) return true;
const { camPos, camTarget, fov } = this.embedConfigInputs;
const camPosRaw = camPos.value.trim();
const camTargetRaw = camTarget.value.trim();
const fovRaw = fov.value.trim();
const camPosOk = camPosRaw === "" || this.parseVector3Param(camPosRaw) !== null;
const camTargetOk = camTargetRaw === "" || this.parseVector3Param(camTargetRaw) !== null;
const parsedFov = this.parseFloatParam(fovRaw);
const fovOk = fovRaw === "" || (Number.isFinite(parsedFov) && parsedFov >= 1 && parsedFov <= 179);
this.setEmbedInputError(camPos, !camPosOk, "Use format: x,y,z (for example 1.2,0.8,2.5)");
this.setEmbedInputError(camTarget, !camTargetOk, "Use format: x,y,z (for example 0,0,0)");
this.setEmbedInputError(fov, !fovOk, "FOV must be a number from 1 to 179");
return camPosOk && camTargetOk && fovOk;
},
buildEmbedPayload(options = {}) {
const embedUrl = this.getEmbedPageUrl();
const params = new URLSearchParams();
if (options.model) {
params.set("model", options.model);
} else if (options.id) {
params.set("id", options.id);
}
if (options.theme === "light") {
params.set("theme", options.theme);
}
if (options.autorotate === true) {
params.set("autorotate", "1");
if (Number.isFinite(options.autorotateSpeed)) {
params.set("autorotateSpeed", String(options.autorotateSpeed));
}
}
if (options.disableInteraction) {
params.set("disableInteraction", "1");
}
if (options.hideUi) {
params.set("hideUi", "1");
}
if (options.hideMetadata) {
params.set("hideMetadata", "1");
}
if (options.presentationMode) {
params.set("presentationMode", "1");
}
if (options.sandboxMode) {
params.set("sandbox", "1");
}
if (options.cameraPosition) {
params.set("camPos", options.cameraPosition);
}
if (options.cameraTarget) {
params.set("camTarget", options.cameraTarget);
}
if (Number.isFinite(options.fov)) {
params.set("fov", String(options.fov));
}
embedUrl.search = params.toString();
return {
url: embedUrl.toString(),
code: `<iframe src="${embedUrl.toString()}" title="DFG 3D Viewer" loading="lazy" allow="fullscreen; xr-spatial-tracking" referrerpolicy="strict-origin-when-cross-origin" style="width:100%; aspect-ratio: 16 / 9; border: 0;"></iframe>`,
};
},
getSharePayload() {
return this.buildEmbedPayload(this.getCurrentEmbedOptions({ includeCamera: true }));
},
collectEmbedConfiguratorOptions() {
console.trace("collectEmbedConfiguratorOptions");
const inputs = this.embedConfigInputs;
if (!inputs) return this.getCurrentEmbedOptions({ includeCamera: true });
const parsedCamPos = this.parseVector3Param(inputs.camPos.value);
const parsedCamTarget = this.parseVector3Param(inputs.camTarget.value);
const parsedFov = this.parseFloatParam(inputs.fov.value);
const normalizedFov = Number.isFinite(parsedFov) ? Math.min(179, Math.max(1, parsedFov)) : null;
return {
model: inputs.model.value.trim() || null,
id: inputs.id.value.trim() || null,
theme: inputs.theme.value === "light" ? "light" : null,
autorotate: inputs.autorotate.checked,
autorotateSpeed: this.parseFloatParam(inputs.autorotateSpeed.value),
disableInteraction: inputs.disableInteraction.checked,
hideUi: inputs.hideUi.checked,
hideMetadata: inputs.hideMetadata.checked,
presentationMode: inputs.presentationMode.checked,
cameraPosition: this.formatVector3Param(parsedCamPos),
cameraTarget: this.formatVector3Param(parsedCamTarget),
fov: normalizedFov,
};
},
hasEmbedSourceSelection(options = {}) {
return Boolean(options.model || options.id);
},
notifyMissingEmbedSource({ force = false } = {}) {
if (!force && this.embedMissingSourceNotified) return;
toastHelper("embedSourceMissing", "warning");
this.embedMissingSourceNotified = true;
},
updateEmbedConfiguratorPreview() {
if (!this.embedConfigInputs) return;
if (this.updatingEmbedFields) return;
this.validateEmbedInputFields();
const options = this.collectEmbedConfiguratorOptions();
if (!this.hasEmbedSourceSelection(options)) {
this.notifyMissingEmbedSource();
} else {
this.embedMissingSourceNotified = false;
}
const payload = this.buildEmbedPayload(options);
this.embedConfigInputs.url.value = payload.url;
this.embedConfigInputs.iframe.value = payload.code;
if (this.embedConfigPreviewFrame) {
this.embedConfigPreviewFrame.src = payload.url;
}
},
fillConfiguratorWithCurrentCamera() {
if (!this.embedConfigInputs) return;
this.updatingEmbedFields = true;
this.embedConfigInputs.camPos.value = this.formatVector3Param(core.camera?.position) || "";
this.embedConfigInputs.camTarget.value = this.formatVector3Param(core.controls?.target) || "";
this.embedConfigInputs.fov.value = Number.isFinite(core.camera?.fov) ? String(core.camera.fov) : "";
this.updatingEmbedFields = false;
this.updateEmbedConfiguratorPreview();
},
resetEmbedConfiguratorFromViewerState() {
if (!this.embedConfigInputs) return;
this.applyEmbedOptionsToInputs(this.getCurrentEmbedOptions({ includeCamera: true }));
this.updateEmbedConfiguratorPreview();
},
toggleEmbedConfigurator(event) {
event?.preventDefault?.();
this.closeActionMenu();
if (!this.embedConfiguratorPanel) return;
const willShow = this.embedConfiguratorPanel.hidden === true;
this.embedConfiguratorPanel.hidden = !willShow;
if (willShow) {
this.updateEmbedConfiguratorPreview();
}
this.updateEmbedMenuEntryState();
},
openEmbedConfiguratorFromMenu(event) {
this.toggleEmbedConfigurator(event);
},
createEmbedConfiguratorPanel() {
if (!core.container || this.embedConfiguratorPanel) return;
const defaults = this.getCurrentEmbedOptions({ includeCamera: true });
const panelText = {
title: t("embedPanel.title", "Embed options"),
closeAria: t("embedPanel.closeAria", "Close embed options"),
modelUrl: t("embedPanel.modelUrl", "Model URL"),
modelUrlPlaceholder: t("embedPanel.modelUrlPlaceholder", "/examples/box.glb"),
entityId: t("embedPanel.entityId", "Entity ID"),
theme: t("embedPanel.theme", "Theme"),
themeDark: t("embedPanel.themeDark", "Dark"),
themeLight: t("embedPanel.themeLight", "Light"),
autoRotateSpeed: t("embedPanel.autoRotateSpeed", "Auto-rotate speed"),
cameraPosition: t("embedPanel.cameraPosition", "Camera position"),
cameraTarget: t("embedPanel.cameraTarget", "Camera target"),
cameraVectorPlaceholder: t("embedPanel.cameraVectorPlaceholder", "x,y,z"),
fov: t("embedPanel.fov", "FOV"),
autoRotate: t("embedPanel.autoRotate", "Auto-rotate"),
disableInteraction: t("embedPanel.disableInteraction", "Disable interaction"),
hideActionMenu: t("embedPanel.hideActionMenu", "Hide action menu"),
hideMetadata: t("embedPanel.hideMetadata", "Hide metadata"),
presentationMode: t("embedPanel.presentationMode", "Presentation mode"),
useCurrentCamera: t("embedPanel.useCurrentCamera", "Use Current Camera"),
resetFromViewer: t("embedPanel.resetFromViewer", "Reset From Viewer"),
copyUrl: t("embedPanel.copyUrl", "Copy URL"),
copyIframe: t("embedPanel.copyIframe", "Copy Iframe"),
embedUrl: t("embedPanel.embedUrl", "Embed URL"),
iframeCode: t("embedPanel.iframeCode", "Iframe code"),
preview: t("embedPanel.preview", "Preview"),
previewTitle: t("embedPanel.previewTitle", "Embed preview"),
};
const panel = document.createElement("div");
panel.id = "embedConfiguratorPanel";
panel.hidden = true;
panel.innerHTML = `
<div class="embed-config-header">
<span>${panelText.title}</span>
<button id="embedClosePanel" type="button" aria-label="${panelText.closeAria}">X</button>
</div>
<div class="embed-config-layout">
<div class="embed-config-main">
<div class="embed-config-grid">
<label>${panelText.modelUrl}<input id="embedModelInput" type="text" placeholder="${panelText.modelUrlPlaceholder}" value="${defaults.model ?? ""}" /></label>
<label>${panelText.entityId}<input id="embedIdInput" type="text" value="${defaults.id ?? ""}" /></label>
<label>${panelText.theme}
<select id="embedThemeInput">
<option value="dark">${panelText.themeDark}</option>
<option value="light"${defaults.theme === "light" ? " selected" : ""}>${panelText.themeLight}</option>
</select>
</label>
<label>${panelText.autoRotateSpeed}<input id="embedAutorotateSpeedInput" type="number" step="0.1" value="${Number.isFinite(defaults.autorotateSpeed) ? defaults.autorotateSpeed : ""}" /></label>
<label>${panelText.cameraPosition}<input id="embedCamPosInput" type="text" placeholder="${panelText.cameraVectorPlaceholder}" value="${defaults.cameraPosition ?? ""}" /></label>
<label>${panelText.cameraTarget}<input id="embedCamTargetInput" type="text" placeholder="${panelText.cameraVectorPlaceholder}" value="${defaults.cameraTarget ?? ""}" /></label>
<label>${panelText.fov}<input id="embedFovInput" type="number" step="1" min="1" max="179" value="${Number.isFinite(defaults.fov) ? defaults.fov : ""}" /></label>
</div>
<div class="embed-config-checks">
<label><input id="embedAutorotateInput" type="checkbox"${defaults.autorotate ? " checked" : ""} /> ${panelText.autoRotate}</label>
<label><input id="embedDisableInteractionInput" type="checkbox"${defaults.disableInteraction ? " checked" : ""} /> ${panelText.disableInteraction}</label>
<label><input id="embedHideUiInput" type="checkbox"${defaults.hideUi ? " checked" : ""} /> ${panelText.hideActionMenu}</label>
<label><input id="embedHideMetadataInput" type="checkbox"${defaults.hideMetadata ? " checked" : ""} /> ${panelText.hideMetadata}</label>
<label><input id="embedPresentationModeInput" type="checkbox"${defaults.presentationMode ? " checked" : ""} /> ${panelText.presentationMode}</label>
</div>
<div class="embed-config-actions">
<button id="embedUseCurrentCamera" type="button">${panelText.useCurrentCamera}</button>
<button id="embedResetFromViewer" type="button">${panelText.resetFromViewer}</button>
<button id="embedCopyUrl" type="button">${panelText.copyUrl}</button>
<button id="embedCopyIframe" type="button">${panelText.copyIframe}</button>
</div>
<label class="embed-config-field">${panelText.embedUrl}<textarea id="embedUrlOutput" readonly></textarea></label>
<label class="embed-config-field">${panelText.iframeCode}<textarea id="embedIframeOutput" readonly></textarea></label>
</div>
<div class="embed-config-preview-side">
<span>${panelText.preview}</span>
<iframe id="embedPreviewFrame" title="${panelText.previewTitle}" loading="lazy"></iframe>
</div>
</div>
`;
core.container.appendChild(panel);
this.embedConfiguratorPanel = panel;
this.embedConfigInputs = {
model: panel.querySelector("#embedModelInput"),
id: panel.querySelector("#embedIdInput"),
theme: panel.querySelector("#embedThemeInput"),
autorotate: panel.querySelector("#embedAutorotateInput"),
autorotateSpeed: panel.querySelector("#embedAutorotateSpeedInput"),
disableInteraction: panel.querySelector("#embedDisableInteractionInput"),
hideUi: panel.querySelector("#embedHideUiInput"),
hideMetadata: panel.querySelector("#embedHideMetadataInput"),
presentationMode: panel.querySelector("#embedPresentationModeInput"),
camPos: panel.querySelector("#embedCamPosInput"),
camTarget: panel.querySelector("#embedCamTargetInput"),
fov: panel.querySelector("#embedFovInput"),
url: panel.querySelector("#embedUrlOutput"),
iframe: panel.querySelector("#embedIframeOutput"),
};
this.embedConfigPreviewFrame = panel.querySelector("#embedPreviewFrame");
const watchedInputs = [
this.embedConfigInputs.model,
this.embedConfigInputs.id,
this.embedConfigInputs.theme,
this.embedConfigInputs.autorotate,
this.embedConfigInputs.autorotateSpeed,
this.embedConfigInputs.disableInteraction,
this.embedConfigInputs.hideUi,
this.embedConfigInputs.hideMetadata,
this.embedConfigInputs.presentationMode,
this.embedConfigInputs.camPos,
this.embedConfigInputs.camTarget,
this.embedConfigInputs.fov,
];
watchedInputs.forEach((input) => {
if (!input) return;
const eventName = input.type === "checkbox" || input.tagName === "SELECT" ? "change" : "input";
this.bindEventListener(input, eventName, () => this.updateEmbedConfiguratorPreview());
});
const useCurrentCameraButton = panel.querySelector("#embedUseCurrentCamera");
const resetFromViewerButton = panel.querySelector("#embedResetFromViewer");
const copyUrlButton = panel.querySelector("#embedCopyUrl");
const copyIframeButton = panel.querySelector("#embedCopyIframe");
const closePanelButton = panel.querySelector("#embedClosePanel");
this.bindEventListener(useCurrentCameraButton, "click", () => this.fillConfiguratorWithCurrentCamera());
this.bindEventListener(resetFromViewerButton, "click", () => this.resetEmbedConfiguratorFromViewerState());
this.bindEventListener(closePanelButton, "click", () => {
this.closeEmbedConfigurator();
});
this.bindEventListener(copyUrlButton, "click", async () => {
try {
const options = this.collectEmbedConfiguratorOptions();
if (!this.hasEmbedSourceSelection(options)) {
this.notifyMissingEmbedSource({ force: true });
return;
}
const payload = this.buildEmbedPayload(options);
await this.copyTextToClipboard(payload.url);
toastHelper("embedUrlCopied", "success");
} catch (error) {
this.reportError(error, { context: "Copy embed URL failed" });
toastHelper("embedUrlCopyError", "error");
}
});
this.bindEventListener(copyIframeButton, "click", async () => {
try {
const options = this.collectEmbedConfiguratorOptions();
if (!this.hasEmbedSourceSelection(options)) {
this.notifyMissingEmbedSource({ force: true });
return;
}
const payload = this.buildEmbedPayload(options);
await this.copyTextToClipboard(payload.code);
toastHelper("embedIframeCopied", "success");
} catch (error) {
this.reportError(error, { context: "Copy embed iframe failed" });
toastHelper("embedIframeCopyError", "error");
}
});
this.updateEmbedConfiguratorPreview();
},
async copyEmbedCode(event) {
event?.preventDefault?.();
this.closeActionMenu();
try {
const { code } = this.getSharePayload();
await this.copyTextToClipboard(code);
toastHelper("embedCodeCopied", "success");
} catch (error) {
this.reportError(error, { context: "Copy embed code failed" });
toastHelper("embedCodeCopyError", "error");
}
},
updateEmbedMenuEntryState() {
if (!this.viewEntity) return;
const isActive = this.isEmbedModeActive();
const label = isActive ? t("menu.exitEmbed", "Exit embed") : t("menu.embed", "Embed");
const iconClass = isActive ? "embed-exit-icon" : "embed-icon";
this.viewEntity.innerHTML = `<span class="${iconClass}"></span><span>${label}</span>`;
const a11yLabel = isActive
? t("menu.exitEmbedMode", "Exit embed mode")
: t("menu.openEmbedOptions", "Open embed options");
this.viewEntity.setAttribute("aria-label", a11yLabel);
this.viewEntity.setAttribute("title", a11yLabel);
},
closeEmbedConfigurator() {
if (this.embedConfiguratorPanel) {
this.embedConfiguratorPanel.hidden = true;
}
this.updateEmbedMenuEntryState();
},
closeEmbedMode() {
this.closeEmbedConfigurator();
},
});
}

614
viewer/ui/loading-status.js Normal file
View file

@ -0,0 +1,614 @@
import { core } from "../core.js";
import { t } from "../i18n-utils.js";
export function attachLoadingStatus(viewer) {
Object.assign(viewer, {
toggleLoadingLogs() {
this.showLoadingLogs = !this.showLoadingLogs;
if (core.loadingLog.get()) {
core.loadingLog.get().classList.toggle("editorToolbar-visible", this.showLoadingLogs);
}
this.updateEditorToolbarLabels();
this.updateEditorToolbarState();
},
getLoadingLogMessages() {
return this.loadingLogMessageKeys.map((key) => t(key));
},
getProcessingLoadingSteps() {
return this.processingLoadingStepKeys.map((key) => t(key));
},
createModelLoadingProgress(shell) {
shell.className = "model-loader";
shell.hidden = true;
shell.setAttribute("role", "status");
shell.setAttribute("aria-live", "polite");
const ring = document.createElement("div");
ring.className = "model-loader__ring";
ring.setAttribute("aria-hidden", "true");
const percent = document.createElement("div");
percent.className = "model-loader__percent";
percent.textContent = "0%";
ring.appendChild(percent);
const content = document.createElement("div");
content.className = "model-loader__content";
const phase = document.createElement("div");
phase.className = "model-loader__phase";
phase.textContent = this.getLoadingLogMessages()[0] ?? t("loadingLog.loadingAssets", "Loading assets");
const phaseViewport = document.createElement("div");
phaseViewport.className = "model-loader__phase-viewport";
const phaseList = document.createElement("div");
phaseList.className = "model-loader__phase-list";
phaseViewport.appendChild(phaseList);
const messages = this.getLoadingLogMessages();
const stageKeyToIndex = new Map(
this.loadingLogMessageKeys.map((key, index) => [key, index])
);
const loadingModelKeyIndex = Math.max(
0,
stageKeyToIndex.get("loadingLog.loadingModel") ?? 0
);
let hideTimer = null;
messages.forEach((msg) => {
const item = document.createElement("div");
item.className = "model-loader__phase-item";
item.textContent = msg;
phaseList.appendChild(item);
});
const clearHideTimer = () => {
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
};
const updatePhase = (index) => {
const itemHeight = phaseList.firstElementChild?.offsetHeight || 24;
phaseList.style.transform = `translateY(-${index * itemHeight}px)`;
phase.textContent = messages[index] || messages[loadingModelKeyIndex] || phase.textContent;
Array.from(phaseList.children).forEach((item, itemIndex) => {
item.toggleAttribute("data-active", itemIndex === index);
item.toggleAttribute("data-near", itemIndex === index - 1 || itemIndex === index + 1);
});
};
const track = document.createElement("div");
track.className = "model-loader__track";
track.setAttribute("role", "progressbar");
track.setAttribute("aria-valuemin", "0");
track.setAttribute("aria-valuemax", "100");
track.setAttribute("aria-valuenow", "0");
content.append(phaseViewport, track);
const bar = document.createElement("div");
bar.className = "model-loader__bar";
track.appendChild(bar);
shell.replaceChildren(ring, content);
const set = (value = 0, maxValue = 100) => {
const safeMax = Number.isFinite(maxValue) && maxValue > 0 ? maxValue : 100;
const normalized = Number.isFinite(value) ? Math.min(Math.max(value / safeMax, 0), 1) : 0;
const progress = Math.round(normalized * 100);
shell.style.setProperty("--model-loader-progress", String(progress));
percent.textContent = `${progress}%`;
bar.style.width = `${progress}%`;
track.setAttribute("aria-valuenow", String(progress));
updatePhase(loadingModelKeyIndex);
};
set(0, 100);
return {
getElement: () => shell,
show: () => {
clearHideTimer();
shell.hidden = false;
delete shell.dataset.complete;
updatePhase(loadingModelKeyIndex);
},
hide: () => {
clearHideTimer();
shell.hidden = true;
delete shell.dataset.complete;
},
setStage: (stageKey, progressValue = null) => {
const index = stageKeyToIndex.get(stageKey);
if (typeof index === "number") {
updatePhase(index);
}
if (Number.isFinite(progressValue)) {
const normalizedProgress = Math.max(0, Math.min(Math.round(progressValue), 100));
shell.style.setProperty("--model-loader-progress", String(normalizedProgress));
percent.textContent = `${normalizedProgress}%`;
bar.style.width = `${normalizedProgress}%`;
track.setAttribute("aria-valuenow", String(normalizedProgress));
}
},
complete: (delayMs = 2400) => {
clearHideTimer();
updatePhase(stageKeyToIndex.get("loadingLog.modelLoaded") ?? messages.length - 1);
shell.style.setProperty("--model-loader-progress", "100");
percent.textContent = "100%";
bar.style.width = "100%";
track.setAttribute("aria-valuenow", "100");
shell.dataset.complete = "true";
hideTimer = window.setTimeout(() => {
hideTimer = null;
delete shell.dataset.complete;
shell.hidden = true;
}, delayMs);
},
set,
reset: (maxValue = 100) => {
clearHideTimer();
delete shell.dataset.complete;
set(0, maxValue);
},
};
},
createLoadingLog() {
const shell = document.createElement("div");
shell.id = "loading-log";
shell.setAttribute("aria-live", "polite");
shell.hidden = true;
const collapsedToggle = document.createElement("button");
collapsedToggle.type = "button";
collapsedToggle.className = "loading-log__collapsed-toggle";
collapsedToggle.setAttribute("aria-label", t("loadingLog.title", "Loading process log"));
collapsedToggle.setAttribute("aria-expanded", "false");
shell.appendChild(collapsedToggle);
const header = document.createElement("button");
header.type = "button";
header.className = "loading-log__header";
header.setAttribute("aria-expanded", "false");
const headerTitle = document.createElement("span");
headerTitle.className = "loading-log__title";
headerTitle.textContent = t("loadingLog.title", "Loading process log");
const headerSummary = document.createElement("span");
headerSummary.className = "loading-log__summary";
header.append(headerTitle, headerSummary);
shell.appendChild(header);
const body = document.createElement("div");
body.className = "loading-log__body";
const list = document.createElement("div");
list.className = "loading-log__messages";
const progress = document.createElement("div");
progress.className = "loading-log__progress";
progress.setAttribute("role", "progressbar");
progress.setAttribute("aria-valuemin", "0");
progress.setAttribute("aria-valuemax", "100");
progress.setAttribute("aria-valuenow", "0");
const progressBar = document.createElement("div");
progressBar.className = "loading-log__progress-bar";
progress.appendChild(progressBar);
body.append(list, progress);
shell.appendChild(body);
core.container.appendChild(shell);
shell.classList.add("editorToolbar-hidden");
let timer = null;
let messageIndex = 0;
let visibleCount = 0;
let currentProgress = 0;
let startedAt = 0;
let hideTimer = null;
let progressUpdated = false;
const minVisibleMs = 900;
const loadingMessages = this.getLoadingLogMessages();
const loadingStageKeyToIndex = new Map(
this.loadingLogMessageKeys.map((key, index) => [key, index])
);
const loadingModelMessageIndex = Math.max(
0,
loadingStageKeyToIndex.get("loadingLog.loadingModel") ?? 0
);
let isExpanded = false;
const setExpanded = (expanded) => {
isExpanded = expanded;
shell.dataset.expanded = expanded ? "true" : "false";
header.setAttribute("aria-expanded", expanded ? "true" : "false");
collapsedToggle.setAttribute("aria-expanded", expanded ? "true" : "false");
};
const updateSummary = () => {
const activeMessage = loadingMessages[messageIndex] || loadingMessages[loadingModelMessageIndex] || "";
headerSummary.textContent = `${currentProgress}% • ${activeMessage}`;
};
header.addEventListener("click", () => {
if (shell.hidden) return;
setExpanded(!isExpanded);
});
collapsedToggle.addEventListener("click", () => {
if (shell.hidden) return;
setExpanded(!isExpanded);
});
const renderMessages = (allDone = false) => {
const messages = loadingMessages
.slice(Math.max(0, messageIndex - visibleCount + 1), messageIndex + 1);
list.replaceChildren(...messages.map((message, index) => {
const row = document.createElement("div");
row.className = "loading-log__message";
if (allDone) {
row.classList.add("loading-log__message--done");
} else if (index === messages.length - 1) {
row.classList.add("loading-log__message--active");
}
row.textContent = message;
return row;
}));
};
const setProgress = (value) => {
const normalizedValue = Number.isFinite(value) ? Math.min(Math.max(value, 0), 100) : 0;
currentProgress = Math.max(currentProgress, Math.round(normalizedValue));
progressBar.style.width = `${currentProgress}%`;
progress.setAttribute("aria-valuenow", String(currentProgress));
updateSummary();
};
const tick = () => {
visibleCount = Math.min(4, visibleCount + 1);
if (!progressUpdated) {
messageIndex = loadingModelMessageIndex;
setProgress(Math.min(currentProgress + 8, 12));
}
renderMessages();
};
const stop = () => {
if (timer) {
window.clearInterval(timer);
timer = null;
}
};
const clearHideTimer = () => {
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
};
const collapseLog = () => {
shell.classList.remove("loading-log--done");
shell.classList.remove("loading-log--error");
setExpanded(false);
hideTimer = null;
};
return {
start: () => {
stop();
clearHideTimer();
progressUpdated = false;
startedAt = performance.now();
shell.hidden = false;
setExpanded(false);
shell.classList.remove("loading-log--done", "loading-log--error");
messageIndex = loadingModelMessageIndex;
visibleCount = 1;
currentProgress = 0;
renderMessages();
setProgress(6);
timer = window.setInterval(tick, 520);
},
update: (value) => {
if (shell.hidden) return;
progressUpdated = true;
const normalized = Number.isFinite(value) ? value : 0;
messageIndex = loadingModelMessageIndex;
visibleCount = Math.min(4, Math.max(visibleCount, 2));
renderMessages();
setProgress(Math.min(normalized, 99));
},
setStage: (stageKey, progressValue = null) => {
if (shell.hidden) return;
const stageIndex = loadingStageKeyToIndex.get(stageKey);
if (typeof stageIndex === "number") {
messageIndex = stageIndex;
visibleCount = Math.min(4, Math.max(visibleCount, 2));
renderMessages();
updateSummary();
}
if (Number.isFinite(progressValue)) {
setProgress(progressValue);
}
},
finish: () => {
if (shell.hidden) return;
stop();
const messageCount = loadingMessages.length;
messageIndex = messageCount - 1;
visibleCount = messageCount;
renderMessages(true);
setProgress(100);
updateSummary();
shell.classList.add("loading-log--done");
const visibleFor = performance.now() - startedAt;
const hideDelay = Math.max(1200, minVisibleMs - visibleFor + 800);
clearHideTimer();
hideTimer = window.setTimeout(() => {
collapseLog();
}, hideDelay);
},
fail: () => {
if (shell.hidden) return;
stop();
clearHideTimer();
setExpanded(true);
shell.classList.add("loading-log--error");
hideTimer = window.setTimeout(() => {
shell.classList.remove("loading-log--error");
setExpanded(false);
hideTimer = null;
}, 2000);
},
get: () => shell,
};
},
showStatusNotice(message, duration = 2600, options = {}) {
this.enqueueStatusNotice({ message, duration, tone: "info", ...options });
},
localizeStatusNotice(notice) {
if (!notice) return notice;
const i18nKey = String(notice.i18nKey || "");
const detailI18nKey = String(notice.detailI18nKey || "");
return {
...notice,
message: i18nKey ? t(i18nKey, notice.i18nVars || {}, notice.message) : notice.message,
detail: detailI18nKey ? t(detailI18nKey, notice.detailI18nVars || {}, notice.detail) : notice.detail,
};
},
renderStatusNoticeContent(notice) {
if (!this.statusNotice || !notice) return;
const message = String(notice.message ?? "");
const detail = String(notice.detail ?? "");
this.statusNotice.textContent = "";
const messageNode = document.createElement("span");
messageNode.className = "viewer-notice-message";
messageNode.textContent = message;
this.statusNotice.appendChild(messageNode);
if (detail) {
const detailLines = detail.split(/\r?\n/);
for (const line of detailLines) {
const detailNode = document.createElement("span");
detailNode.className = "viewer-notice-detail";
detailNode.innerHTML = line;
this.statusNotice.appendChild(detailNode);
}
}
},
getStatusNoticeText(notice) {
return [notice?.message, notice?.detail].filter(Boolean).join(" ");
},
refreshStatusNoticeLanguage() {
if (Array.isArray(this.statusNoticeQueue)) {
this.statusNoticeQueue = this.statusNoticeQueue.map((notice) => this.localizeStatusNotice(notice));
}
if (!this.statusNoticeCurrent) return;
this.statusNoticeCurrent = this.localizeStatusNotice(this.statusNoticeCurrent);
this.renderStatusNoticeContent(this.statusNoticeCurrent);
},
showStatusNoticeNow(notice) {
if (!this.statusNotice || !notice) return;
notice = this.localizeStatusNotice(notice);
if (this.statusNoticeHideTimer) {
clearTimeout(this.statusNoticeHideTimer);
this.statusNoticeHideTimer = null;
}
this.statusNoticeActive = true;
this.statusNoticeCurrent = notice;
this.statusNotice.hidden = false;
this.renderStatusNoticeContent(notice);
this.statusNotice.dataset.tone = notice.tone || "info";
if (notice.variant) {
this.statusNotice.dataset.variant = notice.variant;
} else {
delete this.statusNotice.dataset.variant;
}
this.noticeContainer?.classList.toggle("viewer-notice-container--sandbox", notice.variant === "sandbox");
this.statusNotice.classList.remove("is-hiding");
this.statusNotice.classList.add("is-visible");
if (this.statusNoticeTimer) {
clearTimeout(this.statusNoticeTimer);
this.statusNoticeTimer = null;
}
if (notice.persistent) {
return;
}
this.statusNoticeTimer = setTimeout(() => {
if (this.statusNotice) {
this.statusNotice.classList.remove("is-visible");
this.statusNotice.classList.add("is-hiding");
}
this.statusNoticeHideTimer = setTimeout(() => {
if (this.statusNotice) {
this.statusNotice.hidden = true;
this.statusNotice.classList.remove("is-hiding");
delete this.statusNotice.dataset.variant;
}
this.noticeContainer?.classList.remove("viewer-notice-container--sandbox");
this.statusNoticeActive = false;
this.statusNoticeCurrent = null;
this.statusNoticeTimer = null;
this.statusNoticeHideTimer = null;
this.processStatusNoticeQueue();
}, 220);
}, notice.duration);
},
dismissStatusNotice(key = "") {
const normalizedKey = String(key || "");
this.statusNoticeQueue = this.statusNoticeQueue.filter(
(entry) => normalizedKey && (entry?.key || "") !== normalizedKey
);
if (
normalizedKey &&
(!this.statusNoticeCurrent || (this.statusNoticeCurrent.key || "") !== normalizedKey)
) {
return;
}
if (this.statusNoticeTimer) {
clearTimeout(this.statusNoticeTimer);
this.statusNoticeTimer = null;
}
if (this.statusNoticeHideTimer) {
clearTimeout(this.statusNoticeHideTimer);
this.statusNoticeHideTimer = null;
}
if (this.statusNotice) {
this.statusNotice.hidden = true;
this.statusNotice.classList.remove("is-visible", "is-hiding");
delete this.statusNotice.dataset.variant;
}
this.noticeContainer?.classList.remove("viewer-notice-container--sandbox");
this.statusNoticeActive = false;
this.statusNoticeCurrent = null;
this.processStatusNoticeQueue();
},
enqueueStatusNotice({
message,
duration = 2600,
tone = "info",
key = "",
replace = false,
persistent = false,
variant = "",
i18nKey = "",
i18nVars = {},
detail = "",
detailI18nKey = "",
detailI18nVars = {},
} = {}) {
const text = String(message ?? "");
if (!text) return;
const nextNotice = {
message: text,
tone,
duration: Number.isFinite(duration) ? duration : 2600,
key: String(key || ""),
persistent,
variant: String(variant || ""),
i18nKey: String(i18nKey || ""),
i18nVars: i18nVars && typeof i18nVars === "object" ? { ...i18nVars } : {},
detail: String(detail || ""),
detailI18nKey: String(detailI18nKey || ""),
detailI18nVars: detailI18nVars && typeof detailI18nVars === "object" ? { ...detailI18nVars } : {},
};
if (
this.statusNoticeActive &&
this.getStatusNoticeText(this.statusNoticeCurrent) === this.getStatusNoticeText(nextNotice) &&
(this.statusNoticeCurrent?.tone || "info") === nextNotice.tone &&
(this.statusNoticeCurrent?.key || "") === nextNotice.key
) {
return;
}
if (nextNotice.key) {
this.statusNoticeQueue = this.statusNoticeQueue.filter(
(entry) => (entry?.key || "") !== nextNotice.key
);
}
if (
replace &&
this.statusNoticeActive &&
(!nextNotice.key || (this.statusNoticeCurrent?.key || "") === nextNotice.key)
) {
this.showStatusNoticeNow(nextNotice);
return;
}
const isDuplicateQueued = this.statusNoticeQueue.some(
(entry) =>
this.getStatusNoticeText(entry) === this.getStatusNoticeText(nextNotice) &&
entry?.tone === nextNotice.tone &&
(entry?.key || "") === nextNotice.key
);
if (isDuplicateQueued) return;
const priorityMap = {
error: 0,
warning: 1,
info: 2,
success: 3,
};
const nextPriority = priorityMap[nextNotice.tone] ?? 2;
const insertAt = this.statusNoticeQueue.findIndex((entry) => {
const entryPriority = priorityMap[entry?.tone] ?? 2;
return nextPriority < entryPriority;
});
if (insertAt === -1) {
this.statusNoticeQueue.push(nextNotice);
} else {
this.statusNoticeQueue.splice(insertAt, 0, nextNotice);
}
this.processStatusNoticeQueue();
},
processStatusNoticeQueue() {
if (this.statusNoticeActive) return;
if (!this.statusNotice) return;
if (!Array.isArray(this.statusNoticeQueue) || this.statusNoticeQueue.length === 0) return;
const nextNotice = this.statusNoticeQueue.shift();
if (!nextNotice) return;
this.showStatusNoticeNow(nextNotice);
},
});
}

View file

@ -0,0 +1,228 @@
import { core } from "../core.js";
import { t } from "../i18n-utils.js";
import { UltraLoader } from "../ultra-loader.js";
export function attachLocalizationTheme(viewer) {
Object.assign(viewer, {
getStoredTheme() {
if (this.urlOptions?.theme === "light" || this.urlOptions?.theme === "dark") {
return this.urlOptions.theme;
}
const storedTheme = window.localStorage.getItem(this.THEME_STORAGE_KEY);
return storedTheme === "0" ? "light" : "dark";
},
normalizeLanguage(value) {
if (value == null) return null;
const normalizedValue = String(value).trim().toLowerCase();
if (normalizedValue.startsWith("pl")) return "pl";
if (normalizedValue.startsWith("de")) return "de";
if (normalizedValue.startsWith("en")) return "en";
return null;
},
getStoredLanguage() {
const fromQuery = this.normalizeLanguage(this.urlOptions?.language);
if (fromQuery) return fromQuery;
const storedLanguage = this.normalizeLanguage(window.localStorage.getItem(this.LANGUAGE_STORAGE_KEY));
if (storedLanguage) return storedLanguage;
const browserLanguage = this.normalizeLanguage(navigator?.language || "en");
return browserLanguage || "en";
},
updateThemeControlLabels() {
const isDark = this.currentTheme === "dark";
if (this.themeMode) {
this.themeMode.innerHTML = `
<span class="viewer-theme-icon" aria-hidden="true">${isDark ? "☀️" : "🌙"}</span>
<span>${isDark ? t("theme.lightMode", "Light mode") : t("theme.darkMode", "Dark mode")}</span>
`;
const label = isDark
? t("theme.switchToLightMode", "Switch to light mode")
: t("theme.switchToDarkMode", "Switch to dark mode");
this.themeMode.setAttribute("aria-label", label);
this.themeMode.setAttribute("title", label);
}
const exampleThemeToggle = document.getElementById("example-theme-toggle");
if (exampleThemeToggle) {
exampleThemeToggle.textContent = isDark ? "☀️" : "🌙";
exampleThemeToggle.setAttribute("aria-pressed", isDark ? "true" : "false");
exampleThemeToggle.hidden = true;
}
},
applyTheme(theme, { persist = true } = {}) {
const normalizedTheme = theme === "light" ? "light" : "dark";
const isDark = normalizedTheme === "dark";
this.currentTheme = normalizedTheme;
document.documentElement.setAttribute("data-viewer-theme", normalizedTheme);
document.body.setAttribute("data-viewer-theme", normalizedTheme);
document.body.classList.toggle("iiif-dark", isDark);
this.viewerWrapper?.setAttribute("data-viewer-theme", normalizedTheme);
this.actionMenu?.setAttribute("data-viewer-theme", normalizedTheme);
this.metadataContainer?.setAttribute("data-viewer-theme", normalizedTheme);
core.guiContainer?.setAttribute("data-viewer-theme", normalizedTheme);
document.getElementById("form-IIIF")?.setAttribute("data-viewer-theme", normalizedTheme);
UltraLoader.panel?.setAttribute("data-viewer-theme", normalizedTheme);
if (persist) {
window.localStorage.setItem(this.THEME_STORAGE_KEY, isDark ? "1" : "0");
}
this.updateThemeControlLabels();
},
toggleTheme() {
this.closeActionMenu();
this.applyTheme(this.currentTheme === "dark" ? "light" : "dark");
},
updateLanguageControlLabels() {
if (!this.languageMode) return;
const languages = [
{ code: "en", label: "Language: EN"},
{ code: "pl", label: "Język: PL"},
{ code: "de", label: "Sprache: DE"}
];
const currentLangLabel = languages.find((language) => language.code === core.currentLanguage)?.label || "EN";
this.languageMode.innerHTML = `
<span class="viewer-action-icon language-icon" aria-hidden="true"></span>
<span>${currentLangLabel}</span>
`;
this.languageMode.setAttribute("aria-label", t("language.label", "Language: EN"));
this.languageMode.setAttribute("title", t("language.label", "Language: EN"));
if (this.languageModeDropdown) {
const items = this.languageModeDropdown.querySelectorAll(".language-dropdown-item");
items.forEach((item) => {
item.classList.toggle("active", item.dataset.lang === core.currentLanguage);
});
}
},
updateActionMenuLabels() {
if (!this.actionMenu) return;
const actionMenuLabel = t("menu.mainMenu", "Main menu");
const toggle = this.actionMenu.querySelector(".viewer-action-menu_toggle");
toggle?.setAttribute("title", actionMenuLabel);
const toggleCopy = toggle?.querySelector(".viewer-editor-tool_sr");
if (toggleCopy) toggleCopy.textContent = actionMenuLabel;
this.actionMenu.querySelector(".viewer-action-menu_panel")?.setAttribute("aria-label", actionMenuLabel);
},
updateDownloadMenuEntryLabel() {
if (!this.downloadModel || this.downloadModel.hidden) return;
this.downloadModel.innerHTML = `
<span class="viewer-action-icon download-icon" aria-hidden="true"></span>
<span>${t("menu.download", "Download")}</span>
`;
},
updateLocalizedUI() {
const lang = ["pl", "de"].includes(core.currentLanguage) ? core.currentLanguage : "en";
document.documentElement.setAttribute("lang", lang);
this.updateActionMenuLabels();
this.updateLanguageControlLabels();
this.updateThemeControlLabels();
this.updateEmbedMenuEntryState();
this.updateFullscreenButtonIcon();
this.updateDownloadMenuEntryLabel();
this.updateEditorToolbarLabels();
this.updateEditorToolbarState();
this.updatePickingModeControllerLabel();
this.updateDistanceMeasurementControllerLabel();
this.updateSelectedFacesControllerLabel();
this.updateLocalPreviewLabels();
this.updateIIIFFormLabels();
this.updateMetadataPanelLabels();
this.updateMaterialsDialogLabels();
this.refreshStatusNoticeLanguage();
UltraLoader.updateHeader?.();
if (this.pickingHint) this.pickingHint.textContent = t("hints.picking", "Shift + click to select multiple faces");
if (this.clippingHint) this.clippingHint.textContent = t("hints.clipping", "Drag active clipping plane helper to adjust cut");
},
updateLocalPreviewLabels() {
const label = document.querySelector("#example-model-picker label[for='example-model-select']");
if (label) {
label.textContent = t("localPreview.loadExampleModel", "Load example model");
}
},
updateIIIFFormLabels() {
const form = document.getElementById("form-IIIF");
if (!form) return;
const title = form.querySelector(".form-IIIF-header .title");
if (title) title.textContent = t("iiif.loader", "IIIF Loader");
const collapseBtn = document.getElementById("iiif-toggle-collapse");
if (collapseBtn) {
const isCollapsed = form.classList.contains("collapsed");
collapseBtn.title = isCollapsed
? t("iiif.expand", "Expand")
: t("iiif.collapse", "Collapse");
}
const label = form.querySelector(".form-IIIF-label");
if (label) label.textContent = t("iiif.manifest", "IIIF manifest");
const select = document.getElementById("iiif-manifest-select");
if (select) {
const optionLabelByUrl = {
"https://raw.githubusercontent.com/IIIF/3d/main/manifests/4_transform_and_position/model_transform_scale_position.json": t("iiif.optionModelPositionScale", "Model Position and Scale"),
"https://raw.githubusercontent.com/IIIF/3d/main/manifests/1_basic_model_in_scene/model_origin.json": t("iiif.optionModelOrigin", "Model Origin"),
"https://raw.githubusercontent.com/IIIF/3d/main/manifests/1_basic_model_in_scene/model_origin_bgcolor.json": t("iiif.optionModelOriginBg", "Model Origin with background color"),
"https://raw.githubusercontent.com/IIIF/3d/main/manifests/4_transform_and_position/model_position.json": t("iiif.optionModelPosition", "Model Position"),
};
Array.from(select.options).forEach((option) => {
const labelFromMap = optionLabelByUrl[option.value];
if (labelFromMap) option.textContent = labelFromMap;
});
}
const manifestUrl = document.getElementById("manifest-url");
if (manifestUrl) manifestUrl.placeholder = t("iiif.manifestUrlPlaceholder", "https://example.org/iiif/manifest.json");
const manifestText = document.getElementById("manifest-text");
if (manifestText) manifestText.placeholder = t("iiif.manifestTextPlaceholder", "Paste IIIF manifest JSON here...");
const loadFromUrlButton = document.getElementById("load-manifest-from-url");
if (loadFromUrlButton) loadFromUrlButton.textContent = t("iiif.loadFromUrl", "Load from URL");
const loadFromTextButton = document.getElementById("load-manifest-from-text");
if (loadFromTextButton) loadFromTextButton.textContent = t("iiif.loadFromText", "Load from Text");
},
updateMetadataPanelLabels() {
const metadataContainer = document.getElementById("metadata-container");
if (!metadataContainer) return;
metadataContainer.querySelectorAll("[data-i18n-key]").forEach((node) => {
const key = node.getAttribute("data-i18n-key");
if (!key) return;
const needsColon = node.classList.contains("metadata-label");
const text = t(key, node.textContent?.replace(/:\s*$/, "") || "");
node.textContent = needsColon ? `${text}:` : text;
});
},
applyLanguage({ persist = true } = {}) {
if (persist) {
window.localStorage.setItem(this.LANGUAGE_STORAGE_KEY, core.currentLanguage);
}
this.updateLocalizedUI();
},
toggleLanguage() {
if (!this.languageModeDropdown) return;
const isVisible = !this.languageModeDropdown.hidden;
this.languageModeDropdown.hidden = isVisible;
},
selectLanguage(lang) {
core.currentLanguage = lang;
this.languageModeDropdown.hidden = true;
this.closeActionMenu();
this.applyLanguage();
},
});
}

View file

@ -0,0 +1,331 @@
import { core } from "../core.js";
import { isValidUrl } from "../utils.js";
function getGalleryConfig() {
return core.CONFIG?.viewer?.gallery || {};
}
function getGalleryHost(Viewer, mainElement) {
return (
Viewer.fileElement?.[0] ||
mainElement ||
Viewer.container ||
core.container ||
null
);
}
function removeExistingGalleryDom() {
document.getElementById("image-list")?.remove();
document.getElementById("modalGallery")?.remove();
}
function createPlaceholderSvgDataUrl(index, label = "") {
const palette = [
["#1f3c88", "#6da3ff"],
["#0f766e", "#6ee7b7"],
["#9a3412", "#fdba74"],
["#5b21b6", "#c4b5fd"],
];
const [start, end] = palette[index % palette.length];
const title = label || `Preview ${index + 1}`;
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 320">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${start}"/>
<stop offset="100%" stop-color="${end}"/>
</linearGradient>
</defs>
<rect width="480" height="320" fill="url(#g)"/>
<circle cx="92" cy="86" r="34" fill="rgba(255,255,255,0.25)"/>
<path d="M48 248l94-98 72 66 66-86 152 118H48z" fill="rgba(255,255,255,0.22)"/>
<text x="240" y="164" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="#ffffff">${title}</text>
</svg>
`.trim();
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
}
function getConfiguredTestImages() {
const gallery = getGalleryConfig();
const configuredImages = Array.isArray(gallery.testImages) ? gallery.testImages : [];
const normalizedImages = configuredImages.map((entry, index) => {
if (typeof entry === "string") {
const src = normalizeGalleryUrl(entry);
return src ? { src, alt: `Preview ${index + 1}` } : null;
}
if (entry && typeof entry === "object") {
const src = normalizeGalleryUrl(entry.src || entry.url || "");
if (!src) return null;
return {
src,
alt: String(entry.alt || entry.label || `Preview ${index + 1}`),
};
}
return null;
}).filter(Boolean);
if (normalizedImages.length > 0) {
return normalizedImages;
}
return [];
}
function createDefaultTestImages() {
return Array.from({ length: 9 }, (_unused, index) => ({
src: createPlaceholderSvgDataUrl(index, `Preview ${index + 1}`),
alt: `Preview ${index + 1}`,
}));
}
function createFakeGalleryElements(testImages) {
return testImages.map((entry) => {
const wrapper = document.createElement("div");
wrapper.className = "field__item";
wrapper.innerHTML =
`<img loading="lazy" src="${entry.src}" width="200px" height="200px" alt="${entry.alt}" class="img-fluid image-style-wisski-preview">`;
return wrapper;
});
}
function prepareGalleryImages(Viewer, imageElementsChildren) {
imageElementsChildren = imageElementsChildren.filter(function (_image) {
if (!(_image instanceof Element)) return false;
let rawUrl = "";
const img = _image.querySelector("img");
const link = _image.querySelector("a");
if (img && img.getAttribute("src")) {
rawUrl = img.getAttribute("src");
} else if (link && link.getAttribute("href")) {
rawUrl = link.getAttribute("href");
} else {
rawUrl = (_image.textContent || _image.innerHTML || "").trim();
}
const normalized = normalizeGalleryUrl(rawUrl);
if (!isValidUrl(normalized)) {
return false;
}
_image.innerHTML = normalized;
return !!img;
});
imageElementsChildren.forEach(function (imgLink) {
imgLink.innerHTML =
'<img loading="lazy" src="' +
imgLink.innerHTML +
'" width="200px" height="200px" alt="" class="img-fluid image-style-wisski-preview">';
});
return imageElementsChildren;
}
function normalizeGalleryUrl(rawUrl) {
if (!rawUrl || typeof rawUrl !== "string") {
return "";
}
let url = rawUrl.trim();
if (url === "") {
return "";
}
if (url.startsWith("public://")) {
url = "/sites/default/files/" + url.substring("public://".length);
} else if (url.startsWith("sites/default/files/")) {
url = "/" + url;
}
const base = (core.CONFIG?.mainUrl || window.location.origin || "").replace(/\/+$/, "");
try {
const parsed = new URL(url, window.location.origin);
const host = parsed.host || "";
const path = parsed.pathname || "";
const normalizedHost = host.toLowerCase();
const hasBadHost = host.includes("_") || normalizedHost === "default" || normalizedHost === "dfg_3dviewer";
if (path.startsWith("/sites/default/files/")) {
if (hasBadHost) {
return `${base}${path}`;
}
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.href;
}
return `${base}${path}`;
}
return parsed.href;
} catch (_error) {
if (url.startsWith("/sites/default/files/")) {
return `${base}${url}`;
}
return url;
}
}
function handleImages(Viewer, mainElement, imageElements, imageElementsChildren) {
if (imageElementsChildren === undefined) {
imageElementsChildren = imageElements;
}
removeExistingGalleryDom();
var imageList = document.createElement("div");
imageList.setAttribute("id", "image-list");
imageList.style.display = "flex";
imageList.style.flexWrap = "wrap";
imageList.style.gap = "16px";
imageList.style.alignItems = "center";
var modalGallery = document.createElement("div");
var modalImage = document.createElement("img");
modalImage.setAttribute("class", "modalImage");
modalImage.style.transform = "scale(0.95)";
Viewer.bindEventListener(modalGallery, "wheel", function (e) {
e.preventDefault();
e.stopPropagation();
if (e.deltaY > 0 && Viewer.zoomImage > 0.15) {
modalImage.style.transform = `scale(${(Viewer.zoomImage -= Viewer.ZOOM_SPEED_IMAGE)})`;
} else if (e.deltaY < 0 && Viewer.zoomImage < 5) {
modalImage.style.transform = `scale(${(Viewer.zoomImage += Viewer.ZOOM_SPEED_IMAGE)})`;
}
return false;
});
var modalClose = document.createElement("span");
modalGallery.setAttribute("id", "modalGallery");
modalGallery.setAttribute("class", "modalGallery");
modalClose.setAttribute("class", "closeGallery");
modalClose.setAttribute("title", "Close");
modalClose.innerHTML = "&times";
modalClose.onclick = function () {
modalGallery.classList.remove("is-open");
};
Viewer.bindEventListener(document, "click", function (event) {
if (
!modalGallery.contains(event.target) &&
!imageList.contains(event.target)
) {
modalGallery.classList.remove("is-open");
Viewer.zoomImage = 1.5;
modalImage.style.transform = "scale(1.5)";
}
});
modalGallery.appendChild(modalImage);
modalGallery.appendChild(modalClose);
for (let i = 0; imageElementsChildren.length - i >= 0; i++) {
if (
imageElementsChildren[i] !== undefined &&
imageElementsChildren[i].innerHTML !== undefined
) {
var imgList = imageElementsChildren[i].getElementsByTagName("a");
for (let j = 0; j < imgList.length; j++) {
imgList[j].setAttribute("href", "#");
imgList[j].setAttribute("src", imgList[j].firstChild.src);
imgList[j].setAttribute("class", "image-list-item");
}
imgList = imageElementsChildren[i].getElementsByTagName("img");
if (imgList.length == 1) {
imgList[0].style.maxWidth = "fit-content";
imgList[0].style.maxHeight = "180px";
}
for (let j = 0; j < imgList.length; j++) {
imgList[j].onclick = function () {
modalGallery.classList.add("is-open");
imageList.style.zIndex = 0;
imageList.style.display = "hidden";
modalImage.src = this.src;
};
}
if (imageElementsChildren[i] instanceof HTMLElement) {
imageElementsChildren[i].style.display = "block";
}
imageList.appendChild(imageElementsChildren[i]);
}
}
if (
imageList &&
imageList.childNodes.length > 0 &&
getGalleryHost(Viewer, mainElement)
) {
const galleryHost = getGalleryHost(Viewer, mainElement);
galleryHost.insertAdjacentElement("beforebegin", modalGallery);
galleryHost.insertAdjacentElement("beforebegin", imageList);
}
}
export function buildThumbnailGallery(Viewer) {
const gallery = getGalleryConfig();
var mainElement = gallery.container
? document.getElementById(gallery.container)
: null;
var imageElements;
if (gallery.imageClass !== "") {
imageElements = document.getElementsByClassName(
gallery.imageClass
);
if (imageElements.length === 0) {
const fallbackFields = document.querySelectorAll(
".field--type-image"
);
if (fallbackFields.length > 0) {
imageElements = fallbackFields;
console.warn(
"Gallery imageClass not found, falling back to .field--type-image."
);
}
}
if (imageElements.length > 0) {
var galleryLabel = document.getElementsByClassName("field__label");
if (galleryLabel !== undefined && galleryLabel.length > 0) {
galleryLabel[0].innerText = "";
}
}
} else if (gallery.imageId !== "") {
imageElements = document.getElementById(gallery.imageId);
}
if (imageElements != null) {
if (imageElements.length > 0) {
if (imageElements[0].innerHTML !== undefined) {
let imagesList = Array.from(
imageElements[0].getElementsByClassName("field__items")[0]
.childNodes
);
imagesList = prepareGalleryImages(Viewer, imagesList);
imageElements[0].classList.add("field--label-hidden");
//imageElements[0].classList.add("field__items");
handleImages(Viewer, mainElement, imagesList, imagesList);
} else {
handleImages(Viewer, mainElement, imageElements);
}
} else if (
imageElements.childNodes !== undefined &&
imageElements.childNodes.length > 0
) {
if (
typeof imageElements.childNodes[0].innerHTML == "string" ||
typeof imageElements.childNodes[1].innerHTML == "string"
) {
let imagesList = Array.from(imageElements.childNodes);
imagesList = prepareGalleryImages(Viewer, imagesList);
imageElements.classList.add("field--type-image");
imageElements.classList.add("field--label-hidden");
//imageElements.classList.add("field__items");
handleImages(Viewer, mainElement, imagesList, imageElements);
} else {
handleImages(Viewer, mainElement, imageElements);
}
}
}
if (core.CONFIG?.viewer?.gallery?.buildFake === true) {
const testImages = getConfiguredTestImages();
const fallbackImages = testImages.length > 0 ? testImages : createDefaultTestImages();
if (gallery.build === true) {
const fakeImages = createFakeGalleryElements(fallbackImages);
handleImages(Viewer, mainElement, fakeImages, fakeImages);
console.log("Built fallback thumbnail gallery for local testing");
return;
}
}
console.log("No gallery source found");
}