From d8402c47b5fea855eaa178765b1498094d66d44a Mon Sep 17 00:00:00 2001 From: erseco Date: Fri, 10 Jul 2026 14:26:06 +0100 Subject: [PATCH 01/18] docs(three-d-viewer): add SDD-0001 and ADR-0001 for interaction layer Specify hotspots, guided navigation and single-choice questions for the 3D Viewer iDevice (issue #2153): versioned state, renderer-adapter architecture over the shared runtime, migration, accessibility, export and test strategy. No code changes yet. --- ...R-0001-three-d-viewer-interaction-layer.md | 193 ++++++++ doc/architecture/adr/records.md | 3 +- .../SDD-0001-three-d-viewer-interactions.md | 433 ++++++++++++++++++ doc/architecture/sdd/records.md | 3 +- 4 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 doc/architecture/adr/ADR-0001-three-d-viewer-interaction-layer.md create mode 100644 doc/architecture/sdd/SDD-0001-three-d-viewer-interactions.md diff --git a/doc/architecture/adr/ADR-0001-three-d-viewer-interaction-layer.md b/doc/architecture/adr/ADR-0001-three-d-viewer-interaction-layer.md new file mode 100644 index 000000000..28f20c105 --- /dev/null +++ b/doc/architecture/adr/ADR-0001-three-d-viewer-interaction-layer.md @@ -0,0 +1,193 @@ +--- +id: ADR-0001 +title: "3D Viewer interaction layer: renderer adapters over a shared runtime controller" +status: Proposed +date: 2026-07-10 +deciders: + - "@erseco" +reviewers: + - "@erseco" +related: + issues: [2153] + prs: [] + sdds: [1] + adrs: [] +supersedes: [] +superseded_by: [] +ai_assistance: + tool: "Claude Code" + model: "claude-opus-4-8" +--- + +# ADR-0001: 3D Viewer interaction layer: renderer adapters over a shared runtime controller + +## Status + +Proposed + +## Context + +The `three-d-viewer` iDevice supports two independent render paths: GLB/GLTF via the +`` web component and STL via a bespoke Three.js scene in the shared +`window.eXe3DViewer` runtime (`public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer-runtime.js` +@ f3a32e774). SDD-0001 adds hotspots, guided navigation and single-choice questions to this +iDevice (issue #2153). The two render paths expose completely different placement, projection and +occlusion mechanics: `` offers a declarative hotspot API with native projection and +occlusion (`slot="hotspot-*"`, `positionAndNormalFromPoint()`), while the STL path exposes raw +Three.js objects (`scene/camera/renderer/canvas/mesh`) and normalizes the mesh (`geometry.center()` ++ `2/maxDim` scale, runtime lines 478-485), so markers only make sense in normalized model space +and must be projected to a DOM overlay by hand each frame. + +The sibling `three-sixty-viewer` already implements a hotspot system, but because it has **no shared +runtime**, it duplicates its entire state + projection + dialog stack across `edition/` and `export/`, +and all its coordinate math is panorama-specific (yaw/pitch on an inverted sphere) — reusable in +shape, not in substance. + +## Problem + +How should marker placement, projection, dialogs, questions and guided navigation be structured so +that (a) GLB/GLTF and STL do not each grow their own copy of the marker/dialog/question logic, +(b) the editor preview and the exported learner page behave identically, (c) no new export library +or registration is introduced, and (d) the abstraction stays minimal rather than importing a +framework or the 3Dmol game engine? + +## Decision drivers + +- **No duplicated marker logic** across the two render paths (AGENTS.md "single source of truth"). +- **Editor/export parity** — one behavioural implementation, not two. +- **Minimal surface** — no framework, no new dependency, no new registered file (avoid the ~6-site + export-registration burden documented in repo memory `Export lib registration sites`). +- **Preserve existing runtime semantics** — `window.eXe3DViewer` lifecycle, WebGL disposal, + AssetManager `asset://` handling, no persisted `blob:`. +- **Accessibility + testability** — coordinate math extractable as pure functions; DOM behaviour + reusable from the 360 dialog pattern. +- **Back-compat** — migrate interaction-less state transparently. + +## Options considered + +### Option 1: Two independent implementations (one per render path) + +Add marker/dialog/question code separately to the model-viewer path and the STL path. + +- Pros: each path optimally native; no adapter indirection. +- Cons: marker state, dialogs, questions, guided nav and ARIA duplicated twice **and** across + edition/export (4 copies); guaranteed drift; violates single-source-of-truth; largest surface. + +### Option 2: One renderer abstraction that hides model-viewer and Three.js behind a common scene API + +Build a generic 3D scene facade so a single code path drives both. + +- Pros: one code path. +- Cons: `` deliberately hides its internals; forcing a common low-level scene API means + re-implementing model-viewer's projection/occlusion/animation — large, fragile, over-engineered; + fights the web component instead of using its native hotspots. + +### Option 3 (chosen): Shared renderer-agnostic controller + two thin renderer adapters + +A single `InteractionController` in the shared runtime owns marker state, active-marker tracking, +guided navigation, the accessible dialog, the single-choice question renderer, ARIA announcements and +the fallback list. It talks to the model only through a small adapter contract. Two adapters +implement it: `ModelViewerMarkerAdapter` (native declarative hotspots) and `StlMarkerAdapter` +(raycast + per-frame DOM-overlay reprojection). Both the editor preview and the export runtime +construct the controller via one factory, so behaviour is single-copy and identical. + +Adapter contract: + +```js +{ + enterPlacementMode(onPlaced), // onPlaced({ position, normal, surface, camera }) + exitPlacementMode(), + renderMarkers(markers, { showLabels, activeId }), + focusMarker(marker), // apply marker.camera if present + captureCamera(), // -> opaque { orbit, target, fieldOfView } + updateOverlay(), // per-frame reprojection (STL); no-op for model-viewer + destroy(), +} +``` + +Two supporting decisions ride with this ADR: + +- **Schema `normalize*`/migration are mirrored (byte-identical) in `edition/` and `export/`**, marked + `// mirror edition`, exactly as `three-sixty-viewer` does — rather than adding a new shared + classic-script file (which would need the ~6-site registration + bundle regen and introduce a + runtime load-order dependency). Only the *pure, small* schema layer is duplicated; the large + behavioural layer lives single-copy in the runtime. +- **Interaction state is serialized as a JSON `` breakout. + +## Evidence + +- Render-path divergence and mesh normalization: `three-d-viewer/export/three-d-viewer-runtime.js:246,384,409,478-485,523,289` @ f3a32e774. +- Model-viewer emitted without `src`; flat `data-*`; external asset rewrite: `export/three-d-viewer.js:600,635,1195,1304`; `src/shared/export/exporters/BaseExporter.ts:756`. +- 360 duplication + JSON data script + accessible dialog: `three-sixty-viewer/export/three-sixty-viewer.js:78,348,946-1128`. +- No-registration packaging by directory recursion: `src/shared/export/providers/FileSystemResourceProvider.ts:93`; `Html5Exporter.ts:327`. Registration burden if a new file were added: `src/shared/export/browser/idevice-config-browser.ts:137,179` + bundle regen (repo memory `Export lib registration sites`). +- 3Dmol question concept (reuse) vs game engine (avoid): `3dmol/export/3dmol.js:333,2010-2054` and the timers/lives/scoring/SCORM stack. +- `` hotspot + `positionAndNormalFromPoint` API: model-viewer documentation (`modelviewer.dev`, "Annotations"). + +## Decision + +We will implement **Option 3**: a single renderer-agnostic `InteractionController` in the shared +`three-d-viewer-runtime.js`, with two thin renderer adapters (`ModelViewerMarkerAdapter`, +`StlMarkerAdapter`) implementing a small common contract, constructed by one factory shared between +the editor preview and the export runtime. Schema `normalize*`/migration are mirrored byte-identical +in `edition/` and `export/`; interaction state is serialized as an escaped JSON `` + breakout; it is parsed with `JSON.parse` inside `try/catch`. +- Links: validate URL scheme; `target="_blank"` always paired with `rel="noopener noreferrer"`. +- No `eval`, no dynamic code execution, no inline event handlers, no scripts from marker content. +- `blob:`/`data:` URLs are stripped before persistence, matching `get3DViewerJSON`'s existing guard. + +## Accessibility + +- Viewer region has an accessible name (existing `aria-label` from `alt`). +- Each marker is a real `' + + '' + + ''; + wrapper.appendChild(guidedNav); + } + if (!guidedNav) return; + if (!state.guidedMode) { guidedNav.hidden = true; return; } + guidedNav.hidden = false; + const prevBtn = guidedNav.querySelector('.tdv-nav-prev'); + const nextBtn = guidedNav.querySelector('.tdv-nav-next'); + if (prevBtn && !prevBtn.textContent) prevBtn.textContent = t('Previous'); + if (nextBtn && !nextBtn.textContent) nextBtn.textContent = t('Next'); + guidedStatus = guidedNav.querySelector('.tdv-guided-status'); + if (prevBtn) on(prevBtn, 'click', () => go(-1)); + if (nextBtn) on(nextBtn, 'click', () => go(1)); + updateGuided(); + } + + function revealFallback(show) { + if (!wrapper) return; + const fb = wrapper.querySelector('.tdv-fallback'); + if (fb) fb.hidden = !show; + } + + // ── Public controller surface ────────────────────────────────── + function render() { + if (destroyed) return; + markers = Array.isArray(state.markers) ? state.markers : []; + if (adapter && adapter.renderMarkers) { + adapter.renderMarkers(markers, { showLabels: state.showMarkerLabels !== false, activeId }); + revealFallback(false); + } else { + revealFallback(true); + } + setupGuided(); + } + + function setState(next) { + state = next || { enabled: false, markers: [] }; + if (activeId && !(state.markers || []).some((m) => m.id === activeId)) activeId = ''; + render(); + } + + function enterPlacementMode() { + if (!adapter || !adapter.enterPlacementMode) return; + placing = true; + if (wrapper) wrapper.classList.add('tdv-placing'); + adapter.enterPlacementMode((anchor) => { + exitPlacementMode(); + if (typeof hooks.onPlaced === 'function') hooks.onPlaced(anchor); + }); + } + function exitPlacementMode() { + placing = false; + if (wrapper) wrapper.classList.remove('tdv-placing'); + if (adapter && adapter.exitPlacementMode) adapter.exitPlacementMode(); + } + + function captureCamera() { + return (adapter && adapter.captureCamera) ? adapter.captureCamera() : { orbit: '', target: '', fieldOfView: '' }; + } + + function destroy() { + if (destroyed) return; + destroyed = true; + exitPlacementMode(); + closeDialog(); + offAll(); + if (adapter && adapter.destroy) adapter.destroy(); + adapter = null; + } + + const controller = { + setState, + render, + enterPlacementMode, + exitPlacementMode, + focusMarker, + captureCamera, + next: () => go(1), + prev: () => go(-1), + getActiveId: () => activeId, + destroy, + // exposed for adapters / tests + _activate: activateMarker, + _markerLabel: markerLabel, + }; + + // ── Build the adapter ────────────────────────────────────────── + if (isModelViewer && handle.modelViewer) { + adapter = makeModelViewerAdapter(handle.modelViewer, controller, { on, t }); + } else if (type === 'stl') { + const instance = handle.instance || getInstance(wrapper); + if (instance) { + adapter = makeStlAdapter(instance, controller, wrapper, { on, t }); + } + } + + render(); + return controller; + } + + /** + * Adapter for the render path — uses native declarative + * hotspots (slotted elements with data-position / data-normal), so + * projection and occlusion are handled by the component. + */ + function makeModelViewerAdapter(modelViewer, controller, env) { + let placeHandler = null; + function clearMarkers() { + Array.prototype.slice.call(modelViewer.querySelectorAll('.tdv-marker[slot^="hotspot-"]')).forEach((el) => el.remove()); + } + return { + renderMarkers(markers, opts) { + clearMarkers(); + markers.forEach((m, index) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'tdv-marker tdv-marker--mv'; + btn.setAttribute('slot', 'hotspot-' + m.id); + const p = m.anchor.position; + const n = m.anchor.normal; + btn.dataset.position = p.x + ' ' + p.y + ' ' + p.z; + btn.dataset.normal = n.x + ' ' + n.y + ' ' + n.z; + if (m.anchor.surface) btn.dataset.surface = m.anchor.surface; + btn.dataset.markerId = m.id; + btn.dataset.markerOrder = String(index); + btn.setAttribute('aria-label', controller._markerLabel(m, index)); + const icon = document.createElement('span'); + icon.className = 'tdv-marker-icon tdv-icon-' + m.icon; + icon.setAttribute('aria-hidden', 'true'); + btn.appendChild(icon); + if (opts.showLabels && m.label) { + const label = document.createElement('span'); + label.className = 'tdv-marker-label'; + label.textContent = m.label; + btn.appendChild(label); + } + if (opts.activeId === m.id) { btn.classList.add('tdv-marker--active'); btn.setAttribute('aria-current', 'true'); } + env.on(btn, 'click', () => controller.focusMarker(m.id)); + modelViewer.appendChild(btn); + }); + }, + setActive(activeId) { + Array.prototype.slice.call(modelViewer.querySelectorAll('.tdv-marker')).forEach((el) => { + const on = el.dataset.markerId === activeId; + el.classList.toggle('tdv-marker--active', on); + if (on) el.setAttribute('aria-current', 'true'); else el.removeAttribute('aria-current'); + }); + }, + focusMarker(marker) { + const cam = marker.camera || {}; + if (cam.orbit) modelViewer.cameraOrbit = cam.orbit; + if (cam.target) modelViewer.cameraTarget = cam.target; + if (cam.fieldOfView) modelViewer.fieldOfView = cam.fieldOfView; + }, + captureCamera() { + try { + const orbit = typeof modelViewer.getCameraOrbit === 'function' ? modelViewer.getCameraOrbit().toString() : ''; + const target = typeof modelViewer.getCameraTarget === 'function' ? modelViewer.getCameraTarget().toString() : ''; + const fov = typeof modelViewer.getFieldOfView === 'function' ? modelViewer.getFieldOfView() + 'deg' : ''; + return { orbit, target, fieldOfView: fov }; + } catch (_) { return { orbit: '', target: '', fieldOfView: '' }; } + }, + enterPlacementMode(onPlaced) { + placeHandler = (e) => { + if (typeof modelViewer.positionAndNormalFromPoint !== 'function') return; + const hit = modelViewer.positionAndNormalFromPoint(e.clientX, e.clientY); + if (!hit) return; + onPlaced({ + position: parseTriple(hit.position && hit.position.toString()), + normal: parseTriple(hit.normal && hit.normal.toString()), + surface: '', + camera: this.captureCamera(), + }); + }; + env.on(modelViewer, 'click', placeHandler); + }, + exitPlacementMode() { + if (placeHandler) { + try { modelViewer.removeEventListener('click', placeHandler); } catch (_) { /* noop */ } + placeHandler = null; + } + }, + destroy() { this.exitPlacementMode(); clearMarkers(); }, + }; + } + + /** Parse a "x y z" string into a {x,y,z} vector object. */ + function parseTriple(str) { + const parts = String(str || '').trim().split(/\s+/).map(parseFloat); + return { x: parts[0] || 0, y: parts[1] || 0, z: parts[2] || 0 }; + } + + /** + * Adapter for the STL Three.js render path — projects marker anchors to a + * DOM overlay each frame and hides markers that face away from the camera + * or fall outside the viewport. + */ + function makeStlAdapter(instance, controller, wrapper, env) { + const T = globalScope.THREE; + let layer = wrapper ? wrapper.querySelector('.tdv-marker-layer') : null; + if (!layer && wrapper) { + layer = document.createElement('div'); + layer.className = 'tdv-marker-layer'; + wrapper.appendChild(layer); + } + let entries = []; + let placeHandler = null; + + function reproject() { + if (!T || !instance.mesh || !instance.camera || !instance.canvas || !entries.length) return; + instance.mesh.updateMatrixWorld(); + instance.camera.updateMatrixWorld(); + const cw = instance.canvas.clientWidth || instance.canvas.width || 1; + const ch = instance.canvas.clientHeight || instance.canvas.height || 1; + const camPos = instance.camera.position; + entries.forEach((entry) => { + const world = entry.local.clone(); + instance.mesh.localToWorld(world); + const ndc = world.clone().project(instance.camera); + const nWorld = entry.normal.clone().transformDirection(instance.mesh.matrixWorld); + const toCam = new T.Vector3().subVectors(camPos, world).normalize(); + const facing = nWorld.dot(toCam) > -0.15; + const inFront = ndc.z < 1 && ndc.z > -1; + const onScreen = ndc.x >= -1 && ndc.x <= 1 && ndc.y >= -1 && ndc.y <= 1; + const visible = facing && inFront && onScreen; + const x = (ndc.x * 0.5 + 0.5) * cw; + const y = (-ndc.y * 0.5 + 0.5) * ch; + entry.el.style.left = x + 'px'; + entry.el.style.top = y + 'px'; + entry.el.classList.toggle('tdv-marker--hidden', !visible); + if (visible) { entry.el.removeAttribute('tabindex'); entry.el.removeAttribute('aria-hidden'); } + else { entry.el.setAttribute('tabindex', '-1'); entry.el.setAttribute('aria-hidden', 'true'); } + }); + } + + // Drive reprojection off the shared RAF loop (no second loop). + if (instance.onFrame && instance.onFrame.indexOf(reproject) === -1) { + instance.onFrame.push(reproject); + } + + return { + renderMarkers(markers, opts) { + if (!layer) return; + layer.innerHTML = ''; + entries = markers.map((m, index) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'tdv-marker tdv-marker--stl'; + btn.dataset.markerId = m.id; + btn.dataset.markerOrder = String(index); + btn.setAttribute('aria-label', controller._markerLabel(m, index)); + const icon = document.createElement('span'); + icon.className = 'tdv-marker-icon tdv-icon-' + m.icon; + icon.setAttribute('aria-hidden', 'true'); + btn.appendChild(icon); + if (opts.showLabels && m.label) { + const label = document.createElement('span'); + label.className = 'tdv-marker-label'; + label.textContent = m.label; + btn.appendChild(label); + } + if (opts.activeId === m.id) { btn.classList.add('tdv-marker--active'); btn.setAttribute('aria-current', 'true'); } + env.on(btn, 'click', () => controller.focusMarker(m.id)); + layer.appendChild(btn); + return { + el: btn, + local: T ? new T.Vector3(m.anchor.position.x, m.anchor.position.y, m.anchor.position.z) : { x: 0, y: 0, z: 0, clone() { return this; }, project() { return this; } }, + normal: T ? new T.Vector3(m.anchor.normal.x, m.anchor.normal.y, m.anchor.normal.z) : { clone() { return this; } }, + }; + }); + reproject(); + }, + setActive(activeId) { + entries.forEach((entry) => { + const on = entry.el.dataset.markerId === activeId; + entry.el.classList.toggle('tdv-marker--active', on); + if (on) entry.el.setAttribute('aria-current', 'true'); else entry.el.removeAttribute('aria-current'); + }); + }, + focusMarker(marker) { + const cam = marker.camera || {}; + if (!T || !instance.camera) return; + const pos = parseTriple(cam.orbit); + const tgt = parseTriple(cam.target); + if (cam.orbit) instance.camera.position.set(pos.x, pos.y, pos.z); + if (cam.target && instance.controls) { instance.controls.target.set(tgt.x, tgt.y, tgt.z); instance.controls.update(); } + else if (cam.target) instance.camera.lookAt(tgt.x, tgt.y, tgt.z); + }, + captureCamera() { + if (!instance.camera) return { orbit: '', target: '', fieldOfView: '' }; + const p = instance.camera.position; + const target = instance.controls ? instance.controls.target : { x: 0, y: 0, z: 0 }; + return { + orbit: p.x + ' ' + p.y + ' ' + p.z, + target: target.x + ' ' + target.y + ' ' + target.z, + fieldOfView: (instance.camera.fov || 45) + 'deg', + }; + }, + enterPlacementMode(onPlaced) { + if (!instance.canvas) return; + placeHandler = (e) => { + const hit = raycastFromPointer(instance, e.clientX, e.clientY); + if (!hit) return; + onPlaced({ position: hit.position, normal: hit.normal, surface: '', camera: this.captureCamera() }); + }; + env.on(instance.canvas, 'click', placeHandler); + }, + exitPlacementMode() { + if (placeHandler && instance.canvas) { + try { instance.canvas.removeEventListener('click', placeHandler); } catch (_) { /* noop */ } + placeHandler = null; + } + }, + destroy() { + this.exitPlacementMode(); + if (instance.onFrame) { + const idx = instance.onFrame.indexOf(reproject); + if (idx !== -1) instance.onFrame.splice(idx, 1); + } + if (layer) { try { layer.remove(); } catch (_) { /* noop */ } } + entries = []; + }, + }; + } + // ------------------------------------------------------------------ // Public API // ------------------------------------------------------------------ @@ -570,10 +1326,17 @@ configureRendererColorManagement, disposeObject3D, disposeMaterial, + // Interaction layer (markers / guided navigation / questions). + createInteractionLayer, + raycastFromPointer, // Test-only access — do not use in production code. __registry: REGISTRY, __readWrapperConfig: readWrapperConfig, __track: track, __bootSTL: bootSTL, + __escapeHtml: escapeHtml, + __safeUrl: safeUrl, + __sanitizeHtmlDom: sanitizeHtmlDom, + __parseTriple: parseTriple, }; })(); From fa87590f7226175bec800eb5509708a4910f48f9 Mon Sep 17 00:00:00 2001 From: erseco Date: Fri, 10 Jul 2026 14:50:12 +0100 Subject: [PATCH 04/18] feat(three-d-viewer): export interaction markup and runtime boot renderView embeds enabled interaction state as an escaped JSON breakout', () => { + const html = $tdv.renderView(dataWith(enabled({ markers: [ + { id: 'x', label: 'X', icon: 'circle', + anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } }, + action: { type: 'information', payload: { html: '' } } }, + ] })), null, '{content}'); + // The data block must not contain a raw closing from the + // payload — every leading < is escaped to <. + const block = html.slice(html.indexOf('tdv-interaction-data')); + const jsonPart = block.slice(0, block.indexOf('')); + expect(jsonPart).not.toContain(''); + expect(jsonPart).toContain('\\u003c/script>'); + }); + + it('keeps asset:// media references in the JSON block for the export rewriter', () => { + const html = $tdv.renderView(dataWith(enabled({ markers: [ + { id: 'i', label: 'Img', icon: 'circle', + anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } }, + action: { type: 'image', payload: { src: 'asset://pic.png', alt: 'p' } } }, + ] })), null, '{content}'); + expect(html).toContain('asset://pic.png'); + }); + + it('never emits a blob: URL from marker media', () => { + const html = $tdv.renderView(dataWith(enabled({ markers: [ + { id: 'i', label: 'Img', icon: 'circle', + anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } }, + action: { type: 'image', payload: { src: 'blob:http://x/leak' } } }, + ] })), null, '{content}'); + expect(html).not.toContain('blob:'); + }); + + it('renders guided navigation controls only in guided mode', () => { + const off = $tdv.renderView(dataWith(enabled({ guidedMode: false })), null, '{content}'); + expect(off).not.toContain('tdv-guided-nav'); + const on = $tdv.renderView(dataWith(enabled({ guidedMode: true })), null, '{content}'); + expect(on).toContain('tdv-guided-nav'); + expect(on).toContain('tdv-nav-prev'); + expect(on).toContain('tdv-nav-next'); + }); + + it('bakes a runtime i18n map into the data block', () => { + const html = $tdv.renderView(dataWith(enabled()), null, '{content}'); + expect(html).toContain('"i18n"'); + expect(html).toContain('Check'); + }); + }); }); diff --git a/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js b/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js index 40aa0e30d..4f7b150a4 100644 --- a/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js +++ b/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js @@ -141,6 +141,158 @@ return !!(chosen && chosen.correct); } + // ───────────────────────────────────────────────────────────────────── + // Interaction export markup + // + // The interaction state ships as an escaped JSON l

'); - expect(out).toContain('hi'); - expect(out).not.toContain(' { - expect(runtime.__parseTriple('1 2 3')).toEqual({ x: 1, y: 2, z: 3 }); - expect(runtime.__parseTriple('')).toEqual({ x: 0, y: 0, z: 0 }); - }); - }); - - describe('model-viewer adapter', () => { - function setup(interaction, mode, hooks) { - const wrapper = document.createElement('div'); - wrapper.className = 'three-d-viewer-wrapper'; - const fb = document.createElement('ul'); - fb.className = 'tdv-fallback'; - fb.hidden = true; - wrapper.appendChild(fb); - const mv = document.createElement('model-viewer'); - wrapper.appendChild(mv); - document.body.appendChild(wrapper); - const ctrl = runtime.createInteractionLayer({ wrapper, type: 'glb', modelViewer: mv }, interaction, mode || 'view', hooks || {}); - return { wrapper, mv, ctrl }; - } - - it('renders markers as slotted hotspot buttons with anchors and labels', () => { - const { mv } = setup(IX()); - const btns = mv.querySelectorAll('.tdv-marker[slot^="hotspot-"]'); - expect(btns.length).toBe(2); - expect(btns[0].getAttribute('slot')).toBe('hotspot-m1'); - expect(btns[0].dataset.position).toBe('0 0 0'); - expect(btns[0].dataset.normal).toBe('0 1 0'); - expect(btns[0].getAttribute('aria-label')).toBe('Crater'); - expect(btns[0].querySelector('.tdv-marker-icon.tdv-icon-pin')).toBeTruthy(); - expect(btns[0].querySelector('.tdv-marker-label').textContent).toBe('Crater'); - }); - - it('hides the fallback list when markers render', () => { - const { wrapper } = setup(IX()); - expect(wrapper.querySelector('.tdv-fallback').hidden).toBe(true); - }); - - it('opens an accessible dialog with sanitized information HTML on click', () => { - const { wrapper, mv } = setup(IX()); - mv.querySelector('[data-marker-id="m1"]').click(); - const dialog = wrapper.querySelector('.tdv-dialog[role="dialog"]'); - expect(dialog).toBeTruthy(); - expect(dialog.getAttribute('aria-modal')).toBe('true'); - expect(dialog.getAttribute('aria-label')).toBe('Crater'); - expect(dialog.querySelector('.tdv-dialog-html').innerHTML).toContain('lava'); - }); - - it('closes the dialog on the close button and on Escape', () => { - const { wrapper, mv } = setup(IX()); - mv.querySelector('[data-marker-id="m1"]').click(); - wrapper.querySelector('.tdv-dialog-close').click(); - expect(wrapper.querySelector('.tdv-dialog')).toBeNull(); - mv.querySelector('[data-marker-id="m1"]').click(); - const dialog = wrapper.querySelector('.tdv-dialog'); - dialog.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - expect(wrapper.querySelector('.tdv-dialog')).toBeNull(); - }); - - it('renders an accessible single-choice question and grades a correct answer', () => { - const { wrapper, mv } = setup(IX()); - mv.querySelector('[data-marker-id="m2"]').click(); - const fieldset = wrapper.querySelector('fieldset.tdv-question'); - expect(fieldset.querySelector('legend').textContent).toBe('How hot?'); - const radios = fieldset.querySelectorAll('input[type="radio"]'); - expect(radios.length).toBe(2); - expect(radios[0].name).toBe('tdv-q-m2'); - radios[0].checked = true; - wrapper.querySelector('.tdv-q-check').click(); - const fb = wrapper.querySelector('.tdv-q-feedback'); - expect(fb.className).toContain('tdv-q-feedback--correct'); - expect(fb.getAttribute('aria-live')).toBe('polite'); - expect(fb.textContent).toBe('Yes!'); - }); - - it('marks an incorrect answer and disables checking when attempts run out', () => { - const { wrapper, mv } = setup(IX()); - mv.querySelector('[data-marker-id="m2"]').click(); - const radios = wrapper.querySelectorAll('.tdv-question input[type="radio"]'); - radios[1].checked = true; // wrong - const check = wrapper.querySelector('.tdv-q-check'); - check.click(); - const fb = wrapper.querySelector('.tdv-q-feedback'); - expect(fb.className).toContain('tdv-q-feedback--incorrect'); - expect(check.disabled).toBe(true); // attemptsAllowed = 1 - }); - - it('reveals the fallback list when there is no usable adapter', () => { - // type unknown → no adapter constructed - const wrapper = document.createElement('div'); - wrapper.className = 'three-d-viewer-wrapper'; - const fb = document.createElement('ul'); - fb.className = 'tdv-fallback'; - fb.hidden = true; - wrapper.appendChild(fb); - document.body.appendChild(wrapper); - runtime.createInteractionLayer({ wrapper, type: 'obj' }, IX(), 'view', {}); - expect(fb.hidden).toBe(false); - }); - - it('cleans up markers, dialog and listeners on destroy', () => { - const { wrapper, mv, ctrl } = setup(IX()); - mv.querySelector('[data-marker-id="m1"]').click(); - ctrl.destroy(); - expect(wrapper.querySelector('.tdv-dialog')).toBeNull(); - expect(mv.querySelectorAll('.tdv-marker').length).toBe(0); - }); - }); - - describe('guided navigation', () => { - function setupGuided() { - const wrapper = document.createElement('div'); - wrapper.className = 'three-d-viewer-wrapper'; - const mv = document.createElement('model-viewer'); - wrapper.appendChild(mv); - document.body.appendChild(wrapper); - const ctrl = runtime.createInteractionLayer({ wrapper, type: 'glb', modelViewer: mv }, IX({ guidedMode: true }), 'view', {}); - return { wrapper, ctrl }; - } - - it('builds nav controls and disables prev at the start (no wrap)', () => { - const { wrapper } = setupGuided(); - const nav = wrapper.querySelector('.tdv-guided-nav'); - expect(nav).toBeTruthy(); - expect(nav.hidden).toBe(false); - expect(nav.querySelector('.tdv-nav-prev').textContent).toBe('Previous'); - // no active marker yet → prev disabled, status 0 / 2 - expect(nav.querySelector('.tdv-nav-prev').disabled).toBe(true); - expect(nav.querySelector('.tdv-guided-status').textContent).toContain('/ 2'); - }); - - it('advances with next() and updates disabled state + live status', () => { - const { wrapper, ctrl } = setupGuided(); - const nav = wrapper.querySelector('.tdv-guided-nav'); - ctrl.next(); // → marker 0 (first): prev disabled, next enabled - expect(ctrl.getActiveId()).toBe('m1'); - expect(nav.querySelector('.tdv-nav-prev').disabled).toBe(true); - expect(nav.querySelector('.tdv-nav-next').disabled).toBe(false); - expect(nav.querySelector('.tdv-guided-status').textContent).toContain('1 / 2'); - ctrl.next(); // → marker 1 (last): prev enabled, next disabled - expect(ctrl.getActiveId()).toBe('m2'); - expect(nav.querySelector('.tdv-nav-prev').disabled).toBe(false); - expect(nav.querySelector('.tdv-nav-next').disabled).toBe(true); - expect(nav.querySelector('.tdv-guided-status').textContent).toContain('2 / 2'); - }); - }); - - describe('STL adapter (with THREE stub)', () => { - it('raycastFromPointer returns local position and face normal', () => { - globalThis.THREE = makeThreeStub({ point: new V3(0.2, 0.1, 0), face: { normal: new V3(0, 0, 1) } }); - const inst = makeStlInstance(); - const hit = runtime.raycastFromPointer(inst, 50, 50); - expect(hit.position).toEqual({ x: 0.2, y: 0.1, z: 0 }); - expect(hit.normal).toEqual({ x: 0, y: 0, z: 1 }); - }); - - it('renders a marker overlay and reprojects onto the canvas each frame', () => { - globalThis.THREE = makeThreeStub(null); - const wrapper = document.createElement('div'); - wrapper.className = 'three-d-viewer-wrapper'; - document.body.appendChild(wrapper); - const inst = makeStlInstance(); - runtime.createInteractionLayer({ wrapper, type: 'stl', instance: inst }, IX({ guidedMode: false }), 'view', {}); - const layer = wrapper.querySelector('.tdv-marker-layer'); - expect(layer).toBeTruthy(); - const btn = layer.querySelector('.tdv-marker.tdv-marker--stl[data-marker-id="m1"]'); - expect(btn).toBeTruthy(); - // simulate a frame - expect(inst.onFrame.length).toBe(1); - inst.onFrame[0](); - // marker at NDC (0,0,0) → centre of a 200x100 canvas - expect(btn.style.left).toBe('100px'); - expect(btn.style.top).toBe('50px'); - expect(btn.classList.contains('tdv-marker--hidden')).toBe(false); - }); - - it('hides a marker whose surface normal faces away from the camera', () => { - globalThis.THREE = makeThreeStub(null); - const wrapper = document.createElement('div'); - document.body.appendChild(wrapper); - const inst = makeStlInstance(); - const ix = IX(); - ix.markers = [{ id: 'back', label: 'Back', icon: 'circle', order: 0, - anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 0, z: -1 }, surface: '' }, - camera: { orbit: '', target: '', fieldOfView: '' }, - action: { type: 'information', payload: { html: 'x' } } }]; - runtime.createInteractionLayer({ wrapper, type: 'stl', instance: inst }, ix, 'view', {}); - inst.onFrame[0](); - const btn = wrapper.querySelector('[data-marker-id="back"]'); - expect(btn.classList.contains('tdv-marker--hidden')).toBe(true); - expect(btn.getAttribute('aria-hidden')).toBe('true'); - }); - - it('captures and applies STL camera views', () => { - globalThis.THREE = makeThreeStub(null); - const wrapper = document.createElement('div'); - document.body.appendChild(wrapper); - const inst = makeStlInstance(); - const ctrl = runtime.createInteractionLayer({ wrapper, type: 'stl', instance: inst }, IX(), 'edit', {}); - const cam = ctrl.captureCamera(); - expect(cam.orbit).toBe('0 0 5'); - expect(cam.target).toBe('0 0 0'); - expect(cam.fieldOfView).toBe('45deg'); - }); - - it('places a marker via a canvas click in placement mode', () => { - globalThis.THREE = makeThreeStub({ point: new V3(0.5, 0.5, 0), face: { normal: new V3(1, 0, 0) } }); - const wrapper = document.createElement('div'); - document.body.appendChild(wrapper); - const inst = makeStlInstance(); - let placed = null; - const ctrl = runtime.createInteractionLayer({ wrapper, type: 'stl', instance: inst }, IX({ markers: [] }), 'edit', { - onPlaced: (anchor) => { placed = anchor; }, - }); - ctrl.enterPlacementMode(); - inst.canvas.dispatchEvent(new window.MouseEvent('click', { clientX: 40, clientY: 20, bubbles: true })); - expect(placed).toBeTruthy(); - expect(placed.position).toEqual({ x: 0.5, y: 0.5, z: 0 }); - expect(placed.normal).toEqual({ x: 1, y: 0, z: 0 }); - expect(placed.camera.orbit).toBe('0 0 5'); - }); - - it('removes its onFrame callback and overlay on destroy', () => { - globalThis.THREE = makeThreeStub(null); - const wrapper = document.createElement('div'); - document.body.appendChild(wrapper); - const inst = makeStlInstance(); - const ctrl = runtime.createInteractionLayer({ wrapper, type: 'stl', instance: inst }, IX(), 'view', {}); - expect(inst.onFrame.length).toBe(1); - ctrl.destroy(); - expect(inst.onFrame.length).toBe(0); - expect(wrapper.querySelector('.tdv-marker-layer')).toBeNull(); - }); - }); - - // ── Regression coverage for the adversarial-review findings ────────── - describe('review hardening', () => { - function mvSetup(interaction, mode, extraMv, hooks) { - const wrapper = document.createElement('div'); - wrapper.className = 'three-d-viewer-wrapper'; - const fb = document.createElement('ul'); - fb.className = 'tdv-fallback'; - fb.hidden = true; - wrapper.appendChild(fb); - const mv = Object.assign(document.createElement('model-viewer'), extraMv || {}); - wrapper.appendChild(mv); - document.body.appendChild(wrapper); - const ctrl = runtime.createInteractionLayer({ wrapper, type: 'glb', modelViewer: mv }, interaction, mode || 'view', hooks || {}); - return { wrapper, mv, ctrl }; - } - - it('keeps the fallback visible when WebGL is unavailable', () => { - globalThis.__tdvForceWebGL = false; - const { wrapper } = mvSetup(IX()); - expect(wrapper.querySelector('.tdv-fallback').hidden).toBe(false); - }); - - it('advances exactly one step after repeated setState (no duplicate nav handlers)', () => { - const { wrapper, ctrl } = mvSetup(IX({ guidedMode: true }), 'view'); - // Simulate the editor re-rendering several times. - ctrl.setState(IX({ guidedMode: true })); - ctrl.setState(IX({ guidedMode: true })); - const nextBtn = wrapper.querySelector('.tdv-nav-next'); - nextBtn.click(); - expect(ctrl.getActiveId()).toBe('m1'); // first marker, not jumped past - nextBtn.click(); - expect(ctrl.getActiveId()).toBe('m2'); - }); - - it('wraps navigation and keeps both buttons enabled when wrap is on', () => { - const { wrapper, ctrl } = mvSetup(IX({ guidedMode: true, wrapNavigation: true }), 'view'); - ctrl.next(); // → m1 (first) - const nav = wrapper.querySelector('.tdv-guided-nav'); - expect(nav.querySelector('.tdv-nav-prev').disabled).toBe(false); - expect(nav.querySelector('.tdv-nav-next').disabled).toBe(false); - ctrl.prev(); // wrap back to last - expect(ctrl.getActiveId()).toBe('m2'); - ctrl.next(); // wrap forward to first - expect(ctrl.getActiveId()).toBe('m1'); - }); - - it('clears the active marker when setState drops it', () => { - const { ctrl } = mvSetup(IX(), 'view'); - ctrl.focusMarker('m1'); - expect(ctrl.getActiveId()).toBe('m1'); - ctrl.setState({ enabled: true, guidedMode: false, wrapNavigation: false, showMarkerLabels: true, activeMarkerId: '', markers: [ - { id: 'zzz', label: 'Other', icon: 'circle', order: 0, - anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 }, surface: '' }, - camera: { orbit: '', target: '', fieldOfView: '' }, - action: { type: 'information', payload: { html: '' } } }, - ] }); - expect(ctrl.getActiveId()).toBe(''); - }); - - it('opens a safe link in a new tab and refuses javascript: links', () => { - const opened = []; - const origOpen = window.open; - window.open = (url, target, feats) => { opened.push({ url, target, feats }); return null; }; - try { - const linkIx = IX({ markers: [ - { id: 'l', label: 'Docs', icon: 'circle', order: 0, - anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 }, surface: '' }, - camera: { orbit: '', target: '', fieldOfView: '' }, - action: { type: 'link', payload: { url: 'https://exelearning.net', newTab: true } } }, - { id: 'bad', label: 'Bad', icon: 'circle', order: 1, - anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 }, surface: '' }, - camera: { orbit: '', target: '', fieldOfView: '' }, - action: { type: 'link', payload: { url: 'javascript:alert(1)', newTab: true } } }, - ] }); - const { ctrl } = mvSetup(linkIx, 'view'); - ctrl.focusMarker('l'); - expect(opened).toHaveLength(1); - expect(opened[0].url).toBe('https://exelearning.net'); - expect(opened[0].feats).toContain('noopener'); - ctrl.focusMarker('bad'); - expect(opened).toHaveLength(1); // javascript: refused, no navigation - } finally { - window.open = origOpen; - } - }); - - it('resolves media URLs for image markers via the hook', () => { - const wrapper = document.createElement('div'); - wrapper.className = 'three-d-viewer-wrapper'; - const mv = document.createElement('model-viewer'); - wrapper.appendChild(mv); - document.body.appendChild(wrapper); - const imgIx = IX({ markers: [ - { id: 'img', label: 'Pic', icon: 'circle', order: 0, - anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 }, surface: '' }, - camera: { orbit: '', target: '', fieldOfView: '' }, - action: { type: 'image', payload: { src: 'asset://p.png', alt: 'a pic', caption: 'cap' } } }, - ] }); - const ctrl = runtime.createInteractionLayer({ wrapper, type: 'glb', modelViewer: mv }, imgIx, 'view', { - resolveMediaUrl: (u) => 'RESOLVED:' + u, - }); - ctrl.focusMarker('img'); - const img = wrapper.querySelector('.tdv-dialog-figure img'); - expect(img.getAttribute('src')).toBe('RESOLVED:asset://p.png'); - expect(img.getAttribute('alt')).toBe('a pic'); - expect(wrapper.querySelector('figcaption').textContent).toBe('cap'); - }); - - it('captures model-viewer camera and places via positionAndNormalFromPoint', () => { - const mvStub = { - getCameraOrbit: () => ({ toString: () => '30deg 75deg 105%' }), - getCameraTarget: () => ({ toString: () => '0m 0m 0m' }), - getFieldOfView: () => 45, - positionAndNormalFromPoint: () => ({ position: { toString: () => '1 2 3' }, normal: { toString: () => '0 1 0' } }), - }; - let placed = null; - const { mv, ctrl } = mvSetup(IX({ markers: [] }), 'edit', mvStub, { onPlaced: (a) => { placed = a; } }); - const cam = ctrl.captureCamera(); - expect(cam.orbit).toBe('30deg 75deg 105%'); - expect(cam.target).toBe('0m 0m 0m'); - expect(cam.fieldOfView).toBe('45deg'); - - ctrl.enterPlacementMode(); - mv.dispatchEvent(new window.MouseEvent('click', { clientX: 10, clientY: 20, bubbles: true })); - expect(placed).toBeTruthy(); - expect(placed.position).toEqual({ x: 1, y: 2, z: 3 }); - expect(placed.normal).toEqual({ x: 0, y: 1, z: 0 }); - expect(placed.camera.orbit).toBe('30deg 75deg 105%'); - }); - - it('calls onQuestionAnswered with the graded result', () => { - const answered = []; - const { wrapper, ctrl } = mvSetup(IX(), 'view', undefined, { - onQuestionAnswered: (id, correct) => answered.push({ id, correct }), - }); - ctrl.focusMarker('m2'); // question marker - // wrong answer first - const radios = wrapper.querySelectorAll('.tdv-question input[type="radio"]'); - radios[1].checked = true; - wrapper.querySelector('.tdv-q-check').click(); - expect(answered).toEqual([{ id: 'm2', correct: false }]); - // Note: attemptsAllowed=1 disables further checks; correctness path - // is covered by the model-viewer question test above. - }); - - it('sanitizer strips form action and javascript URLs', () => { - const out = runtime.__sanitizeHtmlDom('
l'); - expect(out).not.toContain(' attribute application for GLB/GLTF - * - Asset source resolution (asset:// → blob via AssetManager, or - * content/resources/... for offline export) - * - Per-wrapper instance registry with cleanup on beforeunload - * - Pure helpers: detectModelType, normalizeColor, normalizeModelSource, - * configureRendererColorManagement, disposeObject3D, disposeMaterial - * - * The runtime publishes a single global `window.eXe3DViewer` so both - * edition/three-d-viewer.js and export/three-d-viewer.js can call it - * without bundling. It is loaded via breakout', () => { - const html = $tdv.renderView(dataWith(enabled({ markers: [ - { id: 'x', label: 'X', icon: 'circle', - anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } }, - action: { type: 'information', payload: { html: '' } } }, - ] })), null, '{content}'); - // The data block must not contain a raw closing from the - // payload — every leading < is escaped to <. - const block = html.slice(html.indexOf('tdv-interaction-data')); - const jsonPart = block.slice(0, block.indexOf('')); - expect(jsonPart).not.toContain(''); - expect(jsonPart).toContain('\\u003c/script>'); - }); - - it('keeps asset:// media references in the JSON block for the export rewriter', () => { - const html = $tdv.renderView(dataWith(enabled({ markers: [ - { id: 'i', label: 'Img', icon: 'circle', - anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } }, - action: { type: 'image', payload: { src: 'asset://pic.png', alt: 'p' } } }, - ] })), null, '{content}'); - expect(html).toContain('asset://pic.png'); - }); - - it('never emits a blob: URL from marker media', () => { - const html = $tdv.renderView(dataWith(enabled({ markers: [ - { id: 'i', label: 'Img', icon: 'circle', - anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } }, - action: { type: 'image', payload: { src: 'blob:http://x/leak' } } }, - ] })), null, '{content}'); - expect(html).not.toContain('blob:'); - }); - - it('renders guided navigation controls only in guided mode', () => { - const off = $tdv.renderView(dataWith(enabled({ guidedMode: false })), null, '{content}'); - expect(off).not.toContain('tdv-guided-nav'); - const on = $tdv.renderView(dataWith(enabled({ guidedMode: true })), null, '{content}'); - expect(on).toContain('tdv-guided-nav'); - expect(on).toContain('tdv-nav-prev'); - expect(on).toContain('tdv-nav-next'); - }); - - it('bakes a runtime i18n map into the data block', () => { - const html = $tdv.renderView(dataWith(enabled()), null, '{content}'); - expect(html).toContain('"i18n"'); - expect(html).toContain('Check'); - }); - - it('embeds the SCORM scoring config in the data block', () => { - const data = Object.assign(dataWith(enabled()), { isScorm: 1, weighted: 80 }); - const html = $tdv.renderView(data, null, '{content}'); - expect(html).toContain('"scorm"'); - expect(html).toContain('"isScorm":1'); - expect(html).toContain('"weighted":80'); - }); - }); - - describe('normalizeScorm + setupScormScoring', () => { - it('clamps the SCORM config', () => { - expect($tdv.__normalizeScorm({ isScorm: 9, weighted: 0 })).toEqual({ isScorm: 2, weighted: 1, textButtonScorm: '' }); - }); - - it('registers the activity and reports the fraction correct in a SCORM export', () => { - const events = []; - globalThis.$exeDevices = { iDevice: { gamification: { scorm: { - registerActivity: (g) => events.push(['register', g.main, g.isScorm, g.weighted]), - sendScoreNew: (auto, g) => events.push(['send', g.scorerp, !!g.gameOver]), - } } } }; - const savedDoc = globalThis.document; - globalThis.document = { body: { classList: { contains: (c) => c === 'exe-scorm' } } }; - try { - const interaction = $tdv.__normalizeInteraction({ enabled: true, markers: [ - { id: 'q1', action: { type: 'question', payload: { options: [{ text: 'a', correct: true }, { text: 'b' }] } } }, - { id: 'q2', action: { type: 'question', payload: { options: [{ text: 'a', correct: true }, { text: 'b' }] } } }, - { id: 'i1', action: { type: 'information', payload: { html: '' } } }, - ] }); - const hooks = {}; - $tdv.__setupScormScoring({ id: 'viewer-1' }, interaction, { isScorm: 1, weighted: 100 }, hooks); - expect(events).toContainEqual(['register', 'viewer-1', 1, 100]); - expect(typeof hooks.onQuestionAnswered).toBe('function'); - hooks.onQuestionAnswered('q1', true); // 1 of 2 questions → 5/10 - expect(events).toContainEqual(['send', 5, false]); - hooks.onQuestionAnswered('q2', true); // 2 of 2 → 10/10, complete - expect(events).toContainEqual(['send', 10, true]); - } finally { - globalThis.document = savedDoc; - delete globalThis.$exeDevices; - } - }); - - it('does not wire scoring outside a SCORM export', () => { - const savedDoc = globalThis.document; - globalThis.document = { body: { classList: { contains: () => false } } }; - try { - const hooks = {}; - const interaction = $tdv.__normalizeInteraction({ enabled: true, markers: [ - { id: 'q', action: { type: 'question', payload: {} } }, - ] }); - $tdv.__setupScormScoring({ id: 'v' }, interaction, { isScorm: 1 }, hooks); - expect(hooks.onQuestionAnswered).toBeUndefined(); - } finally { - globalThis.document = savedDoc; - } - }); - }); -}); diff --git a/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js b/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js index 3def7c80d..64ea5a419 100644 --- a/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js +++ b/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js @@ -1,1761 +1,2733 @@ -/* global eXe */ - -/** - * Three D Viewer iDevice (export runtime) - * - * - Loads the model-viewer web component (ES module) once per page. - * - Renders a using the JSON stored by the edition view. - * - Works on initial page load (refresh) without entering Edit mode. - */ - -(function () { - const globalScope = typeof window !== 'undefined' ? window : globalThis; - - /** Default background color */ - const DEFAULT_BACKGROUND = '#f5f5f5'; - - /** Fallback translations when i18n is not available */ - const FALLBACK_TRANSLATIONS = { - 'viewer.empty_state': 'Select a 3D model to display', - 'viewer.animation_paused': 'Animation paused', - 'viewer.animation_enabled': 'Animation enabled', - 'viewer.local_warning_title': '3D Viewer not available', - 'viewer.local_warning_message': 'The 3D viewer requires a web server to work. Open this content from a web server or use eXeLearning preview.', - 'viewer.fullscreen': 'Fullscreen', - 'viewer.exit_fullscreen': 'Exit fullscreen', - 'viewer.rotate_left': 'Rotate left', - 'viewer.rotate_right': 'Rotate right', - 'viewer.tilt_up': 'Tilt up', - 'viewer.tilt_down': 'Tilt down' - }; - - /** Camera nudge step (radians) — matches threesixty viewer feel */ - const YAW_STEP = (15 * Math.PI) / 180; - const PITCH_STEP = (10 * Math.PI) / 180; - - // ───────────────────────────────────────────────────────────────────── - // Interaction schema (mirror edition/three-d-viewer.js). These pure - // helpers must stay byte-identical with the edition copy — see - // doc/architecture/sdd/SDD-0001. Used by renderView/renderBehaviour and - // consumed by the shared runtime (three-d-viewer-runtime.js). - // ───────────────────────────────────────────────────────────────────── - var STATE_VERSION = 2; - var MARKER_ICONS = ['circle', 'pin', 'info', 'question', 'star']; - var INTERACTION_ACTION_TYPES = ['information', 'image', 'video', 'link', 'question']; - - function tdNum(v, fallback) { var n = typeof v === 'number' ? v : parseFloat(v); return Number.isFinite(n) ? n : fallback; } - function tdClamp(v, min, max) { return Math.min(max, Math.max(min, v)); } - function tdStr(v, fallback) { return typeof v === 'string' ? v : (fallback || ''); } - function tdId(prefix, existing) { - if (typeof existing === 'string' && existing) return existing; - return prefix + '-' + Math.floor(Math.random() * 1e9).toString(36) + Math.floor(Math.random() * 1e6).toString(36); - } - function tdStripUnsafeUrl(v) { var s = tdStr(v, ''); return /^\s*(blob:|data:|javascript:|vbscript:)/i.test(s) ? '' : s.trim(); } - function tdInt(v, fallback) { var n = parseInt(v, 10); return Number.isFinite(n) ? n : fallback; } - /** Normalize the SCORM scoring config (mirror edition/export). */ - function normalizeScorm(data) { - var o = data && typeof data === 'object' ? data : {}; - return { - isScorm: tdClamp(tdInt(o.isScorm, 0), 0, 2), - weighted: tdClamp(tdNum(o.weighted, 100), 1, 100), - textButtonScorm: tdStr(o.textButtonScorm, ''), - }; +(() => { + // public/files/perm/idevices/base/three-d-viewer/src/interactions/marker-renderer.ts + function createMarkerButton(marker, options) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `tdv-marker ${options.variantClass}`; + button.dataset.markerId = marker.id; + button.dataset.markerOrder = String(options.index); + button.setAttribute("aria-label", options.label); + const icon = document.createElement("span"); + icon.className = `tdv-marker-icon tdv-icon-${marker.icon}`; + icon.setAttribute("aria-hidden", "true"); + button.appendChild(icon); + if (options.showLabels && marker.label) { + const label = document.createElement("span"); + label.className = "tdv-marker-label"; + label.textContent = marker.label; + button.appendChild(label); + } + if (options.activeId === marker.id) { + button.classList.add("tdv-marker--active"); + button.setAttribute("aria-current", "true"); + } + button.addEventListener("click", () => options.onActivate(marker.id)); + return button; + } + function applyActiveMarker(buttons, activeId) { + for (const button of buttons) { + const isActive = button.dataset.markerId === activeId; + button.classList.toggle("tdv-marker--active", isActive); + if (isActive) { + button.setAttribute("aria-current", "true"); + } else { + button.removeAttribute("aria-current"); + } } - function normalizeVec3(v, dflt) { - var o = v && typeof v === 'object' ? v : {}; - return { x: tdNum(o.x, dflt.x), y: tdNum(o.y, dflt.y), z: tdNum(o.z, dflt.z) }; + } + + // public/files/perm/idevices/base/three-d-viewer/src/adapters/geometry.ts + var FACING_THRESHOLD = -0.15; + function ndcToScreen(ndc, width, height) { + return { + x: (ndc.x * 0.5 + 0.5) * width, + y: (-ndc.y * 0.5 + 0.5) * height + }; + } + function isOnScreen(ndc) { + const inFrustum = ndc.z < 1 && ndc.z > -1; + return inFrustum && ndc.x >= -1 && ndc.x <= 1 && ndc.y >= -1 && ndc.y <= 1; + } + function isFacingCamera(normal, toCamera) { + return normal.x * toCamera.x + normal.y * toCamera.y + normal.z * toCamera.z > FACING_THRESHOLD; + } + function isMarkerVisible(ndc, normal, toCamera) { + return isFacingCamera(normal, toCamera) && isOnScreen(ndc); + } + function parseTriple(value) { + const parts = String(value ?? "").trim().split(/\s+/).map(Number.parseFloat); + return { + x: Number.isFinite(parts[0]) ? parts[0] : 0, + y: Number.isFinite(parts[1]) ? parts[1] : 0, + z: Number.isFinite(parts[2]) ? parts[2] : 0 + }; + } + function formatTriple(vector) { + return `${vector.x} ${vector.y} ${vector.z}`; + } + function pointerToNdc(rect, clientX, clientY) { + if (!rect.width || !rect.height) { + return null; } - function normalizeAnchor(a) { - var o = a && typeof a === 'object' ? a : {}; + return { + x: (clientX - rect.left) / rect.width * 2 - 1, + y: -((clientY - rect.top) / rect.height) * 2 + 1 + }; + } + + // public/files/perm/idevices/base/three-d-viewer/src/adapters/model-viewer-adapter.ts + var EMPTY_CAMERA = { orbit: "", target: "", fieldOfView: "" }; + function createModelViewerAdapter(modelViewer, deps) { + let placeHandler = null; + const clearMarkers = () => { + for (const element of Array.from(modelViewer.querySelectorAll('.tdv-marker[slot^="hotspot-"]'))) { + element.remove(); + } + }; + const captureCamera = () => { + try { return { - position: normalizeVec3(o.position, { x: 0, y: 0, z: 0 }), - normal: normalizeVec3(o.normal, { x: 0, y: 1, z: 0 }), - surface: tdStr(o.surface, ''), + orbit: modelViewer.getCameraOrbit?.().toString() ?? "", + target: modelViewer.getCameraTarget?.().toString() ?? "", + fieldOfView: modelViewer.getFieldOfView ? `${modelViewer.getFieldOfView()}deg` : "" }; - } - function normalizeCamera(c) { - var o = c && typeof c === 'object' ? c : {}; - return { orbit: tdStr(o.orbit, ''), target: tdStr(o.target, ''), fieldOfView: tdStr(o.fieldOfView, '') }; - } - function normalizeQuestion(p) { - var o = p && typeof p === 'object' ? p : {}; - var rawOpts = Array.isArray(o.options) ? o.options : []; - var seenCorrect = false; - var options = rawOpts.slice(0, 10).map(function (opt) { - var oo = opt && typeof opt === 'object' ? opt : {}; - var correct = !!oo.correct && !seenCorrect; - if (correct) seenCorrect = true; - return { id: tdId('option', oo.id), text: tdStr(oo.text, ''), correct: correct }; + } catch { + return { ...EMPTY_CAMERA }; + } + }; + return { + renderMarkers(markers, options) { + clearMarkers(); + markers.forEach((marker, index) => { + const button = createMarkerButton(marker, { + ...options, + index, + label: deps.markerLabel(marker, index), + variantClass: "tdv-marker--mv", + onActivate: deps.onActivate + }); + button.setAttribute("slot", `hotspot-${marker.id}`); + button.dataset.position = formatTriple(marker.anchor.position); + button.dataset.normal = formatTriple(marker.anchor.normal); + if (marker.anchor.surface) { + button.dataset.surface = marker.anchor.surface; + } + modelViewer.appendChild(button); }); - if (options.length === 0) { - options = [{ id: tdId('option'), text: '', correct: true }, { id: tdId('option'), text: '', correct: false }]; - } else if (!seenCorrect) { - options[0].correct = true; + }, + setActive(activeId) { + applyActiveMarker(modelViewer.querySelectorAll(".tdv-marker"), activeId); + }, + focusMarker(marker) { + const camera = marker.camera; + if (camera.orbit) { + modelViewer.cameraOrbit = camera.orbit; } - return { - prompt: tdStr(o.prompt, ''), - type: 'single-choice', - options: options, - feedbackCorrect: tdStr(o.feedbackCorrect, ''), - feedbackIncorrect: tdStr(o.feedbackIncorrect, ''), - attemptsAllowed: tdClamp(Math.round(tdNum(o.attemptsAllowed, 0)), 0, 20), + if (camera.target) { + modelViewer.cameraTarget = camera.target; + } + if (camera.fieldOfView) { + modelViewer.fieldOfView = camera.fieldOfView; + } + }, + captureCamera, + updateOverlay() {}, + enterPlacementMode(onPlaced) { + placeHandler = (event) => { + const hit = modelViewer.positionAndNormalFromPoint?.(event.clientX, event.clientY); + if (!hit) { + return; + } + onPlaced({ + position: parseTriple(hit.position?.toString()), + normal: parseTriple(hit.normal?.toString()), + surface: "", + camera: captureCamera() + }); }; - } - function normalizeAction(a) { - var o = a && typeof a === 'object' ? a : {}; - var type = INTERACTION_ACTION_TYPES.indexOf(o.type) >= 0 ? o.type : 'information'; - var pin = o.payload && typeof o.payload === 'object' ? o.payload : {}; - var payload; - switch (type) { - case 'image': payload = { src: tdStripUnsafeUrl(pin.src), alt: tdStr(pin.alt, ''), caption: tdStr(pin.caption, '') }; break; - case 'video': payload = { src: tdStripUnsafeUrl(pin.src), poster: tdStripUnsafeUrl(pin.poster) }; break; - case 'link': payload = { url: tdStripUnsafeUrl(pin.url), newTab: pin.newTab !== false }; break; - case 'question': payload = normalizeQuestion(pin); break; - default: payload = { html: tdStr(pin.html, '') }; break; + modelViewer.addEventListener("click", placeHandler); + }, + exitPlacementMode() { + if (placeHandler) { + modelViewer.removeEventListener("click", placeHandler); + placeHandler = null; } - return { type: type, payload: payload }; + }, + destroy() { + this.exitPlacementMode(); + clearMarkers(); + } + }; + } + + // public/files/perm/idevices/base/three-d-viewer/src/shared/urls.ts + var EXECUTABLE_SCHEME = /^\s*(javascript|vbscript):/i; + var EPHEMERAL_OR_EXECUTABLE_SCHEME = /^\s*(blob:|data:|javascript:|vbscript:)/i; + var ALLOWED_RENDER_SCHEME = /^(https?:|mailto:|tel:|asset:|blob:)/i; + var HAS_EXPLICIT_SCHEME = /^[a-z][a-z0-9+.-]*:/i; + var ABSOLUTE_URL = /^(https?:)?\/\//i; + function stripUnsafeUrl(value) { + const raw = typeof value === "string" ? value : ""; + return EPHEMERAL_OR_EXECUTABLE_SCHEME.test(raw) ? "" : raw.trim(); + } + function safeUrl(value) { + const raw = typeof value === "string" ? value.trim() : ""; + if (!raw) { + return ""; } - function normalizeMarker(m, index) { - var o = m && typeof m === 'object' ? m : {}; - var order = tdNum(o.order, NaN); - return { - id: tdId('marker', o.id), - label: tdStr(o.label, ''), - description: tdStr(o.description, ''), - icon: MARKER_ICONS.indexOf(o.icon) >= 0 ? o.icon : 'circle', - order: Number.isFinite(order) ? order : index, - anchor: normalizeAnchor(o.anchor), - camera: normalizeCamera(o.camera), - action: normalizeAction(o.action), - }; + if (EXECUTABLE_SCHEME.test(raw)) { + return ""; } - function normalizeInteraction(it) { - var o = it && typeof it === 'object' ? it : {}; - var markers = (Array.isArray(o.markers) ? o.markers : []).map(normalizeMarker); - markers.sort(function (a, b) { return a.order - b.order; }); - markers.forEach(function (mk, i) { mk.order = i; }); - var ids = markers.map(function (mk) { return mk.id; }); - return { - enabled: !!o.enabled, - guidedMode: !!o.guidedMode, - wrapNavigation: !!o.wrapNavigation, - showMarkerLabels: o.showMarkerLabels !== false, - activeMarkerId: ids.indexOf(o.activeMarkerId) >= 0 ? o.activeMarkerId : '', - markers: markers, - }; + if (ALLOWED_RENDER_SCHEME.test(raw)) { + return raw; } - /** Pure single-choice grading — chosen option id → correct? */ - function gradeSingleChoice(question, selectedOptionId) { - var q = normalizeQuestion(question); - var chosen = q.options.filter(function (op) { return op.id === selectedOptionId; })[0]; - return !!(chosen && chosen.correct); + return HAS_EXPLICIT_SCHEME.test(raw) ? "" : raw; + } + function isAbsoluteUrl(value) { + return ABSOLUTE_URL.test(value); + } + function normalizePath(value) { + const clean = String(value ?? "").trim().replace(/\\+/g, "/"); + if (!clean) { + return ""; } - - // ───────────────────────────────────────────────────────────────────── - // Interaction export markup - // - // The interaction state ships as an escaped JSON '; + expect(parseInteractionData(wrapper)).toEqual({ enabled: true }); + }); + + it('returns null when the block is missing or malformed', () => { + expect(parseInteractionData(createWrapper('a'))).toBeNull(); + const broken = createWrapper('b'); + broken.innerHTML = ''; + expect(parseInteractionData(broken)).toBeNull(); + }); +}); + +describe('findWrappers', () => { + it('finds every wrapper in the document', () => { + createWrapper('one'); + createWrapper('two'); + expect(findWrappers('')).toHaveLength(2); + }); + + it('scopes to the iDevice node when one matches', () => { + const node = document.createElement('div'); + node.className = 'idevice_node three-d-viewer'; + node.id = 'idev-1'; + document.body.appendChild(node); + const scoped = createWrapper('scoped'); + node.appendChild(scoped); + createWrapper('outside'); + expect(findWrappers('idev-1')).toEqual([scoped]); + }); + + it('falls back to the whole document when the scope holds no wrapper', () => { + const node = document.createElement('div'); + node.className = 'idevice_node three-d-viewer'; + node.id = 'idev-1'; + document.body.appendChild(node); + const outside = createWrapper('outside'); + expect(findWrappers('idev-1')).toEqual([outside]); + }); +}); + +describe('attachInteractionLayer', () => { + function withInteraction(wrapper: HTMLElement, payload: Record): void { + const script = document.createElement('script'); + script.type = 'application/json'; + script.className = 'tdv-interaction-data'; + script.textContent = JSON.stringify(payload); + wrapper.appendChild(script); + } + + it('creates the layer for a GLB wrapper', async () => { + const wrapper = createWrapper(); + wrapper.dataset.modelType = 'glb'; + createModelViewerStub(wrapper); + withInteraction(wrapper, { enabled: true, markers: [{ id: 'm1', label: 'One' }] }); + + await attachInteractionLayer(wrapper); + expect(wrapper.querySelector('.tdv-marker')?.getAttribute('aria-label')).toBe('One'); + expect(wrapper.dataset.tdvInteractionBooted).toBe('1'); + }); + + it('uses the baked i18n map for learner strings', async () => { + const wrapper = createWrapper(); + wrapper.dataset.modelType = 'glb'; + createModelViewerStub(wrapper); + withInteraction(wrapper, { enabled: true, markers: [{ id: 'm1' }], i18n: { Marker: 'Marcador' } }); + await attachInteractionLayer(wrapper); + expect(wrapper.querySelector('.tdv-marker')?.getAttribute('aria-label')).toBe('Marcador 1'); + }); + + it('is idempotent and skips a disabled or missing block', async () => { + const wrapper = createWrapper(); + wrapper.dataset.modelType = 'glb'; + createModelViewerStub(wrapper); + withInteraction(wrapper, { enabled: true, markers: [{ id: 'm1' }] }); + await attachInteractionLayer(wrapper); + await attachInteractionLayer(wrapper); + expect(wrapper.querySelectorAll('.tdv-marker')).toHaveLength(1); + + const disabled = createWrapper('disabled'); + withInteraction(disabled, { enabled: false }); + await attachInteractionLayer(disabled); + expect(disabled.dataset.tdvInteractionBooted).toBeUndefined(); + }); + + it('attaches to a booted STL instance', async () => { + const wrapper = createWrapper(); + wrapper.dataset.modelType = 'stl'; + withInteraction(wrapper, { enabled: true, markers: [{ id: 'm1', label: 'STL' }] }); + const runtime = publishViewerRuntime(); + const instance = createStubInstance(wrapper); + runtime.registry.set(wrapper, instance); + + await attachInteractionLayer(wrapper); + expect(wrapper.querySelector('.tdv-marker--stl')?.getAttribute('aria-label')).toBe('STL'); + expect(instance.interaction).not.toBeNull(); + }); + + it('reveals the text fallback when the STL scene never produced a mesh', async () => { + const wrapper = createWrapper(); + wrapper.dataset.modelType = 'stl'; + wrapper.innerHTML = ''; + withInteraction(wrapper, { enabled: true, markers: [{ id: 'm1' }] }); + const runtime = publishViewerRuntime(); + const instance = createStubInstance(wrapper); + instance.mesh = null; + runtime.registry.set(wrapper, instance); + + // A zero deadline keeps the test fast; production waits 20 seconds. + await attachInteractionLayer(wrapper, 0); + expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(false); + }); +}); + +describe('bootWrappers', () => { + it('returns true and does nothing when there is no wrapper', () => { + expect(bootWrappers('')).toBe(true); + }); + + it('migrates, strips and boots every wrapper it finds', async () => { + const wrapper = createWrapper(); + wrapper.dataset.modelSrc = 'content/resources/a.stl'; + wrapper.dataset.modelType = 'stl'; + const modelViewer = createModelViewerStub(wrapper); + modelViewer.setAttribute('src', 'content/resources/a.stl'); + + expect(bootWrappers('')).toBe(true); + await flush(); + + expect(modelViewer.hasAttribute('src')).toBe(false); + expect(wrapper.dataset.threedBooted).toBe('1'); + }); + + it('does not boot the same wrapper twice', async () => { + const wrapper = createWrapper(); + wrapper.dataset.modelSrc = 'a.glb'; + createModelViewerStub(wrapper); + bootWrappers(''); + await flush(); + const first = wrapper.dataset.threedBooted; + bootWrappers(''); + await flush(); + expect(wrapper.dataset.threedBooted).toBe(first); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/export/bootstrap.ts b/public/files/perm/idevices/base/three-d-viewer/src/export/bootstrap.ts new file mode 100644 index 000000000..da9246644 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/export/bootstrap.ts @@ -0,0 +1,298 @@ +/** + * Booting the wrappers on an exported (or previewed) page: legacy upgrade, + * boot-config resolution, viewer construction and interaction attachment. + */ + +import { revealFallback } from '../interactions/fallback'; +import type { InteractionHooks } from '../interactions/types'; +import { getAssetManager } from '../runtime/asset-resolver'; +import { getExportLibBaseUrl, getExportModelViewerUrl, getIdeviceResourcesBase } from '../runtime/paths'; +import { ensureModelViewerLoaded } from '../runtime/model-viewer-loader'; +import { ensureThreeJsLoaded } from '../runtime/three-loader'; +import type { ViewerInstance } from '../runtime/types'; +import { publishViewerRuntime } from '../runtime/viewer-runtime'; +import { DEFAULT_BACKGROUND_COLOR, DEFAULT_MODEL_COLOR, normalizeColor } from '../shared/colors'; +import { normalizeAnimation, normalizeInteraction, normalizeScorm } from '../shared/schema'; +import { detectModelType, isStlSource } from '../shared/model-source'; +import type { InteractionSettings, ScormSettings, ViewerDisplayConfig } from '../shared/types'; +import { resolveRuntimeSrc } from './source-resolver'; +import { setupScormScoring } from './scorm'; +import { ThreeDViewerController } from './viewer-controller'; + +/** How long to wait for a booted STL mesh before showing the text fallback. */ +const STL_INTERACTION_TIMEOUT_MS = 20000; + +/** + * Upgrade persisted HTML that still carries the base64 `data-config` payload + * (written before the flat `data-*` attributes existed) into the current shape. + * Idempotent, silent, and a no-op for wrappers already in the new format. + */ +export function migrateLegacyConfig(wrapper: HTMLElement): void { + const encoded = wrapper.getAttribute('data-config'); + if (!encoded) { + return; + } + let config: Record = {}; + try { + config = JSON.parse(decodeURIComponent(escape(atob(encoded)))) as Record; + } catch { + try { + config = JSON.parse(encoded) as Record; + } catch { + config = {}; + } + } + const data = wrapper.dataset; + const setIfMissing = (key: string, value: unknown): void => { + if (data[key] == null && value != null && value !== '') { + data[key] = String(value); + } + }; + setIfMissing('modelSrc', config.src); + setIfMissing('alt', config.alt); + setIfMissing('backgroundColor', config.backgroundColor); + if (config.cameraControls != null) { + setIfMissing('cameraControls', Boolean(config.cameraControls)); + } + if (config.autoRotate != null) { + setIfMissing('autoRotate', Boolean(config.autoRotate)); + } + setIfMissing('autoRotateSpeed', config.autoRotateSpeed); + if (config.showNavControls != null) { + setIfMissing('showNavControls', Boolean(config.showNavControls)); + } + const animation = config.animation as Record | undefined; + if (animation) { + if (animation.enabled != null) { + setIfMissing('animationEnabled', Boolean(animation.enabled)); + } + setIfMissing('animationName', animation.name); + setIfMissing('animationSpeed', animation.speed); + } + if (!data.modelType && data.modelSrc) { + const type = detectModelType(data.modelSrc); + if (type !== 'unknown') { + data.modelType = type; + } + } + if (!data.modelColor) { + data.modelColor = DEFAULT_MODEL_COLOR; + } + wrapper.removeAttribute('data-config'); +} + +/** + * Read the boot config from a wrapper's flat `data-*` attributes. + * + * The attributes are the single source of truth: the exporter rewrites + * `asset://uuid.ext` → `content/resources/...` inside `data-model-src`, so the + * editor and a static export take the same code path. + */ +export function resolveBootConfig(wrapper: HTMLElement): ViewerDisplayConfig { + const data = wrapper.dataset; + const showNavControls = data.showNavControls === 'true'; + const rawSrc = (data.modelSrc ?? '').trim(); + const assetRef = (data.modelAssetRef ?? '').trim(); + // Prefer the canonical `asset://` handle when AssetManager is live: + // `data-model-src` may have been rewritten to a path that only exists inside + // an export ZIP, or replaced with a blob URL by the workarea resolver. + let src = assetRef && getAssetManager() ? `asset://${assetRef}` : rawSrc; + // `data:` would never round-trip; `blob:` is left intact because the browser + // can still fetch a live one, which beats showing the empty state. + if (src.startsWith('data:')) { + src = ''; + } + return { + src, + type: data.modelType ? (data.modelType as ViewerDisplayConfig['type']) : detectModelType(src), + alt: data.alt ?? '', + modelColor: normalizeColor(data.modelColor, DEFAULT_MODEL_COLOR), + backgroundColor: normalizeColor(data.backgroundColor, DEFAULT_BACKGROUND_COLOR), + cameraControls: data.cameraControls !== 'false', + autoRotate: !showNavControls && data.autoRotate !== 'false', + autoRotateSpeed: Number.parseFloat(data.autoRotateSpeed ?? '') || 30, + showNavControls, + animation: normalizeAnimation({ + enabled: data.animationEnabled === 'true', + name: data.animationName ?? '', + speed: Number.parseFloat(data.animationSpeed ?? '') || 1, + }), + }; +} + +/** Read and parse the JSON interaction block a wrapper carries, if any. */ +export function parseInteractionData(wrapper: HTMLElement): Record | null { + const script = wrapper.querySelector('script.tdv-interaction-data'); + if (!script) { + return null; + } + try { + const parsed: unknown = JSON.parse(script.textContent || '{}'); + return parsed && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + return null; + } +} + +function buildInteractionHooks(wrapper: HTMLElement, raw: Record): InteractionHooks { + const i18n = (raw.i18n && typeof raw.i18n === 'object' ? raw.i18n : {}) as Record; + return { + t: key => i18n[key] ?? key, + resolveMediaUrl: url => { + try { + return resolveRuntimeSrc(url) || url; + } catch { + return url; + } + }, + }; +} + +/** Poll the runtime until the STL mesh exists, or the deadline passes. */ +function waitForStlMesh(wrapper: HTMLElement, timeoutMs: number): Promise { + const runtime = publishViewerRuntime(); + const deadline = Date.now() + timeoutMs; + return new Promise(resolve => { + const poll = (): void => { + const instance = runtime.getInstance(wrapper); + if (instance?.mesh || Date.now() >= deadline) { + resolve(instance); + return; + } + const raf = globalThis.requestAnimationFrame; + if (typeof raf === 'function') { + raf(poll); + } else { + setTimeout(poll, 16); + } + }; + poll(); + }); +} + +/** + * Attach the shared interaction layer to a booted wrapper. Idempotent: the + * wrapper is flagged so a second `renderBehaviour` pass does nothing. + */ +export async function attachInteractionLayer( + wrapper: HTMLElement, + timeoutMs: number = STL_INTERACTION_TIMEOUT_MS, +): Promise { + if (wrapper.dataset.tdvInteractionBooted === '1') { + return; + } + const raw = parseInteractionData(wrapper); + if (!raw?.enabled) { + return; + } + wrapper.dataset.tdvInteractionBooted = '1'; + + const interaction: InteractionSettings = normalizeInteraction(raw); + const scorm: ScormSettings = normalizeScorm(raw.scorm); + const hooks = buildInteractionHooks(wrapper, raw); + setupScormScoring(wrapper, interaction, scorm, hooks); + + const runtime = publishViewerRuntime(); + const type = wrapper.dataset.modelType || detectModelType(wrapper.dataset.modelSrc ?? ''); + + if (type === 'stl') { + const instance = await waitForStlMesh(wrapper, timeoutMs); + if (!instance?.mesh) { + // No mesh means no WebGL or a failed load: expose the text list. + revealFallback(wrapper, true); + return; + } + instance.interaction = runtime.createInteractionLayer( + { wrapper, type: 'stl', instance }, + interaction, + 'view', + hooks, + ); + return; + } + + const modelViewer = wrapper.querySelector('model-viewer'); + runtime.createInteractionLayer({ wrapper, type, modelViewer }, interaction, 'view', hooks); +} + +/** Every 3D Viewer wrapper reachable from an iDevice scope. */ +export function findWrappers(ideviceId: string): HTMLElement[] { + const selector = '.three-d-viewer-wrapper[data-three-d]'; + let scope: ParentNode = document; + if (ideviceId) { + scope = + document.querySelector(`.idevice_node.three-d-viewer[id="${ideviceId}"]`) ?? + document.querySelector(`[idevice-id="${ideviceId}"]`) ?? + document.getElementById(ideviceId) ?? + document; + } + const scoped = Array.from(scope.querySelectorAll(selector)); + if (scoped.length > 0 || scope === document) { + return scoped; + } + return Array.from(document.querySelectorAll(selector)); +} + +/** + * Strip a stale `src` from any `` inside an STL wrapper, BEFORE + * the custom element is defined. The moment model-viewer upgrades it would + * fetch that URL and route the ASCII STL bytes through its GLB/GLTF/USDZ + * loaders, throwing on the `COLOR=` header. + */ +export function stripStlModelViewerSrc(wrapper: HTMLElement): void { + const modelViewer = wrapper.querySelector('model-viewer'); + if (!modelViewer) { + return; + } + const data = wrapper.dataset; + const isStl = + data.modelType === 'stl' || + isStlSource(data.modelSrc ?? '') || + isStlSource(modelViewer.getAttribute('src') ?? ''); + if (isStl) { + modelViewer.removeAttribute('src'); + } +} + +/** Boot every wrapper of an iDevice: viewers first, interaction layers after. */ +export function bootWrappers(ideviceId: string): boolean { + const wrappers = findWrappers(ideviceId); + if (wrappers.length === 0) { + return true; + } + + wrappers.forEach(migrateLegacyConfig); + wrappers.forEach(stripStlModelViewerSrc); + + const modelViewerCandidates = [getExportModelViewerUrl()]; + const resourcesBase = getIdeviceResourcesBase(ideviceId); + if (resourcesBase) { + modelViewerCandidates.push(`${resourcesBase}model-viewer.min.js`); + } + + void ensureModelViewerLoaded(modelViewerCandidates, 'export').then(() => { + for (const wrapper of wrappers) { + if (wrapper.dataset.threedBooted === '1') { + continue; + } + wrapper.dataset.threedBooted = '1'; + void new ThreeDViewerController(wrapper, resolveBootConfig(wrapper)).start(); + } + }); + + const interactive = wrappers.filter(wrapper => parseInteractionData(wrapper)?.enabled); + if (interactive.length > 0) { + // The STL path needs Three.js before markers can project; the GLB path + // does not, and `ensureThreeJsLoaded` is a no-op once it is loaded. + const needsThree = interactive.some(wrapper => wrapper.dataset.modelType === 'stl'); + const ready = needsThree ? ensureThreeJsLoaded(getExportLibBaseUrl()) : Promise.resolve(); + void ready + .then(() => Promise.all(interactive.map(attachInteractionLayer))) + .catch(() => { + for (const wrapper of interactive) { + revealFallback(wrapper, true); + } + }); + } + return true; +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/export/i18n.ts b/public/files/perm/idevices/base/three-d-viewer/src/export/i18n.ts new file mode 100644 index 000000000..2fb1afb43 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/export/i18n.ts @@ -0,0 +1,72 @@ +/** + * Translation helpers for the exported page. + * + * Two vocabularies exist. Chrome strings (`viewer.*`) are translated through + * the workarea GUI translator with a built-in English fallback, because an + * exported package may run without any translator at all. Learner-facing + * micro-strings go through the CONTENT translator (`c_`) so they follow the + * language of the content, not the language of the authoring UI. + */ + +/** English fallbacks for the chrome strings, used when no translator answers. */ +export const FALLBACK_TRANSLATIONS: Readonly> = { + 'viewer.empty_state': 'Select a 3D model to display', + 'viewer.animation_paused': 'Animation paused', + 'viewer.animation_enabled': 'Animation enabled', + 'viewer.local_warning_title': '3D Viewer not available', + 'viewer.local_warning_message': + 'The 3D viewer requires a web server to work. Open this content from a web server or use eXeLearning preview.', + 'viewer.fullscreen': 'Fullscreen', + 'viewer.exit_fullscreen': 'Exit fullscreen', + 'viewer.rotate_left': 'Rotate left', + 'viewer.rotate_right': 'Rotate right', + 'viewer.tilt_up': 'Tilt up', + 'viewer.tilt_down': 'Tilt down', +}; + +/** Translate a `viewer.*` chrome string. */ +export function translate(key: string): string { + try { + const translator = globalThis._; + if (typeof translator === 'function') { + const translated = translator(key); + if (translated && translated !== key) { + return translated; + } + } + } catch { + // A broken translator must never stop the viewer from rendering. + } + return FALLBACK_TRANSLATIONS[key] ?? key; +} + +/** Translate a learner-facing string, preferring the content translator. */ +export function translateContent(text: string): string { + if (typeof globalThis.c_ === 'function') { + return globalThis.c_(text); + } + if (typeof globalThis._ === 'function') { + return globalThis._(text); + } + return text; +} + +/** The micro-strings the interaction controller needs, baked into the export. */ +export function buildRuntimeI18n(): Record { + const keys = [ + 'Marker', + 'Close', + 'Check', + 'Correct', + 'Incorrect', + 'Previous', + 'Next', + 'Please select an answer', + 'No attempts left', + ]; + const map: Record = {}; + for (const key of keys) { + map[key] = translateContent(key); + } + return map; +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/export/index.ts b/public/files/perm/idevices/base/three-d-viewer/src/export/index.ts new file mode 100644 index 000000000..b7a5466c5 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/export/index.ts @@ -0,0 +1,31 @@ +/** + * Export entry point. Compiled by `scripts/build-idevices.ts` into + * `export/three-d-viewer.js` — a self-contained classic-script IIFE. + * + * It publishes three globals the engine and the existing tests rely on: + * + * window.$threedviewer the render/boot contract + * window.ThreeDViewerExportObject the serialization helper class + * window.eXe3DViewer the shared viewer runtime + * + * The globals are ASSIGNED explicitly rather than left to the bundler's + * `globalName`, so the contract is visible in the source and survives any + * change of bundling strategy. + */ + +import { publishViewerRuntime } from '../runtime/viewer-runtime'; +import { createExportRuntime, ThreeDViewerExportObject } from './runtime'; + +const runtime = createExportRuntime(); + +// The shared viewer runtime is published idempotently: the first bundle on the +// page owns the single instance registry, any later one reuses it. +publishViewerRuntime(); + +(globalThis as { $threedviewer?: unknown }).$threedviewer = runtime; +(globalThis as { ThreeDViewerExportObject?: unknown }).ThreeDViewerExportObject = ThreeDViewerExportObject; + +if (typeof window !== 'undefined') { + window.$threedviewer = runtime; + window.ThreeDViewerExportObject = ThreeDViewerExportObject; +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.spec.ts new file mode 100644 index 000000000..37c6d5122 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.spec.ts @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { makeInteraction, sequentialIds } from '../test/helpers'; +import { normalizeScorm } from '../shared/schema'; +import type { ScormSettings, ViewerDisplayConfig } from '../shared/types'; +import { + buildControlsMarkup, + buildInteractionFallback, + buildInteractionMarkup, + buildModelMarkup, + buildViewerMarkup, + buildWrapperAttributes, + computeEmptyStateDisplay, +} from './renderer'; + +const NO_SCORM: ScormSettings = normalizeScorm(undefined); + +function config(overrides: Partial = {}): ViewerDisplayConfig { + return { + src: 'asset://a.glb', + type: 'glb', + alt: '', + modelColor: '#888888', + backgroundColor: '#f5f5f5', + cameraControls: true, + autoRotate: false, + autoRotateSpeed: 30, + showNavControls: false, + animation: { enabled: false, name: '', speed: 1 }, + ...overrides, + }; +} + +afterEach(() => { + globalThis.eXeLearning = undefined; +}); + +describe('buildModelMarkup', () => { + it('emits a WITHOUT a src, which the runtime sets at boot', () => { + const markup = buildModelMarkup(config()); + expect(markup).toContain(' { + const markup = buildModelMarkup(config({ alt: 'A cube' })); + expect(markup).toContain('alt="A cube"'); + expect(markup).toContain('aria-label="A cube"'); + }); + + it('adds the auto-rotation attributes only when enabled', () => { + expect(buildModelMarkup(config({ autoRotate: true, autoRotateSpeed: 45 }))).toContain( + 'rotation-per-second="45deg"', + ); + expect(buildModelMarkup(config())).not.toContain('auto-rotate'); + }); + + it('escapes the alt text so it cannot break out of the attribute', () => { + expect(buildModelMarkup(config({ alt: '">' }))).not.toContain('' } } }], + }, + sequentialIds(), + ); + const markup = buildInteractionMarkup(interaction, NO_SCORM); + const json = markup.substring(markup.indexOf('>') + 1, markup.indexOf('')); + expect(json).not.toContain(''); + expect(JSON.parse(json)).toBeTruthy(); + }); + + it('keeps asset:// media in the block for the export rewriter, and never blob:', () => { + const interaction = makeInteraction( + { + enabled: true, + markers: [ + { id: 'm1', action: { type: 'image', payload: { src: 'asset://pic.png' } } }, + { id: 'm2', action: { type: 'image', payload: { src: 'blob:http://x/1' } } }, + ], + }, + sequentialIds(), + ); + const markup = buildInteractionMarkup(interaction, NO_SCORM); + expect(markup).toContain('asset://pic.png'); + expect(markup).not.toContain('blob:'); + }); + + it('bakes the runtime i18n map and the SCORM configuration into the block', () => { + const interaction = makeInteraction({ enabled: true, markers: [{ id: 'm1' }] }, sequentialIds()); + const markup = buildInteractionMarkup(interaction, normalizeScorm({ mode: 2, weighted: 70 })); + const json = JSON.parse(markup.substring(markup.indexOf('>') + 1, markup.indexOf(''))) as { + i18n: Record; + scorm: ScormSettings; + }; + expect(json.i18n.Check).toBe('Check'); + expect(json.scorm).toEqual({ mode: 2, weighted: 70, saveButtonText: '' }); + }); + + it('renders the guided controls only in guided mode', () => { + const guided = makeInteraction({ enabled: true, guidedMode: true, markers: [{ id: 'm1' }] }, sequentialIds()); + expect(buildInteractionMarkup(guided, NO_SCORM)).toContain('tdv-guided-nav'); + const plain = makeInteraction({ enabled: true, markers: [{ id: 'm1' }] }, sequentialIds()); + expect(buildInteractionMarkup(plain, NO_SCORM)).not.toContain('tdv-guided-nav'); + }); +}); + +describe('buildInteractionFallback', () => { + it('lists every marker with escaped content', () => { + const interaction = makeInteraction( + { + enabled: true, + markers: [ + { + id: 'm1', + label: 'Summit', + description: 'The top', + action: { type: 'information', payload: { html: '

Highest point

' } }, + }, + { id: 'm2', action: { type: 'image', payload: { alt: 'Alt', caption: 'Cap' } } }, + { id: 'm3', action: { type: 'link', payload: { url: 'https://example.org' } } }, + { + id: 'm4', + action: { + type: 'question', + payload: { prompt: 'Q?', options: [{ text: 'A' }, { text: 'B' }] }, + }, + }, + ], + }, + sequentialIds(), + ); + const html = buildInteractionFallback(interaction); + expect(html).toContain('1. Summit'); + expect(html).toContain('The top'); + expect(html).toContain('Highest point'); + expect(html).toContain('Alt'); + expect(html).toContain('Cap'); + expect(html).toContain('rel="noopener noreferrer"'); + expect(html).toContain('Q?'); + expect(html).toContain('
  • A
  • '); + expect(html).toContain('hidden'); + }); + + it('never emits an executable link', () => { + const interaction = makeInteraction( + { enabled: true, markers: [{ id: 'm1', action: { type: 'link', payload: { url: 'javascript:x()' } } }] }, + sequentialIds(), + ); + expect(buildInteractionFallback(interaction)).not.toContain('javascript:'); + }); + + it('escapes marker text rather than embedding it as markup', () => { + const interaction = makeInteraction( + { enabled: true, markers: [{ id: 'm1', label: '' }] }, + sequentialIds(), + ); + const html = buildInteractionFallback(interaction); + expect(html).not.toContain(' { + it('assembles the wrapper with the live region, the empty state and the model', () => { + const markup = buildViewerMarkup({ + viewerId: 'idev-1', + config: config(), + interaction: makeInteraction({}, sequentialIds()), + scorm: NO_SCORM, + }); + expect(markup).toContain('class="three-d-viewer-wrapper"'); + expect(markup).toContain('id="idev-1"'); + expect(markup).toContain('data-live aria-live="polite"'); + expect(markup).toContain('data-empty'); + expect(markup).toContain(' { + it('hides the overlay for every kind of configured source', () => { + for (const src of ['asset://a.glb', 'content/resources/a.glb', 'blob:http://x/1', 'https://x/a.glb']) { + expect(computeEmptyStateDisplay(src, '')).toBe('none'); + } + }); + + it('hides the overlay once model-viewer resolved a source', () => { + expect(computeEmptyStateDisplay('', 'blob:http://x/1')).toBe('none'); + }); + + it('shows the overlay when nothing is configured', () => { + expect(computeEmptyStateDisplay('', '')).toBe('grid'); + expect(computeEmptyStateDisplay(' ', ' ')).toBe('grid'); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.ts b/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.ts new file mode 100644 index 000000000..276e444a6 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.ts @@ -0,0 +1,217 @@ +/** + * Static markup generation for the exported page. + * + * Everything here is a pure string builder: the same input always produces the + * same HTML, which is what makes `renderView` testable without a DOM. + * + * Two deliberate choices carry over from the interaction design: + * + * - The `` element ships WITHOUT a `src`. The runtime sets it at + * boot from `data-model-src`, so an STL never reaches model-viewer's GLB + * loader and no `blob:` URL is ever persisted into the saved HTML. + * - Interaction state travels as an escaped JSON `' } }, + }, + ]); + controller.focusMarker('m1'); + const dialog = wrapper.querySelector('.tdv-dialog'); + expect(dialog?.getAttribute('role')).toBe('dialog'); + expect(dialog?.getAttribute('aria-modal')).toBe('true'); + expect(dialog?.getAttribute('aria-label')).toBe('Summit'); + expect(wrapper.querySelector('.tdv-dialog-html')?.innerHTML).toBe('

    Hi

    '); + }); + + it('shows the marker description above the action content', () => { + const { wrapper, controller } = build([{ id: 'm1', description: 'A short note' }]); + controller.focusMarker('m1'); + expect(wrapper.querySelector('.tdv-dialog-description')?.textContent).toBe('A short note'); + }); + + it('renders an image action with its alt text and caption', () => { + const { wrapper, controller } = build([ + { id: 'm1', action: { type: 'image', payload: { src: 'a.png', alt: 'Alt', caption: 'Cap' } } }, + ]); + controller.focusMarker('m1'); + const image = wrapper.querySelector('.tdv-dialog-figure img'); + expect(image?.getAttribute('src')).toBe('a.png'); + expect(image?.alt).toBe('Alt'); + expect(wrapper.querySelector('figcaption')?.textContent).toBe('Cap'); + }); + + it('renders a video action with controls and a poster', () => { + const { wrapper, controller } = build([ + { id: 'm1', action: { type: 'video', payload: { src: 'a.mp4', poster: 'p.png' } } }, + ]); + controller.focusMarker('m1'); + const video = wrapper.querySelector('.tdv-dialog-video'); + expect(video?.controls).toBe(true); + expect(video?.getAttribute('src')).toBe('a.mp4'); + expect(video?.poster).toBe('p.png'); + }); + + it('resolves media URLs through the host hook', () => { + const resolveMediaUrl = vi.fn((url: string) => `blob:${url}`); + const { wrapper, controller } = build( + [{ id: 'm1', action: { type: 'image', payload: { src: 'asset://a.png' } } }], + { resolveMediaUrl }, + ); + controller.focusMarker('m1'); + expect(resolveMediaUrl).toHaveBeenCalledWith('asset://a.png'); + expect(wrapper.querySelector('img')?.getAttribute('src')).toBe('blob:asset://a.png'); + }); + + it('opens a safe link in a new tab and never opens an executable one', () => { + const open = vi.spyOn(globalThis, 'open').mockImplementation(() => null); + const { wrapper, controller } = build([ + { id: 'm1', action: { type: 'link', payload: { url: 'https://example.org' } } }, + { id: 'm2', action: { type: 'link', payload: { url: 'javascript:alert(1)' } } }, + ]); + controller.focusMarker('m1'); + expect(open).toHaveBeenCalledWith('https://example.org', '_blank', 'noopener,noreferrer'); + expect(wrapper.querySelector('.tdv-dialog')).toBeNull(); + + open.mockClear(); + controller.focusMarker('m2'); + expect(open).not.toHaveBeenCalled(); + }); + + it('calls the activation hook', () => { + const onActivate = vi.fn(); + const { controller } = build([{ id: 'm1' }], { onActivate }); + controller.focusMarker('m1'); + expect(onActivate).toHaveBeenCalledWith('m1'); + }); + + it('ignores a request to focus a marker that does not exist', () => { + const { wrapper, controller } = build([{ id: 'm1' }]); + controller.focusMarker('ghost'); + expect(wrapper.querySelector('.tdv-dialog')).toBeNull(); + expect(controller.getActiveId()).toBe(''); + }); +}); + +describe('dialog accessibility', () => { + it('moves focus into the dialog and returns it on close', () => { + const { wrapper, controller } = build([{ id: 'm1' }]); + const trigger = wrapper.querySelector('.tdv-marker'); + trigger?.focus(); + controller.focusMarker('m1'); + expect(document.activeElement?.classList.contains('tdv-dialog-close')).toBe(true); + wrapper.querySelector('.tdv-dialog-close')?.click(); + expect(wrapper.querySelector('.tdv-dialog')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('closes on Escape', () => { + const { wrapper, controller } = build([{ id: 'm1' }]); + controller.focusMarker('m1'); + wrapper + .querySelector('.tdv-dialog') + ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(wrapper.querySelector('.tdv-dialog')).toBeNull(); + }); + + it('closes when the backdrop is clicked but not when the dialog itself is', () => { + const { wrapper, controller } = build([{ id: 'm1' }]); + controller.focusMarker('m1'); + wrapper.querySelector('.tdv-dialog')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(wrapper.querySelector('.tdv-dialog')).not.toBeNull(); + wrapper.querySelector('.tdv-dialog-overlay')?.dispatchEvent(new MouseEvent('click')); + expect(wrapper.querySelector('.tdv-dialog')).toBeNull(); + }); + + it('traps Tab inside the dialog', () => { + const { wrapper, controller } = build([ + { + id: 'm1', + action: { + type: 'question', + payload: { prompt: 'Q', options: [{ text: 'a', correct: true }, { text: 'b' }] }, + }, + }, + ]); + controller.focusMarker('m1'); + const dialog = wrapper.querySelector('.tdv-dialog'); + const focusable = dialog?.querySelectorAll( + 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])', + ); + const first = focusable?.[0]; + const last = focusable?.[(focusable?.length ?? 1) - 1]; + expect(first).toBeDefined(); + expect(last).toBeDefined(); + + last?.focus(); + dialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })); + expect(document.activeElement).toBe(first); + + first?.focus(); + dialog?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }), + ); + expect(document.activeElement).toBe(last); + }); + + it('opening another marker replaces the open dialog', () => { + const { wrapper, controller } = build([ + { id: 'm1', label: 'One' }, + { id: 'm2', label: 'Two' }, + ]); + controller.focusMarker('m1'); + controller.focusMarker('m2'); + expect(wrapper.querySelectorAll('.tdv-dialog')).toHaveLength(1); + expect(wrapper.querySelector('.tdv-dialog')?.getAttribute('aria-label')).toBe('Two'); + }); +}); + +describe('questions', () => { + const question = (attemptsAllowed = 0): Record => ({ + id: 'q1', + label: 'Quiz', + action: { + type: 'question', + payload: { + prompt: 'Is it a volcano?', + options: [ + { id: 'yes', text: 'Yes', correct: true }, + { id: 'no', text: 'No', correct: false }, + ], + feedbackCorrect: 'Right!', + feedbackIncorrect: 'Nope', + attemptsAllowed, + }, + }, + }); + + function answer(wrapper: HTMLElement, optionId: string): void { + const input = wrapper.querySelector(`.tdv-question input[value="${optionId}"]`); + if (input) { + input.checked = true; + } + wrapper.querySelector('.tdv-q-check')?.click(); + } + + it('renders an accessible single-choice question', () => { + const { wrapper, controller } = build([question()]); + controller.focusMarker('q1'); + expect(wrapper.querySelector('.tdv-question legend')?.textContent).toBe('Is it a volcano?'); + expect(wrapper.querySelectorAll('.tdv-question input[type="radio"]')).toHaveLength(2); + const feedback = wrapper.querySelector('.tdv-q-feedback'); + expect(feedback?.getAttribute('role')).toBe('status'); + expect(feedback?.getAttribute('aria-live')).toBe('polite'); + }); + + it('asks for an answer when nothing is selected', () => { + const { wrapper, controller } = build([question()]); + controller.focusMarker('q1'); + wrapper.querySelector('.tdv-q-check')?.click(); + expect(wrapper.querySelector('.tdv-q-feedback')?.textContent).toBe('Please select an answer'); + }); + + it('shows correct feedback and locks the question', () => { + const { wrapper, controller } = build([question()]); + controller.focusMarker('q1'); + answer(wrapper, 'yes'); + const feedback = wrapper.querySelector('.tdv-q-feedback'); + expect(feedback?.className).toContain('tdv-q-feedback--correct'); + expect(feedback?.textContent).toBe('Right!'); + expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(true); + }); + + it('shows incorrect feedback and keeps unlimited attempts open', () => { + const { wrapper, controller } = build([question()]); + controller.focusMarker('q1'); + answer(wrapper, 'no'); + expect(wrapper.querySelector('.tdv-q-feedback')?.className).toContain('tdv-q-feedback--incorrect'); + expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(false); + }); + + it('locks the question once the attempt allowance runs out', () => { + const { wrapper, controller } = build([question(1)]); + controller.focusMarker('q1'); + answer(wrapper, 'no'); + expect(wrapper.querySelector('.tdv-q-feedback')?.textContent).toContain('No attempts left'); + expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(true); + }); + + it('keeps attempts exhausted after closing and reopening the marker', () => { + const { wrapper, controller } = build([question(1)]); + controller.focusMarker('q1'); + answer(wrapper, 'no'); + wrapper.querySelector('.tdv-dialog-close')?.click(); + + controller.focusMarker('q1'); + // The allowance applies to the marker for the whole session, so the + // reopened dialog must not hand out a fresh attempt. + expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(true); + expect(wrapper.querySelector('.tdv-q-feedback')?.textContent).toContain('No attempts left'); + expect(wrapper.querySelectorAll('.tdv-question input')[0]?.disabled).toBe(true); + }); + + it('keeps a resolved question resolved after reopening, and restores the choice', () => { + const { wrapper, controller } = build([question(2)]); + controller.focusMarker('q1'); + answer(wrapper, 'yes'); + wrapper.querySelector('.tdv-dialog-close')?.click(); + + controller.focusMarker('q1'); + expect(wrapper.querySelector('.tdv-q-feedback')?.className).toContain('tdv-q-feedback--correct'); + expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(true); + expect(wrapper.querySelector('.tdv-question input[value="yes"]')?.checked).toBe(true); + }); + + it('counts attempts across reopens rather than restarting them', () => { + const { wrapper, controller } = build([question(2)]); + controller.focusMarker('q1'); + answer(wrapper, 'no'); + wrapper.querySelector('.tdv-dialog-close')?.click(); + + controller.focusMarker('q1'); + expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(false); + answer(wrapper, 'no'); + expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(true); + }); + + it('reports every graded answer to the host', () => { + const onQuestionAnswered = vi.fn(); + const { wrapper, controller } = build([question()], { onQuestionAnswered }); + controller.focusMarker('q1'); + answer(wrapper, 'no'); + expect(onQuestionAnswered).toHaveBeenCalledWith('q1', false); + answer(wrapper, 'yes'); + expect(onQuestionAnswered).toHaveBeenLastCalledWith('q1', true); + }); + + it('survives a host hook that throws', () => { + const { wrapper, controller } = build([question()], { + onQuestionAnswered: () => { + throw new Error('transport down'); + }, + }); + controller.focusMarker('q1'); + expect(() => answer(wrapper, 'yes')).not.toThrow(); + expect(wrapper.querySelector('.tdv-q-feedback')?.className).toContain('tdv-q-feedback--correct'); + }); + + it('forgets the answers of a marker the author deleted', () => { + const { wrapper, controller } = build([question(1)]); + controller.focusMarker('q1'); + answer(wrapper, 'no'); + wrapper.querySelector('.tdv-dialog-close')?.click(); + + controller.setState(interactionWith([])); + controller.setState(interactionWith([question(1)])); + controller.focusMarker('q1'); + expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(false); + }); +}); + +describe('guided navigation', () => { + const three = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + + it('steps forward and backward through the markers', () => { + const { controller } = build(three, {}, { guidedMode: true }); + controller.next(); + expect(controller.getActiveId()).toBe('a'); + controller.next(); + expect(controller.getActiveId()).toBe('b'); + controller.previous(); + expect(controller.getActiveId()).toBe('a'); + }); + + it('stops at the ends without wrapping', () => { + const { controller } = build(three, {}, { guidedMode: true }); + controller.previous(); + expect(controller.getActiveId()).toBe('c'); + controller.next(); + expect(controller.getActiveId()).toBe('c'); + }); + + it('wraps when wrapping is enabled', () => { + const { controller } = build(three, {}, { guidedMode: true, wrapNavigation: true }); + controller.previous(); + expect(controller.getActiveId()).toBe('c'); + controller.next(); + expect(controller.getActiveId()).toBe('a'); + }); + + it('updates the live status as it moves', () => { + const { wrapper, controller } = build(three, {}, { guidedMode: true }); + controller.next(); + expect(wrapper.querySelector('.tdv-guided-status')?.textContent).toBe('Marker 1 / 3'); + controller.next(); + expect(wrapper.querySelector('.tdv-guided-status')?.textContent).toBe('Marker 2 / 3'); + }); + + it('advances exactly one step per click even after repeated setState', () => { + const { wrapper, controller } = build(three, {}, { guidedMode: true }); + controller.setState(interactionWith(three, { guidedMode: true })); + controller.setState(interactionWith(three, { guidedMode: true })); + wrapper.querySelector('.tdv-nav-next')?.click(); + expect(controller.getActiveId()).toBe('a'); + }); + + it('does nothing when there are no markers', () => { + const { controller } = build([], {}, { guidedMode: true }); + controller.next(); + expect(controller.getActiveId()).toBe(''); + }); +}); + +describe('state changes and the fallback', () => { + it('re-renders markers when the state changes', () => { + const { wrapper, controller } = build([{ id: 'a' }]); + expect(wrapper.querySelectorAll('.tdv-marker')).toHaveLength(1); + controller.setState(interactionWith([{ id: 'a' }, { id: 'b' }])); + expect(wrapper.querySelectorAll('.tdv-marker')).toHaveLength(2); + }); + + it('clears the active marker when the state drops it', () => { + const { controller } = build([{ id: 'a' }, { id: 'b' }]); + controller.focusMarker('b'); + expect(controller.getActiveId()).toBe('b'); + controller.setState(interactionWith([{ id: 'a' }])); + expect(controller.getActiveId()).toBe(''); + }); + + it('keeps the text fallback hidden when WebGL is available', () => { + const wrapper = createWrapper(); + wrapper.innerHTML = ''; + const modelViewer = createModelViewerStub(wrapper); + createInteractionController({ wrapper, type: 'glb', modelViewer }, interactionWith([{ id: 'a' }]), 'view', { + t: key => key, + }); + expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(true); + }); + + it('reveals the text fallback when WebGL is unavailable', () => { + globalThis.__tdvForceWebGL = false; + const wrapper = createWrapper(); + wrapper.innerHTML = ''; + const modelViewer = createModelViewerStub(wrapper); + createInteractionController({ wrapper, type: 'glb', modelViewer }, interactionWith([{ id: 'a' }]), 'view', { + t: key => key, + }); + expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(false); + }); + + it('reveals the text fallback when no adapter could be built', () => { + const wrapper = createWrapper(); + wrapper.innerHTML = ''; + createInteractionController({ wrapper, type: 'unknown' }, interactionWith([{ id: 'a' }]), 'view', { + t: key => key, + }); + expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(false); + }); +}); + +describe('placement mode', () => { + it('enters placement mode in edit mode and reports the anchor', () => { + const { wrapper, modelViewer } = mountModelViewer(); + const onPlaced = vi.fn(); + const controller = createInteractionController( + { wrapper, type: 'glb', modelViewer }, + interactionWith([]), + 'edit', + { t: key => key, onPlaced }, + ); + controller.enterPlacementMode(); + expect(wrapper.classList.contains('tdv-placing')).toBe(true); + modelViewer.dispatchEvent(new MouseEvent('click')); + expect(onPlaced).toHaveBeenCalledTimes(1); + expect(wrapper.classList.contains('tdv-placing')).toBe(false); + }); + + it('never enters placement mode on a learner page', () => { + const { wrapper, modelViewer, controller } = build([]); + const onPlaced = vi.fn(); + controller.enterPlacementMode(); + expect(wrapper.classList.contains('tdv-placing')).toBe(false); + modelViewer.dispatchEvent(new MouseEvent('click')); + expect(onPlaced).not.toHaveBeenCalled(); + }); +}); + +describe('camera capture and teardown', () => { + it('delegates camera capture to the adapter', () => { + const { controller } = build([{ id: 'a' }]); + expect(controller.captureCamera()).toEqual({ orbit: '1rad 2rad 3m', target: '0m 0m 0m', fieldOfView: '40deg' }); + }); + + it('returns an empty camera view when there is no adapter', () => { + const wrapper = createWrapper(); + const controller = createInteractionController({ wrapper, type: 'unknown' }, interactionWith([]), 'view'); + expect(controller.captureCamera()).toEqual({ orbit: '', target: '', fieldOfView: '' }); + }); + + it('removes markers, the dialog and the nav controls on destroy', () => { + const { wrapper, controller } = build([{ id: 'a' }], {}, { guidedMode: true }); + controller.focusMarker('a'); + expect(wrapper.querySelector('.tdv-dialog')).not.toBeNull(); + controller.destroy(); + expect(wrapper.querySelector('.tdv-dialog')).toBeNull(); + expect(wrapper.querySelector('.tdv-marker')).toBeNull(); + expect(wrapper.querySelector('.tdv-guided-nav')).toBeNull(); + // Destroying twice, and rendering afterwards, are both no-ops. + expect(() => controller.destroy()).not.toThrow(); + controller.render(); + expect(wrapper.querySelector('.tdv-marker')).toBeNull(); + }); + + it('keeps two controllers on one page isolated', () => { + const first = build([{ id: 'a' }, { id: 'b' }], {}, { guidedMode: true }); + const second = build([{ id: 'c' }], {}, { guidedMode: true }); + first.controller.next(); + expect(first.controller.getActiveId()).toBe('a'); + expect(second.controller.getActiveId()).toBe(''); + first.controller.destroy(); + expect(second.wrapper.querySelectorAll('.tdv-marker')).toHaveLength(1); + }); +}); + +describe('the STL render path', () => { + it('builds the STL adapter from a viewer instance', () => { + const wrapper = createWrapper(); + const instance = createStubInstance(wrapper); + const controller = createInteractionController( + { wrapper, type: 'stl', instance }, + interactionWith([{ id: 'a', label: 'STL marker' }]), + 'view', + { t: key => key }, + ); + expect(wrapper.querySelector('.tdv-marker--stl')?.getAttribute('aria-label')).toBe('STL marker'); + controller.destroy(); + expect(wrapper.querySelector('.tdv-marker-layer')).toBeNull(); + }); +}); + +describe('marker labels', () => { + it('falls back to a numbered label when the marker has none', () => { + const { wrapper } = build([{ id: 'a' }, { id: 'b', label: 'Named' }]); + const labels = [...wrapper.querySelectorAll('.tdv-marker')].map(node => node.getAttribute('aria-label')); + expect(labels).toEqual(['Marker 1', 'Named']); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/controller.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/controller.ts new file mode 100644 index 000000000..73dae520a --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/controller.ts @@ -0,0 +1,285 @@ +/** + * The renderer-agnostic interaction controller. + * + * It owns everything that must behave identically on both render paths and in + * both hosts (editor preview and exported page): active-marker state, marker + * activation, the accessible dialog, question rendering, learner answer state, + * guided navigation and the text fallback. Renderers are reached only through + * the `MarkerAdapter` contract. + */ + +import { createModelViewerAdapter } from '../adapters/model-viewer-adapter'; +import { createStlAdapter } from '../adapters/stl-adapter'; +import { resolveMediaUrlSync } from '../runtime/asset-resolver'; +import type { ViewerInstance } from '../runtime/types'; +import { sanitizeHtml as defaultSanitizeHtml } from '../shared/html'; +import type { InteractionSettings, Marker, MarkerCamera } from '../shared/types'; +import { safeUrl } from '../shared/urls'; +import { openDialog, type DialogHandle } from './dialog'; +import { hasWebGL, revealFallback } from './fallback'; +import { createGuidedNavigation, resolveStepIndex, type GuidedNavigationView } from './guided-navigation'; +import { renderQuestion } from './question'; +import { createAnswerStore } from './state'; +import type { + InteractionController, + InteractionHandle, + InteractionHooks, + InteractionMode, + MarkerAdapter, +} from './types'; + +const EMPTY_CAMERA: MarkerCamera = { orbit: '', target: '', fieldOfView: '' }; + +function emptyState(): InteractionSettings { + return { + enabled: false, + guidedMode: false, + wrapNavigation: false, + showMarkerLabels: true, + activeMarkerId: '', + markers: [], + }; +} + +function buildActionBody( + body: HTMLElement, + marker: Marker, + deps: { sanitize: (html: string) => string; resolveMedia: (url: string) => string }, +): void { + if (marker.description) { + const description = document.createElement('p'); + description.className = 'tdv-dialog-description'; + description.textContent = marker.description; + body.appendChild(description); + } + const action = marker.action; + switch (action.type) { + case 'information': { + const container = document.createElement('div'); + container.className = 'tdv-dialog-html'; + container.innerHTML = deps.sanitize(action.payload.html); + body.appendChild(container); + return; + } + case 'image': { + const figure = document.createElement('figure'); + figure.className = 'tdv-dialog-figure'; + const image = document.createElement('img'); + image.src = deps.resolveMedia(action.payload.src); + image.alt = action.payload.alt; + figure.appendChild(image); + if (action.payload.caption) { + const caption = document.createElement('figcaption'); + caption.textContent = action.payload.caption; + figure.appendChild(caption); + } + body.appendChild(figure); + return; + } + case 'video': { + const video = document.createElement('video'); + video.className = 'tdv-dialog-video'; + video.controls = true; + video.src = deps.resolveMedia(action.payload.src); + if (action.payload.poster) { + video.poster = deps.resolveMedia(action.payload.poster); + } + body.appendChild(video); + return; + } + case 'link': + case 'question': + // `link` never opens a dialog (handled before this runs) and + // `question` is rendered by the caller, which owns answer state. + return; + } +} + +/** + * Build a controller for one viewer. + * + * `handle.type` selects the adapter; when no adapter can be built (an unknown + * renderer, or an STL instance that never produced a mesh) the controller still + * works — it simply reveals the accessible text fallback instead of an overlay. + */ +export function createInteractionController( + handle: InteractionHandle, + interaction: InteractionSettings, + mode: InteractionMode, + hooks: InteractionHooks = {}, +): InteractionController { + const wrapper = handle.wrapper; + const translate = hooks.t ?? ((key: string) => key); + const resolveMedia = hooks.resolveMediaUrl ?? (url => resolveMediaUrlSync(url)); + const sanitize = hooks.sanitizeHtml ?? defaultSanitizeHtml; + const answers = createAnswerStore(); + + let state: InteractionSettings = interaction ?? emptyState(); + let markers: readonly Marker[] = state.markers; + let activeId = ''; + let destroyed = false; + let dialog: DialogHandle | null = null; + let adapter: MarkerAdapter | null = null; + let guided: GuidedNavigationView | null = null; + + const markerLabel = (marker: Marker, index: number): string => + marker.label || `${translate('Marker')} ${index + 1}`; + + const closeDialog = (): void => { + dialog?.close(); + dialog = null; + }; + + const currentIndex = (): number => markers.findIndex(marker => marker.id === activeId); + + const updateGuided = (): void => { + guided?.update({ + enabled: Boolean(state.guidedMode), + index: currentIndex(), + total: markers.length, + wrap: Boolean(state.wrapNavigation), + }); + }; + + const setActive = (markerId: string): void => { + activeId = markerId; + adapter?.setActive(activeId); + updateGuided(); + }; + + const activateMarker = (marker: Marker, index: number): void => { + if (marker.action.type === 'link') { + const url = safeUrl(marker.action.payload.url); + if (!url) { + return; + } + if (marker.action.payload.newTab) { + globalThis.open(url, '_blank', 'noopener,noreferrer'); + } else if (globalThis.location) { + globalThis.location.href = url; + } + return; + } + closeDialog(); + dialog = openDialog( + { + title: markerLabel(marker, index), + closeLabel: translate('Close'), + host: wrapper ?? null, + onClose: () => { + dialog = null; + }, + }, + body => { + buildActionBody(body, marker, { sanitize, resolveMedia }); + if (marker.action.type === 'question') { + renderQuestion(body, marker, { + answers, + t: translate, + onAnswered: hooks.onQuestionAnswered, + }); + } + }, + ); + hooks.onActivate?.(marker.id); + }; + + const focusMarker = (markerId: string): void => { + const index = markers.findIndex(marker => marker.id === markerId); + const marker = markers[index]; + if (!marker) { + return; + } + setActive(markerId); + adapter?.focusMarker(marker); + activateMarker(marker, index); + }; + + const go = (delta: number): void => { + const next = resolveStepIndex(currentIndex(), delta, markers.length, Boolean(state.wrapNavigation)); + const marker = next === null ? undefined : markers[next]; + if (marker) { + focusMarker(marker.id); + } + }; + + const render = (): void => { + if (destroyed) { + return; + } + markers = state.markers; + if (adapter) { + adapter.renderMarkers(markers, { + showLabels: state.showMarkerLabels !== false, + activeId, + }); + // Keep the text fallback visible without WebGL so assistive-tech and + // no-WebGL users still reach the marker content. + revealFallback(wrapper, !hasWebGL()); + } else { + revealFallback(wrapper, true); + } + updateGuided(); + }; + + const controller: InteractionController = { + setState(next) { + state = next ?? emptyState(); + const ids = state.markers.map(marker => marker.id); + if (activeId && !ids.includes(activeId)) { + activeId = ''; + } + // Answers of deleted markers are dropped so a re-created marker with + // a fresh id starts clean. + answers.retain(ids); + render(); + }, + render, + enterPlacementMode() { + // Placement is an authoring affordance; a learner page never enters it. + if (!adapter || mode !== 'edit') { + return; + } + wrapper?.classList.add('tdv-placing'); + adapter.enterPlacementMode(placement => { + controller.exitPlacementMode(); + hooks.onPlaced?.(placement); + }); + }, + exitPlacementMode() { + wrapper?.classList.remove('tdv-placing'); + adapter?.exitPlacementMode(); + }, + focusMarker, + captureCamera: () => adapter?.captureCamera() ?? { ...EMPTY_CAMERA }, + next: () => go(1), + previous: () => go(-1), + getActiveId: () => activeId, + markerLabel, + destroy() { + if (destroyed) { + return; + } + destroyed = true; + controller.exitPlacementMode(); + closeDialog(); + guided?.destroy(); + guided = null; + adapter?.destroy(); + adapter = null; + answers.clear(); + }, + }; + + const adapterDeps = { markerLabel, onActivate: focusMarker }; + if ((handle.type === 'glb' || handle.type === 'gltf') && handle.modelViewer) { + adapter = createModelViewerAdapter(handle.modelViewer, adapterDeps); + } else if (handle.type === 'stl' && handle.instance) { + adapter = createStlAdapter(handle.instance as ViewerInstance, wrapper, adapterDeps); + } + + guided = createGuidedNavigation(wrapper ?? null, { t: translate, onGo: go }); + + render(); + return controller; +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/dialog.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/dialog.ts new file mode 100644 index 000000000..e62abcf96 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/dialog.ts @@ -0,0 +1,129 @@ +/** + * The accessible marker dialog: a modal with a focus trap, Escape handling and + * focus return. One dialog is open at a time per controller. + */ + +const FOCUSABLE_SELECTOR = + 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; + +/** Focusable descendants that are actually reachable right now. */ +export function getFocusable(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + element => element.offsetParent !== null || element === document.activeElement, + ); +} + +export interface DialogHandle { + readonly overlay: HTMLElement; + readonly dialog: HTMLElement; + readonly body: HTMLElement; + close(): void; +} + +export interface DialogOptions { + title: string; + closeLabel: string; + /** Where the overlay is appended; defaults to `document.body`. */ + host?: HTMLElement | null; + onClose?: () => void; +} + +/** + * Open a modal dialog and fill its body through `buildBody`. + * + * Returns a handle whose `close()` removes the dialog and restores focus to + * whatever had it before. The caller owns the handle and must close it on + * teardown; nothing here registers a global listener. + */ +export function openDialog(options: DialogOptions, buildBody: (body: HTMLElement) => void): DialogHandle { + const previouslyFocused = document.activeElement; + + const overlay = document.createElement('div'); + overlay.className = 'tdv-dialog-overlay'; + + const dialog = document.createElement('div'); + dialog.className = 'tdv-dialog'; + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-label', options.title); + + const header = document.createElement('div'); + header.className = 'tdv-dialog-header'; + const heading = document.createElement('h2'); + heading.className = 'tdv-dialog-title'; + heading.textContent = options.title; + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'tdv-dialog-close'; + closeButton.setAttribute('aria-label', options.closeLabel); + closeButton.textContent = '✕'; + header.append(heading, closeButton); + + const body = document.createElement('div'); + body.className = 'tdv-dialog-body'; + + dialog.append(header, body); + overlay.appendChild(dialog); + (options.host ?? document.body).appendChild(overlay); + + buildBody(body); + + let closed = false; + const close = (): void => { + if (closed) { + return; + } + closed = true; + try { + overlay.remove(); + } catch { + // Already detached, e.g. the wrapper was replaced. + } + if (previouslyFocused instanceof HTMLElement) { + try { + previouslyFocused.focus(); + } catch { + // The previously focused node may have gone away. + } + } + options.onClose?.(); + }; + + closeButton.addEventListener('click', close); + overlay.addEventListener('click', event => { + if (event.target === overlay) { + close(); + } + }); + dialog.addEventListener('keydown', event => { + if (event.key === 'Escape') { + event.stopPropagation(); + close(); + return; + } + if (event.key !== 'Tab') { + return; + } + const focusable = getFocusable(dialog); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first || !last) { + return; + } + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }); + + try { + closeButton.focus(); + } catch { + // happy-dom and detached hosts can refuse focus; not fatal. + } + + return { overlay, dialog, body, close }; +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.spec.ts new file mode 100644 index 000000000..338ea4f66 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.spec.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createWrapper, resetDom } from '../test/helpers'; +import { hasWebGL, resetWebGLProbe, revealFallback } from './fallback'; + +beforeEach(resetWebGLProbe); + +afterEach(() => { + globalThis.__tdvForceWebGL = undefined; + resetWebGLProbe(); + resetDom(); + vi.restoreAllMocks(); +}); + +describe('hasWebGL', () => { + it('honours the deterministic test override in both directions', () => { + globalThis.__tdvForceWebGL = false; + expect(hasWebGL()).toBe(false); + globalThis.__tdvForceWebGL = true; + expect(hasWebGL()).toBe(true); + }); + + it('probes a canvas context and memoizes the answer', () => { + const getContext = vi.fn(() => ({}) as unknown as RenderingContext); + vi.spyOn(document, 'createElement').mockImplementation(() => ({ getContext }) as unknown as HTMLCanvasElement); + expect(hasWebGL()).toBe(true); + expect(hasWebGL()).toBe(true); + // Memoized: the probe runs once even across repeated calls. + expect(getContext).toHaveBeenCalledTimes(1); + }); + + it('falls back to experimental-webgl before giving up', () => { + const getContext = vi.fn((name: string) => (name === 'experimental-webgl' ? ({} as RenderingContext) : null)); + vi.spyOn(document, 'createElement').mockImplementation(() => ({ getContext }) as unknown as HTMLCanvasElement); + expect(hasWebGL()).toBe(true); + expect(getContext).toHaveBeenCalledTimes(2); + }); + + it('reports false when no context can be created', () => { + vi.spyOn(document, 'createElement').mockImplementation( + () => ({ getContext: () => null }) as unknown as HTMLCanvasElement, + ); + expect(hasWebGL()).toBe(false); + }); + + it('reports false when probing throws', () => { + vi.spyOn(document, 'createElement').mockImplementation(() => { + throw new Error('no canvas'); + }); + expect(hasWebGL()).toBe(false); + }); +}); + +describe('revealFallback', () => { + it('shows and hides the static marker list', () => { + const wrapper = createWrapper(); + wrapper.innerHTML = ''; + revealFallback(wrapper, true); + expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(false); + revealFallback(wrapper, false); + expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(true); + }); + + it('is a no-op without a wrapper or without a list', () => { + expect(() => revealFallback(null, true)).not.toThrow(); + expect(() => revealFallback(createWrapper(), true)).not.toThrow(); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.ts new file mode 100644 index 000000000..fdde1ba04 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.ts @@ -0,0 +1,52 @@ +/** + * The accessible text fallback. + * + * Exported pages ship a static, escaped `