Skip to content

refactor(three-sixty-viewer): migrate the iDevice to TypeScript - #39

Open
erseco wants to merge 10 commits into
mainfrom
refactor/three-sixty-viewer-typescript
Open

refactor(three-sixty-viewer): migrate the iDevice to TypeScript#39
erseco wants to merge 10 commits into
mainfrom
refactor/three-sixty-viewer-typescript

Conversation

@erseco

@erseco erseco commented Jul 30, 2026

Copy link
Copy Markdown
Member

Internal PR — base and head both live in ateeducacion/exelearning
(mainrefactor/three-sixty-viewer-typescript). Nothing here targets
exelearning/exelearning.

Summary

Full TypeScript refactor of the 360° Viewer iDevice
(public/files/perm/idevices/base/three-sixty-viewer/):

  • All maintained JavaScript (≈3 100 lines across two mirrored classic scripts)
    moves into a typed, modular src/ tree (shared/, viewer/, edition/,
    export/, test/), compiled by the centralized TypeScript-iDevice build
    convention selectively reused from upstream PR
    exelearning/exelearning#2147

    (ADR-0006).
  • The generated edition/three-sixty-viewer.js and
    export/three-sixty-viewer.js bundles (+ source maps) are not committed
    — they are gitignored and rebuilt by bun run bundle:idevices /
    build:all, and shipped to E2E CI runners via the build artifact.
  • The duplicated state normalization/geometry that edition and export each
    carried is replaced by one shared implementation; the persisted format
    is unchanged and the v1 → v2 migration remains fully supported.

Architecture

src/
├── shared/    document model, hydration (v1/v2/future), normalization,
│              hotspot actions (discriminated union + explicit
│              UnsupportedHotspotAction), geometry, ids, urls, html
├── viewer/    browser layer shared by editor preview AND learner runtime:
│              panorama renderer (owns every WebGL resource), flat-image
│              renderer (letterbox-aware), hotspot overlay, scene controller,
│              nav/fullscreen/drag-capture controls, render-loop + disposer
│              lifecycle, asset:// resolution
├── edition/   window.$exeDevice — device bridge over an explicit Editor
│              instance: typed state store, pure form builders (all legacy
│              control ids preserved), scene list/editor, hotspot list +
│              per-action editors, placement controller, preview, asset picker
├── export/    window.$threesixtyviewer — per-node instances in a
│              WeakMap-backed registry; accessible content modal; safe link
│              opening; WebGL/three.js/no-image fallbacks
└── test/      structural THREE mock + manual frame scheduler, fixtures
               (v1 / v2 / future-version / invalid), bundle-contract specs

Both window globals are assigned explicitly by the entry points (the
workarea re-evaluates the edition script per edit session); nothing relies on
the bundler's globalName. Every runtime instance and every editor owns a
LIFO disposer bag — animation frames, WebGL resources (textures, geometry,
material, renderer), OrbitControls, observers, listeners and modal handlers
are all released on destroy(), and re-rendering a node disposes its
predecessor first. Multiple viewers on one page share no state.
prefers-reduced-motion disables autorotation in both the runtime and the
preview.

Build integration

Selectively applied from upstream PR exelearning#2147 (commits 085d7f9 + f29923e),
infrastructure only:

  • scripts/build-idevices.ts + spec — convention-based builder
    (src/edition|export/index.ts → classic IIFE edition|export/<name>.js,
    per-iDevice strict tsconfig.json typecheck, --only/--watch/--typecheck,
    optional build.config.json manifest).
  • Slide moves onto the central builder via build.config.json
    (scripts/build-slide-editor.ts removed); package.json gains
    typecheck:idevices / bundle:idevices / bundle:idevices:watch, wired
    into build:all before resource packaging so public/bundles/idevices.zip
    always contains fresh bundles (.map files are excluded from resource ZIPs).
  • vitest.config.mts discovers TypeScript-iDevice specs and coverage by
    convention
    (public/files/perm/idevices/base/*/src/**), not by naming a
    single iDevice.
  • Docs: ADR-0006 + doc/development/idevices-typescript.md (adapted to this
    repo's state); ADR-0001…0005 / SDD-0001 IDs stay reserved for the
    upstream Interactive Video PR so the two histories can merge without
    renumbering.
  • Deliberately not cherry-picked: all Interactive Video sources, tests,
    schema/importer/workarea/SCORM changes, bun.lock churn and IV-specific
    ADRs/SDD.
  • .github/workflows/e2e.yml ships the two generated bundles in the
    build-public artifact — a fresh CI checkout serves them without 404s.

Schema compatibility

  • Persisted format unchanged: version: 2, ideviceId, startSceneId,
    scenes[] (id, title, src, alt, description, equirectangular/flat
    projection, initial yaw/pitch/fov, hotspots with yaw/pitch and x/y percent,
    labels, icons), behaviour (autorotate, zoom, fullscreen, nav controls,
    render quality, labels + position, brightness/contrast/saturation). All
    ranges, defaults and enum fallbacks match the legacy normalization.
  • v1 single-image documents still migrate to a one-scene v2 tour with the
    exact legacy detection; nothing is lost (verified by fixtures and by an E2E
    test driving the real bundle's initsave).
  • version > 2 is never rewritten: hydrateDocument() returns
    unsupported-version with the original payload intact; the editor shows an
    explanation and save() passes the payload through byte-for-byte; the
    runtime renders an accessible notice.
  • Unknown hotspot actions are preserved, not converted to text: they
    become an explicit UnsupportedHotspotAction in memory and serialize back
    to their original wire form (lossless round-trip, covered by tests).

Hotspot authoring improvements

  • Explicit placement mode: "Place hotspot by clicking" toggles
    aria-pressed, shows a hint, announces via an aria-live region, cancels
    with Escape, and ends after one placement. Equirectangular clicks are
    unprojected to yaw/pitch; flat clicks map to percentages of the contained
    image rectangle and clicks on letterbox bars are ignored instead of
    snapping to an edge. Numeric fields remain for precise adjustment;
    list-based creation is untouched.
  • Inline validation next to the affected field (goToScene target must
    exist and differ from the current scene; links accept only safe schemes;
    image/video need a source) with text + icon, never colour alone.
  • Scene deletion is reference-aware: a confirmation names how many
    goToScene hotspots point at the scene; accepting clears their targets
    deterministically and flags them for review — no silent dangling
    references.
  • One selected hotspot row at a time, reflected in the list and the preview
    handles; dragging handles updates the row; save announces Saved. in the
    status region. Unsafe link URLs block saving (save()false).

Accessibility

Keyboard-operable authoring controls with visible focus; labelled icon
buttons; aria-live status region; Escape closes the runtime modal and
cancels placement; modal focus trap + focus restoration to the trigger;
hotspots in exported content are real buttons operable via Enter/Space;
accessible fallbacks when WebGL/three.js/the image are unavailable;
reduced-motion support; no colour-only meaning.

Tests

  • 284 colocated Vitest specs under the iDevice's src/ (shared schema/
    migration/normalization/geometry/actions, viewer, export runtime, editor,
    placement, bundle contracts) + 8 builder specs (bun test scripts/build-idevices.spec.ts). The old eval-based *.test.js pairs are
    replaced without losing behavioural coverage.
  • Bundle-contract specs evaluate the real generated IIFEs: classic-script
    shape, both window globals and APIs, safe evaluation without THREE,
    re-evaluation contract, and a v1-open → v2-save round-trip.
  • Playwright: the spec grows from 8 to 14 scenarios — configuration
    persistence, flat projection, link hotspots, second scene + start scene
    persistence, goToScene editing, placement mode (aria-pressed/Escape/click
    placement/letterbox ignore), reference-aware scene deletion, bundle
    contracts, v1 → v2 through the real bundle. Repeated reopen-after-reload
    boilerplate got a shared helper.
  • Coverage (v8, statements/lines): shared/ 99.6 %/100 %, viewer/
    95.4 %/95.9 %, export/ 96.1 %/97.5 %, edition/ 92.8 %/96.1 %. The two
    bundle entry points are excluded by convention (exercised as compiled IIFEs
    by the bundle-contract specs — v8 cannot instrument them through imports).

Verification

All executed on this branch:

  • make fix — clean (2 pre-existing warnings untouched).
  • bun test scripts/build-idevices.spec.ts — 8/8.
  • bun run typecheck:idevices and bun run bundle:idevices — green; slide +
    three-sixty-viewer build through the one convention.
  • bun x vitest run public/files/perm/idevices/base/three-sixty-viewer/src
    284/284.
  • make test-frontend (full Vitest suite) — 13 823 tests, 0 failures.
  • make test-integration721/721.
  • make test-unit — frontend green; backend has pre-existing environmental
    failures unrelated to this PR
    (verified identical on the unmodified
    main base commit): src/routes/api/v1/assets.spec.ts (SQLite migration
    002 fails in the dev container), src/cli/commands/elp-convert.spec.ts
    ("/root must not be writable" — the container runs as root) and one
    order-dependent src/routes/api/convert.spec.ts case that passes in
    isolation.
  • bun x playwright test --project=chromium …/three-sixty-viewer.spec.ts
    14/14. The COMPLETE chromium E2E suite could not finish in the
    development container (three attempts each crashed the container under
    load — resource exhaustion unrelated to this change); it relies on the CI
    run of this PR, which now also receives the generated bundles as build
    artifacts.
  • Clean-build check: deleting both generated bundles and running
    bun scripts/build-idevices.ts --only three-sixty-viewer --typecheck
    regenerates them; git status stays clean (bundles and maps ignored) and
    public/bundles/idevices.zip contains the bundle but no .map files.

Known limitations

  • Hotspot drag on equirectangular scenes and WebGL rendering are covered by
    unit tests against a structural THREE mock plus manual E2E; no automated
    pixel-level WebGL assertions (deliberate — they are unreliable in CI).
  • The editor still rebuilds the whole form on projection toggle (legacy
    behaviour, kept intentionally to swap the renderer + per-mode fields).
  • Backend suite failures listed above are environmental and pre-date this
    branch.

Follow-ups

  • When upstream PR [proposal] Refactor interactive-video iDevice exelearning/exelearning#2147 lands, the reserved ADR-0001…0005 / SDD-0001 IDs and
    the interactive-video build entries merge in without conflicts with this
    branch's ADR-0006/SDD-0002 usage.
  • Hotspot icon choices beyond the current circle and per-hotspot colour
    themes would slot into shared/types.ts + hotspot-renderer.ts without a
    schema bump.
  • doc/development/idevices-typescript.md §Testing suggests adding new
    iDevice spec globs; with the convention-based Vitest config this is now
    automatic and the guide could drop that step.

Docs: adds SDD-0002 (360° Viewer TypeScript architecture), reuses ADR-0006
and the TypeScript-iDevices guide from upstream.


Generated by Claude Code

claude added 7 commits July 30, 2026 07:10
Selectively apply the generic TypeScript-iDevice build infrastructure from
upstream PR exelearning#2147 (commits 085d7f9 and f29923e),
without any Interactive Video code:

- scripts/build-idevices.ts + scripts/build-idevices.spec.ts: centralized,
  convention-based builder (src/edition|export/index.ts -> classic IIFE
  bundles, per-iDevice strict tsconfig typecheck, --only/--watch/--typecheck,
  optional build.config.json manifest for deviations).
- Slide migrates onto the central builder via build.config.json; the bespoke
  scripts/build-slide-editor.ts is removed and package.json gains
  typecheck:idevices / bundle:idevices / bundle:idevices:watch, wired into
  build:all before resource packaging.
- vitest.config.mts discovers TypeScript-iDevice specs and coverage by
  convention (public/files/perm/idevices/base/*/src/**) instead of naming a
  single iDevice.
- build-resource-bundles.js keeps .map files out of resource ZIPs.
- Generic E2E helpers from the upstream infra commit (downloadViaFileMenu,
  extractZipToTempDir) and the e2e.yml artifact-list comment.
- Docs: ADR-0006 (TypeScript iDevices build convention),
  doc/development/idevices-typescript.md, records/mkdocs/AGENTS/skill rows.
  ADR-0001..0005 IDs stay reserved for the upstream Interactive Video PR.

Co-Authored-By: Claude <noreply@anthropic.com>
Single shared implementation of everything the edition and export bundles
used to duplicate:

- src/shared/types.ts: versioned document model (v1/v2), discriminated
  hotspot-action union with an explicit UnsupportedHotspotAction that keeps a
  future action's original type and payload, HydrationResult.
- schema.ts: hydrateDocument() as the only entry point for unknown persisted
  input (object or JSON string) — ok / unsupported-version / invalid, never
  throws, never mutates; serializeDocument() writes unsupported actions back
  in their original wire form (lossless round-trip).
- migration.ts: v1 single-image → v2 one-scene tour, mirroring the legacy
  detection exactly.
- normalization.ts: pure, idempotent v2 normalization (scenes, initial view,
  behaviour, clamped ranges, enum fallbacks, start-scene resolution).
- hotspot-actions.ts: per-action normalization/serialization, explicit
  validation rules, scene-reference lookup and deterministic repair.
- geometry.ts: yaw/pitch ↔ direction, NDC mapping, letterbox-aware flat-image
  rect and percent conversion (clicks outside the contained image are ignored).
- ids.ts: legacy-format ID generation with injectable entropy; deterministic
  sequential generator for tests.
- urls.ts: safe link-scheme allowlist and provider embed-URL mapping.
- html.ts: escaping and label truncation.

91 colocated Vitest specs cover migration fixtures, idempotence, mutation-
freedom, clamping, unsupported-action round-trips and future-version refusal.

Co-Authored-By: Claude <noreply@anthropic.com>
Replace the hand-written export/three-sixty-viewer.js (now a generated,
gitignored IIFE built from src/export/index.ts) with typed, modular code:

- src/viewer/: browser layer shared with the editor preview — structural
  three.js types (the vendored library stays a page global; no @types/three,
  no bundled copy), panorama renderer owning every WebGL resource it creates,
  flat-image renderer with letterbox-aware positioning, hotspot overlay,
  nav/fullscreen/drag-capture controls, render-loop + disposer-bag lifecycle
  helpers, and asset:// resolution behind a boundary.
- src/export/: per-node instances in a WeakMap-backed registry instead of one
  mutable global list. Each instance owns its scene controller, renderers,
  hotspot layer, controls, animation frame, resize observer and modal;
  destroy() releases all of them and re-rendering a node disposes its
  predecessor. Multiple viewers per page stay fully independent.
- Accessible content modal (role=dialog, focus trap, Escape, focus
  restoration), safe link opening (scheme allowlist + noopener), explicit
  handling of unsupported future hotspot actions, WebGL/three.js/no-image
  fallbacks, and prefers-reduced-motion disabling autorotation.
- renderView/renderBehaviour/init keep the exact exe_export.js contract and
  window.$threesixtyviewer is assigned explicitly by the entry point.
  Documents with version > 2 render an accessible notice instead of being
  guessed at.

84 colocated Vitest specs (viewer + export) run against an injectable THREE
mock and manual frame scheduler.

Co-Authored-By: Claude <noreply@anthropic.com>
…otspot authoring

Replace the 1900-line $exeDevice singleton (edition/three-sixty-viewer.js,
now a generated, gitignored IIFE built from src/edition/index.ts) with a thin
device bridge over an explicit editor instance:

- state.ts: the single typed source of truth. Scene/hotspot mutations,
  start-scene resolution, action-type switching, per-hotspot validation and
  save-time validation all live here, DOM-free and headlessly tested.
- device.ts implements the eXeLearning contract (init/save/destroy).
  Re-initializing destroys the previous editor completely; documents from a
  NEWER schema version show an explanation and save() passes the original
  payload through untouched instead of rewriting it. save() before init or
  on validation failure returns false.
- Modular UI: form.ts (pure HTML, every legacy control id preserved),
  scene-list, scene-editor, hotspot-list + action-specific hotspot-editor,
  asset-picker boundary (file manager / FileReader fallback), three-loader
  (lazy vendored three.js for the preview) and preview.ts reusing the SAME
  src/viewer renderers as the export runtime — the mirrored normalization and
  geometry copies are gone.
- Hotspot authoring: explicit placement mode with aria-pressed state, Escape
  cancel, an aria-live status region and a visible hint; clicks land via the
  shared raycast/letterbox geometry and clicks on letterbox bars are ignored
  instead of snapping to an edge; placed hotspots are selected and revealed.
  List-based creation stays. Inline validation renders next to the affected
  field (goToScene targets, safe link schemes, image/video sources) and
  deleting a referenced scene now asks for confirmation, states how many
  hotspots are affected and repairs them deterministically.
- Unsupported future hotspot actions render read-only with their original
  type visible and survive editing sessions byte-for-byte.

89 colocated Vitest specs cover the editing model, form contracts, placement,
drag, preview lifecycle and the device bridge (repeated init, v1 open+save,
future-version passthrough).

Co-Authored-By: Claude <noreply@anthropic.com>
- src/test/bundle-contract.spec.ts evaluates the ACTUAL generated IIFEs in
  the test window: classic-script shape (no imports/require/chunks), the
  window.$exeDevice and window.$threesixtyviewer contracts, safe evaluation
  without THREE loaded, re-evaluation reassigning a fresh editor (the
  workarea reloads the script per edit), and a real v1-open → v2-save
  round-trip through the compiled bundle.
- scripts/build-idevices.spec.ts now asserts the repository state includes
  three-sixty-viewer via the src/ convention (edition + export entries, strict
  tsconfig) next to Slide's manifest.
- .github/workflows/e2e.yml ships the two generated (gitignored) bundles in
  the build-public artifact so fresh E2E runners don't 404 on them.
- Playwright spec: the two tests that reached into removed internals
  (normalizeData/_openLink) now exercise the public bundle contracts; new
  scenarios cover second-scene + start-scene persistence, goToScene hotspot
  editing + persistence, placement mode (aria-pressed, Escape cancel, click
  placement on a flat preview via the file-input fallback, letterbox clicks
  ignored) and the scene-deletion confirmation with deterministic repair.
  Repeated reopen-after-reload boilerplate extracted into reopenForEdit().
- Fix surfaced by E2E: the behaviour controls fieldset (autorotate + speed,
  zoom, fullscreen, labels, nav arrows) was left unwired in the TypeScript
  editor; wireBehaviourFields() restores it with a colocated spec.

bun x playwright test --project=chromium …/three-sixty-viewer.spec.ts:
14/14 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
…002)

SDD-0002 records what is specific to the 360° Viewer on top of the generic
ADR-0006 convention and doc/development/idevices-typescript.md: the src/
layout (shared/viewer/edition/export), the public v1 and v2 formats and the
v1 → v2 migration, explicit future-version and unsupported-action handling,
the single shared normalization used by both bundles, the external three.js/
OrbitControls contract, per-instance lifecycle and cleanup, the hotspot
placement architecture, and the unit / bundle-contract / E2E test strategy.
SDD-0001 stays reserved for the upstream Interactive Video PR.

Co-Authored-By: Claude <noreply@anthropic.com>
… points from v8

The two src/*/index.ts entry points only assign window globals and are
exercised as compiled IIFEs by the bundle-contract specs — v8 cannot see them
through imports, so they are excluded from coverage by convention. New editor
specs cover the asset-picking callbacks (file manager + FileReader fallback),
hotspot media picking, row selection, live scene-list title updates and the
wired behaviour controls.

Co-Authored-By: Claude <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bundle Report

Bundle size has no change ✅

erseco added 3 commits July 30, 2026 14:24
… Video

Rewrite the edition surface to follow the Interactive Video patterns:
accordion hotspot list (one expanded editor at a time), type badges with
text labels, add bar, transient Saved feedback, inline delete confirmation,
section counts, and polished scene rows. Update unit and E2E coverage for
the collapsed-by-default list after reload.
Resolve conflicts with upstream main:
- bun.lock: keep main's dependency versions (pdfjs-dist 6.2.108 and other bumps)
- doc/architecture/adr/records.md: keep ADR-0001/ADR-0042 from main and ADR-0006 from this branch
- package.json: auto-merged (iDevice build scripts + main dep/test updates)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants