import THREE from "./init.js"; import { core } from "./core.js"; import { t } from "./i18n-utils.js"; import { toastHelper } from './viewer-utils.js'; export function getEditorToolbarIcon(icon) { const icons = { moveToolbar: '', orbit: '', move: '', rotate: '', scale: '', lightMove: '', lightTarget: '', lights: '', materials: '', ambientLight: '', cameraLight: '', environmentMap: '', color: '', intensity: '', picking: '', resetCamera: '', preview: '', save: '', mainMenu: '', advancedEditor: '', fullScreen: '', displayHelperX: '', displayHelperY: '', displayHelperZ: '', visible: '', clippingPlanes: '', ruler: '', annotate: '', annotateAdd: '', annotateImport: '', annotateExport: '', IIIFexport: '', hierarchy: '', loadingLogs: '', performance: '', statistics: '', performanceDefault: '', performanceHigh: '', performanceLow: '', expand: '', collapse: '', projection: '', wireframe: '', screenshot: '', download: '', }; return icons[icon] || icons.advancedEditor; } export function syncEditorToolbarSecondaryTrayWidth(viewer) { if (!viewer.editorToolbarSecondaryTray) return; const width = Array.from( viewer.editorToolbarSecondaryTray.children ).reduce( (sum, el) => sum + el.getBoundingClientRect().width + 10, 0 ); viewer.editorToolbarSecondaryTray.style.setProperty( "--viewer-toolbar-secondary-width", `${Math.ceil(width)}px` ); } export function getEditorToolbarHost(viewer) { return core.container || viewer.viewerWrapper || null; } function initializeEditorToolbarDrag(handle, viewer, toolbar, host) { let dragState = null; // persistent toolbar position let currentX = 0; let currentY = 0; const getScale = () => { const style = getComputedStyle(toolbar); const scale = parseFloat( style.getPropertyValue("--viewer-toolbar-scale") ); return Number.isFinite(scale) ? scale : 1; }; const clampPosition = (x, y) => { const hostRect = host.getBoundingClientRect(); return { x: Math.min( Math.max(x, -hostRect.width), hostRect.width ), y: Math.min( Math.max(y, -hostRect.height), hostRect.height ), }; }; const applyPosition = () => { toolbar.style.setProperty("--drag-x", `${currentX}px`); toolbar.style.setProperty("--drag-y", `${currentY}px`); }; const updateToolbarPosition = (event) => { if (!dragState) return; const scale = getScale(); const dx = (event.clientX - dragState.startX) / scale; const dy = (event.clientY - dragState.startY) / scale; const pos = clampPosition( dragState.originX + dx, dragState.originY + dy ); currentX = pos.x; currentY = pos.y; applyPosition(); }; const stopToolbarDrag = () => { if (!dragState) return; dragState = null; toolbar.classList.remove("viewer-editor-toolbar_dragging"); document.removeEventListener( "pointermove", updateToolbarPosition ); document.removeEventListener( "pointerup", stopToolbarDrag ); document.removeEventListener( "pointercancel", stopToolbarDrag ); requestAnimationFrame(() => { toolbar.style.removeProperty("transition"); }); }; const startToolbarDrag = (event) => { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); dragState = { startX: event.clientX, startY: event.clientY, originX: currentX, originY: currentY, }; toolbar.classList.add("viewer-editor-toolbar_dragging"); toolbar.style.transition = "none"; document.addEventListener( "pointermove", updateToolbarPosition, {passive: true} ); document.addEventListener( "pointerup", stopToolbarDrag ); document.addEventListener( "pointercancel", stopToolbarDrag ); }; viewer.bindEventListener( handle, "pointerdown", startToolbarDrag ); viewer.bindEventListener(handle, "click", (event) => { event.stopPropagation(); }); // keep position valid after resize const resizeObserver = new ResizeObserver(() => { const pos = clampPosition(currentX, currentY); currentX = pos.x; currentY = pos.y; applyPosition(); }); resizeObserver.observe(host); applyPosition(); } export function attachEditorToolbar(viewer) { if (!core.editorToolbar || !core.container) return; if (getComputedStyle(core.container).position === 'static') { core.container.style.position = 'relative'; } const host = getEditorToolbarHost(viewer); if (!host || core.editorToolbar.parentElement === host) return; host.appendChild(core.editorToolbar); } export function toggleToolbarExpanded(viewer) { if (!core.editorToolbar) return; syncEditorToolbarSecondaryTrayWidth(viewer); viewer.isToolbarExpanded = !viewer.isToolbarExpanded; core.editorToolbar.classList.toggle("expanded", viewer.isToolbarExpanded); viewer.editorToolbarButtons.expand.classList.toggle("expanded-icon", viewer.isToolbarExpanded); viewer.editorToolbarButtons.expand.setAttribute("aria-expanded", viewer.isToolbarExpanded ? "true" : "false"); const icon = viewer.editorToolbarButtons.expand.querySelector(".viewer-editor-tool_icon"); if (icon) { icon.innerHTML = getEditorToolbarIcon(viewer.isToolbarExpanded ? "collapse" : "expand"); } viewer.updateEditorToolbarLabels(); } async function downloadFile(fileName = "model.glb") { if (!core.downloadModel) return; const handle = await window.showSaveFilePicker({ suggestedName: fileName, }); const writable = await handle.createWritable(); await writable.write(core.downloadModel); await writable.close(); toastHelper("download", "success"); } export function createEditorToolbar(viewer) { if (!core.EDITOR || viewer.urlOptions.hideUi || core.editorToolbar || !core.container) return; const toolbar = document.createElement("div"); toolbar.id = "viewerEditorToolbar"; toolbar.setAttribute("role", "toolbar"); toolbar.setAttribute("aria-label", t("toolbar.editor", "Editor tools")); const tools = [ { key: "moveToolbar", icon: "moveToolbar", onClick: () => {}, pressed:true, primary: true }, { key: "orbit", icon: "orbit", onClick: () => viewer.setObjectTransformMode(""), primary: true }, { key: "move", icon: "move", onClick: () => viewer.toggleObjectTransformMode("translate"), pressed: true, primary: true }, { key: "rotate", icon: "rotate", onClick: () => viewer.toggleObjectTransformMode("rotate"), pressed: true, primary: true }, { key: "scale", icon: "scale", onClick: () => viewer.toggleObjectTransformMode("scale"), pressed: true, primary: true }, { key: "lights", icon: "lights", onClick: () => {}, pressed: false, primary: false }, { key: "materials", icon: "materials", onClick: () => viewer.openMaterialsFolder(), pressed: false, primary: false }, { key: "picking", icon: "picking", onClick: () => viewer.togglePickingMode(), pressed: true, primary: false }, { key: "annotate", icon: "annotate", onClick: () => viewer.openAnnotationDialogWithAutoPicking(), primary: false }, { key: "ruler", icon: "ruler", onClick: () => viewer.toggleDistanceMeasurement(), pressed: true, primary: false }, { key: "fullScreen", icon: "fullScreen", onClick: () => viewer.toggleFullscreen(), pressed: true, primary: true }, { key: "clippingPlanes", icon: "clippingPlanes", onClick: () => viewer.toggleClippingPlanesPanel(), pressed: true, primary: false }, { key: "resetCamera", icon: "resetCamera", onClick: () => viewer.resetCamera(), primary: false }, { key: "hierarchy", icon: "hierarchy", onClick: () => {}, pressed: true, primary: false }, { key: "projection", icon: "projection", onClick: () => viewer.toggleCameraProjection(), pressed: true, primary: false }, { key: "wireframe", icon: "wireframe", onClick: () => viewer.toggleWireframeMode(), pressed: true, primary: false }, { key: "statistics", icon: "statistics", onClick: () => {}, pressed: false, primary: false }, ]; if (!core.isLightweight || core.isLocalPreview) { tools.splice(tools.length - 1, 0, { key: "loadingLogs", icon: "loadingLogs", onClick: () => viewer.toggleLoadingLogs(), pressed: true, primary: false }, { key: "download", icon: "download", onClick: () => downloadFile(core.fileObject.filename), pressed: true, primary: false }, { key: "preview", icon: "preview", onClick: () => viewer.takeScreenshot(), primary: false }, { key: "save", icon: "save", onClick: () => {}, primary: false } ); } viewer.editorToolbarButtons = {}; viewer.environmentMapPreset = viewer.environmentMapPreset || "neutral"; const secondaryTray = document.createElement("div"); secondaryTray.className = "viewer-editor-toolbar_secondary-tray"; viewer.editorToolbarSecondaryTray = secondaryTray; tools.forEach((tool) => { const button = document.createElement("button"); button.type = "button"; button.className = "viewer-editor-tool"; if (!tool.primary) { button.classList.add("viewer-editor-tool-not-primary"); } button.dataset.tool = tool.key; button.dataset.pressed = tool.pressed ? "true" : "false"; button.dataset.primary = tool.primary ? "true" : "false"; if (tool.key === "materials") { const label = t("gui.materials", "Materials"); button.setAttribute("title", label); button.setAttribute("aria-label", label); } button.innerHTML = ` `; if (tool.key === "moveToolbar") { initializeEditorToolbarDrag(button, viewer, toolbar, getEditorToolbarHost(viewer)); } else if (tool.key === "clippingPlanes") { button.classList.add("has-submenu"); const submenu = document.createElement("div"); submenu.className = "viewer-editor-tool_submenu"; const submenuItems = [ { key: "displayHelperX", icon: "displayHelperX", label: t("gui.displayHelperX", "Show X helper"), onClick: () => viewer.toggleClippingPlaneHelper("x") }, { key: "displayHelperY", icon: "displayHelperY", label: t("gui.displayHelperY", "Show Y helper"), onClick: () => viewer.toggleClippingPlaneHelper("y") }, { key: "displayHelperZ", icon: "displayHelperZ", label: t("gui.displayHelperZ", "Show Z helper"), onClick: () => viewer.toggleClippingPlaneHelper("z") }, { key: "visible", icon: "visible", label: t("gui.visible", "Visible"), onClick: () => viewer.toggleClippingPlaneVisible() }, ]; viewer.clippingPlaneSubmenuButtons = {}; submenuItems.forEach((item) => { const subButton = document.createElement("button"); subButton.type = "button"; subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button"; subButton.dataset.tool = item.key; subButton.innerHTML = ` `; subButton.setAttribute("title", item.label); subButton.setAttribute("aria-label", item.label); viewer.bindEventListener(subButton, "click", (event) => { event.stopPropagation(); item.onClick(); }); submenu.appendChild(subButton); viewer.clippingPlaneSubmenuButtons[item.key] = subButton; }); button.appendChild(submenu); } else if (tool.key === "annotate") { button.classList.add("has-submenu"); const submenu = document.createElement("div"); submenu.className = "viewer-editor-tool_submenu"; const submenuItems = [ { key: "annotateAdd", icon: "annotateAdd", label: t("gui.annotateAdd", "Add Annotation"), onClick: () => viewer.openAnnotationDialogWithAutoPicking() }, { key: "annotateImport", icon: "annotateImport", label: t("gui.annotateImport", "Import Annotations"), onClick: () => viewer.triggerAnnotationsXmlImport() }, { key: "annotateExport", icon: "annotateExport", label: t("gui.annotateExport", "Export Annotations"), onClick: () => viewer.downloadAnnotationsXmlFile() }, { key: "IIIFexport", icon: "IIIFexport", label: t("gui.IIIFexport", "Export to IIIF"), onClick: () => viewer.exportIIIFManifest() }, ]; viewer.annotateSubmenuButtons = {}; submenuItems.forEach((item) => { const subButton = document.createElement("button"); subButton.type = "button"; subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button"; subButton.dataset.tool = item.key; subButton.innerHTML = ` `; subButton.setAttribute("title", item.label); subButton.setAttribute("aria-label", item.label); viewer.bindEventListener(subButton, "click", (event) => { event.stopPropagation(); item.onClick(); }); submenu.appendChild(subButton); viewer.annotateSubmenuButtons[item.key] = subButton; }); button.appendChild(submenu); } else if (tool.key === "materials") { button.classList.add("has-submenu"); const submenu = document.createElement("div"); submenu.className = "viewer-editor-tool_submenu viewer-editor-tool_submenu-materials"; viewer.materialsSubmenu = submenu; viewer.refreshMaterialsToolbarMenu(); button.appendChild(submenu); } else if (tool.key === "hierarchy") { button.classList.add("has-submenu"); const submenu = document.createElement("div"); submenu.className = "viewer-editor-tool_submenu viewer-editor-hierarchy-submenu"; viewer.hierarchySubmenu = submenu; const hierarchyList = document.createElement("div"); hierarchyList.className = "viewer-editor-hierarchy-submenu-list"; viewer.hierarchySubmenuList = hierarchyList; const clearButton = document.createElement("button"); clearButton.type = "button"; clearButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button viewer-editor-hierarchy-clear"; viewer.bindEventListener(clearButton, "click", (event) => { event.stopPropagation(); viewer.clearHierarchySelection(); }); viewer.hierarchyClearButton = clearButton; viewer.hierarchySubmenuButtons = {}; submenu.appendChild(hierarchyList); submenu.appendChild(clearButton); button.appendChild(submenu); } else if (tool.key === "save") { button.classList.add("has-submenu"); const submenu = document.createElement("div"); submenu.className = "viewer-editor-tool_submenu viewer-editor-save-submenu"; viewer.bindEventListener(submenu, "click", (event) => { event.stopPropagation(); }); const submenuItems = [ { key: "Position", label: t("gui.position", "Position") }, { key: "Rotation", label: t("gui.rotation", "Rotation") }, { key: "Scale", label: t("gui.scale", "Scale") }, { key: "Camera", label: t("gui.camera", "Camera") }, { key: "DirectionalLight", label: t("gui.directionalLight", "Directional Light") }, { key: "AmbientLight", label: t("gui.ambientLight", "Ambient Light") }, { key: "CameraLight", label: t("gui.cameraLight", "Camera Light") }, { key: "BackgroundColor", label: t("gui.backgroundColor", "Background Color") }, ]; viewer.saveSubmenuCheckboxes = {}; submenuItems.forEach((item) => { const row = document.createElement("label"); row.className = "viewer-editor-save-option"; row.setAttribute("title", item.label); row.setAttribute("aria-label", item.label); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = Boolean(viewer.saveProperties[item.key]); checkbox.dataset.property = item.key; viewer.bindEventListener(checkbox, "click", (event) => { event.stopPropagation(); }); viewer.bindEventListener(checkbox, "change", (event) => { event.stopPropagation(); viewer.saveProperties[item.key] = event.target.checked; }); const text = document.createElement("span"); text.className = "viewer-editor-save-option_label"; text.textContent = item.label; row.appendChild(checkbox); row.appendChild(text); submenu.appendChild(row); viewer.saveSubmenuCheckboxes[item.key] = { row, checkbox, text }; }); const actions = document.createElement("div"); actions.className = "viewer-editor-save-actions"; const saveButton = document.createElement("button"); saveButton.type = "button"; saveButton.className = "viewer-editor-save-apply"; saveButton.textContent = t("gui.saveSettings", "Save settings"); viewer.bindEventListener(saveButton, "click", (event) => { event.stopPropagation(); viewer.saveEditorMetadata(); }); viewer.saveSubmenuActionButton = saveButton; actions.appendChild(saveButton); submenu.appendChild(actions); button.appendChild(submenu); } else if (tool.key === "statistics") { button.classList.add("has-submenu"); const submenu = document.createElement("div"); submenu.className = "viewer-editor-tool_submenu"; viewer.statisticsSubmenuButtons = {}; const appendStatisticsSubmenuItems = (items, container) => { items.forEach((item) => { const subButton = document.createElement("button"); subButton.type = "button"; subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button"; subButton.dataset.tool = item.key; subButton.setAttribute("title", item.label); subButton.setAttribute("aria-label", item.label); subButton.setAttribute("aria-pressed", item.pressed); const iconSpan = document.createElement("span"); iconSpan.className = "viewer-editor-tool_icon"; iconSpan.setAttribute("aria-hidden", "true"); iconSpan.innerHTML = getEditorToolbarIcon(item.icon); subButton.appendChild(iconSpan); const srSpan = document.createElement("span"); srSpan.className = "viewer-editor-tool_sr"; srSpan.textContent = item.label; subButton.appendChild(srSpan); if (item.onClick) { viewer.bindEventListener(subButton, "click", (event) => { event.stopPropagation(); item.onClick(); }); } if (item.children) { subButton.classList.add("has-submenu"); const nested = document.createElement("div"); nested.className = "viewer-editor-tool_submenu"; appendStatisticsSubmenuItems(item.children, nested); subButton.appendChild(nested); } viewer.statisticsSubmenuButtons[item.key] = subButton; container.appendChild(subButton); }); }; appendStatisticsSubmenuItems([ { key: "toggleStats", icon: "statistics", label: t("gui.statistics", "Statistics"), pressed: false, onClick: () => viewer.toggleStatsVisibility(), }, { key: "performance", icon: "performance", label: t("gui.performance", "Performance"), children: [ { key: "performanceDefault", icon: "statistics", label: t("gui.default", "Default"), onClick: () => viewer.setPerformanceMode("default"), pressed: true, }, { key: "performanceHigh", icon: "performanceHigh", label: t("gui.highPerformance", "High-performance"), onClick: () => viewer.setPerformanceMode("high-performance"), pressed: true, }, { key: "performanceLow", icon: "performanceLow", label: t("gui.lowPower", "Low-power"), onClick: () => viewer.setPerformanceMode("low-power"), pressed: true, }, ], }, ], submenu); button.appendChild(submenu); } else if (tool.key === "lights") { button.classList.add("has-submenu"); const submenu = document.createElement("div"); submenu.className = "viewer-editor-tool_submenu"; viewer.lightsSubmenuButtons = {}; const normalizeColorValue = (value) => { if (typeof value !== "string") return "#ffffff"; if (value.startsWith("0x")) { return `#${value.slice(2).padStart(6, "0")}`; } return value.startsWith("#") ? value : `#${value}`; }; const appendSubmenuItems = (items, container) => { items.forEach((item) => { const subButton = document.createElement("button"); subButton.type = "button"; subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button "; subButton.dataset.tool = item.key; subButton.setAttribute("title", item.label); subButton.setAttribute("aria-label", item.label); const iconSpan = document.createElement("span"); iconSpan.className = "viewer-editor-tool_icon"; iconSpan.setAttribute("aria-hidden", "true"); iconSpan.innerHTML = item.iconHtml || getEditorToolbarIcon(item.icon); subButton.appendChild(iconSpan); if (item.type === "color") { subButton.classList.add("viewer-editor-tool_submenu-control"); const colorInput = document.createElement("input"); colorInput.type = "color"; colorInput.value = normalizeColorValue(item.value()); colorInput.className = "viewer-editor-tool_submenu-input"; colorInput.addEventListener("click", (event) => event.stopPropagation()); colorInput.addEventListener("input", (event) => { const value = event.target.value; item.onChange(value); colorInput.value = normalizeColorValue(value); }); subButton.appendChild(colorInput); } else if (item.type === "slider") { subButton.classList.add("viewer-editor-tool_submenu-control"); const slider = document.createElement("input"); slider.type = "range"; slider.min = item.min ?? 0; slider.max = item.max ?? 10; slider.step = item.step ?? 0.01; slider.value = String(item.value()); slider.className = "viewer-editor-tool_submenu-input"; slider.addEventListener("click", (event) => event.stopPropagation()); slider.addEventListener("input", (event) => { const value = parseFloat(event.target.value); item.onChange(value); valueLabel.textContent = value.toFixed(2); }); const valueLabel = document.createElement("span"); valueLabel.className = "viewer-editor-tool_submenu-value"; valueLabel.textContent = Number(item.value()).toFixed(2); valueLabel.setAttribute("aria-hidden", "true"); subButton.appendChild(slider); subButton.appendChild(valueLabel); } else if (item.type === "toggle") { subButton.classList.add("viewer-editor-tool_submenu-control", "viewer-editor-tool_submenu-toggle"); subButton.setAttribute("type", "button"); const toggleState = document.createElement("span"); toggleState.className = "viewer-editor-tool_submenu-toggle-state"; const setToggleState = () => { const enabled = Boolean(item.value()); toggleState.textContent = enabled ? t("gui.on", "ON") : t("gui.off", "OFF"); subButton.setAttribute("aria-pressed", enabled ? "true" : "false"); subButton.classList.toggle("is-active", enabled); }; setToggleState(); viewer.bindEventListener(subButton, "click", async (event) => { event.stopPropagation(); const nextValue = !Boolean(item.value()); if (item.onChange) { await item.onChange(nextValue); } setToggleState(); }); subButton.appendChild(toggleState); } else if (item.onClick) { viewer.bindEventListener(subButton, "click", (event) => { event.stopPropagation(); item.onClick(); }); } if (item.children) { subButton.classList.add("has-submenu"); const nested = document.createElement("div"); nested.className = "viewer-editor-tool_submenu"; appendSubmenuItems(item.children, nested); subButton.appendChild(nested); } if ( item.key === "lightTargetTransformMove" || item.key === "lightTargetTransformTarget" || item.key.startsWith("environmentMap") ) { viewer.lightsSubmenuButtons[item.key] = subButton; } container.appendChild(subButton); }); }; appendSubmenuItems([ { key: "environmentMap", icon: "environmentMap", label: t("gui.environmentMap", "Environment map"), children: [ { key: "environmentMapToggle", icon: "environmentMap", label: t("gui.environmentMapToggle", "Environment map"), type: "toggle", value: () => (core.scene?.environmentIntensity ?? 0) > 0, onChange: async (value) => { if (!core.scene) return; core.scene.traverse((child) => { const materials = child?.material ? Array.isArray(child.material) ? child.material : [child.material] : []; materials.forEach((material) => { if (material?.isMeshStandardMaterial || material?.isMeshPhysicalMaterial) { material.needsUpdate = true; } }); }); viewer.updateEditorToolbarState(); }, }, { key: "environmentMapIntensity", icon: "intensity", label: t("gui.intensity", "Intensity"), type: "slider", min: 0, max: 1, step: 0.01, value: () => core.environmentMapIntensity ?? 0.5, onChange: (value) => { if (!core.scene) return; core.scene.environmentIntensity = value; core.scene.traverse((child) => { const materials = child?.material ? Array.isArray(child.material) ? child.material : [child.material] : []; materials.forEach((material) => { if (material?.isMeshStandardMaterial || material?.isMeshPhysicalMaterial) { material.needsUpdate = true; } }); }); }, }, { key: "environmentMapStyleNeutral", iconHtml: "🌥", label: t("gui.environmentMapNeutral", "Neutral"), onClick: async () => { await viewer.setEnvironmentMapPreset("neutral"); viewer.updateLightsSubmenuState(); }, }, { key: "environmentMapStyleSunny", iconHtml: "☀️", label: t("gui.environmentMapSunny", "Sunny"), onClick: async () => { await viewer.setEnvironmentMapPreset("sunny"); viewer.updateLightsSubmenuState(); }, }, { key: "environmentMapStyleStudio", iconHtml: "📸", label: t("gui.environmentMapStudio", "Studio"), onClick: async () => { await viewer.setEnvironmentMapPreset("studio"); viewer.updateLightsSubmenuState(); }, }, { key: "environmentMapStyleGoldenHour", iconHtml: "🌅", label: t("gui.environmentMapGoldenHour", "Golden Hour"), onClick: async () => { await viewer.setEnvironmentMapPreset("goldenHour"); viewer.updateLightsSubmenuState(); }, }, ], }, { key: "lightTarget", icon: "lightTarget", label: t("gui.target", "Target"), children: [ { key: "lightTargetColor", icon: "color", label: t("gui.color", "Color"), type: "color", value: () => viewer.colors.DirectionalLight, onChange: (value) => { viewer.colors.DirectionalLight = value; core.lightObjects[0].color = new THREE.Color(value); }, }, { key: "lightTargetIntensity", icon: "intensity", label: t("gui.intensity", "Intensity"), type: "slider", min: 0, max: 10, step: 0.01, value: () => viewer.intensity.startIntensityDir, onChange: (value) => { viewer.intensity.startIntensityDir = value; core.lightObjects[0].intensity = value; }, }, { key: "lightTargetTransform", icon: "move", label: t("gui.transform", "Transform"), children: [ { key: "lightTargetTransformMove", icon: "move", label: t("gui.move", "Move"), onClick: () => viewer.toggleLightTransformMode("translate") }, { key: "lightTargetTransformTarget", icon: "lightTarget", label: t("gui.target", "Target"), onClick: () => viewer.toggleLightTransformMode("rotate") }, ], }, ], }, { key: "lightAmbient", icon: "ambientLight", label: t("gui.ambient", "Ambient"), children: [ { key: "lightAmbientColor", icon: "color", label: t("gui.color", "Color"), type: "color", value: () => viewer.colors.AmbientLight, onChange: (value) => { viewer.colors.AmbientLight = value; viewer.ambientLight.color = new THREE.Color(value); }, }, { key: "lightAmbientIntensity", icon: "intensity", label: t("gui.intensity", "Intensity"), type: "slider", min: 0, max: 10, step: 0.01, value: () => viewer.intensity.startIntensityAmbient, onChange: (value) => { viewer.intensity.startIntensityAmbient = value; viewer.ambientLight.intensity = value; }, }, ], }, { key: "lightCamera", icon: "cameraLight", label: t("gui.camera", "Camera"), children: [ { key: "lightCameraColor", icon: "color", label: t("gui.color", "Color"), type: "color", value: () => viewer.colors.CameraLight, onChange: (value) => { viewer.colors.CameraLight = value; viewer.cameraLight.color = new THREE.Color(value); }, }, { key: "lightCameraIntensity", icon: "intensity", label: t("gui.intensity", "Intensity"), type: "slider", min: 0, max: 10, step: 0.01, value: () => viewer.intensity.startIntensityCamera, onChange: (value) => { viewer.intensity.startIntensityCamera = value; viewer.cameraLight.intensity = value; }, }, ], }, ], submenu); button.appendChild(submenu); } else if (tool.key === "materials") { button.classList.add("has-submenu"); const submenu = document.createElement("div"); submenu.className = "viewer-editor-tool_submenu viewer-editor-tool_submenu-materials"; const submenuItems = [ { key: "materialColor", icon: "color", label: t("gui.color", "Color"), onClick: () => viewer.openMaterialsFolder() }, { key: "materialIntensity", icon: "intensity", label: t("gui.intensity", "Intensity"), onClick: () => viewer.openMaterialsFolder() }, ]; submenuItems.forEach((item) => { const subButton = document.createElement("button"); subButton.type = "button"; subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button"; subButton.dataset.tool = item.key; subButton.innerHTML = ` `; subButton.setAttribute("title", item.label); subButton.setAttribute("aria-label", item.label); viewer.bindEventListener(subButton, "click", (event) => { event.stopPropagation(); item.onClick(); }); submenu.appendChild(subButton); }); button.appendChild(submenu); } else if (tool.key === "download") { if (!core.isLightweight || core.isLocalPreview) { button.href = core.downloadModel; button.target = "_blank"; button.rel = "noopener noreferrer"; button.download = core.fileObject.filename; } } viewer.bindEventListener(button, "click", () => { viewer.stopHandMode(); if (tool.onClick) { tool.onClick(); } }); if (tool.primary) toolbar.appendChild(button); else secondaryTray.appendChild(button); viewer.editorToolbarButtons[tool.key] = button; if (!tool.primary) viewer.editorSecondaryKeys.push(button); }); toolbar.appendChild(secondaryTray); const expandButton = document.createElement("button"); expandButton.type = "button"; expandButton.className = "viewer-editor-tool viewer-editor-expand"; expandButton.innerHTML = ``; expandButton.dataset.primary = "true"; expandButton.setAttribute("aria-expanded", "false"); expandButton.setAttribute("title", t("gui.expand", "Expand toolbar")); expandButton.setAttribute("aria-label", t("gui.expand", "Expand toolbar")); viewer.bindEventListener(expandButton, "click", () => toggleToolbarExpanded(viewer)); toolbar.appendChild(expandButton); viewer.editorToolbarButtons.expand = expandButton; if (viewer.actionMenu) { viewer.actionMenu.classList.add("viewer-action-menu_in-toolbar"); toolbar.appendChild(viewer.actionMenu); } getEditorToolbarHost(viewer)?.appendChild(toolbar); core.editorToolbar = toolbar; core.editorToolbar.classList.add("editorToolbar-hidden"); core.editorToolbar.classList.add("collapsed"); viewer.updateFullscreenButtonIcon(); viewer.updateEditorToolbarLabels(); viewer.updateEditorToolbarState(); syncEditorToolbarSecondaryTrayWidth(viewer); viewer.bindEventListener(window, "resize", () => syncEditorToolbarSecondaryTrayWidth(viewer)); } export function updateHierarchySubmenuState(viewer) { if (!viewer.hierarchySubmenuButtons) return; const selectedIds = new Set( (core.selectedObjects || []) .filter((item) => item?.selected === true) .map((item) => String(item.id)) ); Object.entries(viewer.hierarchySubmenuButtons).forEach(([key, button]) => { const isActive = selectedIds.has(String(key)); button.classList.toggle("is-active", isActive); button.setAttribute("aria-pressed", isActive ? "true" : "false"); }); viewer.hierarchyClearButton?.toggleAttribute("disabled", selectedIds.size === 0); } export function updateStatisticsSubmenuState(viewer) { if (!viewer.statisticsSubmenuButtons) return; const isVisible = typeof core.stats !== "undefined" && core.stats?.dom?.style?.visibility !== "hidden"; viewer.statisticsSubmenuButtons.toggleStats?.classList.toggle("is-active", isVisible); viewer.statisticsSubmenuButtons.toggleStats?.setAttribute("aria-pressed", isVisible ? "true" : "false"); const currentMode = core.renderer?.powerPreference || core.CONFIG.viewer?.performanceMode || "default"; const performanceMap = { performanceHigh: "high-performance", performanceLow: "low-power", performanceDefault: "default", }; Object.entries(performanceMap).forEach(([key, value]) => { const isActive = currentMode === value; viewer.statisticsSubmenuButtons[key]?.classList.toggle("is-active", isActive); viewer.statisticsSubmenuButtons[key]?.setAttribute("aria-pressed", isActive ? "true" : "false"); }); } export function updateClippingPlanesSubmenuState(viewer) { if (!viewer.clippingPlaneSubmenuButtons) return; const clippingMode = core.planeParams?.clippingMode || {}; viewer.clippingPlaneSubmenuButtons.displayHelperX?.classList.toggle( "is-active", Boolean(clippingMode.x) ); viewer.clippingPlaneSubmenuButtons.displayHelperY?.classList.toggle( "is-active", Boolean(clippingMode.y) ); viewer.clippingPlaneSubmenuButtons.displayHelperZ?.classList.toggle( "is-active", Boolean(clippingMode.z) ); viewer.clippingPlaneSubmenuButtons.visible?.classList.toggle( "is-active", Boolean(core.planeParams?.outline?.visible) ); } export function updateLightsSubmenuState(viewer) { if (!viewer.lightsSubmenuButtons) return; const activeMode = viewer.transformText["Transform Light"]; viewer.lightsSubmenuButtons.environmentMap?.classList.toggle( "is-active", viewer.environmentMapEnabled !== false ); viewer.lightsSubmenuButtons.environmentMap?.setAttribute( "aria-pressed", viewer.environmentMapEnabled !== false ? "true" : "false" ); const environmentMapToggle = viewer.lightsSubmenuButtons.environmentMapToggle; if (environmentMapToggle) { const toggleLabel = environmentMapToggle.querySelector('.viewer-editor-tool_submenu-toggle-state'); const isEnabled = (core.scene?.environmentIntensity ?? 0) > 0; if (toggleLabel) toggleLabel.textContent = isEnabled ? t("gui.on", "ON") : t("gui.off", "OFF"); environmentMapToggle.setAttribute("aria-pressed", isEnabled ? "true" : "false"); environmentMapToggle.classList.toggle("is-active", isEnabled); } viewer.lightsSubmenuButtons.lightTargetTransformMove?.classList.toggle( "is-active", activeMode === "translate" ); viewer.lightsSubmenuButtons.lightTargetTransformTarget?.classList.toggle( "is-active", activeMode === "rotate" ); const environmentMapPreset = viewer.environmentMapPreset || "neutral"; const environmentMapPresetStates = { environmentMapStyleNeutral: "neutral", environmentMapStyleSunny: "sunny", environmentMapStyleStudio: "studio", environmentMapStyleGoldenHour: "goldenHour", }; Object.entries(environmentMapPresetStates).forEach(([key, value]) => { const isActive = environmentMapPreset === value; viewer.lightsSubmenuButtons[key]?.classList.toggle("is-active", isActive); viewer.lightsSubmenuButtons[key]?.setAttribute("aria-pressed", isActive ? "true" : "false"); }); } export function updateEditorToolbarLabels(viewer) { if (!viewer.editorToolbarButtons) return; const labels = { moveToolbar: t("gui.moveToolbar", "Move toolbar"), orbit: t("gui.orbit", "Navigation mode"), move: t("gui.move", "Move"), rotate: t("gui.rotate", "Rotate"), scale: t("gui.scale", "Scale"), lights: t("gui.lights", "Lights"), picking: viewer.pickingMode ? t("controls.disablePickingMode", "Disable picking mode") : t("controls.enablePickingMode", "Enable picking mode"), annotate: t("gui.addAnnotations", "Add annotations"), ruler: viewer.RULER_MODE ? t("controls.disableDistanceMeasurement", "Disable distance measurement") : t("controls.enableDistanceMeasurement", "Enable distance measurement"), resetCamera: t("gui.resetCameraPosition", "Reset camera position"), preview: t("gui.renderPreview", "Render preview"), save: t("gui.saveSettings", "Save settings"), advancedEditor: viewer.isEditorAdvancedPanelVisible() ? t("gui.hideAdvancedEditor", "Hide advanced editor") : t("gui.showAdvancedEditor", "Show advanced editor"), fullScreen: viewer.FULLSCREEN ? t("fullscreen.exit", "Exit fullscreen") : t("fullscreen.enter", "Enter fullscreen"), clippingPlanes: viewer.clippingMode ? t("gui.disableClippingPlanesMode", "Disable clipping planes mode") : t("gui.enableClippingPlanesMode", "Enable clipping planes mode"), projection: core.camera && core.camera.isPerspectiveCamera ? t("gui.orthographicProjection", "Switch to orthographic projection") : t("gui.perspectiveProjection", "Switch to perspective projection"), wireframe: viewer.wireframeMode ? t("gui.disableWireframeMode", "Disable wireframe mode") : t("gui.enableWireframeMode", "Enable wireframe mode"), loadingLogs: viewer.showLoadingLogs ? t("gui.hideLoadingLogs", "Hide loading logs") : t("gui.showLoadingLogs", "Show loading logs"), hierarchy: t("gui.hierarchy", "Hierarchy"), materials: t("gui.materials", "Materials"), statistics: t("gui.statistics", "Statistics"), expand: viewer.isToolbarExpanded ? t("gui.collapse", "Collapse toolbar") : t("gui.expand", "Expand toolbar"), download: t("gui.download", "Download model"), }; Object.entries(viewer.editorToolbarButtons).forEach(([key, button]) => { const label = labels[key] || key; button.setAttribute("title", label); button.setAttribute("aria-label", label); const sr = button.querySelector(".viewer-editor-tool_sr"); if (sr) sr.textContent = label; }); if (viewer.clippingPlaneSubmenuButtons) { const clippingPlaneSubmenuLabels = { displayHelperX: t("gui.displayHelperX", "Show X helper"), displayHelperY: t("gui.displayHelperY", "Show Y helper"), displayHelperZ: t("gui.displayHelperZ", "Show Z helper"), visible: t("gui.visible", "Visible"), }; Object.entries(viewer.clippingPlaneSubmenuButtons).forEach(([key, button]) => { const label = clippingPlaneSubmenuLabels[key] || key; button.setAttribute("title", label); button.setAttribute("aria-label", label); }); } if (viewer.annotateSubmenuButtons) { const annotateSubmenuLabels = { annotateAdd: t("gui.addAnnotations", "Add Annotation"), annotateImport: t("gui.importAnnotationsXml", "Import Annotations"), annotateExport: t("gui.exportAnnotationsXml", "Export Annotations"), IIIFexport: t("gui.IIIFexport", "Export to IIIF"), }; Object.entries(viewer.annotateSubmenuButtons).forEach(([key, button]) => { const label = annotateSubmenuLabels[key] || key; button.setAttribute("title", label); button.setAttribute("aria-label", label); }); } if (viewer.statisticsSubmenuButtons) { const statisticsSubmenuLabels = { toggleStats: t("gui.statistics", "Statistics"), performance: t("gui.performance", "Performance"), performanceDefault: t("gui.default", "Default"), performanceHigh: t("gui.highPerformance", "High-performance"), performanceLow: t("gui.lowPower", "Low-power"), }; Object.entries(viewer.statisticsSubmenuButtons).forEach(([key, button]) => { const label = statisticsSubmenuLabels[key] || key; button.setAttribute("title", label); button.setAttribute("aria-label", label); }); } if (viewer.lightsSubmenuButtons) { const lightsSubmenuLabels = { environmentMap: t("gui.environmentMap", "Environment map"), lightTargetTransformMove: t("gui.move", "Move"), lightTargetTransformTarget: t("gui.target", "Target"), environmentMapToggle: t("gui.environmentMapToggle", "Environment map"), environmentMapIntensity: t("gui.intensity", "Intensity"), environmentMapStyleNeutral: t("gui.environmentMapNeutral", "Neutral"), environmentMapStyleSunny: t("gui.environmentMapSunny", "Sunny"), environmentMapStyleStudio: t("gui.environmentMapStudio", "Studio"), environmentMapStyleGoldenHour: t("gui.environmentMapGoldenHour", "Golden Hour"), }; Object.entries(viewer.lightsSubmenuButtons).forEach(([key, button]) => { const label = lightsSubmenuLabels[key] || key; button.setAttribute("title", label); button.setAttribute("aria-label", label); }); } if (viewer.hierarchyClearButton) { const label = t("gui.clearSelectedHierarchy", "Clear selected objects"); viewer.hierarchyClearButton.setAttribute("title", label); viewer.hierarchyClearButton.setAttribute("aria-label", label); viewer.hierarchyClearButton.textContent = label; } if (viewer.saveSubmenuCheckboxes) { const saveSubmenuLabels = { Position: t("gui.position", "Position"), Rotation: t("gui.rotation", "Rotation"), Scale: t("gui.scale", "Scale"), Camera: t("gui.camera", "Camera"), DirectionalLight: t("gui.directionalLight", "Directional Light"), AmbientLight: t("gui.ambientLight", "Ambient Light"), CameraLight: t("gui.cameraLight", "Camera Light"), BackgroundColor: t("gui.backgroundColor", "Background Color"), }; Object.entries(viewer.saveSubmenuCheckboxes).forEach(([key, elements]) => { const label = saveSubmenuLabels[key] || key; elements.row.setAttribute("title", label); elements.row.setAttribute("aria-label", label); elements.text.textContent = label; elements.checkbox.checked = Boolean(viewer.saveProperties[key]); }); } if (viewer.saveSubmenuActionButton) { viewer.saveSubmenuActionButton.textContent = t("gui.saveSettings", "Save settings"); } core.editorToolbar?.setAttribute("aria-label", t("toolbar.editor", "Editor tools")); viewer.editorToolbarButtons.expand?.setAttribute("aria-expanded", viewer.isToolbarExpanded ? "true" : "false"); } export function updateEditorToolbarState(viewer) { if (!viewer.editorToolbarButtons) return; const activeMap = { moveToolbar: viewer.transformText["Transform 3D Object"] === "translate" || viewer.transformText["Transform 3D Object"] === "rotate" || viewer.transformText["Transform 3D Object"] === "scale", orbit: viewer.transformText["Transform 3D Object"] === "", move: viewer.transformText["Transform 3D Object"] === "translate", rotate: viewer.transformText["Transform 3D Object"] === "rotate", scale: viewer.transformText["Transform 3D Object"] === "scale", picking: viewer.pickingMode === true, ruler: viewer.RULER_MODE === true, clippingPlanes: viewer.clippingMode === true, advancedEditor: viewer.isEditorAdvancedPanelVisible(), fullScreen: viewer.FULLSCREEN === true, loadingLogs: viewer.showLoadingLogs === true, wireframe: viewer.wireframeMode === true, download: false, }; Object.entries(viewer.editorToolbarButtons).forEach(([key, button]) => { const isActive = activeMap[key] === true; button.classList.toggle("is-active", isActive); if (button.dataset.pressed === "true") { button.setAttribute("aria-pressed", isActive ? "true" : "false"); } else { button.removeAttribute("aria-pressed"); } }); updateHierarchySubmenuState(viewer); updateClippingPlanesSubmenuState(viewer); updateLightsSubmenuState(viewer); updateStatisticsSubmenuState(viewer); }