diff --git a/.agents/skills/idevice/SKILL.md b/.agents/skills/idevice/SKILL.md index 4f52c634d8..d98e8fb46a 100644 --- a/.agents/skills/idevice/SKILL.md +++ b/.agents/skills/idevice/SKILL.md @@ -23,6 +23,25 @@ Creating or modifying interactive devices (iDevices) in `public/files/perm/idevi **Reference iDevices** (well-tested, good to study): `checklist`, `rubric`, `geogebra-activity` +## TypeScript iDevices (`src/`) + +An iDevice with a `src/` directory is a **TypeScript iDevice**: its +`edition/.js` and `export/.js` are GENERATED bundles (gitignored) +— never edit them; edit `src/` and rebuild. Convention and commands: + +- `src/edition/index.ts` → `edition/.js` (assigns `window.$exeDevice`); + `src/export/index.ts` → `export/.js` (assigns the runtime global). +- Build/typecheck: `bun run bundle:idevices` / `bun run typecheck:idevices` + (central runner `scripts/build-idevices.ts`; `--only `, `--watch`). + Run `make bundle` after src/ edits and BEFORE E2E, or the preview serves the + stale bundle from `public/bundles/idevices.zip`. +- Tests are colocated `*.spec.ts` (Vitest — `bun test` ignores `public/**`), + plus bundle-contract smoke tests over the compiled IIFEs. +- Deviations (custom bundle name, externals, minify) go in an optional + `build.config.json` — see `doc/development/idevices-typescript.md` and + ADR-2147-01. Reference implementations: `three-sixty-viewer` (full convention), + `slide` (manifest). + ## Structure ``` diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9f01c7a7a2..bc3a0f52ce 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,6 +47,9 @@ jobs: - name: Build all assets once run: bun run build:static + # The path list must include every GENERATED (gitignored) file the + # workarea serves — the test runners get a fresh checkout, so anything + # missing here 404s at runtime (e.g. TypeScript-iDevice bundles, ADR-2147-01). - name: Upload dynamic bundles (chromium/firefox) uses: actions/upload-artifact@v7 with: @@ -64,6 +67,8 @@ jobs: public/bundles/** public/style/workarea/main.css public/files/perm/idevices/base/slide/edition/slide-editor.bundle.js + public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.js + public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js - name: Upload static distribution (static project) uses: actions/upload-artifact@v7 diff --git a/.gitignore b/.gitignore index 60cc7e67fc..a1db9aedf7 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,10 @@ public/app/dist/ /app/dist/ /app/node_modules/ -# Slide iDevice — pre-built editor bundle (regenerated by package.json postinstall) +# TypeScript iDevice bundles — generated from each iDevice's src/ by scripts/build-idevices.ts /public/files/perm/idevices/base/slide/edition/slide-editor.bundle.js +/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.js +/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.js.map +/public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js +/public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js.map .omc/ diff --git a/doc/architecture/adr/ADR-2147-01-typescript-idevices-build-convention.md b/doc/architecture/adr/ADR-2147-01-typescript-idevices-build-convention.md new file mode 100644 index 0000000000..59aef6d4fe --- /dev/null +++ b/doc/architecture/adr/ADR-2147-01-typescript-idevices-build-convention.md @@ -0,0 +1,121 @@ +--- +id: ADR-2147-01 +title: "TypeScript iDevices: src/ sources compiled by one convention-based build" +status: Proposed +date: 2026-07-30 +tracking_issue: 2147 +deciders: + - "@erseco" +reviewers: + - "@mnunezcedec" + - "@cristinavaldera" +related: + prs: [2147] + changes: + - "39-three-sixty-viewer-typescript-refactor" + adrs: [] +supersedes: [] +superseded_by: [] +ai_assistance: + tool: "Claude Code" + model: "claude-fable-5" +--- + +# ADR-2147-01: TypeScript iDevices: src/ sources compiled by one convention-based build + +## Context + +iDevices are classic-script objects loaded by the workarea and the exporters. +Historically each one is hand-written vanilla JavaScript committed directly +under `edition/` and `export/`. Two iDevices now keep their maintained source +in TypeScript instead — Slide (`src/` + a bespoke `scripts/build-slide-editor.ts`) +and, with this refactor, the 360° Viewer. Per-iDevice build scripts duplicate +Bun plumbing and diverge in flags and behaviour, and every future TypeScript +iDevice would have added another copy plus more package.json entries. + +## Problem + +How does the repository recognise, build, type-check and test an iDevice whose +maintained source is TypeScript, without a new build pipeline per iDevice? + +## Decision drivers + +- One obvious convention for the next TypeScript iDevice (zero new scripts). +- The shipped output must remain plain classic-script IIFEs; the language and + compile step are not a framework. +- Generated artifacts must never be committed; a clean checkout must + regenerate them through the existing pipeline (`build:all` / `make bundle`). +- Existing iDevices with special needs (Slide) must fit without renaming their + shipped bundles. + +## Decision + +**An iDevice that keeps a `src/` directory is a TypeScript iDevice**, built by +the centralized `scripts/build-idevices.ts`: + +- **Convention:** `src/edition/index.ts` → `edition/.js` and + `src/export/index.ts` → `export/.js` — self-contained IIFEs + (`target: browser`, linked source maps, unminified), whose entry points + explicitly assign their window globals (`$exeDevice`, `$`). +- **Escape hatch:** an optional `build.config.json` next to `config.xml` + replaces the convention for that iDevice (custom entries/naming/globalName/ + minify/sourcemap, plus `externals` mapping bare imports to page-provided + globals so vendored libraries are never inlined). Slide uses it. +- **Type checking:** each TypeScript iDevice ships its own `tsconfig.json` + (strict for new code); the runner executes `tsc -p` for every one it finds. +- **Tests:** colocated `*.spec.ts` next to each module, run by **Vitest** + (`bun test` ignores `public/**`), plus bundle-contract smoke tests that + evaluate the compiled IIFEs. +- **Artifacts:** generated bundles and source maps are gitignored; + `build:all` runs `typecheck:idevices` + `bundle:idevices` before + `bundle:resources` (export bundles ship inside `idevices.zip`). + +Package scripts: `typecheck:idevices`, `bundle:idevices`, +`bundle:idevices:watch`; the runner accepts `--only ` and `--watch`. + +## Options considered + +### Option 1: One bespoke build script per TypeScript iDevice (status quo) + +Pros: each script is trivially readable. Cons: duplicated plumbing, per-iDevice +package.json entries, drift between scripts (they already differed in +sourcemaps, watch support and failure reporting). + +### Option 2: Convention-based central runner + per-iDevice manifest (chosen) + +Pros: the next TypeScript iDevice needs no build changes at all; one place to +fix bundler behaviour; deviations are declared, not programmed. Cons: one more +convention to know; the manifest is a small new format (documented in the +runner header and `doc/development/idevices-typescript.md`). + +## Consequences + +### Positive + +- Adding a TypeScript iDevice = create `src/edition|export/index.ts` (+ a + strict `tsconfig.json`); building, type-checking and watching come for free. +- Slide and the 360° Viewer share one build path; Slide's output stayed + byte-identical apart from the generic externals shim's message strings. + +### Negative + +- A hidden convention: `src/` now has meaning. Mitigated by this ADR, + `doc/development/idevices-typescript.md` and the idevice skill. + +### Neutral + +- Classic-script iDevices are untouched; nothing forces a migration. + +## Validation + +- `scripts/build-idevices.spec.ts` covers discovery, the convention, the + manifest and its validation against the real repository state. +- `bun run build:all` exercises typecheck + build for every TypeScript + iDevice on every bundle/test target. + +## References + +- `scripts/build-idevices.ts` (runner; manifest schema in its header). +- `doc/development/idevices-typescript.md` (developer guide). +- PR [#2147](https://github.com/exelearning/exelearning/pull/2147), which + introduced this convention upstream alongside the Interactive Video refactor. diff --git a/doc/architecture/changes/39-three-sixty-viewer-typescript-refactor/design.md b/doc/architecture/changes/39-three-sixty-viewer-typescript-refactor/design.md new file mode 100644 index 0000000000..7c19f42fde --- /dev/null +++ b/doc/architecture/changes/39-three-sixty-viewer-typescript-refactor/design.md @@ -0,0 +1,142 @@ +--- +tracking_issue: 39 +title: "360° Viewer iDevice: TypeScript refactor on the centralized build convention" +status: implemented +date: 2026-07-30 +authors: + - "@erseco" +reviewers: [] +implementation_prs: [39] +related_adrs: + - ADR-2147-01 +supersedes: [] +superseded_by: [] +ai_assistance: + tool: "Claude Code" + model: "Claude" +--- + +# 360° Viewer iDevice — TypeScript refactor design + +## Summary + +The 360° Viewer (`public/files/perm/idevices/base/three-sixty-viewer/`) moves +its maintained source from two hand-written classic scripts +(`edition/three-sixty-viewer.js`, `export/three-sixty-viewer.js`) to a typed, +modular `src/` tree compiled by the centralized TypeScript-iDevice build +([ADR-2147-01](../../adr/ADR-2147-01-typescript-idevices-build-convention.md)). The +generic conventions — discovery, bundling, typecheck, testing, gitignored +bundles — are documented in +[doc/development/idevices-typescript.md](../../../development/idevices-typescript.md); +this design records only what is specific to the 360° Viewer. + +## Source architecture + +```text +src/ +├── globals.d.ts # THREE / eXeLearning / _ ambient declarations +├── shared/ # pure, DOM-free logic used by BOTH bundles +│ ├── types.ts # v1/v2 document model, hotspot-action union +│ ├── schema.ts # hydrateDocument / serializeDocument +│ ├── migration.ts # v1 → v2 lift +│ ├── normalization.ts# idempotent v2 normalization + defaults +│ ├── hotspot-actions.ts # per-action normalize/serialize/validate/repair +│ ├── geometry.ts # yaw/pitch ↔ direction, letterbox math, NDC +│ ├── ids.ts, urls.ts, html.ts +├── viewer/ # browser layer shared by preview and runtime +│ ├── panorama-renderer.ts, flat-image-renderer.ts, hotspot-renderer.ts +│ ├── scene-controller.ts, controls.ts, lifecycle.ts, assets.ts, types.ts +├── edition/ # window.$exeDevice (editor) +│ ├── index.ts, device.ts, editor.ts, state.ts, form.ts +│ ├── scene-list.ts, scene-editor.ts, hotspot-list.ts, hotspot-editor.ts +│ ├── hotspot-placement.ts, preview.ts, asset-picker.ts, three-loader.ts +├── export/ # window.$threesixtyviewer (learner runtime) +│ ├── index.ts, runtime.ts, renderer.ts, instance.ts, modal.ts, actions.ts +└── test/ # THREE mock harness, bundle-contract, fixtures +``` + +Before the refactor, edition and export each carried a full copy of the state +normalization and letterbox geometry ("mirror edition/three-sixty-viewer.js" +comments in the legacy bundles). `src/shared/` is now the single source of +truth for both. + +## Persisted formats and compatibility + +- **v1** (original single-image shape: top-level `src`, `alt`, `initialView`, + `autorotate`, `zoomEnabled`, `fullscreenEnabled`, `showNavControls`) is + never written any more but remains readable; `hydrateDocument()` lifts it + into a one-scene v2 tour with nothing lost. Detection mirrors the legacy + checks exactly. +- **v2** (`version: 2`, `ideviceId`, `startSceneId`, `scenes[]`, `behaviour`) + is unchanged by this refactor: same property names, same ranges, same enum + values, same hotspot actions (`goToScene`, `text`, `image`, `video`, + `link`). The persisted `version` property stays `version: 2`. +- **Future versions** (`version > 2`) are rejected explicitly + (`status: 'unsupported-version'`): the editor shows a notice and `save()` + returns the ORIGINAL payload untouched; the runtime renders an accessible + notice. Unknown hotspot ACTION types inside a v2 document are preserved as + `{ type: 'unsupported', originalType, originalPayload }` in memory and + serialized back verbatim — opening and saving old or future content never + destroys data. + +## Runtime contracts + +- `edition/three-sixty-viewer.js` (generated) assigns + `globalThis.$exeDevice` on every evaluation — the workarea re-runs the + script for each edit session. Contract: `init(element, previousData, + idevicePath)`, `save(): document | false`, `destroy()`. +- `export/three-sixty-viewer.js` (generated) assigns + `globalThis.$threesixtyviewer` with the JSON-iDevice engine API + (`renderView` / `renderBehaviour` / `init`) used by + `public/app/common/exe_export.js`. +- three.js and OrbitControls stay EXTERNAL vendored files + (`export/three.min.js`, `export/OrbitControls.js`, declared in + `config.xml`'s ``). Neither bundle inlines them; the bundles + only dereference `THREE` when a viewer is actually built + (`renderBehaviour()` / preview construction), so bundle evaluation order + relative to the vendor scripts is not critical, and the editor lazy-loads + them (`edition/three-loader.ts`) for its preview. This is asserted by the + bundle-contract tests. + +## Lifecycle + +Every runtime viewer is one instance in a `WeakMap`-backed registry keyed by +its wrapper element. An instance owns its scene controller, panorama/flat +renderers, hotspot layer, nav/fullscreen controls, animation frame, resize +observer, drag blockers and modal; `destroy()` releases all of them (LIFO +disposer bag) and re-rendering a node disposes its predecessor first. +Multiple viewers per page never share state. The editor mirrors the same +pattern: one Editor per `init()`, destroyed on re-init. + +## Hotspot placement + +Direct placement is an additional authoring path next to list-based creation: +an explicit "Place hotspot by clicking" toggle (`aria-pressed`, visible hint, +aria-live announcements, Escape cancels), then one click on the preview. +Equirectangular scenes unproject the click through the camera to yaw/pitch +(`shared/geometry.ts` + `viewer/panorama-renderer.ts`); flat scenes convert +the click to percentages of the `object-fit: contain` rectangle, and clicks +on letterbox bars are ignored rather than snapped to an edge. Numeric fields +remain available for precise adjustment. Deleting a scene referenced by +`goToScene` hotspots asks for confirmation, states how many hotspots are +affected, and clears their targets deterministically (flagged inline until +retargeted). + +## Testing + +- Colocated `*.spec.ts` (Vitest, happy-dom) next to every module; three.js is + injected as a structural mock (`src/test/helpers.ts`), frames are stepped + manually. +- `src/test/bundle-contract.spec.ts` evaluates the real generated IIFEs and + asserts the window globals and their public APIs. +- `test/e2e/playwright/specs/idevices/three-sixty-viewer.spec.ts` covers the + authoring flows (scenes, hotspots, placement, persistence) and the bundle + contracts in a real browser. +- Fixtures for v1, v2, future-version and invalid payloads live in + `src/test/fixtures/`. + +## ADRs required or referenced + +- [ADR-2147-01](../../adr/ADR-2147-01-typescript-idevices-build-convention.md) — + TypeScript iDevices build convention (reused, no new durable decision + introduced by this refactor). diff --git a/doc/development/idevices-typescript.md b/doc/development/idevices-typescript.md new file mode 100644 index 0000000000..9107b3aed5 --- /dev/null +++ b/doc/development/idevices-typescript.md @@ -0,0 +1,97 @@ +# TypeScript iDevices + +Most iDevices are classic-script vanilla JavaScript committed directly under +`edition/` and `export/`. An iDevice whose maintained source lives in a +**`src/` directory is a TypeScript iDevice**: its shipped `edition/*.js` / +`export/*.js` files are **generated bundles** (gitignored — never edit or +commit them) compiled by the centralized build. Slide and the 360° Viewer +follow this model today. The decision record is +[ADR-2147-01](../architecture/adr/ADR-2147-01-typescript-idevices-build-convention.md). + +## The convention + +```text +public/files/perm/idevices/base// +├── config.xml # loads the GENERATED bundles by filename +├── tsconfig.json # strict, per-iDevice (noEmit; the bundler emits) +├── build.config.json # OPTIONAL — only when deviating from the convention +├── src/ +│ ├── edition/index.ts # → edition/.js (window.$exeDevice) +│ ├── export/index.ts # → export/.js (window.$) +│ └── **/*.spec.ts # colocated unit tests (Vitest) +├── edition/.js # generated IIFE + .map (gitignored) +└── export/.js # generated IIFE + .map (gitignored) +``` + +`scripts/build-idevices.ts` discovers every iDevice with a `src/` directory +and builds each existing `src/edition/index.ts` / `src/export/index.ts` into a +self-contained classic-script IIFE (browser target, linked source maps, +unminified). Entry points must assign their window globals explicitly: + +```ts +const device = createMyIdeviceEditionDevice(); +(globalThis as { $exeDevice?: unknown }).$exeDevice = device; +``` + +## Commands + +```bash +bun run typecheck:idevices # tsc -p for every per-iDevice tsconfig +bun run bundle:idevices # build every TypeScript iDevice +bun run bundle:idevices:watch # rebuild on src/ changes +bun scripts/build-idevices.ts --only # filter one iDevice +``` + +`build:all` (and therefore `make bundle` and every test target) runs the +typecheck and the build before `bundle:resources`, because export bundles ship +inside `public/bundles/idevices.zip`. **After editing `src/`, run +`make bundle` (or `bundle:idevices` + `bundle:resources`) before E2E tests**, +or the service-worker preview will serve the stale bundle from the zip. + +## Deviating from the convention + +An iDevice with special needs declares a `build.config.json` next to its +`config.xml`; it replaces the convention for that iDevice. Slide's, for +example, keeps its historical bundle name, IIFE global, minified output and +page-provided libraries: + +```json +{ + "entries": [ + { + "entry": "src/index.ts", + "outdir": "edition", + "naming": "[dir]/slide-editor.bundle.[ext]", + "globalName": "__slideEditorInit", + "minify": true, + "sourcemap": "none", + "externals": { + "fabric": "fabric", + "dompurify": { "global": "DOMPurify", "default": true } + } + } + ] +} +``` + +`externals` maps a bare import to a `window` global (vendored under +`public/libs/`) so the library is never inlined; `"default": true` also +exposes it as the module's default export. + +## Testing + +- Unit tests are **colocated `*.spec.ts`** files next to each module, run by + **Vitest** (`bun test` deliberately ignores `public/**`). Add the iDevice's + `src/**/*.spec.ts` glob to `vitest.config.mts` `include` when creating a new + TypeScript iDevice. +- Add **bundle-contract smoke tests** that evaluate the ACTUAL compiled IIFEs + and assert the window globals and their public methods — they catch bundling + problems source-level imports cannot (see + `three-sixty-viewer/src/test/bundle-contract.spec.ts`). +- Playwright coverage works on the built bundles like for any other iDevice. + +## Debugging + +Bundles ship `.js.map` source maps (excluded from resource ZIPs), so browser +stack traces map back to the TypeScript sources; use +`bundle:idevices:watch` while developing. diff --git a/mkdocs.yml b/mkdocs.yml index 9c02461aa9..dee4e25a72 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,6 +49,7 @@ nav: - Real Time: development/real-time.md - Embedding: development/embedding.md - Profiling: development/profiling.md + - TypeScript iDevices: development/idevices-typescript.md - Customization: development/customization.md - Styles: development/styles.md - Installers: development/installers.md diff --git a/package.json b/package.json index 3b6967b924..d9b6bd6d45 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev:local": "bun run --parallel start:local sass:watch", "build": "bun build src/index.ts --outdir dist --target bun --external kysely --external 'kysely/*' --external jsdom && bun build src/cli/index.ts --outfile dist/cli.js --target bun --external kysely --external 'kysely/*' --external jsdom", "build:standalone": "bun scripts/build-standalone.js", - "build:all": "bun run build && bun run --parallel css:node bundle:resources bundle:i18n bundle:app bundle:importers bundle:exporters bundle:slide-editor", + "build:all": "bun run build && bun run typecheck:idevices && bun run bundle:idevices && bun run --parallel css:node bundle:resources bundle:i18n bundle:app bundle:importers bundle:exporters", "bundle:i18n": "bun scripts/build-i18n-bundles.js", "build:static": "bun run build:all && bun scripts/build-static-bundle.ts", "css:node": "bun x sass assets/styles/main.scss public/style/workarea/main.css --style=compressed --no-source-map", @@ -22,7 +22,9 @@ "bundle:importers": "bun scripts/build-importers-bundle.js", "bundle:exporters": "bun scripts/build-exporters-bundle.js", "bundle:resources": "bun scripts/build-resource-bundles.js", - "bundle:slide-editor": "bun scripts/build-slide-editor.ts", + "typecheck:idevices": "bun scripts/build-idevices.ts --typecheck-only", + "bundle:idevices": "bun scripts/build-idevices.ts", + "bundle:idevices:watch": "bun scripts/build-idevices.ts --watch", "upload:bundles": "bun scripts/upload-bundle-analysis.js", "predev": "bun scripts/setup-local.js", "seed": "bun run src/db/seed.ts", diff --git a/public/files/perm/idevices/base/slide/build.config.json b/public/files/perm/idevices/base/slide/build.config.json new file mode 100644 index 0000000000..f3f917b778 --- /dev/null +++ b/public/files/perm/idevices/base/slide/build.config.json @@ -0,0 +1,16 @@ +{ + "entries": [ + { + "entry": "src/index.ts", + "outdir": "edition", + "naming": "[dir]/slide-editor.bundle.[ext]", + "globalName": "__slideEditorInit", + "minify": true, + "sourcemap": "none", + "externals": { + "fabric": "fabric", + "dompurify": { "global": "DOMPurify", "default": true } + } + } + ] +} diff --git a/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.css b/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.css index c5f81b67a9..9512added9 100644 --- a/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.css +++ b/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.css @@ -1,13 +1,29 @@ -/* 360° panorama viewer iDevice (edition CSS) */ +/** + * 360° panorama viewer iDevice — edition styles. + * + * Inline, responsive, accessible. Mirrors the Interactive Video authoring + * surface: section heads with counts, an edit-stage preview, an add bar and a + * single-editor accordion list (selected row expands). Colour is never the + * sole cue — badges always carry a text label. + */ .three-sixty-viewer-form { - padding: 10px; - border: 1px dashed var(--info-bordercolor, #b0b0b0); - border-radius: var(--radius, 4px); + /* Action-kind colours (badge + selected-row accent). */ + --three-sixty-go-to-scene: #0065ab; + --three-sixty-text: var(--brand-primary, #078e8e); + --three-sixty-image: #d76b4a; + --three-sixty-video: #a25ac4; + --three-sixty-link: #0b6e4f; + --three-sixty-unsupported: #777; + + padding: 0.75rem; + border: 1px solid var(--gray-200, #e5e7eb); + border-radius: var(--radius, 8px); margin-top: 0.4em; display: flex; flex-direction: column; - gap: 0.75em; + gap: 0.9rem; + background: var(--bg-light-gray, #fafafa); } .three-sixty-viewer-form .property-row { @@ -15,6 +31,7 @@ align-items: center; gap: 0.5em; flex-wrap: wrap; + margin-bottom: 0.5rem; } .three-sixty-viewer-form .property-row label.form-label { @@ -22,14 +39,29 @@ } .three-sixty-viewer-form .exe-fieldset { - padding: 0.5em 0.75em; + padding: 0.75em 1em; border: 1px solid var(--elements-hover, #d0d0d0); - border-radius: var(--radius, 4px); + border-radius: var(--radius, 8px); + background: #fff; + margin: 0; } .three-sixty-viewer-form .exe-fieldset legend { - padding: 0 0.25em; + padding: 0 0.35em; font-weight: 600; + display: inline-flex; + align-items: baseline; + gap: 0.4rem; +} + +.three-sixty-section-title { + font-size: 1rem; +} + +.three-sixty-fieldset-count { + font-size: 0.85em; + font-weight: 400; + color: var(--icon-gray, #777); } .three-sixty-viewer-form .toggle-label { @@ -50,6 +82,80 @@ flex: 1 1 auto; } +.three-sixty-viewer-form .exe-form-group { + margin-bottom: 0.65rem; + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.6rem; + align-items: center; +} + +.three-sixty-viewer-form .exe-form-group > label { + min-width: 6rem; +} + +.three-sixty-viewer-form .exe-form-group .form-control, +.three-sixty-viewer-form .exe-form-group select, +.three-sixty-viewer-form .exe-form-group textarea { + flex: 1 1 12rem; + max-width: 100%; +} + +.three-sixty-hint { + margin: 0 0 0.5rem; + color: var(--icon-gray, #555); +} + +.three-sixty-empty { + padding: 0.75rem 1rem; + border: 1px dashed #ccc; + border-radius: 0.5rem; + color: #555; + margin: 0; +} + +/* Add bar (Interactive Video pattern) */ + +.three-sixty-add-bar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin: 0.35rem 0 0.75rem; +} + +.three-sixty-add-bar .btn { + min-height: 2.5rem; +} + +.three-sixty-placement-hint { + margin: 0 0 0.5rem; + font-weight: 500; + color: var(--brand-primary, #078e8e); +} + +#threeSixtyPlaceHotspot[aria-pressed='true'], +#threeSixtyPlaceHotspot.active { + background-color: var(--brand-primary, #078e8e); + border-color: var(--brand-primary, #078e8e); + color: #fff; + box-shadow: 0 0 0 2px rgba(7, 142, 142, 0.25); +} + +/* Edit stage — preview */ + +.three-sixty-edit-stage { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-start; +} + +.three-sixty-edit-stage-main { + flex: 1 1 100%; + min-width: 0; +} + .three-sixty-viewer-preview { width: 100%; display: flex; @@ -59,11 +165,12 @@ .three-sixty-preview-stage { width: 100%; - min-height: 260px; + min-height: 280px; background-color: #111; - border-radius: var(--radius, 4px); + border-radius: var(--radius, 8px); overflow: hidden; position: relative; + border: 1px solid var(--gray-border, #dbdbdb); } .three-sixty-preview-stage canvas { @@ -86,7 +193,6 @@ cursor: crosshair !important; } -/* Flat (non-360) scene preview image. */ .three-sixty-preview-stage .three-sixty-preview-flat { position: absolute; inset: 0; @@ -151,21 +257,12 @@ } .three-sixty-preview-stage .three-sixty-viewer-hotspot--editor:hover .three-sixty-viewer-hotspot-icon, -.three-sixty-preview-stage .three-sixty-viewer-hotspot--editor:focus .three-sixty-viewer-hotspot-icon { +.three-sixty-preview-stage .three-sixty-viewer-hotspot--editor:focus .three-sixty-viewer-hotspot-icon, +.three-sixty-preview-stage .three-sixty-viewer-hotspot--editor.is-selected .three-sixty-viewer-hotspot-icon { transform: scale(1.1); background: rgba(64, 160, 220, 0.9); } -#threeSixtyPlaceHotspot.active { - background-color: var(--info-bordercolor, #4ea0d4); - color: #fff; -} - -.three-sixty-hotspot-item.is-highlighted { - box-shadow: 0 0 0 2px var(--info-bordercolor, #4ea0d4); - transition: box-shadow 0.2s ease; -} - .three-sixty-preview-stage .three-sixty-viewer-nav { position: absolute; right: 8px; @@ -207,49 +304,301 @@ .three-sixty-scene-list { display: flex; flex-direction: column; - gap: 0.25em; - margin-bottom: 0.5em; + gap: 0.35em; + margin-bottom: 0.35em; } .three-sixty-scene-item { display: flex; - align-items: center; - gap: 0.5em; - padding: 0.25em 0.5em; + align-items: stretch; + gap: 0.25em; border: 1px solid var(--elements-hover, #d0d0d0); - border-radius: var(--radius, 4px); + border-radius: 0.5rem; + overflow: hidden; + background: #fff; } .three-sixty-scene-item.is-active { - background-color: var(--info-background, #e9f4fb); - border-color: var(--info-bordercolor, #4ea0d4); + border-color: var(--brand-primary-300, #5fe9d9); + border-left: 3px solid var(--brand-primary, #078e8e); + background: var(--info-background, #e9f4fb); } -.three-sixty-scene-item .three-sixty-scene-select { +.three-sixty-scene-select { flex: 1 1 auto; + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; text-align: left; - padding: 0.25em 0.5em; + min-height: 2.5rem; + padding: 0.4rem 0.6rem; + border: 0; + background: transparent; + cursor: pointer; +} + +.three-sixty-scene-item.is-active .three-sixty-scene-select { + font-weight: 600; +} + +.three-sixty-scene-label { + flex: 1 1 auto; + min-width: 4rem; +} + +.three-sixty-scene-badge { + color: #fff; + font-weight: 500; + font-size: 0.75rem; +} + +.three-sixty-scene-badge--pano { + background-color: var(--three-sixty-go-to-scene, #0065ab); +} + +.three-sixty-scene-badge--flat { + background-color: var(--three-sixty-image, #d76b4a); +} + +.three-sixty-scene-badge--start { + background-color: var(--brand-primary, #078e8e); } .three-sixty-scene-actions { display: inline-flex; - gap: 0.25em; + align-items: center; + gap: 0.15rem; + padding: 0.25rem; } -/* Hotspot list */ +/* Hotspot accordion list (Interactive Video pattern) */ .three-sixty-hotspot-list { display: flex; flex-direction: column; - gap: 0.5em; + gap: 0.35rem; + list-style: none; + margin: 0; + padding: 0; +} + +.three-sixty-kind--go-to-scene { + --three-sixty-kind-color: var(--three-sixty-go-to-scene); +} + +.three-sixty-kind--text { + --three-sixty-kind-color: var(--three-sixty-text); +} + +.three-sixty-kind--image { + --three-sixty-kind-color: var(--three-sixty-image); +} + +.three-sixty-kind--video { + --three-sixty-kind-color: var(--three-sixty-video); +} + +.three-sixty-kind--link { + --three-sixty-kind-color: var(--three-sixty-link); +} + +.three-sixty-kind--unsupported { + --three-sixty-kind-color: var(--three-sixty-unsupported); } .three-sixty-hotspot-item { - border: 1px solid var(--elements-hover, #d0d0d0); - border-radius: var(--radius, 4px); - padding: 0.5em; + border: 1px solid #ccc; + border-radius: 0.5rem; + overflow: hidden; + background: #fff; +} + +.three-sixty-hotspot-item.is-selected { + border-color: var(--brand-primary-300, #5fe9d9); + border-left: 3px solid var(--three-sixty-kind-color, var(--brand-primary, #078e8e)); +} + +.three-sixty-hotspot-row { + display: flex; + gap: 0.25rem; + align-items: stretch; +} + +.three-sixty-hotspot-select { + flex: 1; + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + text-align: left; + min-height: 2.5rem; + padding: 0.4rem 0.6rem; + border: 0; + background: transparent; + cursor: pointer; +} + +.three-sixty-hotspot-item.is-selected .three-sixty-hotspot-select { + font-weight: 600; +} + +.three-sixty-hotspot-badge { + color: #fff; + background-color: var(--three-sixty-kind-color, var(--three-sixty-text)); + font-weight: 500; } -.three-sixty-hotspot-item .form-control { +.three-sixty-hotspot-summary { + flex: 1; + min-width: 6rem; +} + +.three-sixty-hotspot-validity { + color: #b3261e; +} + +.three-sixty-hotspot-actions { + display: inline-flex; + align-items: center; + gap: 0.15rem; + padding: 0.25rem; +} + +.three-sixty-hotspot-actions .btn { + min-width: 2.5rem; + min-height: 2.5rem; +} + +.three-sixty-hotspot-done .exe-icon { + font-family: var(--icons-ff, 'Material Icons Round'); + font-size: 16px; + line-height: 1; + color: var(--success-color, #2e7d32); +} + +/* Expanded editor hosted inside the selected row */ + +.three-sixty-hotspot-detail { + padding: 0.75rem 1rem; + border-top: 1px solid #eee; + background: rgba(0, 0, 0, 0.02); +} + +.three-sixty-hotspot-detail-heading { + margin: 0 0 0.5rem; + font-size: 0.95rem; + color: var(--three-sixty-kind-color, inherit); +} + +.three-sixty-hotspot-coords { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.three-sixty-hotspot-detail .form-control { max-width: 16em; } + +.three-sixty-hotspot-detail textarea.form-control { + max-width: 100%; + width: 100%; +} + +.hotspot-field-error { + color: #b3261e; + display: block; + width: 100%; +} + +.hotspot-field-error:empty { + display: none; +} + +/* Inline delete confirmation (no modal) */ + +.three-sixty-hotspot-item.is-confirming { + padding: 0.75rem 1rem; + background: var(--red-50, #fef2f2); + border: 1px solid var(--red-200, #fecaca); + border-radius: var(--radius, 8px); +} + +.three-sixty-delete-confirm { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.three-sixty-delete-confirm-text { + font-weight: 500; + color: var(--red-700, #b91c1c); + font-size: 14px; +} + +.three-sixty-delete-confirm-actions { + display: flex; + gap: 8px; +} + +.three-sixty-delete-confirm-actions .btn-danger, +.three-sixty-delete-confirm-actions .btn-danger:hover { + color: #fff; + background: var(--red-600, #dc2626); + border-color: var(--red-600, #dc2626); +} + +/* Transient per-row "Saved" confirmation (icon + text, never colour alone) */ + +.three-sixty-hotspot-saved { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + white-space: nowrap; + color: var(--success-color, #2e7d32); + opacity: 0; + align-self: center; + margin-inline-end: 0.35rem; + transition: opacity 0.4s ease-out; +} + +.three-sixty-hotspot-saved.is-saved { + opacity: 1; + transition: opacity 0.2s ease-in; +} + +.three-sixty-hotspot-saved .exe-icon { + font-family: var(--icons-ff, 'Material Icons Round'); + font-size: 15px; + line-height: 1; +} + +.three-sixty-viewer-form :focus-visible { + outline: 2px solid var(--brand-primary-300, #1a73e8); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .three-sixty-hotspot-saved.is-saved { + transition: none; + } +} + +/* Visually-hidden utility if the workarea doesn't supply one */ + +.three-sixty-viewer-form .visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} diff --git a/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.js b/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.js deleted file mode 100644 index b3d49a6d6b..0000000000 --- a/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.js +++ /dev/null @@ -1,1880 +0,0 @@ -/* eslint-disable no-undef */ -/** - * 360° panorama viewer iDevice (edition code). - * Stores a virtual-tour-capable v2 state: scenes[] + behaviour + hotspots. - * v1 single-image data is migrated transparently into a single-scene tour. - * - * Released under Attribution-ShareAlike 4.0 International License. - * License: http://creativecommons.org/licenses/by-sa/4.0/ - */ - -var $exeDevice = { - i18n: { - name: _('360° panorama viewer'), - }, - - SCHEMA_VERSION: 2, - - defaultBehaviour: () => ({ - autorotate: { enabled: false, speed: 1 }, - zoomEnabled: true, - fullscreenEnabled: true, - renderQuality: 'high', - showLabels: true, - labelPosition: 'right', - imageAdjustments: { brightness: 1, contrast: 1, saturation: 1 }, - }), - - defaultScene: id => ({ - id: id || 'scene-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e6).toString(36), - title: '', - src: '', - alt: '', - description: '', - // 'equirectangular' = 360° panorama (default); 'flat' = regular photo, - // shown undistorted (no spherical wrap) with x/y-positioned hotspots. - projection: 'equirectangular', - initialView: { yaw: 0, pitch: 0, fov: 75 }, - hotspots: [], - }), - - defaultHotspot: id => ({ - id: id || 'hs-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e6).toString(36), - label: '', - icon: 'circle', - // yaw/pitch are used on equirectangular scenes; x/y (percent of the - // displayed image) are used on flat scenes. - yaw: 0, - pitch: 0, - x: 50, - y: 50, - action: { type: 'text', payload: { html: '' } }, - }), - - PROJECTION_VALUES: ['equirectangular', 'flat'], - HOTSPOT_ACTION_TYPES: ['goToScene', 'text', 'image', 'video', 'link'], - RENDER_QUALITY_VALUES: ['low', 'medium', 'high'], - LABEL_POSITION_VALUES: ['right', 'left', 'top', 'bottom'], - - // Runtime state for the live preview - _preview: null, - _rafId: null, - _scriptsLoading: null, - _activeSceneIndex: 0, - - /** - * eXeLearning idevice engine init - * @param {HTMLElement} element - form container - * @param {object} previousData - JSON returned by save() - * @param {string} idevicePath - optional path to iDevice resources - */ - init: function (element, previousData, idevicePath) { - this.ideviceBody = element; - this.idevicePreviousData = previousData; - this.idevicePath = idevicePath || ''; - this.state = this.normalizeData(previousData); - this._activeSceneIndex = this.findStartSceneIndex(); - this.createForm(); - this.addFormBehaviour(); - this.updatePreviewSoon(); - }, - - /** - * Always returns a fresh v2-shaped state. Accepts v1 (single-image) input - * and lifts it into a one-scene tour. ideviceId is preserved when present. - */ - normalizeData: function (data) { - var raw = data && typeof data === 'object' ? data : {}; - var v2 = this.migrateToV2(raw); - var scenes = - Array.isArray(v2.scenes) && v2.scenes.length > 0 - ? v2.scenes.map(this.normalizeScene, this) - : [this.defaultScene('scene-1')]; - var startSceneId = this.resolveStartSceneId(v2.startSceneId, scenes); - return { - version: this.SCHEMA_VERSION, - ideviceId: typeof raw.ideviceId === 'string' ? raw.ideviceId : '', - startSceneId: startSceneId, - scenes: scenes, - behaviour: this.normalizeBehaviour(v2.behaviour), - }; - }, - - /** - * Detect the schema version of input. Anything without scenes[] is treated - * as v1 (the original single-image shape from PR #1689). - */ - migrateToV2: function (data) { - if (data && data.version >= 2 && Array.isArray(data.scenes)) { - return { - scenes: data.scenes, - startSceneId: typeof data.startSceneId === 'string' ? data.startSceneId : '', - behaviour: data.behaviour && typeof data.behaviour === 'object' ? data.behaviour : {}, - }; - } - // v1: lift top-level src/alt/initialView into a single scene - var hasV1Fields = - data && - (typeof data.src === 'string' || - typeof data.alt === 'string' || - data.initialView || - data.autorotate || - 'zoomEnabled' in data || - 'fullscreenEnabled' in data); - if (hasV1Fields) { - var scene = this.defaultScene('scene-1'); - scene.src = typeof data.src === 'string' ? data.src : ''; - scene.alt = typeof data.alt === 'string' ? data.alt : ''; - scene.initialView = this.normalizeInitialView(data.initialView); - return { - scenes: [scene], - startSceneId: 'scene-1', - behaviour: { - autorotate: data.autorotate || {}, - zoomEnabled: data.zoomEnabled, - fullscreenEnabled: data.fullscreenEnabled, - showNavControls: data.showNavControls, - }, - }; - } - return { scenes: [], startSceneId: '', behaviour: {} }; - }, - - normalizeInitialView: function (iv) { - var s = iv && typeof iv === 'object' ? iv : {}; - return { - yaw: this.clamp(this.toNumber(s.yaw, 0), -180, 180), - pitch: this.clamp(this.toNumber(s.pitch, 0), -90, 90), - fov: this.clamp(this.toNumber(s.fov, 75), 30, 120), - }; - }, - - normalizeScene: function (s, index) { - var src = s && typeof s === 'object' ? s : {}; - var fallbackId = 'scene-' + (typeof index === 'number' ? index + 1 : 1); - var hotspots = Array.isArray(src.hotspots) ? src.hotspots.map(this.normalizeHotspot, this) : []; - return { - id: typeof src.id === 'string' && src.id ? src.id : fallbackId, - title: typeof src.title === 'string' ? src.title : '', - src: typeof src.src === 'string' ? src.src : '', - alt: typeof src.alt === 'string' ? src.alt : '', - description: typeof src.description === 'string' ? src.description : '', - projection: src.projection === 'flat' ? 'flat' : 'equirectangular', - initialView: this.normalizeInitialView(src.initialView), - hotspots: hotspots, - }; - }, - - normalizeHotspot: function (h) { - var src = h && typeof h === 'object' ? h : {}; - var actionRaw = src.action && typeof src.action === 'object' ? src.action : {}; - var type = this.HOTSPOT_ACTION_TYPES.indexOf(actionRaw.type) >= 0 ? actionRaw.type : 'text'; - var payload = actionRaw.payload && typeof actionRaw.payload === 'object' ? actionRaw.payload : {}; - return { - id: typeof src.id === 'string' && src.id ? src.id : this.defaultHotspot().id, - label: typeof src.label === 'string' ? src.label : '', - icon: typeof src.icon === 'string' ? src.icon : 'circle', - yaw: this.clamp(this.toNumber(src.yaw, 0), -180, 180), - pitch: this.clamp(this.toNumber(src.pitch, 0), -90, 90), - x: this.clamp(this.toNumber(src.x, 50), 0, 100), - y: this.clamp(this.toNumber(src.y, 50), 0, 100), - action: { type: type, payload: this.normalizeHotspotPayload(type, payload) }, - }; - }, - - normalizeHotspotPayload: (type, payload) => { - switch (type) { - case 'goToScene': - return { sceneId: typeof payload.sceneId === 'string' ? payload.sceneId : '' }; - case 'text': - return { html: typeof payload.html === 'string' ? payload.html : '' }; - case 'image': - return { - src: typeof payload.src === 'string' ? payload.src : '', - alt: typeof payload.alt === 'string' ? payload.alt : '', - caption: typeof payload.caption === 'string' ? payload.caption : '', - }; - case 'video': - return { - src: typeof payload.src === 'string' ? payload.src : '', - poster: typeof payload.poster === 'string' ? payload.poster : '', - }; - case 'link': - return { - url: typeof payload.url === 'string' ? payload.url : '', - newTab: payload.newTab !== false, - }; - default: - return {}; - } - }, - - normalizeBehaviour: function (b) { - var src = b && typeof b === 'object' ? b : {}; - var ar = src.autorotate && typeof src.autorotate === 'object' ? src.autorotate : {}; - var ia = src.imageAdjustments && typeof src.imageAdjustments === 'object' ? src.imageAdjustments : {}; - var renderQuality = this.RENDER_QUALITY_VALUES.indexOf(src.renderQuality) >= 0 ? src.renderQuality : 'high'; - var labelPosition = this.LABEL_POSITION_VALUES.indexOf(src.labelPosition) >= 0 ? src.labelPosition : 'right'; - return { - autorotate: { - enabled: !!ar.enabled, - speed: this.clamp(this.toNumber(ar.speed, 1), 0, 10), - }, - zoomEnabled: src.zoomEnabled !== false, - fullscreenEnabled: src.fullscreenEnabled !== false, - showNavControls: src.showNavControls !== false, - renderQuality: renderQuality, - showLabels: src.showLabels !== false, - labelPosition: labelPosition, - imageAdjustments: { - brightness: this.clamp(this.toNumber(ia.brightness, 1), 0.1, 3), - contrast: this.clamp(this.toNumber(ia.contrast, 1), 0.1, 3), - saturation: this.clamp(this.toNumber(ia.saturation, 1), 0, 3), - }, - }; - }, - - /** - * If startSceneId points at a missing scene, fall back to the first scene. - */ - resolveStartSceneId: (requested, scenes) => { - if (!Array.isArray(scenes) || scenes.length === 0) return ''; - if (typeof requested === 'string' && requested) { - for (var i = 0; i < scenes.length; i++) { - if (scenes[i].id === requested) return requested; - } - } - return scenes[0].id; - }, - - findStartSceneIndex: function () { - if (!this.state || !Array.isArray(this.state.scenes)) return 0; - for (var i = 0; i < this.state.scenes.length; i++) { - if (this.state.scenes[i].id === this.state.startSceneId) return i; - } - return 0; - }, - - getActiveScene: function () { - if (!this.state || !Array.isArray(this.state.scenes) || this.state.scenes.length === 0) { - return this.defaultScene('scene-1'); - } - var idx = this._activeSceneIndex; - if (idx < 0 || idx >= this.state.scenes.length) idx = 0; - return this.state.scenes[idx]; - }, - - toNumber: (v, fallback) => { - var n = typeof v === 'number' ? v : parseFloat(v); - return isFinite(n) ? n : fallback; - }, - - clamp: (v, min, max) => { - if (v < min) return min; - if (v > max) return max; - return v; - }, - - /** - * Compute the rectangle a `object-fit: contain` image occupies inside a box, - * accounting for letterboxing. Falls back to the full box when the natural - * dimensions are unavailable (e.g. image not yet decoded, or jsdom tests). - * Shared by editor placement and runtime hotspot positioning so the two - * agree on the flat-image coordinate basis. - */ - containedImageRect: (naturalW, naturalH, boxW, boxH) => { - if (!naturalW || !naturalH || !boxW || !boxH) { - return { left: 0, top: 0, width: boxW || 0, height: boxH || 0 }; - } - var scale = Math.min(boxW / naturalW, boxH / naturalH); - var w = naturalW * scale; - var h = naturalH * scale; - return { left: (boxW - w) / 2, top: (boxH - h) / 2, width: w, height: h }; - }, - - /** - * Make WebGL output match the source panorama's apparent brightness/colour. - * Without this the canvas reads sRGB textures as linear and renders darker. - */ - applyColorManagement: renderer => { - if (typeof THREE === 'undefined' || !renderer) return; - if (THREE.ColorManagement && 'enabled' in THREE.ColorManagement) { - THREE.ColorManagement.enabled = true; - } - if ('outputColorSpace' in renderer && typeof THREE.SRGBColorSpace !== 'undefined') { - renderer.outputColorSpace = THREE.SRGBColorSpace; - } else if ('outputEncoding' in renderer && typeof THREE.sRGBEncoding !== 'undefined') { - renderer.outputEncoding = THREE.sRGBEncoding; - } - if ('toneMapping' in renderer && typeof THREE.NoToneMapping !== 'undefined') { - renderer.toneMapping = THREE.NoToneMapping; - renderer.toneMappingExposure = 1.0; - } - }, - - applyTextureColorSpace: texture => { - if (!texture || typeof THREE === 'undefined') return; - if ('colorSpace' in texture && typeof THREE.SRGBColorSpace !== 'undefined') { - texture.colorSpace = THREE.SRGBColorSpace; - } else if ('encoding' in texture && typeof THREE.sRGBEncoding !== 'undefined') { - texture.encoding = THREE.sRGBEncoding; - } - }, - - // Scene management helpers (used by Phase 3 UI) - - addScene: function () { - var idx = this.state.scenes.length + 1; - var scene = this.defaultScene('scene-' + Date.now().toString(36) + '-' + idx); - scene.title = _('Scene') + ' ' + idx; - this.state.scenes.push(scene); - return scene; - }, - - duplicateScene: function (sceneIndex) { - var src = this.state.scenes[sceneIndex]; - if (!src) return null; - var copy = this.normalizeScene(JSON.parse(JSON.stringify(src))); - copy.id = 'scene-' + Date.now().toString(36) + '-' + this.state.scenes.length; - copy.title = src.title ? src.title + ' (' + _('copy') + ')' : ''; - copy.hotspots = copy.hotspots.map(h => - Object.assign({}, h, { - id: 'hs-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e6).toString(36), - }), - ); - this.state.scenes.splice(sceneIndex + 1, 0, copy); - return copy; - }, - - removeScene: function (sceneIndex) { - if (!this.state.scenes[sceneIndex]) return null; - var removed = this.state.scenes.splice(sceneIndex, 1)[0]; - if (this.state.scenes.length === 0) { - this.state.scenes.push(this.defaultScene('scene-1')); - } - // Repair startSceneId / activeSceneIndex if we deleted them - this.state.startSceneId = this.resolveStartSceneId(this.state.startSceneId, this.state.scenes); - if (this._activeSceneIndex >= this.state.scenes.length) { - this._activeSceneIndex = this.state.scenes.length - 1; - } - // Repair goToScene hotspots that pointed at the removed scene - this.state.scenes.forEach(sc => { - sc.hotspots.forEach(h => { - if ( - h.action && - h.action.type === 'goToScene' && - h.action.payload && - h.action.payload.sceneId === removed.id - ) { - h.action.payload.sceneId = ''; - } - }); - }); - return removed; - }, - - setStartScene: function (sceneId) { - this.state.startSceneId = this.resolveStartSceneId(sceneId, this.state.scenes); - }, - - setActiveSceneIndex: function (idx) { - if (idx < 0 || idx >= this.state.scenes.length) return; - this._activeSceneIndex = idx; - this.refreshActiveSceneInputs(); - this.updatePreviewSoon(); - this.renderHotspotList(); - }, - - addHotspot: function (yaw, pitch) { - var scene = this.getActiveScene(); - var h = this.defaultHotspot(); - h.label = _('Hotspot') + ' ' + (scene.hotspots.length + 1); - h.yaw = this.clamp(this.toNumber(yaw, 0), -180, 180); - h.pitch = this.clamp(this.toNumber(pitch, 0), -90, 90); - scene.hotspots.push(h); - return h; - }, - - /** - * Add a hotspot to a flat scene, positioned by x/y percent of the displayed - * image (0–100). Used when the active scene's projection is 'flat'. - */ - addHotspotFlat: function (x, y) { - var scene = this.getActiveScene(); - var h = this.defaultHotspot(); - h.label = _('Hotspot') + ' ' + (scene.hotspots.length + 1); - h.x = this.clamp(this.toNumber(x, 50), 0, 100); - h.y = this.clamp(this.toNumber(y, 50), 0, 100); - scene.hotspots.push(h); - return h; - }, - - removeHotspot: function (hotspotIndex) { - var scene = this.getActiveScene(); - if (!scene.hotspots[hotspotIndex]) return null; - return scene.hotspots.splice(hotspotIndex, 1)[0]; - }, - - getCurrentCameraYawPitch: function () { - // Try to read live camera direction from the preview; fall back to scene's initial view. - var scene = this.getActiveScene(); - var p = this._preview; - if (p && p.camera && typeof p.camera.getWorldDirection === 'function') { - var dir = new THREE.Vector3(); - try { - p.camera.getWorldDirection(dir); - var pitch = (Math.asin(this.clamp(dir.y, -1, 1)) * 180) / Math.PI; - var yaw = (Math.atan2(dir.x, dir.z) * 180) / Math.PI; - return { yaw: this.clamp(yaw, -180, 180), pitch: this.clamp(pitch, -90, 90) }; - } catch (_) { - /* fall through */ - } - } - return { yaw: scene.initialView.yaw, pitch: scene.initialView.pitch }; - }, - - /** - * Build the form HTML and insert it into the iDevice body. - */ - createForm: function () { - var scene = this.getActiveScene(); - var b = this.state.behaviour; - var isFlat = scene.projection === 'flat'; - // The "Initial view" controls (yaw/pitch/fov) only make sense on a 360° - // panorama; a flat photo is shown undistorted with no camera to aim. - var initialViewFieldset = isFlat - ? '' - : ` -
- ${_('Initial view')} -
- - - - - - -
-
`; - var html = ` -
-

${_('Add equirectangular 360° images (2:1 aspect), or uncheck “360° panorama image” to use a regular flat photo. The viewer uses WebGL for 360° scenes.')}

- -
- ${_('Scenes')} -
-
- -
-
- -
- ${_('Active scene')} -
- - -
-
- -
- - ${scene.src ? this.escapeHtml(this.truncateLabel(scene.src)) : _('No image selected')} - -
- -
-
- - ${_('Uncheck for a regular flat photo (no 360° effect).')} -
-
- - -
-
- - -
- ${initialViewFieldset} - -
- ${_('Hotspots')} -

${isFlat ? _('Click on the image to place a hotspot, or drag an existing hotspot to move it.') : _('Click on the panorama to place a hotspot, or drag an existing hotspot to move it.')}

-
-
- - -
-
-
- -
- ${_('Controls')} -
- - - -
-
- - - - -
-
- -
-
-

${_('Select an image to see a live preview.')}

-
-
- `; - this.ideviceBody.innerHTML = html; - this.renderSceneList(); - this.renderHotspotList(); - }, - - /** - * Render the scene list panel. - */ - renderSceneList: function () { - var body = this.ideviceBody; - if (!body) return; - var list = body.querySelector('#threeSixtySceneList'); - if (!list) return; - - list.innerHTML = ''; - this.state.scenes.forEach((scene, idx) => { - var row = document.createElement('div'); - row.className = 'three-sixty-scene-item' + (idx === this._activeSceneIndex ? ' is-active' : ''); - row.setAttribute('role', 'listitem'); - row.setAttribute('data-scene-index', String(idx)); - var label = scene.title || _('Scene') + ' ' + (idx + 1); - var isStart = scene.id === this.state.startSceneId; - row.innerHTML = - '' + - '
' + - '' + - '' + - '' + - '
'; - list.appendChild(row); - }); - }, - - /** - * Render the hotspot list for the active scene + the live overlay handles - * on the panorama preview, so the form and the canvas stay in sync. - */ - renderHotspotList: function () { - this._renderHotspotListUI(); - this._renderEditorHotspots(); - }, - - _renderHotspotListUI: function () { - var body = this.ideviceBody; - if (!body) return; - var list = body.querySelector('#threeSixtyHotspotList'); - if (!list) return; - - var scene = this.getActiveScene(); - var isFlat = scene.projection === 'flat'; - list.innerHTML = ''; - if (!scene.hotspots.length) { - var empty = document.createElement('p'); - empty.className = 'text-muted small'; - empty.textContent = _('No hotspots in this scene yet.'); - list.appendChild(empty); - return; - } - scene.hotspots.forEach((h, idx) => { - var item = document.createElement('div'); - item.className = 'three-sixty-hotspot-item'; - item.setAttribute('role', 'listitem'); - item.setAttribute('data-hotspot-index', String(idx)); - var actionOptions = this.HOTSPOT_ACTION_TYPES.map( - type => - '', - ).join(''); - item.innerHTML = - '
' + - '' + - '' + - '
' + - '
' + - (isFlat - ? '' + - '' - : '' + - '') + - '' + - '
' + - '
' + - this.renderHotspotPayloadInputs(h, idx) + - '
'; - list.appendChild(item); - }); - }, - - actionTypeLabel: type => { - switch (type) { - case 'goToScene': - return _('Go to scene'); - case 'text': - return _('Text'); - case 'image': - return _('Image'); - case 'video': - return _('Video'); - case 'link': - return _('External link'); - default: - return type; - } - }, - - renderHotspotPayloadInputs: function (h, idx) { - var p = h.action.payload || {}; - - switch (h.action.type) { - case 'goToScene': { - var options = this.state.scenes - .map(sc => { - var label = sc.title || sc.id; - return ( - '' - ); - }) - .join(''); - return ( - '' - ); - } - case 'text': - return ( - '' - ); - case 'image': - return ( - '' + - '' + - '' - ); - case 'video': - return ( - '' + - '' + - '

' + - this.escapeHtml( - _('Paste a YouTube, Vimeo or Educamadrid Mediateca page URL to embed it, or choose an uploaded video file.'), - ) + - '

' - ); - case 'link': - return ( - '' + - '' - ); - default: - return ''; - } - }, - - refreshActiveSceneInputs: function () { - var body = this.ideviceBody; - if (!body) return; - var scene = this.getActiveScene(); - var fields = { - '#threeSixtySceneTitle': scene.title, - '#threeSixtyAlt': scene.alt, - '#threeSixtySceneDescription': scene.description, - '#threeSixtyYaw': scene.initialView.yaw, - '#threeSixtyPitch': scene.initialView.pitch, - '#threeSixtyFov': scene.initialView.fov, - }; - Object.keys(fields).forEach(sel => { - var el = body.querySelector(sel); - if (el && el.value !== String(fields[sel])) el.value = fields[sel]; - }); - this.refreshImageLabel(); - var legend = body.querySelector('#threeSixtyActiveSceneLegend'); - if (legend) legend.textContent = scene.title || _('Scene') + ' ' + (this._activeSceneIndex + 1); - }, - - /** - * Wire up change events on the form controls. - */ - addFormBehaviour: function () { - var body = this.ideviceBody; - - body.querySelector('#threeSixtyImageButton').addEventListener('click', () => { - this.pickImage(); - }); - body.querySelector('#threeSixtyImageClear').addEventListener('click', () => { - this.getActiveScene().src = ''; - this.refreshImageLabel(); - this.updatePreviewSoon(); - }); - body.querySelector('#threeSixtyImageFile').addEventListener('change', ev => { - var file = ev.target.files && ev.target.files[0]; - if (file) this.handleFileFallback(file); - }); - - body.querySelector('#threeSixtySceneTitle').addEventListener('input', ev => { - this.getActiveScene().title = String(ev.target.value || ''); - this.renderSceneList(); - }); - body.querySelector('#threeSixtyAlt').addEventListener('input', ev => { - this.getActiveScene().alt = String(ev.target.value || ''); - }); - body.querySelector('#threeSixtySceneDescription').addEventListener('input', ev => { - this.getActiveScene().description = String(ev.target.value || ''); - }); - - var panoramaToggle = body.querySelector('#threeSixtyIsPanorama'); - if (panoramaToggle) { - panoramaToggle.addEventListener('change', ev => { - this.getActiveScene().projection = ev.target.checked ? 'equirectangular' : 'flat'; - // Mode change swaps the renderer (WebGL sphere ↔ flat ) and - // the per-scene fields, so rebuild the form and preview wholesale. - this.destroyPreview(); - this.createForm(); - this.addFormBehaviour(); - this.updatePreviewSoon(); - }); - } - - var numericFields = [ - ['#threeSixtyYaw', 'initialView.yaw', -180, 180], - ['#threeSixtyPitch', 'initialView.pitch', -90, 90], - ['#threeSixtyFov', 'initialView.fov', 30, 120], - ]; - numericFields.forEach(f => { - var el = body.querySelector(f[0]); - if (!el) return; - el.addEventListener('input', () => { - var scene = this.getActiveScene(); - this.setNestedPath(scene, f[1], this.clamp(this.toNumber(el.value, 0), f[2], f[3])); - this.updatePreviewSoon(); - }); - }); - - body.querySelector('#threeSixtyAutorotateSpeed').addEventListener('input', ev => { - this.state.behaviour.autorotate.speed = this.clamp(this.toNumber(ev.target.value, 0), 0, 10); - this.updatePreviewSoon(); - }); - body.querySelector('#threeSixtyAutorotate').addEventListener('change', ev => { - this.state.behaviour.autorotate.enabled = !!ev.target.checked; - this.updatePreviewSoon(); - }); - body.querySelector('#threeSixtyZoom').addEventListener('change', ev => { - this.state.behaviour.zoomEnabled = !!ev.target.checked; - this.updatePreviewSoon(); - }); - body.querySelector('#threeSixtyFullscreen').addEventListener('change', ev => { - this.state.behaviour.fullscreenEnabled = !!ev.target.checked; - }); - body.querySelector('#threeSixtyShowLabels').addEventListener('change', ev => { - this.state.behaviour.showLabels = !!ev.target.checked; - this.updatePreviewSoon(); - }); - body.querySelector('#threeSixtyNavControls').addEventListener('change', ev => { - this.state.behaviour.showNavControls = !!ev.target.checked; - this.updatePreviewSoon(); - }); - - body.querySelector('#threeSixtyAddScene').addEventListener('click', () => { - this.addScene(); - this._activeSceneIndex = this.state.scenes.length - 1; - this.refreshActiveSceneInputs(); - this.renderSceneList(); - this.renderHotspotList(); - this.updatePreviewSoon(); - }); - - body.querySelector('#threeSixtySceneList').addEventListener('click', ev => { - var btn = ev.target.closest('button[data-action]'); - if (!btn) return; - var action = btn.getAttribute('data-action'); - var idx = parseInt(btn.getAttribute('data-index'), 10); - if (isNaN(idx)) return; - if (action === 'select') this.setActiveSceneIndex(idx); - else if (action === 'set-start') { - this.setStartScene(this.state.scenes[idx].id); - this.renderSceneList(); - } else if (action === 'duplicate') { - this.duplicateScene(idx); - this.renderSceneList(); - } else if (action === 'remove') { - this.removeScene(idx); - this._activeSceneIndex = Math.min(this._activeSceneIndex, this.state.scenes.length - 1); - this.refreshActiveSceneInputs(); - this.renderSceneList(); - this.renderHotspotList(); - this.updatePreviewSoon(); - } - }); - - body.querySelector('#threeSixtyAddHotspot').addEventListener('click', () => { - if (this.getActiveScene().projection === 'flat') { - this.addHotspotFlat(50, 50); - } else { - var pose = this.getCurrentCameraYawPitch(); - this.addHotspot(pose.yaw, pose.pitch); - } - this.renderHotspotList(); - this.updatePreviewSoon(); - }); - body.querySelector('#threeSixtyPlaceHotspot').addEventListener('click', () => { - this._placingHotspot = !this._placingHotspot; - this.refreshPlacementMode(); - }); - - body.querySelector('#threeSixtyHotspotList').addEventListener('input', ev => { - var t = ev.target; - var idx = parseInt(t.getAttribute && t.getAttribute('data-index'), 10); - if (isNaN(idx)) return; - var h = this.getActiveScene().hotspots[idx]; - if (!h) return; - if (t.classList.contains('hotspot-label')) h.label = String(t.value || ''); - else if (t.classList.contains('hotspot-yaw')) h.yaw = this.clamp(this.toNumber(t.value, 0), -180, 180); - else if (t.classList.contains('hotspot-pitch')) h.pitch = this.clamp(this.toNumber(t.value, 0), -90, 90); - else if (t.classList.contains('hotspot-x')) h.x = this.clamp(this.toNumber(t.value, 50), 0, 100); - else if (t.classList.contains('hotspot-y')) h.y = this.clamp(this.toNumber(t.value, 50), 0, 100); - else if (t.classList.contains('hotspot-payload-sceneId')) h.action.payload.sceneId = String(t.value || ''); - else if (t.classList.contains('hotspot-payload-html')) h.action.payload.html = String(t.value || ''); - else if (t.classList.contains('hotspot-payload-src')) h.action.payload.src = String(t.value || ''); - else if (t.classList.contains('hotspot-payload-caption')) h.action.payload.caption = String(t.value || ''); - else if (t.classList.contains('hotspot-payload-url')) h.action.payload.url = String(t.value || ''); - this.updatePreviewSoon(); - }); - - body.querySelector('#threeSixtyHotspotList').addEventListener('change', ev => { - var t = ev.target; - var idx = parseInt(t.getAttribute && t.getAttribute('data-index'), 10); - if (isNaN(idx)) return; - if (t.classList.contains('hotspot-action-type')) { - var h = this.getActiveScene().hotspots[idx]; - if (!h) return; - h.action.type = this.HOTSPOT_ACTION_TYPES.indexOf(t.value) >= 0 ? t.value : 'text'; - h.action.payload = this.normalizeHotspotPayload(h.action.type, {}); - this.renderHotspotList(); - this.updatePreviewSoon(); - } else if (t.classList.contains('hotspot-payload-newTab')) { - var hs = this.getActiveScene().hotspots[idx]; - if (!hs) return; - hs.action.payload.newTab = !!t.checked; - } - }); - - body.querySelector('#threeSixtyHotspotList').addEventListener('click', ev => { - var btn = ev.target.closest('button[data-hotspot-action="remove"]'); - if (btn) { - var idx = parseInt(btn.getAttribute('data-index'), 10); - if (!isNaN(idx)) { - this.removeHotspot(idx); - this.renderHotspotList(); - this.updatePreviewSoon(); - } - return; - } - var pickImg = ev.target.closest('button.hotspot-payload-pickImage'); - if (pickImg) { - var imgIdx = parseInt(pickImg.getAttribute('data-index'), 10); - if (!isNaN(imgIdx)) this.pickHotspotMedia(imgIdx, 'image'); - return; - } - var pickVid = ev.target.closest('button.hotspot-payload-pickVideo'); - if (pickVid) { - var vidIdx = parseInt(pickVid.getAttribute('data-index'), 10); - if (!isNaN(vidIdx)) this.pickHotspotMedia(vidIdx, 'video'); - } - }); - }, - - /** - * Assign a nested property defined by a dotted path (e.g. "initialView.yaw"). - */ - setNestedPath: (target, path, value) => { - var parts = path.split('.'); - var t = target; - for (var i = 0; i < parts.length - 1; i++) { - t = t[parts[i]]; - } - t[parts[parts.length - 1]] = value; - }, - - escapeHtml: str => - String(str == null ? '' : str) - .replace(/&/g, '&') - .replace(//g, '>'), - - escapeAttr: function (str) { - return this.escapeHtml(str).replace(/"/g, '"'); - }, - - /** - * Open the file manager or fall back to a hidden input. - */ - pickImage: function () { - var fm = this._getFileManager(); - if (fm) { - fm.show({ - accept: 'image', - multiSelect: false, - onSelect: result => { - if (!result) return; - var assetUrl = result.assetUrl || ''; - if (!assetUrl) return; - this.getActiveScene().src = assetUrl; - this.refreshImageLabel(); - this.updatePreviewSoon(); - }, - }); - return; - } - this.ideviceBody.querySelector('#threeSixtyImageFile').click(); - }, - - pickHotspotMedia: function (hotspotIndex, kind) { - var fm = this._getFileManager(); - if (!fm) return; - fm.show({ - accept: kind === 'video' ? 'video' : 'image', - multiSelect: false, - onSelect: result => { - if (!result || !result.assetUrl) return; - var h = this.getActiveScene().hotspots[hotspotIndex]; - if (!h) return; - h.action.payload.src = result.assetUrl; - this.renderHotspotList(); - }, - }); - }, - - _getFileManager: () => - typeof eXeLearning !== 'undefined' && - eXeLearning && - eXeLearning.app && - eXeLearning.app.modals && - eXeLearning.app.modals.filemanager && - typeof eXeLearning.app.modals.filemanager.show === 'function' - ? eXeLearning.app.modals.filemanager - : null, - - /** - * Fallback when filemanager is not available: read file as data URL. - */ - handleFileFallback: function (file) { - var reader = new FileReader(); - reader.onload = () => { - this.getActiveScene().src = String(reader.result || ''); - this.refreshImageLabel(); - this.updatePreviewSoon(); - }; - reader.readAsDataURL(file); - }, - - refreshImageLabel: function () { - var body = this.ideviceBody; - if (!body) return; - var name = body.querySelector('#threeSixtyImageName'); - var clearBtn = body.querySelector('#threeSixtyImageClear'); - var src = this.getActiveScene().src; - if (name) name.textContent = src ? this.truncateLabel(src) : _('No image selected'); - if (clearBtn) { - if (src) clearBtn.removeAttribute('hidden'); - else clearBtn.setAttribute('hidden', 'hidden'); - } - }, - - truncateLabel: s => { - if (s.length <= 60) return s; - return s.slice(0, 28) + '…' + s.slice(-28); - }, - - /** - * Save() returns a plain JSON object (component-type=json) shaped for v2. - */ - save: function () { - var body = this.ideviceBody; - if (body) { - var alt = body.querySelector('#threeSixtyAlt'); - if (alt) this.getActiveScene().alt = String(alt.value || ''); - } - var normalized = this.normalizeData(this.state); - if (body && body.getAttribute) { - var id = body.getAttribute('idevice-id') || body.getAttribute('data-idevice-id'); - if (id) normalized.ideviceId = id; - } - this.destroyPreview(); - return normalized; - }, - - /** - * Live preview — lazy-load three.js then render an inverted sphere. - * Tests running without THREE will skip rendering. - */ - updatePreviewSoon: function () { - if (this._updateQueued) return; - this._updateQueued = true; - var schedule = - typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function' - ? window.requestAnimationFrame.bind(window) - : cb => setTimeout(cb, 16); - schedule(() => { - this._updateQueued = false; - this.renderPreview(); - }); - }, - - renderPreview: function () { - var body = this.ideviceBody; - if (!body) return; - var stage = body.querySelector('#threeSixtyPreview'); - var message = body.querySelector('#threeSixtyPreviewMessage'); - if (!stage) return; - - var scene = this.getActiveScene(); - if (!scene.src) { - this.destroyPreview(); - if (message) { - message.textContent = _('Select an image to see a live preview.'); - message.style.display = ''; - } - return; - } - - // Flat scenes are rendered with a plain — no WebGL/three.js needed. - if (scene.projection === 'flat') { - if ( - !this._preview || - this._preview.mode !== 'flat' || - this._preview.currentSrc !== scene.src - ) { - this.destroyPreview(); - this._preview = this.createFlatPreview(stage); - } - this.applyPreviewState(); - if (message) message.style.display = 'none'; - return; - } - - if (typeof THREE === 'undefined') { - this.ensureThreeLoaded( - function () { - if (typeof THREE !== 'undefined') { - this.renderPreview(); - } - }.bind(this), - ); - if (message) { - message.textContent = _('Loading 3D preview…'); - message.style.display = ''; - } - return; - } - - if (!this._preview) { - this._preview = this.createPreview(stage); - } else if (this._preview.mode === 'flat' || this._preview.currentSrc !== scene.src) { - this.destroyPreview(); - this._preview = this.createPreview(stage); - } - this.applyPreviewState(); - if (message) message.style.display = 'none'; - }, - - ensureThreeLoaded: function (cb) { - var self = this; - if (typeof window === 'undefined') return cb(); - if (typeof THREE !== 'undefined' && THREE.OrbitControls) return cb(); - if (typeof document !== 'undefined') { - var already = document.querySelector('script[src$="three.min.js"]'); - if (already && typeof THREE !== 'undefined') return cb(); - } - if (this._scriptsLoading) { - this._scriptsLoading.push(cb); - return; - } - this._scriptsLoading = [cb]; - var base = this.idevicePath || ''; - var candidates = []; - if (base) { - candidates.push(base.replace(/\/edition\/?$/, '/export/')); - candidates.push(base); - } - candidates.push('../export/'); - candidates.push(''); - - function loadScript(url, done) { - var existing = document.querySelector('script[data-three-sixty-src="' + url + '"]'); - if (existing) return done(); - var s = document.createElement('script'); - s.src = url; - s.async = true; - s.setAttribute('data-three-sixty-src', url); - s.onload = () => { - done(); - }; - s.onerror = () => { - done(new Error('failed: ' + url)); - }; - document.head.appendChild(s); - } - - function tryLoad(i) { - if (i >= candidates.length) { - self._scriptsLoading.forEach(c => { - try { - c(); - } catch (_) { - /* ignore */ - } - }); - self._scriptsLoading = null; - return; - } - var prefix = candidates[i]; - loadScript(prefix + 'three.min.js', err => { - if (err) return tryLoad(i + 1); - loadScript(prefix + 'OrbitControls.js', err2 => { - if (err2) return tryLoad(i + 1); - var cbs = self._scriptsLoading || []; - self._scriptsLoading = null; - cbs.forEach(c => { - try { - c(); - } catch (_) { - /* ignore */ - } - }); - }); - }); - } - tryLoad(0); - }, - - createPreview: function (stage) { - var scene = this.getActiveScene(); - while (stage.firstChild) stage.removeChild(stage.firstChild); - var rect = stage.getBoundingClientRect(); - var width = Math.max(200, rect.width | 0); - var height = Math.max(150, rect.height | 0); - - var threeScene = new THREE.Scene(); - var camera = new THREE.PerspectiveCamera(scene.initialView.fov, width / height, 0.1, 1000); - camera.position.set(0, 0, 0.01); - - var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); - if (typeof renderer.setPixelRatio === 'function') { - var dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1; - renderer.setPixelRatio(Math.min(dpr, 2)); - } - renderer.setSize(width, height); - this.applyColorManagement(renderer); - stage.appendChild(renderer.domElement); - this._captureDragEvents(renderer.domElement); - - // Hotspot overlay (placed over the canvas inside the stage). - var overlay = document.createElement('div'); - overlay.className = 'three-sixty-viewer-overlay three-sixty-viewer-overlay--editor'; - stage.appendChild(overlay); - - // Click on the canvas to place a hotspot when placement-mode is active. - var self = this; - renderer.domElement.addEventListener('click', ev => { - if (!self._placingHotspot) return; - var pose = self._clickToYawPitch(camera, renderer.domElement, ev.clientX, ev.clientY); - if (!pose) return; - self.addHotspot(pose.yaw, pose.pitch); - self._placingHotspot = false; - self.refreshPlacementMode(); - self.renderHotspotList(); - // Drop the just-placed hotspot into the overlay immediately so the - // author can see and drag it before the next render tick. - self._renderEditorHotspots(); - }); - - var geometry = new THREE.SphereGeometry(500, 60, 40); - if (typeof geometry.scale === 'function') geometry.scale(-1, 1, 1); - var material = new THREE.MeshBasicMaterial({}); - var mesh = new THREE.Mesh(geometry, material); - threeScene.add(mesh); - - var texture = null; - var loader = new THREE.TextureLoader(); - try { - texture = loader.load( - scene.src, - () => { - /* loaded */ - }, - undefined, - () => { - /* error */ - }, - ); - if (texture) { - this.applyTextureColorSpace(texture); - material.map = texture; - } - } catch (_) { - /* texture failed */ - } - - var controls = null; - if (THREE.OrbitControls) { - controls = new THREE.OrbitControls(camera, renderer.domElement); - controls.enablePan = false; - controls.rotateSpeed = -0.25; - controls.enableDamping = true; - controls.dampingFactor = 0.05; - controls.minDistance = 0.01; - controls.maxDistance = 0.01; - } - - var stopped = false; - function tick() { - if (stopped) return; - if (controls && typeof controls.update === 'function') controls.update(); - renderer.render(threeScene, camera); - self._positionEditorHotspots(); - self._rafId = - typeof window !== 'undefined' && window.requestAnimationFrame - ? window.requestAnimationFrame(tick) - : setTimeout(tick, 16); - } - tick(); - - var preview = { - mode: 'equirectangular', - scene: threeScene, - camera: camera, - renderer: renderer, - geometry: geometry, - material: material, - texture: texture, - controls: controls, - currentSrc: scene.src, - stage: stage, - overlay: overlay, - hotspotButtons: [], - stop: () => { - stopped = true; - }, - }; - - if (this.state.behaviour.showNavControls) { - preview.navControls = this._createNavControls(stage, preview); - } - - return preview; - }, - - /** - * Live preview for a flat (non-360) scene: a plain shown undistorted - * (object-fit: contain) with an overlay of x/y-positioned hotspots. No - * three.js/WebGL is involved, so this works even when THREE never loads. - */ - createFlatPreview: function (stage) { - var scene = this.getActiveScene(); - while (stage.firstChild) stage.removeChild(stage.firstChild); - - var img = document.createElement('img'); - img.className = 'three-sixty-preview-flat'; - img.alt = scene.alt || ''; - img.setAttribute('draggable', 'false'); - img.src = scene.src; - stage.appendChild(img); - - var overlay = document.createElement('div'); - overlay.className = 'three-sixty-viewer-overlay three-sixty-viewer-overlay--editor'; - stage.appendChild(overlay); - - var self = this; - // Click on the image to place a hotspot when placement-mode is active. - var onClick = ev => { - if (!self._placingHotspot) return; - var coords = self._clickToXY(self._preview, ev.clientX, ev.clientY); - if (!coords) return; - self.addHotspotFlat(coords.x, coords.y); - self._placingHotspot = false; - self.refreshPlacementMode(); - self.renderHotspotList(); - self._renderEditorHotspots(); - }; - overlay.addEventListener('click', onClick); - img.addEventListener('click', onClick); - - var stopped = false; - function tick() { - if (stopped) return; - self._positionEditorHotspots(); - self._rafId = - typeof window !== 'undefined' && window.requestAnimationFrame - ? window.requestAnimationFrame(tick) - : setTimeout(tick, 16); - } - tick(); - - return { - mode: 'flat', - img: img, - stage: stage, - overlay: overlay, - currentSrc: scene.src, - hotspotButtons: [], - stop: () => { - stopped = true; - }, - }; - }, - - /** - * Convert a click on the flat preview into x/y percent (0–100) within the - * displayed (contain-fitted) image rectangle. - */ - _clickToXY: function (preview, clientX, clientY) { - if (!preview || !preview.overlay) return null; - var rect = preview.overlay.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return null; - var img = preview.img; - var ir = this.containedImageRect( - img && img.naturalWidth, - img && img.naturalHeight, - rect.width, - rect.height, - ); - if (ir.width <= 0 || ir.height <= 0) return null; - var px = ((clientX - rect.left - ir.left) / ir.width) * 100; - var py = ((clientY - rect.top - ir.top) / ir.height) * 100; - return { x: this.clamp(px, 0, 100), y: this.clamp(py, 0, 100) }; - }, - - /** - * Render a draggable handle for each hotspot in the active scene over the - * editor preview canvas. Recreated whenever the hotspot list changes; the - * tick loop only updates positions via _positionEditorHotspots(). - */ - _renderEditorHotspots: function () { - var p = this._preview; - if (!p || !p.overlay) return; - var overlay = p.overlay; - while (overlay.firstChild) overlay.removeChild(overlay.firstChild); - p.hotspotButtons = []; - var scene = this.getActiveScene(); - var self = this; - scene.hotspots.forEach((h, idx) => { - var btn = document.createElement('button'); - btn.type = 'button'; - btn.className = - 'three-sixty-viewer-hotspot three-sixty-viewer-hotspot-' + (h.action.type || 'text') + - ' three-sixty-viewer-hotspot--editor'; - btn.setAttribute('data-hotspot-id', h.id); - btn.setAttribute('data-hotspot-index', String(idx)); - btn.setAttribute('aria-label', h.label || _('Hotspot') + ' ' + (idx + 1)); - btn.setAttribute('title', _('Drag to move')); - btn.style.display = 'none'; - var icon = document.createElement('span'); - icon.className = 'three-sixty-viewer-hotspot-icon'; - icon.setAttribute('aria-hidden', 'true'); - btn.appendChild(icon); - self._wireHotspotDrag(btn, h, idx); - overlay.appendChild(btn); - p.hotspotButtons.push({ button: btn, hotspot: h, index: idx }); - }); - }, - - _positionEditorHotspots: function () { - var p = this._preview; - if (!p || !p.hotspotButtons || !p.hotspotButtons.length) return; - if (p.mode === 'flat') return this._positionEditorHotspotsFlat(p); - if (typeof THREE === 'undefined' || !THREE.Vector3) return; - var camera = p.camera; - var rect = p.overlay.getBoundingClientRect(); - var w = rect.width; - var h = rect.height; - if (w < 1 || h < 1) return; - for (var i = 0; i < p.hotspotButtons.length; i++) { - var entry = p.hotspotButtons[i]; - var hs = entry.hotspot; - var btn = entry.button; - var yawRad = (hs.yaw * Math.PI) / 180; - var pitchRad = (hs.pitch * Math.PI) / 180; - var v = new THREE.Vector3( - Math.sin(yawRad) * Math.cos(pitchRad), - Math.sin(pitchRad), - Math.cos(yawRad) * Math.cos(pitchRad), - ); - v.multiplyScalar(10); - try { v.project(camera); } catch (_) { btn.style.display = 'none'; continue; } - if (v.z >= 1) { - btn.style.display = 'none'; - continue; - } - var x = ((v.x + 1) / 2) * w; - var y = (1 - (v.y + 1) / 2) * h; - btn.style.display = ''; - btn.style.left = x + 'px'; - btn.style.top = y + 'px'; - } - }, - - /** - * Position flat-scene hotspots at their x/y percent within the displayed - * (contain-fitted) image rectangle. - */ - _positionEditorHotspotsFlat: function (p) { - var rect = p.overlay.getBoundingClientRect(); - var boxW = rect.width; - var boxH = rect.height; - if (boxW < 1 || boxH < 1) return; - var img = p.img; - var ir = this.containedImageRect(img && img.naturalWidth, img && img.naturalHeight, boxW, boxH); - for (var i = 0; i < p.hotspotButtons.length; i++) { - var entry = p.hotspotButtons[i]; - var hs = entry.hotspot; - var btn = entry.button; - var x = ir.left + (this.clamp(this.toNumber(hs.x, 50), 0, 100) / 100) * ir.width; - var y = ir.top + (this.clamp(this.toNumber(hs.y, 50), 0, 100) / 100) * ir.height; - btn.style.display = ''; - btn.style.left = x + 'px'; - btn.style.top = y + 'px'; - } - }, - - /** - * Pointerdown on a hotspot starts a drag. Move = recompute yaw/pitch from - * the cursor position; up = finalize, restore OrbitControls, refresh form. - */ - _wireHotspotDrag: function (btn, hotspot, hotspotIndex) { - var self = this; - var dragging = false; - var pointerId = null; - var preview = this._preview; - var isFlat = preview && preview.mode === 'flat'; - var canvas = preview && preview.renderer ? preview.renderer.domElement : null; - if (!isFlat && !canvas) return; - - function onPointerMove(ev) { - if (!dragging) return; - if (isFlat) { - var coords = self._clickToXY(preview, ev.clientX, ev.clientY); - if (!coords) return; - hotspot.x = coords.x; - hotspot.y = coords.y; - return; - } - var pose = self._clickToYawPitch(preview.camera, canvas, ev.clientX, ev.clientY); - if (!pose) return; - hotspot.yaw = pose.yaw; - hotspot.pitch = pose.pitch; - } - - function onPointerUp(ev) { - if (!dragging) return; - dragging = false; - try { - if (pointerId !== null && typeof btn.releasePointerCapture === 'function') { - btn.releasePointerCapture(pointerId); - } - } catch (_) { /* ignore */ } - pointerId = null; - if (preview.controls) preview.controls.enabled = true; - // Reflect the new yaw/pitch in the form list inputs. - self.renderHotspotList(); - window.removeEventListener('pointermove', onPointerMove); - window.removeEventListener('pointerup', onPointerUp); - } - - btn.addEventListener('pointerdown', ev => { - ev.preventDefault(); - ev.stopPropagation(); - dragging = true; - pointerId = ev.pointerId; - try { - if (typeof btn.setPointerCapture === 'function') btn.setPointerCapture(pointerId); - } catch (_) { /* ignore */ } - if (preview.controls) preview.controls.enabled = false; - window.addEventListener('pointermove', onPointerMove); - window.addEventListener('pointerup', onPointerUp); - }); - - btn.addEventListener('click', ev => { - // Don't open the hotspot row in the form; just scroll its row - // into view to make it findable after dragging. - ev.preventDefault(); - var row = self.ideviceBody.querySelector( - '#threeSixtyHotspotList .three-sixty-hotspot-item[data-hotspot-index="' + hotspotIndex + '"]', - ); - if (row && typeof row.scrollIntoView === 'function') { - row.scrollIntoView({ behavior: 'smooth', block: 'center' }); - row.classList.add('is-highlighted'); - setTimeout(() => row.classList.remove('is-highlighted'), 1200); - } - }); - }, - - _captureDragEvents: canvas => { - if (!canvas || !canvas.addEventListener) return; - try { - canvas.setAttribute('contenteditable', 'false'); - canvas.setAttribute('draggable', 'false'); - } catch (_) { - /* ignore */ - } - var stop = e => { - if (e && typeof e.stopPropagation === 'function') e.stopPropagation(); - }; - var stopAndPrevent = e => { - if (!e) return; - if (typeof e.stopPropagation === 'function') e.stopPropagation(); - if (typeof e.preventDefault === 'function') e.preventDefault(); - }; - ['mousedown', 'pointerdown', 'touchstart', 'wheel'].forEach(evt => { - canvas.addEventListener(evt, stop, { passive: false }); - }); - canvas.addEventListener('dragstart', stopAndPrevent, { capture: true }); - canvas.addEventListener('dragstart', stopAndPrevent); - canvas.addEventListener('selectstart', stopAndPrevent); - }, - - /** - * Convert a click position on the canvas into yaw/pitch in degrees, - * by unprojecting the NDC-space click ray to a world direction. - */ - _clickToYawPitch: function (camera, canvas, clientX, clientY) { - if (!camera || !canvas || typeof THREE === 'undefined' || !THREE.Vector3) return null; - var rect = canvas.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return null; - var ndcX = ((clientX - rect.left) / rect.width) * 2 - 1; - var ndcY = -((clientY - rect.top) / rect.height) * 2 + 1; - var v; - try { - v = new THREE.Vector3(ndcX, ndcY, 0.5); - if (typeof v.unproject !== 'function') return null; - v.unproject(camera); - } catch (_) { - return null; - } - var dx = v.x - camera.position.x; - var dy = v.y - camera.position.y; - var dz = v.z - camera.position.z; - var len = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1; - dx /= len; - dy /= len; - dz /= len; - var yaw = (Math.atan2(dx, dz) * 180) / Math.PI; - var pitch = (Math.asin(this.clamp(dy, -1, 1)) * 180) / Math.PI; - return { - yaw: this.clamp(yaw, -180, 180), - pitch: this.clamp(pitch, -90, 90), - }; - }, - - refreshPlacementMode: function () { - var body = this.ideviceBody; - if (!body) return; - var btn = body.querySelector('#threeSixtyPlaceHotspot'); - var stage = body.querySelector('#threeSixtyPreview'); - if (this._placingHotspot) { - if (btn) btn.classList.add('active'); - if (stage) stage.classList.add('three-sixty-preview-stage--placing'); - } else { - if (btn) btn.classList.remove('active'); - if (stage) stage.classList.remove('three-sixty-preview-stage--placing'); - } - }, - - _createNavControls: function (host, preview) { - var nav = document.createElement('div'); - nav.className = 'three-sixty-viewer-nav'; - nav.setAttribute('role', 'group'); - nav.setAttribute('aria-label', _('Pan navigation')); - var YAW_STEP = (15 * Math.PI) / 180; - var PITCH_STEP = (10 * Math.PI) / 180; - // Camera lives inside an inverted sphere, so visible "right" = camera - // azimuth decreases. Match button-arrow direction to the apparent pan. - var directions = [ - { key: 'left', glyph: '←', label: _('Pan left'), dYaw: YAW_STEP, dPitch: 0 }, - { key: 'up', glyph: '↑', label: _('Pan up'), dYaw: 0, dPitch: -PITCH_STEP }, - { key: 'down', glyph: '↓', label: _('Pan down'), dYaw: 0, dPitch: PITCH_STEP }, - { key: 'right', glyph: '→', label: _('Pan right'), dYaw: -YAW_STEP, dPitch: 0 }, - ]; - var self = this; - directions.forEach(d => { - var btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'three-sixty-viewer-nav-btn three-sixty-viewer-nav-' + d.key; - btn.setAttribute('aria-label', d.label); - btn.setAttribute('title', d.label); - btn.textContent = d.glyph; - btn.addEventListener('click', () => { - self._nudgePreviewCamera(preview, d.dYaw, d.dPitch); - }); - nav.appendChild(btn); - }); - host.appendChild(nav); - return nav; - }, - - _nudgePreviewCamera: function (preview, dYaw, dPitch) { - if (!preview || !preview.camera) return; - var camera = preview.camera; - var controls = preview.controls; - var pos = camera.position; - var radius = (typeof pos.length === 'function' ? pos.length() : 0) || 0.01; - var azimuth = - controls && typeof controls.getAzimuthalAngle === 'function' - ? controls.getAzimuthalAngle() - : Math.atan2(pos.x || 0, pos.z || 0); - var polar = - controls && typeof controls.getPolarAngle === 'function' - ? controls.getPolarAngle() - : Math.acos(this.clamp((pos.y || 0) / radius, -1, 1)); - azimuth += dYaw; - polar = this.clamp(polar - dPitch, 0.05, Math.PI - 0.05); - var sinPolar = Math.sin(polar); - if (typeof pos.set === 'function') { - pos.set( - radius * sinPolar * Math.sin(azimuth), - radius * Math.cos(polar), - radius * sinPolar * Math.cos(azimuth), - ); - } - if (typeof camera.lookAt === 'function') camera.lookAt(0, 0, 0); - if (controls && typeof controls.update === 'function') controls.update(); - }, - - applyPreviewState: function () { - var p = this._preview; - if (!p) return; - // Keep the overlay in sync with whichever scene is now active. - if (p.overlay && (!p.hotspotButtons || p.hotspotButtons.length !== this.getActiveScene().hotspots.length)) { - this._renderEditorHotspots(); - } - // Flat preview has no camera/controls; the is static. - if (p.mode === 'flat') return; - var scene = this.getActiveScene(); - var iv = scene.initialView; - p.camera.fov = iv.fov; - if (typeof p.camera.updateProjectionMatrix === 'function') p.camera.updateProjectionMatrix(); - var yawRad = (iv.yaw * Math.PI) / 180; - var pitchRad = (iv.pitch * Math.PI) / 180; - var target = { - x: Math.sin(yawRad) * Math.cos(pitchRad), - y: Math.sin(pitchRad), - z: Math.cos(yawRad) * Math.cos(pitchRad), - }; - if (typeof p.camera.lookAt === 'function') { - p.camera.lookAt(target.x, target.y, target.z); - } - if (p.controls) { - p.controls.autoRotate = !!this.state.behaviour.autorotate.enabled; - p.controls.autoRotateSpeed = this.state.behaviour.autorotate.speed; - p.controls.enableZoom = !!this.state.behaviour.zoomEnabled; - } - }, - - destroyPreview: function () { - if (this._rafId) { - if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') { - window.cancelAnimationFrame(this._rafId); - } else { - clearTimeout(this._rafId); - } - this._rafId = null; - } - var p = this._preview; - if (!p) return; - try { - p.stop && p.stop(); - } catch (_) { - /* ignore */ - } - try { - p.controls && p.controls.dispose && p.controls.dispose(); - } catch (_) { - /* ignore */ - } - try { - p.geometry && p.geometry.dispose && p.geometry.dispose(); - } catch (_) { - /* ignore */ - } - try { - p.material && p.material.dispose && p.material.dispose(); - } catch (_) { - /* ignore */ - } - try { - p.texture && p.texture.dispose && p.texture.dispose(); - } catch (_) { - /* ignore */ - } - try { - p.renderer && p.renderer.dispose && p.renderer.dispose(); - } catch (_) { - /* ignore */ - } - if (p.renderer && p.renderer.domElement && p.renderer.domElement.parentNode) { - p.renderer.domElement.parentNode.removeChild(p.renderer.domElement); - } - // Flat preview cleanup: remove the and overlay we appended. - [p.img, p.overlay].forEach(el => { - if (el && el.parentNode) { - try { - el.parentNode.removeChild(el); - } catch (_) { - /* ignore */ - } - } - }); - this._preview = null; - }, -}; diff --git a/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.test.js b/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.test.js deleted file mode 100644 index 7761148599..0000000000 --- a/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.test.js +++ /dev/null @@ -1,1066 +0,0 @@ -/** - * Unit tests for three-sixty-viewer iDevice (edition) — v2 tour schema. - */ - -/* eslint-disable no-undef */ - -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -describe('three-sixty-viewer iDevice (edition)', () => { - let $exeDevice; - let container; - - beforeEach(() => { - global.$exeDevice = undefined; - document.body.innerHTML = ''; - container = document.createElement('div'); - container.setAttribute('idevice-id', 'idev-test'); - document.body.appendChild(container); - $exeDevice = global.loadIdevice(join(__dirname, 'three-sixty-viewer.js')); - }); - - afterEach(() => { - if ($exeDevice && typeof $exeDevice.destroyPreview === 'function') { - $exeDevice.destroyPreview(); - } - global.$exeDevice = undefined; - }); - - describe('normalizeData (v2 schema)', () => { - it('produces a one-scene tour from null input', () => { - const n = $exeDevice.normalizeData(null); - expect(n.version).toBe(2); - expect(Array.isArray(n.scenes)).toBe(true); - expect(n.scenes.length).toBe(1); - expect(n.scenes[0].src).toBe(''); - expect(n.scenes[0].initialView).toEqual({ yaw: 0, pitch: 0, fov: 75 }); - expect(n.scenes[0].hotspots).toEqual([]); - expect(n.startSceneId).toBe(n.scenes[0].id); - expect(n.behaviour.zoomEnabled).toBe(true); - expect(n.behaviour.imageAdjustments).toEqual({ brightness: 1, contrast: 1, saturation: 1 }); - }); - - it('produces a one-scene tour from undefined / non-object input', () => { - const n1 = $exeDevice.normalizeData(undefined); - expect(n1.scenes.length).toBe(1); - const n2 = $exeDevice.normalizeData('string'); - expect(n2.scenes.length).toBe(1); - expect(n2.scenes[0].initialView.fov).toBe(75); - }); - - it('clamps yaw / pitch / fov inside scene initialView', () => { - const big = $exeDevice.normalizeData({ initialView: { yaw: 500, pitch: 200, fov: 5 } }); - expect(big.scenes[0].initialView.yaw).toBe(180); - expect(big.scenes[0].initialView.pitch).toBe(90); - expect(big.scenes[0].initialView.fov).toBe(30); - }); - - it('clamps autorotate.speed in behaviour', () => { - expect($exeDevice.normalizeData({ autorotate: { speed: 50 } }).behaviour.autorotate.speed).toBe(10); - expect($exeDevice.normalizeData({ autorotate: { speed: -5 } }).behaviour.autorotate.speed).toBe(0); - }); - - it('coerces numeric strings inside initialView', () => { - const n = $exeDevice.normalizeData({ initialView: { yaw: '45', fov: '90' } }); - expect(n.scenes[0].initialView.yaw).toBe(45); - expect(n.scenes[0].initialView.fov).toBe(90); - }); - - it('falls back to defaults for non-numeric initialView values', () => { - const n = $exeDevice.normalizeData({ initialView: { yaw: 'abc', fov: {} } }); - expect(n.scenes[0].initialView.yaw).toBe(0); - expect(n.scenes[0].initialView.fov).toBe(75); - }); - - it('preserves explicit false for zoom / fullscreen on the behaviour block', () => { - const n = $exeDevice.normalizeData({ zoomEnabled: false, fullscreenEnabled: false }); - expect(n.behaviour.zoomEnabled).toBe(false); - expect(n.behaviour.fullscreenEnabled).toBe(false); - }); - - it('ignores malformed nested objects', () => { - const n = $exeDevice.normalizeData({ initialView: 'bad', autorotate: null }); - expect(n.scenes[0].initialView.yaw).toBe(0); - expect(n.behaviour.autorotate.enabled).toBe(false); - }); - }); - - describe('migrateToV2', () => { - it('lifts a v1 single-image payload into a one-scene tour', () => { - const v1 = { - ideviceId: 'idev-1', - src: 'asset://pano.jpg', - alt: 'Mountain', - initialView: { yaw: 90, pitch: 30, fov: 60 }, - autorotate: { enabled: true, speed: 2 }, - zoomEnabled: false, - fullscreenEnabled: true, - }; - const v2 = $exeDevice.normalizeData(v1); - expect(v2.version).toBe(2); - expect(v2.ideviceId).toBe('idev-1'); - expect(v2.scenes.length).toBe(1); - expect(v2.scenes[0].src).toBe('asset://pano.jpg'); - expect(v2.scenes[0].alt).toBe('Mountain'); - expect(v2.scenes[0].initialView).toEqual({ yaw: 90, pitch: 30, fov: 60 }); - expect(v2.behaviour.autorotate).toEqual({ enabled: true, speed: 2 }); - expect(v2.behaviour.zoomEnabled).toBe(false); - expect(v2.behaviour.fullscreenEnabled).toBe(true); - expect(v2.startSceneId).toBe(v2.scenes[0].id); - }); - - it('keeps v2 input unchanged through normalize', () => { - const v2 = { - version: 2, - startSceneId: 'scene-2', - scenes: [ - { id: 'scene-1', src: 'a.jpg', alt: 'A', hotspots: [] }, - { id: 'scene-2', src: 'b.jpg', alt: 'B', hotspots: [] }, - ], - behaviour: { renderQuality: 'medium', zoomEnabled: false }, - }; - const n = $exeDevice.normalizeData(v2); - expect(n.scenes.length).toBe(2); - expect(n.startSceneId).toBe('scene-2'); - expect(n.behaviour.renderQuality).toBe('medium'); - expect(n.behaviour.zoomEnabled).toBe(false); - }); - - it('falls back startSceneId to the first scene when the requested id is missing', () => { - const n = $exeDevice.normalizeData({ - version: 2, - startSceneId: 'does-not-exist', - scenes: [{ id: 'only-one' }], - behaviour: {}, - }); - expect(n.startSceneId).toBe('only-one'); - }); - - it('clamps imageAdjustments and rejects unknown render quality / label position', () => { - const n = $exeDevice.normalizeData({ - version: 2, - scenes: [{ id: 's1' }], - behaviour: { - renderQuality: 'ultra', - labelPosition: 'somewhere', - imageAdjustments: { brightness: 99, contrast: -1, saturation: 'x' }, - }, - }); - expect(n.behaviour.renderQuality).toBe('high'); - expect(n.behaviour.labelPosition).toBe('right'); - expect(n.behaviour.imageAdjustments.brightness).toBe(3); - expect(n.behaviour.imageAdjustments.contrast).toBe(0.1); - expect(n.behaviour.imageAdjustments.saturation).toBe(1); - }); - }); - - describe('scene management helpers', () => { - beforeEach(() => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - }); - - it('addScene appends a new scene with a unique id', () => { - const before = $exeDevice.state.scenes.length; - const added = $exeDevice.addScene(); - expect($exeDevice.state.scenes.length).toBe(before + 1); - expect(added.id).not.toBe($exeDevice.state.scenes[0].id); - }); - - it('duplicateScene clones a scene and gives it a new id and new hotspot ids', () => { - const sc = $exeDevice.state.scenes[0]; - sc.title = 'Original'; - sc.hotspots.push($exeDevice.normalizeHotspot({ id: 'hs-1', label: 'A' })); - const copy = $exeDevice.duplicateScene(0); - expect($exeDevice.state.scenes.length).toBe(2); - expect(copy.id).not.toBe(sc.id); - expect(copy.title).toContain('copy'); - expect(copy.hotspots.length).toBe(1); - expect(copy.hotspots[0].id).not.toBe('hs-1'); - }); - - it('removeScene falls back to a default scene when the last one is removed', () => { - $exeDevice.removeScene(0); - expect($exeDevice.state.scenes.length).toBe(1); - expect($exeDevice.state.startSceneId).toBe($exeDevice.state.scenes[0].id); - }); - - it('removeScene repairs goToScene hotspots that pointed at the removed scene', () => { - $exeDevice.addScene(); - const target = $exeDevice.state.scenes[1]; - $exeDevice.state.scenes[0].hotspots.push( - $exeDevice.normalizeHotspot({ - action: { type: 'goToScene', payload: { sceneId: target.id } }, - }), - ); - $exeDevice.removeScene(1); - expect($exeDevice.state.scenes[0].hotspots[0].action.payload.sceneId).toBe(''); - }); - - it('setStartScene keeps current start when requested id is missing', () => { - const original = $exeDevice.state.startSceneId; - $exeDevice.setStartScene('non-existent'); - expect($exeDevice.state.startSceneId).toBe(original); - }); - - it('addHotspot clamps yaw/pitch and pushes onto active scene', () => { - const h = $exeDevice.addHotspot(500, 200); - expect(h.yaw).toBe(180); - expect(h.pitch).toBe(90); - expect($exeDevice.getActiveScene().hotspots).toContain(h); - }); - }); - - describe('hotspot normalization', () => { - it('normalizes unknown action type to text', () => { - const h = $exeDevice.normalizeHotspot({ action: { type: 'mystery', payload: {} } }); - expect(h.action.type).toBe('text'); - expect(h.action.payload).toEqual({ html: '' }); - }); - - it('clamps yaw and pitch', () => { - const h = $exeDevice.normalizeHotspot({ yaw: -500, pitch: 999 }); - expect(h.yaw).toBe(-180); - expect(h.pitch).toBe(90); - }); - - it('keeps a well-formed link payload and defaults newTab to true', () => { - const h = $exeDevice.normalizeHotspot({ - action: { type: 'link', payload: { url: 'https://example.com/x' } }, - }); - expect(h.action.type).toBe('link'); - expect(h.action.payload.url).toBe('https://example.com/x'); - expect(h.action.payload.newTab).toBe(true); - }); - - it('coerces non-string link url to empty string and preserves explicit newTab=false', () => { - const h = $exeDevice.normalizeHotspot({ - action: { type: 'link', payload: { url: 123, newTab: false } }, - }); - expect(h.action.payload.url).toBe(''); - expect(h.action.payload.newTab).toBe(false); - }); - - it('exposes "link" as a valid hotspot action type', () => { - expect($exeDevice.HOTSPOT_ACTION_TYPES).toContain('link'); - expect($exeDevice.actionTypeLabel('link')).toMatch(/link/i); - }); - }); - - describe('init with empty previousData', () => { - beforeEach(() => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - }); - - it('applies defaults to scene 0 internal state', () => { - expect($exeDevice.state.scenes[0].src).toBe(''); - expect($exeDevice.state.scenes[0].initialView.fov).toBe(75); - expect($exeDevice.state.behaviour.zoomEnabled).toBe(true); - }); - - it('renders the form HTML with all expected controls', () => { - const ids = [ - '#threeSixtyImageButton', - '#threeSixtyAlt', - '#threeSixtyYaw', - '#threeSixtyPitch', - '#threeSixtyFov', - '#threeSixtyAutorotate', - '#threeSixtyAutorotateSpeed', - '#threeSixtyZoom', - '#threeSixtyFullscreen', - '#threeSixtyShowLabels', - '#threeSixtyPreview', - '#threeSixtyPreviewMessage', - '#threeSixtySceneList', - '#threeSixtyAddScene', - '#threeSixtyHotspotList', - '#threeSixtyAddHotspot', - '#threeSixtySceneTitle', - '#threeSixtySceneDescription', - ]; - ids.forEach(id => { - expect(container.querySelector(id)).not.toBeNull(); - }); - }); - - it('shows empty-state message when no image is selected', () => { - const label = container.querySelector('#threeSixtyImageName'); - expect(label.textContent).toMatch(/No image selected/i); - }); - - it('hides the clear button when no image is selected', () => { - const clearBtn = container.querySelector('#threeSixtyImageClear'); - expect(clearBtn.hasAttribute('hidden')).toBe(true); - }); - - it('renders one entry in the scene list by default', () => { - const items = container.querySelectorAll('#threeSixtySceneList .three-sixty-scene-item'); - expect(items.length).toBe(1); - }); - }); - - describe('init with existing v1 data hydrates form from the migrated scene 0', () => { - beforeEach(() => { - $exeDevice.init( - container, - { - ideviceId: 'idev-42', - src: 'asset://abc.jpg', - alt: 'A mountain vista', - initialView: { yaw: 45, pitch: -10, fov: 100 }, - autorotate: { enabled: true, speed: 3.5 }, - zoomEnabled: false, - fullscreenEnabled: false, - }, - '', - ); - $exeDevice.updatePreviewSoon = function () {}; - }); - - it('applies migrated scene values into the form', () => { - expect(container.querySelector('#threeSixtyAlt').value).toBe('A mountain vista'); - expect(container.querySelector('#threeSixtyYaw').value).toBe('45'); - expect(container.querySelector('#threeSixtyPitch').value).toBe('-10'); - expect(container.querySelector('#threeSixtyFov').value).toBe('100'); - expect(container.querySelector('#threeSixtyAutorotateSpeed').value).toBe('3.5'); - expect(container.querySelector('#threeSixtyAutorotate').checked).toBe(true); - expect(container.querySelector('#threeSixtyZoom').checked).toBe(false); - expect(container.querySelector('#threeSixtyFullscreen').checked).toBe(false); - }); - - it('shows the image label and clear button when scene 0 has a src', () => { - const label = container.querySelector('#threeSixtyImageName'); - expect(label.textContent).toContain('asset://abc.jpg'); - const clearBtn = container.querySelector('#threeSixtyImageClear'); - expect(clearBtn.hasAttribute('hidden')).toBe(false); - }); - - it('truncates very long src labels', () => { - const longSrc = 'a'.repeat(200); - expect($exeDevice.truncateLabel(longSrc).length).toBeLessThan(longSrc.length); - expect($exeDevice.truncateLabel(longSrc)).toContain('…'); - }); - }); - - describe('save()', () => { - it('returns a v2-shaped JSON object', () => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - const result = $exeDevice.save(); - expect(typeof result).toBe('object'); - expect(result.version).toBe(2); - expect(Array.isArray(result.scenes)).toBe(true); - expect(result.scenes.length).toBe(1); - expect(result.behaviour).toBeDefined(); - }); - - it('preserves ideviceId from the container attribute', () => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - const result = $exeDevice.save(); - expect(result.ideviceId).toBe('idev-test'); - }); - - it('re-reads the active scene alt input at save time', () => { - $exeDevice.init(container, { alt: 'initial' }, ''); - $exeDevice.updatePreviewSoon = function () {}; - container.querySelector('#threeSixtyAlt').value = 'updated at save'; - const result = $exeDevice.save(); - expect(result.scenes[0].alt).toBe('updated at save'); - }); - - it('round-trips a v2 tour without data loss', () => { - const original = { - version: 2, - ideviceId: 'idev-test', - startSceneId: 'scene-2', - scenes: [ - { - id: 'scene-1', - title: 'A', - src: 'asset://a.jpg', - alt: 'A scene', - initialView: { yaw: 0, pitch: 0, fov: 75 }, - hotspots: [], - }, - { - id: 'scene-2', - title: 'B', - src: 'asset://b.jpg', - alt: 'B scene', - initialView: { yaw: 30, pitch: 15, fov: 90 }, - hotspots: [ - { - id: 'hs-1', - label: 'Go', - icon: 'circle', - yaw: 10, - pitch: 5, - action: { type: 'goToScene', payload: { sceneId: 'scene-1' } }, - }, - ], - }, - ], - behaviour: { - autorotate: { enabled: true, speed: 2 }, - zoomEnabled: false, - fullscreenEnabled: true, - renderQuality: 'high', - showLabels: true, - labelPosition: 'right', - imageAdjustments: { brightness: 1, contrast: 1, saturation: 1 }, - }, - }; - $exeDevice.init(container, original, ''); - $exeDevice.updatePreviewSoon = function () {}; - const first = $exeDevice.save(); - expect(first.scenes.length).toBe(2); - expect(first.startSceneId).toBe('scene-2'); - - // Fresh instance, same data - document.body.innerHTML = ''; - const container2 = document.createElement('div'); - container2.setAttribute('idevice-id', 'idev-test'); - document.body.appendChild(container2); - global.$exeDevice = undefined; - const $d2 = global.loadIdevice(join(__dirname, 'three-sixty-viewer.js')); - $d2.init(container2, first, ''); - $d2.updatePreviewSoon = function () {}; - const second = $d2.save(); - expect(second).toEqual(first); - }); - }); - - describe('form event wiring', () => { - beforeEach(() => { - $exeDevice.init(container, {}, ''); - // Prevent preview loading attempts during tests - $exeDevice.updatePreviewSoon = function () {}; - }); - - it('updating #threeSixtyYaw updates active scene state', () => { - const yaw = container.querySelector('#threeSixtyYaw'); - yaw.value = '60'; - yaw.dispatchEvent(new Event('input')); - expect($exeDevice.getActiveScene().initialView.yaw).toBe(60); - }); - - it('updating #threeSixtyAlt updates active scene state', () => { - const alt = container.querySelector('#threeSixtyAlt'); - alt.value = 'new alt text'; - alt.dispatchEvent(new Event('input')); - expect($exeDevice.getActiveScene().alt).toBe('new alt text'); - }); - - it('toggling #threeSixtyAutorotate updates behaviour state', () => { - const cb = container.querySelector('#threeSixtyAutorotate'); - cb.checked = true; - cb.dispatchEvent(new Event('change')); - expect($exeDevice.state.behaviour.autorotate.enabled).toBe(true); - }); - - it('toggling #threeSixtyZoom updates behaviour state', () => { - const cb = container.querySelector('#threeSixtyZoom'); - cb.checked = false; - cb.dispatchEvent(new Event('change')); - expect($exeDevice.state.behaviour.zoomEnabled).toBe(false); - }); - - it('toggling #threeSixtyFullscreen updates behaviour state', () => { - const cb = container.querySelector('#threeSixtyFullscreen'); - cb.checked = false; - cb.dispatchEvent(new Event('change')); - expect($exeDevice.state.behaviour.fullscreenEnabled).toBe(false); - }); - - it('clear button wipes active scene src and refreshes label', () => { - $exeDevice.getActiveScene().src = 'asset://x.jpg'; - $exeDevice.refreshImageLabel(); - const clearBtn = container.querySelector('#threeSixtyImageClear'); - clearBtn.dispatchEvent(new Event('click')); - expect($exeDevice.getActiveScene().src).toBe(''); - const label = container.querySelector('#threeSixtyImageName'); - expect(label.textContent).toMatch(/No image selected/i); - }); - - it('clamps yaw input above the max', () => { - const yaw = container.querySelector('#threeSixtyYaw'); - yaw.value = '999'; - yaw.dispatchEvent(new Event('input')); - expect($exeDevice.getActiveScene().initialView.yaw).toBe(180); - }); - - it('add-scene button appends a scene', () => { - const before = $exeDevice.state.scenes.length; - container.querySelector('#threeSixtyAddScene').dispatchEvent(new Event('click')); - expect($exeDevice.state.scenes.length).toBe(before + 1); - expect(container.querySelectorAll('#threeSixtySceneList .three-sixty-scene-item').length).toBe(before + 1); - }); - - it('add-hotspot button creates a hotspot in the active scene', () => { - container.querySelector('#threeSixtyAddHotspot').dispatchEvent(new Event('click')); - expect($exeDevice.getActiveScene().hotspots.length).toBe(1); - expect(container.querySelectorAll('#threeSixtyHotspotList .three-sixty-hotspot-item').length).toBe(1); - }); - - it('toggling #threeSixtyNavControls updates behaviour state', () => { - const cb = container.querySelector('#threeSixtyNavControls'); - expect(cb).not.toBeNull(); - cb.checked = false; - cb.dispatchEvent(new Event('change')); - expect($exeDevice.state.behaviour.showNavControls).toBe(false); - }); - }); - - describe('pickImage', () => { - it('calls filemanager.show when available', () => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - const show = vi.fn(); - global.eXeLearning = { - app: { modals: { filemanager: { show } } }, - }; - $exeDevice.pickImage(); - expect(show).toHaveBeenCalledTimes(1); - const args = show.mock.calls[0][0]; - expect(args.accept).toBe('image'); - expect(args.multiSelect).toBe(false); - expect(typeof args.onSelect).toBe('function'); - }); - - it('onSelect sets the src on the active scene', () => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - let savedOpts = null; - global.eXeLearning = { - app: { - modals: { - filemanager: { - show: opts => { - savedOpts = opts; - }, - }, - }, - }, - }; - $exeDevice.pickImage(); - savedOpts.onSelect({ assetUrl: 'asset://new-pano.jpg', blobUrl: 'blob:x' }); - expect($exeDevice.getActiveScene().src).toBe('asset://new-pano.jpg'); - }); - }); - - describe('applyColorManagement', () => { - beforeEach(() => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - }); - - afterEach(() => { - delete global.THREE; - if (typeof window !== 'undefined') delete window.THREE; - }); - - it('is a no-op when THREE is undefined', () => { - const renderer = { outputColorSpace: '' }; - expect(() => $exeDevice.applyColorManagement(renderer)).not.toThrow(); - expect(renderer.outputColorSpace).toBe(''); - }); - - it('is a no-op when renderer is null', () => { - global.THREE = { SRGBColorSpace: 'srgb' }; - expect(() => $exeDevice.applyColorManagement(null)).not.toThrow(); - }); - - it('sets renderer.outputColorSpace = SRGBColorSpace on modern three.js', () => { - global.THREE = { - SRGBColorSpace: 'srgb', - NoToneMapping: 0, - ColorManagement: { enabled: false }, - }; - const renderer = { outputColorSpace: '', toneMapping: 1, toneMappingExposure: 0.5 }; - $exeDevice.applyColorManagement(renderer); - expect(renderer.outputColorSpace).toBe('srgb'); - expect(renderer.toneMapping).toBe(0); - expect(renderer.toneMappingExposure).toBe(1); - expect(global.THREE.ColorManagement.enabled).toBe(true); - }); - - it('falls back to renderer.outputEncoding = sRGBEncoding on legacy three.js', () => { - global.THREE = { sRGBEncoding: 3001 }; - const renderer = { outputEncoding: 0 }; - $exeDevice.applyColorManagement(renderer); - expect(renderer.outputEncoding).toBe(3001); - }); - - it('does not write toneMapping when property is missing', () => { - global.THREE = { SRGBColorSpace: 'srgb' }; - const renderer = { outputColorSpace: '' }; - $exeDevice.applyColorManagement(renderer); - expect(renderer.outputColorSpace).toBe('srgb'); - expect('toneMapping' in renderer).toBe(false); - }); - }); - - describe('applyTextureColorSpace', () => { - afterEach(() => { - delete global.THREE; - }); - - it('is a no-op for null texture', () => { - global.THREE = { SRGBColorSpace: 'srgb' }; - expect(() => $exeDevice.applyTextureColorSpace(null)).not.toThrow(); - }); - - it('sets colorSpace on a modern texture', () => { - global.THREE = { SRGBColorSpace: 'srgb' }; - const texture = { colorSpace: '' }; - $exeDevice.applyTextureColorSpace(texture); - expect(texture.colorSpace).toBe('srgb'); - }); - - it('falls back to encoding on a legacy texture', () => { - global.THREE = { sRGBEncoding: 3001 }; - const texture = { encoding: 0 }; - $exeDevice.applyTextureColorSpace(texture); - expect(texture.encoding).toBe(3001); - }); - }); - - describe('link hotspot form wiring', () => { - beforeEach(() => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - // Seed one hotspot so the list renders payload inputs we can target. - $exeDevice.addHotspot(0, 0); - $exeDevice.renderHotspotList(); - }); - - it('switching action type to "link" renders a URL input and a newTab checkbox', () => { - const select = container.querySelector('.hotspot-action-type'); - select.value = 'link'; - select.dispatchEvent(new Event('change', { bubbles: true })); - expect(container.querySelector('.hotspot-payload-url')).not.toBeNull(); - expect(container.querySelector('.hotspot-payload-newTab')).not.toBeNull(); - }); - - it('typing in the URL input updates the active hotspot payload', () => { - const select = container.querySelector('.hotspot-action-type'); - select.value = 'link'; - select.dispatchEvent(new Event('change', { bubbles: true })); - const url = container.querySelector('.hotspot-payload-url'); - url.value = 'https://exelearning.net'; - url.dispatchEvent(new Event('input', { bubbles: true })); - expect($exeDevice.getActiveScene().hotspots[0].action.payload.url).toBe('https://exelearning.net'); - }); - - it('unchecking newTab updates the active hotspot payload', () => { - const select = container.querySelector('.hotspot-action-type'); - select.value = 'link'; - select.dispatchEvent(new Event('change', { bubbles: true })); - const cb = container.querySelector('.hotspot-payload-newTab'); - expect(cb.checked).toBe(true); - cb.checked = false; - cb.dispatchEvent(new Event('change', { bubbles: true })); - expect($exeDevice.getActiveScene().hotspots[0].action.payload.newTab).toBe(false); - }); - - it('round-trips a link hotspot through save()/normalizeData()', () => { - const select = container.querySelector('.hotspot-action-type'); - select.value = 'link'; - select.dispatchEvent(new Event('change', { bubbles: true })); - const url = container.querySelector('.hotspot-payload-url'); - url.value = 'https://example.org'; - url.dispatchEvent(new Event('input', { bubbles: true })); - const saved = $exeDevice.save(); - const hs = saved.scenes[0].hotspots[0]; - expect(hs.action.type).toBe('link'); - expect(hs.action.payload.url).toBe('https://example.org'); - expect(hs.action.payload.newTab).toBe(true); - }); - }); - - describe('placement mode (click on panorama to add hotspot)', () => { - beforeEach(() => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - }); - - it('toggles _placingHotspot when the button is clicked', () => { - expect($exeDevice._placingHotspot).toBeFalsy(); - container.querySelector('#threeSixtyPlaceHotspot').dispatchEvent(new Event('click')); - expect($exeDevice._placingHotspot).toBe(true); - container.querySelector('#threeSixtyPlaceHotspot').dispatchEvent(new Event('click')); - expect($exeDevice._placingHotspot).toBe(false); - }); - - it('refreshPlacementMode adds/removes the active + placing classes', () => { - $exeDevice._placingHotspot = true; - $exeDevice.refreshPlacementMode(); - expect(container.querySelector('#threeSixtyPlaceHotspot').classList.contains('active')).toBe(true); - expect( - container - .querySelector('#threeSixtyPreview') - .classList.contains('three-sixty-preview-stage--placing'), - ).toBe(true); - $exeDevice._placingHotspot = false; - $exeDevice.refreshPlacementMode(); - expect(container.querySelector('#threeSixtyPlaceHotspot').classList.contains('active')).toBe(false); - expect( - container - .querySelector('#threeSixtyPreview') - .classList.contains('three-sixty-preview-stage--placing'), - ).toBe(false); - }); - }); - - describe('_clickToYawPitch (pure projection math)', () => { - beforeEach(() => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - global.THREE = { - Vector3: class { - constructor(x, y, z) { - this.x = x; - this.y = y; - this.z = z; - } - // unproject simulates a camera looking forward at +Z; NDC center - // maps to (0, 0, +radius), so the click direction equals (x, y, z). - unproject() { - // identity: pretend NDC == world for this stub - return this; - } - }, - }; - }); - - afterEach(() => { - delete global.THREE; - }); - - it('returns null when canvas has zero size', () => { - const canvas = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }), - }; - const camera = { position: { x: 0, y: 0, z: 0 } }; - expect($exeDevice._clickToYawPitch(camera, canvas, 0, 0)).toBeNull(); - }); - - it('returns null when THREE is missing', () => { - delete global.THREE; - const canvas = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), - }; - const camera = { position: { x: 0, y: 0, z: 0 } }; - expect($exeDevice._clickToYawPitch(camera, canvas, 50, 50)).toBeNull(); - }); - - it('center click of a forward-looking camera yields ~0 yaw and ~0 pitch', () => { - const canvas = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), - }; - const camera = { position: { x: 0, y: 0, z: 0 } }; - const pose = $exeDevice._clickToYawPitch(camera, canvas, 50, 50); - expect(pose).not.toBeNull(); - // NDC (0, 0) with z=0.5 produces direction (0, 0, 0.5) → yaw=0, pitch=0 - expect(Math.abs(pose.yaw)).toBeLessThan(1); - expect(Math.abs(pose.pitch)).toBeLessThan(1); - }); - - it('clicking left of center yields negative yaw', () => { - const canvas = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), - }; - const camera = { position: { x: 0, y: 0, z: 0 } }; - // Click at x=10 → ndcX ≈ -0.8; with our identity unproject this means dx<0 → yaw<0 - const pose = $exeDevice._clickToYawPitch(camera, canvas, 10, 50); - expect(pose.yaw).toBeLessThan(0); - }); - - it('clicking above center yields positive pitch', () => { - const canvas = { - getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), - }; - const camera = { position: { x: 0, y: 0, z: 0 } }; - // Click at y=10 (above) → ndcY ≈ +0.8 → dy>0 → pitch>0 - const pose = $exeDevice._clickToYawPitch(camera, canvas, 50, 10); - expect(pose.pitch).toBeGreaterThan(0); - }); - }); - - describe('destroyPreview', () => { - it('is safe to call when there is no preview', () => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - expect(() => $exeDevice.destroyPreview()).not.toThrow(); - }); - - it('disposes all three.js resources when a preview exists', () => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - const stops = []; - const disposes = []; - $exeDevice._preview = { - stop: () => stops.push(1), - controls: { dispose: () => disposes.push('controls') }, - geometry: { dispose: () => disposes.push('geometry') }, - material: { dispose: () => disposes.push('material') }, - texture: { dispose: () => disposes.push('texture') }, - renderer: { - dispose: () => disposes.push('renderer'), - domElement: document.createElement('canvas'), - }, - }; - $exeDevice.destroyPreview(); - expect(stops).toEqual([1]); - expect(disposes).toEqual( - expect.arrayContaining(['controls', 'geometry', 'material', 'texture', 'renderer']), - ); - expect($exeDevice._preview).toBeNull(); - }); - }); - - describe('flat (non-360) scenes', () => { - it('defaults scene projection to equirectangular', () => { - const n = $exeDevice.normalizeData(null); - expect(n.scenes[0].projection).toBe('equirectangular'); - }); - - it('keeps a flat projection through normalize and rejects unknown values', () => { - expect($exeDevice.normalizeScene({ projection: 'flat' }).projection).toBe('flat'); - expect($exeDevice.normalizeScene({ projection: 'bogus' }).projection).toBe('equirectangular'); - expect($exeDevice.normalizeScene({}).projection).toBe('equirectangular'); - }); - - it('migrated v1 data defaults to equirectangular projection', () => { - const n = $exeDevice.normalizeData({ src: 'old.jpg', initialView: { yaw: 10 } }); - expect(n.scenes[0].projection).toBe('equirectangular'); - }); - - it('normalizes and clamps hotspot x/y, defaulting to 50', () => { - expect($exeDevice.normalizeHotspot({}).x).toBe(50); - expect($exeDevice.normalizeHotspot({}).y).toBe(50); - expect($exeDevice.normalizeHotspot({ x: 200, y: -5 }).x).toBe(100); - expect($exeDevice.normalizeHotspot({ x: 200, y: -5 }).y).toBe(0); - expect($exeDevice.normalizeHotspot({ x: '30' }).x).toBe(30); - }); - - it('addHotspotFlat clamps x/y and pushes onto the active scene', () => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - const h = $exeDevice.addHotspotFlat(150, -10); - expect(h.x).toBe(100); - expect(h.y).toBe(0); - expect($exeDevice.getActiveScene().hotspots).toContain(h); - }); - - it('round-trips projection and hotspot x/y through save()', () => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - const scene = $exeDevice.getActiveScene(); - scene.projection = 'flat'; - scene.src = 'asset://flat.jpg'; - $exeDevice.addHotspotFlat(25, 75); - const saved = $exeDevice.save(); - expect(saved.scenes[0].projection).toBe('flat'); - expect(saved.scenes[0].hotspots[0].x).toBe(25); - expect(saved.scenes[0].hotspots[0].y).toBe(75); - }); - - describe('createForm with a flat scene', () => { - beforeEach(() => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - $exeDevice.getActiveScene().projection = 'flat'; - $exeDevice.createForm(); - $exeDevice.addFormBehaviour(); - }); - - it('checks the panorama toggle off and hides the Initial view fields', () => { - expect(container.querySelector('#threeSixtyIsPanorama').checked).toBe(false); - expect(container.querySelector('#threeSixtyYaw')).toBeNull(); - expect(container.querySelector('#threeSixtyPitch')).toBeNull(); - expect(container.querySelector('#threeSixtyFov')).toBeNull(); - }); - - it('renders X/Y inputs instead of yaw/pitch in the hotspot list', () => { - $exeDevice.addHotspotFlat(40, 60); - $exeDevice.renderHotspotList(); - expect(container.querySelector('#threeSixtyHotspotList .hotspot-x')).not.toBeNull(); - expect(container.querySelector('#threeSixtyHotspotList .hotspot-y')).not.toBeNull(); - expect(container.querySelector('#threeSixtyHotspotList .hotspot-yaw')).toBeNull(); - }); - - it('editing the X input updates the hotspot x', () => { - $exeDevice.addHotspotFlat(40, 60); - $exeDevice.renderHotspotList(); - const xInput = container.querySelector('#threeSixtyHotspotList .hotspot-x'); - xInput.value = '90'; - xInput.dispatchEvent(new Event('input', { bubbles: true })); - expect($exeDevice.getActiveScene().hotspots[0].x).toBe(90); - }); - - it('add-hotspot button places a flat hotspot at the centre', () => { - container.querySelector('#threeSixtyAddHotspot').dispatchEvent(new Event('click')); - const hs = $exeDevice.getActiveScene().hotspots; - expect(hs.length).toBe(1); - expect(hs[0].x).toBe(50); - expect(hs[0].y).toBe(50); - }); - }); - - it('toggling the panorama checkbox off switches the scene to flat', () => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - const cb = container.querySelector('#threeSixtyIsPanorama'); - expect(cb.checked).toBe(true); - cb.checked = false; - cb.dispatchEvent(new Event('change')); - expect($exeDevice.getActiveScene().projection).toBe('flat'); - // Form rebuilt: the new checkbox reflects flat state. - expect(container.querySelector('#threeSixtyIsPanorama').checked).toBe(false); - }); - - describe('containedImageRect', () => { - it('falls back to the full box when natural dimensions are missing', () => { - expect($exeDevice.containedImageRect(0, 0, 200, 100)).toEqual({ - left: 0, - top: 0, - width: 200, - height: 100, - }); - }); - - it('letterboxes a wide image inside a square box', () => { - const r = $exeDevice.containedImageRect(200, 100, 100, 100); - expect(r.width).toBe(100); - expect(r.height).toBe(50); - expect(r.top).toBe(25); - expect(r.left).toBe(0); - }); - - it('pillarboxes a tall image inside a wide box', () => { - const r = $exeDevice.containedImageRect(100, 200, 200, 100); - expect(r.width).toBe(50); - expect(r.height).toBe(100); - expect(r.left).toBe(75); - expect(r.top).toBe(0); - }); - }); - - describe('_clickToXY', () => { - it('converts a click to x/y percent within the image rect', () => { - const overlay = document.createElement('div'); - overlay.getBoundingClientRect = () => ({ left: 0, top: 0, width: 200, height: 100 }); - const img = { naturalWidth: 200, naturalHeight: 100 }; - const preview = { overlay, img }; - // Centre of a full-box image → 50/50 - expect($exeDevice._clickToXY(preview, 100, 50)).toEqual({ x: 50, y: 50 }); - // Top-left corner → clamped to 0/0 - expect($exeDevice._clickToXY(preview, 0, 0)).toEqual({ x: 0, y: 0 }); - }); - - it('returns null when the overlay has no size', () => { - const overlay = document.createElement('div'); - overlay.getBoundingClientRect = () => ({ left: 0, top: 0, width: 0, height: 0 }); - expect($exeDevice._clickToXY({ overlay, img: {} }, 10, 10)).toBeNull(); - }); - }); - - describe('createFlatPreview', () => { - it('renders an and overlay without three.js', () => { - $exeDevice.init(container, {}, ''); - $exeDevice.updatePreviewSoon = function () {}; - const scene = $exeDevice.getActiveScene(); - scene.projection = 'flat'; - scene.src = 'asset://flat.jpg'; - const stage = container.querySelector('#threeSixtyPreview'); - const preview = $exeDevice.createFlatPreview(stage); - expect(preview.mode).toBe('flat'); - expect(stage.querySelector('img.three-sixty-preview-flat')).not.toBeNull(); - expect(stage.querySelector('.three-sixty-viewer-overlay--editor')).not.toBeNull(); - preview.stop(); - $exeDevice._preview = preview; - $exeDevice.destroyPreview(); - expect(stage.querySelector('img.three-sixty-preview-flat')).toBeNull(); - }); - }); - - describe('flat preview rendering and interaction', () => { - let scene; - beforeEach(() => { - $exeDevice.init(container, {}, ''); - scene = $exeDevice.getActiveScene(); - scene.projection = 'flat'; - scene.src = 'asset://flat.jpg'; - // Give the stage a stable size so positioning math is deterministic. - const stage = container.querySelector('#threeSixtyPreview'); - stage.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 200 }); - }); - - it('renderPreview builds a flat preview without three.js', () => { - $exeDevice.renderPreview(); - expect($exeDevice._preview).not.toBeNull(); - expect($exeDevice._preview.mode).toBe('flat'); - expect(container.querySelector('#threeSixtyPreview img.three-sixty-preview-flat')).not.toBeNull(); - }); - - it('positions flat hotspots by x/y percent of the displayed image', () => { - $exeDevice.addHotspotFlat(25, 75); - $exeDevice.renderPreview(); - const p = $exeDevice._preview; - p.overlay.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 200 }); - $exeDevice._positionEditorHotspots(); - const btn = p.hotspotButtons[0].button; - expect(btn.style.left).toBe('100px'); // 25% of 400 - expect(btn.style.top).toBe('150px'); // 75% of 200 - }); - - it('dragging a flat hotspot updates its x/y from the pointer position', () => { - $exeDevice.addHotspotFlat(10, 10); - $exeDevice.renderPreview(); - const p = $exeDevice._preview; - p.overlay.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 200 }); - const btn = p.hotspotButtons[0].button; - - const down = new Event('pointerdown'); - down.clientX = 0; - down.clientY = 0; - btn.dispatchEvent(down); - - const move = new Event('pointermove'); - move.clientX = 200; - move.clientY = 100; - window.dispatchEvent(move); - - expect($exeDevice.getActiveScene().hotspots[0].x).toBe(50); - expect($exeDevice.getActiveScene().hotspots[0].y).toBe(50); - - window.dispatchEvent(new Event('pointerup')); - }); - - it('placement click on the flat preview adds a hotspot at the clicked point', () => { - $exeDevice.renderPreview(); - const p = $exeDevice._preview; - p.overlay.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 200 }); - $exeDevice._placingHotspot = true; - const img = p.img; - const click = new Event('click'); - click.clientX = 100; // 25% - click.clientY = 150; // 75% - img.dispatchEvent(click); - const hs = $exeDevice.getActiveScene().hotspots; - expect(hs.length).toBe(1); - expect(hs[0].x).toBe(25); - expect(hs[0].y).toBe(75); - expect($exeDevice._placingHotspot).toBe(false); - }); - }); - }); -}); diff --git a/public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js b/public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js deleted file mode 100644 index e37c93a8ad..0000000000 --- a/public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js +++ /dev/null @@ -1,1242 +0,0 @@ -/* eslint-disable no-undef */ -/** - * 360° panorama viewer iDevice (export/runtime code). - * Renders a v2 virtual tour: scenes + hotspots + accessible content modals. - * v1 single-image data is migrated transparently into a single-scene tour. - * - * JSON iDevice API (called by public/app/common/exe_export.js): - * renderView(data, accesibility, template) -> HTML string - * renderBehaviour(data, accesibility) -> attach three.js viewer - * init(data, accesibility) -> engine hook (no-op here) - * - * Released under Attribution-ShareAlike 4.0 International License. - * License: http://creativecommons.org/licenses/by-sa/4.0/ - */ - -var $threesixtyviewer = { - cssClass: 'three-sixty-viewer', - SCHEMA_VERSION: 2, - HOTSPOT_ACTION_TYPES: ['goToScene', 'text', 'image', 'video', 'link'], - RENDER_QUALITY_VALUES: ['low', 'medium', 'high'], - LABEL_POSITION_VALUES: ['right', 'left', 'top', 'bottom'], - _instances: [], - - // ───────────────────────────────────────────────────────────────────── - // JSON iDevice engine API (called by public/app/common/exe_export.js) - // ───────────────────────────────────────────────────────────────────── - - renderView: function (data, _accesibility, template) { - var state = this.normalize(data); - var startScene = this.getStartScene(state); - var altAttr = this.escapeAttr((startScene && startScene.alt) || '360° panorama'); - var body = '
'; - var tpl = typeof template === 'string' && template ? template : '{content}'; - return tpl.replace('{content}', body); - }, - - renderBehaviour: function (data, _accesibility) { - var state = this.normalize(data); - var id = data && data.ideviceId; - var node = id ? document.getElementById(id) : null; - if (!node) return; - - this._disposeNode(node); - - var wrapper = node.querySelector('.three-sixty-viewer-wrapper'); - if (!wrapper) { - wrapper = document.createElement('div'); - wrapper.className = 'three-sixty-viewer-wrapper'; - node.appendChild(wrapper); - } - while (wrapper.firstChild) wrapper.removeChild(wrapper.firstChild); - - var startScene = this.getStartScene(state); - wrapper.setAttribute('role', 'region'); - wrapper.setAttribute('aria-label', (startScene && startScene.alt) || '360° panorama'); - - if (!startScene || !startScene.src) { - this.renderFallback(wrapper, state, '(no image)'); - return; - } - if (!this.hasWebGL()) { - this.renderFallback(wrapper, state, (startScene && startScene.alt) || ''); - return; - } - if (typeof THREE === 'undefined') { - this.renderFallback(wrapper, state, (startScene && startScene.alt) || ''); - return; - } - - this._createViewer(wrapper, state); - }, - - init: (_data, _accesibility) => { - // no-op (engine contract) - }, - - // ───────────────────────────────────────────────────────────────────── - // State helpers (mirror edition/three-sixty-viewer.js) - // ───────────────────────────────────────────────────────────────────── - - normalize: function (data) { - var raw = data && typeof data === 'object' ? data : {}; - var v2 = this._migrateToV2(raw); - var scenes = - Array.isArray(v2.scenes) && v2.scenes.length > 0 - ? v2.scenes.map(this._normalizeScene, this) - : [this._defaultScene('scene-1')]; - var startSceneId = this._resolveStartSceneId(v2.startSceneId, scenes); - return { - version: this.SCHEMA_VERSION, - ideviceId: typeof raw.ideviceId === 'string' ? raw.ideviceId : '', - startSceneId: startSceneId, - scenes: scenes, - behaviour: this._normalizeBehaviour(v2.behaviour), - }; - }, - - _migrateToV2: function (data) { - if (data && data.version >= 2 && Array.isArray(data.scenes)) { - return { - scenes: data.scenes, - startSceneId: typeof data.startSceneId === 'string' ? data.startSceneId : '', - behaviour: data.behaviour && typeof data.behaviour === 'object' ? data.behaviour : {}, - }; - } - var hasV1Fields = - data && - (typeof data.src === 'string' || - typeof data.alt === 'string' || - data.initialView || - data.autorotate || - 'zoomEnabled' in data || - 'fullscreenEnabled' in data); - if (hasV1Fields) { - var scene = this._defaultScene('scene-1'); - scene.src = typeof data.src === 'string' ? data.src : ''; - scene.alt = typeof data.alt === 'string' ? data.alt : ''; - scene.initialView = this._normalizeInitialView(data.initialView); - return { - scenes: [scene], - startSceneId: 'scene-1', - behaviour: { - autorotate: data.autorotate || {}, - zoomEnabled: data.zoomEnabled, - fullscreenEnabled: data.fullscreenEnabled, - showNavControls: data.showNavControls, - }, - }; - } - return { scenes: [], startSceneId: '', behaviour: {} }; - }, - - _defaultScene: id => ({ - id: id || 'scene-' + Math.floor(Math.random() * 1e9).toString(36), - title: '', - src: '', - alt: '', - description: '', - projection: 'equirectangular', - initialView: { yaw: 0, pitch: 0, fov: 75 }, - hotspots: [], - }), - - _normalizeInitialView: function (iv) { - var s = iv && typeof iv === 'object' ? iv : {}; - return { - yaw: this.clamp(this.toNumber(s.yaw, 0), -180, 180), - pitch: this.clamp(this.toNumber(s.pitch, 0), -90, 90), - fov: this.clamp(this.toNumber(s.fov, 75), 30, 120), - }; - }, - - _normalizeScene: function (s, index) { - var src = s && typeof s === 'object' ? s : {}; - var fallbackId = 'scene-' + (typeof index === 'number' ? index + 1 : 1); - var hotspots = Array.isArray(src.hotspots) ? src.hotspots.map(this._normalizeHotspot, this) : []; - return { - id: typeof src.id === 'string' && src.id ? src.id : fallbackId, - title: typeof src.title === 'string' ? src.title : '', - src: typeof src.src === 'string' ? src.src : '', - alt: typeof src.alt === 'string' ? src.alt : '', - description: typeof src.description === 'string' ? src.description : '', - projection: src.projection === 'flat' ? 'flat' : 'equirectangular', - initialView: this._normalizeInitialView(src.initialView), - hotspots: hotspots, - }; - }, - - _normalizeHotspot: function (h) { - var src = h && typeof h === 'object' ? h : {}; - var actionRaw = src.action && typeof src.action === 'object' ? src.action : {}; - var type = this.HOTSPOT_ACTION_TYPES.indexOf(actionRaw.type) >= 0 ? actionRaw.type : 'text'; - var payload = actionRaw.payload && typeof actionRaw.payload === 'object' ? actionRaw.payload : {}; - return { - id: typeof src.id === 'string' && src.id ? src.id : 'hs-' + Math.floor(Math.random() * 1e9).toString(36), - label: typeof src.label === 'string' ? src.label : '', - icon: typeof src.icon === 'string' ? src.icon : 'circle', - yaw: this.clamp(this.toNumber(src.yaw, 0), -180, 180), - pitch: this.clamp(this.toNumber(src.pitch, 0), -90, 90), - x: this.clamp(this.toNumber(src.x, 50), 0, 100), - y: this.clamp(this.toNumber(src.y, 50), 0, 100), - action: { type: type, payload: this._normalizeHotspotPayload(type, payload) }, - }; - }, - - _normalizeHotspotPayload: (type, p) => { - switch (type) { - case 'goToScene': - return { sceneId: typeof p.sceneId === 'string' ? p.sceneId : '' }; - case 'text': - return { html: typeof p.html === 'string' ? p.html : '' }; - case 'image': - return { - src: typeof p.src === 'string' ? p.src : '', - alt: typeof p.alt === 'string' ? p.alt : '', - caption: typeof p.caption === 'string' ? p.caption : '', - }; - case 'video': - return { - src: typeof p.src === 'string' ? p.src : '', - poster: typeof p.poster === 'string' ? p.poster : '', - }; - case 'link': - return { - url: typeof p.url === 'string' ? p.url : '', - newTab: p.newTab !== false, - }; - default: - return {}; - } - }, - - _normalizeBehaviour: function (b) { - var src = b && typeof b === 'object' ? b : {}; - var ar = src.autorotate && typeof src.autorotate === 'object' ? src.autorotate : {}; - var ia = src.imageAdjustments && typeof src.imageAdjustments === 'object' ? src.imageAdjustments : {}; - var renderQuality = this.RENDER_QUALITY_VALUES.indexOf(src.renderQuality) >= 0 ? src.renderQuality : 'high'; - var labelPosition = this.LABEL_POSITION_VALUES.indexOf(src.labelPosition) >= 0 ? src.labelPosition : 'right'; - return { - autorotate: { - enabled: !!ar.enabled, - speed: this.clamp(this.toNumber(ar.speed, 1), 0, 10), - }, - zoomEnabled: src.zoomEnabled !== false, - fullscreenEnabled: src.fullscreenEnabled !== false, - showNavControls: src.showNavControls !== false, - renderQuality: renderQuality, - showLabels: src.showLabels !== false, - labelPosition: labelPosition, - imageAdjustments: { - brightness: this.clamp(this.toNumber(ia.brightness, 1), 0.1, 3), - contrast: this.clamp(this.toNumber(ia.contrast, 1), 0.1, 3), - saturation: this.clamp(this.toNumber(ia.saturation, 1), 0, 3), - }, - }; - }, - - _resolveStartSceneId: (requested, scenes) => { - if (!Array.isArray(scenes) || scenes.length === 0) return ''; - if (typeof requested === 'string' && requested) { - for (var i = 0; i < scenes.length; i++) { - if (scenes[i].id === requested) return requested; - } - } - return scenes[0].id; - }, - - getStartScene: state => { - if (!state || !Array.isArray(state.scenes) || state.scenes.length === 0) return null; - for (var i = 0; i < state.scenes.length; i++) { - if (state.scenes[i].id === state.startSceneId) return state.scenes[i]; - } - return state.scenes[0]; - }, - - findSceneById: (state, sceneId) => { - if (!state || !Array.isArray(state.scenes)) return null; - for (var i = 0; i < state.scenes.length; i++) { - if (state.scenes[i].id === sceneId) return state.scenes[i]; - } - return null; - }, - - toNumber: (v, fallback) => { - var n = typeof v === 'number' ? v : parseFloat(v); - return isFinite(n) ? n : fallback; - }, - - clamp: (v, min, max) => { - if (v < min) return min; - if (v > max) return max; - return v; - }, - - /** - * Rectangle a `object-fit: contain` image occupies inside a box (letterbox - * aware). Falls back to the full box when natural dimensions are unknown. - * Mirrors edition/three-sixty-viewer.js so editor and runtime agree on the - * flat-image hotspot coordinate basis. - */ - containedImageRect: (naturalW, naturalH, boxW, boxH) => { - if (!naturalW || !naturalH || !boxW || !boxH) { - return { left: 0, top: 0, width: boxW || 0, height: boxH || 0 }; - } - var scale = Math.min(boxW / naturalW, boxH / naturalH); - var w = naturalW * scale; - var h = naturalH * scale; - return { left: (boxW - w) / 2, top: (boxH - h) / 2, width: w, height: h }; - }, - - escapeAttr: s => - String(s == null ? '' : s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'), - - escapeHtml: s => - String(s == null ? '' : s) - .replace(/&/g, '&') - .replace(//g, '>'), - - /** - * Make WebGL output match the source panorama's apparent brightness/colour. - */ - applyColorManagement: renderer => { - if (typeof THREE === 'undefined' || !renderer) return; - if (THREE.ColorManagement && 'enabled' in THREE.ColorManagement) { - THREE.ColorManagement.enabled = true; - } - if ('outputColorSpace' in renderer && typeof THREE.SRGBColorSpace !== 'undefined') { - renderer.outputColorSpace = THREE.SRGBColorSpace; - } else if ('outputEncoding' in renderer && typeof THREE.sRGBEncoding !== 'undefined') { - renderer.outputEncoding = THREE.sRGBEncoding; - } - if ('toneMapping' in renderer && typeof THREE.NoToneMapping !== 'undefined') { - renderer.toneMapping = THREE.NoToneMapping; - renderer.toneMappingExposure = 1.0; - } - }, - - applyTextureColorSpace: texture => { - if (!texture || typeof THREE === 'undefined') return; - if ('colorSpace' in texture && typeof THREE.SRGBColorSpace !== 'undefined') { - texture.colorSpace = THREE.SRGBColorSpace; - } else if ('encoding' in texture && typeof THREE.sRGBEncoding !== 'undefined') { - texture.encoding = THREE.sRGBEncoding; - } - }, - - /** - * Legacy / test helper: extract state from a DOM node that carries either - * a data-idevice-json-data attribute or a nested ', alt: '' }], + }); + const html = formHtml(state, identity); + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + }); +}); + +describe('unsupportedVersionHtml', () => { + it('names the version and promises data preservation', () => { + const html = unsupportedVersionHtml(3, identity); + expect(html).toContain('role="alert"'); + expect(html).toContain('format version 3'); + expect(html).toContain('saving keeps it unchanged'); + }); +}); diff --git a/public/files/perm/idevices/base/three-sixty-viewer/src/edition/form.ts b/public/files/perm/idevices/base/three-sixty-viewer/src/edition/form.ts new file mode 100644 index 0000000000..735d6a78a2 --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/src/edition/form.ts @@ -0,0 +1,166 @@ +/** + * Pure HTML builders for the editor form. Everything derives from the typed + * editor state; no listeners are attached here. Control ids and classes are + * part of the editor's public surface (CSS + Playwright) and must not change. + * + * Layout mirrors the Interactive Video authoring surface: section heads with + * live counts → edit stage (preview) → add bar → single-editor accordion list + * → behaviour controls. Colour is never the sole cue. + * + * Released under Attribution-ShareAlike 4.0 International License. + * Author: eXeLearning - https://exelearning.net + */ + +import { escapeAttr, escapeHtml, truncateLabel } from '../shared/html'; +import type { Translate } from './i18n'; +import type { EditorState } from './state'; + +export function formHtml(state: EditorState, tr: Translate): string { + const scene = state.activeScene(); + const behaviour = state.doc.behaviour; + const isFlat = scene.projection === 'flat'; + // The "Initial view" controls (yaw/pitch/fov) only make sense on a 360° + // panorama; a flat photo is shown undistorted with no camera to aim. + const initialViewFieldset = isFlat + ? '' + : ` +
+ ${tr('Initial view')} +
+ + + + + + +
+
`; + + const hotspotHint = isFlat + ? tr('Click the image to place a hotspot, or drag an existing hotspot to move it.') + : tr('Click the panorama to place a hotspot, or drag an existing hotspot to move it.'); + + return ` +
+

${tr('Add equirectangular 360° images (2:1 aspect), or uncheck “360° panorama image” to use a regular flat photo. The viewer uses WebGL for 360° scenes.')}

+
+ +
+ + ${tr('Scenes')} + + +
+
+ +
+
+ +
+ ${tr('Active scene')} +
+ + +
+
+ +
+ + ${scene.src ? escapeHtml(truncateLabel(scene.src)) : tr('No image selected')} + +
+ +
+
+ + ${tr('Uncheck for a regular flat photo (no 360° effect).')} +
+
+ + +
+
+ + +
+ ${initialViewFieldset} +
+ +
+
+
+
+

${tr('Select an image to see a live preview.')}

+
+
+
+ +
+ + ${tr('Hotspots')} + + +

${hotspotHint}

+
+ + +
+ +
+
+
+ +
+ ${tr('Controls')} +
+ + + +
+
+ + + + +
+
+
+ `; +} + +/** + * Message shown INSTEAD of the form when the stored document comes from a + * newer schema version. Saving passes the original payload through untouched. + */ +export function unsupportedVersionHtml(version: number, tr: Translate): string { + return ` +
+ +
+ `; +} diff --git a/public/files/perm/idevices/base/three-sixty-viewer/src/edition/hotspot-editor.spec.ts b/public/files/perm/idevices/base/three-sixty-viewer/src/edition/hotspot-editor.spec.ts new file mode 100644 index 0000000000..38762c594c --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/src/edition/hotspot-editor.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { createSequentialIdGenerator } from '../shared/ids'; +import { hydrateDocument } from '../shared/schema'; +import type { Hotspot } from '../shared/types'; +import { actionTypeLabel, actionTypeOptionsHtml, payloadInputsHtml } from './hotspot-editor'; +import { createDefaultHotspot } from '../shared/normalization'; +import { createEditorState } from './state'; + +const identity = (text: string): string => text; + +function makeState() { + const result = hydrateDocument( + { version: 2, scenes: [{ id: 'a', title: 'A' }, { id: 'b', title: 'B' }] }, + createSequentialIdGenerator(), + ); + if (result.status !== 'ok') throw new Error('fixture'); + return createEditorState(result.document, createSequentialIdGenerator()); +} + +function hotspotWith(action: Hotspot['action']): Hotspot { + return { ...createDefaultHotspot('h'), action }; +} + +describe('actionTypeLabel', () => { + it('labels every known type and echoes unknown ones', () => { + expect(actionTypeLabel('goToScene', identity)).toBe('Go to scene'); + expect(actionTypeLabel('text', identity)).toBe('Text'); + expect(actionTypeLabel('image', identity)).toBe('Image'); + expect(actionTypeLabel('video', identity)).toBe('Video'); + expect(actionTypeLabel('link', identity)).toBe('External link'); + expect(actionTypeLabel('quiz3d', identity)).toBe('quiz3d'); + }); +}); + +describe('actionTypeOptionsHtml', () => { + it('marks the current type selected', () => { + const html = actionTypeOptionsHtml(hotspotWith({ type: 'video', payload: { src: '', poster: '' } }), identity); + expect(html).toContain('