diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index ceb517133..809b106f4 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -206,7 +206,9 @@ screen (`#loading-screen` with a `.loader-status` line) as static markup so it paints before any script runs. `createApplication()` renders the views before anything reads the page; `omitViews` names views the application leaves out, such as `provider-settings`, and their hosts are removed. Leaving out -`layer-panels` also leaves out the scene sharing dialogs it draws. An unknown +`layer-panels` also leaves out the scene sharing dialogs it draws, and leaving +out `scene-chrome` the before/after imagery dividers: the imagery still splits, +with no line or handle to move it. An unknown view name is an error. A host that does not initialize Core's locale passes `localePicker: false` to `createApplicationControls` instead of editing the markup. Node tests that import application modules register the component diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index e012afc95..5caa59f1c 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -139,7 +139,11 @@ a feature supplying only its accent. phone shell (`PhoneTabBar` renders the tabs and open sheet the shell publishes to the `gev-display` store, teleporting the tab bar and sheet backdrop to the end of the page from the first publish until the shell - is destroyed; the shell takes their clicks from the document), then + is destroyed; the shell takes their clicks from the document), the + imagery dividers (`ImagerySplitView`, in the scene chrome, teleports each + before/after divider that `createImagerySplit` publishes to the + `gev-imagery-split` store to the body; the split keeps the + drag, keyboard, CSS position and scene split), then Cockpit (in progress: `CockpitBriefing` renders the briefing card's pages, cycle toggle, regional news and local conditions, and `CockpitContext` the Contact card's scope, navigation, counts, nearest diff --git a/scripts/format-scope.json b/scripts/format-scope.json index bc003a414..36a7ce514 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -180,6 +180,8 @@ "src/ui/panelDisclosure.js", "src/ui/imagerySplit.js", "src/ui/imagerySplit.test.mjs", + "src/ui/imagerySplitView.test.mjs", + "src/ui/stores/imagerySplit.js", "src/ui/surfaceKeyboard.js", "src/ui/surfaceKeyboard.test.mjs", "src/ui/panelRails.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ae2600e7c..1173730e4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -92,7 +92,9 @@ "src/ui/rail/focusSafeChildren.js", "src/ui/rail/fieldValue.js", "src/ui/rail/statusText.js", - "src/voice/control.js" + "src/voice/control.js", + "src/ui/views/panels/ImagerySplitView.vue", + "src/ui/stores/imagerySplit.js" ], "external": ["vue", "pinia"] }, @@ -2992,7 +2994,8 @@ "src/ui/stores/sceneSharing.js", "src/ui/stores/voice.js", "src/ui/stores/location.js", - "src/ui/stores/display.js" + "src/ui/stores/display.js", + "src/ui/stores/imagerySplit.js" ], "external": [ "hls.js", @@ -3578,7 +3581,9 @@ "src/layers/streetLevel/providers/mapillary/policy.js", "src/data/geoImportImages.js", "src/data/geoImportPins.js", - "src/data/geoFileImport.js" + "src/data/geoFileImport.js", + "src/ui/stores/imagerySplit.js", + "src/ui/vueRuntime.js" ], "external": [ "hls.js", @@ -3589,7 +3594,9 @@ "pbf", "mgrs", "mapillary-js", - "@jtarrio/webrtlsdr" + "@jtarrio/webrtlsdr", + "vue", + "pinia" ] }, "application-operations": { diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index 48183ee01..61cdfc3c3 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -3,6 +3,8 @@ import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { claimPointer, releasePointer } from './inputOwnership.js'; import * as Cesium from 'cesium'; +import { useImagerySplitStore } from '../ui/stores/imagerySplit.js'; +import { applicationPinia } from '../ui/vueRuntime.js'; import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js'; import { BHOTE_KOSHI_LAYER_ID, @@ -36,6 +38,12 @@ import { SceneDirector } from '../scenes/director.js'; const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url); const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url); +/** The event's divider as the imagery split store holds it, or undefined. */ +const eventSplit = () => + useImagerySplitStore(applicationPinia()).splits.findLast( + (split) => split.view.id === 'bhote-koshi-split-line', + ); + class FakeElement { constructor(tagName = 'div') { this.tagName = tagName; @@ -840,6 +848,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever const originalDocument = globalThis.document; const originalWindow = globalThis.window; const { body, removedProperties } = installEventDom(); + assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test'); const clearedTimeouts = []; globalThis.window = { setTimeout: () => 71, @@ -879,7 +888,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever assert.equal(await layer.enable(viewer, { origin: 'scene' }), true); assert.equal(viewer._test.imagery.length, 2); assert.equal(viewer._test.dataSources.length, 1); - assert.equal(body.children.length, 2); + assert.equal(body.children.length, 1); + assert.ok(eventSplit()); activeMap = 'operator-choice'; generation += 1; @@ -889,6 +899,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever assert.equal(viewer._test.removedImagery.length, 2); assert.equal(viewer._test.removedDataSources.length, 1); assert.ok(body.children.every((element) => element.removed)); + assert.equal(eventSplit(), undefined); assert.equal(viewer.scene.splitPosition, 0.37); assert.deepEqual(clearedTimeouts, [71]); assert.ok(overlayCalls.some(([kind]) => kind === 'clear')); @@ -1075,6 +1086,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; const { body } = installEventDom(); + assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test'); const timers = []; const openedUrls = []; const cancelledFrames = []; @@ -1183,9 +1195,8 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false)); const panel = body.children.find((element) => element.tagName === 'aside'); assert.ok(panel); - const splitLine = body.children.find((element) => element.id === 'bhote-koshi-split-line'); - assert.ok(splitLine); - assert.equal(splitLine.hidden, true); + assert.ok(eventSplit()); + assert.equal(eventSplit().view.hidden, true); assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, true); assert.match(panel.querySelector('[data-role="caveat"]').textContent, /SCHEMATIC CORRIDOR/i); assert.doesNotMatch(panel.querySelector('[data-role="caveat"]').textContent, /historical/i); @@ -1240,30 +1251,27 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership beatId: 'timure-cluster', sceneControls: { imageryComparison: true }, }, { origin: 'scene' }); - assert.equal(splitLine.hidden, false); + assert.equal(eventSplit().view.hidden, false); assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, false); assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true)); - const splitHandle = splitLine.children.find( - (element) => element.className === 'bhote-koshi-split-handle', - ); - assert.ok(splitHandle); - assert.equal(splitHandle.textContent, '↔'); - assert.equal(splitHandle.getAttribute('aria-valuenow'), '62'); - splitHandle.dispatch('pointerdown', { + assert.equal(eventSplit().view.handleClass, 'bhote-koshi-split-handle'); + assert.equal(eventSplit().view.valueNow, 62); + const splitHandle = eventSplit().controls; + splitHandle.pointerdown({ button: 0, clientX: 250, pointerId: 4, preventDefault() {}, }); - splitHandle.dispatch('pointermove', { + splitHandle.pointermove({ clientX: 750, pointerId: 4, preventDefault() {}, }); - splitHandle.dispatch('pointerup', { pointerId: 4 }); + splitHandle.pointerup({ pointerId: 4 }); assert.equal(viewer.scene.splitPosition, 0.75); - assert.equal(splitHandle.getAttribute('aria-valuenow'), '75'); - splitHandle.dispatch('keydown', { + assert.equal(eventSplit().view.valueNow, 75); + splitHandle.keydown({ key: 'ArrowLeft', shiftKey: true, preventDefault() {}, @@ -1276,7 +1284,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership split: 0.62, sceneControls: {}, }, { origin: 'scene' }); - assert.equal(splitLine.hidden, true); + assert.equal(eventSplit().view.hidden, true); assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false)); assert.equal(viewer.scene.splitPosition, 0.62); assert.equal(overlayVisibility.at(-1).visible, false); diff --git a/src/ui/imagerySplit.js b/src/ui/imagerySplit.js index f22bbc208..16a48bc9d 100644 --- a/src/ui/imagerySplit.js +++ b/src/ui/imagerySplit.js @@ -1,3 +1,7 @@ +import { useImagerySplitStore } from './stores/imagerySplit.js'; +import { applicationPinia } from './vueRuntime.js'; + +let splitCount = 0; const HANDLE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']); function clampUnit(value) { @@ -19,17 +23,17 @@ function defaultValueText(beforePercent, afterPercent) { /** * Split divider for before/after imagery comparison. * - * Owns the DOM line + handle, pointer capture, keyboard, ARIA, a CSS custom - * property and scene.splitPosition. `setValue` is the owner's own write and - * never echoes through `onChange`; `onChange` reports pointer and keyboard - * changes so the owner can mirror them, and `requestRender` fires whenever - * the clamped value actually changes. `destroy` is idempotent: it removes the - * listeners and line, clears the CSS property and restores the scene split - * captured at creation once. + * Owns the divider's value, pointer capture, keyboard, ARIA, a CSS custom + * property and scene.splitPosition, and publishes the line to the imagery + * split store, where the ImagerySplitView renders it as a child of the body. + * `setValue` is the owner's own write and never echoes through `onChange`; + * `onChange` reports pointer and keyboard changes so the owner can mirror + * them, and `requestRender` fires whenever the clamped value actually + * changes. `destroy` is idempotent: it withdraws the line, clears the CSS + * property and restores the scene split captured at creation once. */ export function createImagerySplit({ scene, - parent = document.body, initialValue = 0.5, id, handleClass, @@ -44,34 +48,40 @@ export function createImagerySplit({ getViewportWidth = defaultViewportWidth, onChange, requestRender, + store = useImagerySplitStore(applicationPinia()), } = {}) { + const key = ++splitCount; const restoreValue = scene ? (scene.splitPosition ?? null) : null; - const line = document.createElement('div'); - if (id) line.id = id; - const before = document.createElement('span'); - const after = document.createElement('span'); - const handle = document.createElement('button'); - before.className = 'before'; - after.className = 'after'; - before.textContent = beforeLabel; - if (beforeTitle != null) before.title = beforeTitle; - after.textContent = afterLabel; - if (afterTitle != null) after.title = afterTitle; - handle.type = 'button'; - if (handleClass) handle.className = handleClass; - handle.setAttribute('role', 'slider'); - if (ariaLabel != null) handle.setAttribute('aria-label', ariaLabel); - handle.setAttribute('aria-valuemin', '0'); - handle.setAttribute('aria-valuemax', '100'); - handle.setAttribute('aria-orientation', 'horizontal'); - handle.textContent = '↔'; - let value = null; let cssValue = null; + let hidden = false; let dragging = false; let pointerId = null; + // The handle that holds the drag's pointer capture. + let captor = null; let destroyed = false; + function publish() { + if (destroyed) return; + const beforePercent = Math.round(value * 100); + store.publish( + key, + { + id: id ?? null, + hidden, + handleClass: handleClass ?? null, + beforeLabel, + afterLabel, + beforeTitle: beforeTitle ?? null, + afterTitle: afterTitle ?? null, + ariaLabel: ariaLabel ?? null, + valueNow: beforePercent, + valueText: formatValueText(beforePercent, 100 - beforePercent), + }, + controls, + ); + } + function apply(next, { notify = false, render = true } = {}) { const clamped = clampUnit(next); const changed = clamped !== value; @@ -82,12 +92,7 @@ export function createImagerySplit({ cssTarget.style.setProperty(cssProperty, css); cssValue = css; } - const beforePercent = Math.round(value * 100); - handle.setAttribute('aria-valuenow', String(beforePercent)); - handle.setAttribute( - 'aria-valuetext', - formatValueText(beforePercent, 100 - beforePercent), - ); + if (changed) publish(); if (changed && render) requestRender?.(); if (changed && notify) onChange?.(value); return changed; @@ -99,53 +104,45 @@ export function createImagerySplit({ apply(Number(clientX) / viewportWidth, { notify: true }); } - function handlePointerDown(event) { - if (event.button != null && event.button !== 0) return; - event.preventDefault?.(); - dragging = true; - pointerId = event.pointerId ?? null; - handle.setPointerCapture?.(event.pointerId); - setFromClientX(event.clientX); - } + // The view's handle reports its input here. + const controls = { + pointerdown(event) { + if (destroyed) return; + if (event.button != null && event.button !== 0) return; + event.preventDefault?.(); + dragging = true; + pointerId = event.pointerId ?? null; + captor = event.currentTarget ?? null; + captor?.setPointerCapture?.(event.pointerId); + setFromClientX(event.clientX); + }, + pointermove(event) { + if (!dragging) return; + if (pointerId != null && event.pointerId !== pointerId) return; + event.preventDefault?.(); + setFromClientX(event.clientX); + }, + pointerup(event) { + if (!dragging) return; + if (pointerId != null && event.pointerId !== pointerId) return; + captor?.releasePointerCapture?.(event.pointerId); + dragging = false; + pointerId = null; + captor = null; + }, + keydown(event) { + if (destroyed || !HANDLE_KEYS.has(event.key)) return; + event.preventDefault?.(); + const step = event.shiftKey ? 0.05 : 0.01; + if (event.key === 'Home') apply(0, { notify: true }); + else if (event.key === 'End') apply(1, { notify: true }); + else + apply(value + (event.key === 'ArrowRight' ? step : -step), { + notify: true, + }); + }, + }; - function handlePointerMove(event) { - if (!dragging) return; - if (pointerId != null && event.pointerId !== pointerId) return; - event.preventDefault?.(); - setFromClientX(event.clientX); - } - - function finishPointerDrag(event) { - if (!dragging) return; - if (pointerId != null && event.pointerId !== pointerId) return; - handle.releasePointerCapture?.(event.pointerId); - dragging = false; - pointerId = null; - } - - function handleKeyDown(event) { - if (!HANDLE_KEYS.has(event.key)) return; - event.preventDefault?.(); - const step = event.shiftKey ? 0.05 : 0.01; - if (event.key === 'Home') apply(0, { notify: true }); - else if (event.key === 'End') apply(1, { notify: true }); - else - apply(value + (event.key === 'ArrowRight' ? step : -step), { - notify: true, - }); - } - - const listeners = [ - ['pointerdown', handlePointerDown], - ['pointermove', handlePointerMove], - ['pointerup', finishPointerDrag], - ['pointercancel', finishPointerDrag], - ['keydown', handleKeyDown], - ]; - for (const [type, listener] of listeners) - handle.addEventListener(type, listener); - line.append(before, handle, after); - parent.appendChild(line); apply(initialValue, { render: false }); return { @@ -157,18 +154,19 @@ export function createImagerySplit({ return apply(next); }, setVisible(visible) { - line.hidden = !visible; + if (hidden === !visible) return; + hidden = !visible; + publish(); }, destroy() { if (destroyed) return; - destroyed = true; if (dragging && pointerId != null) - handle.releasePointerCapture?.(pointerId); + captor?.releasePointerCapture?.(pointerId); dragging = false; pointerId = null; - for (const [type, listener] of listeners) - handle.removeEventListener?.(type, listener); - line.remove(); + captor = null; + destroyed = true; + store.withdraw(key); if (cssProperty) cssTarget.style.removeProperty(cssProperty); if (scene && restoreValue != null) scene.splitPosition = restoreValue; }, diff --git a/src/ui/imagerySplit.test.mjs b/src/ui/imagerySplit.test.mjs index 23a47b60d..f96eb649d 100644 --- a/src/ui/imagerySplit.test.mjs +++ b/src/ui/imagerySplit.test.mjs @@ -2,51 +2,35 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createImagerySplit } from './imagerySplit.js'; -class FakeElement { - constructor(tagName) { - this.tagName = tagName; - this.children = []; - this.removed = false; - this.captured = []; - this.released = []; - this._attributes = new Map(); - this._listeners = new Map(); - } - setAttribute(name, value) { - this._attributes.set(name, value); - } - getAttribute(name) { - return this._attributes.get(name) ?? null; - } - addEventListener(type, handler) { - this._listeners.set(type, handler); - } - removeEventListener(type, handler) { - if (this._listeners.get(type) === handler) this._listeners.delete(type); - } - dispatch(type, event = {}) { - return this._listeners.get(type)?.(event); - } - setPointerCapture(id) { - this.captured.push(id); - } - releasePointerCapture(id) { - this.released.push(id); - } - append(...children) { - this.children.push(...children); - } - appendChild(child) { - this.children.push(child); - return child; - } - remove() { - this.removed = true; - } +/** The handle the view renders: a pointer capture target. */ +function fakeHandle() { + return { + captured: [], + released: [], + setPointerCapture(id) { + this.captured.push(id); + }, + releasePointerCapture(id) { + this.released.push(id); + }, + }; +} + +/** The imagery split store as the split sees it. */ +function fakeStore() { + const lines = new Map(); + return { + lines, + publish(key, view, controls) { + lines.set(key, { view, controls }); + }, + withdraw(key) { + lines.delete(key); + }, + }; } function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) { - const body = new FakeElement('body'); const cssCalls = []; const changes = []; let renders = 0; @@ -61,15 +45,9 @@ function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) { }, }, }; - const previous = globalThis.document; - globalThis.document = { - body, - createElement: (tagName) => new FakeElement(tagName), - documentElement: cssTarget, - }; + const store = fakeStore(); const split = createImagerySplit({ scene, - parent: body, initialValue, id: 'split-line', handleClass: 'split-handle', @@ -83,37 +61,45 @@ function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) { requestRender: () => { renders += 1; }, + store, }); - globalThis.document = previous; - const line = body.children[0]; - const handle = line.children.find((child) => child.tagName === 'button'); + const [key] = store.lines.keys(); + const line = () => store.lines.get(key) ?? null; + const handle = fakeHandle(); + // The view's handle dispatching to the split, as Vue's listeners do. + const dispatch = (type, event = {}) => + line()?.controls[type === 'pointercancel' ? 'pointerup' : type]({ + currentTarget: handle, + ...event, + }); return { split, scene, - body, + store, line, + view: () => line()?.view, handle, + dispatch, cssCalls, changes, renders: () => renders, }; } -test('the split builds its line and applies the initial value without notifying', () => { +test('the split publishes its line and applies the initial value without notifying', () => { const env = fixture({ initialValue: 0.62 }); - assert.equal(env.line.id, 'split-line'); - assert.equal(env.line.children[0].textContent, 'A'); - assert.equal(env.line.children[0].title, 'Before'); - assert.equal(env.line.children[2].textContent, 'B'); - assert.equal(env.line.children[2].title, 'After'); - assert.equal(env.handle.className, 'split-handle'); - assert.equal(env.handle.getAttribute('role'), 'slider'); - assert.equal(env.handle.getAttribute('aria-label'), 'Divider'); - assert.equal(env.handle.getAttribute('aria-valuenow'), '62'); - assert.equal( - env.handle.getAttribute('aria-valuetext'), - 'A 62 percent, B 38 percent', - ); + assert.deepEqual(env.view(), { + id: 'split-line', + hidden: false, + handleClass: 'split-handle', + beforeLabel: 'A', + afterLabel: 'B', + beforeTitle: 'Before', + afterTitle: 'After', + ariaLabel: 'Divider', + valueNow: 62, + valueText: 'A 62 percent, B 38 percent', + }); assert.equal(env.scene.splitPosition, 0.62); assert.deepEqual(env.cssCalls, [['set', '--split', '62%']]); assert.deepEqual(env.changes, []); @@ -125,7 +111,7 @@ test('keyboard steps 1 percent, 5 percent with Shift, and jumps to Home and End' const env = fixture(); const key = (key, shiftKey = false) => { let prevented = false; - env.handle.dispatch('keydown', { + env.dispatch('keydown', { key, shiftKey, preventDefault() { @@ -140,10 +126,10 @@ test('keyboard steps 1 percent, 5 percent with Shift, and jumps to Home and End' assert.equal(env.scene.splitPosition, 0.46); assert.equal(key('Home'), true); assert.equal(env.scene.splitPosition, 0); - assert.equal(env.handle.getAttribute('aria-valuenow'), '0'); + assert.equal(env.view().valueNow, 0); assert.equal(key('End'), true); assert.equal(env.scene.splitPosition, 1); - assert.equal(env.handle.getAttribute('aria-valuenow'), '100'); + assert.equal(env.view().valueNow, 100); assert.equal(key('Enter'), false, 'other keys are ignored and not prevented'); assert.equal(env.scene.splitPosition, 1); assert.deepEqual(env.changes, [0.51, 0.46, 0, 1]); @@ -168,15 +154,15 @@ test('values clamp to the unit range and only real changes notify or render', () [], 'setValue is the owner writing; it never echoes', ); - env.handle.dispatch('keydown', { key: 'Home', preventDefault() {} }); - env.handle.dispatch('keydown', { key: 'ArrowLeft', preventDefault() {} }); + env.dispatch('keydown', { key: 'Home', preventDefault() {} }); + env.dispatch('keydown', { key: 'ArrowLeft', preventDefault() {} }); assert.deepEqual( env.changes, [], 'interaction that cannot move the value stays silent', ); - env.handle.dispatch('keydown', { key: 'End', preventDefault() {} }); - env.handle.dispatch('keydown', { key: 'End', preventDefault() {} }); + env.dispatch('keydown', { key: 'End', preventDefault() {} }); + env.dispatch('keydown', { key: 'End', preventDefault() {} }); assert.deepEqual(env.changes, [1]); assert.equal(env.renders(), 3); assert.deepEqual(env.cssCalls, [ @@ -189,7 +175,7 @@ test('values clamp to the unit range and only real changes notify or render', () test('pointer drags follow the primary pointer only', () => { const env = fixture(); - env.handle.dispatch('pointerdown', { + env.dispatch('pointerdown', { button: 2, clientX: 900, pointerId: 1, @@ -200,7 +186,7 @@ test('pointer drags follow the primary pointer only', () => { 0.5, 'non-primary buttons never start a drag', ); - env.handle.dispatch('pointermove', { + env.dispatch('pointermove', { clientX: 900, pointerId: 1, preventDefault() {}, @@ -210,7 +196,7 @@ test('pointer drags follow the primary pointer only', () => { 0.5, 'moves without a drag are ignored', ); - env.handle.dispatch('pointerdown', { + env.dispatch('pointerdown', { button: 0, clientX: 250, pointerId: 4, @@ -218,7 +204,7 @@ test('pointer drags follow the primary pointer only', () => { }); assert.equal(env.scene.splitPosition, 0.25); assert.deepEqual(env.handle.captured, [4]); - env.handle.dispatch('pointermove', { + env.dispatch('pointermove', { clientX: 900, pointerId: 9, preventDefault() {}, @@ -228,8 +214,8 @@ test('pointer drags follow the primary pointer only', () => { 0.25, 'a different pointer id is ignored', ); - env.handle.dispatch('pointerup', { pointerId: 9 }); - env.handle.dispatch('pointermove', { + env.dispatch('pointerup', { pointerId: 9 }); + env.dispatch('pointermove', { clientX: 750, pointerId: 4, preventDefault() {}, @@ -239,7 +225,7 @@ test('pointer drags follow the primary pointer only', () => { 0.75, 'the drag survives a foreign pointerup', ); - env.handle.dispatch('pointermove', { + env.dispatch('pointermove', { clientX: Number.NaN, pointerId: 4, preventDefault() {}, @@ -249,9 +235,9 @@ test('pointer drags follow the primary pointer only', () => { 0.75, 'unusable coordinates are ignored', ); - env.handle.dispatch('pointerup', { pointerId: 4 }); + env.dispatch('pointerup', { pointerId: 4 }); assert.deepEqual(env.handle.released, [4]); - env.handle.dispatch('pointermove', { + env.dispatch('pointermove', { clientX: 100, pointerId: 4, preventDefault() {}, @@ -263,23 +249,24 @@ test('pointer drags follow the primary pointer only', () => { test('visibility toggles the line without touching the value', () => { const env = fixture(); env.split.setVisible(false); - assert.equal(env.line.hidden, true); + assert.equal(env.view().hidden, true); env.split.setVisible(true); - assert.equal(env.line.hidden, false); + assert.equal(env.view().hidden, false); assert.equal(env.split.getValue(), 0.5); }); -test('destroy removes the line, clears the CSS property, restores the scene split once, and is idempotent', () => { +test('destroy withdraws the line, clears the CSS property, restores the scene split once, and is idempotent', () => { const env = fixture({ initialValue: 0.8, splitPosition: 0.37 }); - env.handle.dispatch('pointerdown', { + env.dispatch('pointerdown', { button: 0, clientX: 100, pointerId: 2, preventDefault() {}, }); assert.equal(env.scene.splitPosition, 0.1); + const { controls } = env.line(); env.split.destroy(); - assert.equal(env.line.removed, true); + assert.equal(env.line(), null); assert.deepEqual( env.handle.released, [2], @@ -287,7 +274,6 @@ test('destroy removes the line, clears the CSS property, restores the scene spli ); assert.equal(env.cssCalls.at(-1)[0], 'remove'); assert.equal(env.scene.splitPosition, 0.37); - assert.equal(env.handle._listeners.size, 0); env.scene.splitPosition = 0.9; env.split.destroy(); assert.equal( @@ -302,6 +288,8 @@ test('destroy removes the line, clears the CSS property, restores the scene spli 'a destroyed split ignores writes', ); assert.equal(env.scene.splitPosition, 0.9); - env.handle.dispatch('keydown', { key: 'End', preventDefault() {} }); - assert.deepEqual(env.changes, [0.1], 'listeners are gone'); + controls.keydown({ key: 'End', preventDefault() {} }); + controls.pointerdown({ button: 0, clientX: 900, pointerId: 3 }); + assert.deepEqual(env.changes, [0.1], 'its controls are inert'); + assert.equal(env.store.lines.size, 0, 'nothing republishes the line'); }); diff --git a/src/ui/imagerySplitView.test.mjs b/src/ui/imagerySplitView.test.mjs new file mode 100644 index 000000000..0bf6323b7 --- /dev/null +++ b/src/ui/imagerySplitView.test.mjs @@ -0,0 +1,139 @@ +// src/ui/imagerySplitView.test.mjs — the imagery dividers render from the +// imagery split store as children of the body, over happy-dom. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useImagerySplitStore } from './stores/imagerySplit.js'; +import { createImagerySplit } from './imagerySplit.js'; +import ImagerySplitView from './views/panels/ImagerySplitView.vue'; + +function fixture(t) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp(ImagerySplitView).use(pinia); + app.mount(host); + t.after(() => app.unmount()); + const store = useImagerySplitStore(pinia); + const scene = { splitPosition: 0.37 }; + const changes = []; + const split = (options = {}) => + createImagerySplit({ + scene, + id: 'split-line', + handleClass: 'split-handle', + cssProperty: '--split', + beforeTitle: 'Before', + afterTitle: 'After', + ariaLabel: 'Divider', + getViewportWidth: () => 1000, + onChange: (value) => changes.push(value), + store, + ...options, + }); + return { split, scene, changes }; +} + +const $ = (selector) => document.querySelector(selector); + +test('the divider renders on the body with its labels and slider', async (t) => { + const { split } = fixture(t); + split({ initialValue: 0.62 }); + await nextTick(); + const line = $('#split-line'); + assert.equal(line.parentElement, document.body); + const [before, handle, after] = line.children; + assert.equal(before.className, 'before'); + assert.equal(before.textContent, 'A'); + assert.equal(before.title, 'Before'); + assert.equal(after.className, 'after'); + assert.equal(after.textContent, 'B'); + assert.equal(after.title, 'After'); + assert.equal(handle.tagName, 'BUTTON'); + assert.equal(handle.type, 'button'); + assert.equal(handle.className, 'split-handle'); + assert.equal(handle.textContent, '↔'); + assert.equal(handle.getAttribute('role'), 'slider'); + assert.equal(handle.getAttribute('aria-label'), 'Divider'); + assert.equal(handle.getAttribute('aria-valuemin'), '0'); + assert.equal(handle.getAttribute('aria-valuemax'), '100'); + assert.equal(handle.getAttribute('aria-orientation'), 'horizontal'); + assert.equal(handle.getAttribute('aria-valuenow'), '62'); + assert.equal( + handle.getAttribute('aria-valuetext'), + 'A 62 percent, B 38 percent', + ); + assert.equal( + document.documentElement.style.getPropertyValue('--split'), + '62%', + ); +}); + +test('the handle steps by keyboard and drags with the pointer it captured', async (t) => { + const { split, scene, changes } = fixture(t); + split(); + await nextTick(); + const handle = $('#split-line button'); + const captured = []; + const released = []; + handle.setPointerCapture = (id) => captured.push(id); + handle.releasePointerCapture = (id) => released.push(id); + handle.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ); + await nextTick(); + assert.equal(scene.splitPosition, 0.51); + assert.equal(handle.getAttribute('aria-valuenow'), '51'); + const pointer = (type, init) => + handle.dispatchEvent( + new PointerEvent(type, { bubbles: true, pointerId: 7, ...init }), + ); + pointer('pointerdown', { button: 0, clientX: 250 }); + pointer('pointermove', { clientX: 800 }); + pointer('pointercancel', {}); + pointer('pointermove', { clientX: 100 }); + await nextTick(); + assert.equal(scene.splitPosition, 0.8); + assert.deepEqual(captured, [7]); + assert.deepEqual(released, [7]); + assert.deepEqual(changes, [0.51, 0.25, 0.8]); + assert.equal(handle.getAttribute('aria-valuenow'), '80'); +}); + +test('hiding and destroying a divider leave other dividers in place', async (t) => { + const { split } = fixture(t); + const first = split(); + const second = split({ id: 'second-line' }); + await nextTick(); + first.setVisible(false); + await nextTick(); + assert.equal($('#split-line').hidden, true); + assert.equal($('#second-line').hidden, false); + first.destroy(); + await nextTick(); + assert.equal($('#split-line'), null); + assert.ok($('#second-line')); + second.destroy(); + await nextTick(); + assert.equal($('#second-line'), null); +}); + +test('a divider created before the view mounts renders once it does', async (t) => { + document.body.replaceChildren(); + const pinia = createPinia(); + const split = createImagerySplit({ + id: 'early-line', + getViewportWidth: () => 1000, + store: useImagerySplitStore(pinia), + }); + const app = createApp(ImagerySplitView).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + await nextTick(); + assert.equal($('#early-line').parentElement, document.body); + split.destroy(); + await nextTick(); + assert.equal($('#early-line'), null); +}); diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index fcbc494cf..2447e874c 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -705,7 +705,6 @@ export function createRecentImageryPanel({ if (!live || split || destroyed) return; split = createSplit({ scene: viewer?.scene, - parent: document.body, initialValue: snapshot.split, id: 'recent-imagery-split-line', handleClass: 'recent-imagery-split-handle', diff --git a/src/ui/stores/imagerySplit.js b/src/ui/stores/imagerySplit.js new file mode 100644 index 000000000..c7543a9d3 --- /dev/null +++ b/src/ui/stores/imagerySplit.js @@ -0,0 +1,35 @@ +import { defineStore } from 'pinia'; +import { markRaw, shallowRef } from 'vue'; + +/** + * The before/after imagery dividers on the page, as their view renders them. + * Each split (src/ui/imagerySplit.js) publishes its line under its own key + * and withdraws it when destroyed; the view sends pointer and keyboard input + * back to that split's controls. + */ +export const useImagerySplitStore = defineStore('gev-imagery-split', () => { + /** The dividers in creation order: `{key, view, controls}`. */ + const splits = shallowRef([]); + + /** + * Show or update one divider. + * @param {number} key The split's own key. + * @param {object} view A fresh presentation of its line. + * @param {object} controls What the view calls for its input. + */ + function publish(key, view, controls) { + const entry = markRaw({ key, view, controls }); + const index = splits.value.findIndex((split) => split.key === key); + splits.value = + index < 0 + ? [...splits.value, entry] + : splits.value.map((split, at) => (at === index ? entry : split)); + } + + /** @param {number} key The split to remove. */ + function withdraw(key) { + splits.value = splits.value.filter((split) => split.key !== key); + } + + return { splits, publish, withdraw }; +}); diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index b954adaac..1bf8c5a74 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -2,6 +2,7 @@ import { storeToRefs } from 'pinia'; import { t } from '../../i18n/index.js'; import { useDisplayStore } from '../stores/display.js'; +import ImagerySplitView from './panels/ImagerySplitView.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); @@ -69,4 +70,6 @@ const { style } = storeToRefs(useDisplayStore());
+ + diff --git a/src/ui/views/panels/ImagerySplitView.vue b/src/ui/views/panels/ImagerySplitView.vue new file mode 100644 index 000000000..b25c7cf46 --- /dev/null +++ b/src/ui/views/panels/ImagerySplitView.vue @@ -0,0 +1,16 @@ + + +