diff --git a/vue/primitives/docs/intro.vue b/vue/primitives/docs/intro.vue index 95fdea6..a41f34c 100644 --- a/vue/primitives/docs/intro.vue +++ b/vue/primitives/docs/intro.vue @@ -24,35 +24,35 @@

-
-

Unstyled by design

-

+

+

Unstyled by design

+

No CSS shipped. Primitives render the DOM you ask for and expose state via data attributes, so you bring your own styles — Tailwind, vanilla CSS, anything.

-
-

Accessible out of the box

-

+

+

Accessible out of the box

+

Focus scopes, roving tabindex, visually-hidden labels and correct ARIA roles are handled for you. The suite is tested against axe-core in a real browser.

-
-

Controlled or uncontrolled

-

+

+

Controlled or uncontrolled

+

Bind state with v-model when you need control, or set a defaultValue / defaultOpen and let the primitive manage itself.

-
-

Composable & polymorphic

-

+

+

Composable & polymorphic

+

Every part takes an as prop, or use as="template" to merge behavior onto your own element. Floating UI powers positioning for popovers, tooltips and menus. diff --git a/vue/primitives/eslint.config.ts b/vue/primitives/eslint.config.ts index 3f17b52..3298689 100644 --- a/vue/primitives/eslint.config.ts +++ b/vue/primitives/eslint.config.ts @@ -1,4 +1,4 @@ -import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint'; +import { base, compose, imports, stylistic, tests, typescript, vue } from '@robonen/eslint'; export default compose(base, typescript, vue, imports, stylistic, { name: 'primitives/overrides', @@ -6,4 +6,4 @@ export default compose(base, typescript, vue, imports, stylistic, { rules: { '@stylistic/no-multiple-empty-lines': 'off', }, -}); +}, tests); diff --git a/vue/primitives/playground/src/demos/Accordion.vue b/vue/primitives/playground/src/demos/Accordion.vue index c5f9156..12075a0 100644 --- a/vue/primitives/playground/src/demos/Accordion.vue +++ b/vue/primitives/playground/src/demos/Accordion.vue @@ -5,7 +5,7 @@ import { AccordionItem, AccordionRoot, AccordionTrigger, -} from '@primitives/accordion'; +} from '@primitives/disclosure/accordion'; const value = ref('a'); const type = ref<'single' | 'multiple'>('single'); diff --git a/vue/primitives/playground/src/demos/Checkbox.vue b/vue/primitives/playground/src/demos/Checkbox.vue index a7e193e..6fdd066 100644 --- a/vue/primitives/playground/src/demos/Checkbox.vue +++ b/vue/primitives/playground/src/demos/Checkbox.vue @@ -1,7 +1,7 @@ - - - - diff --git a/vue/primitives/src/alert-dialog/AlertDialogCancel.vue b/vue/primitives/src/alert-dialog/AlertDialogCancel.vue deleted file mode 100644 index 594fa50..0000000 --- a/vue/primitives/src/alert-dialog/AlertDialogCancel.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - - - diff --git a/vue/primitives/src/alert-dialog/__test__/AlertDialog.test.ts b/vue/primitives/src/alert-dialog/__test__/AlertDialog.test.ts deleted file mode 100644 index a66c4c7..0000000 --- a/vue/primitives/src/alert-dialog/__test__/AlertDialog.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { mount } from '@vue/test-utils'; -import type { VueWrapper } from '@vue/test-utils'; -import { afterEach, describe, expect, it } from 'vitest'; -import { defineComponent, h, nextTick, ref } from 'vue'; -import { - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogOverlay, - AlertDialogPortal, - AlertDialogRoot, - AlertDialogTitle, - AlertDialogTrigger, -} from '../index'; - -const wrappers: Array> = []; - -afterEach(() => { - while (wrappers.length) wrappers.pop()!.unmount(); - document.body.innerHTML = ''; - document.body.removeAttribute('style'); - delete document.body.dataset['dismissableBlocking']; -}); - -function track>(w: T): T { - wrappers.push(w); - return w; -} - -function mountAlert(initialOpen = true) { - const open = ref(initialOpen); - const Harness = defineComponent({ - setup() { - return () => h( - AlertDialogRoot, - { - open: open.value, - 'onUpdate:open': (v: boolean | undefined) => { open.value = v!; }, - }, - { - default: () => [ - h(AlertDialogTrigger, null, { default: () => 'Open' }), - h(AlertDialogPortal, null, { - default: () => [ - h(AlertDialogOverlay), - h(AlertDialogContent, null, { - default: () => [ - h(AlertDialogTitle, null, { default: () => 'Are you sure?' }), - h(AlertDialogDescription, null, { default: () => 'This cannot be undone.' }), - h(AlertDialogCancel, null, { default: () => 'Cancel' }), - h(AlertDialogAction, null, { default: () => 'OK' }), - ], - }), - ], - }), - ], - }, - ); - }, - }); - const w = track(mount(Harness, { attachTo: document.body })); - return { wrapper: w, open }; -} - -describe('AlertDialog', () => { - it('renders content with role="alertdialog"', async () => { - mountAlert(true); - await nextTick(); - await nextTick(); - const content = document.querySelector('[data-alert-dialog-content]'); - expect(content).toBeTruthy(); - expect(content!.getAttribute('role')).toBe('alertdialog'); - }); - - it('labels content via Title and describes via Description', async () => { - mountAlert(true); - await nextTick(); - await nextTick(); - const content = document.querySelector('[data-alert-dialog-content]')!; - const labelledby = content.getAttribute('aria-labelledby'); - const describedby = content.getAttribute('aria-describedby'); - expect(labelledby).toMatch(/dialog-title/); - expect(describedby).toMatch(/dialog-description/); - expect(document.getElementById(labelledby!)?.textContent).toBe('Are you sure?'); - expect(document.getElementById(describedby!)?.textContent).toBe('This cannot be undone.'); - }); - - it('Cancel button closes the dialog', async () => { - const { open } = mountAlert(true); - await nextTick(); - await nextTick(); - const cancel = document.querySelector('[data-alert-dialog-cancel]')!; - cancel.click(); - await nextTick(); - await nextTick(); - expect(open.value).toBe(false); - }); - - it('Action button closes the dialog', async () => { - const { open } = mountAlert(true); - await nextTick(); - await nextTick(); - const action = document.querySelector('[data-alert-dialog-action]')!; - action.click(); - await nextTick(); - await nextTick(); - expect(open.value).toBe(false); - }); - - it('Cancel and Action carry data attributes', async () => { - mountAlert(true); - await nextTick(); - await nextTick(); - expect(document.querySelector('[data-alert-dialog-cancel]')).toBeTruthy(); - expect(document.querySelector('[data-alert-dialog-action]')).toBeTruthy(); - }); -}); diff --git a/vue/primitives/src/aspect-ratio/__test__/AspectRatio.test.ts b/vue/primitives/src/aspect-ratio/__test__/AspectRatio.test.ts deleted file mode 100644 index 136b94c..0000000 --- a/vue/primitives/src/aspect-ratio/__test__/AspectRatio.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { describe, expect, it } from 'vitest'; -import { AspectRatio } from '../index'; - -describe('AspectRatio', () => { - it('renders with default 1:1 ratio', () => { - const wrapper = mount(AspectRatio); - const outer = wrapper.element as HTMLElement; - expect(outer.style.paddingBottom).toBe('100%'); - }); - - it('computes padding-bottom from ratio', () => { - const wrapper = mount(AspectRatio, { props: { ratio: 16 / 9 } }); - const outer = wrapper.element as HTMLElement; - expect(outer.style.paddingBottom).toMatch(/^56\.25%$/); - }); - - it('updates padding-bottom when ratio prop changes', async () => { - const wrapper = mount(AspectRatio, { props: { ratio: 16 / 9 } }); - const outer = wrapper.element as HTMLElement; - expect(outer.style.paddingBottom).toBe('56.25%'); - - await wrapper.setProps({ ratio: 1 }); - expect(outer.style.paddingBottom).toBe('100%'); - - await wrapper.setProps({ ratio: 4 / 3 }); - expect(outer.style.paddingBottom).toBe('75%'); - }); - - it('places inner element absolutely covering the wrapper', () => { - const wrapper = mount(AspectRatio, { props: { ratio: 4 / 3 }, slots: { default: '' } }); - const inner = wrapper.element.firstElementChild as HTMLElement; - expect(inner.style.position).toBe('absolute'); - expect(inner.getAttribute('data-aspect-ratio')).toBe('true'); - }); -}); diff --git a/vue/primitives/src/avatar/AvatarImage.vue b/vue/primitives/src/avatar/AvatarImage.vue deleted file mode 100644 index 83e5564..0000000 --- a/vue/primitives/src/avatar/AvatarImage.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - - - diff --git a/vue/primitives/src/avatar/__test__/Avatar.test.ts b/vue/primitives/src/avatar/__test__/Avatar.test.ts deleted file mode 100644 index f2a15a8..0000000 --- a/vue/primitives/src/avatar/__test__/Avatar.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { defineComponent, h, nextTick } from 'vue'; -import { AvatarFallback, AvatarImage, AvatarRoot } from '../index'; - -class MockImage { - onload: (() => void) | null = null; - onerror: (() => void) | null = null; - private _src = ''; - set src(value: string) { - this._src = value; - queueMicrotask(() => { - if (value.includes('broken')) this.onerror?.(); - else this.onload?.(); - }); - } - - get src() { return this._src; } -} - -describe('Avatar', () => { - beforeEach(() => { - vi.stubGlobal('Image', MockImage as unknown as typeof Image); - }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('renders fallback until image loads', async () => { - const w = mount(defineComponent({ - setup: () => () => h(AvatarRoot, null, { - default: () => [ - h(AvatarImage, { src: '/ok.png', alt: 'user' }), - h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }), - ], - }), - })); - expect(w.find('.fb').exists()).toBe(true); - expect(w.find('img').exists()).toBe(false); - await new Promise(r => queueMicrotask(() => r(null))); - await nextTick(); - expect(w.find('img').exists()).toBe(true); - expect(w.find('img').attributes('src')).toBe('/ok.png'); - expect(w.find('.fb').exists()).toBe(false); - }); - - it('keeps fallback visible on error', async () => { - const w = mount(defineComponent({ - setup: () => () => h(AvatarRoot, null, { - default: () => [ - h(AvatarImage, { src: '/broken.png' }), - h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }), - ], - }), - })); - await new Promise(r => queueMicrotask(() => r(null))); - await nextTick(); - expect(w.find('img').exists()).toBe(false); - expect(w.find('.fb').exists()).toBe(true); - }); - - it('delays fallback rendering when delayMs is set', async () => { - vi.useFakeTimers(); - const w = mount(defineComponent({ - setup: () => () => h(AvatarRoot, null, { - default: () => [ - h(AvatarFallback, { class: 'fb', delayMs: 500 }, { default: () => 'AB' }), - ], - }), - })); - expect(w.find('.fb').exists()).toBe(false); - vi.advanceTimersByTime(500); - await nextTick(); - expect(w.find('.fb').exists()).toBe(true); - vi.useRealTimers(); - }); - - it('sets data-status on the root element', async () => { - const w = mount(defineComponent({ - setup: () => () => h(AvatarRoot, null, { - default: () => [ - h(AvatarImage, { src: '/ok.png' }), - h(AvatarFallback, null, { default: () => '?' }), - ], - }), - })); - await nextTick(); - expect(w.element.getAttribute('data-status')).toBe('loading'); - await new Promise(r => queueMicrotask(() => r(null))); - await nextTick(); - expect(w.element.getAttribute('data-status')).toBe('loaded'); - }); -}); diff --git a/vue/primitives/src/calendar/CalendarCellTrigger.vue b/vue/primitives/src/calendar/CalendarCellTrigger.vue deleted file mode 100644 index 2c20ea3..0000000 --- a/vue/primitives/src/calendar/CalendarCellTrigger.vue +++ /dev/null @@ -1,202 +0,0 @@ - - - - - diff --git a/vue/primitives/src/calendar/CalendarGridHead.vue b/vue/primitives/src/calendar/CalendarGridHead.vue deleted file mode 100644 index ce273f7..0000000 --- a/vue/primitives/src/calendar/CalendarGridHead.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - - - diff --git a/vue/primitives/src/canvas/angle-dial/AngleDialRoot.vue b/vue/primitives/src/canvas/angle-dial/AngleDialRoot.vue new file mode 100644 index 0000000..0d22f5f --- /dev/null +++ b/vue/primitives/src/canvas/angle-dial/AngleDialRoot.vue @@ -0,0 +1,335 @@ + + + + + diff --git a/vue/primitives/src/canvas/angle-dial/AngleDialThumb.vue b/vue/primitives/src/canvas/angle-dial/AngleDialThumb.vue new file mode 100644 index 0000000..b697b6a --- /dev/null +++ b/vue/primitives/src/canvas/angle-dial/AngleDialThumb.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/vue/primitives/src/canvas/angle-dial/__test__/AngleDial.test.ts b/vue/primitives/src/canvas/angle-dial/__test__/AngleDial.test.ts new file mode 100644 index 0000000..4142761 --- /dev/null +++ b/vue/primitives/src/canvas/angle-dial/__test__/AngleDial.test.ts @@ -0,0 +1,423 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import { AngleDialRoot, AngleDialThumb } from '../index'; +import { + angleToHue, + angleToPoint, + circularDistance, + normalizeDeg, + pointToAngle, + shortestDelta, +} from '../utils'; + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +type RootOpts = Partial<{ + min: number; + max: number; + step: number; + largeStep: number; + wrap: 'wrap' | 'clamp'; + snap: number | number[]; + disabled: boolean; + defaultValue: number; +}>; + +function mountDial(opts: RootOpts = {}, thumbProps: Record = {}) { + const model = ref(undefined); + const Harness = defineComponent({ + setup() { + return () => h(AngleDialRoot, { + value: model.value, + 'onUpdate:value': (v: number | null | undefined) => { model.value = v ?? undefined; }, + ...opts, + }, { + default: () => h(AngleDialThumb, thumbProps), + }); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + return { wrapper: w, model }; +} + +function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false }); + el.dispatchEvent(event); +} + +function thumbEl(): HTMLElement { + return document.querySelector('[role="slider"]')!; +} + +describe('AngleDialThumb — ARIA', () => { + it('role="slider" with aria-valuemin/max/now and NO aria-orientation', async () => { + mountDial({ defaultValue: 45, min: 0, max: 360 }); + await nextTick(); + const thumb = thumbEl(); + expect(thumb).toBeTruthy(); + expect(thumb.getAttribute('role')).toBe('slider'); + expect(thumb.getAttribute('aria-valuemin')).toBe('0'); + expect(thumb.getAttribute('aria-valuemax')).toBe('360'); + expect(thumb.getAttribute('aria-valuenow')).toBe('45'); + // A radial control has no axis — aria-orientation must be absent. + expect(thumb.getAttribute('aria-orientation')).toBeNull(); + expect(thumb.tabIndex).toBe(0); + }); + + it('aria-valuetext defaults to "°"', async () => { + mountDial({ defaultValue: 45 }); + await nextTick(); + expect(thumbEl().getAttribute('aria-valuetext')).toBe('45°'); + }); + + it('rounds the default valuetext degrees', async () => { + mountDial({ defaultValue: 33.6, step: 0.1 }); + await nextTick(); + expect(thumbEl().getAttribute('aria-valuetext')).toBe('34°'); + }); + + it('a custom valueText formatter overrides the default', async () => { + mountDial({ defaultValue: 90 }, { valueText: (deg: number) => `${deg} degrees` }); + await nextTick(); + expect(thumbEl().getAttribute('aria-valuetext')).toBe('90 degrees'); + }); + + it('an explicit aria-valuetext attr wins over the formatter', async () => { + mountDial({ defaultValue: 90 }, { 'aria-valuetext': 'east' }); + await nextTick(); + expect(thumbEl().getAttribute('aria-valuetext')).toBe('east'); + }); + + it('defaults aria-label to "Angle" and lets an explicit label win', async () => { + mountDial({ defaultValue: 0 }); + await nextTick(); + expect(thumbEl().getAttribute('aria-label')).toBe('Angle'); + document.body.innerHTML = ''; + while (wrappers.length) wrappers.pop()!.unmount(); + mountDial({ defaultValue: 0 }, { 'aria-label': 'Hue' }); + await nextTick(); + expect(thumbEl().getAttribute('aria-label')).toBe('Hue'); + }); +}); + +describe('AngleDialThumb — keyboard', () => { + it('ArrowRight increments by step, ArrowLeft decrements', async () => { + const { model } = mountDial({ defaultValue: 50, step: 5, wrap: 'clamp' }); + await nextTick(); + const thumb = thumbEl(); + keydown(thumb, 'ArrowRight'); + await nextTick(); + expect(model.value).toBe(55); + keydown(thumb, 'ArrowLeft'); + keydown(thumb, 'ArrowLeft'); + await nextTick(); + expect(model.value).toBe(45); + }); + + it('ArrowUp increments, ArrowDown decrements', async () => { + const { model } = mountDial({ defaultValue: 50, step: 5, wrap: 'clamp' }); + await nextTick(); + const thumb = thumbEl(); + keydown(thumb, 'ArrowUp'); + await nextTick(); + expect(model.value).toBe(55); + keydown(thumb, 'ArrowDown'); + await nextTick(); + expect(model.value).toBe(50); + }); + + it('Shift+Arrow jumps by largeStep', async () => { + const { model } = mountDial({ defaultValue: 100, step: 1, largeStep: 15, wrap: 'clamp' }); + await nextTick(); + const thumb = thumbEl(); + keydown(thumb, 'ArrowRight', { shiftKey: true }); + await nextTick(); + expect(model.value).toBe(115); + keydown(thumb, 'ArrowLeft', { shiftKey: true }); + await nextTick(); + expect(model.value).toBe(100); + }); + + it('PageUp / PageDown jump by largeStep', async () => { + const { model } = mountDial({ defaultValue: 100, step: 1, largeStep: 15, wrap: 'clamp' }); + await nextTick(); + const thumb = thumbEl(); + keydown(thumb, 'PageUp'); + await nextTick(); + expect(model.value).toBe(115); + keydown(thumb, 'PageDown'); + keydown(thumb, 'PageDown'); + await nextTick(); + expect(model.value).toBe(85); + }); + + it('Home/End jump to the bounds (clamp mode)', async () => { + const { model } = mountDial({ defaultValue: 90, min: 0, max: 270, step: 1, wrap: 'clamp' }); + await nextTick(); + const thumb = thumbEl(); + keydown(thumb, 'Home'); + await nextTick(); + expect(model.value).toBe(0); + keydown(thumb, 'End'); + await nextTick(); + expect(model.value).toBe(270); + }); + + it('Home goes to the seam start and End just before the seam (wrap mode)', async () => { + const { model } = mountDial({ defaultValue: 90, min: 0, max: 360, step: 1, wrap: 'wrap' }); + await nextTick(); + const thumb = thumbEl(); + keydown(thumb, 'Home'); + await nextTick(); + expect(model.value).toBe(0); + keydown(thumb, 'End'); + await nextTick(); + // max (360) folds back to 0 in wrap mode, so End lands just before the seam. + expect(model.value).toBe(359); + }); + + it('disabled: tabindex=-1, aria-disabled, keys do nothing', async () => { + const { model } = mountDial({ defaultValue: 50, disabled: true }); + await nextTick(); + const thumb = thumbEl(); + expect(thumb.tabIndex).toBe(-1); + expect(thumb.getAttribute('aria-disabled')).toBe('true'); + keydown(thumb, 'ArrowRight'); + await nextTick(); + expect(model.value).toBeUndefined(); + }); + + it('preventDefault is called on handled keys', async () => { + mountDial({ defaultValue: 50, wrap: 'clamp' }); + await nextTick(); + const thumb = thumbEl(); + const ev = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }); + thumb.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + }); +}); + +describe('AngleDialRoot — seam handling', () => { + it('wrap mode crosses the 0/360 seam forward without jumping backwards', async () => { + const { model } = mountDial({ defaultValue: 359, min: 0, max: 360, step: 1, largeStep: 5, wrap: 'wrap' }); + await nextTick(); + const thumb = thumbEl(); + // 359 + 5 (large) should land at 4 (wrapped through 0), not 354. + keydown(thumb, 'PageUp'); + await nextTick(); + expect(model.value).toBe(4); + }); + + it('wrap mode crosses the seam backward (0 → 355)', async () => { + const { model } = mountDial({ defaultValue: 0, min: 0, max: 360, step: 1, largeStep: 5, wrap: 'wrap' }); + await nextTick(); + const thumb = thumbEl(); + keydown(thumb, 'PageDown'); + await nextTick(); + expect(model.value).toBe(355); + }); + + it('clamp mode bounds at the arc ends instead of wrapping', async () => { + const { model } = mountDial({ defaultValue: 355, min: 0, max: 360, step: 1, largeStep: 15, wrap: 'clamp' }); + await nextTick(); + const thumb = thumbEl(); + keydown(thumb, 'PageUp'); + await nextTick(); + expect(model.value).toBe(360); + // Pushing further stays clamped. + keydown(thumb, 'ArrowRight'); + await nextTick(); + expect(model.value).toBe(360); + }); + + it('clamp mode bounds at the low end', async () => { + const { model } = mountDial({ defaultValue: 5, min: 0, max: 360, step: 1, largeStep: 15, wrap: 'clamp' }); + await nextTick(); + const thumb = thumbEl(); + keydown(thumb, 'PageDown'); + await nextTick(); + expect(model.value).toBe(0); + }); +}); + +describe('AngleDialRoot — snap', () => { + it('a snapped keyboard nudge advances to the next snap target', async () => { + const { model } = mountDial({ defaultValue: 0, step: 1, snap: 15, wrap: 'clamp' }); + await nextTick(); + const thumb = thumbEl(); + // From a snap point, one ArrowRight steps to the next reachable snap target + // (15) instead of freezing at 0 (snap is coarser than step). + keydown(thumb, 'ArrowRight'); + await nextTick(); + expect(model.value).toBe(15); + keydown(thumb, 'ArrowRight'); + await nextTick(); + expect(model.value).toBe(30); + keydown(thumb, 'ArrowLeft'); + await nextTick(); + expect(model.value).toBe(15); + }); +}); + +describe('AngleDialRoot — pointer drag', () => { + function rootEl(): HTMLElement { + // The root is the AngleDialRoot's rendered element (the dial container). + return document.querySelector('[role="slider"]')!.parentElement!; + } + + function sizeRoot(el: HTMLElement): { cx: number; cy: number } { + el.style.position = 'fixed'; + el.style.left = '0px'; + el.style.top = '0px'; + el.style.width = '200px'; + el.style.height = '200px'; + const rect = el.getBoundingClientRect(); + return { cx: rect.left + rect.width / 2, cy: rect.top + rect.height / 2 }; + } + + function pointer(el: HTMLElement, type: string, x: number, y: number): void { + const ev = new PointerEvent(type, { + pointerId: 1, + button: 0, + clientX: x, + clientY: y, + bubbles: true, + cancelable: true, + }); + el.dispatchEvent(ev); + } + + it('pressing to the right of center sets the value to ~90° (0=up, clockwise)', async () => { + const { model } = mountDial({ defaultValue: 0, min: 0, max: 360, step: 1, wrap: 'wrap' }); + await nextTick(); + const root = rootEl(); + const { cx, cy } = sizeRoot(root); + // A point straight to the right of center. + pointer(root, 'pointerdown', cx + 50, cy); + pointer(root, 'pointerup', cx + 50, cy); + await nextTick(); + expect(model.value).toBe(90); + }); + + it('pressing below center sets the value to ~180°', async () => { + const { model } = mountDial({ defaultValue: 0, min: 0, max: 360, step: 1, wrap: 'wrap' }); + await nextTick(); + const root = rootEl(); + const { cx, cy } = sizeRoot(root); + pointer(root, 'pointerdown', cx, cy + 50); + pointer(root, 'pointerup', cx, cy + 50); + await nextTick(); + expect(model.value).toBe(180); + }); + + it('a press exactly at center is ignored (no angle)', async () => { + const { model } = mountDial({ defaultValue: 42, min: 0, max: 360, step: 1, wrap: 'wrap' }); + await nextTick(); + const root = rootEl(); + const { cx, cy } = sizeRoot(root); + pointer(root, 'pointerdown', cx, cy); + pointer(root, 'pointerup', cx, cy); + await nextTick(); + expect(model.value).toBeUndefined(); + }); + + it('emits valueCommit on pointerup', async () => { + const committed: number[] = []; + const model = ref(undefined); + const Harness = defineComponent({ + setup: () => () => h(AngleDialRoot, { + value: model.value, + wrap: 'wrap', + 'onUpdate:value': (v: number | null | undefined) => { model.value = v ?? undefined; }, + onValueCommit: (v: number) => committed.push(v), + }, { default: () => h(AngleDialThumb) }), + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + const root = document.querySelector('[role="slider"]')!.parentElement!; + const { cx, cy } = sizeRoot(root); + pointer(root, 'pointerdown', cx + 50, cy); + pointer(root, 'pointerup', cx + 50, cy); + await nextTick(); + expect(committed).toContain(90); + }); +}); + +describe('utils — polar math', () => { + const center = { x: 0, y: 0 }; + + it('pointToAngle: up = 0°', () => { + // Screen y grows downward, so "up" is negative y. + expect(pointToAngle({ x: 0, y: -10 }, center)).toBeCloseTo(0); + }); + + it('pointToAngle: right = 90°', () => { + expect(pointToAngle({ x: 10, y: 0 }, center)).toBeCloseTo(90); + }); + + it('pointToAngle: down = 180°', () => { + expect(pointToAngle({ x: 0, y: 10 }, center)).toBeCloseTo(180); + }); + + it('pointToAngle: left = 270°', () => { + expect(pointToAngle({ x: -10, y: 0 }, center)).toBeCloseTo(270); + }); + + it('angleToPoint is the inverse of pointToAngle for cardinals', () => { + for (const deg of [0, 90, 180, 270]) { + const p = angleToPoint(deg, 10, center); + expect(pointToAngle(p, center)).toBeCloseTo(deg); + } + }); + + it('angleToPoint: 0° is straight up (negative y)', () => { + const p = angleToPoint(0, 10, center); + expect(p.x).toBeCloseTo(0); + expect(p.y).toBeCloseTo(-10); + }); + + it('angleToPoint: 90° is to the right (positive x)', () => { + const p = angleToPoint(90, 10, center); + expect(p.x).toBeCloseTo(10); + expect(p.y).toBeCloseTo(0); + }); + + it('normalizeDeg folds into [0, 360)', () => { + expect(normalizeDeg(370)).toBe(10); + expect(normalizeDeg(-10)).toBe(350); + expect(normalizeDeg(0)).toBe(0); + expect(normalizeDeg(360)).toBe(0); + }); + + it('shortestDelta picks the seam-crossing direction', () => { + expect(shortestDelta(350, 10)).toBeCloseTo(20); + expect(shortestDelta(10, 350)).toBeCloseTo(-20); + expect(shortestDelta(0, 90)).toBeCloseTo(90); + expect(shortestDelta(0, 180)).toBeCloseTo(180); + }); + + it('circularDistance is the shortest arc', () => { + expect(circularDistance(350, 10)).toBeCloseTo(20); + expect(circularDistance(0, 180)).toBeCloseTo(180); + expect(circularDistance(10, 350)).toBeCloseTo(20); + }); + + it('angleToHue produces an hsl string from the wrapped angle', () => { + expect(angleToHue(0)).toBe('hsl(0, 100%, 50%)'); + expect(angleToHue(120)).toBe('hsl(120, 100%, 50%)'); + expect(angleToHue(370)).toBe('hsl(10, 100%, 50%)'); + expect(angleToHue(120, 80, 40)).toBe('hsl(120, 80%, 40%)'); + }); +}); diff --git a/vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-computes-padding-bottom-from-ratio-1.png b/vue/primitives/src/canvas/angle-dial/__test__/__screenshots__/AngleDial.test.ts/AngleDialRoot---snap-snaps-to-a-scalar-increment-via-keyboard-1.png similarity index 100% rename from vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-computes-padding-bottom-from-ratio-1.png rename to vue/primitives/src/canvas/angle-dial/__test__/__screenshots__/AngleDial.test.ts/AngleDialRoot---snap-snaps-to-a-scalar-increment-via-keyboard-1.png diff --git a/vue/primitives/src/canvas/angle-dial/context.ts b/vue/primitives/src/canvas/angle-dial/context.ts new file mode 100644 index 0000000..abef3d6 --- /dev/null +++ b/vue/primitives/src/canvas/angle-dial/context.ts @@ -0,0 +1,72 @@ +import type { Ref } from 'vue'; +import { useContextFactory } from '@robonen/vue'; + +export type AngleDialDirection = 'ltr' | 'rtl'; + +/** + * How the value behaves at the bounds. + * - `'wrap'` — the value lives on a continuous circle; crossing the `0` / `360` + * seam (e.g. dragging from `359°` forward) flows through to `0°`+ without + * jumping backwards. `min` / `max` are treated as the seam (a full turn). + * - `'clamp'` — the value is bounded to the arc `[min, max]`; pushing past an + * end stops at that end instead of jumping to the far side. + */ +export type AngleDialWrap = 'wrap' | 'clamp'; + +/** + * Snap increments for the angle, in degrees. + * - A `number` snaps to every multiple of that increment (e.g. `15` → every 15°). + * - A `number[]` snaps to the nearest of an explicit list of angles + * (e.g. `[0, 45, 90, 135, 180, 225, 270, 315]`). + * - `undefined` disables snapping (only `step` rounding applies). + */ +export type AngleDialSnap = number | number[] | undefined; + +/** + * Formatter turning the raw angle (degrees) into a human-friendly string for + * `aria-valuetext`. A bare number on a circular control is ambiguous, so the + * thumb strongly defaults this to `` `${Math.round(deg)}°` ``. Return + * `undefined` to omit `aria-valuetext`. + */ +export type AngleDialValueText = (deg: number) => string | undefined; + +/** + * Context shared between `AngleDialRoot` and `AngleDialThumb`. + * + * Scalar props are exposed as plain `Ref` values, but `AngleDialRoot` builds + * them with `toRef(() => prop)` — a `GetterRefImpl` that is reactive without + * allocating a `ReactiveEffect` / cache (unlike `computed`), mirroring the + * slider's identity-passthrough convention. + */ +export interface AngleDialContext { + /** Current angle in degrees. */ + value: Ref; + min: Ref; + max: Ref; + step: Ref; + /** Large-step increment (Page keys / Shift+Arrow), in degrees. */ + largeStep: Ref; + wrap: Ref; + snap: Ref; + disabled: Ref; + direction: Ref; + /** + * Commit a new raw angle (degrees). The root applies snap, step rounding, and + * wrap/clamp handling before writing the model. + */ + setValue: (deg: number) => void; + /** + * Nudge the value by a signed delta (degrees) from the keyboard. Honors + * wrap/clamp semantics (a wrap dial crosses the seam; a clamp dial stops). + */ + nudge: (delta: number) => void; + /** Jump to the canonical low end (Home). */ + toStart: () => void; + /** Jump to the canonical high end (End). */ + toEnd: () => void; +} + +const ctx = useContextFactory('AngleDialContext'); + +export const provideAngleDialContext = ctx.provide; +export const useAngleDialContext = ctx.inject; diff --git a/vue/primitives/src/canvas/angle-dial/demo.vue b/vue/primitives/src/canvas/angle-dial/demo.vue new file mode 100644 index 0000000..68dfad5 --- /dev/null +++ b/vue/primitives/src/canvas/angle-dial/demo.vue @@ -0,0 +1,49 @@ + + + diff --git a/vue/primitives/src/canvas/angle-dial/index.ts b/vue/primitives/src/canvas/angle-dial/index.ts new file mode 100644 index 0000000..5577d2b --- /dev/null +++ b/vue/primitives/src/canvas/angle-dial/index.ts @@ -0,0 +1,21 @@ +export { default as AngleDialRoot } from './AngleDialRoot.vue'; +export { default as AngleDialThumb } from './AngleDialThumb.vue'; +export type { AngleDialRootEmits, AngleDialRootProps } from './AngleDialRoot.vue'; +export type { AngleDialThumbProps } from './AngleDialThumb.vue'; +export type { + AngleDialContext, + AngleDialDirection, + AngleDialSnap, + AngleDialValueText, + AngleDialWrap, +} from './context'; +export { + angleToHue, + angleToPoint, + applySnap, + circularDistance, + normalizeDeg, + pointToAngle, + shortestDelta, + type Point, +} from './utils'; diff --git a/vue/primitives/src/canvas/angle-dial/utils.ts b/vue/primitives/src/canvas/angle-dial/utils.ts new file mode 100644 index 0000000..220c2cd --- /dev/null +++ b/vue/primitives/src/canvas/angle-dial/utils.ts @@ -0,0 +1,142 @@ +import type { Point } from '../../internal/utils/geometry'; +import type { AngleDialSnap } from './context'; + +// Reuse the package-canonical 2D point (internal `utils/geometry`, not in the +// root barrel) so angle-dial's `Point` is the SAME symbol as spline/pointer-drag/ +// snapping re-export — keeps the root barrel free of a TS2308 `Point` clash. +/** A 2D point in client (screen) pixels. */ +export type { Point }; + +/** + * ANGLE CONVENTION (load-bearing — every consumer assumes it): + * + * 0° points UP (12 o'clock) and the angle increases CLOCKWISE. + * + * up = 0°, right = 90°, down = 180°, left = 270°. + * + * This matches how rotation/heading dials are read by humans (a compass-style + * "12 o'clock is zero, turn right to increase"), not the mathematical + * convention (0° = +x, counter-clockwise). All conversions below honor it. + */ + +const TAU = Math.PI * 2; +const RAD_TO_DEG = 360 / TAU; +const DEG_TO_RAD = TAU / 360; + +/** Wrap an angle (degrees) into the `[0, 360)` range. */ +export function normalizeDeg(deg: number): number { + const r = deg % 360; + return r < 0 ? r + 360 : r; +} + +/** + * Convert a screen-space point into an angle (degrees) about `center`, using + * the documented convention (0° = up, clockwise-positive). Returns a value in + * `[0, 360)`. + * + * Screen y grows downward, so a point directly below the center (`dy > 0`) + * reads as 180° (down), and a point to the right (`dx > 0`) reads as 90°. + */ +export function pointToAngle(point: Point, center: Point): number { + const dx = point.x - center.x; + const dy = point.y - center.y; + // atan2(dx, -dy): rotate the standard math frame so 0° is up and the angle + // increases clockwise. `-dy` flips the down-positive screen axis to up. + const rad = Math.atan2(dx, -dy); + return normalizeDeg(rad * RAD_TO_DEG); +} + +/** + * Convert an angle (degrees) into a point on a circle of `radius` about + * `center`, using the documented convention (0° = up, clockwise-positive). + * The inverse of {@link pointToAngle}. + */ +export function angleToPoint(deg: number, radius: number, center: Point): Point { + const rad = deg * DEG_TO_RAD; + return { + x: center.x + Math.sin(rad) * radius, + y: center.y - Math.cos(rad) * radius, + }; +} + +/** + * Number of decimal digits in a numeric `step`, mirroring the slider's helper. + * Used to compensate floating-point drift after step rounding. + */ +export function getStepDecimals(step: number): number { + if (!Number.isFinite(step)) return 0; + const str = String(step); + const dot = str.indexOf('.'); + if (dot === -1) return 0; + return str.length - dot - 1; +} + +/** Snap `value` to the nearest multiple of `step` anchored at `min`. */ +export function roundToStep(value: number, step: number, min: number, decimals: number): number { + if (step <= 0) return value; + const nearest = Math.round((value - min) / step) * step + min; + return decimals > 0 ? Number(nearest.toFixed(decimals)) : nearest; +} + +/** + * Snap an angle to the nearest configured snap target. + * + * - scalar `snap` → nearest multiple of that increment (anchored at 0). + * - `snap` array → nearest of the explicit angles. In `wrap` mode the distance + * is measured around the circle so a value near 350° can snap to a `0` + * target; in clamp/linear mode it is a plain numeric distance. + * + * Returns `value` unchanged when `snap` is `undefined` or empty. + */ +export function applySnap(value: number, snap: AngleDialSnap, wrap: boolean): number { + if (snap === undefined) return value; + if (typeof snap === 'number') { + if (snap <= 0) return value; + return Math.round(value / snap) * snap; + } + if (snap.length === 0) return value; + let best = snap[0]!; + let bestDist = wrap ? circularDistance(value, best) : Math.abs(value - best); + for (let i = 1; i < snap.length; i++) { + const target = snap[i]!; + const dist = wrap ? circularDistance(value, target) : Math.abs(value - target); + if (dist < bestDist) { + bestDist = dist; + best = target; + } + } + return best; +} + +/** Shortest absolute angular distance (degrees) between two angles on a circle. */ +export function circularDistance(a: number, b: number): number { + const d = Math.abs(normalizeDeg(a) - normalizeDeg(b)) % 360; + return d > 180 ? 360 - d : d; +} + +/** + * Map an angle (degrees) onto the HSL hue wheel, returning a fully-saturated + * CSS color string for that hue. Handy when the dial doubles as a hue ring + * (`min: 0, max: 360, wrap: 'wrap'`): feed the angle straight in to color the + * thumb or a swatch. `saturation` and `lightness` are percentages. + * + * @param deg The angle in degrees (any range; wrapped to `[0, 360)`). + * @param saturation Saturation percentage. @default 100 + * @param lightness Lightness percentage. @default 50 + */ +export function angleToHue(deg: number, saturation = 100, lightness = 50): string { + return `hsl(${normalizeDeg(deg)}, ${saturation}%, ${lightness}%)`; +} + +/** + * Signed shortest angular step (degrees) from `from` to `to` on a circle, + * in `(-180, 180]`. Positive is clockwise (increasing angle). Used to track the + * frame-to-frame delta in `wrap` mode so crossing the `0` / `360` seam + * accumulates smoothly instead of jumping `359 → 0 → 359`. + */ +export function shortestDelta(from: number, to: number): number { + let d = (normalizeDeg(to) - normalizeDeg(from)) % 360; + if (d > 180) d -= 360; + else if (d <= -180) d += 360; + return d; +} diff --git a/vue/primitives/src/canvas/canvas-stage/CanvasStageContent.vue b/vue/primitives/src/canvas/canvas-stage/CanvasStageContent.vue new file mode 100644 index 0000000..0f3a23f --- /dev/null +++ b/vue/primitives/src/canvas/canvas-stage/CanvasStageContent.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/vue/primitives/src/canvas/canvas-stage/CanvasStagePane.vue b/vue/primitives/src/canvas/canvas-stage/CanvasStagePane.vue new file mode 100644 index 0000000..198af4f --- /dev/null +++ b/vue/primitives/src/canvas/canvas-stage/CanvasStagePane.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/vue/primitives/src/canvas/canvas-stage/CanvasStageRoot.vue b/vue/primitives/src/canvas/canvas-stage/CanvasStageRoot.vue new file mode 100644 index 0000000..4e7ec67 --- /dev/null +++ b/vue/primitives/src/canvas/canvas-stage/CanvasStageRoot.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/vue/primitives/src/canvas/canvas-stage/CanvasStageZoomIndicator.vue b/vue/primitives/src/canvas/canvas-stage/CanvasStageZoomIndicator.vue new file mode 100644 index 0000000..50b7ef3 --- /dev/null +++ b/vue/primitives/src/canvas/canvas-stage/CanvasStageZoomIndicator.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/vue/primitives/src/canvas/canvas-stage/__test__/CanvasStage.test.ts b/vue/primitives/src/canvas/canvas-stage/__test__/CanvasStage.test.ts new file mode 100644 index 0000000..2fd640a --- /dev/null +++ b/vue/primitives/src/canvas/canvas-stage/__test__/CanvasStage.test.ts @@ -0,0 +1,208 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import { CanvasStageContent, CanvasStagePane, CanvasStageRoot } from '../index'; + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +/** Wait for `n` real animation frames so layout + ResizeObserver settle. */ +function raf(n = 1): Promise { + return new Promise((resolve) => { + let i = 0; + const step = (): void => { + if (++i >= n) resolve(); + else requestAnimationFrame(step); + }; + requestAnimationFrame(step); + }); +} + +function mountStage(props: Record = {}) { + const exposed = ref(null); + const Harness = defineComponent({ + setup() { + return () => h(CanvasStageRoot, { + ref: (r: any) => { exposed.value = r; }, + style: 'width: 400px; height: 300px;', + contentWidth: 800, + contentHeight: 600, + 'aria-label': 'Photo', + ...props, + }, { + default: () => h('img', { 'data-child': '', src: '', width: 800, height: 600 }), + }); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + return { wrapper: w, exposed }; +} + +describe('CanvasStageRoot (mount)', () => { + it('renders role=application + roledescription + tabindex 0', async () => { + mountStage(); + await nextTick(); + const root = document.querySelector('[data-canvas-stage-root]')!; + expect(root).toBeTruthy(); + expect(root.getAttribute('role')).toBe('application'); + expect(root.getAttribute('aria-roledescription')).toBe('zoomable canvas'); + expect(root.tabIndex).toBe(0); + // Consumer aria-label rides through $attrs. + expect(root.getAttribute('aria-label')).toBe('Photo'); + }); + + it('composes ViewportRoot/Surface/Content and renders the child in the transformed layer', async () => { + mountStage(); + await nextTick(); + expect(document.querySelector('[data-viewport-root]')).toBeTruthy(); + expect(document.querySelector('[data-canvas-stage-pane][data-viewport-surface]')).toBeTruthy(); + const content = document.querySelector('[data-canvas-stage-content][data-viewport-content]'); + expect(content).toBeTruthy(); + // The child renders INSIDE the transformed content layer. + const child = content!.querySelector('[data-child]'); + expect(child).toBeTruthy(); + expect(content!.style.transformOrigin).toBe('0px 0px'); + }); + + it('keeps the pane a real clipping box (overflow hidden + touch-action none)', async () => { + mountStage(); + await nextTick(); + const pane = document.querySelector('[data-canvas-stage-pane]')!; + expect(pane.style.overflow).toBe('hidden'); + expect(pane.style.touchAction).toBe('none'); + }); + + it('exposes the combined api (fitView/zoomToActual/fitFill/reset/...)', async () => { + const { exposed } = mountStage(); + await nextTick(); + for (const fn of ['getViewport', 'setViewport', 'zoomIn', 'zoomOut', 'zoomTo', 'fitView', 'zoomToActual', 'fitFill', 'center', 'reset']) { + expect(typeof exposed.value[fn]).toBe('function'); + } + }); + + it('zoomToActual sets zoom to 1 (1:1)', async () => { + const { exposed } = mountStage({ defaultViewport: { x: 0, y: 0, zoom: 0.3 } }); + await raf(2); + await nextTick(); + exposed.value.zoomToActual(); + await nextTick(); + expect(exposed.value.getViewport().zoom).toBeCloseTo(1, 5); + }); + + it('fitFill covers the pane (zoom = max ratio), fitView contains it (smaller)', async () => { + // pane 400x300, content 800x600 → contain = 0.5, cover = 0.5 here (same aspect). + // Use a non-matching aspect so contain != cover. + const { exposed } = mountStage({ contentWidth: 800, contentHeight: 200 }); + await raf(2); + await nextTick(); + + exposed.value.fitView(); + await nextTick(); + const fitZoom = exposed.value.getViewport().zoom; + + exposed.value.fitFill(); + await nextTick(); + const fillZoom = exposed.value.getViewport().zoom; + + // contain uses min ratio (with padding) → smaller; cover uses max ratio → larger. + expect(fillZoom).toBeGreaterThan(fitZoom); + }); + + it('pressing \'0\' calls zoomToActual and \'1\' calls fitView', async () => { + const { exposed } = mountStage({ defaultViewport: { x: 0, y: 0, zoom: 4 } }); + await raf(2); + await nextTick(); + const root = document.querySelector('[data-canvas-stage-root]')!; + root.focus(); + + root.dispatchEvent(new KeyboardEvent('keydown', { key: '0', bubbles: true })); + await nextTick(); + expect(exposed.value.getViewport().zoom).toBeCloseTo(1, 5); + + root.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true })); + await nextTick(); + // fitView (contain, 800x600 into 400x300 with padding) → ~0.45. + expect(exposed.value.getViewport().zoom).toBeLessThan(1); + }); + + it('arrow keys pan by panStep; Shift multiplies it', async () => { + const { exposed } = mountStage({ panStep: 40, fitOnReady: false, defaultViewport: { x: 0, y: 0, zoom: 1 } }); + await raf(2); + await nextTick(); + const root = document.querySelector('[data-canvas-stage-root]')!; + root.focus(); + + root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + await nextTick(); + expect(exposed.value.getViewport().x).toBe(-40); + + root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true, bubbles: true })); + await nextTick(); + expect(exposed.value.getViewport().x).toBe(-40 - 200); + }); + + it('disableKeyboardA11y downgrades role to group', async () => { + mountStage({ disableKeyboardA11y: true }); + await nextTick(); + const root = document.querySelector('[data-canvas-stage-root]')!; + expect(root.getAttribute('role')).toBe('group'); + }); + + it('auto-measures the content element when no size props are given', async () => { + const exposed = ref(null); + const Harness = defineComponent({ + setup() { + return () => h(CanvasStageRoot, { + ref: (r: any) => { exposed.value = r; }, + style: 'width: 400px; height: 300px;', + }, { + default: () => h('div', { 'data-child': '', style: 'width: 250px; height: 120px;' }, 'x'), + }); + }, + }); + track(mount(Harness, { attachTo: document.body })); + await raf(3); + await nextTick(); + expect(exposed.value.contentSize.width).toBeCloseTo(250, 0); + expect(exposed.value.contentSize.height).toBeCloseTo(120, 0); + }); + + it('drives a controlled v-model:viewport', async () => { + const vp = ref({ x: 0, y: 0, zoom: 1 }); + const Harness = defineComponent({ + setup() { + return () => h(CanvasStageRoot, { + viewport: vp.value, + 'onUpdate:viewport': (v: any) => { vp.value = v; }, + style: 'width: 400px; height: 300px;', + contentWidth: 800, + contentHeight: 600, + fitOnReady: false, + }, { default: () => h('div', { 'data-child': '' }, 'x') }); + }, + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + vp.value = { x: 10, y: 20, zoom: 1.5 }; + await nextTick(); + const content = document.querySelector('[data-canvas-stage-content]')!; + expect(content.style.transform).toContain('translate(10px, 20px)'); + expect(content.style.transform).toContain('scale(1.5)'); + }); + + it('the parts can be composed manually under a custom subtree', async () => { + // Sanity: Pane + Content are exported and mountable inside the Root surface. + expect(CanvasStagePane).toBeTruthy(); + expect(CanvasStageContent).toBeTruthy(); + }); +}); diff --git a/vue/primitives/src/canvas/canvas-stage/__test__/__screenshots__/CanvasStage.test.ts/CanvasStageRoot--mount--auto-measures-the-content-element-when-no-size-props-are-given-1.png b/vue/primitives/src/canvas/canvas-stage/__test__/__screenshots__/CanvasStage.test.ts/CanvasStageRoot--mount--auto-measures-the-content-element-when-no-size-props-are-given-1.png new file mode 100644 index 0000000..52c754d Binary files /dev/null and b/vue/primitives/src/canvas/canvas-stage/__test__/__screenshots__/CanvasStage.test.ts/CanvasStageRoot--mount--auto-measures-the-content-element-when-no-size-props-are-given-1.png differ diff --git a/vue/primitives/src/canvas/canvas-stage/context.ts b/vue/primitives/src/canvas/canvas-stage/context.ts new file mode 100644 index 0000000..64ae599 --- /dev/null +++ b/vue/primitives/src/canvas/canvas-stage/context.ts @@ -0,0 +1,78 @@ +import type { Ref } from 'vue'; +import { useContextFactory } from '@robonen/vue'; +import type { Dimensions, Rect, Viewport, XYPosition } from '../zoom-pan'; + +/** + * The imperative control surface of a {@link CanvasStageRoot}. Wraps the + * underlying zoom-pan `ViewportApi` and adds the photo-editing fit modes + * (`fitView` / `zoomToActual` / `fitFill`) that depend on the content size. Every + * write funnels through the zoom-pan api's `clampViewport`, so the result always + * honours `minZoom`/`maxZoom`. + */ +export interface CanvasStageApi { + /** Current viewport `{x,y,zoom}`. */ + getViewport: () => Viewport; + /** Replace the viewport (clamped). */ + setViewport: (viewport: Viewport) => void; + /** Zoom in, anchored at the pane centre. */ + zoomIn: (factor?: number) => void; + /** Zoom out, anchored at the pane centre. */ + zoomOut: (factor?: number) => void; + /** Zoom to an absolute level, anchored at the pane centre. */ + zoomTo: (zoom: number) => void; + /** + * Fit the whole content into the pane, centred with `fitPadding`. The + * "contain" mode — the entire image/video is visible. No-op until both the + * pane and the content have been measured. + */ + fitView: () => void; + /** + * Zoom to 1:1 (zoom 1, one content px per screen px), centred on the content + * centre. No-op until the pane has been measured. + */ + zoomToActual: () => void; + /** + * Scale the content to *cover* the pane (the larger of the two fit ratios), + * centred — the "fill" mode, no letterboxing. No-op until measured. + */ + fitFill: () => void; + /** Centre a content-space point in the pane, keeping the current zoom. */ + center: (point?: XYPosition) => void; + /** Reset to `{ x: 0, y: 0, zoom: 1 }` (clamped). */ + reset: () => void; +} + +/** + * Context shared by every `CanvasStage` part. Exposes the wrapped + * {@link CanvasStageApi}, the reactive content size, and the resolved content + * extent (a content-space {@link Rect} starting at the origin), plus the surface + * measurement flag so descendants can gate behaviour until the pane is ready. + */ +export interface CanvasStageContext { + /** The combined imperative api (zoom-pan + canvas fit modes). */ + api: CanvasStageApi; + /** Live viewport `{x,y,zoom}` (read-only mirror of the zoom-pan master). */ + viewport: Ref; + /** + * The intrinsic content size in content-space px — either the explicit + * `contentWidth`/`contentHeight` props or the measured size of the content + * element. `{ width: 0, height: 0 }` until known. + */ + contentSize: Ref; + /** The content extent `{ x: 0, y: 0, width, height }` used by the fit modes. */ + contentExtent: Ref; + /** False until the pane has reported its first non-zero rect. */ + measured: Ref; + /** Whether the content element should be auto-measured (no explicit size props). */ + autoMeasure: Ref; + /** `CanvasStageContent` reports its measured intrinsic size here. */ + setMeasuredContentSize: (size: Dimensions) => void; +} + +const context = useContextFactory('CanvasStageContext'); + +/** Provide the {@link CanvasStageContext} to descendants of `CanvasStageRoot`. */ +export const provideCanvasStageContext = context.provide; + +/** Inject the {@link CanvasStageContext}. Throws when no `CanvasStageRoot` ancestor. */ +export const useCanvasStageContext = context.inject; diff --git a/vue/primitives/src/canvas/canvas-stage/demo.vue b/vue/primitives/src/canvas/canvas-stage/demo.vue new file mode 100644 index 0000000..f972d27 --- /dev/null +++ b/vue/primitives/src/canvas/canvas-stage/demo.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/vue/primitives/src/canvas/canvas-stage/index.ts b/vue/primitives/src/canvas/canvas-stage/index.ts new file mode 100644 index 0000000..7fa98fc --- /dev/null +++ b/vue/primitives/src/canvas/canvas-stage/index.ts @@ -0,0 +1,12 @@ +export { default as CanvasStageRoot } from './CanvasStageRoot.vue'; +export { default as CanvasStagePane } from './CanvasStagePane.vue'; +export { default as CanvasStageContent } from './CanvasStageContent.vue'; +export { default as CanvasStageZoomIndicator } from './CanvasStageZoomIndicator.vue'; + +export { provideCanvasStageContext, useCanvasStageContext } from './context'; +export type { CanvasStageApi, CanvasStageContext } from './context'; + +export type { CanvasStageRootProps } from './CanvasStageRoot.vue'; +export type { CanvasStagePaneEmits, CanvasStagePaneProps } from './CanvasStagePane.vue'; +export type { CanvasStageContentProps } from './CanvasStageContent.vue'; +export type { CanvasStageZoomIndicatorProps } from './CanvasStageZoomIndicator.vue'; diff --git a/vue/primitives/src/canvas/compare-slider/CompareSliderAfter.vue b/vue/primitives/src/canvas/compare-slider/CompareSliderAfter.vue new file mode 100644 index 0000000..d79880b --- /dev/null +++ b/vue/primitives/src/canvas/compare-slider/CompareSliderAfter.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/vue/primitives/src/canvas/compare-slider/CompareSliderBefore.vue b/vue/primitives/src/canvas/compare-slider/CompareSliderBefore.vue new file mode 100644 index 0000000..b40e32f --- /dev/null +++ b/vue/primitives/src/canvas/compare-slider/CompareSliderBefore.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/vue/primitives/src/canvas/compare-slider/CompareSliderDivider.vue b/vue/primitives/src/canvas/compare-slider/CompareSliderDivider.vue new file mode 100644 index 0000000..c146e82 --- /dev/null +++ b/vue/primitives/src/canvas/compare-slider/CompareSliderDivider.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/vue/primitives/src/canvas/compare-slider/CompareSliderHandle.vue b/vue/primitives/src/canvas/compare-slider/CompareSliderHandle.vue new file mode 100644 index 0000000..f7df1bf --- /dev/null +++ b/vue/primitives/src/canvas/compare-slider/CompareSliderHandle.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/vue/primitives/src/canvas/compare-slider/CompareSliderRoot.vue b/vue/primitives/src/canvas/compare-slider/CompareSliderRoot.vue new file mode 100644 index 0000000..f38cc03 --- /dev/null +++ b/vue/primitives/src/canvas/compare-slider/CompareSliderRoot.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/vue/primitives/src/canvas/compare-slider/__test__/CompareSlider.test.ts b/vue/primitives/src/canvas/compare-slider/__test__/CompareSlider.test.ts new file mode 100644 index 0000000..cab32fb --- /dev/null +++ b/vue/primitives/src/canvas/compare-slider/__test__/CompareSlider.test.ts @@ -0,0 +1,333 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import { provideConfig } from '../../../utilities/config-provider'; +import { + CompareSliderAfter, + CompareSliderBefore, + CompareSliderDivider, + CompareSliderHandle, + CompareSliderRoot, +} from '../index'; + +// Minimal declarative config wrapper (the package ships no ConfigProvider +// component; config is provided via `provideConfig` inside a setup). +const ConfigProvider = defineComponent({ + props: { dir: { type: String, default: undefined } }, + setup(props, { slots }) { + provideConfig({ dir: () => props.dir as 'ltr' | 'rtl' | undefined }); + return () => slots.default?.(); + }, +}); + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +type RootOpts = Partial<{ + defaultPosition: number; + disabled: boolean; + inverted: boolean; + orientation: 'horizontal' | 'vertical'; + dir: 'ltr' | 'rtl'; + keyboardStep: number; + keyboardLargeStep: number; +}>; + +function mountSlider(opts: RootOpts = {}, wrapInConfig?: { dir?: 'ltr' | 'rtl' }) { + const model = ref(undefined); + const Harness = defineComponent({ + setup() { + const tree = () => h(CompareSliderRoot, { + position: model.value, + 'onUpdate:position': (v: number | undefined) => { model.value = v; }, + ...opts, + }, { + default: () => [ + h(CompareSliderBefore), + h(CompareSliderAfter, { id: 'after' }), + h(CompareSliderDivider), + h(CompareSliderHandle, { id: 'handle' }), + ], + }); + return () => wrapInConfig + ? h(ConfigProvider, { dir: wrapInConfig.dir }, { default: tree }) + : tree(); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + return { wrapper: w, model }; +} + +function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false }); + el.dispatchEvent(event); +} + +describe('CompareSlider — rendering & ARIA', () => { + it('renders the handle as role="slider" with correct aria-value*', async () => { + mountSlider({ defaultPosition: 40 }); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + expect(handle).toBeTruthy(); + expect(handle.getAttribute('aria-valuemin')).toBe('0'); + expect(handle.getAttribute('aria-valuemax')).toBe('100'); + expect(handle.getAttribute('aria-valuenow')).toBe('40'); + expect(handle.getAttribute('aria-orientation')).toBe('horizontal'); + expect(handle.tabIndex).toBe(0); + }); + + it('applies the default aria-label "Comparison position"', async () => { + mountSlider({ defaultPosition: 50 }); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + expect(handle.getAttribute('aria-label')).toBe('Comparison position'); + }); + + it('an explicit aria-label wins over the default', async () => { + const Harness = defineComponent({ + setup: () => () => h(CompareSliderRoot, { defaultPosition: 50 }, { + default: () => [ + h(CompareSliderBefore), + h(CompareSliderAfter), + h(CompareSliderHandle, { 'aria-label': 'Reveal' }), + ], + }), + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + expect(handle.getAttribute('aria-label')).toBe('Reveal'); + }); + + it('sets data-orientation as a literal on the root and handle', async () => { + mountSlider({ defaultPosition: 50, orientation: 'vertical' }); + await nextTick(); + const handle = document.getElementById('handle')!; + expect(handle.getAttribute('data-orientation')).toBe('vertical'); + expect(handle.getAttribute('aria-orientation')).toBe('vertical'); + }); +}); + +describe('CompareSlider — keyboard', () => { + it('ArrowRight increments and ArrowLeft decrements the position', async () => { + const { model } = mountSlider({ defaultPosition: 50, keyboardStep: 5 }); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + keydown(handle, 'ArrowRight'); + await nextTick(); + expect(model.value).toBe(55); + keydown(handle, 'ArrowLeft'); + keydown(handle, 'ArrowLeft'); + await nextTick(); + expect(model.value).toBe(45); + }); + + it('ArrowRight is reversed under dir="rtl"', async () => { + const { model } = mountSlider({ defaultPosition: 50, keyboardStep: 5, dir: 'rtl' }); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + keydown(handle, 'ArrowRight'); + await nextTick(); + expect(model.value).toBe(45); + keydown(handle, 'ArrowLeft'); + await nextTick(); + expect(model.value).toBe(50); + }); + + it('Shift+Arrow and Page keys use the large step', async () => { + const { model } = mountSlider({ defaultPosition: 50, keyboardStep: 1, keyboardLargeStep: 10 }); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + keydown(handle, 'ArrowRight', { shiftKey: true }); + await nextTick(); + expect(model.value).toBe(60); + keydown(handle, 'PageDown'); + keydown(handle, 'PageDown'); + await nextTick(); + expect(model.value).toBe(40); + }); + + it('Home/End jump to 0/100', async () => { + const { model } = mountSlider({ defaultPosition: 50 }); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + keydown(handle, 'Home'); + await nextTick(); + expect(model.value).toBe(0); + keydown(handle, 'End'); + await nextTick(); + expect(model.value).toBe(100); + }); + + it('clamps at 0 and 100', async () => { + const { model } = mountSlider({ defaultPosition: 95, keyboardStep: 10 }); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + keydown(handle, 'ArrowRight'); + await nextTick(); + expect(model.value).toBe(100); + keydown(handle, 'Home'); + keydown(handle, 'ArrowLeft'); + await nextTick(); + expect(model.value).toBe(0); + }); + + it('vertical: ArrowDown increases, ArrowUp decreases (no-flip reveals the top)', async () => { + const { model } = mountSlider({ defaultPosition: 50, orientation: 'vertical', keyboardStep: 5 }); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + keydown(handle, 'ArrowDown'); + await nextTick(); + expect(model.value).toBe(55); + keydown(handle, 'ArrowUp'); + keydown(handle, 'ArrowUp'); + await nextTick(); + expect(model.value).toBe(45); + }); + + it('disabled: tabindex=-1, aria-disabled, and keys do nothing', async () => { + const { model } = mountSlider({ defaultPosition: 50, disabled: true }); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + expect(handle.tabIndex).toBe(-1); + expect(handle.getAttribute('aria-disabled')).toBe('true'); + keydown(handle, 'ArrowRight'); + keydown(handle, 'Home'); + await nextTick(); + expect(model.value).toBeUndefined(); + expect(handle.getAttribute('aria-valuenow')).toBe('50'); + }); +}); + +describe('CompareSlider — after-layer clip-path', () => { + it('clip-path inset reflects position (horizontal, no flip → clips the right)', async () => { + mountSlider({ defaultPosition: 30 }); + await nextTick(); + const after = document.getElementById('after')!; + // 30% revealed from the left → right edge clipped by 70%. + expect(after.style.clipPath).toBe('inset(0% 70% 0% 0%)'); + }); + + it('updates the clip-path when the position changes', async () => { + const { model } = mountSlider({ defaultPosition: 50, keyboardStep: 10 }); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + const after = document.getElementById('after')!; + keydown(handle, 'ArrowRight'); + await nextTick(); + expect(model.value).toBe(60); + expect(after.style.clipPath).toBe('inset(0% 40% 0% 0%)'); + }); + + it('inverted flips the clipped side (horizontal → clips the left)', async () => { + mountSlider({ defaultPosition: 30, inverted: true }); + await nextTick(); + const after = document.getElementById('after')!; + expect(after.style.clipPath).toBe('inset(0% 0% 0% 70%)'); + }); + + it('vertical (no flip) clips the bottom edge', async () => { + mountSlider({ defaultPosition: 30, orientation: 'vertical' }); + await nextTick(); + const after = document.getElementById('after')!; + // The browser collapses the trailing `0%` (CSSOM `inset()` serialization, + // like `margin`): `inset(0% 0% 70% 0%)` → `inset(0% 0% 70%)`. + expect(after.style.clipPath).toBe('inset(0% 0% 70%)'); + }); + + it('0 and 100 produce no sliver (full hide / full show)', async () => { + const { model } = mountSlider({ defaultPosition: 50 }); + await nextTick(); + const handle = document.querySelector('[role="slider"]')!; + const after = document.getElementById('after')!; + keydown(handle, 'Home'); + await nextTick(); + expect(model.value).toBe(0); + // 0% revealed → right edge clipped by the full 100% (nothing shown). + expect(after.style.clipPath).toBe('inset(0% 100% 0% 0%)'); + keydown(handle, 'End'); + await nextTick(); + // 100% revealed → no clipping; the browser collapses `inset(0% 0% 0% 0%)` + // to the single-value shorthand `inset(0%)`. + expect(after.style.clipPath).toBe('inset(0%)'); + }); +}); + +describe('CompareSlider — pointer drag', () => { + it('maps a pointerdown on the root onto the reveal position', async () => { + const { model } = mountSlider({ defaultPosition: 50 }); + await nextTick(); + const root = document.querySelector('[data-orientation]')!; + // Give the root a deterministic box. + root.style.width = '200px'; + root.style.height = '100px'; + await nextTick(); + const rect = root.getBoundingClientRect(); + const down = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + pointerId: 1, + clientX: rect.left + rect.width * 0.25, + clientY: rect.top + rect.height / 2, + }); + root.dispatchEvent(down); + await nextTick(); + expect(model.value).toBeCloseTo(25, 0); + }); + + it('disabled blocks pointer drag', async () => { + const { model } = mountSlider({ defaultPosition: 50, disabled: true }); + await nextTick(); + const root = document.querySelector('[data-orientation]')!; + root.style.width = '200px'; + const rect = root.getBoundingClientRect(); + const down = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + pointerId: 1, + clientX: rect.left + rect.width * 0.75, + clientY: rect.top + 10, + }); + root.dispatchEvent(down); + await nextTick(); + expect(model.value).toBeUndefined(); + }); +}); + +describe('CompareSlider — controlled position model', () => { + it('reflects an external position update onto aria-valuenow and clip-path', async () => { + const model = ref(20); + const Harness = defineComponent({ + setup: () => () => h(CompareSliderRoot, { + position: model.value, + 'onUpdate:position': (v: number | undefined) => { model.value = v; }, + }, { + default: () => [ + h(CompareSliderBefore), + h(CompareSliderAfter, { id: 'after2' }), + h(CompareSliderHandle, { id: 'handle2' }), + ], + }), + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + const handle = document.getElementById('handle2')!; + expect(handle.getAttribute('aria-valuenow')).toBe('20'); + model.value = 75; + await nextTick(); + expect(handle.getAttribute('aria-valuenow')).toBe('75'); + expect(document.getElementById('after2')!.style.clipPath).toBe('inset(0% 25% 0% 0%)'); + }); +}); diff --git a/vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-places-inner-element-absolutely-covering-the-wrapper-1.png b/vue/primitives/src/canvas/compare-slider/__test__/__screenshots__/CompareSlider.test.ts/CompareSlider---after-layer-clip-path-0-and-100-produce-no-sliver--full-hide---full-show--1.png similarity index 100% rename from vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-places-inner-element-absolutely-covering-the-wrapper-1.png rename to vue/primitives/src/canvas/compare-slider/__test__/__screenshots__/CompareSlider.test.ts/CompareSlider---after-layer-clip-path-0-and-100-produce-no-sliver--full-hide---full-show--1.png diff --git a/vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-renders-with-default-1-1-ratio-1.png b/vue/primitives/src/canvas/compare-slider/__test__/__screenshots__/CompareSlider.test.ts/CompareSlider---after-layer-clip-path-vertical--no-flip--clips-the-bottom-edge-1.png similarity index 100% rename from vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-renders-with-default-1-1-ratio-1.png rename to vue/primitives/src/canvas/compare-slider/__test__/__screenshots__/CompareSlider.test.ts/CompareSlider---after-layer-clip-path-vertical--no-flip--clips-the-bottom-edge-1.png diff --git a/vue/primitives/src/canvas/compare-slider/context.ts b/vue/primitives/src/canvas/compare-slider/context.ts new file mode 100644 index 0000000..dea28de --- /dev/null +++ b/vue/primitives/src/canvas/compare-slider/context.ts @@ -0,0 +1,54 @@ +import type { Ref } from 'vue'; +import { useContextFactory } from '@robonen/vue'; + +export type CompareSliderOrientation = 'horizontal' | 'vertical'; +export type CompareSliderDirection = 'ltr' | 'rtl'; + +/** + * Formatter turning the raw reveal position (0–100) into a human-friendly + * string for the handle's `aria-valuetext`. Return `undefined` to omit + * `aria-valuetext`. + */ +export type CompareSliderValueText = (position: number) => string | undefined; + +/** + * Context shared between `CompareSliderRoot` and its descendants + * (`CompareSliderBefore`, `CompareSliderAfter`, `CompareSliderHandle`, + * `CompareSliderDivider`). + * + * Scalar props are exposed as plain `Ref`, but `CompareSliderRoot` builds + * them with `toRef(() => prop)` — a `GetterRefImpl` that is reactive without + * allocating a `ReactiveEffect` / cache (unlike `computed`). For identity + * passthrough of scalar props this avoids redundant effects per instance while + * keeping template auto-unwrap and `.value` ergonomics. + */ +export interface CompareSliderContext { + /** Reveal position, 0–100 (percentage of the after-layer shown). */ + position: Ref; + orientation: Ref; + direction: Ref; + disabled: Ref; + inverted: Ref; + /** + * Combined flip flag driving BOTH the pointer-offset mapping and the + * after-layer clip side. For horizontal: `(dir === 'rtl') !== inverted`; for + * vertical: `inverted`. Keeping a single source of truth prevents the divider + * and the revealed region from desyncing. + */ + flip: Ref; + /** Single keyboard step (Arrow). */ + keyboardStep: Ref; + /** Large keyboard step (Shift+Arrow / Page keys). */ + keyboardLargeStep: Ref; + /** Optional formatter for the handle's `aria-valuetext`; `undefined` when unset. */ + valueText: Ref; + /** Move the reveal position by `delta` (clamped to 0–100). */ + step: (delta: number) => void; + /** Set the reveal position to an absolute value (clamped to 0–100). */ + setPosition: (next: number) => void; +} + +const ctx = useContextFactory('CompareSliderContext'); + +export const provideCompareSliderContext = ctx.provide; +export const useCompareSliderContext = ctx.inject; diff --git a/vue/primitives/src/canvas/compare-slider/demo.vue b/vue/primitives/src/canvas/compare-slider/demo.vue new file mode 100644 index 0000000..00927e3 --- /dev/null +++ b/vue/primitives/src/canvas/compare-slider/demo.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/vue/primitives/src/canvas/compare-slider/index.ts b/vue/primitives/src/canvas/compare-slider/index.ts new file mode 100644 index 0000000..3e822d5 --- /dev/null +++ b/vue/primitives/src/canvas/compare-slider/index.ts @@ -0,0 +1,15 @@ +export { default as CompareSliderRoot } from './CompareSliderRoot.vue'; +export { default as CompareSliderAfter } from './CompareSliderAfter.vue'; +export { default as CompareSliderBefore } from './CompareSliderBefore.vue'; +export { default as CompareSliderDivider } from './CompareSliderDivider.vue'; +export { default as CompareSliderHandle } from './CompareSliderHandle.vue'; +export type { + CompareSliderDirection, + CompareSliderOrientation, + CompareSliderValueText, +} from './context'; +export type { CompareSliderAfterProps } from './CompareSliderAfter.vue'; +export type { CompareSliderBeforeProps } from './CompareSliderBefore.vue'; +export type { CompareSliderDividerProps } from './CompareSliderDivider.vue'; +export type { CompareSliderHandleProps } from './CompareSliderHandle.vue'; +export type { CompareSliderRootProps } from './CompareSliderRoot.vue'; diff --git a/vue/primitives/src/canvas/crop/CropArea.vue b/vue/primitives/src/canvas/crop/CropArea.vue new file mode 100644 index 0000000..e2b9d35 --- /dev/null +++ b/vue/primitives/src/canvas/crop/CropArea.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/vue/primitives/src/canvas/crop/CropGrid.vue b/vue/primitives/src/canvas/crop/CropGrid.vue new file mode 100644 index 0000000..41a4eda --- /dev/null +++ b/vue/primitives/src/canvas/crop/CropGrid.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/vue/primitives/src/canvas/crop/CropHandle.vue b/vue/primitives/src/canvas/crop/CropHandle.vue new file mode 100644 index 0000000..cfb346f --- /dev/null +++ b/vue/primitives/src/canvas/crop/CropHandle.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/vue/primitives/src/canvas/crop/CropOverlay.vue b/vue/primitives/src/canvas/crop/CropOverlay.vue new file mode 100644 index 0000000..a48dd12 --- /dev/null +++ b/vue/primitives/src/canvas/crop/CropOverlay.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/vue/primitives/src/canvas/crop/CropRoot.vue b/vue/primitives/src/canvas/crop/CropRoot.vue new file mode 100644 index 0000000..1a50834 --- /dev/null +++ b/vue/primitives/src/canvas/crop/CropRoot.vue @@ -0,0 +1,392 @@ + + + + + diff --git a/vue/primitives/src/canvas/crop/__test__/Crop.test.ts b/vue/primitives/src/canvas/crop/__test__/Crop.test.ts new file mode 100644 index 0000000..982ee7a --- /dev/null +++ b/vue/primitives/src/canvas/crop/__test__/Crop.test.ts @@ -0,0 +1,253 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import type { CropRect } from '../utils'; +import { CROP_HANDLE_POSITIONS } from '../utils'; +import { CropArea, CropGrid, CropHandle, CropOverlay, CropRoot } from '../index'; + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +/** Mount a full crop with all parts. The Root is sized so its rect is well-defined. */ +function mountCrop(opts: Record = {}, initial: CropRect | null = { x: 0.2, y: 0.2, width: 0.4, height: 0.4 }) { + const model = ref(initial); + const committed = ref(undefined); + const Harness = defineComponent({ + setup() { + const props: Record = { + modelValue: model.value, + 'onUpdate:modelValue': (v: CropRect | null) => { model.value = v; }, + onCropCommit: (v: CropRect | null) => { committed.value = v; }, + style: { position: 'relative', width: '400px', height: '300px' }, + ...opts, + }; + return () => h(CropRoot, props, { + default: () => [ + h(CropOverlay), + h(CropArea, null, { + default: () => [ + h(CropGrid), + ...CROP_HANDLE_POSITIONS.map(position => h(CropHandle, { position, key: position })), + ], + }), + ], + }); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + return { wrapper: w, model, committed }; +} + +function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false })); +} + +function pointer(type: string, el: Element, x: number, y: number, id = 1): void { + el.dispatchEvent(new PointerEvent(type, { pointerId: id, clientX: x, clientY: y, button: 0, bubbles: true, cancelable: true })); +} + +describe('CropArea', () => { + it('has role="group", is focusable, and carries an aria-label', async () => { + mountCrop(); + await nextTick(); + const area = document.querySelector('[role="group"]')!; + expect(area).toBeTruthy(); + expect(area.tabIndex).toBe(0); + expect(area.getAttribute('aria-label')).toBe('Crop region'); + }); + + it('renders nothing while the selection is empty', async () => { + mountCrop({}, null); + await nextTick(); + expect(document.querySelector('[role="group"]')).toBeNull(); + }); +}); + +describe('CropHandle', () => { + it('renders 8 handle buttons each with an aria-label', async () => { + mountCrop(); + await nextTick(); + const handles = Array.from(document.querySelectorAll('[data-position]')); + expect(handles).toHaveLength(8); + for (const h of handles) { + expect(h.tagName.toLowerCase()).toBe('button'); + expect(h.getAttribute('type')).toBe('button'); + expect(h.getAttribute('aria-label')).toMatch(/^Resize /); + } + }); +}); + +describe('keyboard — CropArea moves the rect', () => { + it('ArrowRight/ArrowDown nudge the whole rect by keyboardStep', async () => { + const { model } = mountCrop({ keyboardStep: 0.05 }); + await nextTick(); + const area = document.querySelector('[role="group"]')!; + keydown(area, 'ArrowRight'); + await nextTick(); + expect(model.value!.x).toBeCloseTo(0.25, 6); + keydown(area, 'ArrowDown'); + await nextTick(); + expect(model.value!.y).toBeCloseTo(0.25, 6); + // Size is unchanged by a move. + expect(model.value!.width).toBeCloseTo(0.4, 6); + }); + + it('clamps to [0,1] at the media edge', async () => { + const { model } = mountCrop({ keyboardStep: 0.5 }); + await nextTick(); + const area = document.querySelector('[role="group"]')!; + keydown(area, 'ArrowRight'); + keydown(area, 'ArrowRight'); + keydown(area, 'ArrowRight'); + await nextTick(); + expect(model.value!.x + model.value!.width).toBeLessThanOrEqual(1 + 1e-9); + }); + + it('Shift+Arrow uses the large step', async () => { + const { model } = mountCrop({ keyboardStep: 0.01, keyboardLargeStep: 0.1 }); + await nextTick(); + const area = document.querySelector('[role="group"]')!; + keydown(area, 'ArrowRight', { shiftKey: true }); + await nextTick(); + expect(model.value!.x).toBeCloseTo(0.3, 6); + }); +}); + +describe('keyboard — CropHandle resizes with the opposite edge fixed', () => { + it('ArrowRight on the right handle widens, left edge stays put', async () => { + const { model } = mountCrop({ keyboardStep: 0.05 }); + await nextTick(); + const right = document.querySelector('[data-position="right"]')!; + const before = { ...model.value! }; + keydown(right, 'ArrowRight'); + await nextTick(); + expect(model.value!.x).toBeCloseTo(before.x, 6); // left fixed + expect(model.value!.width).toBeCloseTo(before.width + 0.05, 6); + }); + + it('ArrowLeft on the left handle keeps the right edge fixed', async () => { + const { model } = mountCrop({ keyboardStep: 0.05 }); + await nextTick(); + const left = document.querySelector('[data-position="left"]')!; + const rightEdge = model.value!.x + model.value!.width; + keydown(left, 'ArrowLeft'); + await nextTick(); + expect(model.value!.x + model.value!.width).toBeCloseTo(rightEdge, 6); + expect(model.value!.x).toBeLessThan(0.2); + }); +}); + +describe('aspectRatio keeps the ratio on keyboard resize', () => { + it('a corner nudge preserves width/height', async () => { + const { model } = mountCrop({ keyboardStep: 0.05, aspectRatio: 1, units: 'pixels', mediaWidth: 400, mediaHeight: 400 }, { x: 100, y: 100, width: 100, height: 100 }); + await nextTick(); + const corner = document.querySelector('[data-position="bottom-right"]')!; + keydown(corner, 'ArrowRight'); + await nextTick(); + expect(model.value!.width / model.value!.height).toBeCloseTo(1, 6); + }); +}); + +describe('constrain clamps the rect within bounds', () => { + it('a handle drag past the edge keeps the rect inside the media', async () => { + const { model } = mountCrop({ keyboardStep: 0.5 }); + await nextTick(); + const corner = document.querySelector('[data-position="bottom-right"]')!; + keydown(corner, 'ArrowRight'); + keydown(corner, 'ArrowDown'); + keydown(corner, 'ArrowRight'); + keydown(corner, 'ArrowDown'); + await nextTick(); + expect(model.value!.x + model.value!.width).toBeLessThanOrEqual(1 + 1e-9); + expect(model.value!.y + model.value!.height).toBeLessThanOrEqual(1 + 1e-9); + }); +}); + +describe('createOnEmpty draws from null', () => { + it('a pointer drag on empty media creates a rect', async () => { + const { model } = mountCrop({ keyboardStep: 0.05 }, null); + await nextTick(); + const root = document.querySelector('[data-empty]')!; + const rect = root.getBoundingClientRect(); + expect(model.value).toBeNull(); + // Press near the top-left quarter, drag toward the centre. + pointer('pointerdown', root, rect.left + rect.width * 0.25, rect.top + rect.height * 0.25); + pointer('pointermove', document.body, rect.left + rect.width * 0.75, rect.top + rect.height * 0.75); + pointer('pointerup', document.body, rect.left + rect.width * 0.75, rect.top + rect.height * 0.75); + await nextTick(); + expect(model.value).not.toBeNull(); + expect(model.value!.width).toBeGreaterThan(0.3); + expect(model.value!.height).toBeGreaterThan(0.3); + }); + + it('does not create when createOnEmpty is false', async () => { + const { model } = mountCrop({ createOnEmpty: false }, null); + await nextTick(); + const root = document.querySelector('[data-empty]')!; + const rect = root.getBoundingClientRect(); + pointer('pointerdown', root, rect.left + 10, rect.top + 10); + pointer('pointermove', document.body, rect.left + 100, rect.top + 100); + pointer('pointerup', document.body, rect.left + 100, rect.top + 100); + await nextTick(); + expect(model.value).toBeNull(); + }); +}); + +describe('disabled blocks interaction', () => { + it('arrow keys do nothing when disabled', async () => { + const { model } = mountCrop({ disabled: true, keyboardStep: 0.05 }); + await nextTick(); + const area = document.querySelector('[role="group"]')!; + expect(area.getAttribute('aria-disabled')).toBe('true'); + const before = { ...model.value! }; + keydown(area, 'ArrowRight'); + await nextTick(); + expect(model.value).toEqual(before); + }); + + it('handle buttons are disabled', async () => { + mountCrop({ disabled: true }); + await nextTick(); + const handle = document.querySelector('[data-position="right"]')!; + expect(handle.disabled).toBe(true); + }); +}); + +describe('CropGrid', () => { + it('renders the rule-of-thirds lines (2 vertical + 2 horizontal) when grid is on', async () => { + mountCrop(); + await nextTick(); + const grid = document.querySelector('[data-crop-grid]')!; + expect(grid).toBeTruthy(); + expect(grid.getAttribute('aria-hidden')).toBe('true'); + expect(grid.querySelectorAll('[data-orientation="vertical"]')).toHaveLength(2); + expect(grid.querySelectorAll('[data-orientation="horizontal"]')).toHaveLength(2); + }); + + it('is absent when grid is disabled', async () => { + mountCrop({ grid: false }); + await nextTick(); + expect(document.querySelector('[data-crop-grid]')).toBeNull(); + }); +}); + +describe('CropOverlay', () => { + it('renders 4 scrim rects (aria-hidden) when a selection exists', async () => { + mountCrop(); + await nextTick(); + const overlay = document.querySelector('[data-crop-overlay]')!; + expect(overlay).toBeTruthy(); + expect(overlay.getAttribute('aria-hidden')).toBe('true'); + expect(overlay.querySelectorAll('[data-side]')).toHaveLength(4); + }); +}); diff --git a/vue/primitives/src/canvas/crop/__test__/utils.test.ts b/vue/primitives/src/canvas/crop/__test__/utils.test.ts new file mode 100644 index 0000000..210a961 --- /dev/null +++ b/vue/primitives/src/canvas/crop/__test__/utils.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import type { CropBounds, CropRect } from '../utils'; +import { + createRect, + fitRectToRatio, + minBox, + moveRect, + normalizeRect, + resizeRect, + resolveAspectRatio, +} from '../utils'; + +const UNIT: CropBounds = { width: 1, height: 1 }; + +function opts(over: Partial<{ aspectRatio: number | null; minWidth: number; minHeight: number; bounds: CropBounds; constrain: boolean }> = {}) { + return { + aspectRatio: null, + minWidth: 0, + minHeight: 0, + bounds: UNIT, + constrain: true, + ...over, + }; +} + +describe('minBox — min + ratio conflict', () => { + it('returns the raw mins with no ratio', () => { + expect(minBox(0.2, 0.3, null)).toEqual({ width: 0.2, height: 0.3 }); + }); + + it('grows the binding dimension so BOTH mins are satisfied (ratio > mins imply width grows)', () => { + // ratio 2 (w = 2h). minWidth 0.2, minHeight 0.3 → height-min forces w >= 0.6. + const box = minBox(0.2, 0.3, 2); + expect(box.width).toBeCloseTo(0.6, 6); + expect(box.height).toBeCloseTo(0.3, 6); + // Ratio is exactly held. + expect(box.width / box.height).toBeCloseTo(2, 6); + }); + + it('picks width-min as binding when it forces the larger box', () => { + // ratio 2. minWidth 0.8 dominates (0.8 > 2*0.3=0.6). + const box = minBox(0.8, 0.3, 2); + expect(box.width).toBeCloseTo(0.8, 6); + expect(box.height).toBeCloseTo(0.4, 6); + expect(box.width / box.height).toBeCloseTo(2, 6); + }); +}); + +describe('moveRect', () => { + const rect: CropRect = { x: 0.2, y: 0.2, width: 0.4, height: 0.4 }; + + it('translates without changing size', () => { + const out = moveRect(rect, 0.1, -0.05, UNIT, false); + expect(out.x).toBeCloseTo(0.3, 6); + expect(out.y).toBeCloseTo(0.15, 6); + expect(out.width).toBeCloseTo(0.4, 6); + expect(out.height).toBeCloseTo(0.4, 6); + }); + + it('clamps within bounds when constrained', () => { + const out = moveRect(rect, 1, 1, UNIT, true); + // Can't go past 1 - width. + expect(out.x).toBeCloseTo(0.6, 6); + expect(out.y).toBeCloseTo(0.6, 6); + expect(out.width).toBeCloseTo(0.4, 6); + }); +}); + +describe('resizeRect — free resize keeps the opposite edge fixed', () => { + const rect: CropRect = { x: 0.2, y: 0.2, width: 0.4, height: 0.4 }; // right=0.6, bottom=0.6 + + it('drags the left edge, right edge stays put', () => { + const out = resizeRect(rect, 'left', 0.1, 0.2, opts()); + expect(out.x).toBeCloseTo(0.1, 6); + expect(out.x + out.width).toBeCloseTo(0.6, 6); // right fixed + }); + + it('drags the bottom-right corner, top-left stays put', () => { + const out = resizeRect(rect, 'bottom-right', 0.9, 0.8, opts()); + expect(out.x).toBeCloseTo(0.2, 6); // left fixed + expect(out.y).toBeCloseTo(0.2, 6); // top fixed + expect(out.x + out.width).toBeCloseTo(0.9, 6); + expect(out.y + out.height).toBeCloseTo(0.8, 6); + }); +}); + +describe('resizeRect — aspect ratio', () => { + const rect: CropRect = { x: 0.2, y: 0.2, width: 0.2, height: 0.2 }; + + it('keeps the ratio on a corner drag', () => { + const out = resizeRect(rect, 'bottom-right', 0.8, 0.5, opts({ aspectRatio: 1 })); + expect(out.width / out.height).toBeCloseTo(1, 6); + }); + + it('adjusts the paired dimension on an edge drag (right → height follows)', () => { + const out = resizeRect(rect, 'right', 0.6, 0.3, opts({ aspectRatio: 2 })); + expect(out.width / out.height).toBeCloseTo(2, 6); + }); +}); + +describe('resizeRect — constrain clamps into bounds while holding ratio', () => { + it('a corner drag past the media edge clamps and preserves the ratio', () => { + const rect: CropRect = { x: 0.5, y: 0.5, width: 0.2, height: 0.2 }; + const out = resizeRect(rect, 'bottom-right', 2, 2, opts({ aspectRatio: 1, constrain: true })); + expect(out.x + out.width).toBeLessThanOrEqual(1 + 1e-9); + expect(out.y + out.height).toBeLessThanOrEqual(1 + 1e-9); + expect(out.width / out.height).toBeCloseTo(1, 6); + }); + + it('free corner drag past the edge clamps the dimension', () => { + const rect: CropRect = { x: 0.2, y: 0.2, width: 0.2, height: 0.2 }; + const out = resizeRect(rect, 'bottom-right', 2, 2, opts({ constrain: true })); + expect(out.x + out.width).toBeLessThanOrEqual(1 + 1e-9); + expect(out.y + out.height).toBeLessThanOrEqual(1 + 1e-9); + }); + + it('never shrinks below the combined min box', () => { + const rect: CropRect = { x: 0.4, y: 0.4, width: 0.2, height: 0.2 }; + const out = resizeRect(rect, 'top-left', 0.59, 0.59, opts({ minWidth: 0.1, minHeight: 0.1 })); + expect(out.width).toBeGreaterThanOrEqual(0.1 - 1e-9); + expect(out.height).toBeGreaterThanOrEqual(0.1 - 1e-9); + }); +}); + +describe('fitRectToRatio', () => { + it('re-fits a free rect to the ratio about its centre and clamps into bounds', () => { + const rect: CropRect = { x: 0.1, y: 0.1, width: 0.8, height: 0.4 }; + const out = fitRectToRatio(rect, 1, UNIT, 0, 0); + expect(out.width / out.height).toBeCloseTo(1, 6); + expect(out.x).toBeGreaterThanOrEqual(-1e-9); + expect(out.y).toBeGreaterThanOrEqual(-1e-9); + expect(out.x + out.width).toBeLessThanOrEqual(1 + 1e-9); + expect(out.y + out.height).toBeLessThanOrEqual(1 + 1e-9); + }); +}); + +describe('createRect — draw from empty', () => { + it('draws a rect from the pointerdown origin to the current point', () => { + const out = createRect({ x: 0.2, y: 0.3 }, { x: 0.6, y: 0.7 }, opts()); + expect(out.x).toBeCloseTo(0.2, 6); + expect(out.y).toBeCloseTo(0.3, 6); + expect(out.width).toBeCloseTo(0.4, 6); + expect(out.height).toBeCloseTo(0.4, 6); + }); + + it('normalises a backwards drag (point above/left of origin)', () => { + const out = createRect({ x: 0.6, y: 0.7 }, { x: 0.2, y: 0.3 }, opts()); + expect(out.x).toBeCloseTo(0.2, 6); + expect(out.y).toBeCloseTo(0.3, 6); + expect(out.width).toBeCloseTo(0.4, 6); + expect(out.height).toBeCloseTo(0.4, 6); + }); +}); + +describe('normalizeRect', () => { + it('clamps an out-of-bounds rect inside the media', () => { + const out = normalizeRect({ x: 0.8, y: 0.8, width: 0.5, height: 0.5 }, opts({ constrain: true })); + expect(out.x + out.width).toBeLessThanOrEqual(1 + 1e-9); + expect(out.y + out.height).toBeLessThanOrEqual(1 + 1e-9); + }); + + it('enforces the min size', () => { + const out = normalizeRect({ x: 0.4, y: 0.4, width: 0.01, height: 0.01 }, opts({ minWidth: 0.1, minHeight: 0.1 })); + expect(out.width).toBeGreaterThanOrEqual(0.1 - 1e-9); + expect(out.height).toBeGreaterThanOrEqual(0.1 - 1e-9); + }); +}); + +describe('resolveAspectRatio', () => { + it('returns the ratio unchanged in pixel units', () => { + expect(resolveAspectRatio(2, 'pixels', 800, 600)).toBe(2); + }); + + it('corrects for the media pixel-aspect in normalized units', () => { + // visual 1:1 on a 2:1 media → normalized w/h = 1 * (h/w) = 0.5. + expect(resolveAspectRatio(1, 'normalized', 2, 1)).toBeCloseTo(0.5, 6); + }); + + it('passes through null', () => { + expect(resolveAspectRatio(null, 'normalized', 100, 100)).toBeNull(); + }); +}); diff --git a/vue/primitives/src/canvas/crop/context.ts b/vue/primitives/src/canvas/crop/context.ts new file mode 100644 index 0000000..77b8c4b --- /dev/null +++ b/vue/primitives/src/canvas/crop/context.ts @@ -0,0 +1,73 @@ +import type { Ref } from 'vue'; +import { useContextFactory } from '@robonen/vue'; +import type { CropBounds, CropHandlePosition, CropRect } from './utils'; + +export type CropUnits = 'normalized' | 'pixels'; +export type CropDirection = 'ltr' | 'rtl'; + +/** + * Context shared between `CropRoot` and its parts (`CropArea`, `CropHandle`, + * `CropGrid`, `CropOverlay`). The Root owns the rect and the gesture engines; + * every part reads the live rect and asks the Root to begin a gesture or nudge + * the rect via the keyboard. + * + * Scalar props are exposed as plain `Ref` built with `toRef(() => prop)` (a + * reactive getter ref without a `ReactiveEffect`) for identity passthrough, + * mirroring the slider convention. + */ +export interface CropContext { + /** The live crop rect in the chosen `units`, or `null` when there is no selection. */ + rect: Ref; + /** Whether `rect` is `null` (no selection yet). */ + isEmpty: Ref; + /** The active coordinate space. */ + units: Ref; + /** Reading direction (affects arrow-key x sign). */ + direction: Ref; + /** Media bounds in the rect's units (`{1,1}` normalized, media px otherwise). */ + mediaSize: Ref; + /** Media size in pixels — always real px regardless of `units` (for layout). */ + mediaPixels: Ref; + /** Whether the rect is kept within the media bounds. */ + constrain: Ref; + /** Whether the rule-of-thirds grid is enabled. */ + grid: Ref; + /** Master interactivity switch. */ + disabled: Ref; + /** Resolved `width / height` lock in the rect's units, or `null` when free. */ + aspectRatio: Ref; + /** Minimum width in the rect's units. */ + minWidth: Ref; + /** Minimum height in the rect's units. */ + minHeight: Ref; + /** Keyboard nudge step on the x axis, in the rect's units. */ + keyboardStepX: Ref; + /** Keyboard nudge step on the y axis, in the rect's units. */ + keyboardStepY: Ref; + /** Large keyboard nudge step (Shift+Arrow) on the x axis, in the rect's units. */ + keyboardLargeStepX: Ref; + /** Large keyboard nudge step (Shift+Arrow) on the y axis, in the rect's units. */ + keyboardLargeStepY: Ref; + /** Whether a pointer gesture is currently in progress. */ + isCropping: Ref; + /** Replace the rect wholesale (the value is normalised by the Root). */ + setRect: (rect: CropRect | null) => void; + /** Move the whole rect by a delta in the rect's units, clamped + committed. */ + nudgeMove: (dx: number, dy: number) => void; + /** Resize an edge/corner by a delta in the rect's units, clamped + committed. */ + nudgeResize: (handle: CropHandlePosition, dx: number, dy: number) => void; + /** Begin a pointer move gesture on the crop surface. */ + beginMove: (event: PointerEvent, surface: HTMLElement | null) => void; + /** Begin a pointer resize gesture for `handle`. */ + beginResize: (handle: CropHandlePosition, event: PointerEvent, el: HTMLElement | null) => void; + /** Begin a draw-from-empty create gesture on the media surface. */ + beginCreate: (event: PointerEvent, surface: HTMLElement | null) => void; +} + +const context = useContextFactory('CropContext'); + +/** Provide the {@link CropContext} to descendants of `CropRoot`. */ +export const provideCropContext = context.provide; + +/** Inject the {@link CropContext}. Throws when used outside a `CropRoot`. */ +export const useCropContext = context.inject; diff --git a/vue/primitives/src/canvas/crop/demo.vue b/vue/primitives/src/canvas/crop/demo.vue new file mode 100644 index 0000000..c7be918 --- /dev/null +++ b/vue/primitives/src/canvas/crop/demo.vue @@ -0,0 +1,132 @@ + + + diff --git a/vue/primitives/src/canvas/crop/index.ts b/vue/primitives/src/canvas/crop/index.ts new file mode 100644 index 0000000..0b27c90 --- /dev/null +++ b/vue/primitives/src/canvas/crop/index.ts @@ -0,0 +1,26 @@ +export { default as CropRoot } from './CropRoot.vue'; +export { default as CropArea } from './CropArea.vue'; +export { default as CropGrid } from './CropGrid.vue'; +export { default as CropHandle } from './CropHandle.vue'; +export { default as CropOverlay } from './CropOverlay.vue'; + +export { provideCropContext, useCropContext } from './context'; +export type { CropContext, CropDirection, CropUnits } from './context'; + +export type { CropAreaProps } from './CropArea.vue'; +export type { CropGridProps } from './CropGrid.vue'; +export type { CropHandleProps } from './CropHandle.vue'; +export type { CropOverlayProps } from './CropOverlay.vue'; +export type { CropRootEmits, CropRootProps } from './CropRoot.vue'; + +export type { CropBounds, CropHandlePosition, CropRect } from './utils'; +export { + CROP_HANDLE_POSITIONS, + createRect, + fitRectToRatio, + minBox, + moveRect, + normalizeRect, + resizeRect, + resolveAspectRatio, +} from './utils'; diff --git a/vue/primitives/src/canvas/crop/utils.ts b/vue/primitives/src/canvas/crop/utils.ts new file mode 100644 index 0000000..da39336 --- /dev/null +++ b/vue/primitives/src/canvas/crop/utils.ts @@ -0,0 +1,441 @@ +import type { Point } from '../../internal/utils/geometry'; + +/** + * An axis-aligned crop rectangle. Lives in whatever coordinate space the + * consumer chose via the `units` prop on `CropRoot`: + * - `'normalized'` (default): `x`/`y`/`width`/`height` are fractions `0..1` of + * the media, so the rect is resolution-independent. + * - `'pixels'`: the same fields are media-space pixels. + * + * The pure helpers in this module operate in a single, consistent space — the + * caller picks one and stays in it. They never mix units. + */ +export interface CropRect { + /** Left edge. */ + x: number; + /** Top edge. */ + y: number; + /** Rectangle width (always `>= 0`). */ + width: number; + /** Rectangle height (always `>= 0`). */ + height: number; +} + +/** + * The eight resize handles of a crop rectangle: four corners and four edge + * midpoints. Corner handles move two edges, edge handles move one. + */ +export type CropHandlePosition + = | 'top-left' + | 'top' + | 'top-right' + | 'right' + | 'bottom-right' + | 'bottom' + | 'bottom-left' + | 'left'; + +/** The media bounds the crop is clamped against, in the rect's own units. */ +export interface CropBounds { + /** Media width (`1` in normalized units, media pixels otherwise). */ + width: number; + /** Media height (`1` in normalized units, media pixels otherwise). */ + height: number; +} + +/** All eight handle positions in a stable, clockwise-from-top-left order. */ +export const CROP_HANDLE_POSITIONS: readonly CropHandlePosition[] = [ + 'top-left', + 'top', + 'top-right', + 'right', + 'bottom-right', + 'bottom', + 'bottom-left', + 'left', +] as const; + +/** Whether a handle moves the top edge. */ +function movesTop(h: CropHandlePosition): boolean { + return h === 'top-left' || h === 'top' || h === 'top-right'; +} +/** Whether a handle moves the bottom edge. */ +function movesBottom(h: CropHandlePosition): boolean { + return h === 'bottom-left' || h === 'bottom' || h === 'bottom-right'; +} +/** Whether a handle moves the left edge. */ +function movesLeft(h: CropHandlePosition): boolean { + return h === 'top-left' || h === 'left' || h === 'bottom-left'; +} +/** Whether a handle moves the right edge. */ +function movesRight(h: CropHandlePosition): boolean { + return h === 'top-right' || h === 'right' || h === 'bottom-right'; +} + +/** Clamp `n` into `[lo, hi]`. (Local copy — keeps `utils.ts` dependency-free.) */ +function clamp(n: number, lo: number, hi: number): number { + return n < lo ? lo : n > hi ? hi : n; +} + +/** + * The smallest box (in the rect's units) that satisfies BOTH `minWidth`/ + * `minHeight` AND, when `aspectRatio` is set, the ratio. The binding constraint + * is whichever forces the larger box — so a tall ratio grows the width to keep + * the height-min legal, and vice versa. The crop must never shrink past this. + * + * @param minWidth Minimum width in the rect's units (`>= 0`). + * @param minHeight Minimum height in the rect's units (`>= 0`). + * @param aspectRatio Locked `width / height`, or `null` for free resize. + */ +export function minBox( + minWidth: number, + minHeight: number, + aspectRatio: number | null, +): { width: number; height: number } { + const mw = Math.max(0, minWidth); + const mh = Math.max(0, minHeight); + if (aspectRatio === null || !(aspectRatio > 0) || !Number.isFinite(aspectRatio)) + return { width: mw, height: mh }; + // Grow whichever dimension is too small to honour the ratio about the other. + // width = ratio * height. Pick the larger of (mw, ratio*mh) for the width, then + // derive the matching height so both minimums are met. + const widthFromHeight = aspectRatio * mh; + const width = Math.max(mw, widthFromHeight); + const height = width / aspectRatio; + return { width, height }; +} + +/** + * Re-fit an existing rect to `aspectRatio` about its centre (used when the + * ratio is set/changed on a free rect), then clamp the result into `bounds`. + * The area is preserved as closely as possible: the new box is the + * ratio-correct rect whose area matches the old one, centred on the old centre. + * + * @param rect The current (free) rectangle. + * @param aspectRatio Target `width / height` (`> 0`). + * @param bounds Media bounds to clamp into. + * @param minWidth Minimum width in units. + * @param minHeight Minimum height in units. + */ +export function fitRectToRatio( + rect: CropRect, + aspectRatio: number, + bounds: CropBounds, + minWidth: number, + minHeight: number, +): CropRect { + if (!(aspectRatio > 0) || !Number.isFinite(aspectRatio)) return rect; + const cx = rect.x + rect.width / 2; + const cy = rect.y + rect.height / 2; + const area = Math.max(rect.width * rect.height, 1e-9); + // w * h = area, w / h = ratio → h = sqrt(area / ratio), w = ratio * h. + let height = Math.sqrt(area / aspectRatio); + let width = aspectRatio * height; + // Never below the combined min box. + const min = minBox(minWidth, minHeight, aspectRatio); + if (width < min.width) { + width = min.width; + height = min.height; + } + // Never exceed the media in either dimension while holding the ratio. + const maxW = bounds.width; + const maxH = bounds.height; + if (width > maxW) { + width = maxW; + height = width / aspectRatio; + } + if (height > maxH) { + height = maxH; + width = height * aspectRatio; + } + const x = clamp(cx - width / 2, 0, Math.max(0, bounds.width - width)); + const y = clamp(cy - height / 2, 0, Math.max(0, bounds.height - height)); + return { x, y, width, height }; +} + +/** + * Translate (move) `rect` by `dx`/`dy`, optionally clamped so it stays fully + * inside `bounds`. Size is never changed — only the origin. + * + * @param rect The rectangle to move. + * @param dx Delta on x in the rect's units. + * @param dy Delta on y in the rect's units. + * @param bounds Media bounds. + * @param constrain When `true`, the rect is kept within `bounds`. + */ +export function moveRect( + rect: CropRect, + dx: number, + dy: number, + bounds: CropBounds, + constrain: boolean, +): CropRect { + let x = rect.x + dx; + let y = rect.y + dy; + if (constrain) { + x = clamp(x, 0, Math.max(0, bounds.width - rect.width)); + y = clamp(y, 0, Math.max(0, bounds.height - rect.height)); + } + return { x, y, width: rect.width, height: rect.height }; +} + +/** + * Resize `rect` by dragging `handle` to a new pointer position, with the + * opposite edge(s) held fixed. Honours `aspectRatio` (paired-dimension + * adjustment), the combined `minBox`, and — when `constrain` is set — the media + * bounds (clamping the limiting dimension while preserving the ratio). + * + * The whole computation runs in the rect's units. `px`/`py` are the desired new + * position of the dragged handle (the anchor edge stays put). This is the core + * of every corner/edge drag and of keyboard edge-resize (with a synthetic + * target one step away). + * + * @param rect The rectangle being resized. + * @param handle Which handle is being dragged. + * @param px Desired new x of the dragged handle, in units. + * @param py Desired new y of the dragged handle, in units. + * @param options Aspect, mins, bounds and the constrain flag. + */ +export function resizeRect( + rect: CropRect, + handle: CropHandlePosition, + px: number, + py: number, + options: { + aspectRatio: number | null; + minWidth: number; + minHeight: number; + bounds: CropBounds; + constrain: boolean; + }, +): CropRect { + const { aspectRatio, minWidth, minHeight, bounds, constrain } = options; + const ratio = aspectRatio !== null && aspectRatio > 0 && Number.isFinite(aspectRatio) + ? aspectRatio + : null; + const min = minBox(minWidth, minHeight, ratio); + + // Fixed (anchor) edges — the opposite edge of whatever the handle moves. + const left = rect.x; + const right = rect.x + rect.width; + const top = rect.y; + const bottom = rect.y + rect.height; + + const ml = movesLeft(handle); + const mr = movesRight(handle); + const mt = movesTop(handle); + const mb = movesBottom(handle); + + // 1) Free resize: move the active edges to the pointer, clamp each so the box + // keeps a non-negative size at least `min` and (when constrain) stays in + // bounds. Anchor edges are the opposite, untouched edges. + let newLeft = left; + let newRight = right; + let newTop = top; + let newBottom = bottom; + + if (ml) newLeft = constrain ? clamp(px, 0, right - min.width) : Math.min(px, right - min.width); + if (mr) newRight = constrain ? clamp(px, left + min.width, bounds.width) : Math.max(px, left + min.width); + if (mt) newTop = constrain ? clamp(py, 0, bottom - min.height) : Math.min(py, bottom - min.height); + if (mb) newBottom = constrain ? clamp(py, top + min.height, bounds.height) : Math.max(py, top + min.height); + + let width = newRight - newLeft; + let height = newBottom - newTop; + + if (ratio !== null) { + // 2) Aspect-locked: the dragged edge(s) drive the PRIMARY dimension; the + // paired dimension is derived and grown about the appropriate anchor. + const isCorner = (ml || mr) && (mt || mb); + if (isCorner) { + // Corner: let width lead, derive height (pick the larger so the pointer + // is always "inside" the box — feels natural on a corner drag). + const hFromW = width / ratio; + if (hFromW >= height) { + height = hFromW; + } + else { + width = height * ratio; + } + } + else if (ml || mr) { + // Horizontal edge handle: width leads, height follows about the centre. + height = width / ratio; + } + else { + // Vertical edge handle: height leads, width follows about the centre. + width = height * ratio; + } + + // Never below the combined min. + if (width < min.width) { + width = min.width; + height = min.height; + } + + // 3) Constrain + ratio: if the derived box would exit the media, clamp the + // LIMITING dimension and re-derive the other to preserve the ratio. + if (constrain) { + // Determine the anchor corner the box grows from. + const anchorX = ml ? right : left; // fixed x edge + const anchorY = mt ? bottom : top; // fixed y edge + // Max width/height available from the anchor toward the drag direction. + const availW = ml ? anchorX : bounds.width - anchorX; + const availH = mt ? anchorY : bounds.height - anchorY; + if (width > availW) { + width = availW; + height = width / ratio; + } + if (height > availH) { + height = availH; + width = height * ratio; + } + // Re-apply the min after clamping (clamp may have pushed below min when + // the media is smaller than the min box — min wins, bounds are honoured + // by the final position clamp). + if (width < min.width) { + width = min.width; + height = min.height; + } + } + + // Recompute the moving edges from the fixed anchors + the (possibly + // ratio-adjusted) dimensions. For a centre-anchored edge handle the paired + // dimension grows symmetrically about the box centre. + if (ml) newLeft = right - width; + else if (mr) newRight = left + width; + else { + // vertical-only handle: centre the width about the existing centre x. + const cx = (left + right) / 2; + newLeft = cx - width / 2; + newRight = cx + width / 2; + } + if (mt) newTop = bottom - height; + else if (mb) newBottom = top + height; + else { + // horizontal-only handle: centre the height about the existing centre y. + const cy = (top + bottom) / 2; + newTop = cy - height / 2; + newBottom = cy + height / 2; + } + } + + let outX = newLeft; + let outY = newTop; + let outW = newRight - newLeft; + let outH = newBottom - newTop; + + // Final safety clamp: keep the box inside the media when constrained. Shift + // (not shrink) so the ratio/size survive; only shrink if the box is wider/ + // taller than the media itself. + if (constrain) { + if (outW > bounds.width) outW = bounds.width; + if (outH > bounds.height) outH = bounds.height; + outX = clamp(outX, 0, Math.max(0, bounds.width - outW)); + outY = clamp(outY, 0, Math.max(0, bounds.height - outH)); + } + + return { x: outX, y: outY, width: outW, height: outH }; +} + +/** + * Build a fresh rect from a draw-from-empty create gesture: the press `origin` + * and the current pointer `point` define opposite corners. Normalised so width + * and height are non-negative, then aspect-fitted (anchored at `origin`) and + * clamped into bounds. Used by the `createOnEmpty` path. + * + * @param origin The pointerdown corner, in units. + * @param point The current pointer corner, in units. + * @param options Aspect, mins, bounds and the constrain flag. + */ +export function createRect( + origin: Point, + point: Point, + options: { + aspectRatio: number | null; + minWidth: number; + minHeight: number; + bounds: CropBounds; + constrain: boolean; + }, +): CropRect { + // Which handle the pointer is dragging is decided by the sign of the drag, so + // we drive `resizeRect` from a zero-area rect anchored at `origin` and let it + // do all the ratio/bounds/min work. + const dirX = point.x >= origin.x ? 'right' : 'left'; + const dirY = point.y >= origin.y ? 'bottom' : 'top'; + const handle = `${dirY === 'bottom' ? 'bottom' : 'top'}-${dirX === 'right' ? 'right' : 'left'}` as CropHandlePosition; + const seed: CropRect = { x: origin.x, y: origin.y, width: 0, height: 0 }; + return resizeRect(seed, handle, point.x, point.y, options); +} + +/** + * Clamp/normalise an arbitrary rect into a legal crop: non-negative size, at + * least the combined `minBox`, optionally ratio-fitted, and (when `constrain`) + * within `bounds`. Used to sanitise an incoming `modelValue` and to settle a + * rect after a units / aspectRatio / bounds change. + * + * @param rect The candidate rectangle. + * @param options Aspect, mins, bounds and the constrain flag. + */ +export function normalizeRect( + rect: CropRect, + options: { + aspectRatio: number | null; + minWidth: number; + minHeight: number; + bounds: CropBounds; + constrain: boolean; + }, +): CropRect { + const { aspectRatio, minWidth, minHeight, bounds, constrain } = options; + const ratio = aspectRatio !== null && aspectRatio > 0 && Number.isFinite(aspectRatio) + ? aspectRatio + : null; + let width = Math.max(0, rect.width); + let height = Math.max(0, rect.height); + let x = rect.x; + let y = rect.y; + + if (ratio !== null) { + const fitted = fitRectToRatio({ x, y, width, height }, ratio, bounds, minWidth, minHeight); + return fitted; + } + + const min = minBox(minWidth, minHeight, ratio); + if (width < min.width) width = min.width; + if (height < min.height) height = min.height; + + if (constrain) { + if (width > bounds.width) width = bounds.width; + if (height > bounds.height) height = bounds.height; + x = clamp(x, 0, Math.max(0, bounds.width - width)); + y = clamp(y, 0, Math.max(0, bounds.height - height)); + } + return { x, y, width, height }; +} + +/** + * Convert between coordinate spaces for an aspect-ratio supplied in display + * (pixel) terms. The `aspectRatio` prop is unit-agnostic (`width / height` of + * the *crop box*), so in normalized space it must be divided by the media's own + * pixel aspect to stay visually correct. Returns the ratio expressed in the + * rect's units. + * + * @param aspectRatio The visual `width / height` the consumer asked for. + * @param units The active coordinate space. + * @param mediaWidth Media width in pixels (for the normalized correction). + * @param mediaHeight Media height in pixels. + */ +export function resolveAspectRatio( + aspectRatio: number | null, + units: 'normalized' | 'pixels', + mediaWidth: number, + mediaHeight: number, +): number | null { + if (aspectRatio === null || !(aspectRatio > 0) || !Number.isFinite(aspectRatio)) return null; + if (units === 'pixels') return aspectRatio; + // Normalized: a visual ratio of R means width_px/height_px = R, i.e. + // (wx*mediaW)/(hy*mediaH) = R → wx/hy = R * mediaH / mediaW. + if (mediaWidth <= 0 || mediaHeight <= 0) return aspectRatio; + return aspectRatio * (mediaHeight / mediaWidth); +} diff --git a/vue/primitives/src/canvas/curve-editor/CurveEditorCurve.vue b/vue/primitives/src/canvas/curve-editor/CurveEditorCurve.vue new file mode 100644 index 0000000..c5476f1 --- /dev/null +++ b/vue/primitives/src/canvas/curve-editor/CurveEditorCurve.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/vue/primitives/src/canvas/curve-editor/CurveEditorGrid.vue b/vue/primitives/src/canvas/curve-editor/CurveEditorGrid.vue new file mode 100644 index 0000000..748aff3 --- /dev/null +++ b/vue/primitives/src/canvas/curve-editor/CurveEditorGrid.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/vue/primitives/src/canvas/curve-editor/CurveEditorHandle.vue b/vue/primitives/src/canvas/curve-editor/CurveEditorHandle.vue new file mode 100644 index 0000000..0908879 --- /dev/null +++ b/vue/primitives/src/canvas/curve-editor/CurveEditorHandle.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/vue/primitives/src/canvas/curve-editor/CurveEditorPoint.vue b/vue/primitives/src/canvas/curve-editor/CurveEditorPoint.vue new file mode 100644 index 0000000..d6377da --- /dev/null +++ b/vue/primitives/src/canvas/curve-editor/CurveEditorPoint.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/vue/primitives/src/canvas/curve-editor/CurveEditorRoot.vue b/vue/primitives/src/canvas/curve-editor/CurveEditorRoot.vue new file mode 100644 index 0000000..1c7d985 --- /dev/null +++ b/vue/primitives/src/canvas/curve-editor/CurveEditorRoot.vue @@ -0,0 +1,428 @@ + + + + + diff --git a/vue/primitives/src/canvas/curve-editor/__test__/CurveEditor.test.ts b/vue/primitives/src/canvas/curve-editor/__test__/CurveEditor.test.ts new file mode 100644 index 0000000..fbcb164 --- /dev/null +++ b/vue/primitives/src/canvas/curve-editor/__test__/CurveEditor.test.ts @@ -0,0 +1,285 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import type { CurveEditorAnchor, CurveEditorInterpolation } from '../index'; +import { CurveEditorCurve, CurveEditorPoint, CurveEditorRoot } from '../index'; +import { buildEvaluator, clampAnchorX } from '../utils'; + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void { + el.dispatchEvent(new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + shiftKey: opts.shiftKey ?? false, + })); +} + +function anchorsFromIds(...pts: Array<[number, number]>): CurveEditorAnchor[] { + return pts.map(([x, y], i) => ({ id: `a${i}`, x, y })); +} + +function round(n: number, d = 3): number { + const f = 10 ** d; + return Math.round(n * f) / f; +} + +function mountEditor( + opts: Partial<{ + defaultValue: CurveEditorAnchor[]; + interpolation: CurveEditorInterpolation; + monotonicX: boolean; + fixedEndpoints: boolean; + step: number; + largeStep: number; + disabled: boolean; + }> = {}, +) { + const model = ref(undefined); + const Harness = defineComponent({ + setup: () => () => h( + CurveEditorRoot, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: CurveEditorAnchor[] | null | undefined) => { + model.value = v ?? undefined; + }, + ...opts, + }, + { + default: ({ anchors }: { anchors: CurveEditorAnchor[] }) => [ + h(CurveEditorCurve), + ...anchors.map(a => h(CurveEditorPoint, { key: a.id, anchor: a })), + ], + }, + ), + }); + const w = track(mount(Harness, { attachTo: document.body })); + return { wrapper: w, model }; +} + +// ── unit tests: evaluator / sample logic ──────────────────────────────────── + +describe('CurveEditor evaluator (unit)', () => { + it('linear interpolates and passes through anchors', () => { + const f = buildEvaluator(anchorsFromIds([0, 0], [1, 1]), 'linear'); + expect(f(0)).toBe(0); + expect(f(1)).toBe(1); + expect(round(f(0.5))).toBe(0.5); + }); + + it('monotone passes through anchors and is non-decreasing for a rising set', () => { + const f = buildEvaluator(anchorsFromIds([0, 0], [0.5, 0.2], [1, 1]), 'monotone'); + expect(round(f(0))).toBe(0); + expect(round(f(0.5))).toBe(0.2); + expect(round(f(1))).toBe(1); + // Monotone (no overshoot): sampling forward never decreases. + let prev = -Infinity; + for (let i = 0; i <= 20; i++) { + const y = f(i / 20); + expect(y).toBeGreaterThanOrEqual(prev - 1e-9); + prev = y; + } + }); + + it('catmull-rom passes through its anchors', () => { + const f = buildEvaluator(anchorsFromIds([0, 0], [0.5, 0.7], [1, 1]), 'catmull-rom'); + expect(round(f(0), 2)).toBe(0); + expect(round(f(0.5), 2)).toBe(0.7); + expect(round(f(1), 2)).toBe(1); + }); + + it('bezier with default tangents reproduces a near-linear segment', () => { + const f = buildEvaluator(anchorsFromIds([0, 0], [1, 1]), 'bezier'); + expect(round(f(0))).toBe(0); + expect(round(f(1))).toBe(1); + expect(round(f(0.5), 2)).toBe(0.5); + }); + + it('clamps x outside the domain to the endpoint y', () => { + const f = buildEvaluator(anchorsFromIds([0, 0.1], [1, 0.9]), 'monotone'); + expect(f(-1)).toBe(0.1); + expect(f(2)).toBe(0.9); + }); + + it('clampAnchorX keeps an anchor between its neighbours under monotonicX', () => { + const list = anchorsFromIds([0, 0], [0.5, 0.5], [1, 1]); + // Try to push the middle anchor past the right neighbour. + const x = clampAnchorX(list, 1, 5, { + domainMin: 0, + domainMax: 1, + monotonicX: true, + fixedEndpoints: true, + minGap: 0.01, + }); + expect(x).toBeLessThanOrEqual(1 - 0.01); + expect(x).toBeGreaterThan(0); + }); + + it('clampAnchorX pins endpoints when fixedEndpoints', () => { + const list = anchorsFromIds([0, 0], [1, 1]); + expect(clampAnchorX(list, 0, 0.4, { domainMin: 0, domainMax: 1, monotonicX: true, fixedEndpoints: true, minGap: 0.01 })).toBe(0); + expect(clampAnchorX(list, 1, 0.4, { domainMin: 0, domainMax: 1, monotonicX: true, fixedEndpoints: true, minGap: 0.01 })).toBe(1); + }); +}); + +// ── component tests (browser mode) ────────────────────────────────────────── + +describe('CurveEditor component', () => { + it('renders anchors as role=slider with aria-valuetext conveying input + output', async () => { + mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 0.5]) }); + await nextTick(); + const sliders = document.querySelectorAll('[role="slider"]'); + expect(sliders.length).toBe(2); + const last = sliders[1]!; + expect(last.getAttribute('aria-valuenow')).toBe('0.5'); + expect(last.getAttribute('aria-valuetext')).toBe('input 1, output 0.5'); + expect(last.getAttribute('aria-valuemin')).toBe('0'); + expect(last.getAttribute('aria-valuemax')).toBe('1'); + }); + + it('exposes sample() and toLUT() that pass through anchors', async () => { + const { wrapper } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [0.5, 0.2], [1, 1]), interpolation: 'monotone' }); + await nextTick(); + const root = wrapper.findComponent(CurveEditorRoot); + const sample = (root.vm as any).sample as (x: number) => number; + expect(round(sample(0))).toBe(0); + expect(round(sample(0.5))).toBe(0.2); + expect(round(sample(1))).toBe(1); + const lut = (root.vm as any).toLUT(16) as number[]; + expect(lut.length).toBe(16); + expect(round(lut[0]!)).toBe(0); + expect(round(lut[lut.length - 1]!)).toBe(1); + }); + + it('ArrowUp increases y on the focused anchor', async () => { + const { model } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 0.5]), step: 0.1 }); + await nextTick(); + const slider = document.querySelectorAll('[role="slider"]')[1]!; + keydown(slider, 'ArrowUp'); + await nextTick(); + expect(round(model.value![1]!.y)).toBe(0.6); + }); + + it('ArrowRight increases x; endpoints are x-locked when fixedEndpoints', async () => { + const { model } = mountEditor({ + defaultValue: anchorsFromIds([0, 0], [0.5, 0.5], [1, 1]), + step: 0.1, + fixedEndpoints: true, + }); + await nextTick(); + const sliders = document.querySelectorAll('[role="slider"]'); + // Interior anchor moves in x. + keydown(sliders[1]!, 'ArrowRight'); + await nextTick(); + expect(round(model.value![1]!.x)).toBe(0.6); + // Endpoint x is locked. + keydown(sliders[2]!, 'ArrowRight'); + await nextTick(); + expect(round(model.value![2]!.x)).toBe(1); + }); + + it('monotonicX prevents an anchor from crossing its neighbour', async () => { + const { model } = mountEditor({ + defaultValue: anchorsFromIds([0, 0], [0.5, 0.5], [1, 1]), + step: 0.1, + monotonicX: true, + }); + await nextTick(); + const mid = document.querySelectorAll('[role="slider"]')[1]!; + // Hammer ArrowRight far past the right neighbour at x=1. + for (let i = 0; i < 20; i++) keydown(mid, 'ArrowRight'); + await nextTick(); + expect(model.value![1]!.x).toBeLessThan(1); + // Order is preserved. + expect(model.value![0]!.x).toBeLessThan(model.value![1]!.x); + expect(model.value![1]!.x).toBeLessThan(model.value![2]!.x); + }); + + it('Shift+Arrow uses the large step', async () => { + const { model } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 0.5]), step: 0.01, largeStep: 0.1 }); + await nextTick(); + const slider = document.querySelectorAll('[role="slider"]')[1]!; + keydown(slider, 'ArrowUp', { shiftKey: true }); + await nextTick(); + expect(round(model.value![1]!.y)).toBe(0.6); + }); + + it('Enter adds an anchor at the midpoint, Delete removes an interior anchor', async () => { + const { model } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 1]) }); + await nextTick(); + const first = document.querySelectorAll('[role="slider"]')[0]!; + keydown(first, 'Enter'); + await nextTick(); + expect(model.value!.length).toBe(3); + expect(round(model.value![1]!.x)).toBe(0.5); + // Remove the new interior anchor. + const interior = document.querySelectorAll('[role="slider"]')[1]!; + keydown(interior, 'Delete'); + await nextTick(); + expect(model.value!.length).toBe(2); + }); + + it('Delete does not remove an endpoint', async () => { + mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 1]) }); + await nextTick(); + const first = document.querySelectorAll('[role="slider"]')[0]!; + keydown(first, 'Delete'); + await nextTick(); + // The endpoint is never removed: both anchors still render. + expect(document.querySelectorAll('[role="slider"]').length).toBe(2); + }); + + it('roving focus: only the active anchor is tabbable', async () => { + mountEditor({ defaultValue: anchorsFromIds([0, 0], [0.5, 0.5], [1, 1]) }); + await nextTick(); + const sliders = document.querySelectorAll('[role="slider"]'); + // Active index defaults to 0. + expect(sliders[0]!.tabIndex).toBe(0); + expect(sliders[1]!.tabIndex).toBe(-1); + expect(sliders[2]!.tabIndex).toBe(-1); + }); + + it('disabled: tabindex=-1 and keys do nothing', async () => { + const { model } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 0.5]), disabled: true }); + await nextTick(); + const slider = document.querySelectorAll('[role="slider"]')[1]!; + expect(slider.tabIndex).toBe(-1); + keydown(slider, 'ArrowUp'); + await nextTick(); + // No mutation occurred (model never written). + expect(model.value).toBeUndefined(); + }); + + it('renders the curve as an aria-hidden path with a non-empty d', async () => { + mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 1]) }); + await nextTick(); + const path = document.querySelector('[data-curve-editor-curve]')!; + expect(path).toBeTruthy(); + expect(path.getAttribute('aria-hidden')).toBe('true'); + }); + + it('interpolation modes each produce a curve through the anchors', async () => { + for (const interpolation of ['linear', 'monotone', 'catmull-rom', 'bezier'] as const) { + const { wrapper } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [0.5, 0.4], [1, 1]), interpolation }); + await nextTick(); + const sample = (wrapper.findComponent(CurveEditorRoot).vm as any).sample as (x: number) => number; + expect(round(sample(0), 2)).toBe(0); + expect(round(sample(0.5), 2)).toBe(0.4); + expect(round(sample(1), 2)).toBe(1); + wrapper.unmount(); + } + }); +}); diff --git a/vue/primitives/src/calendar/__test__/__screenshots__/Calendar.test.ts/Calendar-mounts-cell-triggers-without--expose---should-be-called-only-once--warnings-1.png b/vue/primitives/src/canvas/curve-editor/__test__/__screenshots__/CurveEditor.test.ts/CurveEditor-component-Delete-does-not-remove-an-endpoint-1.png similarity index 100% rename from vue/primitives/src/calendar/__test__/__screenshots__/Calendar.test.ts/Calendar-mounts-cell-triggers-without--expose---should-be-called-only-once--warnings-1.png rename to vue/primitives/src/canvas/curve-editor/__test__/__screenshots__/CurveEditor.test.ts/CurveEditor-component-Delete-does-not-remove-an-endpoint-1.png diff --git a/vue/primitives/src/canvas/curve-editor/context.ts b/vue/primitives/src/canvas/curve-editor/context.ts new file mode 100644 index 0000000..1361169 --- /dev/null +++ b/vue/primitives/src/canvas/curve-editor/context.ts @@ -0,0 +1,122 @@ +import type { Ref } from 'vue'; +import type { Point } from '../../internal/utils/geometry'; +import type { UseScaleReturn } from '../../internal/scale'; +import { useContextFactory } from '@robonen/vue'; + +export type CurveEditorDirection = 'ltr' | 'rtl'; + +/** + * How the curve is interpolated between anchors. + * - `'linear'` — straight segments (piecewise-linear). + * - `'bezier'` — per-anchor cubic tangents (`inHandle`/`outHandle`), solved for + * `x` so the curve stays a function of `x`. + * - `'monotone'` — Fritsch-Carlson monotone cubic (no overshoot; the tone/gamma + * default). + * - `'catmull-rom'` — Catmull-Rom spline through the anchors. + */ +export type CurveEditorInterpolation = 'linear' | 'bezier' | 'monotone' | 'catmull-rom'; + +/** + * Which curve is being edited. For animation easing this is always `'value'`; + * for photo tone curves the consumer switches between the composite `'value'` + * curve and the per-channel `'r'`/`'g'`/`'b'` curves. The channel only tags the + * curve (for styling / the `#channel` slot) — it does not change the maths. + */ +export type CurveEditorChannel = 'value' | 'r' | 'g' | 'b'; + +/** + * A single control point on the curve. + * + * `x`/`y` are in domain space (`domainX`/`domainY`). `inHandle`/`outHandle` are + * bezier tangents **relative** to the anchor (deltas, not absolute points) and + * are only consulted in `'bezier'` interpolation. + */ +export interface CurveEditorAnchor { + /** Stable identity used as the `v-for` key and roving-focus handle. */ + id: string; + /** Input coordinate in `domainX` space. */ + x: number; + /** Output coordinate in `domainY` space. */ + y: number; + /** Incoming bezier tangent, relative to the anchor (`'bezier'` mode only). */ + inHandle?: Point; + /** Outgoing bezier tangent, relative to the anchor (`'bezier'` mode only). */ + outHandle?: Point; +} + +/** + * Which tangent handle of an anchor a `CurveEditorHandle` controls. + */ +export type CurveEditorHandleSide = 'in' | 'out'; + +/** + * Context shared between `CurveEditorRoot` and its descendants. + * + * Scalar props are exposed as plain `Ref` — `CurveEditorRoot` builds them + * with `toRef(() => prop)` (a reactive getter ref without an extra effect), + * matching the slider / color-area convention. + */ +export interface CurveEditorContext { + /** The live anchor array (sorted ascending by `x`). */ + anchors: Ref; + /** Active interpolation mode. */ + interpolation: Ref; + /** Input (x) domain `[min, max]`. */ + domainX: Ref; + /** Output (y) domain `[min, max]`. */ + domainY: Ref; + /** x-axis value↔pixel projection (horizontal). */ + scaleX: UseScaleReturn; + /** y-axis value↔pixel projection (vertical, value-up). */ + scaleY: UseScaleReturn; + /** Channel tag for styling / the `#channel` slot. */ + channel: Ref; + /** Keyboard step for x/y nudges. */ + step: Ref; + /** Large keyboard step (Shift+Arrow / Page keys). */ + largeStep: Ref; + /** Whether x is kept single-valued (neighbour-clamped, anchors can't cross). */ + monotonicX: Ref; + /** Whether the first/last anchor are locked in x. */ + fixedEndpoints: Ref; + direction: Ref; + disabled: Ref; + /** Index of the currently focused anchor (roving focus tab-stop). */ + activeIndex: Ref; + /** Evaluate the curve: input `x` → output `y`. */ + sample: (x: number) => number; + /** Sample the curve into a `size`-length lookup table across `domainX`. */ + toLUT: (size?: number) => number[]; + /** Index of an anchor by id (`-1` when absent). */ + indexOf: (id: string) => number; + /** Register an anchor element so roving focus can move between them. */ + registerAnchorEl: (id: string, el: HTMLElement | null) => void; + /** Whether `id` is the first or last anchor (endpoint). */ + isEndpoint: (id: string) => boolean; + /** Move the active anchor to `index` and focus its element. */ + setActiveIndex: (index: number) => void; + /** Move roving focus by `delta` anchors (wraps). */ + moveFocus: (delta: number) => void; + /** + * Emit `anchorsCommit` with a snapshot of the current anchors. Called by parts + * once a gesture/keypress settles (not per drag frame). + */ + commit: () => void; + /** + * Update an anchor's `x`/`y` (neighbour + domain clamped, endpoints x-locked). + * Returns whether the anchor actually moved (so discrete callers can decide + * whether to `commit()`); does not itself emit `anchorsCommit`. + */ + updateAnchor: (id: string, next: { x?: number; y?: number }) => boolean; + /** Update an anchor's bezier tangent handle (`'bezier'` mode). */ + updateHandle: (id: string, side: CurveEditorHandleSide, handle: Point) => void; + /** Insert an anchor at `x` (y defaults to the sampled curve value). */ + addAnchor: (x: number, y?: number) => string | undefined; + /** Remove an anchor by id (endpoints are never removed). */ + removeAnchor: (id: string) => void; +} + +const ctx = useContextFactory('CurveEditorContext'); + +export const provideCurveEditorContext = ctx.provide; +export const useCurveEditorContext = ctx.inject; diff --git a/vue/primitives/src/canvas/curve-editor/demo.vue b/vue/primitives/src/canvas/curve-editor/demo.vue new file mode 100644 index 0000000..b71ccb7 --- /dev/null +++ b/vue/primitives/src/canvas/curve-editor/demo.vue @@ -0,0 +1,183 @@ + + + diff --git a/vue/primitives/src/canvas/curve-editor/index.ts b/vue/primitives/src/canvas/curve-editor/index.ts new file mode 100644 index 0000000..7273afb --- /dev/null +++ b/vue/primitives/src/canvas/curve-editor/index.ts @@ -0,0 +1,31 @@ +export { default as CurveEditorRoot } from './CurveEditorRoot.vue'; +export { default as CurveEditorCurve } from './CurveEditorCurve.vue'; +export { default as CurveEditorGrid } from './CurveEditorGrid.vue'; +export { default as CurveEditorHandle } from './CurveEditorHandle.vue'; +export { default as CurveEditorPoint } from './CurveEditorPoint.vue'; + +export type { CurveEditorRootEmits, CurveEditorRootProps } from './CurveEditorRoot.vue'; +export type { CurveEditorCurveProps } from './CurveEditorCurve.vue'; +export type { CurveEditorGridProps } from './CurveEditorGrid.vue'; +export type { CurveEditorHandleProps } from './CurveEditorHandle.vue'; +export type { CurveEditorPointProps } from './CurveEditorPoint.vue'; + +export { + type CurveEditorAnchor, + type CurveEditorChannel, + type CurveEditorContext, + type CurveEditorDirection, + type CurveEditorHandleSide, + type CurveEditorInterpolation, + provideCurveEditorContext, + useCurveEditorContext, +} from './context'; + +export { + anchorsToPoints, + buildEvaluator, + clampAnchorX, + clampAnchorY, + formatAnchorValueText, + sortAnchors, +} from './utils'; diff --git a/vue/primitives/src/canvas/curve-editor/utils.ts b/vue/primitives/src/canvas/curve-editor/utils.ts new file mode 100644 index 0000000..d5ac56f --- /dev/null +++ b/vue/primitives/src/canvas/curve-editor/utils.ts @@ -0,0 +1,257 @@ +import type { Point } from '../../internal/utils/geometry'; +import type { CurveEditorAnchor, CurveEditorInterpolation } from './context'; +import { catmullRom, evalCubicBezier, linearInterpolate, monotoneCubic } from '../../internal/spline'; + +/** + * Sort anchors ascending by `x`, returning a new array (never mutating the + * input). Stable for equal `x` (preserves insertion order), so monotonic-x + * neighbour-clamping keeps a deterministic order. + */ +export function sortAnchors(anchors: readonly CurveEditorAnchor[]): CurveEditorAnchor[] { + return anchors + .map((a, i) => [a, i] as const) + .sort((p, q) => (p[0].x - q[0].x) || (p[1] - q[1])) + .map(p => p[0]); +} + +/** + * Project anchors onto bare `Point`s (`{ x, y }`), dropping bezier handles. The + * shape the spline samplers consume. + */ +export function anchorsToPoints(anchors: readonly CurveEditorAnchor[]): Point[] { + const out: Point[] = Array.from({ length: anchors.length }); + for (let i = 0; i < anchors.length; i++) { + const a = anchors[i]!; + out[i] = { x: a.x, y: a.y }; + } + return out; +} + +/** + * Build a `y = f(x)` evaluator from `anchors` for the given `interpolation` + * mode. Anchors are assumed sorted ascending by `x`. Out-of-range `x` clamps to + * the endpoint y (every mode is range-clamped, matching the spline helpers). + * + * - `'linear'` → {@link linearInterpolate}. + * - `'monotone'` → {@link monotoneCubic} (Fritsch-Carlson, no overshoot — the + * tone/gamma default). + * - `'catmull-rom'` → {@link catmullRom} sampled by `x` (resampled to a dense + * monotone-x table so it stays a function of `x`). + * - `'bezier'` → per-segment cubic from each anchor's `outHandle`/`inHandle` + * tangents, solved for `x` via a Newton-Raphson search so the curve remains + * single-valued in `x`. + * + * The returned closure is allocation-free per call (it closes over precomputed + * arrays), so it is cheap on the render / LUT hot path. + */ +export function buildEvaluator( + anchors: readonly CurveEditorAnchor[], + interpolation: CurveEditorInterpolation, +): (x: number) => number { + const n = anchors.length; + if (n === 0) + return () => 0; + if (n === 1) { + const y = anchors[0]!.y; + return () => y; + } + + const points = anchorsToPoints(anchors); + + switch (interpolation) { + case 'linear': + return (x: number) => linearInterpolate(points, x); + + case 'monotone': + return monotoneCubic(points); + + case 'catmull-rom': + return buildCatmullRomEvaluator(points); + + case 'bezier': + return buildBezierEvaluator(anchors); + + default: + return monotoneCubic(points); + } +} + +/** Default density for resampling a parametric spline into a monotone-x table. */ +const RESAMPLE = 256; + +/** + * Catmull-Rom is parametric (`t → Point`), so it can fold back on itself in `x`. + * For a single-valued `y = f(x)` curve we sample it densely in `t`, then build a + * piecewise-linear lookup by `x`. The result passes through every anchor (the + * spline interpolates its control points) and is monotone-clamped at the ends. + */ +function buildCatmullRomEvaluator(points: Point[]): (x: number) => number { + const samples: Point[] = Array.from({ length: RESAMPLE + 1 }); + for (let i = 0; i <= RESAMPLE; i++) + samples[i] = catmullRom(points, i / RESAMPLE); + // The sampled x's are not guaranteed strictly increasing, but for a curve that + // is monotone in x (the editor enforces this) they are non-decreasing, so a + // linear lookup by x is well-defined. + return (x: number) => linearInterpolate(samples, x); +} + +/** + * Build the per-segment cubic-bezier evaluator. Between anchors `a` and `b` the + * control points are `a + a.outHandle` and `b + b.inHandle` (handles are deltas + * relative to their anchor); absent handles default to one-third of the segment + * (a straight-through tangent), giving a linear segment. Each segment is solved + * for `t` from `x` with Newton-Raphson + bisection so the curve stays a function + * of `x`. + */ +function buildBezierEvaluator(anchors: readonly CurveEditorAnchor[]): (x: number) => number { + const n = anchors.length; + const xs: number[] = Array.from({ length: n }); + for (let i = 0; i < n; i++) + xs[i] = anchors[i]!.x; + + return (x: number): number => { + const first = anchors[0]!; + const last = anchors[n - 1]!; + if (x <= first.x) + return first.y; + if (x >= last.x) + return last.y; + + // Binary search for the segment [lo, lo+1] containing x. + let lo = 0; + let hi = n - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (xs[mid]! <= x) + lo = mid; + else + hi = mid; + } + + const a = anchors[lo]!; + const b = anchors[lo + 1]!; + const dx = b.x - a.x; + if (dx === 0) + return a.y; + + // Default tangents (one-third along the segment) when a handle is missing. + const c1: Point = a.outHandle + ? { x: a.x + a.outHandle.x, y: a.y + a.outHandle.y } + : { x: a.x + dx / 3, y: a.y + (b.y - a.y) / 3 }; + const c2: Point = b.inHandle + ? { x: b.x + b.inHandle.x, y: b.y + b.inHandle.y } + : { x: b.x - dx / 3, y: b.y - (b.y - a.y) / 3 }; + + const t = solveSegmentT(a.x, c1.x, c2.x, b.x, x); + return evalCubicBezier(a, c1, c2, b, t).y; + }; +} + +/** + * Solve `x(t) = target` on a cubic whose x-components are `x0..x3` (general + * endpoints, unlike `solveBezierX` which assumes 0/1). Newton-Raphson from a + * normalized initial guess, with a bisection fallback. + */ +function solveSegmentT(x0: number, x1: number, x2: number, x3: number, target: number, epsilon = 1e-6): number { + const sampleX = (t: number): number => { + const u = 1 - t; + return u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3; + }; + const sampleDX = (t: number): number => { + const u = 1 - t; + return 3 * u * u * (x1 - x0) + 6 * u * t * (x2 - x1) + 3 * t * t * (x3 - x2); + }; + + const span = x3 - x0; + let t = span === 0 ? 0 : (target - x0) / span; + t = Math.min(Math.max(t, 0), 1); + + for (let i = 0; i < 8; i++) { + const fx = sampleX(t) - target; + if (Math.abs(fx) < epsilon) + return t; + const dx = sampleDX(t); + if (Math.abs(dx) < epsilon) + break; + t -= fx / dx; + t = Math.min(Math.max(t, 0), 1); + } + + let lo = 0; + let hi = 1; + t = Math.min(Math.max(span === 0 ? 0 : (target - x0) / span, 0), 1); + for (let i = 0; i < 32 && hi - lo > epsilon; i++) { + const fx = sampleX(t); + if (Math.abs(fx - target) < epsilon) + return t; + if (target > fx) + lo = t; + else + hi = t; + t = (lo + hi) / 2; + } + return t; +} + +/** + * Clamp a candidate `x` for the anchor at `index` so it stays strictly between + * its neighbours (when `monotonicX`) by at least `minGap`, and within + * `[domainMin, domainMax]`. Endpoints are pinned to the domain edge when + * `fixedEndpoints`. + */ +export function clampAnchorX( + anchors: readonly CurveEditorAnchor[], + index: number, + x: number, + options: { + domainMin: number; + domainMax: number; + monotonicX: boolean; + fixedEndpoints: boolean; + minGap: number; + }, +): number { + const { domainMin, domainMax, monotonicX, fixedEndpoints, minGap } = options; + const lo = Math.min(domainMin, domainMax); + const hi = Math.max(domainMin, domainMax); + const isFirst = index === 0; + const isLast = index === anchors.length - 1; + + if (fixedEndpoints && isFirst) + return lo; + if (fixedEndpoints && isLast) + return hi; + + let v = Math.min(Math.max(x, lo), hi); + if (monotonicX) { + const prev = anchors[index - 1]; + const next = anchors[index + 1]; + if (prev !== undefined) + v = Math.max(v, prev.x + minGap); + if (next !== undefined) + v = Math.min(v, next.x - minGap); + } + return v; +} + +/** Clamp a candidate `y` into `[domainMin, domainMax]` (order-agnostic). */ +export function clampAnchorY(y: number, domainMin: number, domainMax: number): number { + const lo = Math.min(domainMin, domainMax); + const hi = Math.max(domainMin, domainMax); + return Math.min(Math.max(y, lo), hi); +} + +/** + * Format the default `aria-valuetext` for an anchor: a 2-coordinate control + * whose single `aria-valuenow` can't carry both axes, so both the input (x) and + * output (y) are announced. + */ +export function formatAnchorValueText(x: number, y: number, decimals = 2): string { + return `input ${round(x, decimals)}, output ${round(y, decimals)}`; +} + +/** Round to `decimals` places, trimming float noise (no trailing-zero padding). */ +export function round(value: number, decimals: number): number { + const f = 10 ** decimals; + return Math.round(value * f) / f; +} diff --git a/vue/primitives/src/canvas/flow/FlowBackground.vue b/vue/primitives/src/canvas/flow/FlowBackground.vue new file mode 100644 index 0000000..dd86c77 --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowBackground.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowConnectionLine.vue b/vue/primitives/src/canvas/flow/FlowConnectionLine.vue new file mode 100644 index 0000000..380c64c --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowConnectionLine.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowControls.vue b/vue/primitives/src/canvas/flow/FlowControls.vue new file mode 100644 index 0000000..e338007 --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowControls.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowEdge.vue b/vue/primitives/src/canvas/flow/FlowEdge.vue new file mode 100644 index 0000000..a437f9c --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowEdge.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowEdgeRenderer.vue b/vue/primitives/src/canvas/flow/FlowEdgeRenderer.vue new file mode 100644 index 0000000..9aa77e9 --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowEdgeRenderer.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowHandle.vue b/vue/primitives/src/canvas/flow/FlowHandle.vue new file mode 100644 index 0000000..4f9e841 --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowHandle.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowMiniMap.vue b/vue/primitives/src/canvas/flow/FlowMiniMap.vue new file mode 100644 index 0000000..b578864 --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowMiniMap.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowNode.vue b/vue/primitives/src/canvas/flow/FlowNode.vue new file mode 100644 index 0000000..2d7d0c8 --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowNode.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowNodeRenderer.vue b/vue/primitives/src/canvas/flow/FlowNodeRenderer.vue new file mode 100644 index 0000000..9f9c7aa --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowNodeRenderer.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowNodeResizer.vue b/vue/primitives/src/canvas/flow/FlowNodeResizer.vue new file mode 100644 index 0000000..9b96b9b --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowNodeResizer.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowNodeToolbar.vue b/vue/primitives/src/canvas/flow/FlowNodeToolbar.vue new file mode 100644 index 0000000..9c6dbf5 --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowNodeToolbar.vue @@ -0,0 +1,91 @@ + diff --git a/vue/primitives/src/canvas/flow/FlowPane.vue b/vue/primitives/src/canvas/flow/FlowPane.vue new file mode 100644 index 0000000..43f3737 --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowPane.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowPanel.vue b/vue/primitives/src/canvas/flow/FlowPanel.vue new file mode 100644 index 0000000..7e44c53 --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowPanel.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowRoot.vue b/vue/primitives/src/canvas/flow/FlowRoot.vue new file mode 100644 index 0000000..d3057da --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowRoot.vue @@ -0,0 +1,573 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/FlowViewport.vue b/vue/primitives/src/canvas/flow/FlowViewport.vue new file mode 100644 index 0000000..e743e21 --- /dev/null +++ b/vue/primitives/src/canvas/flow/FlowViewport.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/__test__/Advanced.test.ts b/vue/primitives/src/canvas/flow/__test__/Advanced.test.ts new file mode 100644 index 0000000..ae406fb --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/Advanced.test.ts @@ -0,0 +1,73 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; +import { FlowNodeResizer, FlowNodeToolbar, FlowRoot, useFlow } from '../index'; +import type { FlowNode, UseFlowReturn } from '../index'; + +const wrappers: Array> = []; +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; + delete (globalThis as any).__api; +}); +function track>(w: T): T { + wrappers.push(w); + return w; +} + +const Capture = defineComponent({ + setup() { + (globalThis as any).__api = useFlow(); + return () => null; + }, +}); + +describe('subflows', () => { + it('computes absolute position as the parent chain sum', async () => { + const nodes: FlowNode[] = [ + { id: 'p', position: { x: 100, y: 100 } }, + { id: 'c', position: { x: 10, y: 20 }, parentId: 'p' }, + ]; + track(mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: nodes }, + slots: { 'node-default': () => h('div', 'n'), default: () => h(Capture) }, + })); + await nextTick(); + const api = (globalThis as any).__api as UseFlowReturn; + expect(api.getNode('c')?.positionAbsolute).toEqual({ x: 110, y: 120 }); + }); +}); + +describe('FlowNodeResizer', () => { + it('renders eight resize handles inside a node', () => { + const w = track(mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: [{ id: 'a', position: { x: 0, y: 0 }, width: 120, height: 60 }] }, + slots: { 'node-default': () => [h('div', 'n'), h(FlowNodeResizer)] }, + })); + expect(w.findAll('[data-flow-resize-handle]')).toHaveLength(8); + expect(w.find('[data-flow-resize-handle][data-position="bottom-right"]').exists()).toBe(true); + }); +}); + +describe('FlowNodeToolbar', () => { + it('teleports a toolbar to the body only while the node is selected', async () => { + const w = track(mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: [{ id: 'a', position: { x: 0, y: 0 } }] }, + slots: { + 'node-default': () => [h('div', 'n'), h(FlowNodeToolbar, null, { default: () => h('button', 'del') })], + }, + })); + await nextTick(); + expect(document.querySelector('[data-flow-node-toolbar]')).toBeNull(); + + w.find('[data-id="a"]').element.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true })); + await nextTick(); + expect(w.find('[data-id="a"]').attributes('data-selected')).toBe(''); + await nextTick(); + expect(document.querySelector('[data-flow-node-toolbar]')).not.toBeNull(); + }); +}); diff --git a/vue/primitives/src/canvas/flow/__test__/Chrome.test.ts b/vue/primitives/src/canvas/flow/__test__/Chrome.test.ts new file mode 100644 index 0000000..5df0b4d --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/Chrome.test.ts @@ -0,0 +1,74 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { h, nextTick } from 'vue'; +import { FlowBackground, FlowControls, FlowMiniMap, FlowPanel, FlowRoot } from '../index'; +import type { FlowNode } from '../index'; + +const wrappers: Array> = []; +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); +function track>(w: T): T { + wrappers.push(w); + return w; +} + +const nodes: FlowNode[] = [ + { id: 'a', position: { x: 0, y: 0 } }, + { id: 'b', position: { x: 300, y: 200 } }, +]; + +function mountWithChrome(chrome: () => any) { + return track(mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: nodes }, + slots: { 'node-default': () => h('div', 'n'), default: chrome }, + })); +} + +describe('FlowPanel', () => { + it('renders at the requested corner and stops pane events', () => { + const w = mountWithChrome(() => h(FlowPanel, { position: 'top-right' }, () => 'panel')); + const panel = w.find('[data-flow-panel]'); + expect(panel.exists()).toBe(true); + expect(panel.attributes('data-position')).toBe('top-right'); + expect((panel.element as HTMLElement).style.position).toBe('absolute'); + }); +}); + +describe('FlowBackground', () => { + it('renders an svg pattern that reacts to the variant', () => { + const w = mountWithChrome(() => h(FlowBackground, { variant: 'lines' })); + const bg = w.find('[data-flow-background]'); + expect(bg.exists()).toBe(true); + expect(bg.attributes('data-variant')).toBe('lines'); + expect(w.find('pattern').exists()).toBe(true); + }); +}); + +describe('FlowControls', () => { + it('renders zoom/fit buttons that drive the viewport', async () => { + const w = mountWithChrome(() => h(FlowControls)); + const zoomIn = w.find('[data-flow-control="zoom-in"]'); + expect(zoomIn.exists()).toBe(true); + expect(w.find('[data-flow-control="fit-view"]').exists()).toBe(true); + + const before = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform; + await zoomIn.trigger('click'); + await nextTick(); + const after = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform; + expect(after).not.toBe(before); + }); +}); + +describe('FlowMiniMap', () => { + it('renders a rect per node plus a viewport mask', async () => { + const w = mountWithChrome(() => h(FlowMiniMap)); + await nextTick(); + expect(w.find('[data-flow-minimap]').exists()).toBe(true); + expect(w.findAll('[data-flow-minimap-node]')).toHaveLength(2); + expect(w.find('[data-flow-minimap-mask]').exists()).toBe(true); + }); +}); diff --git a/vue/primitives/src/canvas/flow/__test__/Connect.test.ts b/vue/primitives/src/canvas/flow/__test__/Connect.test.ts new file mode 100644 index 0000000..7b2a8cb --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/Connect.test.ts @@ -0,0 +1,80 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { h, nextTick } from 'vue'; +import { FlowHandle, FlowRoot } from '../index'; +import type { Connection, FlowNode } from '../index'; + +const wrappers: Array> = []; +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function center(el: Element): { x: number; y: number } { + const r = el.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; +} + +const nodeSlot = () => [ + h('div', { style: 'width:80px;height:40px' }, 'n'), + h(FlowHandle, { type: 'target', position: 'left', id: 'in', style: 'width:10px;height:10px' }), + h(FlowHandle, { type: 'source', position: 'right', id: 'out', style: 'width:10px;height:10px' }), +]; + +describe('connection creation (regression: connecting did nothing)', () => { + it('drags from a source handle to a target handle and emits @connect + adds the edge', async () => { + const connections: Connection[] = []; + const nodes: FlowNode[] = [ + { id: 'a', position: { x: 20, y: 20 } }, + { id: 'b', position: { x: 240, y: 20 } }, + ]; + const w = mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: nodes, defaultEdges: [], onConnect: (c: Connection) => connections.push(c) }, + slots: { 'node-default': nodeSlot }, + }); + wrappers.push(w); + await nextTick(); + await nextTick(); // allow handle measurement + + const sourceHandle = w.find('[data-id="a"] [data-handletype="source"]').element; + const targetHandle = w.find('[data-id="b"] [data-handletype="target"]').element; + const target = center(targetHandle); + + // Fast-flick gesture: NO awaits between events. With reactive (watch-based) + // listener attachment this dropped the moves and never connected; the + // always-on window listeners make it reliable. + sourceHandle.dispatchEvent(new PointerEvent('pointerdown', { button: 0, pointerId: 1, ...center(sourceHandle), bubbles: true })); + globalThis.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: target.x, clientY: target.y })); + globalThis.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: target.x, clientY: target.y })); + await nextTick(); + + expect(connections).toHaveLength(1); + expect(connections[0]).toMatchObject({ source: 'a', target: 'b', sourceHandle: 'out', targetHandle: 'in' }); + expect(w.findAll('[data-flow-edge]')).toHaveLength(1); + }); + + it('does not connect a node to itself (strict mode)', async () => { + const connections: Connection[] = []; + const nodes: FlowNode[] = [{ id: 'a', position: { x: 20, y: 20 } }]; + const w = mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: nodes, defaultEdges: [], onConnect: (c: Connection) => connections.push(c) }, + slots: { 'node-default': nodeSlot }, + }); + wrappers.push(w); + await nextTick(); + await nextTick(); + + const sourceHandle = w.find('[data-id="a"] [data-handletype="source"]').element; + const targetHandle = w.find('[data-id="a"] [data-handletype="target"]').element; + const target = center(targetHandle); + sourceHandle.dispatchEvent(new PointerEvent('pointerdown', { button: 0, pointerId: 1, ...center(sourceHandle), bubbles: true })); + await nextTick(); + globalThis.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: target.x, clientY: target.y })); + await nextTick(); + + expect(connections).toHaveLength(0); + }); +}); diff --git a/vue/primitives/src/canvas/flow/__test__/Drag.test.ts b/vue/primitives/src/canvas/flow/__test__/Drag.test.ts new file mode 100644 index 0000000..c25620e --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/Drag.test.ts @@ -0,0 +1,73 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { h, nextTick } from 'vue'; +import { FlowRoot } from '../index'; +import type { FlowEdge, FlowNode } from '../index'; + +const wrappers: Array> = []; +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function raf(): Promise { + return new Promise(resolve => requestAnimationFrame(() => resolve())); +} + +function pointer(el: Element, type: string, x: number, y: number) { + el.dispatchEvent(new PointerEvent(type, { button: 0, pointerId: 1, clientX: x, clientY: y, bubbles: true, cancelable: true })); +} + +const nodes: FlowNode[] = [ + { id: 'a', position: { x: 0, y: 0 } }, + { id: 'b', position: { x: 300, y: 200 } }, +]; +const edges: FlowEdge[] = [{ id: 'e', source: 'a', target: 'b', type: 'straight' }]; + +function mountFlow() { + return mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: nodes, defaultEdges: edges }, + slots: { 'node-default': () => h('div', { style: 'width:80px;height:40px' }, 'n') }, + }); +} + +describe('node drag updates the DOM (regression: in-place mutation froze nodes)', () => { + it('moves the dragged node element and follows with the edge path', async () => { + const w = mountFlow(); + wrappers.push(w); + await nextTick(); + + const nodeEl = w.find('[data-id="a"]').element as HTMLElement; + const edgeBefore = w.find('[data-flow-edge-path]').attributes('d'); + + pointer(nodeEl, 'pointerdown', 10, 10); + pointer(nodeEl, 'pointermove', 70, 50); // delta (60, 40) at zoom 1 + await raf(); + await nextTick(); + + const transform = (w.find('[data-id="a"]').element as HTMLElement).style.transform; + expect(transform).toBe('translate(60px, 40px)'); + + const edgeAfter = w.find('[data-flow-edge-path]').attributes('d'); + expect(edgeAfter).not.toBe(edgeBefore); // edge endpoint followed the node + + pointer(nodeEl, 'pointerup', 70, 50); + await nextTick(); + // committed to the model + expect((w.findAll('[data-flow-node]')[0]!.element as HTMLElement).style.transform).toBe('translate(60px, 40px)'); + }); + + it('does not move other nodes', async () => { + const w = mountFlow(); + wrappers.push(w); + await nextTick(); + const nodeEl = w.find('[data-id="a"]').element as HTMLElement; + pointer(nodeEl, 'pointerdown', 10, 10); + pointer(nodeEl, 'pointermove', 70, 50); + await raf(); + await nextTick(); + expect((w.find('[data-id="b"]').element as HTMLElement).style.transform).toBe('translate(300px, 200px)'); + }); +}); diff --git a/vue/primitives/src/canvas/flow/__test__/Flow.test.ts b/vue/primitives/src/canvas/flow/__test__/Flow.test.ts new file mode 100644 index 0000000..6ef6045 --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/Flow.test.ts @@ -0,0 +1,142 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { h, nextTick, ref } from 'vue'; +import { FlowRoot } from '../index'; +import type { FlowEdge, FlowNode, Viewport } from '../index'; + +const wrappers: Array> = []; +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); +function track>(w: T): T { + wrappers.push(w); + return w; +} + +const nodeSlot = (p: { data?: { label?: string } }) => h('div', { class: 'node-body' }, p.data?.label ?? ''); + +function mountFlow(props: Record = {}) { + return track(mount(FlowRoot, { + attachTo: document.body, + props, + slots: { 'node-default': nodeSlot }, + })); +} + +describe('FlowRoot skeleton', () => { + it('renders pane, single transformed viewport and the edge svg', () => { + const w = mountFlow({ defaultNodes: [], defaultEdges: [] }); + expect(w.find('[data-flow-pane]').exists()).toBe(true); + const viewport = w.find('[data-flow-viewport]'); + expect(viewport.exists()).toBe(true); + expect((viewport.element as HTMLElement).style.transformOrigin).toBe('0px 0px'); + // exactly one shared edge layer + expect(w.findAll('[data-flow-edges]')).toHaveLength(1); + }); + + it('applies the viewport transform string', () => { + const w = mountFlow({ defaultViewport: { x: 40, y: 20, zoom: 2 } }); + const t = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform; + expect(t).toContain('translate(40px, 20px)'); + expect(t).toContain('scale(2)'); + }); +}); + +describe('node state model', () => { + const nodes: FlowNode[] = [ + { id: 'a', position: { x: 0, y: 0 }, data: { label: 'A' } }, + { id: 'b', position: { x: 200, y: 100 }, data: { label: 'B' } }, + ]; + + it('uncontrolled defaultNodes seeds the canvas', () => { + const w = mountFlow({ defaultNodes: nodes }); + const els = w.findAll('[data-flow-node]'); + expect(els).toHaveLength(2); + expect(els[0]!.attributes('data-id')).toBe('a'); + expect((els[1]!.element as HTMLElement).style.transform).toBe('translate(200px, 100px)'); + }); + + it('controlled v-model:nodes reflects external updates', async () => { + const model = ref([{ id: 'a', position: { x: 0, y: 0 }, data: { label: 'A' } }]); + const w = track(mount(FlowRoot, { + attachTo: document.body, + props: { nodes: model.value, 'onUpdate:nodes': (v: FlowNode[]) => { model.value = v; } }, + slots: { 'node-default': nodeSlot }, + })); + expect(w.findAll('[data-flow-node]')).toHaveLength(1); + + await w.setProps({ nodes: [...model.value, { id: 'c', position: { x: 10, y: 10 }, data: { label: 'C' } }] }); + await nextTick(); + expect(w.findAll('[data-flow-node]')).toHaveLength(2); + }); + + it('renders the node-default slot content', () => { + const w = mountFlow({ defaultNodes: nodes }); + expect(w.find('[data-id="a"] .node-body').text()).toBe('A'); + }); + + it('hides hidden nodes', () => { + const w = mountFlow({ defaultNodes: [{ id: 'a', position: { x: 0, y: 0 } }, { id: 'h', position: { x: 0, y: 0 }, hidden: true }] }); + expect(w.findAll('[data-flow-node]')).toHaveLength(1); + }); +}); + +describe('edges', () => { + const nodes: FlowNode[] = [ + { id: 'a', position: { x: 0, y: 0 } }, + { id: 'b', position: { x: 200, y: 100 } }, + ]; + const edges: FlowEdge[] = [{ id: 'e1', source: 'a', target: 'b', type: 'straight' }]; + + it('renders an edge path between two nodes', async () => { + const w = mountFlow({ defaultNodes: nodes, defaultEdges: edges }); + await nextTick(); + const edge = w.find('[data-flow-edge]'); + expect(edge.exists()).toBe(true); + expect(edge.attributes('data-id')).toBe('e1'); + const d = w.find('[data-flow-edge-path]').attributes('d'); + expect(d).toMatch(/^M /); + expect(d).not.toContain('NaN'); + }); + + it('drops edges whose endpoints are missing', () => { + const w = mountFlow({ defaultNodes: nodes, defaultEdges: [{ id: 'bad', source: 'a', target: 'zzz' }] }); + expect(w.find('[data-flow-edge]').exists()).toBe(false); + }); +}); + +describe('selection', () => { + it('selects a node on click and marks data-selected', async () => { + const onSel = ref<{ nodes: string[]; edges: string[] } | null>(null); + const w = track(mount(FlowRoot, { + attachTo: document.body, + props: { + defaultNodes: [{ id: 'a', position: { x: 0, y: 0 } }], + onSelectionChange: (s: { nodes: string[]; edges: string[] }) => { onSel.value = s; }, + }, + slots: { 'node-default': nodeSlot }, + })); + const node = w.find('[data-id="a"]'); + node.element.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true })); + await nextTick(); + expect(onSel.value?.nodes).toEqual(['a']); + expect(w.find('[data-id="a"]').attributes('data-selected')).toBe(''); + }); +}); + +describe('viewport v-model', () => { + it('exposes the viewport and updates on prop change', async () => { + const vp = ref({ x: 0, y: 0, zoom: 1 }); + const w = track(mount(FlowRoot, { + attachTo: document.body, + props: { viewport: vp.value, 'onUpdate:viewport': (v: Viewport) => { vp.value = v; } }, + slots: { 'node-default': nodeSlot }, + })); + await w.setProps({ viewport: { x: 100, y: 50, zoom: 1.5 } }); + await nextTick(); + const t = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform; + expect(t).toContain('translate(100px, 50px)'); + }); +}); diff --git a/vue/primitives/src/canvas/flow/__test__/Handles.test.ts b/vue/primitives/src/canvas/flow/__test__/Handles.test.ts new file mode 100644 index 0000000..618dff4 --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/Handles.test.ts @@ -0,0 +1,77 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { h, nextTick } from 'vue'; +import { FlowHandle, FlowRoot } from '../index'; +import type { FlowEdge, FlowNode } from '../index'; + +const wrappers: Array> = []; +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); +function track>(w: T): T { + wrappers.push(w); + return w; +} + +const nodeWithHandles = () => [ + h('div', { class: 'body' }, 'node'), + h(FlowHandle, { type: 'target', position: 'left', id: 'in' }), + h(FlowHandle, { type: 'source', position: 'right', id: 'out' }), +]; + +describe('FlowHandle', () => { + const nodes: FlowNode[] = [ + { id: 'a', position: { x: 0, y: 0 } }, + { id: 'b', position: { x: 300, y: 0 } }, + ]; + + it('renders handles with the right data attributes and side positioning', () => { + const w = track(mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: nodes }, + slots: { 'node-default': nodeWithHandles }, + })); + const source = w.find('[data-flow-handle][data-handletype="source"]'); + const target = w.find('[data-flow-handle][data-handletype="target"]'); + expect(source.exists()).toBe(true); + expect(target.exists()).toBe(true); + expect(source.attributes('data-handlepos')).toBe('right'); + expect(source.attributes('data-handleid')).toBe('out'); + expect((source.element as HTMLElement).style.position).toBe('absolute'); + }); + + it('measures handle bounds so handle ids are present in the DOM for hit-testing', async () => { + const w = track(mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: nodes }, + slots: { 'node-default': nodeWithHandles }, + })); + await nextTick(); + expect(w.findAll('[data-handleid]').length).toBeGreaterThanOrEqual(4); + }); +}); + +describe('edge markers', () => { + it('renders a deduped marker def and references it from the path', async () => { + const nodes: FlowNode[] = [ + { id: 'a', position: { x: 0, y: 0 } }, + { id: 'b', position: { x: 200, y: 0 } }, + ]; + const edges: FlowEdge[] = [ + { id: 'e1', source: 'a', target: 'b', markerEnd: { type: 'arrowclosed', color: '#333' } }, + { id: 'e2', source: 'b', target: 'a', markerEnd: { type: 'arrowclosed', color: '#333' } }, + ]; + const w = track(mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: nodes, defaultEdges: edges }, + slots: { 'node-default': () => h('div', 'n') }, + })); + await nextTick(); + // identical markers dedupe to a single + expect(w.findAll('marker')).toHaveLength(1); + const path = w.find('[data-flow-edge-path]'); + expect(path.attributes('marker-end')).toMatch(/^url\(#/); + }); +}); diff --git a/vue/primitives/src/canvas/flow/__test__/Interactions.test.ts b/vue/primitives/src/canvas/flow/__test__/Interactions.test.ts new file mode 100644 index 0000000..6a93647 --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/Interactions.test.ts @@ -0,0 +1,95 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; +import { FlowRoot, useFlow } from '../index'; +import type { FlowNode, UseFlowReturn } from '../index'; + +const wrappers: Array> = []; +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; + delete (globalThis as any).__api; +}); +function track>(w: T): T { + wrappers.push(w); + return w; +} + +const nodes: FlowNode[] = [ + { id: 'a', position: { x: 0, y: 0 } }, + { id: 'b', position: { x: 200, y: 0 } }, + { id: 'c', position: { x: 0, y: 200 } }, +]; + +function key(el: Element, k: string, opts: KeyboardEventInit = {}) { + el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...opts })); +} + +describe('keyboard', () => { + it('select-all then delete removes all selected nodes', async () => { + const events: Array<{ nodes: string[] }> = []; + const w = track(mount(FlowRoot, { + attachTo: document.body, + props: { + defaultNodes: nodes, + onSelectionChange: (s: { nodes: string[] }) => events.push(s), + onNodesChange: () => {}, + }, + slots: { 'node-default': () => h('div', 'n') }, + })); + const pane = w.find('[data-flow-pane]').element; + + key(pane, 'a', { metaKey: true }); + await nextTick(); + expect(events.at(-1)?.nodes.sort()).toEqual(['a', 'b', 'c']); + + key(pane, 'Delete'); + await nextTick(); + expect(w.findAll('[data-flow-node]')).toHaveLength(0); + }); + + it('arrow keys nudge the selected node', async () => { + const changes: any[] = []; + const w = track(mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: nodes, onNodesChange: (c: any) => changes.push(...c) }, + slots: { 'node-default': () => h('div', 'n') }, + })); + const pane = w.find('[data-flow-pane]').element; + w.find('[data-id="a"]').element.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true })); + await nextTick(); + key(pane, 'ArrowRight'); + await nextTick(); + const move = changes.find(c => c.type === 'position' && c.id === 'a'); + expect(move?.position.x).toBe(5); + }); +}); + +describe('useFlow imperative API', () => { + const Capture = defineComponent({ + setup() { + (globalThis as any).__api = useFlow(); + return () => null; + }, + }); + + it('exposes nodes and drives the viewport', async () => { + track(mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: nodes }, + slots: { + 'node-default': () => h('div', 'n'), + default: () => h(Capture), + }, + })); + await nextTick(); + const api = (globalThis as any).__api as UseFlowReturn; + expect(api.getNodes()).toHaveLength(3); + expect(api.getNode('a')?.id).toBe('a'); + + const before = api.getViewport().zoom; + api.zoomIn(); + expect(api.getViewport().zoom).toBeGreaterThan(before); + }); +}); diff --git a/vue/primitives/src/scroll-area/__test__/__screenshots__/ScrollArea.a11y.test.ts/scroll-area---scrollbar-keyboard-support-keydown-handler-does-not-call-preventDefault-when-scrollbar-is-non-interactive-1.png b/vue/primitives/src/canvas/flow/__test__/__screenshots__/Advanced.test.ts/FlowNodeToolbar-teleports-a-toolbar-to-the-body-only-while-the-node-is-selected-1.png similarity index 100% rename from vue/primitives/src/scroll-area/__test__/__screenshots__/ScrollArea.a11y.test.ts/scroll-area---scrollbar-keyboard-support-keydown-handler-does-not-call-preventDefault-when-scrollbar-is-non-interactive-1.png rename to vue/primitives/src/canvas/flow/__test__/__screenshots__/Advanced.test.ts/FlowNodeToolbar-teleports-a-toolbar-to-the-body-only-while-the-node-is-selected-1.png diff --git a/vue/primitives/src/scroll-area/__test__/__screenshots__/ScrollArea.a11y.test.ts/scroll-area---scrollbar-keyboard-support-keydown-handler-is-a-no-op-when-content-does-not-overflow-1.png b/vue/primitives/src/canvas/flow/__test__/__screenshots__/Chrome.test.ts/FlowMiniMap-renders-a-rect-per-node-plus-a-viewport-mask-1.png similarity index 100% rename from vue/primitives/src/scroll-area/__test__/__screenshots__/ScrollArea.a11y.test.ts/scroll-area---scrollbar-keyboard-support-keydown-handler-is-a-no-op-when-content-does-not-overflow-1.png rename to vue/primitives/src/canvas/flow/__test__/__screenshots__/Chrome.test.ts/FlowMiniMap-renders-a-rect-per-node-plus-a-viewport-mask-1.png diff --git a/vue/primitives/src/canvas/flow/__test__/__screenshots__/Connect.test.ts/connection-creation--regression--connecting-did-nothing--drags-from-a-source-handle-to-a-target-handle-and-emits--connect---adds-the-edge-1.png b/vue/primitives/src/canvas/flow/__test__/__screenshots__/Connect.test.ts/connection-creation--regression--connecting-did-nothing--drags-from-a-source-handle-to-a-target-handle-and-emits--connect---adds-the-edge-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/flow/__test__/__screenshots__/Connect.test.ts/connection-creation--regression--connecting-did-nothing--drags-from-a-source-handle-to-a-target-handle-and-emits--connect---adds-the-edge-1.png differ diff --git a/vue/primitives/src/canvas/flow/__test__/changes.test.ts b/vue/primitives/src/canvas/flow/__test__/changes.test.ts new file mode 100644 index 0000000..1ff5dcd --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/changes.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import type { FlowEdge, FlowNode } from '../types'; +import { addEdge, applyEdgeChanges, applyNodeChanges } from '../changes'; + +const nodes: FlowNode[] = [ + { id: 'a', position: { x: 0, y: 0 } }, + { id: 'b', position: { x: 10, y: 10 } }, +]; + +describe('applyNodeChanges', () => { + it('returns the same array reference for no changes', () => { + expect(applyNodeChanges([], nodes)).toBe(nodes); + }); + + it('applies position changes without touching other nodes', () => { + const out = applyNodeChanges([{ type: 'position', id: 'a', position: { x: 99, y: 99 } }], nodes); + expect(out[0]!.position).toEqual({ x: 99, y: 99 }); + expect(out[1]).toBe(nodes[1]); // identity preserved for untouched node + }); + + it('removes nodes', () => { + const out = applyNodeChanges([{ type: 'remove', id: 'a' }], nodes); + expect(out.map(n => n.id)).toEqual(['b']); + }); + + it('adds a node at an index', () => { + const out = applyNodeChanges([{ type: 'add', item: { id: 'c', position: { x: 0, y: 0 } }, index: 1 }], nodes); + expect(out.map(n => n.id)).toEqual(['a', 'c', 'b']); + }); + + it('toggles selection', () => { + const out = applyNodeChanges([{ type: 'select', id: 'b', selected: true }], nodes); + expect(out[1]!.selected).toBe(true); + }); + + it('applies dimensions', () => { + const out = applyNodeChanges([{ type: 'dimensions', id: 'a', dimensions: { width: 120, height: 60 } }], nodes); + expect(out[0]).toMatchObject({ width: 120, height: 60 }); + }); +}); + +describe('applyEdgeChanges', () => { + const edges: FlowEdge[] = [{ id: 'e1', source: 'a', target: 'b' }]; + it('removes and selects edges', () => { + expect(applyEdgeChanges([{ type: 'remove', id: 'e1' }], edges)).toHaveLength(0); + expect(applyEdgeChanges([{ type: 'select', id: 'e1', selected: true }], edges)[0]!.selected).toBe(true); + }); +}); + +describe('addEdge', () => { + const edges: FlowEdge[] = [{ id: 'xy-edge__as-bt', source: 'a', target: 'b', sourceHandle: 's', targetHandle: 't' }]; + it('appends a connection as an edge', () => { + const out = addEdge({ source: 'a', target: 'c', sourceHandle: null, targetHandle: null }, edges); + expect(out).toHaveLength(2); + expect(out[1]!.source).toBe('a'); + expect(out[1]!.target).toBe('c'); + }); + + it('skips duplicates by id', () => { + const out = addEdge({ source: 'a', target: 'b', sourceHandle: 's', targetHandle: 't' }, edges); + expect(out).toBe(edges); + }); +}); diff --git a/vue/primitives/src/canvas/flow/__test__/connection.test.ts b/vue/primitives/src/canvas/flow/__test__/connection.test.ts new file mode 100644 index 0000000..52270aa --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/connection.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import type { FlowEdge, HandleBound, InternalNode } from '../types'; +import { buildConnection, connectionToEdgeId, findClosestHandle, isValidConnection } from '../connection'; + +function handle(id: string | null, type: 'source' | 'target', x: number, y: number): HandleBound { + return { id, type, position: type === 'source' ? 'right' : 'left', x, y, width: 0, height: 0 }; +} + +function node(id: string, x: number, y: number, handles: HandleBound[]): InternalNode { + const bounds = { source: handles.filter(h => h.type === 'source'), target: handles.filter(h => h.type === 'target') }; + return { + id, + position: { x, y }, + measured: { width: 100, height: 40 }, + positionAbsolute: { x, y }, + handleBounds: bounds, + }; +} + +function lookup(...nodes: InternalNode[]): Map { + return new Map(nodes.map(n => [n.id, n])); +} + +describe('findClosestHandle', () => { + const map = lookup( + node('a', 0, 0, [handle('s', 'source', 100, 20)]), + node('b', 200, 0, [handle('t', 'target', 0, 20)]), + ); + + it('finds the opposite-type handle within radius (strict)', () => { + // dragging from a's source; target b's target at (200,20) + const hit = findClosestHandle({ x: 205, y: 22 }, map, 'source', 'a', 's', 'strict', 30); + expect(hit?.nodeId).toBe('b'); + expect(hit?.handle.id).toBe('t'); + }); + + it('returns null when nothing is within radius', () => { + expect(findClosestHandle({ x: 500, y: 500 }, map, 'source', 'a', 's', 'strict', 30)).toBeNull(); + }); + + it('excludes the same node in strict mode', () => { + const self = lookup(node('a', 0, 0, [handle('s', 'source', 100, 20), handle('t', 'target', 0, 20)])); + expect(findClosestHandle({ x: 0, y: 20 }, self, 'source', 'a', 's', 'strict', 30)).toBeNull(); + }); + + it('allows other handle types on any node in loose mode', () => { + const m = lookup(node('a', 0, 0, [handle('s', 'source', 100, 20)]), node('b', 110, 20, [handle('s2', 'source', 0, 0)])); + const hit = findClosestHandle({ x: 110, y: 20 }, m, 'source', 'a', 's', 'loose', 30); + expect(hit?.nodeId).toBe('b'); + }); +}); + +describe('isValidConnection', () => { + const edges: FlowEdge[] = [{ id: 'e', source: 'a', target: 'b', sourceHandle: 's', targetHandle: 't' }]; + + it('rejects self-connections', () => { + expect(isValidConnection({ source: 'a', target: 'a', sourceHandle: null, targetHandle: null }, [])).toBe(false); + }); + + it('rejects duplicate edges', () => { + expect(isValidConnection({ source: 'a', target: 'b', sourceHandle: 's', targetHandle: 't' }, edges)).toBe(false); + }); + + it('accepts a new distinct connection', () => { + expect(isValidConnection({ source: 'a', target: 'c', sourceHandle: null, targetHandle: null }, edges)).toBe(true); + }); + + it('defers to a custom predicate', () => { + const conn = { source: 'a', target: 'c', sourceHandle: null, targetHandle: null }; + expect(isValidConnection(conn, edges, () => false)).toBe(false); + expect(isValidConnection(conn, edges, c => c.target === 'c')).toBe(true); + }); +}); + +describe('buildConnection', () => { + const target = { nodeId: 'b', handle: handle('t', 'target', 0, 0), point: { x: 0, y: 0 } }; + + it('keeps source/target orientation when dragging from a source', () => { + const conn = buildConnection('source', 'a', handle('s', 'source', 0, 0), target); + expect(conn).toEqual({ source: 'a', target: 'b', sourceHandle: 's', targetHandle: 't' }); + }); + + it('inverts orientation when dragging from a target', () => { + const src = { nodeId: 'b', handle: handle('s', 'source', 0, 0), point: { x: 0, y: 0 } }; + const conn = buildConnection('target', 'a', handle('t', 'target', 0, 0), src); + expect(conn).toEqual({ source: 'b', target: 'a', sourceHandle: 's', targetHandle: 't' }); + }); +}); + +describe('connectionToEdgeId', () => { + it('is deterministic', () => { + const conn = { source: 'a', target: 'b', sourceHandle: 's', targetHandle: 't' }; + expect(connectionToEdgeId(conn)).toBe(connectionToEdgeId({ ...conn })); + }); +}); diff --git a/vue/primitives/src/canvas/flow/__test__/edge-paths.test.ts b/vue/primitives/src/canvas/flow/__test__/edge-paths.test.ts new file mode 100644 index 0000000..afed8b2 --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/edge-paths.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { + getBezierPath, + getEdgeCenter, + getMarkerId, + getSmoothStepPath, + getStepPath, + getStraightPath, +} from '../edge-paths'; + +describe('getStraightPath', () => { + it('draws a line and reports the midpoint', () => { + const [path, labelX, labelY, ox, oy] = getStraightPath({ sourceX: 0, sourceY: 0, targetX: 100, targetY: 50 }); + expect(path).toBe('M 0,0 L 100,50'); + expect([labelX, labelY]).toEqual([50, 25]); + expect([ox, oy]).toEqual([50, 25]); + }); +}); + +describe('getEdgeCenter', () => { + it('returns centre and absolute offset', () => { + expect(getEdgeCenter({ sourceX: 0, sourceY: 0, targetX: 40, targetY: -20 })).toEqual([20, -10, 20, 10]); + }); +}); + +describe('getBezierPath', () => { + it('produces a cubic with control points', () => { + const [path, labelX, labelY] = getBezierPath({ sourceX: 0, sourceY: 0, sourcePosition: 'bottom', targetX: 0, targetY: 200, targetPosition: 'top' }); + expect(path.startsWith('M 0,0 C')).toBe(true); + expect(Number.isFinite(labelX)).toBe(true); + expect(labelY).toBeCloseTo(100, 6); + }); + + it('does not collapse to NaN when the target is behind the source (sqrt fallback)', () => { + const [path] = getBezierPath({ sourceX: 0, sourceY: 0, sourcePosition: 'bottom', targetX: 0, targetY: -200, targetPosition: 'top' }); + expect(path).not.toContain('NaN'); + }); +}); + +describe('getSmoothStepPath', () => { + it('produces an orthogonal path with rounded corners', () => { + const [path, labelX, labelY] = getSmoothStepPath({ sourceX: 0, sourceY: 0, sourcePosition: 'bottom', targetX: 200, targetY: 200, targetPosition: 'top' }); + expect(path.startsWith('M ')).toBe(true); + expect(path).toContain('Q'); // at least one rounded corner + expect(path).not.toContain('NaN'); + expect(Number.isFinite(labelX) && Number.isFinite(labelY)).toBe(true); + }); + + it('clamps the corner radius and never emits NaN for close handles', () => { + const [path] = getSmoothStepPath({ sourceX: 0, sourceY: 0, sourcePosition: 'right', targetX: 5, targetY: 5, targetPosition: 'left', borderRadius: 100 }); + expect(path).not.toContain('NaN'); + }); +}); + +describe('getStepPath', () => { + it('is a smooth-step path with zero radius (no curve)', () => { + const [path] = getStepPath({ sourceX: 0, sourceY: 0, sourcePosition: 'bottom', targetX: 200, targetY: 200, targetPosition: 'top' }); + expect(path).not.toContain('NaN'); + expect(path.startsWith('M ')).toBe(true); + }); +}); + +describe('getMarkerId', () => { + it('is deterministic for identical descriptors (dedupe key)', () => { + const a = getMarkerId({ type: 'arrowclosed', color: '#222' }, 'flow1'); + const b = getMarkerId({ type: 'arrowclosed', color: '#222' }, 'flow1'); + expect(a).toBe(b); + }); + + it('differs for different markers or flows', () => { + expect(getMarkerId({ type: 'arrow' }, 'flow1')).not.toBe(getMarkerId({ type: 'arrowclosed' }, 'flow1')); + expect(getMarkerId({ type: 'arrow' }, 'flow1')).not.toBe(getMarkerId({ type: 'arrow' }, 'flow2')); + }); +}); diff --git a/vue/primitives/src/canvas/flow/__test__/interaction-state.test.ts b/vue/primitives/src/canvas/flow/__test__/interaction-state.test.ts new file mode 100644 index 0000000..0eb110c --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/interaction-state.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, shallowRef } from 'vue'; +import type { Viewport } from '../types'; +import { useInteractionState } from '../composables/useInteractionState'; + +describe('useInteractionState', () => { + it('flips true on a viewport change and back to false after the idle delay', async () => { + vi.useFakeTimers(); + try { + const viewport = shallowRef({ x: 0, y: 0, zoom: 1 }); + const scope = effectScope(); + const interacting = scope.run(() => useInteractionState(() => viewport.value, 200))!; + + expect(interacting.value).toBe(false); + + viewport.value = { x: 10, y: 0, zoom: 1.2 }; + await nextTick(); + expect(interacting.value).toBe(true); + + // Still interacting before the idle delay elapses. + vi.advanceTimersByTime(150); + expect(interacting.value).toBe(true); + + // A further change (continuous zoom) resets the idle countdown. + viewport.value = { x: 20, y: 0, zoom: 1.5 }; + await nextTick(); + vi.advanceTimersByTime(150); + expect(interacting.value).toBe(true); + + // Settles to false once motion stops for the full delay. + vi.advanceTimersByTime(200); + expect(interacting.value).toBe(false); + + scope.stop(); + } + finally { + vi.useRealTimers(); + } + }); + + it('clears its pending timer on scope dispose so it never resolves late', async () => { + vi.useFakeTimers(); + try { + const viewport = shallowRef({ x: 0, y: 0, zoom: 1 }); + const scope = effectScope(); + const interacting = scope.run(() => useInteractionState(() => viewport.value, 200))!; + + viewport.value = { x: 5, y: 5, zoom: 1 }; + await nextTick(); + expect(interacting.value).toBe(true); + + scope.stop(); + // The pending idle timer was cleared on dispose: advancing past it must + // not flip the (now detached) ref. + vi.advanceTimersByTime(500); + expect(interacting.value).toBe(true); + } + finally { + vi.useRealTimers(); + } + }); +}); diff --git a/vue/primitives/src/canvas/flow/__test__/utils.test.ts b/vue/primitives/src/canvas/flow/__test__/utils.test.ts new file mode 100644 index 0000000..408f663 --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/utils.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import type { InternalNode, Viewport } from '../types'; +import { + fitViewTransform, + flowToScreen, + getAbsoluteHandlePoint, + getNodePositionAbsolute, + getNodesBounds, + getNodesInsideRect, + nodeInRect, + rectContains, + rectsIntersect, + screenToFlow, + snapPoint, + visibleFlowRect, + zoomAtPointer, +} from '../utils'; + +const ORIGIN = { left: 0, top: 0 }; + +function node(id: string, x: number, y: number, w = 100, h = 50, parentId?: string): InternalNode { + return { + id, + position: { x, y }, + parentId, + measured: { width: w, height: h }, + positionAbsolute: { x, y }, + handleBounds: null, + }; +} + +describe('screenToFlow / flowToScreen', () => { + it('are exact inverses at zoom 1', () => { + const vp: Viewport = { x: 30, y: -10, zoom: 1 }; + const p = { x: 123, y: 456 }; + const flow = screenToFlow(p, vp, ORIGIN); + expect(flowToScreen(flow, vp, ORIGIN)).toEqual(p); + }); + + it('are exact inverses at zoom != 1 and with a pane origin', () => { + const vp: Viewport = { x: 200, y: 80, zoom: 1.5 }; + const origin = { left: 64, top: 40 }; + const p = { x: 512, y: 300 }; + const flow = screenToFlow(p, vp, origin); + const back = flowToScreen(flow, vp, origin); + expect(back.x).toBeCloseTo(p.x, 6); + expect(back.y).toBeCloseTo(p.y, 6); + }); + + it('divides out zoom and translation', () => { + const vp: Viewport = { x: 100, y: 50, zoom: 2 }; + expect(screenToFlow({ x: 100, y: 50 }, vp, ORIGIN)).toEqual({ x: 0, y: 0 }); + expect(screenToFlow({ x: 300, y: 250 }, vp, ORIGIN)).toEqual({ x: 100, y: 100 }); + }); +}); + +describe('zoomAtPointer', () => { + it('keeps the pointer anchored while zooming', () => { + const vp: Viewport = { x: 0, y: 0, zoom: 1 }; + const pointer = { x: 400, y: 300 }; + const next = zoomAtPointer(vp, pointer, 2); + // The flow point under the pointer must be identical before and after. + const before = screenToFlow(pointer, vp, ORIGIN); + const after = screenToFlow(pointer, next, ORIGIN); + expect(after.x).toBeCloseTo(before.x, 6); + expect(after.y).toBeCloseTo(before.y, 6); + expect(next.zoom).toBe(2); + }); +}); + +describe('snapPoint', () => { + it('rounds to the nearest grid intersection', () => { + expect(snapPoint({ x: 23, y: 47 }, [10, 10])).toEqual({ x: 20, y: 50 }); + expect(snapPoint({ x: 26, y: 41 }, [10, 20])).toEqual({ x: 30, y: 40 }); + }); + + it('passes through on a zero grid axis', () => { + expect(snapPoint({ x: 7, y: 9 }, [0, 0])).toEqual({ x: 7, y: 9 }); + }); +}); + +describe('getNodesBounds', () => { + it('encloses all nodes', () => { + const bounds = getNodesBounds([node('a', 0, 0, 100, 50), node('b', 200, 100, 50, 50)]); + expect(bounds).toEqual({ x: 0, y: 0, width: 250, height: 150 }); + }); + + it('returns a zero rect when empty', () => { + expect(getNodesBounds([])).toEqual({ x: 0, y: 0, width: 0, height: 0 }); + }); +}); + +describe('fitViewTransform', () => { + it('centres known bounds in the container', () => { + const bounds = { x: 0, y: 0, width: 100, height: 100 }; + const vp = fitViewTransform(bounds, { width: 400, height: 400 }, { padding: 0, minZoom: 0.1, maxZoom: 4 }); + expect(vp.zoom).toBeCloseTo(4, 6); + // center of bounds (50,50) maps to container center (200,200) + const center = flowToScreen({ x: 50, y: 50 }, vp, ORIGIN); + expect(center.x).toBeCloseTo(200, 6); + expect(center.y).toBeCloseTo(200, 6); + }); + + it('respects maxZoom', () => { + const vp = fitViewTransform({ x: 0, y: 0, width: 10, height: 10 }, { width: 1000, height: 1000 }, { padding: 0, minZoom: 0.5, maxZoom: 2 }); + expect(vp.zoom).toBe(2); + }); +}); + +describe('rect predicates', () => { + const a = { x: 0, y: 0, width: 100, height: 100 }; + it('detects intersection and containment', () => { + expect(rectsIntersect(a, { x: 50, y: 50, width: 100, height: 100 })).toBe(true); + expect(rectsIntersect(a, { x: 200, y: 0, width: 10, height: 10 })).toBe(false); + expect(rectContains(a, { x: 10, y: 10, width: 20, height: 20 })).toBe(true); + expect(rectContains(a, { x: 90, y: 90, width: 20, height: 20 })).toBe(false); + }); + + it('nodeInRect honours partial vs full', () => { + const n = node('n', 80, 80, 40, 40); // overlaps a but not contained + expect(nodeInRect(n, a, 'partial')).toBe(true); + expect(nodeInRect(n, a, 'full')).toBe(false); + }); + + it('getNodesInsideRect collects ids and skips hidden', () => { + const hidden = { ...node('h', 10, 10), hidden: true }; + const ids = getNodesInsideRect([node('a', 10, 10), node('b', 500, 500), hidden], a, 'partial'); + expect(ids).toEqual(['a']); + }); +}); + +describe('visibleFlowRect', () => { + it('expands the visible area by the buffer in flow space', () => { + const rect = visibleFlowRect({ x: 0, y: 0, zoom: 1 }, { width: 800, height: 600 }, 100); + expect(rect).toEqual({ x: -100, y: -100, width: 1000, height: 800 }); + }); +}); + +describe('getNodePositionAbsolute', () => { + it('sums the parent chain', () => { + const lookup = new Map(); + lookup.set('parent', node('parent', 100, 100)); + lookup.set('child', node('child', 10, 20, 100, 50, 'parent')); + const abs = getNodePositionAbsolute(lookup.get('child')!, lookup); + expect(abs).toEqual({ x: 110, y: 120 }); + }); +}); + +describe('getAbsoluteHandlePoint', () => { + it('returns the handle centre in absolute flow coords', () => { + const point = getAbsoluteHandlePoint({ x: 100, y: 100 }, { id: null, type: 'source', position: 'right', x: 100, y: 25, width: 10, height: 10 }); + expect(point).toEqual({ x: 205, y: 130 }); + }); +}); diff --git a/vue/primitives/src/canvas/flow/__test__/virtualization.test.ts b/vue/primitives/src/canvas/flow/__test__/virtualization.test.ts new file mode 100644 index 0000000..30f55f7 --- /dev/null +++ b/vue/primitives/src/canvas/flow/__test__/virtualization.test.ts @@ -0,0 +1,62 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { h, nextTick } from 'vue'; +import { getVisibleEdgeIds, getVisibleNodeIds } from '../virtualization'; +import { FlowRoot } from '../index'; +import type { FlowEdge, FlowNode, InternalNode } from '../index'; + +function internal(id: string, x: number, y: number, w = 100, h = 50): InternalNode { + return { id, position: { x, y }, measured: { width: w, height: h }, positionAbsolute: { x, y }, handleBounds: null }; +} + +describe('getVisibleNodeIds', () => { + const lookup = new Map([ + ['a', internal('a', 0, 0)], + ['b', internal('b', 5000, 5000)], + ]); + const nodes = [...lookup.values()]; + + it('keeps nodes intersecting the rect and culls the rest', () => { + const rect = { x: -100, y: -100, width: 800, height: 600 }; + expect(getVisibleNodeIds(nodes, lookup, rect)).toEqual(['a']); + }); + + it('keeps unmeasured nodes (no internal entry) so they are not flicker-culled', () => { + const ids = getVisibleNodeIds([{ id: 'z', position: { x: 9999, y: 9999 } } as FlowNode], new Map(), { x: 0, y: 0, width: 10, height: 10 }); + expect(ids).toEqual(['z']); + }); +}); + +describe('getVisibleEdgeIds', () => { + it('keeps edges with at least one visible endpoint', () => { + const edges: FlowEdge[] = [ + { id: 'e1', source: 'a', target: 'b' }, + { id: 'e2', source: 'b', target: 'c' }, + ]; + expect(getVisibleEdgeIds(edges, new Set(['a']))).toEqual(['e1']); + }); +}); + +const wrappers: Array> = []; +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +describe('FlowRoot onlyRenderVisibleElements', () => { + it('renders all nodes by default', async () => { + const nodes: FlowNode[] = [ + { id: 'a', position: { x: 0, y: 0 } }, + { id: 'far', position: { x: 100000, y: 100000 } }, + ]; + const w = mount(FlowRoot, { + attachTo: document.body, + props: { defaultNodes: nodes }, + slots: { 'node-default': () => h('div', 'n') }, + }); + wrappers.push(w); + await nextTick(); + expect(w.findAll('[data-flow-node]')).toHaveLength(2); + }); +}); diff --git a/vue/primitives/src/canvas/flow/changes.ts b/vue/primitives/src/canvas/flow/changes.ts new file mode 100644 index 0000000..f0683e0 --- /dev/null +++ b/vue/primitives/src/canvas/flow/changes.ts @@ -0,0 +1,111 @@ +import type { Connection, EdgeChange, FlowEdge, FlowNode, NodeChange } from './types'; +import { connectionToEdgeId } from './connection'; + +/** + * Apply a batch of node changes to a nodes array, returning a NEW array (and new + * objects only for changed nodes — untouched nodes keep identity so per-node + * `v-memo` stays effective). The React-Flow-style helper for controlled mode: + * bind `@nodes-change` and call this in your handler. + */ +export function applyNodeChanges(changes: Array>, nodes: Array>): Array> { + if (changes.length === 0) return nodes; + + const byId = new Map(nodes.map(n => [n.id, n])); + const removed = new Set(); + const added: Array<{ item: FlowNode; index?: number }> = []; + + for (const c of changes) { + switch (c.type) { + case 'position': { + const n = byId.get(c.id); + if (n && c.position) byId.set(c.id, { ...n, position: c.position }); + break; + } + case 'dimensions': { + const n = byId.get(c.id); + if (n) byId.set(c.id, { ...n, width: c.dimensions.width, height: c.dimensions.height }); + break; + } + case 'select': { + const n = byId.get(c.id); + if (n) byId.set(c.id, { ...n, selected: c.selected }); + break; + } + case 'remove': + removed.add(c.id); + break; + case 'add': + added.push({ item: c.item, index: c.index }); + break; + case 'replace': + if (byId.has(c.id)) byId.set(c.id, c.item); + break; + } + } + + const result = nodes + .filter(n => !removed.has(n.id)) + .map(n => byId.get(n.id) ?? n); + + for (const { item, index } of added) { + if (index === undefined || index >= result.length) result.push(item); + else result.splice(index, 0, item); + } + + return result; +} + +/** Apply a batch of edge changes to an edges array, returning a NEW array. */ +export function applyEdgeChanges(changes: Array>, edges: Array>): Array> { + if (changes.length === 0) return edges; + + const byId = new Map(edges.map(e => [e.id, e])); + const removed = new Set(); + const added: Array<{ item: FlowEdge; index?: number }> = []; + + for (const c of changes) { + switch (c.type) { + case 'select': { + const e = byId.get(c.id); + if (e) byId.set(c.id, { ...e, selected: c.selected }); + break; + } + case 'remove': + removed.add(c.id); + break; + case 'add': + added.push({ item: c.item, index: c.index }); + break; + case 'replace': + if (byId.has(c.id)) byId.set(c.id, c.item); + break; + } + } + + const result = edges + .filter(e => !removed.has(e.id)) + .map(e => byId.get(e.id) ?? e); + + for (const { item, index } of added) { + if (index === undefined || index >= result.length) result.push(item); + else result.splice(index, 0, item); + } + + return result; +} + +/** + * Append an edge for a connection (or a full edge) to an edges array, skipping + * duplicates. Returns the same array when the edge already exists. + */ +export function addEdge( + edgeOrConnection: Connection | FlowEdge, + edges: Array>, +): Array> { + const edge: FlowEdge = 'id' in edgeOrConnection + ? edgeOrConnection + : { id: connectionToEdgeId(edgeOrConnection), ...edgeOrConnection } as FlowEdge; + + if (edges.some(e => e.id === edge.id)) return edges; + return [...edges, edge]; +} diff --git a/vue/primitives/src/canvas/flow/composables/dom.ts b/vue/primitives/src/canvas/flow/composables/dom.ts new file mode 100644 index 0000000..e26b25a --- /dev/null +++ b/vue/primitives/src/canvas/flow/composables/dom.ts @@ -0,0 +1,22 @@ +/** + * `setPointerCapture` / `releasePointerCapture` throw when there is no live + * pointer for the id — which happens for synthetic events in tests and for + * pointers that were already released. These guards swallow only that case. + */ +export function capturePointer(el: HTMLElement | undefined, id: number): void { + try { + el?.setPointerCapture?.(id); + } + catch { + /* no active pointer (synthetic event) */ + } +} + +export function releasePointer(el: HTMLElement | undefined, id: number): void { + try { + el?.releasePointerCapture?.(id); + } + catch { + /* pointer already released */ + } +} diff --git a/vue/primitives/src/canvas/flow/composables/useConnection.ts b/vue/primitives/src/canvas/flow/composables/useConnection.ts new file mode 100644 index 0000000..176552b --- /dev/null +++ b/vue/primitives/src/canvas/flow/composables/useConnection.ts @@ -0,0 +1,63 @@ +import { onScopeDispose } from 'vue'; +import { useEventListener } from '@robonen/vue'; +import type { FlowContext } from '../context'; +import type { XYPosition } from '../types'; + +/** + * Drives an in-progress connection: while `connection.inProgress`, follows the + * pointer (converting to flow space and hit-testing candidate handles via + * `ctx.updateConnection`) and finalises on pointerup (`ctx.endConnection`). + * + * Listeners are bound to the window ONCE on mount (client only) and stay bound + * for the component's life — the handlers no-op unless a connection is active. + * This avoids the race of attaching them reactively when a drag starts. + * + * `updateConnection` is RAF-batched: a pointermove only stashes the latest flow + * point, and at most one hit-test (`findClosestHandle` scans every handle) + + * state write runs per frame — matching usePanZoom / useNodeDrag, instead of + * 60–120×/sec. pointerup flushes the pending point synchronously first so the + * committed connection reflects the final cursor position. + */ +export function useConnection(ctx: FlowContext): void { + let rafId: number | null = null; + let pending: XYPosition | null = null; + + function flush(): void { + rafId = null; + if (pending) { + ctx.updateConnection(pending); + pending = null; + } + } + + function onMove(event: PointerEvent): void { + if (!ctx.connection.value.inProgress) return; + pending = ctx.screenToFlow({ x: event.clientX, y: event.clientY }); + if (rafId === null) rafId = requestAnimationFrame(flush); + } + + function onUp(): void { + if (!ctx.connection.value.inProgress) return; + // Apply the last pointer position before committing. + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + flush(); + ctx.endConnection(); + } + + // Listeners stay bound to the window for the component's life and are + // auto-removed on scope dispose. SSR-safe: the window default no-ops without a + // `window`. The handlers no-op unless a connection is active. + useEventListener('pointermove', onMove); + useEventListener('pointerup', onUp); + + // `useEventListener` owns the listeners; we still cancel any pending RAF here. + onScopeDispose(() => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + }); +} diff --git a/vue/primitives/src/canvas/flow/composables/useFlow.ts b/vue/primitives/src/canvas/flow/composables/useFlow.ts new file mode 100644 index 0000000..8a455cf --- /dev/null +++ b/vue/primitives/src/canvas/flow/composables/useFlow.ts @@ -0,0 +1,40 @@ +import type { Ref, ShallowRef } from 'vue'; +import { useFlowContext } from '../context'; +import type { FlowSelection } from '../context'; +import type { Viewport } from '../types'; +import type { FlowApi } from './useViewportApi'; +import { useViewportApi } from './useViewportApi'; + +/** Everything a consumer needs to drive a flow imperatively from app code. */ +export interface UseFlowReturn extends FlowApi { + /** Reactive viewport. */ + viewport: Ref; + /** Reactive selection sets. */ + selection: ShallowRef; + selectNode: (id: string, additive?: boolean) => void; + selectEdge: (id: string, additive?: boolean) => void; + setSelection: (nodes: string[], edges: string[]) => void; + clearSelection: () => void; + removeSelected: () => void; +} + +/** + * Public consumer hook (the "useReactFlow"/"useVueFlow" equivalent). Call from + * any component inside a `FlowRoot` to read reactive state and drive the canvas: + * `fitView`, `setViewport`, `zoomIn/Out`, `screenToFlowPosition`, selection, and + * node/edge getters. Throws if used outside a `FlowRoot`. + */ +export function useFlow(): UseFlowReturn { + const ctx = useFlowContext(); + const api = useViewportApi(ctx); + return { + ...api, + viewport: ctx.viewport, + selection: ctx.selection, + selectNode: ctx.selectNode, + selectEdge: ctx.selectEdge, + setSelection: ctx.setSelection, + clearSelection: ctx.clearSelection, + removeSelected: ctx.removeSelected, + }; +} diff --git a/vue/primitives/src/canvas/flow/composables/useInteractionState.ts b/vue/primitives/src/canvas/flow/composables/useInteractionState.ts new file mode 100644 index 0000000..7bfefe0 --- /dev/null +++ b/vue/primitives/src/canvas/flow/composables/useInteractionState.ts @@ -0,0 +1,40 @@ +import type { Ref, WatchSource } from 'vue'; +import { onScopeDispose, shallowRef, watch } from 'vue'; +import type { Viewport } from '../types'; + +/** + * True while the viewport is actively panning/zooming, flipped back to `false` + * `idleDelay` ms after the last change. Driven purely by viewport mutations, so + * it covers every path — wheel, pinch, drag-pan, double-click and the imperative + * `zoomIn`/`zoomTo`/`fitView` API — without each call site having to opt in. + * + * Used to gate `will-change: transform` on `FlowViewport`. Keeping that hint on + * permanently pins the compositor's raster scale: the layer is rasterised once + * and the `scale(zoom)` is applied by GPU-upscaling that cached texture, so at + * high zoom every node and edge stays blurry even after the gesture ends. + * Toggling the hint on only while interacting lets the browser re-rasterise the + * layer crisply once motion settles — the same pattern `FlowNode` already uses + * for its drag transform. + */ +export function useInteractionState( + source: WatchSource, + idleDelay = 200, +): Readonly> { + const interacting = shallowRef(false); + let timer: ReturnType | undefined; + + watch(source, () => { + interacting.value = true; + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(() => { + interacting.value = false; + timer = undefined; + }, idleDelay); + }); + + onScopeDispose(() => { + if (timer !== undefined) clearTimeout(timer); + }); + + return interacting; +} diff --git a/vue/primitives/src/canvas/flow/composables/useKeyboard.ts b/vue/primitives/src/canvas/flow/composables/useKeyboard.ts new file mode 100644 index 0000000..da2adf0 --- /dev/null +++ b/vue/primitives/src/canvas/flow/composables/useKeyboard.ts @@ -0,0 +1,107 @@ +import type { Ref } from 'vue'; +import { useEventListener } from '@robonen/vue'; +import type { FlowContext } from '../context'; +import type { XYPosition } from '../types'; +import type { FlowApi } from './useViewportApi'; + +export interface KeyboardOptions { + /** Keys that delete the selection. @default ['Backspace', 'Delete'] */ + deleteKeyCode?: string[]; + /** Pixel step for arrow-key node nudging. @default 5 */ + nudgeStep?: number; +} + +const EDITABLE = /^(?:input|textarea|select)$/i; + +/** True when focus is in a text field, so global shortcuts must stand down. */ +function isTyping(): boolean { + const el = document.activeElement as HTMLElement | null; + return !!el && (EDITABLE.test(el.tagName) || el.isContentEditable); +} + +/** + * Canvas keyboard layer (scoped to the pane, suppressed while typing): Delete / + * Backspace removes the selection, ⌘/Ctrl+A selects all, Arrows nudge selected + * nodes (Shift = ×4), ⌘/Ctrl +/-/0 zoom-in/out/fit, Escape clears selection and + * cancels an in-progress connection. Disabled via `disableKeyboardA11y`. + */ +export function useKeyboard( + target: Ref, + ctx: FlowContext, + api: FlowApi, + options: KeyboardOptions = {}, +): void { + const deleteKeys = options.deleteKeyCode ?? ['Backspace', 'Delete']; + const step = options.nudgeStep ?? 5; + + function nudge(dx: number, dy: number): void { + const sel = ctx.selection.value.nodes; + if (sel.size === 0) return; + const moves = new Map(); + for (const id of sel) { + const node = ctx.nodeLookup.value.get(id); + if (node && node.draggable !== false) + moves.set(id, { x: node.position.x + dx, y: node.position.y + dy }); + } + if (moves.size === 0) return; + ctx.updateNodePositions(moves, false); + ctx.commitNodeDrag(); + } + + useEventListener(target, 'keydown', (event: KeyboardEvent) => { + if (ctx.disableKeyboardA11y.value || !ctx.interactive.value || isTyping()) return; + const mod = event.metaKey || event.ctrlKey; + + if (deleteKeys.includes(event.key)) { + event.preventDefault(); + ctx.removeSelected(); + return; + } + + if (mod && (event.key === 'a' || event.key === 'A')) { + event.preventDefault(); + ctx.setSelection( + [...ctx.nodeLookup.value.keys()], + ctx.elementsSelectable.value ? [...ctx.edgeLookup.value.keys()] : [], + ); + return; + } + + if (event.key === 'Escape') { + ctx.endConnection(); + ctx.clearSelection(); + return; + } + + if (mod) { + if (event.key === '=' || event.key === '+') { + event.preventDefault(); + api.zoomIn(); + return; + } + if (event.key === '-') { + event.preventDefault(); + api.zoomOut(); + return; + } + if (event.key === '0') { + event.preventDefault(); + api.fitView(); + return; + } + } + + const dist = event.shiftKey ? step * 4 : step; + const arrows: Record = { + ArrowUp: { x: 0, y: -dist }, + ArrowDown: { x: 0, y: dist }, + ArrowLeft: { x: -dist, y: 0 }, + ArrowRight: { x: dist, y: 0 }, + }; + const delta = arrows[event.key]; + if (delta) { + event.preventDefault(); + nudge(delta.x, delta.y); + } + }); +} diff --git a/vue/primitives/src/canvas/flow/composables/useMarquee.ts b/vue/primitives/src/canvas/flow/composables/useMarquee.ts new file mode 100644 index 0000000..9e157ef --- /dev/null +++ b/vue/primitives/src/canvas/flow/composables/useMarquee.ts @@ -0,0 +1,84 @@ +import type { Ref } from 'vue'; +import { shallowRef } from 'vue'; +import { useEventListener } from '@robonen/vue'; +import type { FlowContext } from '../context'; +import { getNodesInsideRect } from '../utils'; +import { capturePointer, releasePointer } from './dom'; + +/** A marquee rectangle in pane-relative pixels, for rendering the overlay. */ +export interface MarqueeRect { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Shift + drag on the empty pane draws a selection rectangle and selects the + * nodes it covers on release (`selectionMode` decides partial vs full). Pan is + * suppressed while Shift is held (see `usePanZoom`), so the two never fight. + * Returns the live rect for `FlowPane` to render. + */ +export function useMarquee( + target: Ref, + ctx: FlowContext, +): { active: Readonly>; rect: Readonly> } { + const active = shallowRef(false); + const rect = shallowRef(null); + + let pointerId = -1; + let startClientX = 0; + let startClientY = 0; + + function paneRelative(clientX: number, clientY: number): { x: number; y: number } { + const pane = ctx.paneRect.value; + return { x: clientX - pane.left, y: clientY - pane.top }; + } + + useEventListener(target, 'pointerdown', (event: PointerEvent) => { + if (event.button !== 0 || !event.shiftKey) return; + if (!ctx.interactive.value || !ctx.elementsSelectable.value) return; + pointerId = event.pointerId; + startClientX = event.clientX; + startClientY = event.clientY; + active.value = true; + rect.value = { ...paneRelative(event.clientX, event.clientY), width: 0, height: 0 }; + capturePointer(target.value, event.pointerId); + }); + + useEventListener(target, 'pointermove', (event: PointerEvent) => { + if (!active.value || event.pointerId !== pointerId) return; + const start = paneRelative(startClientX, startClientY); + const cur = paneRelative(event.clientX, event.clientY); + rect.value = { + x: Math.min(start.x, cur.x), + y: Math.min(start.y, cur.y), + width: Math.abs(cur.x - start.x), + height: Math.abs(cur.y - start.y), + }; + }); + + function end(event: PointerEvent): void { + if (!active.value || event.pointerId !== pointerId) return; + const a = ctx.screenToFlow({ x: startClientX, y: startClientY }); + const b = ctx.screenToFlow({ x: event.clientX, y: event.clientY }); + const flowRect = { + x: Math.min(a.x, b.x), + y: Math.min(a.y, b.y), + width: Math.abs(b.x - a.x), + height: Math.abs(b.y - a.y), + }; + const ids = getNodesInsideRect(ctx.nodeLookup.value.values(), flowRect, ctx.selectionMode.value); + ctx.setSelection(ids, []); + + active.value = false; + rect.value = null; + pointerId = -1; + releasePointer(target.value, event.pointerId); + } + + useEventListener(target, 'pointerup', end); + useEventListener(target, 'pointercancel', end); + + return { active, rect }; +} diff --git a/vue/primitives/src/canvas/flow/composables/useNodeDrag.ts b/vue/primitives/src/canvas/flow/composables/useNodeDrag.ts new file mode 100644 index 0000000..abe483a --- /dev/null +++ b/vue/primitives/src/canvas/flow/composables/useNodeDrag.ts @@ -0,0 +1,164 @@ +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { onScopeDispose, shallowRef, toValue } from 'vue'; +import { useEventListener } from '@robonen/vue'; +import { clamp } from '@robonen/stdlib'; +import type { FlowContext } from '../context'; +import type { InternalNode, XYPosition } from '../types'; +import { snapPoint } from '../utils'; +import { capturePointer, releasePointer } from './dom'; + +/** Constrain a dragged position by a node's `extent` (parent box or explicit rect). */ +function applyExtent(pos: XYPosition, node: InternalNode, lookup: Map): XYPosition { + if (node.extent === 'parent' && node.parentId) { + const parent = lookup.get(node.parentId); + if (!parent) return pos; + return { + x: clamp(pos.x, 0, Math.max(0, parent.measured.width - node.measured.width)), + y: clamp(pos.y, 0, Math.max(0, parent.measured.height - node.measured.height)), + }; + } + if (Array.isArray(node.extent)) { + const [[x1, y1], [x2, y2]] = node.extent; + return { + x: clamp(pos.x, x1, x2 - node.measured.width), + y: clamp(pos.y, y1, y2 - node.measured.height), + }; + } + return pos; +} + +export interface NodeDragOptions { + /** Pixels the pointer must travel before a click becomes a drag. @default 1 */ + threshold?: number; +} + +/** Elements inside a node that must not initiate a drag. */ +const NO_DRAG_SELECTOR = 'input, textarea, select, button, [contenteditable="true"], [data-handleid], .nodrag'; + +/** + * Pointer-capture node drag. Moves the node (and every co-selected node) by the + * pointer delta converted to flow space (`delta / zoom`), optionally snapped to + * the grid. Positions are written to the transient drag overlay every frame and + * committed to the model once on pointerup — never 60×/sec. + * + * Stops propagation so the pane never pans while a node is grabbed. A pointer + * that never crosses `threshold` stays a click (selection handled elsewhere). + */ +export function useNodeDrag( + target: Ref, + ctx: FlowContext, + nodeId: MaybeRefOrGetter, + options: NodeDragOptions = {}, +): { dragging: Readonly> } { + const threshold = options.threshold ?? 1; + const dragging = shallowRef(false); + + let pointerId = -1; + let startX = 0; + let startY = 0; + let started = false; + let lastX = 0; + let lastY = 0; + let rafId: number | null = null; + /** id → starting flow-space position, snapshot at pointerdown. */ + let snapshot = new Map(); + /** Reused across RAF frames — only the container is pooled; entries are fresh. */ + const nextPositions = new Map(); + + function eligible(): boolean { + if (!ctx.interactive.value || !ctx.nodesDraggable.value) return false; + const node = ctx.nodeLookup.value.get(toValue(nodeId)); + return !!node && node.draggable !== false; + } + + function collectDragSet(id: string): Map { + const set = new Map(); + const selected = ctx.selection.value.nodes; + const ids = selected.has(id) && selected.size > 1 ? selected : new Set([id]); + for (const nid of ids) { + const node = ctx.nodeLookup.value.get(nid); + if (node && node.draggable !== false) + set.set(nid, { x: node.position.x, y: node.position.y }); + } + return set; + } + + function flush() { + rafId = null; + const dx = (lastX - startX) / ctx.viewport.value.zoom; + const dy = (lastY - startY) / ctx.viewport.value.zoom; + const snap = ctx.snapToGrid.value; + const grid = ctx.snapGrid.value; + const next = nextPositions; + next.clear(); + for (const [id, start] of snapshot) { + let pos: XYPosition = { x: start.x + dx, y: start.y + dy }; + if (snap) pos = snapPoint(pos, grid); + const node = ctx.nodeLookup.value.get(id); + if (node?.extent) pos = applyExtent(pos, node, ctx.nodeLookup.value); + next.set(id, pos); + } + ctx.updateNodePositions(next, true); + } + + function schedule() { + if (rafId === null) rafId = requestAnimationFrame(flush); + } + + onScopeDispose(() => { + if (rafId !== null) cancelAnimationFrame(rafId); + }); + + useEventListener(target, 'pointerdown', (event: PointerEvent) => { + if (event.button !== 0) return; + if (event.target instanceof Element && event.target.closest(NO_DRAG_SELECTOR)) return; + // Always stop the pane from treating this as a pan, even when not draggable. + event.stopPropagation(); + if (!eligible()) return; + + const id = toValue(nodeId); + snapshot = collectDragSet(id); + if (snapshot.size === 0) return; + + pointerId = event.pointerId; + startX = lastX = event.clientX; + startY = lastY = event.clientY; + started = false; + capturePointer(target.value, event.pointerId); + }); + + useEventListener(target, 'pointermove', (event: PointerEvent) => { + if (pointerId !== event.pointerId || snapshot.size === 0) return; + lastX = event.clientX; + lastY = event.clientY; + + if (!started) { + if (Math.abs(lastX - startX) < threshold && Math.abs(lastY - startY) < threshold) return; + started = true; + dragging.value = true; + } + schedule(); + }); + + function endDrag(event: PointerEvent) { + if (pointerId !== event.pointerId) return; + releasePointer(target.value, event.pointerId); + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + if (started) { + flush(); + ctx.commitNodeDrag(); + } + pointerId = -1; + started = false; + dragging.value = false; + snapshot = new Map(); + } + + useEventListener(target, 'pointerup', endDrag); + useEventListener(target, 'pointercancel', endDrag); + + return { dragging }; +} diff --git a/vue/primitives/src/canvas/flow/composables/useNodesInView.ts b/vue/primitives/src/canvas/flow/composables/useNodesInView.ts new file mode 100644 index 0000000..687c40e --- /dev/null +++ b/vue/primitives/src/canvas/flow/composables/useNodesInView.ts @@ -0,0 +1,43 @@ +import type { ComputedRef } from 'vue'; +import { computed } from 'vue'; +import type { FlowContext } from '../context'; +import type { Rect } from '../types'; +import { visibleFlowRect } from '../utils'; +import { getVisibleEdgeIds, getVisibleNodeIds } from '../virtualization'; + +export interface UseNodesInViewOptions { + /** Extra px around the viewport kept "in view". @default 200 */ + buffer?: number; +} + +export interface UseNodesInViewReturn { + /** Flow-space rect currently visible (buffered). */ + visibleRect: ComputedRef; + /** Ids of nodes intersecting the visible rect. */ + visibleNodeIds: ComputedRef; + /** Ids of edges with an endpoint in view. */ + visibleEdgeIds: ComputedRef; +} + +/** + * Reactive viewport culling for very large graphs. `FlowRoot` already culls + * internally when `onlyRenderVisibleElements` is set; this composable exposes + * the same computation to advanced consumers building custom renderers or + * spatial overlays. Recomputes on pan/zoom — only enable where the node count + * makes culling worth the per-frame cost. + */ +export function useNodesInView(ctx: FlowContext, options: UseNodesInViewOptions = {}): UseNodesInViewReturn { + const buffer = options.buffer ?? 200; + + const visibleRect = computed(() => visibleFlowRect(ctx.viewport.value, ctx.paneRect.value, buffer)); + + const visibleNodeIds = computed(() => + getVisibleNodeIds(ctx.nodeLookup.value.values(), ctx.nodeLookup.value, visibleRect.value)); + + const visibleNodeSet = computed(() => new Set(visibleNodeIds.value)); + + const visibleEdgeIds = computed(() => + getVisibleEdgeIds(ctx.edgeLookup.value.values(), visibleNodeSet.value)); + + return { visibleRect, visibleNodeIds, visibleEdgeIds }; +} diff --git a/vue/primitives/src/canvas/flow/composables/usePanZoom.ts b/vue/primitives/src/canvas/flow/composables/usePanZoom.ts new file mode 100644 index 0000000..414aa1c --- /dev/null +++ b/vue/primitives/src/canvas/flow/composables/usePanZoom.ts @@ -0,0 +1,169 @@ +import type { Ref } from 'vue'; +import { onScopeDispose, shallowRef } from 'vue'; +import { useEventListener } from '@robonen/vue'; +import type { FlowContext } from '../context'; +import type { Viewport } from '../types'; +import { clampZoom, wheelToZoomFactor, zoomAtPointer } from '../utils'; +import { capturePointer, releasePointer } from './dom'; + +export interface PanZoomOptions { + /** Wheel scroll zooms toward the pointer. @default true */ + zoomOnScroll?: boolean; + /** Trackpad pinch (ctrl+wheel) zooms. @default true */ + zoomOnPinch?: boolean; + /** Wheel scroll pans instead of zooms (pinch still zooms). @default false */ + panOnScroll?: boolean; + /** Scroll-pan speed multiplier. @default 0.5 */ + panOnScrollSpeed?: number; + /** Drag the empty pane to pan. @default true */ + panOnDrag?: boolean; + /** Double-click zooms in toward the pointer. @default true */ + zoomOnDoubleClick?: boolean; + /** Factor applied per double-click. @default 1.2 */ + doubleClickZoomFactor?: number; +} + +/** True if the event target opts out of panning via a `.nopan` ancestor. */ +function isPanBlocked(target: EventTarget | null): boolean { + return target instanceof Element && !!target.closest('.nopan'); +} + +/** + * Wires wheel zoom-at-pointer, optional scroll-pan, and drag-pan onto the pane + * element, writing the master `viewport` through one RAF-batched flush per frame + * so a burst of wheel/pointer events never causes more than one layout write. + * + * Node drag stops propagation on its own pointerdown, so any pointerdown that + * reaches the pane is a pan (or, later, a marquee — gated by the caller). + */ +export function usePanZoom( + target: Ref, + ctx: FlowContext, + options: PanZoomOptions = {}, +): { isPanning: Readonly>; panningRef: Ref } { + const { + zoomOnScroll = true, + zoomOnPinch = true, + panOnScroll = false, + panOnScrollSpeed = 0.5, + panOnDrag = true, + zoomOnDoubleClick = true, + doubleClickZoomFactor = 1.2, + } = options; + + // ── RAF-batched viewport writes ────────────────────────────────────────── + let rafId: number | null = null; + let pending: Viewport | null = null; + + function flush() { + rafId = null; + if (pending) { + ctx.viewport.value = pending; + pending = null; + } + } + + function schedule(next: Viewport) { + pending = next; + if (rafId === null) rafId = requestAnimationFrame(flush); + } + + /** Latest known viewport, including any not-yet-flushed pending value. */ + function current(): Viewport { + return pending ?? ctx.viewport.value; + } + + onScopeDispose(() => { + if (rafId !== null) cancelAnimationFrame(rafId); + }); + + function paneOffset(event: { clientX: number; clientY: number }) { + const rect = ctx.paneRect.value; + return { x: event.clientX - rect.left, y: event.clientY - rect.top }; + } + + // ── wheel ──────────────────────────────────────────────────────────────── + useEventListener(target, 'wheel', (event: WheelEvent) => { + if (!ctx.interactive.value) return; + if (event.target instanceof Element && event.target.closest('.nowheel')) return; + + const pinch = event.ctrlKey; + const shouldZoom = pinch ? zoomOnPinch : zoomOnScroll && !panOnScroll; + + event.preventDefault(); + + if (shouldZoom) { + const vp = current(); + const factor = wheelToZoomFactor(event); + const newZoom = clampZoom(vp.zoom * factor, ctx.minZoom.value, ctx.maxZoom.value); + if (newZoom === vp.zoom) return; + schedule(zoomAtPointer(vp, paneOffset(event), newZoom)); + return; + } + + // Scroll-pan. + const vp = current(); + schedule({ + zoom: vp.zoom, + x: vp.x - event.deltaX * panOnScrollSpeed, + y: vp.y - event.deltaY * panOnScrollSpeed, + }); + }, { passive: false }); + + // ── drag-pan ─────────────────────────────────────────────────────────────── + const panningRef = shallowRef(false); + let startX = 0; + let startY = 0; + let baseX = 0; + let baseY = 0; + let pointerId = -1; + + useEventListener(target, 'pointerdown', (event: PointerEvent) => { + if (!panOnDrag || !ctx.interactive.value) return; + if (event.button !== 0 && event.button !== 1) return; + // Shift + drag is reserved for marquee selection. + if (event.button === 0 && event.shiftKey) return; + if (isPanBlocked(event.target)) return; + + const vp = current(); + startX = event.clientX; + startY = event.clientY; + baseX = vp.x; + baseY = vp.y; + pointerId = event.pointerId; + panningRef.value = true; + capturePointer(target.value, event.pointerId); + }); + + useEventListener(target, 'pointermove', (event: PointerEvent) => { + if (!panningRef.value || event.pointerId !== pointerId) return; + const vp = current(); + schedule({ + zoom: vp.zoom, + x: baseX + (event.clientX - startX), + y: baseY + (event.clientY - startY), + }); + }); + + function endPan(event: PointerEvent) { + if (!panningRef.value || event.pointerId !== pointerId) return; + panningRef.value = false; + pointerId = -1; + releasePointer(target.value, event.pointerId); + } + + useEventListener(target, 'pointerup', endPan); + useEventListener(target, 'pointercancel', endPan); + + // ── double-click zoom ────────────────────────────────────────────────────── + useEventListener(target, 'dblclick', (event: MouseEvent) => { + if (!zoomOnDoubleClick || !ctx.interactive.value) return; + if (event.target instanceof Element && event.target.closest('.nopan')) return; + const vp = current(); + const newZoom = clampZoom(vp.zoom * doubleClickZoomFactor, ctx.minZoom.value, ctx.maxZoom.value); + if (newZoom === vp.zoom) return; + schedule(zoomAtPointer(vp, paneOffset(event), newZoom)); + }); + + return { isPanning: panningRef, panningRef }; +} diff --git a/vue/primitives/src/canvas/flow/composables/useViewportApi.ts b/vue/primitives/src/canvas/flow/composables/useViewportApi.ts new file mode 100644 index 0000000..7d3ab54 --- /dev/null +++ b/vue/primitives/src/canvas/flow/composables/useViewportApi.ts @@ -0,0 +1,86 @@ +import type { FlowContext } from '../context'; +import type { FlowEdge, InternalNode, Viewport, XYPosition } from '../types'; +import { clampZoom, fitViewTransform, getNodesBounds, zoomAtPointer } from '../utils'; + +export interface FitViewParams { + /** Fractional inset on each side, 0–1. @default 0.1 */ + padding?: number; + /** Restrict the fit to these node ids. */ + nodes?: string[]; +} + +/** Imperative, framework-agnostic control surface for the flow. */ +export interface FlowApi { + /** Current viewport `{x,y,zoom}`. */ + getViewport: () => Viewport; + /** Replace the viewport. */ + setViewport: (viewport: Viewport) => void; + /** Zoom in toward the pane centre. */ + zoomIn: (factor?: number) => void; + /** Zoom out from the pane centre. */ + zoomOut: (factor?: number) => void; + /** Zoom to an absolute level, anchored at the pane centre. */ + zoomTo: (zoom: number) => void; + /** Fit all (or the given) nodes into view. */ + fitView: (params?: FitViewParams) => void; + /** Convert a screen (client) point to flow space. */ + screenToFlowPosition: (point: XYPosition) => XYPosition; + /** Convert a flow-space point to a screen (client) point. */ + flowToScreenPosition: (point: XYPosition) => XYPosition; + /** All internal nodes (with measured geometry). */ + getNodes: () => InternalNode[]; + /** One internal node by id. */ + getNode: (id: string) => InternalNode | undefined; + /** All edges. */ + getEdges: () => FlowEdge[]; + /** One edge by id. */ + getEdge: (id: string) => FlowEdge | undefined; +} + +/** Builds the imperative API over a flow context. */ +export function useViewportApi(ctx: FlowContext): FlowApi { + function center(): XYPosition { + const rect = ctx.paneRect.value; + return { x: rect.width / 2, y: rect.height / 2 }; + } + + function zoomBy(factor: number): void { + const vp = ctx.viewport.value; + const next = clampZoom(vp.zoom * factor, ctx.minZoom.value, ctx.maxZoom.value); + if (next === vp.zoom) return; + ctx.viewport.value = zoomAtPointer(vp, center(), next); + } + + return { + getViewport: () => ctx.viewport.value, + setViewport: (vp) => { ctx.viewport.value = vp; }, + zoomIn: (factor = 1.2) => zoomBy(factor), + zoomOut: (factor = 1.2) => zoomBy(1 / factor), + zoomTo: (zoom) => { + const vp = ctx.viewport.value; + const next = clampZoom(zoom, ctx.minZoom.value, ctx.maxZoom.value); + ctx.viewport.value = zoomAtPointer(vp, center(), next); + }, + fitView: (params = {}) => { + const rect = ctx.paneRect.value; + if (rect.width === 0 || rect.height === 0) return; + const all = [...ctx.nodeLookup.value.values()].filter(n => !n.hidden); + const subset = params.nodes + ? all.filter(n => params.nodes!.includes(n.id)) + : all; + if (subset.length === 0) return; + const bounds = getNodesBounds(subset); + ctx.viewport.value = fitViewTransform(bounds, { width: rect.width, height: rect.height }, { + padding: params.padding ?? 0.1, + minZoom: ctx.minZoom.value, + maxZoom: ctx.maxZoom.value, + }); + }, + screenToFlowPosition: point => ctx.screenToFlow(point), + flowToScreenPosition: point => ctx.flowToScreen(point), + getNodes: () => [...ctx.nodeLookup.value.values()], + getNode: id => ctx.nodeLookup.value.get(id), + getEdges: () => [...ctx.edgeLookup.value.values()], + getEdge: id => ctx.edgeLookup.value.get(id), + }; +} diff --git a/vue/primitives/src/canvas/flow/connection.ts b/vue/primitives/src/canvas/flow/connection.ts new file mode 100644 index 0000000..43038ab --- /dev/null +++ b/vue/primitives/src/canvas/flow/connection.ts @@ -0,0 +1,100 @@ +import type { + Connection, + ConnectionMode, + FlowEdge, + HandleBound, + HandleType, + InternalNode, + IsValidConnection, + XYPosition, +} from './types'; +import { getAbsoluteHandlePoint } from './utils'; + +/** A hovered drop candidate during a connection drag. */ +export interface ConnectionTarget { + nodeId: string; + handle: HandleBound; +} + +/** + * Closest handle to `point` (flow space) within `radius`, eligible as a drop + * target given the drag origin. In `strict` mode only the opposite handle type + * on a *different* node qualifies; in `loose` mode any handle but the origin's + * own does. Returns `null` when nothing is in range. + */ +export function findClosestHandle( + point: XYPosition, + nodeLookup: Map, + fromType: HandleType, + fromNodeId: string, + fromHandleId: string | null, + mode: ConnectionMode, + radius: number, +): ConnectionTarget | null { + const types: HandleType[] = mode === 'loose' + ? ['source', 'target'] + : [fromType === 'source' ? 'target' : 'source']; + + let best: ConnectionTarget | null = null; + let bestDist = radius; + + for (const node of nodeLookup.values()) { + if (node.hidden || !node.handleBounds) continue; + if (mode === 'strict' && node.id === fromNodeId) continue; + + for (const type of types) { + for (const handle of node.handleBounds[type]) { + if (node.id === fromNodeId && handle.id === fromHandleId && handle.type === fromType) continue; + const p = getAbsoluteHandlePoint(node.positionAbsolute, handle); + const dist = Math.hypot(p.x - point.x, p.y - point.y); + if (dist <= bestDist) { + bestDist = dist; + best = { nodeId: node.id, handle }; + } + } + } + } + + return best; +} + +/** Orient a connection so `source` is always the source-typed end. */ +export function buildConnection( + fromType: HandleType, + fromNodeId: string, + fromHandle: HandleBound, + target: ConnectionTarget, +): Connection { + return fromType === 'source' + ? { source: fromNodeId, target: target.nodeId, sourceHandle: fromHandle.id, targetHandle: target.handle.id } + : { source: target.nodeId, target: fromNodeId, sourceHandle: target.handle.id, targetHandle: fromHandle.id }; +} + +/** + * Default connection gate: rejects self-loops and duplicates, then defers to a + * consumer-supplied `custom` predicate. + */ +export function isValidConnection( + conn: Connection, + edges: Iterable, + custom?: IsValidConnection, +): boolean { + if (conn.source === conn.target) return false; + + for (const e of edges) { + if ( + e.source === conn.source + && e.target === conn.target + && (e.sourceHandle ?? null) === conn.sourceHandle + && (e.targetHandle ?? null) === conn.targetHandle + ) return false; + } + + if (custom && !custom(conn)) return false; + return true; +} + +/** Deterministic edge id from a connection's endpoints. */ +export function connectionToEdgeId(conn: Connection): string { + return `xy-edge__${conn.source}${conn.sourceHandle ?? ''}-${conn.target}${conn.targetHandle ?? ''}`; +} diff --git a/vue/primitives/src/canvas/flow/context.ts b/vue/primitives/src/canvas/flow/context.ts new file mode 100644 index 0000000..51fea4f --- /dev/null +++ b/vue/primitives/src/canvas/flow/context.ts @@ -0,0 +1,150 @@ +import type { ComputedRef, Ref, ShallowRef } from 'vue'; +import { useContextFactory } from '@robonen/vue'; +import type { + ConnectionMode, + ConnectionState, + Dimensions, + EdgeChange, + FlowEdge, + FlowNode, + HandleType, + InternalNode, + NodeChange, + Position, + SelectionMode, + Viewport, + XYPosition, +} from './types'; + +/** + * Namespaced collection keys. Two `useCollectionProvider` instances live under + * one `FlowRoot` — one for node elements, one for edge `` elements — so they + * MUST use distinct keys, otherwise the inner provider shadows the outer for + * every descendant (see `useCollection` namespacing note). + */ +export const FLOW_NODES_COLLECTION = 'flow-nodes'; +export const FLOW_EDGES_COLLECTION = 'flow-edges'; + +/** Canonical selection store: Sets give O(1) membership on the hot path. */ +export interface FlowSelection { + nodes: Set; + edges: Set; +} + +/** A handle registering itself into its node's sub-context. */ +export interface HandleRegistration { + id: string | null; + type: HandleType; + position: Position; + element: HTMLElement; +} + +/** + * Root context shared by every flow part. Reactive fields are `Ref`/`ShallowRef` + * (never raw values — descendants would lose reactivity); mutations go through + * the action functions so behaviour stays in one place. + */ +export interface FlowContext { + /** Stable id for scoping DOM ids / marker ids per flow instance. */ + flowId: string; + + // ── viewport + measurement ────────────────────────────────────────────── + /** The master transform `{x,y,zoom}`. */ + viewport: Ref; + /** Live bounding rect of `FlowPane`; the screen origin for coord math. */ + paneRect: Readonly>; + minZoom: Ref; + maxZoom: Ref; + /** True while the viewport is actively panning/zooming (gates `will-change`). */ + isInteracting: Readonly>; + + // ── capability flags (global defaults, overridable per element) ─────────── + nodesDraggable: Ref; + nodesConnectable: Ref; + elementsSelectable: Ref; + snapToGrid: Ref; + snapGrid: Ref<[number, number]>; + connectionMode: Ref; + connectionRadius: Ref; + selectionMode: Ref; + /** Master interactivity switch (lock). */ + interactive: Ref; + /** Disable the keyboard a11y layer (and `role=application`). */ + disableKeyboardA11y: Ref; + + // ── data access (shallow Maps — mutate in place + triggerRef) ──────────── + /** Read path for components, keyed by node id. */ + nodeLookup: ShallowRef>; + /** Read path for components, keyed by edge id. */ + edgeLookup: ShallowRef>; + /** Canonical selection state (replace wholesale + triggerRef). */ + selection: ShallowRef; + /** Live connection-drag state (only the preview + hovered handle subscribe). */ + connection: ShallowRef; + /** Ordered id list of currently rendered (post-virtualization) nodes. */ + visibleNodeIds: ComputedRef; + /** Ordered id list of currently rendered edges. */ + visibleEdgeIds: ComputedRef; + + // ── coordinate actions ─────────────────────────────────────────────────── + /** Screen (client) point → flow space. */ + screenToFlow: (point: XYPosition) => XYPosition; + /** Flow space → screen (client) point. */ + flowToScreen: (point: XYPosition) => XYPosition; + /** `FlowPane` reports its live bounding rect here (the screen origin). */ + setPaneRect: (rect: { left: number; top: number; width: number; height: number }) => void; + + // ── node actions ───────────────────────────────────────────────────────── + /** Apply a batch of per-node flow-space deltas (drag). Transient. */ + updateNodePositions: (deltas: Map, dragging: boolean) => void; + /** Commit the in-flight drag overlay into the model (one change). */ + commitNodeDrag: () => void; + /** Write a measured size for a node into `nodeLookup`. */ + setNodeMeasured: (id: string, size: Dimensions, handleBounds: InternalNode['handleBounds']) => void; + /** Patch a node in the model (e.g. resize/move) and emit the matching change. */ + updateNode: (id: string, patch: Partial) => void; + /** True while a node drag is in progress (blocks external sync clobber). */ + isDragging: Readonly>; + + // ── selection actions ──────────────────────────────────────────────────── + selectNode: (id: string, additive?: boolean) => void; + selectEdge: (id: string, additive?: boolean) => void; + setSelection: (nodes: string[], edges: string[]) => void; + clearSelection: () => void; + removeSelected: () => void; + + // ── connection actions ─────────────────────────────────────────────────── + startConnection: (from: HandleRegistration, nodeId: string) => void; + updateConnection: (flowPoint: XYPosition) => void; + endConnection: () => void; + + // ── change emission ────────────────────────────────────────────────────── + emitNodesChange: (changes: NodeChange[]) => void; + emitEdgesChange: (changes: EdgeChange[]) => void; +} + +const flow = useContextFactory('FlowContext'); +export const provideFlowContext = flow.provide; +export const useFlowContext = flow.inject; + +/** + * Per-node sub-context. Read by `FlowHandle`, `FlowNodeResizer`, + * `FlowNodeToolbar` so they never DOM-walk to find their node's state. + */ +export interface FlowNodeContext { + nodeId: string; + node: ComputedRef; + positionAbsolute: ComputedRef; + measured: ComputedRef; + selected: ComputedRef; + dragging: ComputedRef; + connectable: ComputedRef; + /** Element of the node wrapper, for handle measurement. */ + nodeRef: Readonly>; + registerHandle: (reg: HandleRegistration) => void; + unregisterHandle: (id: string | null, type: HandleType) => void; +} + +const flowNode = useContextFactory('FlowNodeContext'); +export const provideFlowNodeContext = flowNode.provide; +export const useFlowNodeContext = flowNode.inject; diff --git a/vue/primitives/src/canvas/flow/demo.vue b/vue/primitives/src/canvas/flow/demo.vue new file mode 100644 index 0000000..b1d66d6 --- /dev/null +++ b/vue/primitives/src/canvas/flow/demo.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/vue/primitives/src/canvas/flow/edge-paths.ts b/vue/primitives/src/canvas/flow/edge-paths.ts new file mode 100644 index 0000000..38ccebf --- /dev/null +++ b/vue/primitives/src/canvas/flow/edge-paths.ts @@ -0,0 +1,253 @@ +import type { EdgeMarker, Position, XYPosition } from './types'; + +/** + * Result of every path builder: the SVG `d` string plus the label anchor + * (`labelX`/`labelY`) and its offset from the source (`offsetX`/`offsetY`), + * used to place edge labels and badges. + */ +export type PathResult = [path: string, labelX: number, labelY: number, offsetX: number, offsetY: number]; + +/** Geometric centre of two points + the |centre − source| offset. */ +export function getEdgeCenter(p: { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; +}): [centerX: number, centerY: number, offsetX: number, offsetY: number] { + const centerX = (p.sourceX + p.targetX) / 2; + const centerY = (p.sourceY + p.targetY) / 2; + return [centerX, centerY, Math.abs(centerX - p.sourceX), Math.abs(centerY - p.sourceY)]; +} + +/** Straight line between two endpoints. */ +export function getStraightPath(p: { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; +}): PathResult { + const [labelX, labelY, offsetX, offsetY] = getEdgeCenter(p); + return [`M ${p.sourceX},${p.sourceY} L ${p.targetX},${p.targetY}`, labelX, labelY, offsetX, offsetY]; +} + +// ── Bezier ────────────────────────────────────────────────────────────────── + +/** + * Control-point offset for a bezier endpoint. Positive distance scales linearly; + * a negative distance (target behind source) uses a `sqrt` fallback so the curve + * bows out instead of collapsing into a cusp. + */ +function calculateControlOffset(distance: number, curvature: number): number { + if (distance >= 0) return 0.5 * distance; + return curvature * 25 * Math.sqrt(-distance); +} + +function getControlWithCurvature( + pos: Position, + x1: number, + y1: number, + x2: number, + y2: number, + c: number, +): [number, number] { + switch (pos) { + case 'left': return [x1 - calculateControlOffset(x1 - x2, c), y1]; + case 'right': return [x1 + calculateControlOffset(x2 - x1, c), y1]; + case 'top': return [x1, y1 - calculateControlOffset(y1 - y2, c)]; + case 'bottom': return [x1, y1 + calculateControlOffset(y2 - y1, c)]; + } +} + +/** Cubic bezier respecting handle sides. `curvature` (0–1) controls bow. */ +export function getBezierPath(p: { + sourceX: number; + sourceY: number; + sourcePosition?: Position; + targetX: number; + targetY: number; + targetPosition?: Position; + curvature?: number; +}): PathResult { + const { + sourceX, sourceY, sourcePosition = 'bottom', + targetX, targetY, targetPosition = 'top', + curvature = 0.25, + } = p; + + const [sourceCx, sourceCy] = getControlWithCurvature(sourcePosition, sourceX, sourceY, targetX, targetY, curvature); + const [targetCx, targetCy] = getControlWithCurvature(targetPosition, targetX, targetY, sourceX, sourceY, curvature); + + // Point on the cubic at t = 0.5 (binomial weights 1/8, 3/8, 3/8, 1/8). + const labelX = sourceX * 0.125 + sourceCx * 0.375 + targetCx * 0.375 + targetX * 0.125; + const labelY = sourceY * 0.125 + sourceCy * 0.375 + targetCy * 0.375 + targetY * 0.125; + + const path = `M ${sourceX},${sourceY} C ${sourceCx},${sourceCy} ${targetCx},${targetCy} ${targetX},${targetY}`; + return [path, labelX, labelY, Math.abs(labelX - sourceX), Math.abs(labelY - sourceY)]; +} + +// ── Smooth step / step ──────────────────────────────────────────────────────── + +const handleDirections: Record = { + top: { x: 0, y: -1 }, + right: { x: 1, y: 0 }, + bottom: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, +}; + +function getDirection(source: XYPosition, sourcePosition: Position, target: XYPosition): XYPosition { + if (sourcePosition === 'left' || sourcePosition === 'right') + return source.x < target.x ? { x: 1, y: 0 } : { x: -1, y: 0 }; + return source.y < target.y ? { x: 0, y: 1 } : { x: 0, y: -1 }; +} + +function distance(a: XYPosition, b: XYPosition): number { + return Math.hypot((b.x - a.x), (b.y - a.y)); +} + +/** + * One rounded corner at point `b` between segments `a→b` and `b→c`. Bend radius + * is clamped to half of each adjacent segment so it never overshoots. + */ +function getBend(a: XYPosition, b: XYPosition, c: XYPosition, size: number): string { + const bend = Math.min(distance(a, b) / 2, distance(b, c) / 2, size); + const { x, y } = b; + + // Collinear → no corner. + if ((a.x === x && x === c.x) || (a.y === y && y === c.y)) + return `L ${x},${y}`; + + if (a.y === y) { + const xDir = a.x < c.x ? -1 : 1; + const yDir = a.y < c.y ? 1 : -1; + return `L ${x + bend * xDir},${y} Q ${x},${y} ${x},${y + bend * yDir}`; + } + + const xDir = a.x < c.x ? 1 : -1; + const yDir = a.y < c.y ? -1 : 1; + return `L ${x},${y + bend * yDir} Q ${x},${y} ${x + bend * xDir},${y}`; +} + +function getStepPoints( + source: XYPosition, + sourcePosition: Position, + target: XYPosition, + targetPosition: Position, + offset: number, + center: { x?: number; y?: number }, +): { points: XYPosition[]; labelX: number; labelY: number } { + const sourceDir = handleDirections[sourcePosition]; + const targetDir = handleDirections[targetPosition]; + const sourceGapped: XYPosition = { x: source.x + sourceDir.x * offset, y: source.y + sourceDir.y * offset }; + const targetGapped: XYPosition = { x: target.x + targetDir.x * offset, y: target.y + targetDir.y * offset }; + const dir = getDirection(sourceGapped, sourcePosition, targetGapped); + const dirAccessor: keyof XYPosition = dir.x !== 0 ? 'x' : 'y'; + const currDir = dir[dirAccessor]; + + let points: XYPosition[]; + let centerX: number; + let centerY: number; + const sourceGapPoint: XYPosition = { ...sourceGapped }; + const targetGapPoint: XYPosition = { ...targetGapped }; + const dirX = dir.x !== 0; + + if (sourceDir[dirAccessor] * targetDir[dirAccessor] === -1) { + centerX = center.x ?? (sourceGapped.x + targetGapped.x) / 2; + centerY = center.y ?? (sourceGapped.y + targetGapped.y) / 2; + + // Build only the split that is actually used (avoids allocating both). + const useVertical = (sourceDir[dirAccessor] === currDir) === dirX; + points = useVertical + ? [{ x: centerX, y: sourceGapped.y }, { x: centerX, y: targetGapped.y }] + : [{ x: sourceGapped.x, y: centerY }, { x: targetGapped.x, y: centerY }]; + } + else { + const useTargetSource = dirX ? sourceDir.y === currDir : sourceDir.x !== currDir; + points = useTargetSource + ? [{ x: targetGapped.x, y: sourceGapped.y }] + : [{ x: sourceGapped.x, y: targetGapped.y }]; + + centerX = (sourceGapped.x + targetGapped.x) / 2; + centerY = (sourceGapped.y + targetGapped.y) / 2; + + // Same side: nudge the gap point so it doesn't overlap when handles are close. + if (sourcePosition === targetPosition) { + const diff = Math.abs(source[dirAccessor] - target[dirAccessor]); + if (diff <= offset) { + const gapOffset = Math.min(offset - 1, offset - diff); + if (sourceDir[dirAccessor] === currDir) + sourceGapPoint[dirAccessor] = source[dirAccessor] + sourceDir[dirAccessor] * gapOffset; + else + targetGapPoint[dirAccessor] = target[dirAccessor] + targetDir[dirAccessor] * gapOffset; + } + } + } + + return { + points: [source, sourceGapPoint, ...points, targetGapPoint, target], + labelX: centerX, + labelY: centerY, + }; +} + +/** Orthogonal path with rounded corners. `borderRadius` 0 yields sharp steps. */ +export function getSmoothStepPath(p: { + sourceX: number; + sourceY: number; + sourcePosition?: Position; + targetX: number; + targetY: number; + targetPosition?: Position; + borderRadius?: number; + offset?: number; + centerX?: number; + centerY?: number; +}): PathResult { + const { + sourceX, sourceY, sourcePosition = 'bottom', + targetX, targetY, targetPosition = 'top', + borderRadius = 5, offset = 20, centerX, centerY, + } = p; + + const { points, labelX, labelY } = getStepPoints( + { x: sourceX, y: sourceY }, sourcePosition, + { x: targetX, y: targetY }, targetPosition, + offset, { x: centerX, y: centerY }, + ); + + let path = ''; + for (let i = 0; i < points.length; i++) { + const cur = points[i]!; + if (i > 0 && i < points.length - 1) + path += getBend(points[i - 1]!, cur, points[i + 1]!, borderRadius); + else + path += `${i === 0 ? 'M' : 'L'} ${cur.x},${cur.y} `; + } + + return [path.trim(), labelX, labelY, Math.abs(labelX - sourceX), Math.abs(labelY - sourceY)]; +} + +/** Sharp orthogonal path (smooth step with zero corner radius). */ +export function getStepPath(p: Parameters[0]): PathResult { + return getSmoothStepPath({ ...p, borderRadius: 0 }); +} + +// ── Markers ────────────────────────────────────────────────────────────────── + +/** + * Deterministic, dedupe-safe id for a marker `` entry, scoped to the flow + * instance. Identical descriptors collapse to one DOM marker. + */ +export function getMarkerId(marker: EdgeMarker | string, flowId: string): string { + if (typeof marker === 'string') + return `${flowId}__marker-url-${marker.replaceAll(/\W/g, '')}`; + const parts = [ + marker.type, + marker.color ?? '', + marker.width ?? '', + marker.height ?? '', + marker.strokeWidth ?? '', + marker.orient ?? '', + marker.markerUnits ?? '', + ]; + return `${flowId}__marker-${parts.join('-').replaceAll(/[^\w-]/g, '')}`; +} diff --git a/vue/primitives/src/canvas/flow/index.ts b/vue/primitives/src/canvas/flow/index.ts new file mode 100644 index 0000000..6f3c410 --- /dev/null +++ b/vue/primitives/src/canvas/flow/index.ts @@ -0,0 +1,119 @@ +// Composable parts. The per-item `FlowNode` / `FlowEdge` components are internal +// (rendered by the renderers); the public `FlowNode` / `FlowEdge` names below are +// the DATA types consumers bind their `nodes` / `edges` arrays to. +export { default as FlowBackground } from './FlowBackground.vue'; +export { default as FlowConnectionLine } from './FlowConnectionLine.vue'; +export { default as FlowControls } from './FlowControls.vue'; +export { default as FlowEdgeRenderer } from './FlowEdgeRenderer.vue'; +export { default as FlowHandle } from './FlowHandle.vue'; +export { default as FlowMiniMap } from './FlowMiniMap.vue'; +export { default as FlowNodeRenderer } from './FlowNodeRenderer.vue'; +export { default as FlowNodeResizer } from './FlowNodeResizer.vue'; +export { default as FlowNodeToolbar } from './FlowNodeToolbar.vue'; +export { default as FlowPane } from './FlowPane.vue'; +export { default as FlowPanel } from './FlowPanel.vue'; +export { default as FlowRoot } from './FlowRoot.vue'; +export { default as FlowViewport } from './FlowViewport.vue'; + +export type { FlowBackgroundProps, FlowBackgroundVariant } from './FlowBackground.vue'; +export type { FlowConnectionLineProps } from './FlowConnectionLine.vue'; +export type { FlowControlsProps } from './FlowControls.vue'; +export type { FlowEdgeRendererProps } from './FlowEdgeRenderer.vue'; +export type { FlowHandleProps } from './FlowHandle.vue'; +export type { FlowMiniMapProps } from './FlowMiniMap.vue'; +export type { FlowNodeRendererProps } from './FlowNodeRenderer.vue'; +export type { FlowNodeResizerProps } from './FlowNodeResizer.vue'; +export type { FlowNodeToolbarProps } from './FlowNodeToolbar.vue'; +export type { FlowPaneProps } from './FlowPane.vue'; +export type { FlowPanelPosition, FlowPanelProps } from './FlowPanel.vue'; +export type { FlowRootEmits, FlowRootProps } from './FlowRoot.vue'; +export type { FlowViewportProps } from './FlowViewport.vue'; + +export { + FLOW_EDGES_COLLECTION, + FLOW_NODES_COLLECTION, + provideFlowContext, + provideFlowNodeContext, + useFlowContext, + useFlowNodeContext, +} from './context'; +export type { FlowContext, FlowNodeContext, FlowSelection, HandleRegistration } from './context'; + +export type { + AriaLabelConfig, + Connection, + ConnectionMode, + ConnectionState, + Dimensions, + EdgeChange, + EdgeMarker, + EdgeMarkerType, + FlowEdge, + FlowNode, + HandleBound, + HandleBounds, + HandleType, + InternalNode, + IsValidConnection, + NodeChange, + Position, + Rect, + SelectionMode, + Viewport, + XYPosition, +} from './types'; + +export type { FitViewOptions, NodeBox, PaneOrigin } from './utils'; +export { + clampZoom, + findHandle, + fitViewTransform, + flowToScreen, + getAbsoluteHandlePoint, + getDefaultEndpoint, + getHandleBoundsFromDom, + getNodePositionAbsolute, + getNodesBounds, + getNodesInsideRect, + nodeInRect, + rectContains, + rectsIntersect, + screenToFlow, + snapPoint, + visibleFlowRect, + wheelToZoomFactor, + zoomAtPointer, +} from './utils'; + +export type { PathResult } from './edge-paths'; +export { + getBezierPath, + getEdgeCenter, + getMarkerId, + getSmoothStepPath, + getStepPath, + getStraightPath, +} from './edge-paths'; + +export type { ConnectionTarget } from './connection'; +export { buildConnection, connectionToEdgeId, findClosestHandle } from './connection'; + +export { addEdge, applyEdgeChanges, applyNodeChanges } from './changes'; + +export { getVisibleEdgeIds, getVisibleNodeIds } from './virtualization'; + +export { usePanZoom } from './composables/usePanZoom'; +export type { PanZoomOptions } from './composables/usePanZoom'; +export { useNodeDrag } from './composables/useNodeDrag'; +export type { NodeDragOptions } from './composables/useNodeDrag'; +export { useConnection } from './composables/useConnection'; +export { useMarquee } from './composables/useMarquee'; +export type { MarqueeRect } from './composables/useMarquee'; +export { useKeyboard } from './composables/useKeyboard'; +export type { KeyboardOptions } from './composables/useKeyboard'; +export { useViewportApi } from './composables/useViewportApi'; +export type { FitViewParams, FlowApi } from './composables/useViewportApi'; +export { useFlow } from './composables/useFlow'; +export type { UseFlowReturn } from './composables/useFlow'; +export { useNodesInView } from './composables/useNodesInView'; +export type { UseNodesInViewOptions, UseNodesInViewReturn } from './composables/useNodesInView'; diff --git a/vue/primitives/src/canvas/flow/types.ts b/vue/primitives/src/canvas/flow/types.ts new file mode 100644 index 0000000..1e240d1 --- /dev/null +++ b/vue/primitives/src/canvas/flow/types.ts @@ -0,0 +1,224 @@ +/** + * Domain model for the headless flow canvas. + * + * Public types (`FlowNode`, `FlowEdge`, `Viewport`, …) are what consumers bind + * through `v-model` and serialize. Internal types (`InternalNode`, + * `HandleBound`, `ConnectionState`) live only in the state layer and are never + * emitted — they hold measured DOM geometry derived from the public model. + */ + +/** A point in flow space (unscaled, viewport-independent coordinates). */ +export interface XYPosition { + x: number; + y: number; +} + +/** Measured size of an element in flow space. */ +export interface Dimensions { + width: number; + height: number; +} + +/** Axis-aligned rectangle in flow space. */ +export interface Rect extends XYPosition, Dimensions {} + +/** + * The single master transform. `x`/`y` are unscaled screen pixels applied + * *before* `scale(zoom)` on the one transformed layer (`transform-origin:0 0`). + */ +export interface Viewport { + x: number; + y: number; + zoom: number; +} + +/** Side of a node a handle/edge attaches to. */ +export type Position = 'top' | 'right' | 'bottom' | 'left'; + +/** Whether a handle starts (`source`) or ends (`target`) a connection. */ +export type HandleType = 'source' | 'target'; + +/** SVG arrowhead descriptor resolved into a shared `` marker. */ +export interface EdgeMarker { + type: 'arrow' | 'arrowclosed'; + color?: string; + width?: number; + height?: number; + strokeWidth?: number; + orient?: string; + markerUnits?: string; +} + +export type EdgeMarkerType = EdgeMarker | string; + +/** + * Public node model. Bound via `v-model:nodes`; only these fields round-trip + * through serialization. `data` carries the consumer's payload. + */ +export interface FlowNode { + /** Unique, stable identity. Used as the render key — never reuse. */ + id: string; + /** Position in flow space. Relative to `parentId` when set. */ + position: XYPosition; + /** Consumer payload passed to the node slot/component. */ + data?: Data; + /** Resolves the renderer via `nodeTypes` / `#node-` slot. */ + type?: Type; + /** Optional explicit width; otherwise measured from the DOM. */ + width?: number; + /** Optional explicit height; otherwise measured from the DOM. */ + height?: number; + /** Default side for outgoing edges when a node renders implicit handles. */ + sourcePosition?: Position; + /** Default side for incoming edges. */ + targetPosition?: Position; + /** Per-node override of the global drag flag. */ + draggable?: boolean; + /** Per-node override of the global selectable flag. */ + selectable?: boolean; + /** Per-node override of the global connectable flag. */ + connectable?: boolean; + /** Per-node override of the global deletable flag. */ + deletable?: boolean; + /** Selection state mirrored from the canonical selection Set on serialize. */ + selected?: boolean; + /** Skip rendering without removing from the model. */ + hidden?: boolean; + /** Parent node id for subflows; `position` becomes parent-relative. */ + parentId?: string; + /** Constrain dragging to the parent's box or an explicit rect. */ + extent?: 'parent' | [[number, number], [number, number]]; + /** Explicit stacking order; otherwise derived (selected/dragging lift). */ + zIndex?: number; + /** Accessible label override for keyboard users / screen readers. */ + ariaLabel?: string; +} + +/** + * State-layer node. Extends the public node with measured geometry that is + * recomputed from the DOM (never serialized). Lives in `nodeLookup`. + */ +export interface InternalNode + extends FlowNode { + /** Measured size in flow space (from the shared ResizeObserver). */ + measured: Dimensions; + /** Absolute flow position = sum of the `parentId` chain + `position`. */ + positionAbsolute: XYPosition; + /** Handle rects measured once per node (already divided by zoom). */ + handleBounds: HandleBounds | null; + /** Live drag flag; set on pointerdown, cleared on commit. */ + dragging?: boolean; + /** + * @internal The public node this entry was derived from. Identity is the + * change signal: when the consumer replaces a node object, reconcile rebuilds + * a fresh `InternalNode` so that node (and only that node) re-renders. + */ + _source?: FlowNode; +} + +/** Measured handle rectangles grouped by type, in node-local flow space. */ +export interface HandleBounds { + source: HandleBound[]; + target: HandleBound[]; +} + +/** One measured handle anchor relative to its node's top-left, in flow space. */ +export interface HandleBound { + id: string | null; + type: HandleType; + position: Position; + /** Offset from node left, flow space. */ + x: number; + /** Offset from node top, flow space. */ + y: number; + width: number; + height: number; +} + +/** + * Public edge model. Bound via `v-model:edges`. Endpoints reference node ids + * and optional handle ids. + */ +export interface FlowEdge { + id: string; + source: string; + target: string; + sourceHandle?: string | null; + targetHandle?: string | null; + /** Resolves the renderer via `edgeTypes` / `#edge-` slot. */ + type?: Type; + data?: Data; + label?: string; + selected?: boolean; + animated?: boolean; + hidden?: boolean; + deletable?: boolean; + selectable?: boolean; + updatable?: boolean; + markerStart?: EdgeMarkerType; + markerEnd?: EdgeMarkerType; + zIndex?: number; + ariaLabel?: string; +} + +/** A proposed or committed connection between two handles. */ +export interface Connection { + source: string; + target: string; + sourceHandle: string | null; + targetHandle: string | null; +} + +/** Strict requires matching source→target types; loose allows any handle. */ +export type ConnectionMode = 'strict' | 'loose'; + +/** Predicate gating which connections may be committed. */ +export type IsValidConnection = (connection: Connection) => boolean; + +/** Live state of an in-progress connection drag (only the preview subscribes). */ +export interface ConnectionState { + inProgress: boolean; + /** Node the drag originated from. */ + fromNode: string | null; + /** Handle the drag originated from. */ + fromHandle: HandleBound | null; + /** Absolute flow position of the origin handle. */ + fromPosition: XYPosition | null; + /** Whether the origin acts as source or target (loose mode can invert). */ + fromType: HandleType | null; + /** Current pointer position in flow space. */ + toPosition: XYPosition | null; + /** Hovered candidate handle, if any. */ + toHandle: HandleBound | null; + toNode: string | null; + /** Null until a candidate is hovered; then validity of dropping there. */ + isValid: boolean | null; +} + +/** Whether a node counts as "inside" a marquee rect. */ +export type SelectionMode = 'partial' | 'full'; + +/** Granular node mutations emitted via `@nodes-change`. */ +export type NodeChange + = | { type: 'position'; id: string; position?: XYPosition; dragging?: boolean } + | { type: 'dimensions'; id: string; dimensions: Dimensions; resizing?: boolean } + | { type: 'select'; id: string; selected: boolean } + | { type: 'remove'; id: string } + | { type: 'add'; item: FlowNode; index?: number } + | { type: 'replace'; id: string; item: FlowNode }; + +/** Granular edge mutations emitted via `@edges-change`. */ +export type EdgeChange + = | { type: 'select'; id: string; selected: boolean } + | { type: 'remove'; id: string } + | { type: 'add'; item: FlowEdge; index?: number } + | { type: 'replace'; id: string; item: FlowEdge }; + +/** Override strings for screen-reader announcements (i18n). */ +export interface AriaLabelConfig { + 'node.a11yDescription.default'?: string; + 'node.a11yDescription.keyboardDisabled'?: string; + 'edge.a11yDescription.default'?: string; + 'controls.ariaLabel'?: string; + 'minimap.ariaLabel'?: string; +} diff --git a/vue/primitives/src/canvas/flow/utils.ts b/vue/primitives/src/canvas/flow/utils.ts new file mode 100644 index 0000000..643e783 --- /dev/null +++ b/vue/primitives/src/canvas/flow/utils.ts @@ -0,0 +1,293 @@ +import { clamp } from '@robonen/stdlib'; +import type { + Dimensions, + HandleBound, + HandleBounds, + InternalNode, + Position, + Rect, + SelectionMode, + Viewport, + XYPosition, +} from './types'; + +/** Re-exported so flow code has one import surface for clamping. */ +export { clamp }; + +/** A pane origin: only `left`/`top` matter for coordinate conversion. */ +export interface PaneOrigin { + left: number; + top: number; +} + +/** + * Convert a screen (client) point to flow space. + * + * Inverse of {@link flowToScreen}. The client point must have the pane's + * `left`/`top` subtracted first, then the viewport translation removed and the + * zoom divided out. On the pointer hot path — no allocations beyond the result. + */ +export function screenToFlow(point: XYPosition, vp: Viewport, origin: PaneOrigin): XYPosition { + return { + x: (point.x - origin.left - vp.x) / vp.zoom, + y: (point.y - origin.top - vp.y) / vp.zoom, + }; +} + +/** Convert a flow-space point to a screen (client) point. Inverse of {@link screenToFlow}. */ +export function flowToScreen(point: XYPosition, vp: Viewport, origin: PaneOrigin): XYPosition { + return { + x: point.x * vp.zoom + vp.x + origin.left, + y: point.y * vp.zoom + vp.y + origin.top, + }; +} + +/** + * New viewport that keeps `pointer` (pane-relative pixels) fixed on screen while + * the zoom changes to `newZoom`. Caller is expected to clamp `newZoom` first + * via {@link clampZoom}. + */ +export function zoomAtPointer(vp: Viewport, pointer: XYPosition, newZoom: number): Viewport { + const ratio = newZoom / vp.zoom; + return { + zoom: newZoom, + x: pointer.x - (pointer.x - vp.x) * ratio, + y: pointer.y - (pointer.y - vp.y) * ratio, + }; +} + +/** + * Multiplicative zoom factor for a wheel event, normalising the three + * `deltaMode` units and amplifying trackpad pinch (`ctrlKey`). Multiply the + * current zoom by the result. + */ +export function wheelToZoomFactor(event: WheelEvent): number { + const unit = event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002; + const delta = -event.deltaY * unit * (event.ctrlKey ? 10 : 1); + return 2 ** delta; +} + +/** Clamp a zoom level to `[min, max]`. */ +export function clampZoom(zoom: number, min: number, max: number): number { + return clamp(zoom, min, max); +} + +/** Snap a flow-space point to the nearest grid intersection. */ +export function snapPoint(point: XYPosition, grid: [number, number]): XYPosition { + const [gx, gy] = grid; + return { + x: gx ? Math.round(point.x / gx) * gx : point.x, + y: gy ? Math.round(point.y / gy) * gy : point.y, + }; +} + +/** Node geometry needed for bounds/hit-testing: absolute position + measured size. */ +export interface NodeBox { + positionAbsolute: XYPosition; + measured: Dimensions; +} + +/** + * Bounding rect (flow space) enclosing every node. Single pass. Returns a zero + * rect when the iterable is empty. + */ +export function getNodesBounds(nodes: Iterable): Rect { + let xMin = Infinity; + let yMin = Infinity; + let xMax = -Infinity; + let yMax = -Infinity; + let seen = false; + + for (const node of nodes) { + seen = true; + const { x, y } = node.positionAbsolute; + const { width, height } = node.measured; + if (x < xMin) xMin = x; + if (y < yMin) yMin = y; + if (x + width > xMax) xMax = x + width; + if (y + height > yMax) yMax = y + height; + } + + if (!seen) return { x: 0, y: 0, width: 0, height: 0 }; + return { x: xMin, y: yMin, width: xMax - xMin, height: yMax - yMin }; +} + +export interface FitViewOptions { + /** Fractional inset on each side, 0–1. @default 0.1 */ + padding?: number; + minZoom: number; + maxZoom: number; +} + +/** + * Viewport that fits `bounds` inside a `container` of the given size, centred, + * with `padding`. Zoom is clamped to `[minZoom, maxZoom]`. + */ +export function fitViewTransform(bounds: Rect, container: Dimensions, opts: FitViewOptions): Viewport { + const padding = opts.padding ?? 0.1; + const { width: cw, height: ch } = container; + const bw = bounds.width || 1; + const bh = bounds.height || 1; + + const zoom = clamp(Math.min(cw / bw, ch / bh) * (1 - padding), opts.minZoom, opts.maxZoom); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + + return { + zoom, + x: cw / 2 - centerX * zoom, + y: ch / 2 - centerY * zoom, + }; +} + +/** Whether two flow-space rects overlap (touching edges count as overlap). */ +export function rectsIntersect(a: Rect, b: Rect): boolean { + return ( + a.x <= b.x + b.width + && a.x + a.width >= b.x + && a.y <= b.y + b.height + && a.y + a.height >= b.y + ); +} + +/** Whether rect `a` fully contains rect `b`. */ +export function rectContains(a: Rect, b: Rect): boolean { + return ( + b.x >= a.x + && b.y >= a.y + && b.x + b.width <= a.x + a.width + && b.y + b.height <= a.y + a.height + ); +} + +/** Whether a node falls inside a marquee `rect` per the selection `mode`. */ +export function nodeInRect(node: NodeBox, rect: Rect, mode: SelectionMode): boolean { + const box: Rect = { + x: node.positionAbsolute.x, + y: node.positionAbsolute.y, + width: node.measured.width, + height: node.measured.height, + }; + return mode === 'full' ? rectContains(rect, box) : rectsIntersect(rect, box); +} + +/** Ids of nodes selected by a marquee `rect`. */ +export function getNodesInsideRect( + nodes: Iterable, + rect: Rect, + mode: SelectionMode, +): string[] { + const ids: string[] = []; + for (const node of nodes) { + if (node.hidden) continue; + if (nodeInRect(node, rect, mode)) ids.push(node.id); + } + return ids; +} + +/** + * The flow-space rect currently visible through the viewport, expanded by + * `buffer` screen pixels on each side. Drives virtualization culling. + */ +export function visibleFlowRect(vp: Viewport, container: Dimensions, buffer: number): Rect { + return { + x: (-vp.x - buffer) / vp.zoom, + y: (-vp.y - buffer) / vp.zoom, + width: (container.width + 2 * buffer) / vp.zoom, + height: (container.height + 2 * buffer) / vp.zoom, + }; +} + +/** + * Absolute flow position of a node = its `position` plus the sum of every + * ancestor's position. Guards against cycles via a depth cap. + */ +export function getNodePositionAbsolute( + node: { id: string; position: XYPosition; parentId?: string }, + lookup: Map, +): XYPosition { + let x = node.position.x; + let y = node.position.y; + let parentId = node.parentId; + let guard = 0; + + while (parentId && guard++ < 100) { + const parent = lookup.get(parentId); + if (!parent) break; + x += parent.position.x; + y += parent.position.y; + parentId = parent.parentId; + } + + return { x, y }; +} + +/** + * Measure a node's handle rectangles from the DOM, relative to the node's + * top-left and converted to flow space (divided by `zoom`). Called once per + * measurement, not per frame. + */ +export function getHandleBoundsFromDom( + nodeEl: HTMLElement, + zoom: number, +): HandleBounds { + const nodeRect = nodeEl.getBoundingClientRect(); + const handles = nodeEl.querySelectorAll('[data-handleid]'); + const bounds: HandleBounds = { source: [], target: [] }; + + for (const handle of handles) { + const type = (handle.dataset['handletype'] as HandleBound['type']) ?? 'source'; + const rect = handle.getBoundingClientRect(); + const bound: HandleBound = { + id: handle.dataset['handleid'] || null, + type, + position: (handle.dataset['handlepos'] as Position) ?? 'top', + x: (rect.left - nodeRect.left) / zoom, + y: (rect.top - nodeRect.top) / zoom, + width: rect.width / zoom, + height: rect.height / zoom, + }; + bounds[type].push(bound); + } + + return bounds; +} + +/** Absolute flow point at the centre of a measured handle. */ +export function getAbsoluteHandlePoint(positionAbsolute: XYPosition, handle: HandleBound): XYPosition { + return { + x: positionAbsolute.x + handle.x + handle.width / 2, + y: positionAbsolute.y + handle.y + handle.height / 2, + }; +} + +/** + * Pick a handle from a node's measured bounds. Falls back to the first handle of + * the right type when `handleId` is null (single-handle nodes). + */ +export function findHandle( + node: InternalNode | undefined, + type: HandleBound['type'], + handleId: string | null | undefined, +): HandleBound | undefined { + const list = node?.handleBounds?.[type]; + if (!list || list.length === 0) return undefined; + if (handleId === null || handleId === undefined) return list[0]; + return list.find(h => h.id === handleId) ?? list[0]; +} + +/** + * Default attachment point for an edge endpoint when a node has no measured + * handles yet — the centre of the appropriate side, or the node centre. + */ +export function getDefaultEndpoint(node: NodeBox, position?: Position): XYPosition { + const { x, y } = node.positionAbsolute; + const { width, height } = node.measured; + switch (position) { + case 'top': return { x: x + width / 2, y }; + case 'bottom': return { x: x + width / 2, y: y + height }; + case 'left': return { x, y: y + height / 2 }; + case 'right': return { x: x + width, y: y + height / 2 }; + default: return { x: x + width / 2, y: y + height / 2 }; + } +} diff --git a/vue/primitives/src/canvas/flow/virtualization.ts b/vue/primitives/src/canvas/flow/virtualization.ts new file mode 100644 index 0000000..c8fddb1 --- /dev/null +++ b/vue/primitives/src/canvas/flow/virtualization.ts @@ -0,0 +1,45 @@ +import type { FlowEdge, FlowNode, InternalNode, Rect } from './types'; +import { rectsIntersect } from './utils'; + +/** + * Ids of nodes whose box intersects the (buffered) visible `rect`. Unmeasured + * nodes are treated as points at their absolute position, so they are kept while + * near the viewport rather than flickering out before first measure. + */ +export function getVisibleNodeIds( + nodes: Iterable, + nodeLookup: Map, + rect: Rect, +): string[] { + const ids: string[] = []; + for (const node of nodes) { + if (node.hidden) continue; + const internal = nodeLookup.get(node.id); + if (!internal) { + ids.push(node.id); + continue; + } + const box: Rect = { + x: internal.positionAbsolute.x, + y: internal.positionAbsolute.y, + width: internal.measured.width, + height: internal.measured.height, + }; + if (rectsIntersect(rect, box)) ids.push(node.id); + } + return ids; +} + +/** + * Ids of edges with at least one endpoint among the visible nodes. Cheap and + * good enough: short edges between two off-screen nodes are culled, which is the + * desired behaviour for large graphs. + */ +export function getVisibleEdgeIds(edges: Iterable, visibleNodes: Set): string[] { + const ids: string[] = []; + for (const edge of edges) { + if (edge.hidden) continue; + if (visibleNodes.has(edge.source) || visibleNodes.has(edge.target)) ids.push(edge.id); + } + return ids; +} diff --git a/vue/primitives/src/canvas/gradient-editor/GradientEditorAngle.vue b/vue/primitives/src/canvas/gradient-editor/GradientEditorAngle.vue new file mode 100644 index 0000000..75cfd78 --- /dev/null +++ b/vue/primitives/src/canvas/gradient-editor/GradientEditorAngle.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/vue/primitives/src/canvas/gradient-editor/GradientEditorColorEditor.vue b/vue/primitives/src/canvas/gradient-editor/GradientEditorColorEditor.vue new file mode 100644 index 0000000..567529c --- /dev/null +++ b/vue/primitives/src/canvas/gradient-editor/GradientEditorColorEditor.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/vue/primitives/src/canvas/gradient-editor/GradientEditorRoot.vue b/vue/primitives/src/canvas/gradient-editor/GradientEditorRoot.vue new file mode 100644 index 0000000..738aa00 --- /dev/null +++ b/vue/primitives/src/canvas/gradient-editor/GradientEditorRoot.vue @@ -0,0 +1,372 @@ + + + + + diff --git a/vue/primitives/src/canvas/gradient-editor/GradientEditorStop.vue b/vue/primitives/src/canvas/gradient-editor/GradientEditorStop.vue new file mode 100644 index 0000000..d0bb6bf --- /dev/null +++ b/vue/primitives/src/canvas/gradient-editor/GradientEditorStop.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/vue/primitives/src/canvas/gradient-editor/GradientEditorStops.vue b/vue/primitives/src/canvas/gradient-editor/GradientEditorStops.vue new file mode 100644 index 0000000..7b15da4 --- /dev/null +++ b/vue/primitives/src/canvas/gradient-editor/GradientEditorStops.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/vue/primitives/src/canvas/gradient-editor/GradientEditorTrack.vue b/vue/primitives/src/canvas/gradient-editor/GradientEditorTrack.vue new file mode 100644 index 0000000..53dc8ba --- /dev/null +++ b/vue/primitives/src/canvas/gradient-editor/GradientEditorTrack.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/vue/primitives/src/canvas/gradient-editor/__test__/GradientEditor.test.ts b/vue/primitives/src/canvas/gradient-editor/__test__/GradientEditor.test.ts new file mode 100644 index 0000000..85568e7 --- /dev/null +++ b/vue/primitives/src/canvas/gradient-editor/__test__/GradientEditor.test.ts @@ -0,0 +1,446 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import { + GradientEditorRoot, + GradientEditorStops, + GradientEditorTrack, +} from '../index'; +import type { GradientStop } from '../index'; +import { buildCssGradient, interpolateColorAt, neighboursAt, sortStops } from '../utils'; + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +type RootOpts = Partial<{ + type: 'linear' | 'radial'; + defaultAngle: number; + minStops: number; + reorder: boolean; + step: number; + largeStep: number; + snapStep: number; + disabled: boolean; + dir: 'ltr' | 'rtl'; + defaultSelectedId: string | null; +}>; + +function mountEditor(initial: GradientStop[], opts: RootOpts = {}) { + const model = ref(initial.map(s => ({ ...s }))); + const angle = ref(undefined); + const Harness = defineComponent({ + setup() { + return () => h(GradientEditorRoot, { + modelValue: model.value, + 'onUpdate:modelValue': (v: GradientStop[] | null | undefined) => { if (v) model.value = v; }, + angle: angle.value, + 'onUpdate:angle': (v: number | null | undefined) => { angle.value = v ?? undefined; }, + defaultSelectedId: initial[0]?.id ?? null, + ...opts, + }, { + default: () => h(GradientEditorTrack, null, { + default: () => h(GradientEditorStops), + }), + }); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + return { wrapper: w, model, angle }; +} + +function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false }); + el.dispatchEvent(event); +} + +function pointer(el: Element, type: string, x: number, y: number, button = 0): void { + const ev = new PointerEvent(type, { + pointerId: 1, + button, + clientX: x, + clientY: y, + bubbles: true, + cancelable: true, + }); + el.dispatchEvent(ev); +} + +function stopEls(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="slider"]')); +} + +function sizeTrack(el: HTMLElement): { left: number; width: number } { + el.style.position = 'fixed'; + el.style.left = '0px'; + el.style.top = '0px'; + el.style.width = '200px'; + el.style.height = '20px'; + const rect = el.getBoundingClientRect(); + return { left: rect.left, width: rect.width }; +} + +function trackEl(): HTMLElement { + // The track is the GradientEditorStops' parent (the Track's rendered element). + return stopEls()[0]!.parentElement!.parentElement!; +} + +const STOPS: GradientStop[] = [ + { id: 'a', position: 0, color: '#ff0000' }, + { id: 'b', position: 1, color: '#0000ff' }, +]; + +describe('GradientEditorStop — ARIA', () => { + it('renders role=slider with aria-valuemin/max/now and " at %" valuetext', async () => { + mountEditor(STOPS); + await nextTick(); + const stops = stopEls(); + expect(stops).toHaveLength(2); + expect(stops[0]!.getAttribute('role')).toBe('slider'); + expect(stops[0]!.getAttribute('aria-valuemin')).toBe('0'); + expect(stops[0]!.getAttribute('aria-valuemax')).toBe('1'); + expect(stops[0]!.getAttribute('aria-valuenow')).toBe('0'); + expect(stops[0]!.getAttribute('aria-valuetext')).toBe('#ff0000 at 0%'); + expect(stops[1]!.getAttribute('aria-valuetext')).toBe('#0000ff at 100%'); + }); + + it('roving tabindex: only the selected stop is tabbable', async () => { + mountEditor(STOPS, { defaultSelectedId: 'a' }); + await nextTick(); + const stops = stopEls(); + expect(stops[0]!.tabIndex).toBe(0); + expect(stops[1]!.tabIndex).toBe(-1); + }); + + it('reflects selection via aria-selected + data-selected', async () => { + mountEditor(STOPS, { defaultSelectedId: 'a' }); + await nextTick(); + const stops = stopEls(); + expect(stops[0]!.getAttribute('aria-selected')).toBe('true'); + expect(stops[0]!.hasAttribute('data-selected')).toBe(true); + expect(stops[1]!.getAttribute('aria-selected')).toBeNull(); + }); + + it('default aria-label is "Stop N of M"; explicit label wins', async () => { + mountEditor(STOPS); + await nextTick(); + expect(stopEls()[0]!.getAttribute('aria-label')).toBe('Stop 1 of 2'); + }); +}); + +describe('GradientEditorStop — keyboard', () => { + it('ArrowRight moves the selected stop by step; ArrowLeft moves it back', async () => { + const { model } = mountEditor( + [{ id: 'a', position: 0.5, color: '#ff0000' }, { id: 'b', position: 1, color: '#0000ff' }], + { step: 0.01, defaultSelectedId: 'a' }, + ); + await nextTick(); + const stop = stopEls()[0]!; + keydown(stop, 'ArrowRight'); + await nextTick(); + expect(model.value.find(s => s.id === 'a')!.position).toBeCloseTo(0.51); + keydown(stop, 'ArrowLeft'); + keydown(stop, 'ArrowLeft'); + await nextTick(); + expect(model.value.find(s => s.id === 'a')!.position).toBeCloseTo(0.49); + }); + + it('Shift+Arrow / Page move by largeStep', async () => { + const { model } = mountEditor( + [{ id: 'a', position: 0.5, color: '#f00' }, { id: 'b', position: 1, color: '#00f' }], + { step: 0.001, largeStep: 0.1, defaultSelectedId: 'a' }, + ); + await nextTick(); + const stop = stopEls()[0]!; + keydown(stop, 'ArrowRight', { shiftKey: true }); + await nextTick(); + expect(model.value.find(s => s.id === 'a')!.position).toBeCloseTo(0.6); + keydown(stop, 'PageDown'); + await nextTick(); + expect(model.value.find(s => s.id === 'a')!.position).toBeCloseTo(0.5); + }); + + it('Home/End jump to 0 / 1', async () => { + const { model } = mountEditor( + [{ id: 'a', position: 0.5, color: '#f00' }, { id: 'b', position: 0.6, color: '#00f' }], + { reorder: true, defaultSelectedId: 'a' }, + ); + await nextTick(); + const stop = stopEls()[0]!; + keydown(stop, 'Home'); + await nextTick(); + expect(model.value.find(s => s.id === 'a')!.position).toBe(0); + keydown(stop, 'End'); + await nextTick(); + expect(model.value.find(s => s.id === 'a')!.position).toBe(1); + }); + + it('ArrowLeft is reversed in RTL', async () => { + const { model } = mountEditor( + [{ id: 'a', position: 0.5, color: '#f00' }, { id: 'b', position: 1, color: '#00f' }], + { step: 0.01, dir: 'rtl', defaultSelectedId: 'a' }, + ); + await nextTick(); + keydown(stopEls()[0]!, 'ArrowLeft'); + await nextTick(); + expect(model.value.find(s => s.id === 'a')!.position).toBeCloseTo(0.51); + }); + + it('Delete removes the selected stop', async () => { + const { model } = mountEditor( + [ + { id: 'a', position: 0, color: '#f00' }, + { id: 'b', position: 0.5, color: '#0f0' }, + { id: 'c', position: 1, color: '#00f' }, + ], + { minStops: 2, defaultSelectedId: 'b' }, + ); + await nextTick(); + const stop = stopEls()[1]!; + keydown(stop, 'Delete'); + await nextTick(); + expect(model.value.map(s => s.id)).toEqual(['a', 'c']); + }); + + it('Delete is a no-op at minStops', async () => { + const { model } = mountEditor(STOPS, { minStops: 2, defaultSelectedId: 'a' }); + await nextTick(); + keydown(stopEls()[0]!, 'Delete'); + await nextTick(); + expect(model.value).toHaveLength(2); + }); +}); + +describe('GradientEditorTrack — add on click', () => { + it('clicking the track adds a stop at the click position', async () => { + const { model } = mountEditor(STOPS, { step: 0.01 }); + await nextTick(); + const track = trackEl(); + const { left, width } = sizeTrack(track); + // Click at 25% of the track. + pointer(track, 'pointerdown', left + width * 0.25, 10); + await nextTick(); + expect(model.value).toHaveLength(3); + const added = model.value.find(s => s.id !== 'a' && s.id !== 'b')!; + expect(added.position).toBeCloseTo(0.25, 2); + }); + + it('interpolates the new stop color from the neighbours', async () => { + const { model } = mountEditor( + [{ id: 'a', position: 0, color: '#000000' }, { id: 'b', position: 1, color: '#ffffff' }], + { step: 0.01 }, + ); + await nextTick(); + const track = trackEl(); + const { left, width } = sizeTrack(track); + pointer(track, 'pointerdown', left + width * 0.5, 10); + await nextTick(); + const added = model.value.find(s => s.id !== 'a' && s.id !== 'b')!; + // Midway between black and white → mid grey ~ rgb(128,128,128). + expect(added.color).toMatch(/^rgba\(12[78], 12[78], 12[78], 1\)$/); + }); + + it('Enter on the focused track adds a stop at the center', async () => { + const { model } = mountEditor(STOPS); + await nextTick(); + const track = trackEl(); + track.focus(); + keydown(track, 'Enter'); + await nextTick(); + expect(model.value).toHaveLength(3); + const added = model.value.find(s => s.id !== 'a' && s.id !== 'b')!; + expect(added.position).toBe(0.5); + }); +}); + +describe('GradientEditorRoot — reorder policy', () => { + it('reorder=false neighbour-clamps (ids never cross)', async () => { + const { model } = mountEditor( + [ + { id: 'a', position: 0.4, color: '#f00' }, + { id: 'b', position: 0.5, color: '#0f0' }, + { id: 'c', position: 0.6, color: '#00f' }, + ], + { reorder: false, defaultSelectedId: 'a' }, + ); + await nextTick(); + // Push 'a' hard right past 'b'; it must clamp to b's position, never cross. + keydown(stopEls()[0]!, 'End'); + await nextTick(); + const a = model.value.find(s => s.id === 'a')!; + expect(a.position).toBe(0.5); + // Order preserved. + expect(sortStops(model.value).map(s => s.id)).toEqual(['a', 'b', 'c']); + }); + + it('reorder=true lets a stop cross and re-sorts; each id keeps its color', async () => { + const { model } = mountEditor( + [ + { id: 'a', position: 0.4, color: '#ff0000' }, + { id: 'b', position: 0.5, color: '#00ff00' }, + { id: 'c', position: 0.6, color: '#0000ff' }, + ], + { reorder: true, defaultSelectedId: 'a' }, + ); + await nextTick(); + keydown(stopEls()[0]!, 'End'); + await nextTick(); + // 'a' is now last by position but keeps its red color. + const a = model.value.find(s => s.id === 'a')!; + expect(a.position).toBe(1); + expect(a.color).toBe('#ff0000'); + expect(sortStops(model.value).map(s => s.id)).toEqual(['b', 'c', 'a']); + }); +}); + +describe('GradientEditorRoot — disabled', () => { + it('blocks keyboard moves, removal, and track clicks', async () => { + const { model } = mountEditor( + [{ id: 'a', position: 0.5, color: '#f00' }, { id: 'b', position: 1, color: '#00f' }], + { disabled: true, defaultSelectedId: 'a' }, + ); + await nextTick(); + const stops = stopEls(); + expect(stops[0]!.tabIndex).toBe(-1); + expect(stops[0]!.getAttribute('aria-disabled')).toBe('true'); + keydown(stops[0]!, 'ArrowRight'); + keydown(stops[0]!, 'Delete'); + await nextTick(); + expect(model.value.find(s => s.id === 'a')!.position).toBe(0.5); + expect(model.value).toHaveLength(2); + // Track click does nothing. + const track = trackEl(); + const { left, width } = sizeTrack(track); + pointer(track, 'pointerdown', left + width * 0.5, 10); + await nextTick(); + expect(model.value).toHaveLength(2); + }); +}); + +describe('GradientEditorRoot — cssGradient via exposed root', () => { + it('reflects stops + type + angle', async () => { + const exposed = ref(null); + const Harness = defineComponent({ + setup() { + const model = ref([ + { id: 'a', position: 0, color: '#ff0000' }, + { id: 'b', position: 1, color: '#0000ff' }, + ]); + return () => h(GradientEditorRoot, { + ref: (r: any) => { exposed.value = r; }, + modelValue: model.value, + 'onUpdate:modelValue': (v: GradientStop[] | null | undefined) => { if (v) model.value = v; }, + type: 'linear', + defaultAngle: 45, + }, { + default: () => h(GradientEditorTrack, null, { default: () => h(GradientEditorStops) }), + }); + }, + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + expect(exposed.value.cssGradient).toBe('linear-gradient(45deg, #ff0000 0%, #0000ff 100%)'); + }); +}); + +describe('utils — sort / tie-break', () => { + it('sorts ascending by position', () => { + const out = sortStops([ + { id: 'b', position: 0.8, color: '#00f' }, + { id: 'a', position: 0.2, color: '#f00' }, + ]); + expect(out.map(s => s.id)).toEqual(['a', 'b']); + }); + + it('keeps both stops at identical positions (stable tie-break by index)', () => { + const input: GradientStop[] = [ + { id: 'x', position: 0.5, color: '#f00' }, + { id: 'y', position: 0.5, color: '#0f0' }, + { id: 'z', position: 0.5, color: '#00f' }, + ]; + const out = sortStops(input); + // Order preserved (stable) and all three present. + expect(out.map(s => s.id)).toEqual(['x', 'y', 'z']); + expect(out).toHaveLength(3); + }); + + it('does not mutate the input', () => { + const input: GradientStop[] = [ + { id: 'b', position: 1, color: '#00f' }, + { id: 'a', position: 0, color: '#f00' }, + ]; + const snapshot = input.map(s => s.id); + sortStops(input); + expect(input.map(s => s.id)).toEqual(snapshot); + }); +}); + +describe('utils — buildCssGradient', () => { + it('builds a linear gradient with the angle', () => { + const css = buildCssGradient( + [{ id: 'a', position: 0, color: 'red' }, { id: 'b', position: 1, color: 'blue' }], + 'linear', + 90, + ); + expect(css).toBe('linear-gradient(90deg, red 0%, blue 100%)'); + }); + + it('builds a radial gradient (ignores angle)', () => { + const css = buildCssGradient( + [{ id: 'a', position: 0, color: 'red' }, { id: 'b', position: 0.5, color: 'blue' }], + 'radial', + 45, + ); + expect(css).toBe('radial-gradient(circle, red 0%, blue 50%)'); + }); + + it('compacts fractional percentages', () => { + const css = buildCssGradient([{ id: 'a', position: 1 / 3, color: 'red' }], 'linear', 0); + expect(css).toBe('linear-gradient(0deg, red 33.333%)'); + }); + + it('returns empty string for no stops', () => { + expect(buildCssGradient([], 'linear', 90)).toBe(''); + }); +}); + +describe('utils — color interpolation / neighbours', () => { + it('interpolates midway between two colors', () => { + const c = interpolateColorAt( + 0.5, + { id: 'a', position: 0, color: '#000000' }, + { id: 'b', position: 1, color: '#ffffff' }, + '#000000', + ); + expect(c).toMatch(/^rgba\(12[78], 12[78], 12[78], 1\)$/); + }); + + it('copies the single neighbour when only one exists', () => { + const c = interpolateColorAt(0.3, { id: 'a', position: 0, color: '#abcdef' }, null, '#000000'); + expect(c).toBe('#abcdef'); + }); + + it('falls back when there are no neighbours', () => { + expect(interpolateColorAt(0.5, null, null, '#123456')).toBe('#123456'); + }); + + it('neighboursAt finds the bracketing stops', () => { + const sorted: GradientStop[] = [ + { id: 'a', position: 0, color: '#f00' }, + { id: 'b', position: 0.5, color: '#0f0' }, + { id: 'c', position: 1, color: '#00f' }, + ]; + const { before, after } = neighboursAt(sorted, 0.3); + expect(before!.id).toBe('a'); + expect(after!.id).toBe('b'); + }); +}); diff --git a/vue/primitives/src/canvas/gradient-editor/__test__/__screenshots__/GradientEditor.test.ts/GradientEditorTrack---add-on-click-Enter-on-the-focused-track-adds-a-stop-at-the-center-1.png b/vue/primitives/src/canvas/gradient-editor/__test__/__screenshots__/GradientEditor.test.ts/GradientEditorTrack---add-on-click-Enter-on-the-focused-track-adds-a-stop-at-the-center-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/gradient-editor/__test__/__screenshots__/GradientEditor.test.ts/GradientEditorTrack---add-on-click-Enter-on-the-focused-track-adds-a-stop-at-the-center-1.png differ diff --git a/vue/primitives/src/canvas/gradient-editor/__test__/__screenshots__/GradientEditor.test.ts/GradientEditorTrack---add-on-click-clicking-the-track-adds-a-stop-at-the-click-position-1.png b/vue/primitives/src/canvas/gradient-editor/__test__/__screenshots__/GradientEditor.test.ts/GradientEditorTrack---add-on-click-clicking-the-track-adds-a-stop-at-the-click-position-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/gradient-editor/__test__/__screenshots__/GradientEditor.test.ts/GradientEditorTrack---add-on-click-clicking-the-track-adds-a-stop-at-the-click-position-1.png differ diff --git a/vue/primitives/src/canvas/gradient-editor/__test__/__screenshots__/GradientEditor.test.ts/GradientEditorTrack---add-on-click-interpolates-the-new-stop-color-from-the-neighbours-1.png b/vue/primitives/src/canvas/gradient-editor/__test__/__screenshots__/GradientEditor.test.ts/GradientEditorTrack---add-on-click-interpolates-the-new-stop-color-from-the-neighbours-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/gradient-editor/__test__/__screenshots__/GradientEditor.test.ts/GradientEditorTrack---add-on-click-interpolates-the-new-stop-color-from-the-neighbours-1.png differ diff --git a/vue/primitives/src/canvas/gradient-editor/context.ts b/vue/primitives/src/canvas/gradient-editor/context.ts new file mode 100644 index 0000000..638beaf --- /dev/null +++ b/vue/primitives/src/canvas/gradient-editor/context.ts @@ -0,0 +1,122 @@ +import type { Ref } from 'vue'; +import { useContextFactory } from '@robonen/vue'; + +export type GradientEditorDirection = 'ltr' | 'rtl'; + +/** Whether the gradient is rendered linearly (with an `angle`) or radially. */ +export type GradientType = 'linear' | 'radial'; + +/** + * A single color stop on the gradient bar. + * + * `position` is a fraction in `[0, 1]` (`0` = start of the bar, `1` = end) and + * `color` is any CSS color string (`#rrggbb`, `rgb()/rgba()`, `hsl()/hsla()`). + * `id` is a stable identity used as the `v-for` key, the roving-focus handle, + * and the tie-break for two stops sharing a `position`. + */ +export interface GradientStop { + /** Stable identity (v-for key, roving-focus handle, selection target). */ + id: string; + /** Position along the bar as a fraction in `[0, 1]`. */ + position: number; + /** Any CSS color string. */ + color: string; +} + +/** Patch applied to a stop by {@link GradientEditorContext.updateStop}. */ +export interface GradientStopPatch { + /** New position (fraction `[0, 1]`). */ + position?: number; + /** New CSS color string. */ + color?: string; +} + +/** + * Formatter turning a stop's `color` + `position` into a human-friendly string + * for `aria-valuetext`. Defaults to `` `${color} at ${pct}%` ``. Color is NEVER + * surfaced alone (WCAG 1.4.1) — keep the position in any override. + */ +export type GradientEditorValueText = (color: string, position: number) => string; + +/** A stop paired with its index in the sorted list, keyed by id in {@link GradientEditorContext.stopIndex}. */ +export interface GradientStopEntry { + /** The stop. */ + stop: GradientStop; + /** Its index within the sorted stops. */ + index: number; +} + +/** + * Context shared between `GradientEditorRoot` and its descendants. + * + * Scalar props are exposed as plain `Ref` values, built by the root with + * `toRef(() => prop)` (a `GetterRefImpl` that is reactive without an extra + * `ReactiveEffect` / cache), mirroring the slider / curve-editor convention. + */ +export interface GradientEditorContext { + /** The live stops, sorted ascending by position (stable tie-break by index). */ + stops: Ref; + /** + * Memoized `id -> { stop, index }` over the sorted stops, rebuilt once per + * stops change. Per-stop parts read this for O(1) lookups instead of scanning + * the wholesale-replaced array each drag frame. + */ + stopIndex: Ref>; + /** The id of the currently selected stop, or `null`. */ + selectedId: Ref; + /** Gradient type — `'linear'` (uses `angle`) or `'radial'`. */ + type: Ref; + /** Linear gradient angle in degrees. */ + angle: Ref; + /** Minimum number of stops; removal is blocked at this floor. */ + minStops: Ref; + /** Keyboard step (fraction) for Arrow nudges. */ + step: Ref; + /** Large keyboard step (Shift+Arrow / Page keys). */ + largeStep: Ref; + /** Optional grid snap increment (fraction); `undefined` disables grid snap. */ + snapStep: Ref; + /** Whether dragging a stop past a neighbour re-sorts (`true`) or clamps (`false`). */ + reorder: Ref; + /** Optional per-stop `aria-valuetext` formatter. */ + valueText: Ref; + direction: Ref; + disabled: Ref; + /** The CSS gradient string for the current stops + type + angle (preview). */ + cssGradient: Ref; + /** Whether removal is allowed (`stops.length > minStops`). */ + canRemove: Ref; + /** The bar element used for pointer ↔ position math (set by the Track). */ + trackRef: Ref; + /** Set the linear gradient angle (degrees), writing the `angle` model. */ + setAngle: (deg: number) => void; + /** Select a stop (or clear with `null`). */ + select: (id: string | null) => void; + /** + * Add a stop at `position` (fraction `[0, 1]`). When `color` is omitted it is + * interpolated from the neighbouring stops. Returns the new stop's id, or + * `undefined` when disabled. + */ + addStop: (position: number, color?: string) => string | undefined; + /** Remove a stop by id (no-op at `minStops` or when disabled). */ + removeStop: (id: string) => void; + /** Patch a stop's `position` / `color` by id (neighbour-clamped position). */ + updateStop: (id: string, patch: GradientStopPatch) => void; + /** + * Move a stop to `position` honoring the `reorder` policy: when `reorder` is + * `false` the position is clamped between its neighbours (ids never cross); + * when `true` it may cross and the array re-sorts (each id keeps its color). + */ + moveStop: (id: string, position: number) => void; + /** Register a stop's DOM element so roving focus can move between them. */ + registerStopEl: (id: string, el: HTMLElement | null) => void; + /** The element for a stop id, or `null`. */ + getStopEl: (id: string) => HTMLElement | null; + /** Index of a stop id within the sorted stops (`-1` when absent). */ + indexOf: (id: string) => number; +} + +const ctx = useContextFactory('GradientEditorContext'); + +export const provideGradientEditorContext = ctx.provide; +export const useGradientEditorContext = ctx.inject; diff --git a/vue/primitives/src/canvas/gradient-editor/demo.vue b/vue/primitives/src/canvas/gradient-editor/demo.vue new file mode 100644 index 0000000..2325972 --- /dev/null +++ b/vue/primitives/src/canvas/gradient-editor/demo.vue @@ -0,0 +1,108 @@ + + + diff --git a/vue/primitives/src/canvas/gradient-editor/index.ts b/vue/primitives/src/canvas/gradient-editor/index.ts new file mode 100644 index 0000000..e07eb2b --- /dev/null +++ b/vue/primitives/src/canvas/gradient-editor/index.ts @@ -0,0 +1,34 @@ +export { default as GradientEditorRoot } from './GradientEditorRoot.vue'; +export { default as GradientEditorAngle } from './GradientEditorAngle.vue'; +export { default as GradientEditorColorEditor } from './GradientEditorColorEditor.vue'; +export { default as GradientEditorStop } from './GradientEditorStop.vue'; +export { default as GradientEditorStops } from './GradientEditorStops.vue'; +export { default as GradientEditorTrack } from './GradientEditorTrack.vue'; + +export type { GradientEditorRootEmits, GradientEditorRootProps } from './GradientEditorRoot.vue'; +export type { GradientEditorAngleProps } from './GradientEditorAngle.vue'; +export type { GradientEditorColorEditorProps } from './GradientEditorColorEditor.vue'; +export type { GradientEditorStopProps } from './GradientEditorStop.vue'; +export type { GradientEditorStopsProps } from './GradientEditorStops.vue'; +export type { GradientEditorTrackProps } from './GradientEditorTrack.vue'; + +export type { + GradientEditorContext, + GradientEditorDirection, + GradientEditorValueText, + GradientStop, + GradientStopPatch, + GradientType, +} from './context'; +export { + provideGradientEditorContext, + useGradientEditorContext, +} from './context'; + +export { + buildCssGradient, + defaultStopValueText, + interpolateColorAt, + neighboursAt, + sortStops, +} from './utils'; diff --git a/vue/primitives/src/canvas/gradient-editor/utils.ts b/vue/primitives/src/canvas/gradient-editor/utils.ts new file mode 100644 index 0000000..8799aa0 --- /dev/null +++ b/vue/primitives/src/canvas/gradient-editor/utils.ts @@ -0,0 +1,168 @@ +import type { GradientStop, GradientType } from './context'; +import { clamp } from '@robonen/stdlib'; +import { hsvToRgb, parseColor } from '../../internal/color'; +import type { RGB } from '../../internal/color'; + +/** + * Returns the number of decimal digits in a numeric `step`. + * + * Used by {@link roundToStep} to compensate floating-point drift without + * allocating strings on every invocation (the cost is paid once per `step` + * change and cached by the caller). + */ +export function getStepDecimals(step: number): number { + if (!Number.isFinite(step)) return 0; + const str = String(step); + const dot = str.indexOf('.'); + if (dot === -1) return 0; + return str.length - dot - 1; +} + +/** + * Snap `value` to the nearest multiple of `step` anchored at `0`. + * + * `decimals` must be pre-computed by the caller via {@link getStepDecimals} and + * cached per-`step` — this is on the pointermove hot path. + */ +export function roundToStep(value: number, step: number, decimals: number): number { + if (!(step > 0)) return value; + const nearest = Math.round(value / step) * step; + return decimals > 0 ? Number(nearest.toFixed(decimals)) : nearest; +} + +/** + * Stable comparator for two stops: ascending by `position`, then by the + * positional `index` they held in the source array as a tie-break. + * + * Tying on the source index (rather than `id`) keeps an O(n log n) sort total + * order deterministic AND stable — two stops at the same position never swap on + * an unrelated re-render, and the CSS string stays byte-identical frame to + * frame (important so the browser does not re-rasterize the preview). + */ +function compareIndexed(a: { stop: GradientStop; index: number }, b: { stop: GradientStop; index: number }): number { + if (a.stop.position !== b.stop.position) return a.stop.position - b.stop.position; + return a.index - b.index; +} + +/** + * Return a new array of `stops` sorted ascending by `position`, breaking ties at + * identical positions by the stop's original index so the order is stable and + * deterministic (both stops remain present and individually selectable). + * + * The input is never mutated. + */ +export function sortStops(stops: readonly GradientStop[]): GradientStop[] { + const indexed = stops.map((stop, index) => ({ stop, index })); + indexed.sort(compareIndexed); + return indexed.map(e => e.stop); +} + +/** Round a fractional position to a percentage string used in CSS gradients. */ +function positionToPercent(position: number): string { + const pct = clamp(position, 0, 1) * 100; + // Trim to at most 3 decimals, then drop trailing zeros for a stable, compact + // string (`50%` not `50.000%`); avoids float noise like `33.333333%`. + const rounded = Math.round(pct * 1000) / 1000; + return `${rounded}%`; +} + +/** + * Build a CSS `linear-gradient(...)` / `radial-gradient(...)` string from the + * (already-sorted) `stops`. + * + * For `'linear'` the `angle` (degrees) is emitted as `${angle}deg`; `'radial'` + * ignores the angle and produces `radial-gradient(circle, ...)`. Each stop + * contributes ` %`. Returns an empty string when there are no stops. + */ +export function buildCssGradient(stops: readonly GradientStop[], type: GradientType, angle: number): string { + if (stops.length === 0) return ''; + let body = ''; + for (let i = 0; i < stops.length; i++) { + const s = stops[i]!; + if (i > 0) body += ', '; + body += `${s.color} ${positionToPercent(s.position)}`; + } + if (type === 'radial') return `radial-gradient(circle, ${body})`; + return `linear-gradient(${angle}deg, ${body})`; +} + +/** Resolve any CSS color string to opaque-or-alpha RGBA channels, or `null`. */ +function toRgb(color: string): (RGB & { a: number }) | null { + const hsva = parseColor(color); + if (!hsva) return null; + const rgb = hsvToRgb(hsva); + return { r: rgb.r, g: rgb.g, b: rgb.b, a: hsva.a }; +} + +/** Linear interpolation of a single numeric channel. */ +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** + * Interpolate a color at fractional `position` (0..1) between two neighbouring + * stops `before`/`after` (either may be `null` at the ends). The blend happens + * in sRGB space and the result is returned as an `rgba(...)` string. + * + * - Both neighbours present: linearly blend by the relative position. + * - One neighbour: copy that neighbour's color. + * - Neither (no stops): fall back to `fallback`. + * - A neighbour whose color cannot be parsed is treated as absent. + */ +export function interpolateColorAt( + position: number, + before: GradientStop | null, + after: GradientStop | null, + fallback: string, +): string { + const lo = before ? toRgb(before.color) : null; + const hi = after ? toRgb(after.color) : null; + + if (lo && hi) { + const span = after!.position - before!.position; + const t = span <= 0 ? 0.5 : clamp((position - before!.position) / span, 0, 1); + const r = Math.round(lerp(lo.r, hi.r, t)); + const g = Math.round(lerp(lo.g, hi.g, t)); + const b = Math.round(lerp(lo.b, hi.b, t)); + const a = lerp(lo.a, hi.a, t); + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + if (lo) return before!.color; + if (hi) return after!.color; + return fallback; +} + +/** + * Given the sorted stops and an insertion `position`, find the immediate + * lower/upper neighbours (by position) used to interpolate a new stop's color. + * `before` is the last stop at or below `position`; `after` is the first stop + * strictly above it. + */ +export function neighboursAt( + sorted: readonly GradientStop[], + position: number, +): { before: GradientStop | null; after: GradientStop | null } { + let before: GradientStop | null = null; + let after: GradientStop | null = null; + for (let i = 0; i < sorted.length; i++) { + const s = sorted[i]!; + if (s.position <= position) { + before = s; + } + else { + after = s; + break; + } + } + return { before, after }; +} + +/** + * Default accessible value text for a stop: ` at %`. NEVER the color + * alone — a screen-reader user must hear the position too, and color must never + * be the sole carrier of meaning (WCAG 1.4.1 Use of Color). + */ +export function defaultStopValueText(color: string, position: number): string { + const pct = Math.round(clamp(position, 0, 1) * 100); + return `${color} at ${pct}%`; +} diff --git a/vue/primitives/src/canvas/histogram/HistogramBars.vue b/vue/primitives/src/canvas/histogram/HistogramBars.vue new file mode 100644 index 0000000..b07f619 --- /dev/null +++ b/vue/primitives/src/canvas/histogram/HistogramBars.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/vue/primitives/src/canvas/histogram/HistogramRoot.vue b/vue/primitives/src/canvas/histogram/HistogramRoot.vue new file mode 100644 index 0000000..cde60f5 --- /dev/null +++ b/vue/primitives/src/canvas/histogram/HistogramRoot.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/vue/primitives/src/canvas/histogram/__test__/Histogram.test.ts b/vue/primitives/src/canvas/histogram/__test__/Histogram.test.ts new file mode 100644 index 0000000..b4adca6 --- /dev/null +++ b/vue/primitives/src/canvas/histogram/__test__/Histogram.test.ts @@ -0,0 +1,136 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; +import { HistogramBars, HistogramRoot } from '../index'; +import { histogramMax, projectBarHeight, projectBars } from '../utils'; + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +function mountHistogram(props: Record) { + const Harness = defineComponent({ + setup: () => () => h(HistogramRoot, props, { default: () => h(HistogramBars) }), + }); + return track(mount(Harness, { attachTo: document.body })); +} + +describe('Histogram — bar projection (unit)', () => { + it('histogramMax returns the peak; 0 for empty / all-zero', () => { + expect(histogramMax([])).toBe(0); + expect(histogramMax([0, 0, 0])).toBe(0); + expect(histogramMax([1, 9, 3])).toBe(9); + }); + + it('linear projection normalises against the peak', () => { + const heights = projectBars([0, 5, 10], 'linear'); + expect(heights[0]).toBe(0); + expect(heights[1]).toBeCloseTo(0.5, 5); + expect(heights[2]).toBe(1); + }); + + it('log projection is 0 at empty bins and 1 at the peak, monotonic', () => { + const heights = projectBars([0, 5, 100], 'log'); + expect(heights[0]).toBe(0); + expect(heights[2]).toBeCloseTo(1, 5); + // Monotonic non-decreasing. + expect(heights[1]!).toBeGreaterThan(heights[0]!); + expect(heights[2]!).toBeGreaterThan(heights[1]!); + // Log compresses: the midpoint sits well above its linear 0.05. + expect(heights[1]!).toBeGreaterThan(0.05); + }); + + it('all-zero / empty input yields 0 height with no NaN', () => { + for (const scale of ['linear', 'log'] as const) { + const zeros = projectBars([0, 0, 0], scale); + expect(zeros.every(h => h === 0)).toBe(true); + expect(zeros.some(Number.isNaN)).toBe(false); + const empty = projectBars([], scale); + expect(empty).toHaveLength(0); + } + }); + + it('projectBarHeight guards divide-by-zero (max <= 0 → 0)', () => { + expect(projectBarHeight(5, 0, 'linear')).toBe(0); + expect(projectBarHeight(5, 0, 'log')).toBe(0); + expect(Number.isNaN(projectBarHeight(5, 0, 'linear'))).toBe(false); + }); +}); + +describe('Histogram — root ARIA + rendering', () => { + it('exposes role="img" with a channel summary label', async () => { + mountHistogram({ data: [1, 2, 3], channel: 'l' }); + await nextTick(); + const root = document.querySelector('[role="img"]')!; + expect(root).toBeTruthy(); + expect(root.getAttribute('aria-label')).toBe('Histogram, L'); + expect(root.getAttribute('data-channel')).toBe('l'); + }); + + it('reports "no data" for an all-zero / empty histogram', async () => { + mountHistogram({ data: [0, 0, 0], channel: 'r' }); + await nextTick(); + const root = document.querySelector('[role="img"]')!; + expect(root.getAttribute('aria-label')).toBe('Histogram, R, no data'); + expect(root.hasAttribute('data-empty')).toBe(true); + }); + + it('role="group" when group is set', async () => { + mountHistogram({ data: [1, 2], group: true }); + await nextTick(); + expect(document.querySelector('[role="group"]')).toBeTruthy(); + expect(document.querySelector('[role="img"]')).toBeNull(); + }); + + it('bars are aria-hidden and compute normalised heights', async () => { + mountHistogram({ data: [0, 50, 100], channel: 'l' }); + await nextTick(); + const bars = document.querySelector('[aria-hidden="true"]')!; + expect(bars).toBeTruthy(); + const drawn = bars.querySelectorAll('[data-bar]'); + expect(drawn).toHaveLength(3); + // Peak (100) → 100% height. + expect(drawn[2]!.style.height).toBe('100%'); + expect(drawn[0]!.style.height).toBe('0%'); + }); + + it('rgb composite expands to three primary channels', async () => { + const Harness = defineComponent({ + setup: () => () => h( + HistogramRoot, + { data: { r: [1, 2], g: [3, 4], b: [5, 6] }, channel: 'rgb' }, + { default: () => h(HistogramBars) }, + ), + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + expect(document.querySelector('[data-channel="r"]')).toBeTruthy(); + expect(document.querySelector('[data-channel="g"]')).toBeTruthy(); + expect(document.querySelector('[data-channel="b"]')).toBeTruthy(); + expect(document.querySelector('[role="img"]')!.getAttribute('aria-label')).toBe('Histogram, RGB'); + }); + + it('record data renders the requested single channel', async () => { + const Harness = defineComponent({ + setup: () => () => h( + HistogramRoot, + { data: { r: [10, 20], g: [0, 0] }, channel: 'r' }, + { default: () => h(HistogramBars) }, + ), + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + const group = document.querySelector('[data-channel="r"]')!; + const drawn = group.querySelectorAll('[data-bar]'); + expect(drawn[1]!.style.height).toBe('100%'); + }); +}); diff --git a/vue/primitives/src/canvas/histogram/context.ts b/vue/primitives/src/canvas/histogram/context.ts new file mode 100644 index 0000000..88e012a --- /dev/null +++ b/vue/primitives/src/canvas/histogram/context.ts @@ -0,0 +1,35 @@ +import type { ComputedRef, Ref } from 'vue'; +import { useContextFactory } from '@robonen/vue'; +import type { HistogramBarChannel, HistogramChannel, HistogramData, HistogramScaleType } from './utils'; + +/** + * Context shared between `HistogramRoot` and its descendants. + * + * `bars` returns the normalised heights `[0, 1]` for the requested concrete + * channel under the active scale (with the all-zero / empty guard applied), so + * `HistogramBars` never re-implements the projection or touches the peak. + */ +export interface HistogramContext { + /** Raw per-channel bin data, as supplied to the root. */ + data: Ref; + /** The root's primary channel (`'l'`/`'r'`/`'g'`/`'b'`/`'rgb'`). */ + channel: Ref; + /** Bar-height mapping (`'linear'` or `'log'`). */ + scaleType: Ref; + /** Requested bin count (bars are clamped/derived from the supplied data length). */ + bins: Ref; + /** Whether the histogram is non-interactive / dimmed. */ + disabled: Ref; + /** Whether the resolved primary channel has any non-zero data. */ + hasData: ComputedRef; + /** + * Normalised heights `[0, 1]` for `channel`. Empty/all-zero input yields + * all-zero heights — never `NaN`. Stable function identity. + */ + bars: (channel: HistogramBarChannel) => number[]; +} + +export const { + inject: useHistogramContext, + provide: provideHistogramContext, +} = useContextFactory('histogram'); diff --git a/vue/primitives/src/canvas/histogram/demo.vue b/vue/primitives/src/canvas/histogram/demo.vue new file mode 100644 index 0000000..f992cc9 --- /dev/null +++ b/vue/primitives/src/canvas/histogram/demo.vue @@ -0,0 +1,130 @@ + + + diff --git a/vue/primitives/src/canvas/histogram/index.ts b/vue/primitives/src/canvas/histogram/index.ts new file mode 100644 index 0000000..0c4434c --- /dev/null +++ b/vue/primitives/src/canvas/histogram/index.ts @@ -0,0 +1,23 @@ +export { default as HistogramRoot } from './HistogramRoot.vue'; +export { default as HistogramBars } from './HistogramBars.vue'; +export type { HistogramRootProps } from './HistogramRoot.vue'; +export type { HistogramBarsProps } from './HistogramBars.vue'; +export { + provideHistogramContext, + useHistogramContext, + type HistogramContext, +} from './context'; +export { + HISTOGRAM_CHANNEL_COLORS, + getChannelBins, + histogramMax, + isSingleChannelData, + projectBarHeight, + projectBars, +} from './utils'; +export type { + HistogramBarChannel, + HistogramChannel, + HistogramData, + HistogramScaleType, +} from './utils'; diff --git a/vue/primitives/src/canvas/histogram/utils.ts b/vue/primitives/src/canvas/histogram/utils.ts new file mode 100644 index 0000000..26fc9dc --- /dev/null +++ b/vue/primitives/src/canvas/histogram/utils.ts @@ -0,0 +1,111 @@ +/** + * The channels a histogram can describe. `'l'` is luminance (a single combined + * channel), `'r'`/`'g'`/`'b'` are the colour primaries, and `'rgb'` is the + * composite request that asks the display to render all three primaries + * overlaid. + */ +export type HistogramChannel = 'l' | 'r' | 'g' | 'b' | 'rgb'; + +/** A single concrete channel whose bins can be drawn (excludes the `'rgb'` composite). */ +export type HistogramBarChannel = 'l' | 'r' | 'g' | 'b'; + +/** How bin counts are mapped to bar heights. */ +export type HistogramScaleType = 'linear' | 'log'; + +/** + * Per-channel bin counts. Either a single `number[]` (interpreted as the channel + * named by the root's `channel` prop) or an object carrying any subset of the + * four concrete channels. + */ +export type HistogramData = number[] | Partial>; + +/** + * Default CSS colour stamped on `--histogram-color` for each channel so a + * consumer can style bars purely from the data-attribute without re-deriving the + * colour. These are plain hints; the consumer owns the final paint. + */ +export const HISTOGRAM_CHANNEL_COLORS: Record = { + l: '#888888', + r: '#ff3b30', + g: '#34c759', + b: '#0a84ff', +}; + +/** Whether `data` is the single-array form (vs. the per-channel record). */ +export function isSingleChannelData(data: HistogramData): data is number[] { + return Array.isArray(data); +} + +/** + * Resolve the bin array for `channel` out of `data`. The single-array form maps + * onto `fallbackChannel` (the root's `channel`); the record form is keyed + * directly. Returns an empty array when the channel is absent — callers must + * tolerate a zero-length result (an empty histogram is a valid state). + */ +export function getChannelBins( + data: HistogramData, + channel: HistogramBarChannel, + fallbackChannel: HistogramBarChannel, +): number[] { + if (isSingleChannelData(data)) { + return channel === fallbackChannel ? data : []; + } + return data[channel] ?? []; +} + +/** + * The peak bin count across `bins`. `0` for an empty or all-zero array — the + * caller uses this as the normalisation divisor and MUST guard against `0` so a + * flat/empty histogram projects to zero height rather than `NaN`. + * + * Single pass, allocation-free. + */ +export function histogramMax(bins: number[]): number { + let max = 0; + for (let i = 0; i < bins.length; i++) { + const v = bins[i]!; + if (v > max) max = v; + } + return max; +} + +/** + * Project a raw bin `count` to a normalised bar height in `[0, 1]` against the + * histogram peak `max`, under the chosen `scaleType`. + * + * The all-zero guard is load-bearing: when `max <= 0` every bar is `0` (no + * divide-by-zero, no `NaN`). `log` uses `log1p` so an empty bin maps to `0` and + * the peak maps to `1`, compressing tall spikes the way an image histogram's + * log view does. + */ +export function projectBarHeight(count: number, max: number, scaleType: HistogramScaleType): number { + if (max <= 0) return 0; + if (count <= 0) return 0; + if (scaleType === 'log') { + // log1p(count)/log1p(max): 0→0, max→1, monotonic in between. + return Math.log1p(count) / Math.log1p(max); + } + const h = count / max; + return h > 1 ? 1 : h; +} + +/** + * Map a channel's bins to normalised heights `[0, 1]` for rendering. Returns a + * fresh packed array; an empty/all-zero input yields all-zero heights (no + * `NaN`). The peak may be supplied (e.g. a shared peak across channels) or + * derived from `bins`. + */ +export function projectBars( + bins: number[], + scaleType: HistogramScaleType, + max = histogramMax(bins), +): number[] { + // Grow from empty with `push` so the array stays in V8's packed numeric + // elements-kind. Pre-sizing via `Array.from({ length })` would seed it with + // `undefined` (a tagged hole), defeating the PACKED_DOUBLE fast path. + const out: number[] = []; + for (let i = 0; i < bins.length; i++) { + out.push(projectBarHeight(bins[i]!, max, scaleType)); + } + return out; +} diff --git a/vue/primitives/src/canvas/keyframe-track/KeyframeTrackEasingEditor.vue b/vue/primitives/src/canvas/keyframe-track/KeyframeTrackEasingEditor.vue new file mode 100644 index 0000000..6410cb6 --- /dev/null +++ b/vue/primitives/src/canvas/keyframe-track/KeyframeTrackEasingEditor.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/vue/primitives/src/canvas/keyframe-track/KeyframeTrackKeyframe.vue b/vue/primitives/src/canvas/keyframe-track/KeyframeTrackKeyframe.vue new file mode 100644 index 0000000..a6cf1a2 --- /dev/null +++ b/vue/primitives/src/canvas/keyframe-track/KeyframeTrackKeyframe.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/vue/primitives/src/canvas/keyframe-track/KeyframeTrackRoot.vue b/vue/primitives/src/canvas/keyframe-track/KeyframeTrackRoot.vue new file mode 100644 index 0000000..2bb8f1d --- /dev/null +++ b/vue/primitives/src/canvas/keyframe-track/KeyframeTrackRoot.vue @@ -0,0 +1,520 @@ + + + + + diff --git a/vue/primitives/src/canvas/keyframe-track/KeyframeTrackSegment.vue b/vue/primitives/src/canvas/keyframe-track/KeyframeTrackSegment.vue new file mode 100644 index 0000000..eab9a9f --- /dev/null +++ b/vue/primitives/src/canvas/keyframe-track/KeyframeTrackSegment.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/vue/primitives/src/canvas/keyframe-track/__test__/KeyframeTrack.test.ts b/vue/primitives/src/canvas/keyframe-track/__test__/KeyframeTrack.test.ts new file mode 100644 index 0000000..c5ffed0 --- /dev/null +++ b/vue/primitives/src/canvas/keyframe-track/__test__/KeyframeTrack.test.ts @@ -0,0 +1,261 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import { + KeyframeTrackEasingEditor, + KeyframeTrackKeyframe, + KeyframeTrackRoot, +} from '../index'; +import type { KeyframeTrackKeyframeData } from '../index'; + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false })); +} + +interface MountOpts { + defaultValue?: KeyframeTrackKeyframeData[]; + modelValue?: KeyframeTrackKeyframeData[]; + property?: string; + valueAxis?: boolean; + valueRange?: [number, number]; + duration?: number; + fps?: number; + step?: number; + allowOverlap?: boolean; + disabled?: boolean; + selectedId?: string | null; +} + +function mountTrack(opts: MountOpts = {}, withEasing = false) { + const model = ref(opts.modelValue); + const selected = ref(opts.selectedId ?? null); + const commits: string[] = []; + const Harness = defineComponent({ + setup() { + // Cast to `any` for the `h()` call: vue-tsc cannot resolve the `h` overload + // for a `defineModel` component passed an inline props object (same pattern + // the accordion/checkbox suites use); the runtime props are correct. + return () => h(KeyframeTrackRoot as any, { + modelValue: model.value, + 'onUpdate:modelValue': (v: KeyframeTrackKeyframeData[]) => { model.value = v; }, + selectedId: selected.value, + 'onUpdate:selectedId': (v: string | null) => { selected.value = v; }, + onKeyframeCommit: (id: string) => { commits.push(id); }, + defaultValue: opts.defaultValue, + property: opts.property, + valueAxis: opts.valueAxis, + valueRange: opts.valueRange, + duration: opts.duration, + fps: opts.fps, + step: opts.step, + allowOverlap: opts.allowOverlap, + disabled: opts.disabled, + style: 'width: 300px; height: 40px; position: relative; display: block;', + }, { + default: ({ keyframes }: { keyframes: KeyframeTrackKeyframeData[] }) => [ + ...keyframes.map(k => h(KeyframeTrackKeyframe, { key: k.id, keyframeId: k.id, id: `kf-${k.id}` })), + ...(withEasing ? [h(KeyframeTrackEasingEditor)] : []), + ], + }); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + return { wrapper: w, model, selected, commits }; +} + +const TWO = (): KeyframeTrackKeyframeData[] => [ + { id: 'a', time: 0, value: 0, easing: [0, 0, 1, 1] }, + { id: 'b', time: 1, value: 1 }, +]; + +describe('KeyframeTrack — rendering', () => { + it('standalone root is a group with keyframe-track roledescription', async () => { + mountTrack({ defaultValue: TWO(), property: 'opacity' }); + await nextTick(); + const root = document.querySelector('[aria-roledescription="keyframe track"]')!; + expect(root).toBeTruthy(); + expect(root.getAttribute('role')).toBe('group'); + expect(root.getAttribute('aria-label')).toBe('opacity keyframes'); + }); + + it('renders each keyframe as role="slider" with seconds aria-valuetext announcing the property + value', async () => { + mountTrack({ defaultValue: TWO(), property: 'opacity' }); + await nextTick(); + const sliders = document.querySelectorAll('[role="slider"]'); + expect(sliders).toHaveLength(2); + // aria-valuenow is the TIME in seconds (default, non-valueAxis). + expect(sliders[0]!.getAttribute('aria-valuenow')).toBe('0'); + expect(sliders[1]!.getAttribute('aria-valuenow')).toBe('1'); + // aria-valuetext leads with the formatted time then property + value. + expect(sliders[0]!.getAttribute('aria-valuetext')).toContain('opacity 0'); + expect(sliders[1]!.getAttribute('aria-valuetext')).toContain('opacity 1'); + expect(sliders[0]!.getAttribute('aria-orientation')).toBe('horizontal'); + }); +}); + +describe('KeyframeTrack — keyboard', () => { + it('ArrowRight nudges the keyframe forward by one frame (neighbour-clamped)', async () => { + const { model } = mountTrack({ defaultValue: TWO(), fps: 30 }); + await nextTick(); + const first = document.getElementById('kf-a')!; + keydown(first, 'ArrowRight'); + await nextTick(); + const a = model.value!.find(k => k.id === 'a')!; + expect(a.time).toBeCloseTo(1 / 30, 6); + }); + + it('ArrowRight does not cross the next keyframe unless allowOverlap', async () => { + mountTrack({ + defaultValue: [ + { id: 'a', time: 0, value: 0 }, + { id: 'b', time: 5 / 30, value: 1 }, + ], + fps: 30, + }); + await nextTick(); + const first = document.getElementById('kf-a')!; + // Push hard into b; neighbour-clamp keeps a strictly before b by minTimeBetween (1 frame). + for (let i = 0; i < 20; i++) keydown(first, 'ArrowRight'); + await nextTick(); + // aria-valuenow always reflects the live time (seconds), even when clamped. + const aTime = Number(first.getAttribute('aria-valuenow')); + const bTime = Number(document.getElementById('kf-b')!.getAttribute('aria-valuenow')); + expect(aTime).toBeLessThan(bTime); + // Clamped exactly one frame before b. + expect(aTime).toBeCloseTo(4 / 30, 6); + }); + + it('ArrowLeft nudges backward and clamps at 0', async () => { + mountTrack({ defaultValue: [{ id: 'a', time: 2 / 30, value: 0 }, { id: 'b', time: 1, value: 1 }], fps: 30 }); + await nextTick(); + const first = document.getElementById('kf-a')!; + keydown(first, 'ArrowLeft'); + await nextTick(); + expect(Number(first.getAttribute('aria-valuenow'))).toBeCloseTo(1 / 30, 6); + keydown(first, 'ArrowLeft'); + keydown(first, 'ArrowLeft'); + await nextTick(); + expect(Number(first.getAttribute('aria-valuenow'))).toBe(0); + }); + + it('ArrowUp/ArrowDown change the value in valueAxis mode', async () => { + const { model } = mountTrack({ defaultValue: TWO(), valueAxis: true, valueRange: [0, 1] }); + await nextTick(); + const first = document.getElementById('kf-a')!; + keydown(first, 'ArrowUp'); + await nextTick(); + expect(model.value!.find(k => k.id === 'a')!.value).toBeCloseTo(0.01, 6); + keydown(first, 'ArrowDown'); + keydown(first, 'ArrowDown'); + await nextTick(); + expect(model.value!.find(k => k.id === 'a')!.value).toBeCloseTo(0, 6); + }); + + it('Home/End jump to min / max time (neighbour-clamped)', async () => { + const { model } = mountTrack({ defaultValue: TWO(), duration: 1, fps: 30 }); + await nextTick(); + const second = document.getElementById('kf-b')!; + keydown(second, 'Home'); + await nextTick(); + // b is neighbour-clamped one frame after a (time 0). + expect(model.value!.find(k => k.id === 'b')!.time).toBeCloseTo(1 / 30, 6); + keydown(second, 'End'); + await nextTick(); + expect(model.value!.find(k => k.id === 'b')!.time).toBeCloseTo(1, 6); + }); + + it('Delete removes the keyframe', async () => { + const { model } = mountTrack({ defaultValue: TWO() }); + await nextTick(); + const first = document.getElementById('kf-a')!; + keydown(first, 'Delete'); + await nextTick(); + expect(model.value!.map(k => k.id)).toEqual(['b']); + }); + + it('disabled: keys do nothing and tabindex is -1', async () => { + const { model } = mountTrack({ defaultValue: TWO(), disabled: true }); + await nextTick(); + const first = document.getElementById('kf-a')!; + expect(first.tabIndex).toBe(-1); + expect(first.getAttribute('aria-disabled')).toBe('true'); + keydown(first, 'ArrowRight'); + keydown(first, 'Delete'); + await nextTick(); + // unchanged (model never written, stays the seeded uncontrolled value). + expect(model.value).toBeUndefined(); + }); +}); + +describe('KeyframeTrack — selection', () => { + it('focus selects the keyframe and marks it selected', async () => { + const { selected } = mountTrack({ defaultValue: TWO() }); + await nextTick(); + const second = document.getElementById('kf-b')!; + second.dispatchEvent(new FocusEvent('focus', { bubbles: true })); + await nextTick(); + expect(selected.value).toBe('b'); + expect(second.getAttribute('aria-selected')).toBe('true'); + }); +}); + +describe('KeyframeTrack — sampling expose', () => { + it('sampleAt returns the eased value between two keyframes (linear midpoint ≈ average)', async () => { + const { wrapper } = mountTrack({ defaultValue: TWO() }); + await nextTick(); + const root = wrapper.findComponent(KeyframeTrackRoot); + const sampleAt = (root.vm as any).sampleAt as (t: number) => number; + expect(sampleAt(0.5)).toBeCloseTo(0.5, 6); + expect(sampleAt(-1)).toBe(0); + expect(sampleAt(5)).toBe(1); + }); + + it('addKeyframe / removeKeyframe via the exposed API mutate the model', async () => { + const { wrapper, model } = mountTrack({ defaultValue: TWO() }); + await nextTick(); + const root = wrapper.findComponent(KeyframeTrackRoot); + const id = (root.vm as any).addKeyframe(0.5) as string; + await nextTick(); + expect(model.value!.some(k => k.id === id)).toBe(true); + expect(model.value!.find(k => k.id === id)!.value).toBeCloseTo(0.5, 6); + (root.vm as any).removeKeyframe(id); + await nextTick(); + expect(model.value!.some(k => k.id === id)).toBe(false); + }); +}); + +describe('KeyframeTrack — easing editor', () => { + it('embeds a CurveEditor for the selected keyframe and setEasing updates it', async () => { + const { wrapper, model, selected } = mountTrack({ defaultValue: TWO(), selectedId: 'a' }, true); + await nextTick(); + // The selected keyframe (a) has a following segment → the editor renders a CurveEditor. + expect(selected.value).toBe('a'); + expect(document.querySelector('[data-easing-editor]')).toBeTruthy(); + expect(document.querySelector('[data-interpolation="bezier"]')).toBeTruthy(); + + // Drive setEasing directly through the context-backed API. + const root = wrapper.findComponent(KeyframeTrackRoot); + (root.vm as any).setEasing('a', [0.42, 0, 0.58, 1]); + await nextTick(); + expect(model.value!.find(k => k.id === 'a')!.easing).toEqual([0.42, 0, 0.58, 1]); + }); + + it('renders no editor when the selected keyframe is the last (no following segment)', async () => { + mountTrack({ defaultValue: TWO(), selectedId: 'b' }, true); + await nextTick(); + expect(document.querySelector('[data-interpolation="bezier"]')).toBeNull(); + }); +}); diff --git a/vue/primitives/src/canvas/keyframe-track/__test__/__screenshots__/KeyframeTrack.test.ts/KeyframeTrack---keyboard-ArrowLeft-nudges-backward-and-clamps-at-0-1.png b/vue/primitives/src/canvas/keyframe-track/__test__/__screenshots__/KeyframeTrack.test.ts/KeyframeTrack---keyboard-ArrowLeft-nudges-backward-and-clamps-at-0-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/keyframe-track/__test__/__screenshots__/KeyframeTrack.test.ts/KeyframeTrack---keyboard-ArrowLeft-nudges-backward-and-clamps-at-0-1.png differ diff --git a/vue/primitives/src/canvas/keyframe-track/__test__/__screenshots__/KeyframeTrack.test.ts/KeyframeTrack---keyboard-ArrowRight-does-not-cross-the-next-keyframe-unless-allowOverlap-1.png b/vue/primitives/src/canvas/keyframe-track/__test__/__screenshots__/KeyframeTrack.test.ts/KeyframeTrack---keyboard-ArrowRight-does-not-cross-the-next-keyframe-unless-allowOverlap-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/keyframe-track/__test__/__screenshots__/KeyframeTrack.test.ts/KeyframeTrack---keyboard-ArrowRight-does-not-cross-the-next-keyframe-unless-allowOverlap-1.png differ diff --git a/vue/primitives/src/canvas/keyframe-track/__test__/utils.test.ts b/vue/primitives/src/canvas/keyframe-track/__test__/utils.test.ts new file mode 100644 index 0000000..af49c1c --- /dev/null +++ b/vue/primitives/src/canvas/keyframe-track/__test__/utils.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_KEYFRAME_EASING } from '../context'; +import type { KeyframeTrackKeyframeData } from '../context'; +import { + clampKeyframeTime, + defaultKeyframeValueText, + sampleKeyframes, + snapTimeToFrame, + sortKeyframes, +} from '../utils'; + +function kf(id: string, time: number, value: number, easing?: [number, number, number, number]): KeyframeTrackKeyframeData { + return easing ? { id, time, value, easing } : { id, time, value }; +} + +describe('sortKeyframes', () => { + it('sorts ascending by time without mutating the input', () => { + const input = [kf('b', 2, 1), kf('a', 0, 0), kf('c', 1, 0.5)]; + const sorted = sortKeyframes(input); + expect(sorted.map(k => k.id)).toEqual(['a', 'c', 'b']); + // input untouched + expect(input.map(k => k.id)).toEqual(['b', 'a', 'c']); + }); + + it('breaks ties deterministically on id', () => { + const sorted = sortKeyframes([kf('z', 1, 0), kf('a', 1, 1)]); + expect(sorted.map(k => k.id)).toEqual(['a', 'z']); + }); +}); + +describe('sampleKeyframes', () => { + it('returns the single value for 0 / 1 keyframes', () => { + expect(sampleKeyframes([], 5)).toBe(0); + expect(sampleKeyframes([kf('a', 2, 0.7)], 100)).toBe(0.7); + expect(sampleKeyframes([kf('a', 2, 0.7)], -100)).toBe(0.7); + }); + + it('holds constant outside the keyframe range', () => { + const ks = [kf('a', 1, 0), kf('b', 3, 1)]; + expect(sampleKeyframes(ks, 0)).toBe(0); + expect(sampleKeyframes(ks, 1)).toBe(0); + expect(sampleKeyframes(ks, 3)).toBe(1); + expect(sampleKeyframes(ks, 10)).toBe(1); + }); + + it('linear easing midpoint ≈ the average of the two values', () => { + // DEFAULT_KEYFRAME_EASING is a linear ramp. + const ks = [kf('a', 0, 0, [...DEFAULT_KEYFRAME_EASING] as [number, number, number, number]), kf('b', 2, 1)]; + expect(sampleKeyframes(ks, 1)).toBeCloseTo(0.5, 6); + expect(sampleKeyframes(ks, 0.5)).toBeCloseTo(0.25, 6); + }); + + it('an ease curve is off-center at the midpoint (vs linear)', () => { + // ease-in: cubic-bezier(0.42, 0, 1, 1) starts slow → midpoint below 0.5. + const ks = [kf('a', 0, 0, [0.42, 0, 1, 1]), kf('b', 2, 1)]; + const mid = sampleKeyframes(ks, 1); + expect(mid).toBeLessThan(0.5); + expect(mid).toBeGreaterThan(0); + }); + + it('ease-out is above 0.5 at the midpoint', () => { + // ease-out: cubic-bezier(0, 0, 0.58, 1) ends slow → midpoint above 0.5. + const ks = [kf('a', 0, 0, [0, 0, 0.58, 1]), kf('b', 2, 1)]; + expect(sampleKeyframes(ks, 1)).toBeGreaterThan(0.5); + }); +}); + +describe('clampKeyframeTime', () => { + const ks = [kf('a', 0, 0), kf('b', 1, 0), kf('c', 2, 0)]; + + it('clamps to >= 0 and <= duration', () => { + expect(clampKeyframeTime(ks, 1, -5, { allowOverlap: true, minTimeBetween: 0 })).toBe(0); + expect(clampKeyframeTime(ks, 1, 99, { allowOverlap: true, minTimeBetween: 0, duration: 2 })).toBe(2); + }); + + it('neighbour-clamps with minTimeBetween when overlap is disallowed', () => { + // Moving b (index 1) far right stops minTimeBetween before c (time 2). + expect(clampKeyframeTime(ks, 1, 5, { allowOverlap: false, minTimeBetween: 0.25 })).toBe(1.75); + // Moving b far left stops minTimeBetween after a (time 0). + expect(clampKeyframeTime(ks, 1, -5, { allowOverlap: false, minTimeBetween: 0.25 })).toBe(0.25); + }); + + it('allows crossing neighbours when overlap is enabled', () => { + expect(clampKeyframeTime(ks, 1, 1.9, { allowOverlap: true, minTimeBetween: 0.25 })).toBe(1.9); + }); +}); + +describe('snapTimeToFrame', () => { + it('quantizes to whole frames at fps', () => { + expect(snapTimeToFrame(0.51, 30)).toBeCloseTo(15 / 30, 6); + expect(snapTimeToFrame(0.49, 30)).toBeCloseTo(15 / 30, 6); + }); + + it('passes through when fps <= 0', () => { + expect(snapTimeToFrame(1.234, 0)).toBe(1.234); + }); +}); + +describe('defaultKeyframeValueText', () => { + it('includes the property when present', () => { + expect(defaultKeyframeValueText(0.5, 'opacity')).toBe('opacity 0.5'); + }); + + it('omits the property when absent', () => { + expect(defaultKeyframeValueText(0.5)).toBe('0.5'); + }); +}); diff --git a/vue/primitives/src/canvas/keyframe-track/context.ts b/vue/primitives/src/canvas/keyframe-track/context.ts new file mode 100644 index 0000000..44cdfcd --- /dev/null +++ b/vue/primitives/src/canvas/keyframe-track/context.ts @@ -0,0 +1,144 @@ +import type { ComputedRef, Ref } from 'vue'; +import { useContextFactory } from '@robonen/vue'; +import type { Direction } from '../../utilities/config-provider'; +import type { UseSnappingReturn } from '../../internal/snapping'; + +/** + * A single keyframe on the track's time axis. + * + * `time` is in seconds, `value` is the animated value (in `valueRange` space). + * `easing` is the cubic-bezier control tuple `[x1, y1, x2, y2]` for the segment + * that STARTS at this keyframe and runs to the next one in time order — the + * implicit anchors are `(0,0)` and `(1,1)` (CSS `cubic-bezier` semantics). When + * `easing` is absent the segment falls back to {@link DEFAULT_KEYFRAME_EASING}. + */ +export interface KeyframeTrackKeyframeData { + /** Stable identity used as the `v-for` key, roving-focus handle, and selection id. */ + id: string; + /** Time of the keyframe in seconds. */ + time: number; + /** The animated value at this keyframe (in `valueRange` space). */ + value: number; + /** Cubic-bezier control points `[x1, y1, x2, y2]` for the segment starting here. */ + easing?: [number, number, number, number]; +} + +/** + * Default easing for a segment with no explicit `easing` tuple: a linear ramp + * (`cubic-bezier(0, 0, 1, 1)`), so an un-eased segment interpolates straight. + */ +export const DEFAULT_KEYFRAME_EASING: readonly [number, number, number, number] = [0, 0, 1, 1]; + +/** + * Context shared between `KeyframeTrackRoot` and its parts. + * + * Scalar props are exposed as plain `Ref` — `KeyframeTrackRoot` builds them + * with `toRef(() => prop)` (a reactive getter ref without an extra effect), + * matching the slider / timeline / curve-editor convention. `projection` / + * `invert` are stable closures safe on the pointer hot path. + */ +export interface KeyframeTrackContext { + /** Stable id base for scoping DOM ids per track instance. */ + trackId: string; + /** The live keyframes, sorted ascending by `time`. */ + keyframes: ComputedRef; + /** + * Memoized `id → array index` map over {@link keyframes}, rebuilt once per + * change. Parts use it for O(1) id lookup instead of an O(n) `find`/`findIndex` + * scan per part per frame (the whole-track cost stays O(n), not O(n²)). + */ + indexById: ComputedRef>; + /** Currently selected keyframe id (drives the easing editor + roving focus), or null. */ + selectedId: Ref; + /** The animated property name (for the a11y label). */ + property: Ref; + /** Whether keyframes move vertically to edit `value` (else single horizontal lane). */ + valueAxis: Ref; + /** Value domain `[min, max]` (the y-axis extent in `valueAxis` mode). */ + valueRange: Ref; + /** Total track duration in seconds (auto / injected from a Timeline / explicit). */ + duration: ComputedRef; + /** Frame rate (timecode + frame snapping + keyboard nudge). */ + fps: Ref; + /** Keyboard nudge step in seconds. */ + step: Ref; + /** Large keyboard step in seconds (Shift+Arrow). */ + largeStep: Ref; + /** Value-axis keyboard nudge step (per Arrow Up/Down in `valueAxis` mode). */ + valueStep: Ref; + /** Whether keyframes may overlap in time (else neighbour-clamped to keep order). */ + allowOverlap: Ref; + /** Minimum time gap between neighbouring keyframes (seconds) when `allowOverlap` is false. */ + minTimeBetween: Ref; + /** Snapping master enable. */ + snapping: Ref; + /** Master interactivity / disabled switch. */ + disabled: Ref; + /** Resolved reading direction. */ + direction: ComputedRef; + /** Live width (px) of the lane; `projection` range is `[0, width]`. */ + laneWidth: Ref; + /** Live height (px) of the lane (used by `valueAxis` y-projection). */ + laneHeight: Ref; + + // ── coordinate model ────────────────────────────────────────────────────── + /** Project a time (seconds) to a pixel offset in the lane. Stable identity. */ + projection: (seconds: number) => number; + /** Invert a pixel offset back to a time (seconds). Stable identity. */ + invert: (px: number) => number; + /** Project a value to a pixel offset on the y-axis (value-up). Stable identity. */ + projectValue: (value: number) => number; + /** Invert a y pixel offset back to a value. Stable identity. */ + invertValue: (px: number) => number; + /** Format a time (seconds) as a wall-clock string. */ + formatTime: (seconds: number) => string; + /** Snap a candidate time to the nearest snap target (frame grid). */ + snapTime: (seconds: number, exclude?: string) => number; + /** The shared snap engine (frame-grid targets). */ + snapEngine: UseSnappingReturn; + + // ── data access ─────────────────────────────────────────────────────────── + /** True while a keyframe drag is in flight (blocks external sync clobber). */ + isMutating: Readonly>; + /** The id of the keyframe currently being dragged (or null). */ + draggingId: Readonly>; + /** Whether this track is nested inside a Timeline (renders as a `listitem`). */ + inTimeline: boolean; + + // ── sampling ────────────────────────────────────────────────────────────── + /** + * Sample the animated value at an arbitrary time: find the bracketing + * keyframes and apply the segment easing via the spline. Constant outside the + * keyframe range (and for 0 / 1 keyframes). + */ + sampleAt: (time: number) => number; + + // ── selection ───────────────────────────────────────────────────────────── + /** Select a keyframe by id (null clears). */ + select: (id: string | null) => void; + + // ── mutation ────────────────────────────────────────────────────────────── + /** Insert a keyframe at `time` (value defaults to the sampled curve). Returns its id. */ + addKeyframe: (time: number, value?: number) => string | undefined; + /** Remove a keyframe by id. */ + removeKeyframe: (id: string) => void; + /** Move a keyframe (transient overlay while `mutating`; commit on settle). */ + moveKeyframe: (id: string, time: number, value?: number, mutating?: boolean) => void; + /** Set the segment easing (cubic-bezier tuple) of the segment starting at `id`. */ + setEasing: (id: string, bezier: [number, number, number, number]) => void; + /** Commit the in-flight transient mutation into the model (one commit). */ + commit: () => void; + + // ── roving focus registration ───────────────────────────────────────────── + /** Register a keyframe element for roving focus. */ + registerKeyframeEl: (id: string, el: HTMLElement | null) => void; + /** Focus the next/prev keyframe in time order from `fromId` (roving). */ + focusAdjacent: (fromId: string, direction: 1 | -1) => void; + /** Focus a keyframe element by id. */ + focusKeyframe: (id: string) => void; +} + +const ctx = useContextFactory('KeyframeTrackContext'); + +export const provideKeyframeTrackContext = ctx.provide; +export const useKeyframeTrackContext = ctx.inject; diff --git a/vue/primitives/src/canvas/keyframe-track/demo.vue b/vue/primitives/src/canvas/keyframe-track/demo.vue new file mode 100644 index 0000000..6e392e3 --- /dev/null +++ b/vue/primitives/src/canvas/keyframe-track/demo.vue @@ -0,0 +1,250 @@ + + + diff --git a/vue/primitives/src/canvas/keyframe-track/index.ts b/vue/primitives/src/canvas/keyframe-track/index.ts new file mode 100644 index 0000000..820cd29 --- /dev/null +++ b/vue/primitives/src/canvas/keyframe-track/index.ts @@ -0,0 +1,29 @@ +export { default as KeyframeTrackRoot } from './KeyframeTrackRoot.vue'; +export type { KeyframeTrackRootEmits, KeyframeTrackRootProps } from './KeyframeTrackRoot.vue'; + +export { default as KeyframeTrackKeyframe } from './KeyframeTrackKeyframe.vue'; +export type { KeyframeTrackKeyframeProps } from './KeyframeTrackKeyframe.vue'; + +export { default as KeyframeTrackSegment } from './KeyframeTrackSegment.vue'; +export type { KeyframeTrackSegmentProps } from './KeyframeTrackSegment.vue'; + +export { default as KeyframeTrackEasingEditor } from './KeyframeTrackEasingEditor.vue'; +export type { KeyframeTrackEasingEditorProps } from './KeyframeTrackEasingEditor.vue'; + +export { + DEFAULT_KEYFRAME_EASING, + provideKeyframeTrackContext, + useKeyframeTrackContext, +} from './context'; +export type { + KeyframeTrackContext, + KeyframeTrackKeyframeData, +} from './context'; + +export { + clampKeyframeTime, + defaultKeyframeValueText, + sampleKeyframes, + snapTimeToFrame, + sortKeyframes, +} from './utils'; diff --git a/vue/primitives/src/canvas/keyframe-track/utils.ts b/vue/primitives/src/canvas/keyframe-track/utils.ts new file mode 100644 index 0000000..c2d92ff --- /dev/null +++ b/vue/primitives/src/canvas/keyframe-track/utils.ts @@ -0,0 +1,134 @@ +import { solveBezierX } from '../../internal/spline'; +import { framesToSeconds, secondsToFrames } from '../../internal/scale'; +import { DEFAULT_KEYFRAME_EASING } from './context'; +import type { KeyframeTrackKeyframeData } from './context'; + +/** + * Sort keyframes ascending by `time`, returning a NEW array (never mutating the + * input). Stable for equal times (a tie breaks on `id`) so the order is + * deterministic across reconciles and neighbour-clamping stays predictable. + */ +export function sortKeyframes(keyframes: readonly KeyframeTrackKeyframeData[]): KeyframeTrackKeyframeData[] { + return keyframes + .slice() + .sort((a, b) => (a.time - b.time) || a.id.localeCompare(b.id)); +} + +/** + * Linear interpolate between `a` and `b` by `t ∈ [0, 1]`. + */ +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** + * Sample the animated value at an arbitrary `time` (seconds) over a SORTED + * keyframe list. + * + * Finds the bracketing pair `[k, k+1]`, computes the normalized progress along + * the segment, applies the starting keyframe's cubic-bezier easing (via the + * spline `solveBezierX`, defaulting to {@link DEFAULT_KEYFRAME_EASING} — a linear + * ramp), and lerps the value. The result is CONSTANT outside the keyframe range + * (held at the first / last keyframe's value) and for the 0- and 1-keyframe + * degenerate cases. + * + * `valueRange` is accepted for parity with the projection model but does not + * affect the sampled value (values are sampled in their own space, never + * normalized) — it is reserved so callers can pass it without a second overload. + * + * @param keyframes Keyframes sorted ascending by `time`. + * @param time Time to sample, in seconds. + * @param valueRange Optional value domain (unused by the maths; see above). + */ +export function sampleKeyframes( + keyframes: readonly KeyframeTrackKeyframeData[], + time: number, + _valueRange?: readonly [number, number], +): number { + const n = keyframes.length; + if (n === 0) return 0; + const first = keyframes[0]!; + if (n === 1) return first.value; + const last = keyframes[n - 1]!; + + // Held constant outside the keyframe range. + if (time <= first.time) return first.value; + if (time >= last.time) return last.value; + + // Binary search for the segment [lo, lo+1] containing `time`. + let lo = 0; + let hi = n - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (keyframes[mid]!.time <= time) lo = mid; + else hi = mid; + } + + const a = keyframes[lo]!; + const b = keyframes[lo + 1]!; + const span = b.time - a.time; + if (span <= 0) return a.value; + + const progress = (time - a.time) / span; + const easing = a.easing ?? DEFAULT_KEYFRAME_EASING; + // Easing maps normalized progress (x) to eased progress (y) in [0, 1]. + const eased = solveBezierX(easing[0], easing[1], easing[2], easing[3], progress); + return lerp(a.value, b.value, eased); +} + +/** + * Clamp a candidate `time` for the keyframe at `index` so it stays ordered + * relative to its neighbours by at least `minTimeBetween` seconds (unless + * `allowOverlap`), and never goes below `0`. `keyframes` MUST be sorted by time. + * + * @param keyframes Keyframes sorted ascending by `time`. + * @param index Index of the keyframe being moved. + * @param time Candidate time (seconds). + * @param options Neighbour-clamp configuration. + */ +export function clampKeyframeTime( + keyframes: readonly KeyframeTrackKeyframeData[], + index: number, + time: number, + options: { allowOverlap: boolean; minTimeBetween: number; duration?: number }, +): number { + const { allowOverlap, minTimeBetween, duration } = options; + let v = Math.max(0, time); + if (duration !== undefined && duration > 0) v = Math.min(v, duration); + + if (!allowOverlap) { + const prev = keyframes[index - 1]; + const next = keyframes[index + 1]; + if (prev !== undefined) v = Math.max(v, prev.time + minTimeBetween); + if (next !== undefined) v = Math.min(v, next.time - minTimeBetween); + } + return v; +} + +/** + * Snap a `time` (seconds) to the nearest whole frame at `fps`. The default + * frame-grid quantizer used as the keyboard nudge granularity / snap fallback. + */ +export function snapTimeToFrame(time: number, fps: number): number { + if (fps <= 0) return time; + return framesToSeconds(secondsToFrames(time, fps), fps); +} + +/** + * Round to `decimals` places, trimming float noise (no trailing-zero padding). + */ +function round(value: number, decimals: number): number { + const f = 10 ** decimals; + return Math.round(value * f) / f; +} + +/** + * The default `aria-valuetext` value-token for a keyframe: the animated property + * (when present) followed by the value, e.g. `"opacity 0.5"` or just `"0.5"`. + * The time is announced separately by the caller (a slider's `aria-valuetext` + * leads with the formatted time). + */ +export function defaultKeyframeValueText(value: number, property?: string, decimals = 3): string { + const v = round(value, decimals); + return property ? `${property} ${v}` : `${v}`; +} diff --git a/vue/primitives/src/canvas/levels/LevelsHandleValue.vue b/vue/primitives/src/canvas/levels/LevelsHandleValue.vue new file mode 100644 index 0000000..d523a89 --- /dev/null +++ b/vue/primitives/src/canvas/levels/LevelsHandleValue.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/vue/primitives/src/canvas/levels/LevelsRoot.vue b/vue/primitives/src/canvas/levels/LevelsRoot.vue new file mode 100644 index 0000000..de2e6c7 --- /dev/null +++ b/vue/primitives/src/canvas/levels/LevelsRoot.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/vue/primitives/src/canvas/levels/LevelsThumb.vue b/vue/primitives/src/canvas/levels/LevelsThumb.vue new file mode 100644 index 0000000..b78f743 --- /dev/null +++ b/vue/primitives/src/canvas/levels/LevelsThumb.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/vue/primitives/src/canvas/levels/LevelsTrack.vue b/vue/primitives/src/canvas/levels/LevelsTrack.vue new file mode 100644 index 0000000..a171a9d --- /dev/null +++ b/vue/primitives/src/canvas/levels/LevelsTrack.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/vue/primitives/src/canvas/levels/__test__/Levels.test.ts b/vue/primitives/src/canvas/levels/__test__/Levels.test.ts new file mode 100644 index 0000000..208c7ac --- /dev/null +++ b/vue/primitives/src/canvas/levels/__test__/Levels.test.ts @@ -0,0 +1,306 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import { + LevelsHandleValue, + LevelsRoot, + LevelsThumb, + LevelsTrack, +} from '../index'; +import type { LevelsValue } from '../index'; +import { applyLevels, buildOutputCurve, computeAutoLevels } from '../utils'; + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void { + el.dispatchEvent(new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + shiftKey: opts.shiftKey ?? false, + })); +} + +function mountLevels(opts: { + defaultValue?: Partial; + step?: number; + gammaStep?: number; + minStepsBetweenHandles?: number; + disabled?: boolean; + kinds?: Array<'black' | 'gamma' | 'white' | 'outputBlack' | 'outputWhite'>; +} = {}) { + const model = ref(undefined); + const kinds = opts.kinds ?? ['black', 'gamma', 'white', 'outputBlack', 'outputWhite']; + const Harness = defineComponent({ + setup() { + return () => h(LevelsRoot, { + modelValue: model.value, + defaultValue: { black: 0, gamma: 1, white: 255, outputBlack: 0, outputWhite: 255, ...opts.defaultValue }, + step: opts.step, + gammaStep: opts.gammaStep, + minStepsBetweenHandles: opts.minStepsBetweenHandles, + disabled: opts.disabled, + 'onUpdate:modelValue': (v: LevelsValue | null | undefined) => { model.value = v; }, + }, { + default: () => h(LevelsTrack, null, { + default: () => kinds.map(kind => h(LevelsThumb, { kind, id: `thumb-${kind}` })), + }), + }); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + return { wrapper: w, model }; +} + +function thumb(kind: string): HTMLElement { + return document.getElementById(`thumb-${kind}`)!; +} + +describe('Levels — utils (unit)', () => { + it('applyLevels is the identity for the default adjustment', () => { + const v: LevelsValue = { black: 0, gamma: 1, white: 255, outputBlack: 0, outputWhite: 255 }; + expect(applyLevels(0, v)).toBeCloseTo(0, 5); + expect(applyLevels(128, v)).toBeCloseTo(128, 5); + expect(applyLevels(255, v)).toBeCloseTo(255, 5); + }); + + it('getOutputCurve returns a 256-length, monotonic non-decreasing LUT', () => { + const lut = buildOutputCurve({ black: 0, gamma: 1, white: 255, outputBlack: 0, outputWhite: 255 }); + expect(lut).toHaveLength(256); + for (let i = 1; i < lut.length; i++) expect(lut[i]!).toBeGreaterThanOrEqual(lut[i - 1]!); + // Identity LUT. + expect(lut[0]).toBeCloseTo(0, 4); + expect(lut[255]).toBeCloseTo(255, 4); + }); + + it('LUT reflects black/white clipping and the output range', () => { + const lut = buildOutputCurve({ black: 64, gamma: 1, white: 192, outputBlack: 10, outputWhite: 240 }); + // Below black clips to outputBlack, above white clips to outputWhite. + expect(lut[0]).toBeCloseTo(10, 4); + expect(lut[255]).toBeCloseTo(240, 4); + expect(lut[32]).toBeCloseTo(10, 4); // still below black + expect(lut[220]).toBeCloseTo(240, 4); // above white + // Midpoint of the window maps near the output midpoint at gamma 1. + expect(lut[128]!).toBeGreaterThan(10); + expect(lut[128]!).toBeLessThan(240); + }); + + it('gamma > 1 brightens midtones in the LUT', () => { + const linear = buildOutputCurve({ black: 0, gamma: 1, white: 255, outputBlack: 0, outputWhite: 255 }); + const bright = buildOutputCurve({ black: 0, gamma: 2, white: 255, outputBlack: 0, outputWhite: 255 }); + expect(bright[128]!).toBeGreaterThan(linear[128]!); + }); + + it('computeAutoLevels clips tails and falls back on empty/flat data', () => { + expect(computeAutoLevels(undefined)).toEqual({ black: 0, white: 255 }); + expect(computeAutoLevels([])).toEqual({ black: 0, white: 255 }); + expect(computeAutoLevels([0, 0, 0])).toEqual({ black: 0, white: 255 }); + // Signal concentrated in the middle bins → black moves up, white moves down. + const hist = Array.from({ length: 256 }, (_, i) => (i >= 64 && i <= 192 ? 100 : 0)); + const { black, white } = computeAutoLevels(hist, 0.001); + expect(black).toBeGreaterThan(0); + expect(white).toBeLessThan(255); + expect(black).toBeLessThan(white); + }); +}); + +describe('Levels — thumb ARIA', () => { + it('each thumb is role=slider with the per-kind aria-label', async () => { + mountLevels(); + await nextTick(); + expect(thumb('black').getAttribute('role')).toBe('slider'); + expect(thumb('black').getAttribute('aria-label')).toBe('Black point'); + expect(thumb('gamma').getAttribute('aria-label')).toBe('Gamma'); + expect(thumb('white').getAttribute('aria-label')).toBe('White point'); + expect(thumb('outputBlack').getAttribute('aria-label')).toBe('Output black'); + expect(thumb('outputWhite').getAttribute('aria-label')).toBe('Output white'); + }); + + it('reports correct aria-value* for the 0..255 handles', async () => { + mountLevels({ defaultValue: { black: 20, white: 230, outputBlack: 5, outputWhite: 250 } }); + await nextTick(); + const black = thumb('black'); + expect(black.getAttribute('aria-valuenow')).toBe('20'); + expect(black.getAttribute('aria-valuemin')).toBe('0'); + // black's max is white - minGap (230 - 1). + expect(black.getAttribute('aria-valuemax')).toBe('229'); + expect(thumb('white').getAttribute('aria-valuenow')).toBe('230'); + expect(thumb('white').getAttribute('aria-valuemin')).toBe('21'); + expect(thumb('outputBlack').getAttribute('aria-valuemax')).toBe('250'); + expect(thumb('outputWhite').getAttribute('aria-valuemin')).toBe('5'); + }); + + it('gamma thumb aria-valuenow carries the factor and aria-valuetext the effective level', async () => { + mountLevels({ defaultValue: { black: 0, gamma: 1, white: 255 } }); + await nextTick(); + const gamma = thumb('gamma'); + expect(gamma.getAttribute('aria-valuenow')).toBe('1'); + // gamma 1.00 with black 0 / white 255 → midtone at ~128. + expect(gamma.getAttribute('aria-valuetext')).toMatch(/^Gamma 1\.00, midtone at 12[78]$/); + expect(gamma.getAttribute('aria-valuemin')).toBe('0.1'); + expect(gamma.getAttribute('aria-valuemax')).toBe('9.99'); + }); +}); + +describe('Levels — keyboard & neighbour clamp', () => { + it('ArrowRight/ArrowLeft step the handle by step', async () => { + const { model } = mountLevels({ defaultValue: { black: 50 }, step: 5 }); + await nextTick(); + keydown(thumb('black'), 'ArrowRight'); + await nextTick(); + expect(model.value!.black).toBe(55); + keydown(thumb('black'), 'ArrowLeft'); + keydown(thumb('black'), 'ArrowLeft'); + await nextTick(); + expect(model.value!.black).toBe(45); + }); + + it('gamma steps by gammaStep', async () => { + const { model } = mountLevels({ defaultValue: { gamma: 1 }, gammaStep: 0.1 }); + await nextTick(); + keydown(thumb('gamma'), 'ArrowRight'); + await nextTick(); + expect(model.value!.gamma).toBeCloseTo(1.1, 5); + }); + + it('black cannot cross white (neighbour clamp, pins instead of swaps)', async () => { + const { model } = mountLevels({ defaultValue: { black: 100, white: 110 }, step: 1, minStepsBetweenHandles: 1 }); + await nextTick(); + for (let i = 0; i < 40; i++) keydown(thumb('black'), 'ArrowRight'); + await nextTick(); + // Pins one minGap (1*1) below white. + expect(model.value!.black).toBe(109); + expect(model.value!.black).toBeLessThan(model.value!.white); + }); + + it('Shift+Arrow & Page jump by largeStep', async () => { + const { model } = mountLevels({ defaultValue: { black: 50 }, step: 1 }); + await nextTick(); + keydown(thumb('black'), 'PageUp'); + await nextTick(); + expect(model.value!.black).toBe(60); + keydown(thumb('black'), 'ArrowLeft', { shiftKey: true }); + await nextTick(); + expect(model.value!.black).toBe(50); + }); + + it('Home/End jump to the handle lowest/highest legal value', async () => { + const { model } = mountLevels({ defaultValue: { black: 50, white: 200 } }); + await nextTick(); + keydown(thumb('black'), 'End'); + await nextTick(); + // black's legal max is white - minGap = 199. + expect(model.value!.black).toBe(199); + keydown(thumb('black'), 'Home'); + await nextTick(); + expect(model.value!.black).toBe(0); + }); + + it('disabled: tabindex=-1, aria-disabled, keys do nothing', async () => { + const { model } = mountLevels({ defaultValue: { black: 50 }, disabled: true }); + await nextTick(); + const black = thumb('black'); + expect(black.tabIndex).toBe(-1); + expect(black.getAttribute('aria-disabled')).toBe('true'); + keydown(black, 'ArrowRight'); + await nextTick(); + expect(model.value).toBeUndefined(); + }); +}); + +describe('Levels — getOutputCurve via exposed API', () => { + it('exposes a 256-length LUT reflecting the current adjustment', async () => { + const rootRef = ref(null); + const Harness = defineComponent({ + setup: () => () => h(LevelsRoot, { + ref: rootRef, + defaultValue: { black: 32, gamma: 1, white: 224, outputBlack: 0, outputWhite: 255 }, + }, { default: () => h(LevelsTrack, null, { default: () => h(LevelsThumb, { kind: 'black' }) }) }), + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + const lut = rootRef.value.getOutputCurve(); + expect(lut).toHaveLength(256); + expect(lut[0]).toBeCloseTo(0, 3); + expect(lut[255]).toBeCloseTo(255, 3); + for (let i = 1; i < lut.length; i++) expect(lut[i]!).toBeGreaterThanOrEqual(lut[i - 1]!); + }); + + it('autoLevels updates black/white from a histogram', async () => { + const rootRef = ref(null); + const model = ref(undefined); + const Harness = defineComponent({ + setup: () => () => h(LevelsRoot, { + ref: rootRef, + modelValue: model.value, + 'onUpdate:modelValue': (v: LevelsValue | null | undefined) => { model.value = v; }, + }, { default: () => h(LevelsTrack, null, { default: () => h(LevelsThumb, { kind: 'black' }) }) }), + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + const hist = Array.from({ length: 256 }, (_, i) => (i >= 80 && i <= 180 ? 100 : 0)); + rootRef.value.autoLevels(hist); + await nextTick(); + expect(model.value!.black).toBeGreaterThan(0); + expect(model.value!.white).toBeLessThan(255); + expect(model.value!.black).toBeLessThan(model.value!.white); + }); +}); + +describe('Levels — HandleValue numeric entry', () => { + it('composes a number-field bound to the handle and writes back clamped', async () => { + const model = ref(undefined); + const Harness = defineComponent({ + setup: () => () => h(LevelsRoot, { + modelValue: model.value, + defaultValue: { black: 50, gamma: 1, white: 200, outputBlack: 0, outputWhite: 255 }, + 'onUpdate:modelValue': (v: LevelsValue | null | undefined) => { model.value = v; }, + }, { + default: () => h(LevelsHandleValue, { kind: 'black' }), + }), + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + const input = document.querySelector('input[role="spinbutton"]')!; + expect(input).toBeTruthy(); + expect(input.value).toBe('50'); + // It carries the handle's neighbour-aware max (white - minGap = 199). + expect(input.getAttribute('aria-valuemax')).toBe('199'); + expect(input.getAttribute('aria-label')).toBe('Black point'); + + // Typing past the neighbour clamps to 199, never crossing white. + input.value = '240'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await nextTick(); + expect(model.value!.black).toBeLessThanOrEqual(199); + expect(model.value!.black).toBeLessThan(model.value!.white); + }); + + it('renders an increment and decrement stepper bound to the handle', async () => { + const Harness = defineComponent({ + setup: () => () => h(LevelsRoot, { + defaultValue: { black: 50, gamma: 1, white: 200, outputBlack: 0, outputWhite: 255 }, + }, { + default: () => h(LevelsHandleValue, { kind: 'gamma' }), + }), + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + const input = document.querySelector('input[role="spinbutton"]')!; + expect(input.value).toBe('1'); + // Gamma field uses the gamma bounds. + expect(input.getAttribute('aria-valuemin')).toBe('0.1'); + expect(input.getAttribute('aria-valuemax')).toBe('9.99'); + }); +}); diff --git a/vue/primitives/src/canvas/levels/context.ts b/vue/primitives/src/canvas/levels/context.ts new file mode 100644 index 0000000..73629db --- /dev/null +++ b/vue/primitives/src/canvas/levels/context.ts @@ -0,0 +1,55 @@ +import type { Ref } from 'vue'; +import { useContextFactory } from '@robonen/vue'; +import type { LevelsHandleKind, LevelsValue } from './utils'; + +/** Orientation of the levels track. */ +export type LevelsOrientation = 'horizontal' | 'vertical'; +/** Writing direction (flips horizontal pointer/keyboard mapping). */ +export type LevelsDirection = 'ltr' | 'rtl'; + +/** + * Context shared between `LevelsRoot`, `LevelsTrack`, `LevelsThumb`, and + * `LevelsHandleValue`. + * + * Scalar props are plain `Ref` (built with `toRef(() => prop)` in the root — + * a reactive getter ref without a `computed` cache). Actions are functions that + * apply the neighbour-clamping and commit to the model. + */ +export interface LevelsContext { + /** The live adjustment (input black/white, gamma factor, output range). */ + value: Ref; + /** Keyboard/arrow step for the `0..255` handles. */ + step: Ref; + /** Keyboard step for the gamma handle (`gammaStep`). */ + gammaStep: Ref; + /** Large-step multiplier (Page keys / Shift+Arrow). */ + largeStep: Ref; + /** Minimum gap (in steps) the black handle keeps below white. */ + minStepsBetweenHandles: Ref; + orientation: Ref; + direction: Ref; + /** Invert the direction of interaction (independent of `dir`/orientation). */ + inverted: Ref; + disabled: Ref; + /** Geometry reference for pointer math (set by `LevelsTrack`). */ + trackRef: Ref; + /** Set a handle to `next`, applying step rounding + neighbour clamping + commit. */ + setHandle: (kind: LevelsHandleKind, next: number) => void; + /** Nudge a handle by `delta` (already in the handle's units), clamped + committed. */ + nudgeHandle: (kind: LevelsHandleKind, delta: number) => void; + /** Jump a handle to its lowest (`'min'`) or highest (`'max'`) legal value. */ + jumpHandle: (kind: LevelsHandleKind, edge: 'min' | 'max') => void; + /** Map a pointer event to a `0..255` value along the track (min when unmeasured). */ + getValueFromPointer: (event: PointerEvent) => number; + /** Start a drag for `kind` from `LevelsTrack`/`LevelsThumb` pointer-down. */ + startDrag: (kind: LevelsHandleKind, event: PointerEvent) => void; + /** Build the 256-entry output LUT for the current adjustment. */ + getOutputCurve: (size?: number) => number[]; + /** Apply auto black/white from an optional luminance histogram. */ + autoLevels: (histogram?: number[]) => void; +} + +export const { + inject: useLevelsContext, + provide: provideLevelsContext, +} = useContextFactory('levels'); diff --git a/vue/primitives/src/canvas/levels/demo.vue b/vue/primitives/src/canvas/levels/demo.vue new file mode 100644 index 0000000..85b7d5b --- /dev/null +++ b/vue/primitives/src/canvas/levels/demo.vue @@ -0,0 +1,121 @@ + + + diff --git a/vue/primitives/src/canvas/levels/index.ts b/vue/primitives/src/canvas/levels/index.ts new file mode 100644 index 0000000..3cf8fd1 --- /dev/null +++ b/vue/primitives/src/canvas/levels/index.ts @@ -0,0 +1,33 @@ +export { default as LevelsRoot } from './LevelsRoot.vue'; +export { default as LevelsTrack } from './LevelsTrack.vue'; +export { default as LevelsThumb } from './LevelsThumb.vue'; +export { default as LevelsHandleValue } from './LevelsHandleValue.vue'; +export type { LevelsRootEmits, LevelsRootProps } from './LevelsRoot.vue'; +export type { LevelsTrackProps } from './LevelsTrack.vue'; +export type { LevelsThumbProps } from './LevelsThumb.vue'; +export type { LevelsHandleValueProps } from './LevelsHandleValue.vue'; +export { + provideLevelsContext, + useLevelsContext, + type LevelsContext, + type LevelsDirection, + type LevelsOrientation, +} from './context'; +export { + LEVELS_DEFAULT_VALUE, + LEVELS_GAMMA_MAX, + LEVELS_GAMMA_MIN, + LEVELS_HANDLE_LABELS, + LEVELS_INPUT_MAX, + LEVELS_INPUT_MIN, + applyLevels, + buildOutputCurve, + clampHandle, + computeAutoLevels, + gammaMidtoneLevel, + handleBounds, + handleValue, + isOutputHandle, + roundClamp, +} from './utils'; +export type { LevelsHandleKind, LevelsValue } from './utils'; diff --git a/vue/primitives/src/canvas/levels/utils.ts b/vue/primitives/src/canvas/levels/utils.ts new file mode 100644 index 0000000..7c019d9 --- /dev/null +++ b/vue/primitives/src/canvas/levels/utils.ts @@ -0,0 +1,228 @@ +import { clamp } from '@robonen/stdlib'; +import { toLUT } from '../../internal/spline'; + +/** The five handles a levels adjustment exposes. */ +export type LevelsHandleKind = 'black' | 'gamma' | 'white' | 'outputBlack' | 'outputWhite'; + +/** + * A complete levels adjustment. + * + * - `black` / `white` — input clipping points in the `0..255` domain + * (`black < white`). + * - `gamma` — midtone factor in `0.1..9.99`; `1` is linear, `> 1` brightens + * midtones, `< 1` darkens them. + * - `outputBlack` / `outputWhite` — the output range the remapped tones are + * compressed into (`0..255`). + */ +export interface LevelsValue { + black: number; + gamma: number; + white: number; + outputBlack: number; + outputWhite: number; +} + +/** Inclusive bounds for the input/output `0..255` domain. */ +export const LEVELS_INPUT_MIN = 0; +export const LEVELS_INPUT_MAX = 255; +/** Inclusive bounds for the gamma factor. */ +export const LEVELS_GAMMA_MIN = 0.1; +export const LEVELS_GAMMA_MAX = 9.99; + +/** The canonical identity adjustment (a no-op pass-through). */ +export const LEVELS_DEFAULT_VALUE: LevelsValue = Object.freeze({ + black: 0, + gamma: 1, + white: 255, + outputBlack: 0, + outputWhite: 255, +}); + +/** Round to `decimals` places, killing float drift, then clamp to `[lo, hi]`. */ +export function roundClamp(value: number, lo: number, hi: number, decimals = 0): number { + const rounded = decimals > 0 + ? Number((Math.round(value / 10 ** -decimals) / 10 ** decimals).toFixed(decimals)) + : Math.round(value); + return clamp(rounded, lo, hi); +} + +/** Whether `kind` is one of the two `0..255` output handles. */ +export function isOutputHandle(kind: LevelsHandleKind): boolean { + return kind === 'outputBlack' || kind === 'outputWhite'; +} + +/** + * The effective input level the gamma midpoint maps to: the point in + * `[black, white]` whose normalised position is `0.5 ** gamma`. With `gamma = 1` + * this is the literal midpoint `(black + white) / 2`; raising gamma slides the + * midtone anchor toward black. Reported in `aria-valuetext` so screen-reader + * users hear where the midtone sits. + */ +export function gammaMidtoneLevel(value: Pick): number { + const span = value.white - value.black; + if (span <= 0) return value.black; + const t = 0.5 ** value.gamma; + return Math.round(value.black + t * span); +} + +/** + * Apply a levels adjustment to a single normalised-ish `0..255` input sample, + * returning the `0..255` output. The pipeline is the standard one: + * + * 1. remap `[black, white]` → `[0, 1]` (clipping outside the window), + * 2. apply gamma as `t ** (1 / gamma)` (so `gamma > 1` lifts midtones), + * 3. expand `[0, 1]` → `[outputBlack, outputWhite]`. + */ +export function applyLevels(input: number, value: LevelsValue): number { + const { black, gamma, white, outputBlack, outputWhite } = value; + const span = white - black; + // Degenerate window (black >= white): everything below the point is output + // black, everything at/above is output white — a hard threshold. + if (span <= 0) { + return input < black ? outputBlack : outputWhite; + } + let t = (input - black) / span; + t = t < 0 ? 0 : t > 1 ? 1 : t; + // gamma > 1 brightens midtones → exponent 1/gamma. + const corrected = gamma === 1 ? t : t ** (1 / gamma); + return outputBlack + corrected * (outputWhite - outputBlack); +} + +/** + * Build the `size`-length lookup table (default 256) that applies `value` across + * the full `0..255` input domain, via the shared `toLUT` sampler. Each entry is + * the `0..255` output for input index `i`. The curve is monotonic non-decreasing + * when `outputBlack <= outputWhite` (the normal case), and is exactly the + * identity for the default adjustment. + */ +export function buildOutputCurve(value: LevelsValue, size = 256): number[] { + // toLUT samples `fn` across `[x0, x1]` → here the full input domain. The LUT + // index IS the input level, so we sample over `0..(size-1)` mapped to + // `0..255`. + return toLUT( + input => applyLevels(input, value), + size, + LEVELS_INPUT_MIN, + LEVELS_INPUT_MAX, + ); +} + +/** + * Neighbour-clamp a candidate value for `kind` against the rest of `value`, + * preserving the structural invariants: + * + * - `black` stays in `[0, white - minGap]`, + * - `white` stays in `[black + minGap, 255]`, + * - `gamma` stays in `[0.1, 9.99]`, + * - `outputBlack` stays in `[0, outputWhite]`, + * - `outputWhite` stays in `[outputBlack, 255]`. + * + * `minGap` (`minStepsBetweenHandles * step`) keeps `black` strictly below + * `white`. A value pushed past its neighbour PINS at the boundary rather than + * swapping order. + */ +export function clampHandle( + kind: LevelsHandleKind, + candidate: number, + value: LevelsValue, + minGap: number, +): number { + switch (kind) { + case 'black': + return clamp(candidate, LEVELS_INPUT_MIN, value.white - minGap); + case 'white': + return clamp(candidate, value.black + minGap, LEVELS_INPUT_MAX); + case 'gamma': + return clamp(candidate, LEVELS_GAMMA_MIN, LEVELS_GAMMA_MAX); + case 'outputBlack': + return clamp(candidate, LEVELS_INPUT_MIN, value.outputWhite); + case 'outputWhite': + return clamp(candidate, value.outputBlack, LEVELS_INPUT_MAX); + } +} + +/** Per-kind inclusive bounds reported as `aria-valuemin` / `aria-valuemax`. */ +export function handleBounds(kind: LevelsHandleKind, value: LevelsValue, minGap: number): { min: number; max: number } { + switch (kind) { + case 'black': + return { min: LEVELS_INPUT_MIN, max: value.white - minGap }; + case 'white': + return { min: value.black + minGap, max: LEVELS_INPUT_MAX }; + case 'gamma': + return { min: LEVELS_GAMMA_MIN, max: LEVELS_GAMMA_MAX }; + case 'outputBlack': + return { min: LEVELS_INPUT_MIN, max: value.outputWhite }; + case 'outputWhite': + return { min: value.outputBlack, max: LEVELS_INPUT_MAX }; + } +} + +/** Read the current numeric value for `kind` out of a `LevelsValue`. */ +export function handleValue(kind: LevelsHandleKind, value: LevelsValue): number { + return value[kind]; +} + +/** Human-readable label for each handle, used for `aria-label`. */ +export const LEVELS_HANDLE_LABELS: Record = { + black: 'Black point', + gamma: 'Gamma', + white: 'White point', + outputBlack: 'Output black', + outputWhite: 'Output white', +}; + +/** + * Derive auto black/white points from a 256-bin luminance `histogram` by + * clipping `clipFraction` of the total pixel count off each tail (Photoshop's + * default is 0.1 % per side). Gamma is left at `1` and the output range is left + * full. Returns the default adjustment when the histogram is empty/flat so the + * caller never produces a degenerate (black >= white) window. + */ +export function computeAutoLevels( + histogram: number[] | undefined, + clipFraction = 0.001, +): Pick { + if (!histogram || histogram.length === 0) { + return { black: LEVELS_DEFAULT_VALUE.black, white: LEVELS_DEFAULT_VALUE.white }; + } + let total = 0; + for (let i = 0; i < histogram.length; i++) total += histogram[i]!; + if (total <= 0) { + return { black: LEVELS_DEFAULT_VALUE.black, white: LEVELS_DEFAULT_VALUE.white }; + } + + const lastBin = histogram.length - 1; + const clipCount = total * clipFraction; + const toLevel = (bin: number): number => Math.round((bin / lastBin) * LEVELS_INPUT_MAX); + + // Walk in from the dark end until `clipCount` pixels are accounted for. + let acc = 0; + let blackBin = 0; + for (let i = 0; i <= lastBin; i++) { + acc += histogram[i]!; + if (acc > clipCount) { + blackBin = i; + break; + } + } + + // Walk in from the bright end. + acc = 0; + let whiteBin = lastBin; + for (let i = lastBin; i >= 0; i--) { + acc += histogram[i]!; + if (acc > clipCount) { + whiteBin = i; + break; + } + } + + let black = toLevel(blackBin); + let white = toLevel(whiteBin); + // Guarantee a valid window even on near-flat data. + if (white <= black) { + black = LEVELS_DEFAULT_VALUE.black; + white = LEVELS_DEFAULT_VALUE.white; + } + return { black, white }; +} diff --git a/vue/primitives/src/canvas/time-ruler/TimeRulerCursor.vue b/vue/primitives/src/canvas/time-ruler/TimeRulerCursor.vue new file mode 100644 index 0000000..84cb25f --- /dev/null +++ b/vue/primitives/src/canvas/time-ruler/TimeRulerCursor.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/vue/primitives/src/canvas/time-ruler/TimeRulerLabel.vue b/vue/primitives/src/canvas/time-ruler/TimeRulerLabel.vue new file mode 100644 index 0000000..43e35be --- /dev/null +++ b/vue/primitives/src/canvas/time-ruler/TimeRulerLabel.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/vue/primitives/src/canvas/time-ruler/TimeRulerRoot.vue b/vue/primitives/src/canvas/time-ruler/TimeRulerRoot.vue new file mode 100644 index 0000000..731be5a --- /dev/null +++ b/vue/primitives/src/canvas/time-ruler/TimeRulerRoot.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/vue/primitives/src/canvas/time-ruler/TimeRulerScreenReaderSummary.vue b/vue/primitives/src/canvas/time-ruler/TimeRulerScreenReaderSummary.vue new file mode 100644 index 0000000..c75f57f --- /dev/null +++ b/vue/primitives/src/canvas/time-ruler/TimeRulerScreenReaderSummary.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/vue/primitives/src/canvas/time-ruler/TimeRulerTick.vue b/vue/primitives/src/canvas/time-ruler/TimeRulerTick.vue new file mode 100644 index 0000000..dafaac0 --- /dev/null +++ b/vue/primitives/src/canvas/time-ruler/TimeRulerTick.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/vue/primitives/src/canvas/time-ruler/__test__/TimeRuler.test.ts b/vue/primitives/src/canvas/time-ruler/__test__/TimeRuler.test.ts new file mode 100644 index 0000000..fe74587 --- /dev/null +++ b/vue/primitives/src/canvas/time-ruler/__test__/TimeRuler.test.ts @@ -0,0 +1,319 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import { provideConfig } from '../../../utilities/config-provider'; +import { TimeRulerCursor, TimeRulerRoot } from '../index'; + +// Minimal declarative config wrapper (the package ships no ConfigProvider +// component; config is provided via `provideConfig` inside a setup). +const ConfigProvider = defineComponent({ + props: { dir: { type: String, default: undefined } }, + setup(props, { slots }) { + provideConfig({ dir: () => props.dir as 'ltr' | 'rtl' | undefined }); + return () => slots.default?.(); + }, +}); + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +interface MountOpts { + duration?: number; + fps?: number; + mode?: 'seconds' | 'timecode' | 'frames'; + zoom?: number; + offset?: number; + minZoom?: number; + maxZoom?: number; + focusable?: boolean; + disabled?: boolean; + width?: number; + dir?: 'ltr' | 'rtl'; +} + +// Drive the root through a controlled harness so we can read back `offset` / +// `zoom`, and give the root a fixed CSS width so its measured geometry is real. +function mountRuler(opts: MountOpts = {}) { + const { width = 600, dir, ...rootProps } = opts; + const offset = ref(opts.offset ?? 0); + const zoom = ref(opts.zoom ?? 100); + + let exposed: any; + + const Harness = defineComponent({ + setup() { + const setExposed = (el: any) => { + exposed = el; + }; + return () => h(ConfigProvider, { dir }, { + default: () => h(TimeRulerRoot, { + ...rootProps, + ref: setExposed, + style: { display: 'block', width: `${width}px`, position: 'relative' }, + offset: offset.value, + 'onUpdate:offset': (v: number) => { offset.value = v; }, + zoom: zoom.value, + 'onUpdate:zoom': (v: number) => { zoom.value = v; }, + }), + }); + }, + }); + + const w = track(mount(Harness, { attachTo: document.body })); + return { wrapper: w, offset, zoom, getExposed: () => exposed }; +} + +function keydown(el: Element, key: string, opts: { shiftKey?: boolean; ctrlKey?: boolean } = {}): void { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + shiftKey: opts.shiftKey ?? false, + ctrlKey: opts.ctrlKey ?? false, + }); + el.dispatchEvent(event); +} + +/** Wait a frame for ResizeObserver-backed `useElementSize` to flush. */ +async function settle(): Promise { + await nextTick(); + await new Promise(r => requestAnimationFrame(() => r(null))); + await nextTick(); +} + +describe('timeRulerRoot — rendering & a11y', () => { + it('renders role=group with horizontal orientation', () => { + const { wrapper } = mountRuler({ duration: 60 }); + const root = wrapper.find('[role="group"]'); + expect(root.exists()).toBe(true); + expect(root.attributes('aria-orientation')).toBe('horizontal'); + expect(root.attributes('data-orientation')).toBe('horizontal'); + }); + + it('reflects aria-disabled / data-disabled when disabled', () => { + const { wrapper } = mountRuler({ duration: 60, disabled: true }); + const root = wrapper.find('[role="group"]'); + expect(root.attributes('aria-disabled')).toBe('true'); + expect(root.attributes('data-disabled')).toBe(''); + }); + + it('is focusable (tabindex 0) only when focusable and enabled', () => { + const { wrapper: a } = mountRuler({ duration: 60, focusable: true }); + expect(a.find('[role="group"]').attributes('tabindex')).toBe('0'); + const { wrapper: b } = mountRuler({ duration: 60 }); + expect(b.find('[role="group"]').attributes('tabindex')).toBeUndefined(); + const { wrapper: c } = mountRuler({ duration: 60, focusable: true, disabled: true }); + expect(c.find('[role="group"]').attributes('tabindex')).toBeUndefined(); + }); +}); + +describe('timeRulerRoot — scale & ticks', () => { + it('exposes scale/invert that round-trip and ticks that are non-empty', async () => { + const { getExposed } = mountRuler({ duration: 60, zoom: 100, width: 600 }); + await settle(); + const ex = getExposed(); + expect(ex).toBeTruthy(); + // scale/invert round-trip (offset 0, zoom 100 → 1s = 100px). + expect(ex.scale(0)).toBeCloseTo(0, 3); + expect(ex.scale(1)).toBeCloseTo(100, 3); + expect(ex.invert(ex.scale(3.5))).toBeCloseTo(3.5, 3); + // ticks span the visible window [0, 6s] → non-empty. (Exposed computed refs + // are auto-unwrapped on the public instance proxy, so read them directly.) + expect(ex.ticks.length).toBeGreaterThan(0); + }); + + it('formatTime renders clock labels in seconds mode', () => { + const { getExposed } = mountRuler({ duration: 600, mode: 'seconds' }); + const ex = getExposed(); + expect(ex.formatTime(0)).toBe('0:00'); + expect(ex.formatTime(75)).toBe('1:15'); + expect(ex.formatTime(3661)).toBe('1:01:01'); + }); + + it('formatTime renders HH:MM:SS:FF in timecode mode', () => { + const { getExposed } = mountRuler({ duration: 600, mode: 'timecode', fps: 30 }); + const ex = getExposed(); + expect(ex.formatTime(0)).toBe('00:00:00:00'); + // 1.5s @30fps → 1 second + 15 frames. + expect(ex.formatTime(1.5)).toBe('00:00:01:15'); + expect(ex.formatTime(61)).toBe('00:01:01:00'); + }); + + it('produces timecode-formatted tick labels in timecode mode', async () => { + const { getExposed } = mountRuler({ duration: 60, mode: 'timecode', fps: 30, zoom: 200, width: 600 }); + await settle(); + const ex = getExposed(); + const labelled = ex.majorTicks.filter((t: any) => t.label); + expect(labelled.length).toBeGreaterThan(0); + for (const t of labelled) { + expect(t.label).toMatch(/^\d{2}:\d{2}:\d{2}:\d{2}$/); + } + }); + + it('formatTime renders bare frame numbers in frames mode', () => { + const { getExposed } = mountRuler({ duration: 600, mode: 'frames', fps: 30 }); + const ex = getExposed(); + expect(ex.formatTime(0)).toBe('0'); + expect(ex.formatTime(2)).toBe('60'); + }); +}); + +describe('timeRulerRoot — keyboard pan & zoom', () => { + it('ArrowRight pans the offset forward', async () => { + const { wrapper, offset } = mountRuler({ duration: 600, focusable: true, zoom: 100, width: 600 }); + await settle(); + const root = wrapper.find('[role="group"]').element; + const before = offset.value; + keydown(root, 'ArrowRight'); + await nextTick(); + expect(offset.value).toBeGreaterThan(before); + }); + + it('ArrowLeft is clamped at zero (no negative offset)', async () => { + const { wrapper, offset } = mountRuler({ duration: 600, focusable: true, zoom: 100, width: 600, offset: 0 }); + await settle(); + const root = wrapper.find('[role="group"]').element; + keydown(root, 'ArrowLeft'); + await nextTick(); + expect(offset.value).toBe(0); + }); + + it('Shift+ArrowRight pans by a larger (major) interval than ArrowRight', async () => { + const a = mountRuler({ duration: 600, focusable: true, zoom: 100, width: 600 }); + await settle(); + keydown(a.wrapper.find('[role="group"]').element, 'ArrowRight'); + await nextTick(); + const minorDelta = a.offset.value; + + const b = mountRuler({ duration: 600, focusable: true, zoom: 100, width: 600 }); + await settle(); + keydown(b.wrapper.find('[role="group"]').element, 'ArrowRight', { shiftKey: true }); + await nextTick(); + const majorDelta = b.offset.value; + + expect(majorDelta).toBeGreaterThan(minorDelta); + }); + + it('+ increases zoom and - decreases it, clamped to min/max', async () => { + const { wrapper, zoom } = mountRuler({ + duration: 600, focusable: true, zoom: 100, minZoom: 50, maxZoom: 300, width: 600, + }); + await settle(); + const root = wrapper.find('[role="group"]').element; + + keydown(root, '+'); + await nextTick(); + expect(zoom.value).toBeGreaterThan(100); + + // Zoom in repeatedly → clamps at maxZoom. Await between presses so the + // controlled `zoom` prop propagates back before the next keypress reads it. + for (let i = 0; i < 20; i++) { + keydown(root, '+'); + await nextTick(); + } + expect(zoom.value).toBe(300); + + // Zoom out repeatedly → clamps at minZoom. + for (let i = 0; i < 30; i++) { + keydown(root, '-'); + await nextTick(); + } + expect(zoom.value).toBe(50); + }); + + it('does not handle keys when not focusable', async () => { + const { wrapper, offset } = mountRuler({ duration: 600, zoom: 100, width: 600 }); + await settle(); + const root = wrapper.find('[role="group"]').element; + keydown(root, 'ArrowRight'); + await nextTick(); + expect(offset.value).toBe(0); + }); +}); + +describe('timeRulerRoot — settle events & state flags', () => { + it('emits panCommit and rangeChange on keyboard pan', async () => { + const onPan = ref([]); + const onRange = ref>([]); + const Harness = defineComponent({ + setup() { + const offset = ref(0); + const zoom = ref(100); + return () => h(TimeRulerRoot, { + duration: 600, + focusable: true, + style: { display: 'block', width: '600px', position: 'relative' }, + offset: offset.value, + 'onUpdate:offset': (v: number) => { offset.value = v; }, + zoom: zoom.value, + 'onUpdate:zoom': (v: number) => { zoom.value = v; }, + onPanCommit: (o: number) => { onPan.value.push(o); }, + onRangeChange: (r: [number, number]) => { onRange.value.push(r); }, + }); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + await settle(); + keydown(w.find('[role="group"]').element, 'ArrowRight'); + await nextTick(); + expect(onPan.value.length).toBe(1); + expect(onRange.value.length).toBe(1); + }); + + it('emits zoomCommit on + / -', async () => { + const onZoom = ref([]); + const Harness = defineComponent({ + setup() { + const zoom = ref(100); + return () => h(TimeRulerRoot, { + duration: 600, + focusable: true, + style: { display: 'block', width: '600px', position: 'relative' }, + zoom: zoom.value, + 'onUpdate:zoom': (v: number) => { zoom.value = v; }, + onZoomCommit: (z: number) => { onZoom.value.push(z); }, + }); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + await settle(); + keydown(w.find('[role="group"]').element, '+'); + await nextTick(); + expect(onZoom.value.length).toBe(1); + }); +}); + +describe('timeRulerCursor', () => { + it('positions itself via the ruler scale', async () => { + const Harness = defineComponent({ + setup() { + return () => h(TimeRulerRoot, { + duration: 600, + zoom: 100, + offset: 0, + style: { display: 'block', width: '600px', position: 'relative' }, + }, { + default: () => h(TimeRulerCursor, { time: 2, 'data-testid': 'cursor' }), + }); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + await settle(); + const cursor = w.find('[data-testid="cursor"]'); + expect(cursor.exists()).toBe(true); + // time 2s * 100px/s = 200px. + expect(cursor.attributes('style')).toContain('left: 200px'); + expect(cursor.attributes('aria-hidden')).toBe('true'); + expect(cursor.attributes('role')).toBe('presentation'); + }); +}); diff --git a/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/TimeRuler.test.ts/timeRulerRoot---keyboard-pan---zoom---increases-zoom-and---decreases-it--clamped-to-min-max-1.png b/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/TimeRuler.test.ts/timeRulerRoot---keyboard-pan---zoom---increases-zoom-and---decreases-it--clamped-to-min-max-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/TimeRuler.test.ts/timeRulerRoot---keyboard-pan---zoom---increases-zoom-and---decreases-it--clamped-to-min-max-1.png differ diff --git a/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/TimeRuler.test.ts/timeRulerRoot---scale---ticks-exposes-scale-invert-that-round-trip-and-ticks-that-are-non-empty-1.png b/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/TimeRuler.test.ts/timeRulerRoot---scale---ticks-exposes-scale-invert-that-round-trip-and-ticks-that-are-non-empty-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/TimeRuler.test.ts/timeRulerRoot---scale---ticks-exposes-scale-invert-that-round-trip-and-ticks-that-are-non-empty-1.png differ diff --git a/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/TimeRuler.test.ts/timeRulerRoot---scale---ticks-produces-timecode-formatted-tick-labels-in-timecode-mode-1.png b/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/TimeRuler.test.ts/timeRulerRoot---scale---ticks-produces-timecode-formatted-tick-labels-in-timecode-mode-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/TimeRuler.test.ts/timeRulerRoot---scale---ticks-produces-timecode-formatted-tick-labels-in-timecode-mode-1.png differ diff --git a/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/debug.test.ts/debug-width-and-ticks-1.png b/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/debug.test.ts/debug-width-and-ticks-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/time-ruler/__test__/__screenshots__/debug.test.ts/debug-width-and-ticks-1.png differ diff --git a/vue/primitives/src/canvas/time-ruler/context.ts b/vue/primitives/src/canvas/time-ruler/context.ts new file mode 100644 index 0000000..7819461 --- /dev/null +++ b/vue/primitives/src/canvas/time-ruler/context.ts @@ -0,0 +1,55 @@ +import type { ComputedRef, Ref } from 'vue'; +import { useContextFactory } from '@robonen/vue'; +import type { Tick } from '../../internal/scale'; + +/** How tick labels are rendered. */ +export type TimeRulerMode = 'seconds' | 'timecode' | 'frames'; + +/** Writing direction; mirrors the binary contract used across primitives. */ +export type TimeRulerDirection = 'ltr' | 'rtl'; + +/** + * Context shared between `TimeRulerRoot` and its descendants (`TimeRulerTick`, + * `TimeRulerLabel`, `TimeRulerCursor`, `TimeRulerScreenReaderSummary`), and the + * surface a future `Timeline` reads to embed a ruler. + * + * Tick collections and the `scale`/`invert` projectors come straight from the + * `useScale` instance the root owns; scalar props are exposed as plain `Ref`s + * (built with `toRef(() => prop)` for identity passthrough). `scale` / `invert` + * are stable closures safe to call on the pointer hot path. + */ +export interface TimeRulerContext { + /** All ticks for the visible window. */ + ticks: ComputedRef; + /** Ticks where `major` is true (labelled / emphasised). */ + majorTicks: ComputedRef; + /** Ticks where `major` is false. */ + minorTicks: ComputedRef; + /** Project a time (seconds) to a pixel offset within the ruler. Stable identity. */ + scale: (seconds: number) => number; + /** Invert a pixel offset back to a time (seconds). Stable identity. */ + invert: (px: number) => number; + /** Left edge time (seconds) of the visible window. Two-way via `v-model:offset`. */ + offset: Ref; + /** Zoom in pixels-per-second. Two-way via `v-model:zoom`. */ + zoom: Ref; + /** Total content duration in seconds. */ + duration: Ref; + /** Frame rate used for timecode / frame modes. */ + fps: Ref; + /** Active label mode. */ + mode: Ref; + /** Format a time (seconds) per the active mode. */ + formatTime: (seconds: number) => string; + /** Whether a pan gesture (keyboard, drag, or wheel) is in progress. */ + isPanning: Ref; + /** Whether a zoom gesture is in progress. */ + isZooming: Ref; + /** Whether the ruler is disabled (non-interactive). */ + disabled: Ref; +} + +const ctx = useContextFactory('TimeRulerContext'); + +export const provideTimeRulerContext = ctx.provide; +export const useTimeRulerContext = ctx.inject; diff --git a/vue/primitives/src/canvas/time-ruler/demo.vue b/vue/primitives/src/canvas/time-ruler/demo.vue new file mode 100644 index 0000000..d374346 --- /dev/null +++ b/vue/primitives/src/canvas/time-ruler/demo.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/vue/primitives/src/canvas/time-ruler/index.ts b/vue/primitives/src/canvas/time-ruler/index.ts new file mode 100644 index 0000000..353c790 --- /dev/null +++ b/vue/primitives/src/canvas/time-ruler/index.ts @@ -0,0 +1,12 @@ +export { default as TimeRulerRoot } from './TimeRulerRoot.vue'; +export type { TimeRulerRootProps, TimeRulerRootEmits } from './TimeRulerRoot.vue'; +export { default as TimeRulerTick } from './TimeRulerTick.vue'; +export type { TimeRulerTickProps } from './TimeRulerTick.vue'; +export { default as TimeRulerLabel } from './TimeRulerLabel.vue'; +export type { TimeRulerLabelProps } from './TimeRulerLabel.vue'; +export { default as TimeRulerCursor } from './TimeRulerCursor.vue'; +export type { TimeRulerCursorProps } from './TimeRulerCursor.vue'; +export { default as TimeRulerScreenReaderSummary } from './TimeRulerScreenReaderSummary.vue'; +export type { TimeRulerScreenReaderSummaryProps } from './TimeRulerScreenReaderSummary.vue'; +export { provideTimeRulerContext, useTimeRulerContext } from './context'; +export type { TimeRulerContext, TimeRulerMode, TimeRulerDirection } from './context'; diff --git a/vue/primitives/src/canvas/time-ruler/utils.ts b/vue/primitives/src/canvas/time-ruler/utils.ts new file mode 100644 index 0000000..251aa9e --- /dev/null +++ b/vue/primitives/src/canvas/time-ruler/utils.ts @@ -0,0 +1,65 @@ +import type { TickKind } from '../../internal/scale'; +import { formatClock, formatFrames, formatTimecode, secondsToFrames } from '../../internal/scale'; +import type { TimeRulerMode } from './context'; + +/** + * Map a {@link TimeRulerMode} to the {@link TickKind} its `useScale` should use. + * + * The ruler's scale domain is always in SECONDS, so both frame-based modes route + * through the `'timecode'` ticker — which converts the seconds domain to an + * integer-frame ladder internally (positions never drift) while keeping + * `domain` / `range` in seconds. A `format` override (see {@link tickFormatFor}) + * then chooses between SMPTE timecode and bare frame numbers. + * + * - `'seconds'` → `'time'` (human time ladder, `M:SS` / `H:MM:SS` labels) + * - `'timecode'` → `'timecode'` (SMPTE `HH:MM:SS:FF` labels) + * - `'frames'` → `'timecode'` (frame-aligned ticks, frame-number labels) + */ +export function modeToTickKind(mode: TimeRulerMode): TickKind { + switch (mode) { + case 'timecode': + case 'frames': + return 'timecode'; + case 'seconds': + default: + return 'time'; + } +} + +/** + * A `format` callback (seconds → label) for the active `mode`, or `undefined` + * to let the ticker apply its default. Only `'frames'` needs an override (it + * borrows the timecode ticker's frame-aligned geometry but labels as frame + * numbers); `'seconds'` and `'timecode'` use their generators' defaults. + */ +export function tickFormatFor( + mode: TimeRulerMode, + fps: number, +): ((seconds: number) => string) | undefined { + if (mode !== 'frames') return undefined; + return (seconds: number) => formatFrames(secondsToFrames(seconds, fps)); +} + +/** + * Format a time (in seconds) for display per the active {@link TimeRulerMode}. + * + * - `'seconds'` → wall-clock string via {@link formatClock} + * - `'timecode'` → SMPTE timecode via {@link formatTimecode} (drop-frame aware) + * - `'frames'` → integer frame number via {@link formatFrames} + */ +export function formatTimeForMode( + seconds: number, + mode: TimeRulerMode, + fps: number, + dropFrame = false, +): string { + switch (mode) { + case 'timecode': + return formatTimecode(seconds, fps, dropFrame); + case 'frames': + return formatFrames(secondsToFrames(seconds, fps)); + case 'seconds': + default: + return formatClock(seconds); + } +} diff --git a/vue/primitives/src/canvas/timeline/TimelineClip.vue b/vue/primitives/src/canvas/timeline/TimelineClip.vue new file mode 100644 index 0000000..2b81319 --- /dev/null +++ b/vue/primitives/src/canvas/timeline/TimelineClip.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/vue/primitives/src/canvas/timeline/TimelineClipHandle.vue b/vue/primitives/src/canvas/timeline/TimelineClipHandle.vue new file mode 100644 index 0000000..9b1b110 --- /dev/null +++ b/vue/primitives/src/canvas/timeline/TimelineClipHandle.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/vue/primitives/src/canvas/timeline/TimelineMarker.vue b/vue/primitives/src/canvas/timeline/TimelineMarker.vue new file mode 100644 index 0000000..2676e02 --- /dev/null +++ b/vue/primitives/src/canvas/timeline/TimelineMarker.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/vue/primitives/src/canvas/timeline/TimelinePlayhead.vue b/vue/primitives/src/canvas/timeline/TimelinePlayhead.vue new file mode 100644 index 0000000..8003cf7 --- /dev/null +++ b/vue/primitives/src/canvas/timeline/TimelinePlayhead.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/vue/primitives/src/canvas/timeline/TimelineRoot.vue b/vue/primitives/src/canvas/timeline/TimelineRoot.vue new file mode 100644 index 0000000..dba033d --- /dev/null +++ b/vue/primitives/src/canvas/timeline/TimelineRoot.vue @@ -0,0 +1,738 @@ + + + + + diff --git a/vue/primitives/src/canvas/timeline/TimelineRuler.vue b/vue/primitives/src/canvas/timeline/TimelineRuler.vue new file mode 100644 index 0000000..1d9c037 --- /dev/null +++ b/vue/primitives/src/canvas/timeline/TimelineRuler.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/vue/primitives/src/canvas/timeline/TimelineSelection.vue b/vue/primitives/src/canvas/timeline/TimelineSelection.vue new file mode 100644 index 0000000..22bb2f4 --- /dev/null +++ b/vue/primitives/src/canvas/timeline/TimelineSelection.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/vue/primitives/src/canvas/timeline/TimelineTrack.vue b/vue/primitives/src/canvas/timeline/TimelineTrack.vue new file mode 100644 index 0000000..01738cf --- /dev/null +++ b/vue/primitives/src/canvas/timeline/TimelineTrack.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/vue/primitives/src/canvas/timeline/TimelineTrackHeader.vue b/vue/primitives/src/canvas/timeline/TimelineTrackHeader.vue new file mode 100644 index 0000000..c26f79e --- /dev/null +++ b/vue/primitives/src/canvas/timeline/TimelineTrackHeader.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/vue/primitives/src/canvas/timeline/TimelineTracks.vue b/vue/primitives/src/canvas/timeline/TimelineTracks.vue new file mode 100644 index 0000000..e374fcd --- /dev/null +++ b/vue/primitives/src/canvas/timeline/TimelineTracks.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/vue/primitives/src/canvas/timeline/__test__/Timeline.test.ts b/vue/primitives/src/canvas/timeline/__test__/Timeline.test.ts new file mode 100644 index 0000000..fca3460 --- /dev/null +++ b/vue/primitives/src/canvas/timeline/__test__/Timeline.test.ts @@ -0,0 +1,324 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import { + TimelineClip, + TimelineClipHandle, + TimelineMarker, + TimelinePlayhead, + TimelineRoot, + TimelineTrack, + TimelineTrackHeader, + TimelineTracks, +} from '../index'; +import type { TimelineClipData, TimelineMarkerData, TimelineTrackData } from '../index'; + +const wrappers: Array> = []; +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); +function track>(w: T): T { + wrappers.push(w); + return w; +} + +const tracks: TimelineTrackData[] = [ + { id: 't1', label: 'Video' }, + { id: 't2', label: 'Audio' }, +]; +const clips: TimelineClipData[] = [ + { id: 'c1', trackId: 't1', start: 1, duration: 2, label: 'Clip 1' }, + { id: 'c2', trackId: 't2', start: 4, duration: 3, label: 'Clip 2' }, +]; +const markers: TimelineMarkerData[] = [{ id: 'm1', time: 2, label: 'Cue' }]; + +/** + * A full timeline composition with a fixed-width viewport so the scale projects + * to real pixels (`pxPerSecond` px/s). Width 1000px, pxPerSecond 100 → 10s window. + */ +function mountTimeline(props: Record = {}, clipChildren?: (id: string) => any) { + const Comp = defineComponent({ + setup() { + // Render clips from a LIVE model so add/remove reflect in the DOM (real usage). + const clipsRef = ref(clips.map(c => ({ ...c }))); + return () => h( + TimelineRoot, + { + tracks, + clips: clipsRef.value, + 'onUpdate:clips': (v: TimelineClipData[]) => { clipsRef.value = v; }, + defaultMarkers: markers, + defaultPxPerSecond: 100, + fps: 30, + style: 'width:1000px;display:block;', + ...props, + } as Record, + { + default: () => [ + h(TimelinePlayhead, {}, { default: () => 'PH' }), + ...markers.map(m => h(TimelineMarker, { markerId: m.id })), + h(TimelineTracks, { style: 'position:relative;display:block;width:1000px;height:128px;' }, { + default: () => tracks.map(t => h( + TimelineTrack, + { trackId: t.id, key: t.id, style: 'position:relative;display:block;' }, + { + default: () => [ + h(TimelineTrackHeader, {}, {}), + ...clipsRef.value + .filter(c => c.trackId === t.id) + .map(c => h( + TimelineClip, + { clipId: c.id, key: c.id }, + { default: () => (clipChildren ? clipChildren(c.id) : c.label) }, + )), + ], + }, + )), + }), + ], + }, + ); + }, + }); + return track(mount(Comp, { attachTo: document.body })); +} + +describe('TimelineRoot', () => { + it('renders the root group with timeline roledescription', () => { + const w = mountTimeline(); + const root = w.find('[aria-roledescription="timeline"]'); + expect(root.exists()).toBe(true); + expect(root.attributes('role')).toBe('group'); + }); + + it('renders every track lane and clip', () => { + const w = mountTimeline(); + expect(w.findAll('[data-track-id]').length).toBeGreaterThanOrEqual(2); + expect(w.findAll('[data-clip-id]')).toHaveLength(2); + }); +}); + +describe('scale projection', () => { + it('positions clips at real px from the scale (start * pxPerSecond)', async () => { + const w = mountTimeline(); + await nextTick(); + const c1 = w.find('[data-clip-id="c1"]').element as HTMLElement; + // start=1s @ 100px/s => left 100px; duration=2s => width 200px. + expect(c1.style.left).toBe('100px'); + expect(c1.style.width).toBe('200px'); + const c2 = w.find('[data-clip-id="c2"]').element as HTMLElement; + expect(c2.style.left).toBe('400px'); // start 4s + expect(c2.style.width).toBe('300px'); // duration 3s + }); +}); + +describe('TimelinePlayhead', () => { + it('is a horizontal slider with a timecode aria-valuetext', () => { + const w = mountTimeline({ defaultCurrentTime: 2 }); + const ph = w.find('[role="slider"][aria-label="Playhead"]'); + expect(ph.exists()).toBe(true); + expect(ph.attributes('aria-orientation')).toBe('horizontal'); + expect(ph.attributes('aria-valuenow')).toBe('2'); + expect(ph.attributes('aria-valuetext')).toBe('00:00:02:00'); + }); + + it('ArrowRight scrubs the playhead by exactly one frame', async () => { + const onScrub = ref(null); + const w = mountTimeline({ + defaultCurrentTime: 1, + onScrubCommit: (t: number) => { onScrub.value = t; }, + }); + const ph = w.find('[role="slider"][aria-label="Playhead"]'); + await ph.trigger('keydown', { key: 'ArrowRight' }); + await nextTick(); + // 1s + 1 frame @30fps = 1 + 1/30. + expect(onScrub.value).toBeCloseTo(1 + 1 / 30, 5); + expect(Number(ph.attributes('aria-valuenow'))).toBeCloseTo(1 + 1 / 30, 5); + }); + + it('Home jumps to 0 and End to duration', async () => { + const w = mountTimeline({ defaultCurrentTime: 3, duration: 10 }); + const ph = w.find('[role="slider"][aria-label="Playhead"]'); + await ph.trigger('keydown', { key: 'Home' }); + await nextTick(); + expect(ph.attributes('aria-valuenow')).toBe('0'); + await ph.trigger('keydown', { key: 'End' }); + await nextTick(); + expect(ph.attributes('aria-valuenow')).toBe('10'); + }); +}); + +describe('TimelineClip roving focus + selection', () => { + it('exposes a single tab-stop with aria-selected', async () => { + const w = mountTimeline(); + await nextTick(); + const stops = w.findAll('[data-clip-id]').filter(c => c.attributes('tabindex') === '0'); + expect(stops).toHaveLength(1); + // The first clip in time order (c1 @1s) is the tab stop. + expect(stops[0]!.attributes('data-clip-id')).toBe('c1'); + }); + + it('selects a clip on pointerdown (data-selected + aria-selected)', async () => { + const w = mountTimeline(); + const c1 = w.find('[data-clip-id="c1"]'); + c1.element.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true })); + await nextTick(); + expect(w.find('[data-clip-id="c1"]').attributes('data-selected')).toBe(''); + expect(w.find('[data-clip-id="c1"]').attributes('aria-selected')).toBe('true'); + }); + + it('ArrowRight nudges a focused clip by one frame', async () => { + const onChange = ref(null); + const w = mountTimeline({ onClipsChange: (c: any) => void (onChange.value = c) }); + const c1 = w.find('[data-clip-id="c1"]'); + c1.element.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true })); + await nextTick(); + await c1.trigger('keydown', { key: 'ArrowRight' }); + await nextTick(); + expect(onChange.value).toBeTruthy(); + const moved = onChange.value.find((ch: any) => ch.id === 'c1'); + // start 1s + 1 frame @30fps. + expect(moved.start).toBeCloseTo(1 + 1 / 30, 5); + }); + + it('ArrowDown moves a clip to the track below', async () => { + const onChange = ref(null); + const w = mountTimeline({ onClipsChange: (c: any) => void (onChange.value = c) }); + const c1 = w.find('[data-clip-id="c1"]'); + c1.element.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true })); + await nextTick(); + await c1.trigger('keydown', { key: 'ArrowDown' }); + await nextTick(); + const moved = onChange.value.find((ch: any) => ch.id === 'c1'); + expect(moved.trackId).toBe('t2'); // moved from t1 to the next lane t2 + }); + + it('Delete removes the selected clip', async () => { + const onChange = ref(null); + const w = mountTimeline({ onClipsChange: (c: any) => void (onChange.value = c) }); + const c1 = w.find('[data-clip-id="c1"]'); + c1.element.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true })); + await nextTick(); + await c1.trigger('keydown', { key: 'Delete' }); + await nextTick(); + expect(onChange.value.some((ch: any) => ch.type === 'remove' && ch.id === 'c1')).toBe(true); + expect(w.find('[data-clip-id="c1"]').exists()).toBe(false); + }); +}); + +describe('TimelineClipHandle (trim)', () => { + it('is a slider with valuemin/valuemax/valuenow in seconds + timecode', async () => { + const w = mountTimeline({}, () => [ + h(TimelineClipHandle, { side: 'start', key: 's' }), + h(TimelineClipHandle, { side: 'end', key: 'e' }), + ]); + await nextTick(); + // Two handles (start + end) per clip; scope to the first clip. + const c1 = w.find('[data-clip-id="c1"]'); + const handles = c1.findAll('[role="slider"][data-side]'); + expect(handles).toHaveLength(2); + const startHandle = handles.find(h => h.attributes('data-side') === 'start')!; + expect(startHandle.attributes('aria-orientation')).toBe('horizontal'); + expect(startHandle.attributes('aria-valuetext')).toMatch(/^\d\d:\d\d:\d\d:\d\d$/); + }); + + it('ArrowRight on the start handle trims the clip start by a frame', async () => { + const onChange = ref(null); + const w = mountTimeline( + { onClipsChange: (c: any) => void (onChange.value = c) }, + () => h(TimelineClipHandle, { side: 'start' }), + ); + await nextTick(); + const handle = w.find('[role="slider"][data-side="start"]'); + await handle.trigger('keydown', { key: 'ArrowRight' }); + await nextTick(); + expect(onChange.value).toBeTruthy(); + const trimmed = onChange.value.find((ch: any) => ch.type === 'move' || ch.type === 'trim'); + expect(trimmed).toBeTruthy(); + }); +}); + +describe('TimelineMarker', () => { + it('renders a marker button with timecode label at its time', async () => { + const w = mountTimeline(); + await nextTick(); + const marker = w.find('[data-marker-id="m1"]'); + expect(marker.exists()).toBe(true); + expect(marker.attributes('aria-label')).toContain('00:00:02:00'); + // time=2s @ 100px/s => left 200px. + expect((marker.element as HTMLElement).style.left).toBe('200px'); + }); +}); + +describe('track header toggles', () => { + it('mute/solo/lock buttons set aria-pressed + emit a track patch', async () => { + const onTracks = ref(null); + const w = mountTimeline({ onTracksChange: (c: any) => void (onTracks.value = c) }); + const muteBtn = w.findAll('button').find(b => b.attributes('aria-label')?.startsWith('Mute'))!; + expect(muteBtn.attributes('aria-pressed')).toBe('false'); + await muteBtn.trigger('click'); + await nextTick(); + expect(onTracks.value[0]).toMatchObject({ type: 'patch', patch: { muted: true } }); + const after = w.findAll('button').find(b => b.attributes('aria-label')?.startsWith('Mute'))!; + expect(after.attributes('aria-pressed')).toBe('true'); + }); +}); + +describe('disabled', () => { + it('blocks scrubbing and selection', async () => { + const onChange = ref(null); + const w = mountTimeline({ disabled: true, onClipsChange: (c: any) => void (onChange.value = c) }); + const root = w.find('[aria-roledescription="timeline"]'); + expect(root.attributes('data-disabled')).toBe(''); + const c1 = w.find('[data-clip-id="c1"]'); + c1.element.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true })); + await nextTick(); + expect(w.find('[data-clip-id="c1"]').attributes('data-selected')).toBeUndefined(); + }); +}); + +describe('reconcile does not clobber a mid-mutation clip', () => { + it('ignores an external clips write while isMutating', async () => { + const model = ref([{ id: 'c1', trackId: 't1', start: 1, duration: 2 }]); + const Comp = defineComponent({ + setup(_, { expose }) { + const rootRef = ref(null); + expose({ rootRef }); + return () => h( + TimelineRoot, + { + ref: rootRef, + tracks: [{ id: 't1' }], + clips: model.value, + 'onUpdate:clips': (v: TimelineClipData[]) => { model.value = v; }, + defaultPxPerSecond: 100, + style: 'width:1000px;display:block;', + } as Record, + { + default: () => h(TimelineTracks, { style: 'position:relative;width:1000px;height:64px;' }, { + default: () => h(TimelineTrack, { trackId: 't1' }, { + default: () => h(TimelineClip, { clipId: 'c1' }), + }), + }), + }, + ); + }, + }); + const w = track(mount(Comp, { attachTo: document.body })); + await nextTick(); + // Begin a drag on the clip so isMutating flips. + const c1 = w.find('[data-clip-id="c1"]').element as HTMLElement; + c1.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true, clientX: 100, clientY: 10 })); + c1.dispatchEvent(new PointerEvent('pointermove', { button: 0, bubbles: true, clientX: 160, clientY: 10 })); + await nextTick(); + // External write during the drag MUST NOT reset the live position. + model.value = [{ id: 'c1', trackId: 't1', start: 1, duration: 2 }]; + await nextTick(); + c1.dispatchEvent(new PointerEvent('pointerup', { button: 0, bubbles: true, clientX: 160, clientY: 10 })); + await nextTick(); + // After commit the clip reflects the drag, not the clobbering external value. + expect(model.value[0]!.start).toBeGreaterThan(1); + }); +}); diff --git a/vue/primitives/src/canvas/timeline/__test__/__screenshots__/Timeline.test.ts/TimelineClip-roving-focus---selection-Delete-removes-the-selected-clip-1.png b/vue/primitives/src/canvas/timeline/__test__/__screenshots__/Timeline.test.ts/TimelineClip-roving-focus---selection-Delete-removes-the-selected-clip-1.png new file mode 100644 index 0000000..205935f Binary files /dev/null and b/vue/primitives/src/canvas/timeline/__test__/__screenshots__/Timeline.test.ts/TimelineClip-roving-focus---selection-Delete-removes-the-selected-clip-1.png differ diff --git a/vue/primitives/src/canvas/timeline/__test__/__screenshots__/Timeline.test.ts/TimelineClipHandle--trim--is-a-slider-with-valuemin-valuemax-valuenow-in-seconds---timecode-1.png b/vue/primitives/src/canvas/timeline/__test__/__screenshots__/Timeline.test.ts/TimelineClipHandle--trim--is-a-slider-with-valuemin-valuemax-valuenow-in-seconds---timecode-1.png new file mode 100644 index 0000000..2ae7cb6 Binary files /dev/null and b/vue/primitives/src/canvas/timeline/__test__/__screenshots__/Timeline.test.ts/TimelineClipHandle--trim--is-a-slider-with-valuemin-valuemax-valuenow-in-seconds---timecode-1.png differ diff --git a/vue/primitives/src/canvas/timeline/__test__/__screenshots__/changes.test.ts/applyClipChanges-treats-select-changes-as-a-no-op-on-the-clip-array-1.png b/vue/primitives/src/canvas/timeline/__test__/__screenshots__/changes.test.ts/applyClipChanges-treats-select-changes-as-a-no-op-on-the-clip-array-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/timeline/__test__/__screenshots__/changes.test.ts/applyClipChanges-treats-select-changes-as-a-no-op-on-the-clip-array-1.png differ diff --git a/vue/primitives/src/canvas/timeline/__test__/changes.test.ts b/vue/primitives/src/canvas/timeline/__test__/changes.test.ts new file mode 100644 index 0000000..f16683b --- /dev/null +++ b/vue/primitives/src/canvas/timeline/__test__/changes.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import type { TimelineClip, TimelineTrack } from '../utils'; +import { applyClipChanges, applyTrackChanges } from '../changes'; + +const clips: TimelineClip[] = [ + { id: 'a', trackId: 't1', start: 0, duration: 2 }, + { id: 'b', trackId: 't1', start: 5, duration: 3 }, + { id: 'c', trackId: 't2', start: 1, duration: 4 }, +]; + +describe('applyClipChanges', () => { + it('returns the SAME array reference for an empty batch', () => { + expect(applyClipChanges(clips, [])).toBe(clips); + }); + + it('moves a clip immutably, keeping untouched clips by identity', () => { + const out = applyClipChanges(clips, [{ type: 'move', id: 'a', trackId: 't2', start: 10 }]); + expect(out).not.toBe(clips); + expect(out[0]).not.toBe(clips[0]); // touched clip is a new object + expect(out[0]).toMatchObject({ id: 'a', trackId: 't2', start: 10 }); + expect(out[1]).toBe(clips[1]); // untouched identity preserved + expect(out[2]).toBe(clips[2]); + }); + + it('trims a clip (start + duration)', () => { + const out = applyClipChanges(clips, [{ type: 'trim', id: 'b', start: 6, duration: 2 }]); + expect(out[1]).toMatchObject({ id: 'b', start: 6, duration: 2 }); + }); + + it('rejects a trim that would make duration non-positive', () => { + const out = applyClipChanges(clips, [{ type: 'trim', id: 'b', start: 6, duration: 0 }]); + expect(out[1]).toBe(clips[1]); // unchanged + }); + + it('splits a clip into -a / -b halves at the cut, dropping the source', () => { + const out = applyClipChanges(clips, [{ type: 'split', id: 'b', at: 6 }]); + const ids = out.map(c => c.id); + expect(ids).toEqual(['a', 'b-a', 'b-b', 'c']); + const left = out.find(c => c.id === 'b-a')!; + const right = out.find(c => c.id === 'b-b')!; + expect(left).toMatchObject({ start: 5, duration: 1 }); + expect(right).toMatchObject({ start: 6, duration: 2 }); + }); + + it('ignores a split outside the clip span', () => { + const out = applyClipChanges(clips, [{ type: 'split', id: 'a', at: 9 }]); + expect(out.map(c => c.id)).toEqual(['a', 'b', 'c']); + }); + + it('removes a clip', () => { + const out = applyClipChanges(clips, [{ type: 'remove', id: 'a' }]); + expect(out.map(c => c.id)).toEqual(['b', 'c']); + }); + + it('adds a clip at an index', () => { + const out = applyClipChanges(clips, [ + { type: 'add', clip: { id: 'd', trackId: 't1', start: 9, duration: 1 }, index: 1 }, + ]); + expect(out.map(c => c.id)).toEqual(['a', 'd', 'b', 'c']); + }); + + it('treats select changes as a no-op on the clip array', () => { + const out = applyClipChanges(clips, [{ type: 'select', ids: ['a', 'b'] }]); + expect(out).toBe(clips); + }); + + it('folds a mixed batch (move + remove + add)', () => { + const out = applyClipChanges(clips, [ + { type: 'move', id: 'a', trackId: 't1', start: 1 }, + { type: 'remove', id: 'c' }, + { type: 'add', clip: { id: 'z', trackId: 't3', start: 0, duration: 1 } }, + ]); + expect(out.map(c => c.id)).toEqual(['a', 'b', 'z']); + expect(out.find(c => c.id === 'a')).toMatchObject({ start: 1 }); + }); +}); + +describe('applyTrackChanges', () => { + const tracks: TimelineTrack[] = [ + { id: 't1', label: 'Video' }, + { id: 't2', label: 'Audio' }, + ]; + + it('returns the SAME reference for an empty batch', () => { + expect(applyTrackChanges(tracks, [])).toBe(tracks); + }); + + it('patches a track flag immutably', () => { + const out = applyTrackChanges(tracks, [{ type: 'patch', id: 't1', patch: { muted: true } }]); + expect(out[0]).toMatchObject({ id: 't1', muted: true }); + expect(out[1]).toBe(tracks[1]); + }); + + it('removes and adds tracks', () => { + const removed = applyTrackChanges(tracks, [{ type: 'remove', id: 't1' }]); + expect(removed.map(t => t.id)).toEqual(['t2']); + const added = applyTrackChanges(tracks, [{ type: 'add', track: { id: 't3' }, index: 0 }]); + expect(added.map(t => t.id)).toEqual(['t3', 't1', 't2']); + }); +}); diff --git a/vue/primitives/src/canvas/timeline/__test__/utils.test.ts b/vue/primitives/src/canvas/timeline/__test__/utils.test.ts new file mode 100644 index 0000000..203e5f5 --- /dev/null +++ b/vue/primitives/src/canvas/timeline/__test__/utils.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { clipIntersectsTime, clipsDuration, snapToFrame, timeToTimecode } from '../utils'; +import type { TimelineClip } from '../utils'; + +describe('timeToTimecode', () => { + it('formats seconds as SMPTE HH:MM:SS:FF at fps', () => { + expect(timeToTimecode(0, 30)).toBe('00:00:00:00'); + expect(timeToTimecode(1, 30)).toBe('00:00:01:00'); + // 1.5s @ 30fps = 45 frames = 1s + 15f. + expect(timeToTimecode(1.5, 30)).toBe('00:00:01:15'); + expect(timeToTimecode(61, 30)).toBe('00:01:01:00'); + }); +}); + +describe('snapToFrame', () => { + it('snaps a time to the nearest whole frame at fps', () => { + // 0.02s @ 30fps -> 0.6 frames -> rounds to 1 frame -> 1/30s. + expect(snapToFrame(0.02, 30)).toBeCloseTo(1 / 30, 6); + // Exact frame stays put. + expect(snapToFrame(2 / 30, 30)).toBeCloseTo(2 / 30, 6); + }); + + it('is a no-op for fps <= 0', () => { + expect(snapToFrame(1.234, 0)).toBe(1.234); + }); +}); + +describe('clipsDuration', () => { + it('derives the largest start+duration', () => { + const clips: TimelineClip[] = [ + { id: 'a', trackId: 't', start: 0, duration: 2 }, + { id: 'b', trackId: 't', start: 5, duration: 3 }, // ends at 8 + { id: 'c', trackId: 't', start: 1, duration: 4 }, + ]; + expect(clipsDuration(clips)).toBe(8); + }); + + it('is 0 for an empty set', () => { + expect(clipsDuration([])).toBe(0); + }); +}); + +describe('clipIntersectsTime', () => { + const clip: TimelineClip = { id: 'a', trackId: 't', start: 4, duration: 2 }; // [4, 6] + + it('intersects an overlapping window', () => { + expect(clipIntersectsTime(clip, 5, 7)).toBe(true); + expect(clipIntersectsTime(clip, 0, 4.5)).toBe(true); + }); + + it('does not intersect a disjoint window', () => { + expect(clipIntersectsTime(clip, 0, 3)).toBe(false); + expect(clipIntersectsTime(clip, 7, 9)).toBe(false); + }); + + it('normalizes a reversed window (to < from)', () => { + expect(clipIntersectsTime(clip, 7, 5)).toBe(true); + }); +}); diff --git a/vue/primitives/src/canvas/timeline/changes.ts b/vue/primitives/src/canvas/timeline/changes.ts new file mode 100644 index 0000000..39f05ad --- /dev/null +++ b/vue/primitives/src/canvas/timeline/changes.ts @@ -0,0 +1,186 @@ +import type { TimelineClip, TimelineTrack } from './utils'; + +/** + * A granular track mutation, emitted via `@tracks-change` so a controlled + * consumer can fold flag toggles (mute/lock/solo) and height resizes into its + * own `tracks` array. + * + * - `patch` — shallow-merge a partial track (flag toggles + height). + * - `remove` — delete the track. + * - `add` — insert a track. + */ +export type TimelineTrackChange + = | { type: 'patch'; id: string; patch: Partial } + | { type: 'remove'; id: string } + | { type: 'add'; track: TimelineTrack; index?: number }; + +/** + * Apply a batch of track changes to a tracks array, returning a NEW array (and + * new objects only for changed tracks). Returns the SAME reference when empty. + */ +export function applyTrackChanges( + tracks: TimelineTrack[], + changes: TimelineTrackChange[], +): TimelineTrack[] { + if (changes.length === 0) return tracks; + + const byId = new Map(tracks.map(t => [t.id, t])); + const removed = new Set(); + const added: Array<{ item: TimelineTrack; index?: number }> = []; + + for (const change of changes) { + switch (change.type) { + case 'patch': { + const track = byId.get(change.id); + if (track) byId.set(change.id, { ...track, ...change.patch }); + break; + } + case 'remove': + removed.add(change.id); + break; + case 'add': + added.push({ item: change.track, index: change.index }); + break; + } + } + + const result = tracks + .filter(t => !removed.has(t.id)) + .map(t => byId.get(t.id) ?? t); + + for (const { item, index } of added) { + if (index === undefined || index >= result.length) result.push(item); + else result.splice(index, 0, item); + } + + return result; +} + +/** + * A granular clip mutation. `applyClipChanges` is the controlled-mode reducer: + * bind `@clips-change` and fold the batch into your own `clips` array (mirrors + * the React-Flow / `applyNodeChanges` pattern). Every variant is immutable — + * only the touched clips get a new object so per-clip computeds (and `v-memo`) + * stay effective for untouched clips. + * + * - `move` — reposition `start` (and optionally cross-track via `trackId`). + * - `trim` — adjust `start` + `duration` (a handle drag). + * - `split` — cut the clip at time `at`, producing two clips (`-a` / `-b` ids). + * - `remove` — delete the clip. + * - `add` — insert a clip. + * - `select` — replace the selected-clip id set (selection state, not data). + */ +export type TimelineClipChange + = | { type: 'move'; id: string; trackId: string; start: number } + | { type: 'trim'; id: string; start: number; duration: number } + | { type: 'split'; id: string; at: number } + | { type: 'remove'; id: string } + | { type: 'add'; clip: TimelineClip; index?: number } + | { type: 'select'; ids: string[] }; + +/** + * Suffix appended to the two halves a split produces. The original id is dropped + * and replaced by `${id}-a` (left) and `${id}-b` (right). + */ +const SPLIT_LEFT = '-a'; +const SPLIT_RIGHT = '-b'; + +/** + * Apply a batch of clip changes to a clips array, returning a NEW array (and new + * objects only for changed clips — untouched clips keep identity). Returns the + * SAME array reference when `changes` is empty. + * + * `select` changes carry no data payload so they are a no-op here (selection is + * Root state, not part of the clip model); the reducer accepts them so a mixed + * batch can be folded in one call. + * + * Order of operations within the batch: per-id patches (`move` / `trim`) and + * `split` mutate the `byId` map; `remove` collects ids; `add` is appended after + * filtering — matching `applyNodeChanges` semantics. + */ +export function applyClipChanges( + clips: Array>, + changes: Array>, +): Array> { + if (changes.length === 0) return clips; + + const byId = new Map(clips.map(c => [c.id, c])); + const removed = new Set(); + const added: Array<{ item: TimelineClip; index?: number }> = []; + // Splits remove the source clip and insert its two halves where the source was; + // tracked here so ordering stays stable rather than appending to the end. + const splits = new Map, TimelineClip]>(); + // Did any data-affecting change land? A batch of only `select` (or no-op) changes + // returns the SAME reference so consumers don't re-render the whole list. + let mutated = false; + + for (const change of changes) { + switch (change.type) { + case 'move': { + const clip = byId.get(change.id); + if (clip) { + byId.set(change.id, { ...clip, start: change.start, trackId: change.trackId }); + mutated = true; + } + break; + } + case 'trim': { + const clip = byId.get(change.id); + // Guard the invariant: duration must stay positive. + if (clip && change.duration > 0) { + byId.set(change.id, { ...clip, start: change.start, duration: change.duration }); + mutated = true; + } + break; + } + case 'split': { + const clip = byId.get(change.id); + if (!clip) break; + const cut = change.at; + const leftDur = cut - clip.start; + const rightDur = clip.start + clip.duration - cut; + // Only a cut strictly inside the clip produces two clips. + if (leftDur <= 0 || rightDur <= 0) break; + const left: TimelineClip = { ...clip, id: `${clip.id}${SPLIT_LEFT}`, start: clip.start, duration: leftDur }; + const right: TimelineClip = { ...clip, id: `${clip.id}${SPLIT_RIGHT}`, start: cut, duration: rightDur }; + splits.set(change.id, [left, right]); + mutated = true; + break; + } + case 'remove': + if (byId.has(change.id)) { + removed.add(change.id); + mutated = true; + } + break; + case 'add': + added.push({ item: change.clip, index: change.index }); + mutated = true; + break; + case 'select': + // Selection is Root state; nothing to fold into the clip array. + break; + } + } + + // Only `select` (or fully no-op) changes landed: return the original array. + if (!mutated) return clips; + + const result: Array> = []; + for (const clip of clips) { + if (removed.has(clip.id)) continue; + const split = splits.get(clip.id); + if (split) { + result.push(split[0], split[1]); + continue; + } + result.push(byId.get(clip.id) ?? clip); + } + + for (const { item, index } of added) { + if (index === undefined || index >= result.length) result.push(item); + else result.splice(index, 0, item); + } + + return result; +} diff --git a/vue/primitives/src/canvas/timeline/context.ts b/vue/primitives/src/canvas/timeline/context.ts new file mode 100644 index 0000000..45d6900 --- /dev/null +++ b/vue/primitives/src/canvas/timeline/context.ts @@ -0,0 +1,180 @@ +import type { ComputedRef, Ref, ShallowRef } from 'vue'; +import { useContextFactory } from '@robonen/vue'; +import type { Direction } from '../../utilities/config-provider'; +import type { UseSnappingReturn } from '../../internal/snapping'; +import type { TimelineClip, TimelineMarker, TimelineTrack } from './utils'; + +/** + * Namespaced roving-focus collection keys. Two pools live under one + * `TimelineRoot` — clip blocks and marker pins — so they MUST use distinct keys, + * otherwise the inner provider shadows the outer for every descendant (mirrors + * `FLOW_NODES_COLLECTION` vs `FLOW_EDGES_COLLECTION`). + */ +export const TIMELINE_TRACKS_COLLECTION = 'timeline-tracks'; +export const TIMELINE_CLIPS_COLLECTION = 'timeline-clips'; + +/** A marquee rectangle in viewport-relative pixels, for rendering the overlay. */ +export interface TimelineMarqueeRect { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Root context shared by every timeline part. Reactive fields are `Ref` / + * `ShallowRef` / `ComputedRef` (never raw values — descendants would lose + * reactivity); mutations go through the action functions so behaviour stays in + * one place. `scale` / `invert` are stable closures safe on the pointer hot path. + */ +export interface TimelineContext { + /** Stable id for scoping DOM ids per timeline instance. */ + timelineId: string; + + // ── coordinate model (zoom = pixels-per-second; vertical lanes fixed) ────── + /** Left-edge time (seconds) of the visible window. Two-way via `v-model:offset`. */ + offset: Ref; + /** Zoom in pixels-per-second. Two-way via `v-model:px-per-second`. */ + pxPerSecond: Ref; + /** Total content duration in seconds. */ + duration: ComputedRef; + /** Frame rate (for timecode + frame snapping). */ + fps: Ref; + /** Default track-lane height in pixels (fixed; NOT zoomed). */ + trackHeight: Ref; + /** Live width (px) of the tracks viewport; `scale` range is `[0, width]`. */ + viewportWidth: Ref; + /** Project a time (seconds) to a pixel offset in the viewport. Stable identity. */ + scale: (seconds: number) => number; + /** Invert a pixel offset back to a time (seconds). Stable identity. Returns 0 pre-measure. */ + invert: (px: number) => number; + /** Snap a time to the nearest whole frame at `fps`. */ + snapToFrame: (seconds: number) => number; + /** Format a time (seconds) as `HH:MM:SS:FF` timecode. */ + formatTimecode: (seconds: number) => string; + + // ── capability flags ────────────────────────────────────────────────────── + /** Snapping master enable. */ + snapping: Ref; + /** Master interactivity / disabled switch. */ + disabled: Ref; + /** Resolved reading direction. */ + direction: ComputedRef; + + // ── data access (immutable shallow Maps — replace items, never mutate) ───── + /** Read path for tracks, keyed by id. */ + trackLookup: ShallowRef>; + /** Read path for clips, keyed by id. */ + clipLookup: ShallowRef>; + /** Read path for markers, keyed by id. */ + markerLookup: ShallowRef>; + /** Ordered track ids (declaration order). */ + trackIds: ComputedRef; + /** Ordered clip ids (sorted by start time, for roving focus). */ + orderedClipIds: ComputedRef; + /** Ordered marker ids (sorted by time). */ + orderedMarkerIds: ComputedRef; + /** + * The single roving tab-stop clip id (first selected clip in time order, else + * the first clip). Computed once in the Root so each clip does an O(1) check. + */ + tabStopClipId: ComputedRef; + /** Canonical selected-clip id set (replace wholesale). O(1) membership. */ + selectedClipIds: ShallowRef>; + /** Live current time (playhead) in seconds. Two-way via `v-model:current-time`. */ + currentTime: Ref; + + // ── shared snap engine ──────────────────────────────────────────────────── + /** The shared snap engine; targets = clip edges + playhead + markers + grid. */ + snapEngine: UseSnappingReturn; + /** Snap a candidate time, excluding an optional set of ids (a clip's own edges). */ + snapTime: (seconds: number, exclude?: string | Set) => number; + + // ── mutation state ──────────────────────────────────────────────────────── + /** True while a clip/playhead/trim gesture is mutating (blocks external sync clobber). */ + isMutating: Readonly>; + /** The id of the clip currently being dragged/trimmed (or null). */ + draggingClipId: Readonly>; + + // ── track actions ───────────────────────────────────────────────────────── + /** Shallow-merge a partial into a track in the model + emit a `patch` change. */ + patchTrack: (id: string, patch: Partial) => void; + + // ── clip actions ────────────────────────────────────────────────────────── + /** Insert a clip + emit an `add` change. */ + addClip: (clip: TimelineClip) => void; + /** Patch a clip in the model + emit the matching change(s). */ + updateClip: (id: string, patch: Partial) => void; + /** Remove a clip + emit a `remove` change. */ + removeClip: (id: string) => void; + /** Split a clip at a time + emit a `split` change. */ + splitClip: (id: string, at: number) => void; + /** Move a clip (transient overlay, snapped); commit on pointerup. */ + moveClip: (id: string, start: number, trackId: string, mutating: boolean) => void; + /** Trim a clip start/duration (transient overlay, snapped); commit on pointerup. */ + trimClip: (id: string, start: number, duration: number, mutating: boolean) => void; + /** Commit the in-flight transient mutation into the model (one change batch). */ + commitMutation: () => void; + /** Nudge every selected clip by `deltaSeconds` (keyboard), then commit. */ + nudgeSelected: (deltaSeconds: number) => void; + /** Move every selected clip up/down one track (keyboard), then commit. */ + moveSelectedToAdjacentTrack: (direction: 1 | -1) => void; + /** Remove every selected clip + emit changes. */ + removeSelected: () => void; + + // ── playhead ────────────────────────────────────────────────────────────── + /** Set the playhead time (clamped, optionally snapped). Marks `isMutating` while scrubbing. */ + setCurrentTime: (seconds: number, scrubbing?: boolean) => void; + /** End a scrub gesture (clears `isMutating`, emits the settle). */ + commitScrub: () => void; + + // ── selection ───────────────────────────────────────────────────────────── + /** Replace the selection with one clip (or toggle when `additive`). */ + selectClip: (id: string, additive?: boolean) => void; + /** Replace the entire selected set. */ + setSelection: (ids: string[]) => void; + /** Clear all selection. */ + clearSelection: () => void; + + // ── roving focus registration ───────────────────────────────────────────── + /** Register a clip element for roving focus / marquee hit-testing. */ + registerClipEl: (id: string, el: HTMLElement) => void; + unregisterClipEl: (id: string) => void; + /** Register a marker element for roving focus. */ + registerMarkerEl: (id: string, el: HTMLElement) => void; + unregisterMarkerEl: (id: string) => void; + /** Focus the next/prev clip in time order from `fromId` (roving). */ + focusAdjacentClip: (fromId: string, direction: 1 | -1) => void; + /** Focus a clip element by id (roving Home/End). */ + focusClip: (id: string) => void; + + // ── viewport ────────────────────────────────────────────────────────────── + /** The tracks viewport element (marquee + scrub rect origin). */ + viewportEl: ShallowRef; + /** Live marquee rect (viewport-relative px), or null. */ + marquee: ShallowRef; +} + +const timeline = useContextFactory('TimelineContext'); +export const provideTimelineContext = timeline.provide; +export const useTimelineContext = timeline.inject; + +/** + * Per-track sub-context. Read by `TimelineTrackHeader` and clips so they never + * DOM-walk to find their lane's state. + */ +export interface TimelineTrackContext { + trackId: string; + /** The track record (reactive). */ + track: ComputedRef; + /** Resolved lane height (px). */ + height: ComputedRef; + /** Toggle a boolean track flag (mute/lock/solo) + emit a track change. */ + toggleFlag: (flag: 'muted' | 'locked' | 'soloed') => void; + /** Patch the track (e.g. height resize) + emit a track change. */ + patchTrack: (patch: Partial) => void; +} + +const timelineTrack = useContextFactory('TimelineTrackContext'); +export const provideTimelineTrackContext = timelineTrack.provide; +export const useTimelineTrackContext = timelineTrack.inject; diff --git a/vue/primitives/src/canvas/timeline/demo.vue b/vue/primitives/src/canvas/timeline/demo.vue new file mode 100644 index 0000000..0fe1777 --- /dev/null +++ b/vue/primitives/src/canvas/timeline/demo.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/vue/primitives/src/canvas/timeline/index.ts b/vue/primitives/src/canvas/timeline/index.ts new file mode 100644 index 0000000..214fa9d --- /dev/null +++ b/vue/primitives/src/canvas/timeline/index.ts @@ -0,0 +1,66 @@ +export { default as TimelineRoot } from './TimelineRoot.vue'; +export type { TimelineRootProps, TimelineRootEmits } from './TimelineRoot.vue'; + +export { default as TimelineRuler } from './TimelineRuler.vue'; +export type { TimelineRulerProps } from './TimelineRuler.vue'; + +export { default as TimelineTracks } from './TimelineTracks.vue'; +export type { TimelineTracksProps } from './TimelineTracks.vue'; + +export { default as TimelineTrack } from './TimelineTrack.vue'; +export type { TimelineTrackProps } from './TimelineTrack.vue'; + +export { default as TimelineTrackHeader } from './TimelineTrackHeader.vue'; +export type { TimelineTrackHeaderProps } from './TimelineTrackHeader.vue'; + +export { default as TimelineClip } from './TimelineClip.vue'; +export type { TimelineClipProps } from './TimelineClip.vue'; + +export { default as TimelineClipHandle } from './TimelineClipHandle.vue'; +export type { TimelineClipHandleProps } from './TimelineClipHandle.vue'; + +export { default as TimelinePlayhead } from './TimelinePlayhead.vue'; +export type { TimelinePlayheadProps } from './TimelinePlayhead.vue'; + +export { default as TimelineMarker } from './TimelineMarker.vue'; +export type { TimelineMarkerProps } from './TimelineMarker.vue'; + +export { default as TimelineSelection } from './TimelineSelection.vue'; +export type { TimelineSelectionProps } from './TimelineSelection.vue'; + +export { + TIMELINE_CLIPS_COLLECTION, + TIMELINE_TRACKS_COLLECTION, + provideTimelineContext, + provideTimelineTrackContext, + useTimelineContext, + useTimelineTrackContext, +} from './context'; +export type { + TimelineContext, + TimelineMarqueeRect, + TimelineTrackContext, +} from './context'; + +export { + applyClipChanges, + applyTrackChanges, +} from './changes'; +export type { + TimelineClipChange, + TimelineTrackChange, +} from './changes'; + +export { + clipIntersectsTime, + clipsDuration, + snapToFrame, + timeToTimecode, +} from './utils'; +// Data interfaces are aliased with a `Data` suffix so they do not collide with +// the same-named part components (`TimelineClip` / `TimelineMarker` / `Track`). +export type { + TimelineClip as TimelineClipData, + TimelineMarker as TimelineMarkerData, + TimelineTrack as TimelineTrackData, +} from './utils'; diff --git a/vue/primitives/src/canvas/timeline/utils.ts b/vue/primitives/src/canvas/timeline/utils.ts new file mode 100644 index 0000000..1cf0dbe --- /dev/null +++ b/vue/primitives/src/canvas/timeline/utils.ts @@ -0,0 +1,104 @@ +import { framesToSeconds, framesToTimecode, secondsToFrames } from '../../internal/scale'; + +/** + * A track lane in the timeline. Tracks stack vertically; their height is fixed + * (NOT affected by horizontal zoom) and read from `height` (falling back to the + * Root's `trackHeight`). + */ +export interface TimelineTrack { + /** Stable identity. */ + id: string; + /** Human label rendered in the gutter. */ + label?: string; + /** Lane height in pixels (overrides the Root default). */ + height?: number; + /** Muted state (audio off). */ + muted?: boolean; + /** Locked state (clips not editable). */ + locked?: boolean; + /** Soloed state. */ + soloed?: boolean; + /** Hidden lane (not rendered). */ + hidden?: boolean; + /** Free-form kind tag (`'video'` / `'audio'` / …). */ + kind?: string; +} + +/** + * A clip on a track. `start` / `duration` are in SECONDS (the timeline's domain + * unit); pixel geometry is derived via the Root's scale (`pxPerSecond`). + */ +export interface TimelineClip { + /** Stable identity. */ + id: string; + /** Owning track id. */ + trackId: string; + /** Start time in seconds. */ + start: number; + /** Duration in seconds (always `> 0`). */ + duration: number; + /** Human label. */ + label?: string; + /** Display color token. */ + color?: string; + /** Locked clip (not draggable / trimmable). */ + locked?: boolean; + /** Consumer payload. */ + data?: Data; +} + +/** + * A marker pinned to a time on the timeline (chapter / cue point). + */ +export interface TimelineMarker { + /** Stable identity. */ + id: string; + /** Time in seconds. */ + time: number; + /** Human label. */ + label?: string; + /** Display color token. */ + color?: string; +} + +/** + * Convert a time in seconds to its SMPTE timecode string at `fps`. Thin wrapper + * routed through the shared `scale` timecode helpers so the timeline and ruler + * format identically. + */ +export function timeToTimecode(seconds: number, fps: number, dropFrame = false): string { + return framesToTimecode(secondsToFrames(seconds, fps), fps, dropFrame); +} + +/** + * Snap a time (seconds) to the nearest whole frame at `fps`. Used as the keyboard + * nudge granularity and the default snap grid. + */ +export function snapToFrame(seconds: number, fps: number): number { + if (fps <= 0) return seconds; + return framesToSeconds(secondsToFrames(seconds, fps), fps); +} + +/** + * Auto-derive the content duration from a set of clips: the largest + * `start + duration`. Returns `0` for an empty set. + */ +export function clipsDuration(clips: Iterable): number { + let max = 0; + for (const c of clips) { + const end = c.start + c.duration; + if (end > max) max = end; + } + return max; +} + +/** + * Test whether a clip's time span `[start, start+duration]` intersects a + * time window `[from, to]` (half-open-tolerant; touching edges count as an + * intersection so a zero-width marquee on an edge still selects). + */ +export function clipIntersectsTime(clip: TimelineClip, from: number, to: number): boolean { + const lo = Math.min(from, to); + const hi = Math.max(from, to); + return clip.start <= hi && clip.start + clip.duration >= lo; +} diff --git a/vue/primitives/src/canvas/transform-box/TransformBoxHandle.vue b/vue/primitives/src/canvas/transform-box/TransformBoxHandle.vue new file mode 100644 index 0000000..ac8efcc --- /dev/null +++ b/vue/primitives/src/canvas/transform-box/TransformBoxHandle.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/vue/primitives/src/canvas/transform-box/TransformBoxRoot.vue b/vue/primitives/src/canvas/transform-box/TransformBoxRoot.vue new file mode 100644 index 0000000..8746d8c --- /dev/null +++ b/vue/primitives/src/canvas/transform-box/TransformBoxRoot.vue @@ -0,0 +1,458 @@ + + + + + diff --git a/vue/primitives/src/canvas/transform-box/TransformBoxRotateHandle.vue b/vue/primitives/src/canvas/transform-box/TransformBoxRotateHandle.vue new file mode 100644 index 0000000..87ad980 --- /dev/null +++ b/vue/primitives/src/canvas/transform-box/TransformBoxRotateHandle.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/vue/primitives/src/canvas/transform-box/TransformBoxStatus.vue b/vue/primitives/src/canvas/transform-box/TransformBoxStatus.vue new file mode 100644 index 0000000..d14f967 --- /dev/null +++ b/vue/primitives/src/canvas/transform-box/TransformBoxStatus.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/vue/primitives/src/canvas/transform-box/__test__/TransformBox.test.ts b/vue/primitives/src/canvas/transform-box/__test__/TransformBox.test.ts new file mode 100644 index 0000000..81b38d2 --- /dev/null +++ b/vue/primitives/src/canvas/transform-box/__test__/TransformBox.test.ts @@ -0,0 +1,479 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import { + TransformBoxHandle, + TransformBoxRoot, + TransformBoxRotateHandle, + TransformBoxStatus, +} from '../index'; +import type { TransformBoxHandlePosition, TransformBoxValue } from '../utils'; +import { + applyAspectRatio, + constrainRect, + decomposeTransform, + handleAxes, + pointerAngle, + resizeEdge, + rotatePoint, + shortestAngleDelta, + snapRotation, +} from '../utils'; + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +/** One animation-frame tick so the rAF-batched drag flush has run. */ +function raf(): Promise { + return new Promise(resolve => requestAnimationFrame(() => resolve())); +} + +function down(el: Element, clientX: number, clientY: number, opts: { shiftKey?: boolean; altKey?: boolean } = {}): void { + el.dispatchEvent(new PointerEvent('pointerdown', { + pointerId: 1, button: 0, clientX, clientY, bubbles: true, cancelable: true, + shiftKey: opts.shiftKey ?? false, altKey: opts.altKey ?? false, + })); +} +function move(el: Element, clientX: number, clientY: number, opts: { shiftKey?: boolean; altKey?: boolean } = {}): void { + el.dispatchEvent(new PointerEvent('pointermove', { + pointerId: 1, clientX, clientY, bubbles: true, + shiftKey: opts.shiftKey ?? false, altKey: opts.altKey ?? false, + })); +} +function up(el: Element, clientX: number, clientY: number): void { + el.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX, clientY, bubbles: true })); +} + +function keydown(el: Element, key: string, opts: { shiftKey?: boolean; altKey?: boolean } = {}): void { + el.dispatchEvent(new KeyboardEvent('keydown', { + key, bubbles: true, cancelable: true, + shiftKey: opts.shiftKey ?? false, altKey: opts.altKey ?? false, + })); +} + +interface MountOpts { + defaultValue?: TransformBoxValue; + aspectRatio?: number | null; + allowFlip?: boolean; + rotationSnap?: number; + rotationStep?: number; + keyboardStep?: number; + minWidth?: number; + minHeight?: number; + disabled?: boolean; + withRotate?: boolean; + withStatus?: boolean; +} + +const POSITIONS: TransformBoxHandlePosition[] = [ + 'top', 'right', 'bottom', 'left', 'top-left', 'top-right', 'bottom-right', 'bottom-left', +]; + +function mountBox(opts: MountOpts = {}) { + const model = ref(undefined); + const commits: TransformBoxValue[] = []; + const Harness = defineComponent({ + setup() { + return () => h(TransformBoxRoot, { + modelValue: model.value, + 'onUpdate:modelValue': (v: TransformBoxValue | null | undefined) => { model.value = v ?? undefined; }, + onTransformCommit: (v: TransformBoxValue) => { commits.push(v); }, + defaultValue: opts.defaultValue ?? { x: 0, y: 0, width: 100, height: 100, rotation: 0 }, + aspectRatio: opts.aspectRatio ?? null, + allowFlip: opts.allowFlip ?? true, + rotationSnap: opts.rotationSnap ?? 0, + rotationStep: opts.rotationStep ?? 1, + keyboardStep: opts.keyboardStep ?? 1, + minWidth: opts.minWidth ?? 1, + minHeight: opts.minHeight ?? 1, + disabled: opts.disabled ?? false, + }, { + default: () => [ + ...POSITIONS.map(p => h(TransformBoxHandle, { key: p, position: p })), + opts.withRotate ? h(TransformBoxRotateHandle) : null, + opts.withStatus ? h(TransformBoxStatus) : null, + ], + }); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + return { wrapper: w, model, commits }; +} + +function rootEl(): HTMLElement { + return document.querySelector('[data-selected], [data-disabled], [tabindex="0"]')!; +} +function handleEl(position: TransformBoxHandlePosition): HTMLElement { + return document.querySelector(`[data-position="${position}"]`)!; +} +function rotateEl(): HTMLElement { + return document.querySelector('[data-transform-box-rotate]')!; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pure math (utils.ts) — Crop shares these. +// ───────────────────────────────────────────────────────────────────────────── +describe('utils — handleAxes', () => { + it('decodes edges and corners with outward signs', () => { + expect(handleAxes('left')).toEqual({ x: -1, y: 0 }); + expect(handleAxes('right')).toEqual({ x: 1, y: 0 }); + expect(handleAxes('top')).toEqual({ x: 0, y: -1 }); + expect(handleAxes('bottom')).toEqual({ x: 0, y: 1 }); + expect(handleAxes('top-left')).toEqual({ x: -1, y: -1 }); + expect(handleAxes('bottom-right')).toEqual({ x: 1, y: 1 }); + }); +}); + +describe('utils — constrainRect', () => { + it('folds a negative width/height into the origin (positive size)', () => { + const r = constrainRect({ x: 0, y: 0, width: -40, height: -20, rotation: 0 }, 1, 1); + expect(r).toEqual({ x: -40, y: -20, width: 40, height: 20, rotation: 0 }); + }); + it('clamps below the minimum size', () => { + const r = constrainRect({ x: 5, y: 5, width: 0.5, height: 0.2, rotation: 0 }, 1, 1); + expect(r.width).toBe(1); + expect(r.height).toBe(1); + }); +}); + +describe('utils — applyAspectRatio', () => { + it('derives height from width on a horizontal edge', () => { + const { width, height } = applyAspectRatio(200, 50, 2, { x: 1, y: 0 }); + expect(width).toBe(200); + expect(height).toBe(100); // 200 / 2 + }); + it('keeps the dominant axis on a corner', () => { + // width (300) implies a bigger box than height (10) at ratio 2 → keep width. + const { width, height } = applyAspectRatio(300, 10, 2, { x: 1, y: 1 }); + expect(width).toBe(300); + expect(height).toBeCloseTo(150); + }); +}); + +describe('utils — resizeEdge (unrotated)', () => { + it('right handle grows width with the LEFT edge fixed', () => { + const start: TransformBoxValue = { x: 0, y: 0, width: 100, height: 100, rotation: 0 }; + const { box } = resizeEdge(start, 'right', { x: 20, y: 0 }); + expect(box.x).toBe(0); // left edge fixed + expect(box.width).toBe(120); + expect(box.height).toBe(100); + }); + it('left handle grows width with the RIGHT edge fixed', () => { + const start: TransformBoxValue = { x: 0, y: 0, width: 100, height: 100, rotation: 0 }; + const { box } = resizeEdge(start, 'left', { x: -20, y: 0 }); + expect(box.x).toBe(-20); // grew left + expect(box.width).toBe(120); + // right edge stayed at 100. + expect(box.x + box.width).toBe(100); + }); + it('symmetric (Alt) resize grows both sides about the center', () => { + const start: TransformBoxValue = { x: 0, y: 0, width: 100, height: 100, rotation: 0 }; + const { box } = resizeEdge(start, 'right', { x: 10, y: 0 }, { symmetric: true }); + // both sides move by 10 → width +20, x shifts -10 to keep center fixed. + expect(box.width).toBe(120); + expect(box.x).toBe(-10); + expect(box.x + box.width / 2).toBe(50); // center unchanged + }); + it('aspect lock keeps the ratio on a corner', () => { + const start: TransformBoxValue = { x: 0, y: 0, width: 100, height: 100, rotation: 0 }; + const { box } = resizeEdge(start, 'bottom-right', { x: 40, y: 0 }, { aspectRatio: 1 }); + expect(box.width).toBeCloseTo(box.height); + }); +}); + +describe('utils — resizeEdge (rotated, local-axis delta)', () => { + it('a 90° box: a screen-x drag rotated into local axes resizes the right edge', () => { + // Box rotated 90°. A pure local-x delta resizes width regardless of rotation, + // because resizeEdge receives the delta ALREADY in local axes. + const start: TransformBoxValue = { x: 0, y: 0, width: 100, height: 100, rotation: 90 }; + const { box } = resizeEdge(start, 'right', { x: 30, y: 0 }); + expect(box.width).toBeCloseTo(130); + expect(box.height).toBeCloseTo(100); + expect(box.rotation).toBe(90); + }); + it('preserves the world anchor of the opposite edge under rotation', () => { + const start: TransformBoxValue = { x: 0, y: 0, width: 100, height: 100, rotation: 45 }; + // World position of the anchored LEFT-edge midpoint before resize. + const center0 = { x: start.x + start.width / 2, y: start.y + start.height / 2 }; + const anchorBefore = rotatePoint({ x: start.x, y: start.y + start.height / 2 }, 45, center0); + + const { box } = resizeEdge(start, 'right', { x: 40, y: 0 }); + const center1 = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + const anchorAfter = rotatePoint({ x: box.x, y: box.y + box.height / 2 }, 45, center1); + + expect(anchorAfter.x).toBeCloseTo(anchorBefore.x, 4); + expect(anchorAfter.y).toBeCloseTo(anchorBefore.y, 4); + expect(box.width).toBeCloseTo(140); + }); +}); + +describe('utils — flip policy', () => { + it('allowFlip=false clamps the dragged edge at the minimum size', () => { + const start: TransformBoxValue = { x: 0, y: 0, width: 100, height: 100, rotation: 0 }; + // Drag the right edge far past the left edge (would flip). + const { box, flippedX } = resizeEdge(start, 'right', { x: -200, y: 0 }, { + allowFlip: false, minWidth: 5, + }); + expect(box.width).toBe(5); + expect(flippedX).toBe(false); + expect(box.x).toBe(0); // left edge still anchored + }); + it('allowFlip=true reports a flip and normalizes to a positive box', () => { + const start: TransformBoxValue = { x: 0, y: 0, width: 100, height: 100, rotation: 0 }; + const { box, flippedX } = resizeEdge(start, 'right', { x: -150, y: 0 }, { + allowFlip: true, minWidth: 5, + }); + expect(flippedX).toBe(true); + expect(box.width).toBeGreaterThan(0); // normalized + }); +}); + +describe('utils — rotation helpers', () => { + it('pointerAngle: up=0, right=90, down=180, left=270', () => { + const c = { x: 0, y: 0 }; + expect(pointerAngle({ x: 0, y: -10 }, c)).toBeCloseTo(0); + expect(pointerAngle({ x: 10, y: 0 }, c)).toBeCloseTo(90); + expect(pointerAngle({ x: 0, y: 10 }, c)).toBeCloseTo(180); + expect(pointerAngle({ x: -10, y: 0 }, c)).toBeCloseTo(270); + }); + it('shortestAngleDelta crosses the seam smoothly', () => { + expect(shortestAngleDelta(350, 10)).toBeCloseTo(20); + expect(shortestAngleDelta(10, 350)).toBeCloseTo(-20); + }); + it('snapRotation snaps to increments', () => { + expect(snapRotation(43, 15)).toBe(45); + expect(snapRotation(43, 0)).toBe(43); // disabled + }); +}); + +describe('utils — decomposeTransform', () => { + it('returns the normalized rect, center, and unrotated corners at rotation 0', () => { + const d = decomposeTransform({ x: 10, y: 20, width: 40, height: 60, rotation: 0 }); + expect(d.rect).toEqual({ x: 10, y: 20, width: 40, height: 60 }); + expect(d.center).toEqual({ x: 30, y: 50 }); + expect(d.rotation).toBe(0); + expect(d.corners[0]).toEqual({ x: 10, y: 20 }); // top-left + expect(d.corners[2]).toEqual({ x: 50, y: 80 }); // bottom-right + }); + it('rotates the corners about the center under rotation', () => { + const d = decomposeTransform({ x: 0, y: 0, width: 100, height: 100, rotation: 90 }); + // 90° about center (50,50): top-left (0,0) → (100,0). + expect(d.corners[0].x).toBeCloseTo(100, 4); + expect(d.corners[0].y).toBeCloseTo(0, 4); + expect(d.rotation).toBe(90); + }); + it('normalizes a negative-size box', () => { + const d = decomposeTransform({ x: 50, y: 50, width: -30, height: -20, rotation: 0 }); + expect(d.rect).toEqual({ x: 20, y: 30, width: 30, height: 20 }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Components +// ───────────────────────────────────────────────────────────────────────────── +describe('TransformBoxHandle — rendering & ARIA', () => { + it('renders 8 handles as buttons with derived aria-labels', async () => { + mountBox(); + await nextTick(); + const buttons = Array.from(document.querySelectorAll('[data-position]')); + expect(buttons).toHaveLength(8); + for (const btn of buttons) { + expect(btn.tagName).toBe('BUTTON'); + expect(btn.getAttribute('type')).toBe('button'); + const pos = btn.getAttribute('data-position'); + expect(btn.getAttribute('aria-label')).toBe(`Resize ${pos}`); + expect(btn.tabIndex).toBe(0); + } + }); + + it('renders the rotate handle as a button labelled "Rotate"', async () => { + mountBox({ withRotate: true }); + await nextTick(); + const rot = rotateEl(); + expect(rot.tagName).toBe('BUTTON'); + expect(rot.getAttribute('aria-label')).toBe('Rotate'); + }); +}); + +describe('TransformBoxRoot — keyboard move', () => { + it('ArrowRight on the body moves x by keyboardStep', async () => { + const { commits } = mountBox({ keyboardStep: 5 }); + await nextTick(); + const root = rootEl(); + root.focus(); + keydown(root, 'ArrowRight'); + await nextTick(); + expect(commits.at(-1)!.x).toBe(5); + }); + + it('Shift+ArrowRight uses the large step', async () => { + const { commits } = mountBox({ keyboardStep: 1 }); + await nextTick(); + const root = rootEl(); + keydown(root, 'ArrowRight', { shiftKey: true }); + await nextTick(); + expect(commits.at(-1)!.x).toBe(10); // default keyboardLargeStep + }); +}); + +describe('TransformBoxHandle — keyboard resize', () => { + it('ArrowRight on the RIGHT handle increases width with the left edge fixed', async () => { + const { commits } = mountBox({ keyboardStep: 4 }); + await nextTick(); + const handle = handleEl('right'); + handle.focus(); + keydown(handle, 'ArrowRight'); + await nextTick(); + const v = commits.at(-1)!; + expect(v.width).toBe(104); + expect(v.x).toBe(0); // left edge fixed + }); + + it('Shift+Arrow aspect-locks the resize ratio', async () => { + const { commits } = mountBox({ keyboardStep: 10, defaultValue: { x: 0, y: 0, width: 100, height: 100, rotation: 0 } }); + await nextTick(); + const handle = handleEl('bottom-right'); + handle.focus(); + keydown(handle, 'ArrowRight', { shiftKey: true }); + await nextTick(); + const v = commits.at(-1)!; + // ratio 1 preserved. + expect(v.width).toBeCloseTo(v.height); + }); +}); + +describe('TransformBoxHandle — pointer resize', () => { + it('dragging the right handle increases width', async () => { + const { commits } = mountBox(); + await nextTick(); + const handle = handleEl('right'); + down(handle, 100, 50); + await raf(); + move(handle, 130, 50); + await raf(); + up(handle, 130, 50); + await raf(); + const v = commits.at(-1)!; + expect(v.width).toBeGreaterThan(100); + expect(v.x).toBe(0); // left edge fixed + }); +}); + +describe('TransformBoxRotateHandle — rotation', () => { + it('ArrowRight on the rotate handle changes rotation by rotationStep', async () => { + const { commits } = mountBox({ withRotate: true, rotationStep: 3 }); + await nextTick(); + const rot = rotateEl(); + rot.focus(); + keydown(rot, 'ArrowRight'); + await nextTick(); + expect(commits.at(-1)!.rotation).toBe(3); + keydown(rot, 'ArrowLeft'); + await nextTick(); + expect(commits.at(-1)!.rotation).toBe(0); + }); + + it('Shift+Arrow on the rotate handle rotates by rotationSnap', async () => { + const { commits } = mountBox({ withRotate: true, rotationSnap: 15, rotationStep: 1 }); + await nextTick(); + const rot = rotateEl(); + keydown(rot, 'ArrowRight', { shiftKey: true }); + await nextTick(); + expect(commits.at(-1)!.rotation).toBe(15); + }); +}); + +describe('TransformBoxRoot — allowFlip clamping (pointer)', () => { + it('allowFlip=false clamps at min on a keyboard over-drag', async () => { + const { commits } = mountBox({ + allowFlip: false, + minWidth: 5, + keyboardStep: 200, + defaultValue: { x: 0, y: 0, width: 100, height: 100, rotation: 0 }, + }); + await nextTick(); + const handle = handleEl('right'); + // Shrink the right edge past the left edge → would flip; clamps to min. + keydown(handle, 'ArrowLeft'); + await nextTick(); + const v = commits.at(-1)!; + expect(v.width).toBe(5); + expect(v.x).toBe(0); + }); +}); + +describe('TransformBoxStatus', () => { + it('renders an aria-live region announcing the settled transform', async () => { + mountBox({ withStatus: true, defaultValue: { x: 12, y: 0, width: 40, height: 20, rotation: 0 } }); + await nextTick(); + const status = document.querySelector('[aria-live="polite"]')!; + expect(status).toBeTruthy(); + expect(status.getAttribute('role')).toBe('status'); + expect(status.textContent).toContain('width 40'); + expect(status.textContent).toContain('height 20'); + }); +}); + +describe('TransformBoxRoot — disabled', () => { + it('blocks keyboard move when disabled', async () => { + const { commits } = mountBox({ disabled: true, keyboardStep: 5 }); + await nextTick(); + const root = rootEl(); + keydown(root, 'ArrowRight'); + await nextTick(); + expect(commits).toHaveLength(0); + }); + + it('blocks handle resize when disabled', async () => { + const { commits } = mountBox({ disabled: true }); + await nextTick(); + const handle = handleEl('right'); + keydown(handle, 'ArrowRight'); + down(handle, 100, 50); + await raf(); + move(handle, 130, 50); + await raf(); + up(handle, 130, 50); + await raf(); + expect(commits).toHaveLength(0); + }); + + it('sets tabindex -1 on handles when disabled', async () => { + mountBox({ disabled: true }); + await nextTick(); + const handle = handleEl('right'); + expect(handle.tabIndex).toBe(-1); + expect(handle.getAttribute('data-disabled')).toBe(''); + }); +}); + +describe('TransformBoxRoot — selection state', () => { + it('exposes data-selected by default and toggles via the model', async () => { + const selected = ref(true); + const Harness = defineComponent({ + setup() { + return () => h(TransformBoxRoot, { + selected: selected.value, + 'onUpdate:selected': (v: boolean) => { selected.value = v; }, + }); + }, + }); + track(mount(Harness, { attachTo: document.body })); + await nextTick(); + const root = document.querySelector('[data-selected]'); + expect(root).toBeTruthy(); + selected.value = false; + await nextTick(); + expect(document.querySelector('[data-selected]')).toBeNull(); + }); +}); diff --git a/vue/primitives/src/canvas/transform-box/__test__/__screenshots__/TransformBox.test.ts/utils---flip-policy-allowFlip-false-clamps-the-dragged-edge-at-the-minimum-size-1.png b/vue/primitives/src/canvas/transform-box/__test__/__screenshots__/TransformBox.test.ts/utils---flip-policy-allowFlip-false-clamps-the-dragged-edge-at-the-minimum-size-1.png new file mode 100644 index 0000000..47767d2 Binary files /dev/null and b/vue/primitives/src/canvas/transform-box/__test__/__screenshots__/TransformBox.test.ts/utils---flip-policy-allowFlip-false-clamps-the-dragged-edge-at-the-minimum-size-1.png differ diff --git a/vue/primitives/src/canvas/transform-box/context.ts b/vue/primitives/src/canvas/transform-box/context.ts new file mode 100644 index 0000000..2bfd08d --- /dev/null +++ b/vue/primitives/src/canvas/transform-box/context.ts @@ -0,0 +1,101 @@ +import type { Ref } from 'vue'; +import { useContextFactory } from '@robonen/vue'; +import type { UseSnappingReturn } from '../../internal/snapping'; +import type { + Point, + TransformBoxHandlePosition, + TransformBoxPivot, + TransformBoxValue, +} from './utils'; + +export type TransformBoxDirection = 'ltr' | 'rtl'; + +/** Modifier flags read off the live pointer event for a gesture frame. */ +export interface TransformBoxModifiers { + /** Aspect-lock (corner) request. */ + shift: boolean; + /** Symmetric-about-pivot request. */ + alt: boolean; +} + +/** + * Context shared between `TransformBoxRoot` and its handle parts. + * + * Scalar props are exposed as plain `Ref` and built by the root with + * `toRef(() => prop)` — a `GetterRefImpl` that is reactive without allocating a + * `ReactiveEffect`/cache (the slider/angle-dial identity-passthrough convention). + * + * The handle parts own their `usePointerDrag` instance (bound to their own + * button element, so capture/state never tangles between handles) and call the + * root's gesture callbacks per frame: `beginScale`/`updateScale`/`endScale` + * (and the rotate equivalents). The root owns ALL transform math; the parts + * stay dumb. The body's MOVE gesture is owned wholly by the root. + */ +export interface TransformBoxContext { + /** Current transform (normalized box + rotation). */ + value: Ref; + /** Commit a raw transform; the root normalizes (min-size, flip) and emits. */ + setValue: (next: TransformBoxValue) => void; + + // ── scale gesture (driven by TransformBoxHandle) ─────────────────────────── + /** Snapshot the start box and mark the gesture active. */ + beginScale: (handle: TransformBoxHandlePosition) => void; + /** + * Apply a cumulative SCREEN-space pointer delta for the active scale gesture. + * The root rotates it into the box's local axes and resizes the edge(s). + */ + updateScale: (handle: TransformBoxHandlePosition, screenDelta: Point, mods: TransformBoxModifiers) => void; + /** End the scale gesture; `commit` true on pointerup (false on cancel). */ + endScale: (commit: boolean) => void; + + // ── rotate gesture (driven by TransformBoxRotateHandle) ──────────────────── + /** Snapshot the start rotation and pivot (in client space) from `pointer`. */ + beginRotate: (pointer: Point, handleEl: HTMLElement) => void; + /** Apply the current pointer position; root computes the angle about the pivot. */ + updateRotate: (pointer: Point, mods: TransformBoxModifiers) => void; + /** End the rotate gesture; `commit` true on pointerup. */ + endRotate: (commit: boolean) => void; + + // ── keyboard nudges ──────────────────────────────────────────────────────── + /** Resize the handle's edge(s) by a LOCAL-axis step (Arrow on a handle). */ + nudgeScale: (handle: TransformBoxHandlePosition, dx: number, dy: number, mods: TransformBoxModifiers) => void; + /** Move the whole box by a world-space delta (Arrow on the body). */ + nudgeMove: (dx: number, dy: number) => void; + /** Rotate by a signed degree delta (Arrow on the rotate handle). */ + nudgeRotate: (delta: number) => void; + + /** The root's snap engine. */ + snapping: UseSnappingReturn; + /** Whether the box is currently selected/active. */ + selected: Ref; + /** Mark the box selected (click-to-activate). */ + setSelected: (value: boolean) => void; + /** Whether all interaction is disabled. */ + disabled: Ref; + /** Whether a move/scale/rotate gesture is in flight. */ + transforming: Ref; + /** Resolved reading direction (affects horizontal keyboard nudge). */ + direction: Ref; + /** The pivot rotation/symmetric-resize anchor to. */ + pivot: Ref; + /** Keyboard step (Arrow). */ + keyboardStep: Ref; + /** Keyboard large step (Shift+Arrow for move). */ + keyboardLargeStep: Ref; + /** Rotation keyboard step (degrees). */ + rotationStep: Ref; + /** Rotation snap increment while Shift is held (degrees, 0 disables). */ + rotationSnap: Ref; + /** Live flip flag for `data-flipped-x` on the root. */ + flippedX: Ref; + /** Live flip flag for `data-flipped-y` on the root. */ + flippedY: Ref; +} + +/** Re-export so handle parts can name these without reaching into `utils`. */ +export type { Point, TransformBoxHandlePosition, TransformBoxPivot, TransformBoxValue }; + +const ctx = useContextFactory('TransformBoxContext'); + +export const provideTransformBoxContext = ctx.provide; +export const useTransformBoxContext = ctx.inject; diff --git a/vue/primitives/src/canvas/transform-box/demo.vue b/vue/primitives/src/canvas/transform-box/demo.vue new file mode 100644 index 0000000..08f82ae --- /dev/null +++ b/vue/primitives/src/canvas/transform-box/demo.vue @@ -0,0 +1,116 @@ + + + diff --git a/vue/primitives/src/canvas/transform-box/index.ts b/vue/primitives/src/canvas/transform-box/index.ts new file mode 100644 index 0000000..0dd470b --- /dev/null +++ b/vue/primitives/src/canvas/transform-box/index.ts @@ -0,0 +1,50 @@ +export { default as TransformBoxRoot } from './TransformBoxRoot.vue'; +export type { TransformBoxRootEmits, TransformBoxRootProps } from './TransformBoxRoot.vue'; + +export { default as TransformBoxHandle } from './TransformBoxHandle.vue'; +export type { TransformBoxHandleProps } from './TransformBoxHandle.vue'; + +export { default as TransformBoxRotateHandle } from './TransformBoxRotateHandle.vue'; +export type { TransformBoxRotateHandleProps } from './TransformBoxRotateHandle.vue'; + +export { default as TransformBoxStatus } from './TransformBoxStatus.vue'; +export type { TransformBoxStatusProps } from './TransformBoxStatus.vue'; + +export { + provideTransformBoxContext, + useTransformBoxContext, +} from './context'; +export type { + TransformBoxContext, + TransformBoxDirection, + TransformBoxModifiers, +} from './context'; + +export { + applyAspectRatio, + boxCenter, + constrainRect, + decomposeTransform, + handleAxes, + handleLabel, + localToWorld, + moveBox, + normalizeRotation, + pointerAngle, + resizeEdge, + resolvePivot, + rotatePoint, + rotateVector, + rotationFromPointer, + shortestAngleDelta, + snapRotation, + worldToLocal, +} from './utils'; +export type { + Point, + ResizeEdgeOptions, + ResizeResult, + TransformBoxHandlePosition, + TransformBoxPivot, + TransformBoxValue, +} from './utils'; diff --git a/vue/primitives/src/canvas/transform-box/utils.ts b/vue/primitives/src/canvas/transform-box/utils.ts new file mode 100644 index 0000000..544c389 --- /dev/null +++ b/vue/primitives/src/canvas/transform-box/utils.ts @@ -0,0 +1,496 @@ +import type { Point } from '../../internal/utils/geometry'; + +// Reuse the package-canonical 2D point (internal `utils/geometry`, not in the +// root barrel) so transform-box's `Point` is the SAME symbol as +// spline/pointer-drag/snapping/angle-dial re-export — keeps the root barrel free +// of a TS2308 `Point` clash. +/** A 2D point. Client (screen) pixels unless the call site says otherwise. */ +export type { Point }; + +const TAU = Math.PI * 2; +const DEG_TO_RAD = TAU / 360; + +/** + * The full transform a {@link TransformBoxRoot} owns: an axis-aligned box + * `{x, y, width, height}` in the box's UNROTATED local frame plus a `rotation` + * (degrees, clockwise-positive to match screen-y-down). The box is drawn by + * translating to `(x, y)`, then rotating by `rotation` about the pivot. + * + * Width/height may be negative mid-gesture when a corner is dragged past its + * anchor (a flip); {@link normalizeTransform} folds a negative size back into a + * positive one by shifting the origin, which is what the root commits. + */ +export interface TransformBoxValue { + /** Left edge in the unrotated local frame. */ + x: number; + /** Top edge in the unrotated local frame. */ + y: number; + /** Box width (may be transiently negative during a flip). */ + width: number; + /** Box height (may be transiently negative during a flip). */ + height: number; + /** Rotation in degrees, clockwise-positive (screen y grows downward). */ + rotation: number; +} + +/** The 8 resize handle positions: 4 edges + 4 corners. */ +export type TransformBoxHandlePosition + = | 'top' + | 'right' + | 'bottom' + | 'left' + | 'top-left' + | 'top-right' + | 'bottom-right' + | 'bottom-left'; + +/** + * Pivot the box rotates about and that symmetric (Alt) resize anchors to. + * `'center'` is the box center; an explicit {@link Point} is a fractional + * position in `[0, 1]²` of the box (e.g. `{ x: 0, y: 0 }` is the top-left + * corner, `{ x: 0.5, y: 0.5 }` equals `'center'`). + */ +export type TransformBoxPivot = 'center' | Point; + +/** Whether a handle controls the left/right (x) and top/bottom (y) edges. */ +interface HandleAxes { + /** `-1` left edge, `1` right edge, `0` neither (no horizontal resize). */ + x: -1 | 0 | 1; + /** `-1` top edge, `1` bottom edge, `0` neither (no vertical resize). */ + y: -1 | 0 | 1; +} + +/** + * Decode a handle position into which edges it drives. Corners drive both axes; + * edge handles drive one. The sign points OUTWARD from the box center toward the + * handle (so `right` is `+1`, `left` is `-1`). + */ +export function handleAxes(position: TransformBoxHandlePosition): HandleAxes { + let x: -1 | 0 | 1 = 0; + let y: -1 | 0 | 1 = 0; + if (position.includes('left')) x = -1; + else if (position.includes('right')) x = 1; + if (position.includes('top')) y = -1; + else if (position.includes('bottom')) y = 1; + return { x, y }; +} + +/** Default human-readable `aria-label` for a scale handle (e.g. "Resize top-left"). */ +export function handleLabel(position: TransformBoxHandlePosition): string { + return `Resize ${position}`; +} + +/** + * Rotate `point` by `angleDeg` (clockwise-positive, screen y down) about + * `origin`. Pure; the workhorse for moving between world and the box's local + * frame. Rotating by `-rotation` brings a world vector into local axes; + * rotating by `+rotation` sends a local point back to world. + */ +export function rotatePoint(point: Point, angleDeg: number, origin: Point = { x: 0, y: 0 }): Point { + const rad = angleDeg * DEG_TO_RAD; + // Screen y grows downward, so a positive (clockwise) rotation uses the + // standard matrix with +sin on the y row: [c -s; s c]. + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = point.x - origin.x; + const dy = point.y - origin.y; + return { + x: origin.x + dx * cos - dy * sin, + y: origin.y + dx * sin + dy * cos, + }; +} + +/** + * Rotate a free VECTOR (no origin) by `angleDeg`. Equivalent to + * `rotatePoint(vec, angleDeg)`; named separately because handle math rotates a + * screen-space delta into the box's local axes (`angleDeg = -rotation`) and the + * intent reads more clearly as "rotate this delta". + */ +export function rotateVector(vec: Point, angleDeg: number): Point { + return rotatePoint(vec, angleDeg); +} + +/** + * Resolve a {@link TransformBoxPivot} to an absolute point in the box's local + * (unrotated) frame, given the box's `x/y/width/height`. `'center'` maps to the + * geometric center; a fractional point maps to that fraction of the box. + */ +export function resolvePivot(box: TransformBoxValue, pivot: TransformBoxPivot): Point { + if (pivot === 'center') { + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + } + return { x: box.x + box.width * pivot.x, y: box.y + box.height * pivot.y }; +} + +/** + * World-space center of the box (the point the local→world rotation pivots + * about for rendering). The CSS `transform: translate(x,y) rotate(r)` rotates + * about the element's own center, so the local center maps to itself under the + * world rotation and this is simply the local center. + */ +export function boxCenter(box: TransformBoxValue): Point { + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +/** + * Map a point given in the box's LOCAL (unrotated) frame to WORLD space, where + * the box is rotated by `box.rotation` about its center. Used to find the + * fixed-world position of an anchor corner before a resize. + */ +export function localToWorld(box: TransformBoxValue, local: Point): Point { + return rotatePoint(local, box.rotation, boxCenter(box)); +} + +/** Inverse of {@link localToWorld}: a world point into the box's local frame. */ +export function worldToLocal(box: TransformBoxValue, world: Point): Point { + return rotatePoint(world, -box.rotation, boxCenter(box)); +} + +/** + * Options controlling a single resize step. All optional; sensible defaults + * keep an axis-aligned, anchor-the-opposite-edge resize. + */ +export interface ResizeEdgeOptions { + /** Smallest allowed width. @default 1 */ + minWidth?: number; + /** Smallest allowed height. @default 1 */ + minHeight?: number; + /** + * Lock the width/height ratio to this value (`width / height`). `null` + * disables the lock. When set, the dominant dragged axis drives the other. + * @default null + */ + aspectRatio?: number | null; + /** + * Anchor symmetrically about the pivot instead of the opposite edge — the + * box grows/shrinks equally on both sides (the Alt gesture). @default false + */ + symmetric?: boolean; + /** + * Pivot used when `symmetric` is true (fractional point or `'center'`). + * @default 'center' + */ + pivot?: TransformBoxPivot; + /** + * Allow the box to flip through zero (negative size) instead of clamping at + * the minimum. @default true + */ + allowFlip?: boolean; +} + +/** + * Apply an aspect ratio to a width/height pair, keeping whichever axis the + * handle primarily drives and deriving the other. + * + * - A corner (`axes.x !== 0 && axes.y !== 0`) uses the axis with the larger + * absolute size so the box tracks the dominant drag direction. + * - A horizontal edge derives height from width; a vertical edge derives width + * from height. + * + * Signs are preserved (so a flipped, negative size stays negative). Pure. + */ +export function applyAspectRatio( + width: number, + height: number, + ratio: number, + axes: HandleAxes, +): { width: number; height: number } { + if (!(ratio > 0) || !Number.isFinite(ratio)) return { width, height }; + + const horizontal = axes.x !== 0; + const vertical = axes.y !== 0; + + // Corner: pick the axis whose magnitude implies the larger box and derive the + // other from it. This makes diagonal drags feel like they track the pointer. + if (horizontal && vertical) { + const wFromH = Math.abs(height) * ratio; + if (Math.abs(width) >= wFromH) { + const h = (Math.abs(width) / ratio) * Math.sign(height || 1); + return { width, height: h }; + } + const w = (Math.abs(height) * ratio) * Math.sign(width || 1); + return { width: w, height }; + } + + // Horizontal edge: width is authoritative. + if (horizontal) { + return { width, height: (Math.abs(width) / ratio) * Math.sign(height || 1) }; + } + // Vertical edge: height is authoritative. + return { width: (Math.abs(height) * ratio) * Math.sign(width || 1), height }; +} + +/** + * Clamp / normalize a box to satisfy the min-size and flip policy, returning a + * box with NON-negative width/height (a flip is folded into the origin). + * + * - When `allowFlip` is false a size below its minimum is clamped to the + * minimum (the edge cannot cross its anchor). + * - When `allowFlip` is true a negative size is allowed but normalized: the + * origin shifts and the size is made positive, so the committed box is always + * well-formed. The caller reads {@link TransformBoxValue} flip flags from the + * sign BEFORE normalization (see `resizeEdge`'s return). + * + * Pure. + */ +export function constrainRect( + box: TransformBoxValue, + minWidth = 1, + minHeight = 1, +): TransformBoxValue { + let { x, y, width, height } = box; + + if (width < 0) { + x += width; + width = -width; + } + if (height < 0) { + y += height; + height = -height; + } + if (width < minWidth) width = minWidth; + if (height < minHeight) height = minHeight; + + return { x, y, width, height, rotation: box.rotation }; +} + +/** Result of a resize step, carrying the new box plus the flip flags it crossed. */ +export interface ResizeResult { + /** The resized box (already constrained / normalized). */ + box: TransformBoxValue; + /** Whether the box is mirrored on x relative to the gesture start. */ + flippedX: boolean; + /** Whether the box is mirrored on y relative to the gesture start. */ + flippedY: boolean; +} + +/** + * Resize `box` by moving the edge(s) a `handle` controls by `deltaLocal` — a + * delta already expressed in the box's LOCAL (unrotated) axes. + * + * CRITICAL (rotated boxes): the caller MUST rotate the raw screen-space pointer + * delta into local axes first (`rotateVector(screenDelta, -box.rotation)`) + * before calling this. A naive axis-aligned delta is wrong once `rotation ≠ 0` + * because the box's "width" axis no longer aligns with screen x. + * + * The opposite edge/corner stays fixed in WORLD space (or the pivot stays fixed + * when `symmetric`). To keep the world anchor fixed we: + * 1. record the anchor's world position from the original box, + * 2. mutate the dragged edges in local space (so width/height/x/y change), + * 3. re-place the box so the anchor's local position maps back to the same + * world point under the (unchanged) rotation. + * + * Aspect lock, min-size, and flip policy are applied between (2) and (3). + * Returns the new box and the flip flags. Pure. + */ +export function resizeEdge( + box: TransformBoxValue, + handle: TransformBoxHandlePosition, + deltaLocal: Point, + options: ResizeEdgeOptions = {}, +): ResizeResult { + const { + minWidth = 1, + minHeight = 1, + aspectRatio = null, + symmetric = false, + pivot = 'center', + allowFlip = true, + } = options; + + const axes = handleAxes(handle); + + // ── 1. world anchor ──────────────────────────────────────────────────────── + // The anchor is the opposite corner/edge midpoint that must stay put. For a + // symmetric resize the anchor is the pivot itself. We capture it in world + // space so rotation is accounted for. + const startCenter = boxCenter(box); + const anchorLocal = symmetric + ? resolvePivot(box, pivot) + : { + // Opposite edge: flip the handle's outward sign. `0` axis → box center + // coordinate (that edge is not being moved). + x: box.x + (axes.x === 0 ? box.width / 2 : axes.x < 0 ? box.width : 0), + y: box.y + (axes.y === 0 ? box.height / 2 : axes.y < 0 ? box.height : 0), + }; + const anchorWorld = rotatePoint(anchorLocal, box.rotation, startCenter); + + // ── 2. apply the local delta to the dragged edges ────────────────────────── + // Symmetric doubles the delta (both sides move). `axes.* === 0` → that axis is + // untouched. + const scale = symmetric ? 2 : 1; + let newWidth = box.width + axes.x * deltaLocal.x * scale; + let newHeight = box.height + axes.y * deltaLocal.y * scale; + + // ── 3. aspect lock ───────────────────────────────────────────────────────── + if (aspectRatio !== null && aspectRatio !== undefined) { + const applied = applyAspectRatio(newWidth, newHeight, aspectRatio, axes); + newWidth = applied.width; + newHeight = applied.height; + } + + // Flip flags are read from the sign BEFORE we clamp/normalize. When flips are + // disallowed the size is clamped to the minimum (never crosses zero), so the + // box is never reported as flipped. + const flippedX = allowFlip && axes.x !== 0 && newWidth < 0; + const flippedY = allowFlip && axes.y !== 0 && newHeight < 0; + + // ── 4. min size / flip policy ────────────────────────────────────────────── + if (!allowFlip) { + if (newWidth < minWidth) newWidth = minWidth; + if (newHeight < minHeight) newHeight = minHeight; + } + else { + // Allow negative size but keep |size| above the minimum so a flipped box is + // still grabbable. + if (Math.abs(newWidth) < minWidth) newWidth = minWidth * Math.sign(newWidth || 1); + if (Math.abs(newHeight) < minHeight) newHeight = minHeight * Math.sign(newHeight || 1); + } + + // ── 5. re-place so the world anchor is preserved ─────────────────────────── + // Build a provisional box whose anchor sits at the SAME LOCAL position + // (relative to the new size) as before, then translate it so that local + // anchor maps back to `anchorWorld`. + // + // Local anchor fraction (0..1) is constant across the resize because the + // anchor is the opposite edge/pivot. We compute the anchor's local position in + // the resized box, rotate it about the resized center, and shift. + const anchorFracX = symmetric + ? pivotFrac(pivot, 'x') + : axes.x === 0 ? 0.5 : axes.x < 0 ? 1 : 0; + const anchorFracY = symmetric + ? pivotFrac(pivot, 'y') + : axes.y === 0 ? 0.5 : axes.y < 0 ? 1 : 0; + + // Provisional origin at (0,0); we only need it to compute the rotated offset of + // the anchor from the center, which is translation-invariant. + const provisional: TransformBoxValue = { x: 0, y: 0, width: newWidth, height: newHeight, rotation: box.rotation }; + const provCenter = boxCenter(provisional); + const provAnchorLocal = { x: newWidth * anchorFracX, y: newHeight * anchorFracY }; + const provAnchorWorld = rotatePoint(provAnchorLocal, box.rotation, provCenter); + + // Shift the provisional box so its anchor lands on the recorded world anchor. + const placed: TransformBoxValue = { + x: anchorWorld.x - provAnchorWorld.x, + y: anchorWorld.y - provAnchorWorld.y, + width: newWidth, + height: newHeight, + rotation: box.rotation, + }; + + // Normalize negative sizes into a positive, well-formed box (flip folded into + // the origin). When flips are disallowed sizes are already positive. + const box2 = constrainRect(placed, minWidth, minHeight); + + return { box: box2, flippedX, flippedY }; +} + +/** Fractional anchor position for a pivot on one axis (0..1). */ +function pivotFrac(pivot: TransformBoxPivot, axis: 'x' | 'y'): number { + if (pivot === 'center') return 0.5; + return axis === 'x' ? pivot.x : pivot.y; +} + +/** + * Move the box by a WORLD-space translation `delta`. Rotation does not change + * the world-space translate, so this just shifts `x/y` (the box's local origin + * lives in the same space its center renders in). Pure. + */ +export function moveBox(box: TransformBoxValue, delta: Point): TransformBoxValue { + return { ...box, x: box.x + delta.x, y: box.y + delta.y }; +} + +/** + * Compute a rotation (degrees) from a pointer at `point` about `pivotWorld`, + * carrying a `start` reference so the seam (the ±180° wrap) is crossed smoothly: + * the returned angle is `startRotation + signedDelta(startAngle → currentAngle)`. + * + * Convention matches the rest of the editor: 0° up, clockwise-positive. + */ +export function rotationFromPointer( + point: Point, + pivotWorld: Point, + startPointerAngle: number, + startRotation: number, +): number { + const current = pointerAngle(point, pivotWorld); + return startRotation + shortestAngleDelta(startPointerAngle, current); +} + +/** + * Raw angle (degrees) of `point` about `center`: 0° up, clockwise-positive, + * range `[0, 360)`. Mirrors angle-dial's convention so rotation handles read the + * same way. A point exactly at the center returns `0`. + */ +export function pointerAngle(point: Point, center: Point): number { + const dx = point.x - center.x; + const dy = point.y - center.y; + if (dx === 0 && dy === 0) return 0; + const rad = Math.atan2(dx, -dy); + let deg = rad * (360 / TAU); + if (deg < 0) deg += 360; + return deg; +} + +/** + * Signed shortest step (degrees) from `from` to `to`, in `(-180, 180]`. Positive + * is clockwise. Lets a rotate drag accumulate across the 0/360 seam instead of + * snapping backwards. + */ +export function shortestAngleDelta(from: number, to: number): number { + let d = (to - from) % 360; + if (d > 180) d -= 360; + else if (d <= -180) d += 360; + return d; +} + +/** Wrap an angle (degrees) into `[0, 360)`. */ +export function normalizeRotation(deg: number): number { + const r = deg % 360; + return r < 0 ? r + 360 : r; +} + +/** + * Snap a rotation to the nearest multiple of `increment` degrees. `increment <= + * 0` returns the value unchanged. Anchored at 0. Pure. + */ +export function snapRotation(deg: number, increment: number): number { + if (!(increment > 0)) return deg; + return Math.round(deg / increment) * increment; +} + +/** + * Decompose a transform into its readable parts. Given a box, returns the + * normalized box dimensions, the world-space center, the four corner points in + * WORLD space (top-left, top-right, bottom-right, bottom-left, in that order, + * rotation applied), and the normalized rotation in `[0, 360)`. + * + * Shared with Crop (which needs corner world positions to draw the crop overlay + * and to hit-test handles) so the corner math lives in ONE pure place. + */ +export function decomposeTransform(box: TransformBoxValue): { + /** Normalized box (positive width/height, flip folded into origin). */ + rect: { x: number; y: number; width: number; height: number }; + /** World-space center the box rotates about. */ + center: Point; + /** Rotation folded into `[0, 360)`. */ + rotation: number; + /** World-space corners: [top-left, top-right, bottom-right, bottom-left]. */ + corners: [Point, Point, Point, Point]; +} { + const norm = constrainRect(box, 0, 0); + const center = { x: norm.x + norm.width / 2, y: norm.y + norm.height / 2 }; + const rotation = normalizeRotation(box.rotation); + + const tl = rotatePoint({ x: norm.x, y: norm.y }, box.rotation, center); + const tr = rotatePoint({ x: norm.x + norm.width, y: norm.y }, box.rotation, center); + const br = rotatePoint({ x: norm.x + norm.width, y: norm.y + norm.height }, box.rotation, center); + const bl = rotatePoint({ x: norm.x, y: norm.y + norm.height }, box.rotation, center); + + return { + rect: { x: norm.x, y: norm.y, width: norm.width, height: norm.height }, + center, + rotation, + corners: [tl, tr, br, bl], + }; +} diff --git a/vue/primitives/src/canvas/waveform/WaveformBars.vue b/vue/primitives/src/canvas/waveform/WaveformBars.vue new file mode 100644 index 0000000..0c94b73 --- /dev/null +++ b/vue/primitives/src/canvas/waveform/WaveformBars.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/vue/primitives/src/canvas/waveform/WaveformCursor.vue b/vue/primitives/src/canvas/waveform/WaveformCursor.vue new file mode 100644 index 0000000..4d20967 --- /dev/null +++ b/vue/primitives/src/canvas/waveform/WaveformCursor.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/vue/primitives/src/canvas/waveform/WaveformEmpty.vue b/vue/primitives/src/canvas/waveform/WaveformEmpty.vue new file mode 100644 index 0000000..4cf4941 --- /dev/null +++ b/vue/primitives/src/canvas/waveform/WaveformEmpty.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/vue/primitives/src/canvas/waveform/WaveformPath.vue b/vue/primitives/src/canvas/waveform/WaveformPath.vue new file mode 100644 index 0000000..04b4b0d --- /dev/null +++ b/vue/primitives/src/canvas/waveform/WaveformPath.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/vue/primitives/src/canvas/waveform/WaveformRegion.vue b/vue/primitives/src/canvas/waveform/WaveformRegion.vue new file mode 100644 index 0000000..625606b --- /dev/null +++ b/vue/primitives/src/canvas/waveform/WaveformRegion.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/vue/primitives/src/canvas/waveform/WaveformRegionHandle.vue b/vue/primitives/src/canvas/waveform/WaveformRegionHandle.vue new file mode 100644 index 0000000..2fafc67 --- /dev/null +++ b/vue/primitives/src/canvas/waveform/WaveformRegionHandle.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/vue/primitives/src/canvas/waveform/WaveformRoot.vue b/vue/primitives/src/canvas/waveform/WaveformRoot.vue new file mode 100644 index 0000000..3a2026d --- /dev/null +++ b/vue/primitives/src/canvas/waveform/WaveformRoot.vue @@ -0,0 +1,437 @@ + + + + + diff --git a/vue/primitives/src/canvas/waveform/WaveformSelectionPreview.vue b/vue/primitives/src/canvas/waveform/WaveformSelectionPreview.vue new file mode 100644 index 0000000..fb03c2d --- /dev/null +++ b/vue/primitives/src/canvas/waveform/WaveformSelectionPreview.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/vue/primitives/src/canvas/waveform/__test__/Waveform.test.ts b/vue/primitives/src/canvas/waveform/__test__/Waveform.test.ts new file mode 100644 index 0000000..238c008 --- /dev/null +++ b/vue/primitives/src/canvas/waveform/__test__/Waveform.test.ts @@ -0,0 +1,298 @@ +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import type { WaveformRegionData } from '../utils'; +import { + WaveformBars, + WaveformCursor, + WaveformEmpty, + WaveformRegion, + WaveformRegionHandle, + WaveformRoot, +} from '../index'; + +const wrappers: Array> = []; + +afterEach(() => { + while (wrappers.length) wrappers.pop()!.unmount(); + document.body.innerHTML = ''; +}); + +function track>(w: T): T { + wrappers.push(w); + return w; +} + +function makePeaks(n: number): number[] { + return Array.from({ length: n }, (_, i) => Math.sin((i / n) * Math.PI * 8)); +} + +const ROOT_STYLE = 'position: relative; width: 300px; height: 60px; display: block;'; + +function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false })); +} + +// Wait for the ResizeObserver-backed width to settle. +async function settle(): Promise { + await nextTick(); + await new Promise(r => requestAnimationFrame(() => r(null))); + await nextTick(); +} + +interface RootOpts { + peaks?: number[]; + duration?: number; + currentTime?: number; + regions?: WaveformRegionData[]; + createRegionOnDrag?: boolean; + disabled?: boolean; + step?: number; +} + +function mountRoot(opts: RootOpts = {}, children?: (props: any) => any) { + const currentTime = ref(opts.currentTime ?? 0); + const regions = ref(opts.regions ?? []); + const events = { + seekCommit: [] as number[], + regionCreate: [] as WaveformRegionData[], + regionUpdate: [] as WaveformRegionData[], + regionRemove: [] as string[], + }; + const Harness = defineComponent({ + setup() { + return () => h(WaveformRoot, { + peaks: opts.peaks ?? makePeaks(200), + peaksRange: '-1..1', + duration: opts.duration ?? 100, + currentTime: currentTime.value, + 'onUpdate:currentTime': (v: number) => { currentTime.value = v; }, + regions: regions.value, + 'onUpdate:regions': (v: WaveformRegionData[]) => { regions.value = v; }, + createRegionOnDrag: opts.createRegionOnDrag, + disabled: opts.disabled, + step: opts.step, + style: ROOT_STYLE, + onSeekCommit: (t: number) => events.seekCommit.push(t), + onRegionCreate: (r: WaveformRegionData) => events.regionCreate.push(r), + onRegionUpdate: (r: WaveformRegionData) => events.regionUpdate.push(r), + onRegionRemove: (id: string) => events.regionRemove.push(id), + }, { + default: (slotProps: any) => children + ? children(slotProps) + : [ + h(WaveformBars), + h(WaveformCursor, { 'aria-label': 'Playback position' }), + ], + }); + }, + }); + const w = track(mount(Harness, { attachTo: document.body })); + return { wrapper: w, currentTime, regions, events }; +} + +describe('Waveform — cursor', () => { + it('renders the cursor as role="slider" with aria-value* attrs', async () => { + mountRoot({ duration: 100, currentTime: 25 }); + await settle(); + const cursor = document.querySelector('[role="slider"]')!; + expect(cursor).toBeTruthy(); + expect(cursor.getAttribute('aria-valuemin')).toBe('0'); + expect(cursor.getAttribute('aria-valuemax')).toBe('100'); + expect(cursor.getAttribute('aria-valuenow')).toBe('25'); + expect(cursor.getAttribute('aria-orientation')).toBe('horizontal'); + expect(cursor.getAttribute('aria-label')).toBe('Playback position'); + expect(cursor.tabIndex).toBe(0); + }); + + it('Arrow Right/Left scrub by step seconds', async () => { + const { currentTime } = mountRoot({ duration: 100, currentTime: 50, step: 5 }); + await settle(); + const cursor = document.querySelector('[role="slider"]')!; + keydown(cursor, 'ArrowRight'); + await nextTick(); + expect(currentTime.value).toBe(55); + keydown(cursor, 'ArrowLeft'); + keydown(cursor, 'ArrowLeft'); + await nextTick(); + expect(currentTime.value).toBe(45); + }); + + it('Home/End seek to 0 / duration', async () => { + const { currentTime } = mountRoot({ duration: 100, currentTime: 30, step: 1 }); + await settle(); + const cursor = document.querySelector('[role="slider"]')!; + keydown(cursor, 'End'); + await nextTick(); + expect(currentTime.value).toBe(100); + keydown(cursor, 'Home'); + await nextTick(); + expect(currentTime.value).toBe(0); + }); + + it('clamps seeks to [0, duration] (no NaN, no overflow)', async () => { + const { currentTime } = mountRoot({ duration: 10, currentTime: 9, step: 5 }); + await settle(); + const cursor = document.querySelector('[role="slider"]')!; + keydown(cursor, 'ArrowRight'); + keydown(cursor, 'ArrowRight'); + await nextTick(); + expect(currentTime.value).toBe(10); + expect(Number.isNaN(currentTime.value)).toBe(false); + }); + + it('is positioned via the projection (left grows with time)', async () => { + const { currentTime, wrapper } = mountRoot({ duration: 100, currentTime: 0, step: 10 }); + await settle(); + const cursor = document.querySelector('[role="slider"]')!; + const at0 = cursor.style.left; + currentTime.value = 50; + await wrapper.setProps({}); + await nextTick(); + const at50 = cursor.style.left; + expect(at0).toBe('0px'); + expect(Number.parseFloat(at50)).toBeGreaterThan(0); + }); +}); + +describe('Waveform — bars', () => { + it('resamples to the expected bar count for the width / barWidth / barGap', async () => { + // width 300, barWidth 2, barGap 1 → 100 bars regardless of peaks length. + mountRoot({ peaks: makePeaks(777), duration: 100 }); + await settle(); + const bars = document.querySelectorAll('[data-waveform-bar]'); + expect(bars.length).toBe(100); + }); + + it('renders nothing meaningful when peaks are empty', async () => { + mountRoot({ peaks: [], duration: 100 }); + await settle(); + const bars = document.querySelectorAll('[data-waveform-bar]'); + expect(bars.length).toBe(0); + }); +}); + +describe('Waveform — empty state', () => { + it('sets data-empty on the root when duration is 0', async () => { + mountRoot({ duration: 0 }, () => [h(WaveformEmpty, null, { default: () => 'No audio' })]); + await settle(); + const root = document.querySelector('[data-waveform-root]')!; + expect(root.hasAttribute('data-empty')).toBe(true); + expect(document.querySelector('[data-waveform-empty]')).toBeTruthy(); + }); + + it('no data-empty when there is audio', async () => { + mountRoot({ duration: 100, peaks: makePeaks(100) }); + await settle(); + const root = document.querySelector('[data-waveform-root]')!; + expect(root.hasAttribute('data-empty')).toBe(false); + }); + + it('pins the cursor at 0 with no NaN when duration is 0', async () => { + mountRoot({ duration: 0 }); + await settle(); + const cursor = document.querySelector('[role="slider"]')!; + expect(cursor.getAttribute('aria-valuenow')).toBe('0'); + expect(cursor.style.left).toBe('0px'); + }); +}); + +describe('Waveform — regions', () => { + function mountWithRegion(region: WaveformRegionData, opts: RootOpts = {}) { + return mountRoot({ ...opts, regions: [region] }, () => [ + h(WaveformBars), + h(WaveformRegion, { regionId: region.id }, { + default: () => [ + h(WaveformRegionHandle, { edge: 'start' }), + h(WaveformRegionHandle, { edge: 'end' }), + ], + }), + ]); + } + + it('renders the region as role="group" with formatted aria-label', async () => { + mountWithRegion({ id: 'r1', start: 20, end: 40, label: 'Intro' }); + await settle(); + const group = document.querySelector('[role="group"]')!; + expect(group).toBeTruthy(); + expect(group.getAttribute('aria-label')).toContain('Intro'); + }); + + it('renders two handles as role="slider" (Region start / Region end)', async () => { + mountWithRegion({ id: 'r1', start: 20, end: 40 }); + await settle(); + const handles = document.querySelectorAll('[role="group"] [role="slider"]'); + expect(handles).toHaveLength(2); + expect(handles[0]!.getAttribute('aria-label')).toBe('Region start'); + expect(handles[1]!.getAttribute('aria-label')).toBe('Region end'); + expect(handles[0]!.getAttribute('aria-valuenow')).toBe('20'); + expect(handles[1]!.getAttribute('aria-valuenow')).toBe('40'); + }); + + it('keyboard trims the start handle by step', async () => { + const { regions } = mountWithRegion({ id: 'r1', start: 20, end: 40 }, { step: 5 }); + await settle(); + const startHandle = document.querySelector('[role="slider"][data-edge="start"]')!; + keydown(startHandle, 'ArrowRight'); + await nextTick(); + expect(regions.value[0]!.start).toBe(25); + }); + + it('Home moves the start handle to 0; End moves end to duration', async () => { + const { regions } = mountWithRegion({ id: 'r1', start: 20, end: 40 }, { duration: 100, step: 1 }); + await settle(); + const startHandle = document.querySelector('[data-edge="start"]')!; + const endHandle = document.querySelector('[data-edge="end"]')!; + keydown(startHandle, 'Home'); + await nextTick(); + expect(regions.value[0]!.start).toBe(0); + keydown(endHandle, 'End'); + await nextTick(); + expect(regions.value[0]!.end).toBe(100); + }); + + it('Delete on the region removes it and emits regionRemove', async () => { + const { regions, events } = mountWithRegion({ id: 'r1', start: 20, end: 40 }); + await settle(); + const group = document.querySelector('[role="group"]')!; + keydown(group, 'Delete'); + await nextTick(); + expect(regions.value).toHaveLength(0); + expect(events.regionRemove).toContain('r1'); + }); +}); + +describe('Waveform — disabled', () => { + it('blocks keyboard seeks and sets aria/data-disabled', async () => { + const { currentTime } = mountRoot({ duration: 100, currentTime: 50, step: 5, disabled: true }); + await settle(); + const cursor = document.querySelector('[role="slider"]')!; + expect(cursor.tabIndex).toBe(-1); + expect(cursor.getAttribute('aria-disabled')).toBe('true'); + keydown(cursor, 'ArrowRight'); + await nextTick(); + expect(currentTime.value).toBe(50); + const root = document.querySelector('[data-waveform-root]')!; + expect(root.hasAttribute('data-disabled')).toBe(true); + }); +}); + +describe('Waveform — createRegionOnDrag', () => { + it('emits a new region when a marquee drag commits', async () => { + const { regions, events } = mountRoot({ duration: 100, createRegionOnDrag: true }, () => [h(WaveformBars)]); + await settle(); + const root = document.querySelector('[data-waveform-root]')!; + const rect = root.getBoundingClientRect(); + const id = 1; + const opts = (x: number) => ({ pointerId: id, clientX: rect.left + x, clientY: rect.top + 10, button: 0, bubbles: true, cancelable: true }); + root.dispatchEvent(new PointerEvent('pointerdown', opts(30))); + root.dispatchEvent(new PointerEvent('pointermove', opts(150))); + await new Promise(r => requestAnimationFrame(() => r(null))); + root.dispatchEvent(new PointerEvent('pointerup', opts(150))); + await nextTick(); + expect(events.regionCreate.length).toBeGreaterThan(0); + expect(regions.value.length).toBe(1); + expect(regions.value[0]!.end).toBeGreaterThan(regions.value[0]!.start); + }); +}); diff --git a/vue/primitives/src/canvas/waveform/__test__/utils.test.ts b/vue/primitives/src/canvas/waveform/__test__/utils.test.ts new file mode 100644 index 0000000..4050759 --- /dev/null +++ b/vue/primitives/src/canvas/waveform/__test__/utils.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { buildBars, buildPathPoints, countBars, resamplePeaks } from '../utils'; + +describe('countBars', () => { + it('counts bars by pitch (barWidth + barGap), no trailing gap', () => { + // pitch = 2 + 1 = 3; (300 + 1) / 3 = 100.33 → 100 + expect(countBars(300, 2, 1)).toBe(100); + // pitch = 4 + 2 = 6; (120 + 2) / 6 = 20.33 → 20 + expect(countBars(120, 4, 2)).toBe(20); + // no gap: pitch = barWidth + expect(countBars(100, 2, 0)).toBe(50); + }); + + it('returns 0 for degenerate widths (no divide-by-zero)', () => { + expect(countBars(0, 2, 1)).toBe(0); + expect(countBars(-10, 2, 1)).toBe(0); + }); + + it('treats non-positive barWidth as 1px and non-positive gap as 0', () => { + expect(countBars(10, 0, 0)).toBe(10); + expect(countBars(10, -5, -5)).toBe(10); + }); +}); + +describe('resamplePeaks', () => { + it('produces exactly bucketCount values regardless of peaks length', () => { + expect(resamplePeaks([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 4, false)).toHaveLength(4); + expect(resamplePeaks([0.5], 8, false)).toHaveLength(8); + expect(resamplePeaks(Array.from({ length: 1000 }, () => 0.3), 37, false)).toHaveLength(37); + }); + + it('takes the MAX magnitude within each bucket slice', () => { + // 8 samples → 2 buckets → slice [0,4) and [4,8) + const peaks = [0.1, 0.9, 0.2, 0.3, 0.4, 0.5, 0.1, 0.2]; + expect(resamplePeaks(peaks, 2, false)).toEqual([0.9, 0.5]); + }); + + it('rectifies signed peaks via absolute value', () => { + const peaks = [-0.8, 0.2, -0.1, 0.3]; + // signed → |−0.8| = 0.8 max in first slice + expect(resamplePeaks(peaks, 2, true)).toEqual([0.8, 0.3]); + }); + + it('passes through 0..1 peaks unchanged (clamped to 1)', () => { + expect(resamplePeaks([0.4, 1.5, 0.2, 0.6], 2, false)).toEqual([1, 0.6]); + }); + + it('resamples a window slice by ratio', () => { + const peaks = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]; + // window [4, 8) → 2 buckets → [0.5, 0.6] and [0.7, 0.8] + expect(resamplePeaks(peaks, 2, false, 4, 8)).toEqual([0.6, 0.8]); + }); + + it('returns [] for empty peaks or non-positive bucket count', () => { + expect(resamplePeaks([], 4, false)).toEqual([]); + expect(resamplePeaks([1, 2, 3], 0, false)).toEqual([]); + }); + + it('yields flat zero buckets for a degenerate window (no NaN)', () => { + const out = resamplePeaks([1, 2, 3], 3, false, 2, 2); + expect(out).toEqual([0, 0, 0]); + expect(out.some(Number.isNaN)).toBe(false); + }); + + it('upsamples (more buckets than samples) without NaN', () => { + const out = resamplePeaks([0.2, 0.8], 6, false); + expect(out).toHaveLength(6); + expect(out.some(Number.isNaN)).toBe(false); + // every bucket samples at least one source value + expect(out.every(v => v >= 0)).toBe(true); + }); +}); + +describe('buildBars', () => { + it('returns one bar per fitted bucket with correct width', () => { + const peaks = Array.from({ length: 50 }, (_, i) => i / 50); + const bars = buildBars(peaks, 300, 2, 1, false); + expect(bars).toHaveLength(100); // countBars(300,2,1) + expect(bars.every(b => b.width === 2)).toBe(true); + }); + + it('respects an arbitrary peaks length (more or fewer than bars)', () => { + expect(buildBars([0.5], 100, 2, 0, false)).toHaveLength(50); + expect(buildBars(Array.from({ length: 9999 }, () => 0.5), 100, 2, 0, false)).toHaveLength(50); + }); + + it('places bars on a pitch and centers leftover space', () => { + // width 10, barWidth 2, gap 0 → 5 bars at x = 0,2,4,6,8 (no leftover) + const bars = buildBars([1, 1, 1, 1, 1], 10, 2, 0, false); + expect(bars.map(b => b.x)).toEqual([0, 2, 4, 6, 8]); + }); + + it('returns [] when width is 0 (renders nothing)', () => { + expect(buildBars([0.5, 0.6], 0, 2, 1, false)).toEqual([]); + }); + + it('heights are 0..1 fractions', () => { + const bars = buildBars([0.25, 1, 0.5], 6, 2, 0, false); + expect(bars.every(b => b.height >= 0 && b.height <= 1)).toBe(true); + }); +}); + +describe('buildPathPoints', () => { + it('returns `samples` points spanning the width', () => { + const pts = buildPathPoints([0, 0.5, 1, 0.5], 100, 40, 4, false); + expect(pts).toHaveLength(4); + expect(pts[0]!.x).toBe(0); + expect(pts[3]!.x).toBeCloseTo(100); + }); + + it('maps magnitude onto the vertical center', () => { + // height 40 → mid 20; magnitude 1 → y = 0 (top), magnitude 0 → y = 20 (mid) + const pts = buildPathPoints([1, 0], 100, 40, 2, false); + expect(pts[0]!.y).toBeCloseTo(0); + expect(pts[1]!.y).toBeCloseTo(20); + }); + + it('returns [] for degenerate geometry', () => { + expect(buildPathPoints([0.5], 0, 40, 8, false)).toEqual([]); + expect(buildPathPoints([0.5], 100, 0, 8, false)).toEqual([]); + expect(buildPathPoints([0.5], 100, 40, 0, false)).toEqual([]); + }); +}); diff --git a/vue/primitives/src/canvas/waveform/context.ts b/vue/primitives/src/canvas/waveform/context.ts new file mode 100644 index 0000000..90ad54f --- /dev/null +++ b/vue/primitives/src/canvas/waveform/context.ts @@ -0,0 +1,108 @@ +import type { ComputedRef, Ref } from 'vue'; +import { useContextFactory } from '@robonen/vue'; +import type { WaveformBar, WaveformRegionData } from './utils'; + +/** Writing direction for the waveform body. */ +export type WaveformDirection = 'ltr' | 'rtl'; + +/** Formats a time (seconds) into a human string for `aria-valuetext`. */ +export type WaveformTimeFormatter = (seconds: number) => string; + +/** + * The value↔pixel projection exposed to descendants: `scale` maps a time (s) to + * a pixel x within the body, `invert` maps a pixel x back to a time. Mirrors the + * subset of {@link import('../../internal/scale').useScale} that parts consume. + */ +export interface WaveformProjection { + /** Project a time (seconds) to a pixel x within the body. */ + scale: (seconds: number) => number; + /** Invert a pixel x within the body back to a time (seconds). */ + invert: (px: number) => number; +} + +/** + * Context shared between `WaveformRoot` and its descendants. + * + * Scalar props are plain `Ref` (built with `toRef(() => prop)` in the root — + * a `GetterRefImpl` that is reactive without allocating an effect). `buckets` + * and `projection` are derived and read on every render of the body. + */ +export interface WaveformContext { + /** Source per-sample amplitudes (passthrough of the `peaks` prop). */ + peaks: Ref>; + /** Whether `peaks` are signed (`'-1..1'`) and must be rectified. */ + signed: Ref; + /** Total media duration, in seconds. */ + duration: Ref; + /** Current playback position (seconds), clamped to `[0, duration]`. */ + currentTime: Ref; + /** The current set of regions. */ + regions: Ref; + /** Measured body width, in pixels. */ + width: Ref; + /** Effective writing direction. */ + direction: Ref; + /** Whether interaction is disabled. */ + disabled: Ref; + /** Keyboard step for the cursor / region edges, in seconds. */ + step: Ref; + /** Large keyboard step (Shift+Arrow), in seconds. */ + largeStep: Ref; + /** Default formatter for cursor `aria-valuetext`. */ + timeFormatter: Ref; + + /** The visible time window `[start, end]` (seconds). */ + window: ComputedRef; + /** Time↔pixel projection over the visible window → `[0, width]`. */ + projection: WaveformProjection; + /** Computed bar geometry, resampled by ratio for the current width. */ + buckets: ComputedRef; + /** `true` when duration is 0 or there are no peaks. */ + isEmpty: ComputedRef; + /** `true` while peaks are loading (async fetch). */ + loading: Ref; + /** The transient create-region marquee (only meaningful while `active`). */ + preview: ComputedRef<{ active: boolean; start: number; end: number }>; + + /** Seek to a time (seconds); clamps to bounds and emits a commit. */ + seek: (seconds: number, commit?: boolean) => void; + /** Append a new region (id auto-generated when omitted); returns its id. */ + addRegion: (region: Partial & { start: number; end: number }) => string; + /** Patch an existing region by id (start/end re-ordered & clamped). */ + updateRegion: (id: string, patch: Partial>, commit?: boolean) => void; + /** Remove a region by id. */ + removeRegion: (id: string) => void; + + /** Register the cursor element so the root can drive focus/keyboard. */ + registerCursor: (el: HTMLElement | null) => void; +} + +const ctx = useContextFactory('WaveformContext'); + +export const provideWaveformContext = ctx.provide; +export const useWaveformContext = ctx.inject; + +/** Which edge of a region a `WaveformRegionHandle` trims. */ +export type WaveformRegionEdge = 'start' | 'end'; + +/** + * Context provided by `WaveformRegion` to its two `WaveformRegionHandle` + * children so each handle knows the region's id and current bounds and can trim + * the correct edge. + */ +export interface WaveformRegionContext { + /** The region's stable id. */ + id: Ref; + /** Live region bounds `{ start, end }` in seconds. */ + start: Ref; + end: Ref; + /** Whether this region is the selected/active one. */ + selected: Ref; + /** Trim one edge to `seconds`; the root re-orders & clamps. */ + trim: (edge: WaveformRegionEdge, seconds: number, commit?: boolean) => void; +} + +const regionCtx = useContextFactory('WaveformRegionContext'); + +export const provideWaveformRegionContext = regionCtx.provide; +export const useWaveformRegionContext = regionCtx.inject; diff --git a/vue/primitives/src/canvas/waveform/demo-audio.ts b/vue/primitives/src/canvas/waveform/demo-audio.ts new file mode 100644 index 0000000..f556a4b --- /dev/null +++ b/vue/primitives/src/canvas/waveform/demo-audio.ts @@ -0,0 +1,60 @@ +// AUTO-GENERATED demo data — real peaks of the "Healing 01" track (sousound.com), +// decoded offline (the source has no CORS, so peaks can't be computed at runtime; +// the audio still plays cross-origin via a plain