feat(primitives): media-editor components, category reorg, perf + type cleanup

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
+12 -12
View File
@@ -24,35 +24,35 @@
</p> </p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="m-0 text-sm font-semibold text-(--fg)">Unstyled by design</h3> <h3 class="m-0 text-sm font-semibold text-fg">Unstyled by design</h3>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)"> <p class="mt-2 mb-0 text-sm text-fg-muted">
No CSS shipped. Primitives render the DOM you ask for and expose state via 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. data attributes, so you bring your own styles Tailwind, vanilla CSS, anything.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="m-0 text-sm font-semibold text-(--fg)">Accessible out of the box</h3> <h3 class="m-0 text-sm font-semibold text-fg">Accessible out of the box</h3>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)"> <p class="mt-2 mb-0 text-sm text-fg-muted">
Focus scopes, roving tabindex, visually-hidden labels and correct ARIA roles Focus scopes, roving tabindex, visually-hidden labels and correct ARIA roles
are handled for you. The suite is tested against are handled for you. The suite is tested against
<code>axe-core</code> in a real browser. <code>axe-core</code> in a real browser.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="m-0 text-sm font-semibold text-(--fg)">Controlled or uncontrolled</h3> <h3 class="m-0 text-sm font-semibold text-fg">Controlled or uncontrolled</h3>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)"> <p class="mt-2 mb-0 text-sm text-fg-muted">
Bind state with <code>v-model</code> when you need control, or set a Bind state with <code>v-model</code> when you need control, or set a
<code>defaultValue</code> / <code>defaultOpen</code> and let the primitive <code>defaultValue</code> / <code>defaultOpen</code> and let the primitive
manage itself. manage itself.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="m-0 text-sm font-semibold text-(--fg)">Composable & polymorphic</h3> <h3 class="m-0 text-sm font-semibold text-fg">Composable & polymorphic</h3>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)"> <p class="mt-2 mb-0 text-sm text-fg-muted">
Every part takes an <code>as</code> prop, or use <code>as="template"</code> Every part takes an <code>as</code> prop, or use <code>as="template"</code>
to merge behavior onto your own element. Floating UI powers positioning for to merge behavior onto your own element. Floating UI powers positioning for
popovers, tooltips and menus. popovers, tooltips and menus.
+2 -2
View File
@@ -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, { export default compose(base, typescript, vue, imports, stylistic, {
name: 'primitives/overrides', name: 'primitives/overrides',
@@ -6,4 +6,4 @@ export default compose(base, typescript, vue, imports, stylistic, {
rules: { rules: {
'@stylistic/no-multiple-empty-lines': 'off', '@stylistic/no-multiple-empty-lines': 'off',
}, },
}); }, tests);
@@ -5,7 +5,7 @@ import {
AccordionItem, AccordionItem,
AccordionRoot, AccordionRoot,
AccordionTrigger, AccordionTrigger,
} from '@primitives/accordion'; } from '@primitives/disclosure/accordion';
const value = ref<string | string[] | undefined>('a'); const value = ref<string | string[] | undefined>('a');
const type = ref<'single' | 'multiple'>('single'); const type = ref<'single' | 'multiple'>('single');
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import type { CheckedState } from '@primitives/checkbox'; import type { CheckedState } from '@primitives/forms/checkbox';
import { CheckboxIndicator, CheckboxRoot } from '@primitives/checkbox'; import { CheckboxIndicator, CheckboxRoot } from '@primitives/forms/checkbox';
const checked = ref<CheckedState>(false); const checked = ref<CheckedState>(false);
const disabled = ref(false); const disabled = ref(false);
@@ -0,0 +1,71 @@
import { mount } from '@vue/test-utils';
import type { VueWrapper } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterEach, describe, expect, it } from 'vitest';
// Smoke test: every new media-editor `demo.vue` must MOUNT without throwing
// (catches context-consumed-outside-its-Root, null-ref api calls at setup, and
// other runtime breakage that type-checking / SFC-parsing do not surface).
import AlphaSliderDemo from '../color/alpha-slider/demo.vue';
import AngleDialDemo from '../canvas/angle-dial/demo.vue';
import CanvasStageDemo from '../canvas/canvas-stage/demo.vue';
import ColorAreaDemo from '../color/color-area/demo.vue';
import ColorFieldDemo from '../color/color-field/demo.vue';
import CompareSliderDemo from '../canvas/compare-slider/demo.vue';
import CropDemo from '../canvas/crop/demo.vue';
import CurveEditorDemo from '../canvas/curve-editor/demo.vue';
import GradientEditorDemo from '../canvas/gradient-editor/demo.vue';
import HistogramDemo from '../canvas/histogram/demo.vue';
import HueSliderDemo from '../color/hue-slider/demo.vue';
import KeyframeTrackDemo from '../canvas/keyframe-track/demo.vue';
import LevelsDemo from '../canvas/levels/demo.vue';
import TimeRulerDemo from '../canvas/time-ruler/demo.vue';
import TimelineDemo from '../canvas/timeline/demo.vue';
import TransformBoxDemo from '../canvas/transform-box/demo.vue';
import WaveformDemo from '../canvas/waveform/demo.vue';
import ZoomPanDemo from '../canvas/zoom-pan/demo.vue';
const demos: Record<string, unknown> = {
'alpha-slider': AlphaSliderDemo,
'angle-dial': AngleDialDemo,
'canvas-stage': CanvasStageDemo,
'color-area': ColorAreaDemo,
'color-field': ColorFieldDemo,
'compare-slider': CompareSliderDemo,
crop: CropDemo,
'curve-editor': CurveEditorDemo,
'gradient-editor': GradientEditorDemo,
histogram: HistogramDemo,
'hue-slider': HueSliderDemo,
'keyframe-track': KeyframeTrackDemo,
levels: LevelsDemo,
'time-ruler': TimeRulerDemo,
timeline: TimelineDemo,
'transform-box': TransformBoxDemo,
waveform: WaveformDemo,
'zoom-pan': ZoomPanDemo,
};
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
describe('media-editor demos mount without error or Vue warnings', () => {
for (const [name, Comp] of Object.entries(demos)) {
it(`${name}/demo.vue mounts cleanly`, async () => {
// Collect Vue warnings (e.g. slot-prop-out-of-scope, missing context,
// unknown template refs) instead of letting them slip to console.
const warnings: string[] = [];
const wrapper = mount(Comp as any, {
attachTo: document.body,
global: { config: { warnHandler: (msg: string) => { warnings.push(msg); } } },
});
wrappers.push(wrapper);
await nextTick();
expect(wrapper.html().length).toBeGreaterThan(0);
expect(warnings, `Vue warnings in ${name}/demo.vue:\n${warnings.join('\n')}`).toEqual([]);
});
}
});
@@ -1,45 +0,0 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The collapsible panel revealed when its item is open. Rendered as an ARIA
* `region` labelled by its trigger and mounted/unmounted via `Presence` so
* enter/leave transitions can run (use `forceMount` to keep it mounted for
* custom animation).
*/
export interface AccordionContentProps extends PrimitiveProps {
/** Keep content mounted even when closed. */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { useAccordionContext, useAccordionItemContext } from './context';
import { Presence } from '../presence';
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div', forceMount = false } = defineProps<AccordionContentProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAccordionContext();
const item = useAccordionItemContext();
</script>
<template>
<Presence :present="forceMount || item.open.value">
<Primitive
:ref="forwardRef"
:as="as"
role="region"
:id="item.contentId.value"
:aria-labelledby="item.triggerId.value"
:data-state="item.open.value ? 'open' : 'closed'"
:data-disabled="item.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
:hidden="!item.open.value || undefined"
>
<slot :open="item.open.value" />
</Primitive>
</Presence>
</template>
@@ -1,22 +0,0 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The button that dismisses the alert without acting and closes the dialog.
* Receives focus automatically when the alert opens, making it the safe default
* choice; always include one so the user has a non-destructive way out.
*/
export interface AlertDialogCancelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { DialogClose } from '../dialog';
const { as = 'button' } = defineProps<AlertDialogCancelProps>();
</script>
<template>
<DialogClose :as="as" data-alert-dialog-cancel>
<slot />
</DialogClose>
</template>
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
delete document.body.dataset['dismissableBlocking'];
});
function track<T extends VueWrapper<any>>(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<HTMLElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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();
});
});
@@ -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: '<img />' } });
const inner = wrapper.element.firstElementChild as HTMLElement;
expect(inner.style.position).toBe('absolute');
expect(inner.getAttribute('data-aspect-ratio')).toBe('true');
});
});
-89
View File
@@ -1,89 +0,0 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { AvatarImageLoadingStatus } from './context';
/**
* The image to display. It loads the `src` out of band and only renders once
* the image has successfully loaded, reporting its loading status to the root
* so the fallback can take over while loading or on error.
*/
export interface AvatarImageProps extends PrimitiveProps {
/** Image source URL — loaded out of band before the image is shown. */
src?: string;
/** Alternative text describing the image. */
alt?: string;
/** Called whenever the image's loading status changes (`idle`/`loading`/`loaded`/`error`). */
onLoadingStatusChange?: (status: AvatarImageLoadingStatus) => void;
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useAvatarContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'img', src, alt, onLoadingStatusChange } = defineProps<AvatarImageProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAvatarContext();
const status = ref<AvatarImageLoadingStatus>('idle');
function setStatus(next: AvatarImageLoadingStatus) {
status.value = next;
ctx.onImageLoadingStatusChange(next);
onLoadingStatusChange?.(next);
}
let currentImage: HTMLImageElement | null = null;
function load(nextSrc: string | undefined) {
if (currentImage) {
currentImage.onload = null;
currentImage.onerror = null;
currentImage = null;
}
if (!nextSrc) {
setStatus('error');
return;
}
if (globalThis.window === undefined) {
setStatus('loading');
return;
}
setStatus('loading');
const img = new globalThis.Image();
currentImage = img;
img.onload = () => {
if (currentImage === img) setStatus('loaded');
};
img.onerror = () => {
if (currentImage === img) setStatus('error');
};
img.src = nextSrc;
}
watch(() => src, load, { immediate: true });
onBeforeUnmount(() => {
if (currentImage) {
currentImage.onload = null;
currentImage.onerror = null;
currentImage = null;
}
});
const shouldRender = computed(() => status.value === 'loaded');
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
v-if="shouldRender"
:src="src"
:alt="alt"
/>
</template>
@@ -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');
});
});
@@ -1,202 +0,0 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The focusable, clickable day button inside a `CalendarCell`. Selects its
* `day` on click/Enter/Space, drives roving focus and full arrow-key /
* Home-End / PageUp-Down keyboard navigation (paging the month when focus
* crosses the visible range), and exposes day state through its slot.
*/
export interface CalendarCellTriggerProps extends PrimitiveProps {
/** The day this trigger represents. */
day: Date;
/** The month this trigger's cell belongs to. Defaults to grid context. */
month?: Date;
}
export interface CalendarCellTriggerSlotProps {
dayValue: string;
disabled: boolean;
selected: boolean;
today: boolean;
outsideView: boolean;
unavailable: boolean;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { computed, nextTick } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarGridContext, useCalendarRootContext } from './context';
import { addDays, addMonths, addYears, formatFullDate, isAfter, isBefore, isSameDay, isSameMonth, toIsoDate } from './utils';
const { as = 'div', day, month } = defineProps<CalendarCellTriggerProps>();
defineSlots<{
default?: (props: CalendarCellTriggerSlotProps) => unknown;
}>();
const ctx = useCalendarRootContext();
const gridCtx = useCalendarGridContext();
const { forwardRef } = useForwardExpose();
const monthValue = computed(() => month ?? gridCtx.month.value);
const isOutsideView = computed(() => !isSameMonth(day, monthValue.value));
const isDisabled = computed(() => ctx.isDateDisabled(day));
const isUnavailable = computed(() => ctx.isDateUnavailable(day));
const isSelected = computed(() => ctx.isDateSelected(day));
const isToday = computed(() => isSameDay(day, new Date()));
const dayValue = computed(() => day.getDate().toLocaleString(ctx.locale.value));
const labelText = computed(() => formatFullDate(day, ctx.locale.value));
const isFocusedDate = computed(() => {
if (isOutsideView.value || isDisabled.value) return false;
if (ctx.focusedDate.value) return isSameDay(day, ctx.focusedDate.value);
// Fallback focusable: selected, else today (if in view), else first day of month.
if (ctx.modelValue.value && isSameMonth(ctx.modelValue.value, monthValue.value))
return isSameDay(day, ctx.modelValue.value);
const today = new Date();
if (isSameMonth(today, monthValue.value))
return isSameDay(day, today);
return day.getDate() === 1 && isSameMonth(day, monthValue.value);
});
function selectIfAllowed() {
if (ctx.readonly.value) return;
if (isDisabled.value || isUnavailable.value) return;
ctx.setDate(day);
ctx.focusedDate.value = day;
}
function handleClick() {
selectIfAllowed();
}
function focusByDataValue(target: Date) {
const parent = ctx.parentElement.value;
if (!parent) return false;
const el = parent.querySelector<HTMLElement>(
`[data-primitives-calendar-cell-trigger][data-value="${toIsoDate(target)}"]:not([data-outside-view])`,
);
if (el) {
el.focus();
return true;
}
return false;
}
function shiftFocus(target: Date) {
if (ctx.minValue.value && isBefore(target, ctx.minValue.value)) return;
if (ctx.maxValue.value && isAfter(target, ctx.maxValue.value)) return;
ctx.focusedDate.value = target;
if (focusByDataValue(target)) return;
// Crossed visible range — page placeholder and retry.
if (target > ctx.placeholder.value) {
if (ctx.isNextButtonDisabled()) return;
ctx.nextPage();
}
else {
if (ctx.isPrevButtonDisabled()) return;
ctx.prevPage();
}
nextTick(() => focusByDataValue(target));
}
function handleKeyDown(e: KeyboardEvent) {
if (isDisabled.value) return;
const rtl = ctx.dir.value === 'rtl' ? -1 : 1;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
shiftFocus(addDays(day, rtl));
break;
case 'ArrowLeft':
e.preventDefault();
shiftFocus(addDays(day, -rtl));
break;
case 'ArrowUp':
e.preventDefault();
shiftFocus(addDays(day, -7));
break;
case 'ArrowDown':
e.preventDefault();
shiftFocus(addDays(day, 7));
break;
case 'Home': {
e.preventDefault();
const dow = day.getDay();
const offset = (dow - ctx.weekStartsOn.value + 7) % 7;
shiftFocus(addDays(day, -offset));
break;
}
case 'End': {
e.preventDefault();
const dow = day.getDay();
const offset = (dow - ctx.weekStartsOn.value + 7) % 7;
shiftFocus(addDays(day, 6 - offset));
break;
}
case 'PageUp':
e.preventDefault();
shiftFocus(e.shiftKey ? addYears(day, -1) : addMonths(day, -1));
break;
case 'PageDown':
e.preventDefault();
shiftFocus(e.shiftKey ? addYears(day, 1) : addMonths(day, 1));
break;
case 'Enter':
case ' ':
e.preventDefault();
selectIfAllowed();
break;
}
}
function handleFocus() {
ctx.focusedDate.value = day;
}
const dataValue = computed(() => toIsoDate(day));
const tabindex = computed(() => {
if (isFocusedDate.value) return 0;
if (isOutsideView.value || isDisabled.value) return undefined;
return -1;
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="button"
:aria-label="labelText"
:aria-disabled="(isDisabled || isUnavailable) ? true : undefined"
:aria-selected="isSelected ? true : undefined"
:tabindex="tabindex"
:data-primitives-calendar-cell-trigger="''"
:data-value="dataValue"
:data-selected="isSelected ? '' : undefined"
:data-disabled="isDisabled ? '' : undefined"
:data-unavailable="isUnavailable ? '' : undefined"
:data-outside-view="isOutsideView ? '' : undefined"
:data-today="isToday ? '' : undefined"
:data-focused="isFocusedDate ? '' : undefined"
@click="handleClick"
@focus="handleFocus"
@keydown="handleKeyDown"
>
<slot
:day-value="dayValue"
:disabled="isDisabled"
:selected="isSelected"
:today="isToday"
:outside-view="isOutsideView"
:unavailable="isUnavailable"
>
{{ dayValue }}
</slot>
</Primitive>
</template>
@@ -1,21 +0,0 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
/**
* The grid's `<thead>` wrapper holding the row of weekday `CalendarHeadCell`
* labels.
*/
export interface CalendarGridHeadProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'thead' } = defineProps<CalendarGridHeadProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-head="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,335 @@
<script lang="ts">
import type { AngleDialDirection, AngleDialSnap, AngleDialWrap } from './context';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* An accessible circular angle / rotation picker. The root owns the angle value
* in DEGREES (controlled via `v-model:value` or uncontrolled via
* `defaultValue`), converts pointer presses anywhere on the dial into an angle,
* snaps to `snap` / `step`, and handles the `0` / `360` seam either by wrapping
* continuously (`wrap`) or bounding to an arc (`clamp`). It provides context to
* `AngleDialThumb`, which renders the `role="slider"` handle on the ring and
* owns keyboard interaction.
*
* The angle convention is fixed: `0°` points UP (12 o'clock) and increases
* CLOCKWISE (right = 90°, down = 180°, left = 270°). Reach for it for rotation,
* heading, or hue (a hue ring is `min: 0, max: 360, wrap: 'wrap'`).
*/
export interface AngleDialRootProps extends PrimitiveProps {
/** Min angle in degrees. @default 0 */
min?: number;
/** Max angle in degrees. @default 360 */
max?: number;
/** Step granularity in degrees. @default 1 */
step?: number;
/** Increment for Page keys / Shift+Arrow, in degrees. @default 15 */
largeStep?: number;
/**
* Seam behavior at the bounds. `'wrap'` lets the value cross `0` / `360`
* continuously; `'clamp'` bounds it to the arc `[min, max]`.
* @default 'wrap'
*/
wrap?: AngleDialWrap;
/**
* Snap increments in degrees — a scalar (e.g. `15`) or an explicit list
* (e.g. `[0, 45, 90, 135, 180, 225, 270, 315]`). `undefined` disables
* snapping (only `step` rounding applies).
* @default undefined
*/
snap?: AngleDialSnap;
/** Disable all interaction. @default false */
disabled?: boolean;
/**
* Writing direction. When omitted it is inherited from the nearest
* `ConfigProvider` (falling back to `'ltr'`); an explicit value wins.
*/
dir?: AngleDialDirection;
/** Uncontrolled initial angle in degrees. @default 0 */
defaultValue?: number;
}
export interface AngleDialRootEmits {
/** Emitted when a drag settles (pointerup), with the final angle in degrees. */
valueCommit: [value: number];
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { provideAngleDialContext } from './context';
import { useDirection } from '../../utilities/config-provider';
import { useForwardExpose } from '@robonen/vue';
import { usePointerDrag } from '../../internal/pointer-drag';
import { clamp } from '@robonen/stdlib';
import {
applySnap,
getStepDecimals,
pointToAngle,
roundToStep,
shortestDelta,
} from './utils';
const {
min = 0,
max = 360,
step = 1,
largeStep = 15,
wrap = 'wrap',
snap,
disabled = false,
dir,
defaultValue = 0,
as = 'div',
} = defineProps<AngleDialRootProps>();
const emit = defineEmits<AngleDialRootEmits>();
const direction = useDirection(() => dir);
// `defineModel('value')` drives controlled (`v-model:value`) and uncontrolled
// modes; in uncontrolled mode it is `undefined` until first write, so the
// internal `localValue` seeds from `defaultValue`. `null` is tolerated and
// treated like "no value" for parity with controllers that reset by binding it.
const model = defineModel<number | null>('value');
const seed = typeof model.value === 'number' ? model.value : defaultValue;
const localValue = shallowRef<number>(seed);
// Cache decimals per `step` out of the pointermove hot path.
let stepDecimals = getStepDecimals(step);
watch(() => step, (s) => {
stepDecimals = getStepDecimals(s);
});
watch(model, (v) => {
if (v === null || v === undefined) return;
if (v === localValue.value) return;
localValue.value = v;
});
const value = computed<number>({
get: () => localValue.value,
set: (v) => {
if (v === localValue.value) return;
localValue.value = v;
// `defineModel` emits `update:value` on write — no manual emit needed.
model.value = v;
},
});
/**
* Normalize a raw angle (degrees) into the committed value, applying snap then
* step rounding then the wrap/clamp seam policy.
*
* In `wrap` mode the result is folded back into `[min, max)` (a full turn); in
* `clamp` mode it is bounded to `[min, max]`.
*/
function resolve(raw: number): number {
const isWrap = wrap === 'wrap';
let v = applySnap(raw, snap, isWrap);
v = roundToStep(v, step, min, stepDecimals);
if (isWrap) {
const span = max - min;
if (span <= 0) return min;
// Fold into [min, max): a 360-span dial returns [0, 360).
const folded = ((v - min) % span + span) % span + min;
return folded;
}
return clamp(v, min, max);
}
function commit(): void {
emit('valueCommit', localValue.value);
}
// ── pointer → angle ───────────────────────────────────────────────────────
const rootRef = shallowRef<HTMLElement | null>(null);
/** Center + radius of the dial from its current rect; `radius === 0` when unlaid-out. */
function geometry(): { cx: number; cy: number; radius: number } | null {
const el = rootRef.value;
if (!el) return null;
const rect = el.getBoundingClientRect();
// Guard size===0 (mirror SliderRoot's `if (size===0) return min`): an
// unlaid-out dial has no meaningful center, so the caller keeps the value.
const size = Math.min(rect.width, rect.height);
if (size === 0) return null;
return {
cx: rect.left + rect.width / 2,
cy: rect.top + rect.height / 2,
radius: size / 2,
};
}
// Accumulator state for `wrap`-mode seam continuity. `lastRaw` is the previous
// frame's raw pointer angle; `accum` is the unwrapped running angle. Tracking
// the signed shortest delta per frame means dragging across the seam (e.g.
// 350° → 10°) accumulates +20° rather than snapping 340°.
let lastRaw = 0;
let accum = 0;
/**
* Convert a client point to an angle and commit it. In `wrap` mode the value is
* accumulated via the shortest per-frame delta so the seam is crossed smoothly;
* a pointer exactly at the center (radius 0 from the center) is ignored.
*/
function applyPointer(
point: { x: number; y: number },
first: boolean,
geo?: { cx: number; cy: number; radius: number } | null,
): void {
if (disabled) return;
// Use the gesture-cached geometry when given (the dial cannot move/resize
// mid-drag), else measure live. Caching avoids a getBoundingClientRect reflow
// on every onMove frame.
const g = geo ?? geometry();
if (!g) return;
const dx = point.x - g.cx;
const dy = point.y - g.cy;
// Pointer exactly at center → no defined angle; ignore (keep current value).
if (dx === 0 && dy === 0) return;
const raw = pointToAngle(point, { x: g.cx, y: g.cy });
if (wrap === 'wrap') {
if (first) {
// Seed the accumulator from the current value, re-based so the first
// frame's raw angle maps to it without a jump.
accum = raw;
lastRaw = raw;
}
else {
accum += shortestDelta(lastRaw, raw);
lastRaw = raw;
}
value.value = resolve(accum);
}
else {
// Clamp mode: the raw angle is a linear coordinate; snapping/rounding then
// clamp to the arc. No accumulation — pushing past an end simply clamps.
value.value = resolve(raw);
}
}
// Center + radius snapshotted at gesture start and reused each frame.
let gestureGeo: { cx: number; cy: number; radius: number } | null = null;
usePointerDrag(rootRef, {
threshold: 0,
disabled: () => disabled,
// Engage immediately on press: position the value to the press point.
onStart: (state) => {
gestureGeo = geometry();
applyPointer(state.point, true, gestureGeo);
},
onMove: (state) => {
applyPointer(state.point, false, gestureGeo);
},
onCommit: () => {
commit();
},
onEnd: () => {
gestureGeo = null;
},
});
// ── keyboard nudge (delegated from the thumb) ──────────────────────────────
function nudge(delta: number): void {
if (disabled) return;
const current = localValue.value;
// Walk from the current value; `resolve` re-folds (wrap) or clamps (clamp).
// Near the seam, wrap mode keeps moving in one direction before re-folding.
let next = resolve(current + delta);
// When `snap` is coarser than `step`, a single `delta` can resolve back to the
// current snap point — which would freeze the keyboard. Keep advancing in the
// delta direction until the resolved value actually changes (or we cover a
// full turn and conclude there's nowhere else to land, e.g. a clamped end).
if (next === current && delta !== 0) {
const span = max - min || 360;
const maxSteps = Math.ceil(span / Math.max(Math.abs(delta), 1)) + 2;
let probe = current;
for (let i = 0; i < maxSteps; i++) {
probe += delta;
const candidate = resolve(probe);
if (candidate !== current) {
next = candidate;
break;
}
}
}
if (next === current) return;
value.value = next;
commit();
}
function setValue(deg: number): void {
if (disabled) return;
value.value = resolve(deg);
commit();
}
function toStart(): void {
if (disabled) return;
value.value = resolve(min);
commit();
}
function toEnd(): void {
if (disabled) return;
// In wrap mode `max` folds back to `min`; land just before the seam so End is
// distinguishable from Home. In clamp mode End is exactly `max`.
if (wrap === 'wrap') {
const span = max - min;
const justBefore = span <= 0 ? min : max - step;
value.value = resolve(justBefore);
}
else {
value.value = clamp(roundToStep(max, step, min, stepDecimals), min, max);
}
commit();
}
// Keep the value valid if bounds change.
watch([() => min, () => max, () => wrap], () => {
const next = resolve(localValue.value);
if (next !== localValue.value) value.value = next;
});
provideAngleDialContext({
value,
min: toRef(() => min),
max: toRef(() => max),
step: toRef(() => step),
largeStep: toRef(() => largeStep),
wrap: toRef(() => wrap),
snap: toRef(() => snap),
disabled: toRef(() => disabled),
direction,
setValue,
nudge,
toStart,
toEnd,
});
defineExpose({ value });
// `useForwardExpose` runs AFTER `defineExpose` so the composable merges the
// prior expose bindings (plus props + `$el`) instead of clobbering them.
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (el) => {
rootRef.value = el ?? null;
}, { immediate: true });
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
:dir="direction"
>
<slot :value="value" />
</Primitive>
</template>
@@ -0,0 +1,132 @@
<script lang="ts">
import type { AngleDialValueText } from './context';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The draggable handle of an `AngleDialRoot`, rendered as `role="slider"` and
* positioned on the ring by the current angle. It owns keyboard interaction
* (Arrow keys step by `step`, Shift+Arrow / Page keys by `largeStep`, Home / End
* jump to the bounds) and carries the ARIA value attributes.
*
* It INTENTIONALLY omits `aria-orientation`: a radial control has no single
* axis, so claiming `horizontal` / `vertical` would be misleading. Because a
* bare number is ambiguous on a circle, `aria-valuetext` strongly defaults to
* `` `${Math.round(deg)}°` `` (override via `valueText`). Give the thumb an
* `aria-label` (defaults to `'Angle'`). Exposes `value` and `point` (its
* fractional `{ x, y }` position on a unit circle, with `0.5,0.5` at center) as
* slot props for positioning.
*/
export interface AngleDialThumbProps extends PrimitiveProps {
/**
* Formatter producing a human-friendly `aria-valuetext` from the angle in
* degrees. Defaults to `` (deg) => `${Math.round(deg)}°` `` — a degree suffix
* disambiguates the bare number on a circular control. Return `undefined` to
* omit `aria-valuetext`.
* @default (deg) => `${Math.round(deg)}°`
*/
valueText?: AngleDialValueText;
}
</script>
<script setup lang="ts">
import { computed, useAttrs } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useAngleDialContext } from './context';
import { angleToPoint } from './utils';
// Module-shareable unit-circle center; `angleToPoint` only reads `center.x` /
// `center.y`, so a single frozen const avoids re-allocating the literal on every
// drag-frame recompute of `point`.
const CENTER = Object.freeze({ x: 0.5, y: 0.5 });
const { as = 'span', valueText = (deg: number) => `${Math.round(deg)}°` } = defineProps<AngleDialThumbProps>();
const ctx = useAngleDialContext();
const attrs = useAttrs();
const value = computed(() => ctx.value.value);
// Fractional position on a unit circle for CSS placement: x/y in [0, 1] with the
// center at (0.5, 0.5). Consumers typically render `left: x*100%, top: y*100%`
// on a thumb sized so its center lands on the ring.
const point = computed<{ x: number; y: number }>(() => angleToPoint(value.value, 0.5, CENTER));
const positionStyle = computed<{ left: string; top: string }>(() => ({
left: `${point.value.x * 100}%`,
top: `${point.value.y * 100}%`,
}));
// Fall back to 'Angle' only when the consumer has not supplied an explicit
// `aria-label` / `aria-labelledby`.
const accessibleLabel = computed<string | undefined>(() => {
const hasLabel = attrs['aria-label'] !== undefined && attrs['aria-label'] !== null;
const hasLabelledBy = attrs['aria-labelledby'] !== undefined && attrs['aria-labelledby'] !== null;
if (hasLabel || hasLabelledBy) return undefined;
return 'Angle';
});
// Humanised `aria-valuetext`; a consumer-supplied `aria-valuetext` attr wins
// (it falls through via `$attrs`).
const valueTextAttr = computed<string | undefined>(() => {
if (attrs['aria-valuetext'] !== undefined && attrs['aria-valuetext'] !== null) return undefined;
return valueText(value.value);
});
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value) return;
const step = ctx.step.value;
const big = ctx.largeStep.value;
const unit = event.shiftKey ? big : step;
switch (event.key) {
case 'ArrowRight':
case 'ArrowUp':
event.preventDefault();
ctx.nudge(unit);
return;
case 'ArrowLeft':
case 'ArrowDown':
event.preventDefault();
ctx.nudge(-unit);
return;
case 'PageUp':
event.preventDefault();
ctx.nudge(big);
return;
case 'PageDown':
event.preventDefault();
ctx.nudge(-big);
return;
case 'Home':
event.preventDefault();
ctx.toStart();
return;
case 'End':
event.preventDefault();
ctx.toEnd();
break;
default:
}
}
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:as="as"
:ref="forwardRef"
role="slider"
:tabindex="ctx.disabled.value ? -1 : 0"
:aria-label="accessibleLabel"
:aria-valuemin="ctx.min.value"
:aria-valuemax="ctx.max.value"
:aria-valuenow="value"
:aria-valuetext="valueTextAttr"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:style="positionStyle"
@keydown="onKeyDown"
>
<slot :value="value" :point="point" />
</Primitive>
</template>
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(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<string, unknown> = {}) {
const model = ref<number | undefined>(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<HTMLElement>('[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 "<n>°"', 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<HTMLElement>('[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<number | undefined>(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<HTMLElement>('[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%)');
});
});
@@ -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<T>` 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<number>;
min: Ref<number>;
max: Ref<number>;
step: Ref<number>;
/** Large-step increment (Page keys / Shift+Arrow), in degrees. */
largeStep: Ref<number>;
wrap: Ref<AngleDialWrap>;
snap: Ref<AngleDialSnap>;
disabled: Ref<boolean>;
direction: Ref<AngleDialDirection>;
/**
* 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>('AngleDialContext');
export const provideAngleDialContext = ctx.provide;
export const useAngleDialContext = ctx.inject;
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { AngleDialRoot, AngleDialThumb } from '@robonen/primitives';
import { ref } from 'vue';
const angle = ref(45);
</script>
<template>
<div class="demo-card flex w-full max-w-sm flex-col items-center gap-6 p-6 text-fg">
<div class="flex w-full items-baseline justify-between text-sm">
<span class="font-medium">Rotation</span>
<span class="font-mono text-fg-muted">{{ Math.round(angle) }}°</span>
</div>
<!-- The dial: a round track with the thumb on the ring (the box edge), angle
in the center. The thumb auto-positions at radius 0.5 of the box, so its
center lands exactly on the root's border ring. -->
<AngleDialRoot
v-model:value="angle"
:snap="15"
class="relative size-44 touch-none select-none rounded-full border-2 border-border-strong bg-bg-inset shadow-(--shadow-card)"
>
<!-- tick at 0° (top edge) -->
<div class="pointer-events-none absolute left-1/2 top-2 h-2.5 w-0.5 -translate-x-1/2 rounded-full bg-fg-subtle" />
<!-- center readout -->
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span class="font-mono text-2xl font-semibold tabular-nums text-fg">{{ Math.round(angle) }}°</span>
<span class="text-xs text-fg-subtle">drag the dial</span>
</div>
<AngleDialThumb
aria-label="Rotation angle"
class="absolute z-10 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-accent bg-bg shadow-md outline-none transition-[transform] focus-visible:ring-2 focus-visible:ring-ring hover:scale-110"
/>
</AngleDialRoot>
<!-- a small element rotated by the live angle -->
<div class="flex items-center gap-3 text-sm text-fg-muted">
<span>preview</span>
<span
class="grid size-12 place-items-center rounded-card border border-border-strong bg-accent text-accent-text shadow-sm transition-transform duration-75"
:style="{ transform: `rotate(${angle}deg)` }"
>
<span class="text-lg leading-none"></span>
</span>
</div>
</div>
</template>
@@ -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';
@@ -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;
}
@@ -0,0 +1,57 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The transformed content layer of a `CanvasStage`. Wraps the zoom-pan
* `ViewportContent` (one GPU-composited `transform`, `transform-origin: 0 0`) and
* renders the consumer's `<img>` / `<video>` / `<canvas>` via the default slot.
*
* When the `CanvasStageRoot` has no explicit `contentWidth`/`contentHeight`, this
* part measures its own intrinsic content size with a `ResizeObserver`
* (`useElementSize`) and reports it to the context so `fitView` / `zoomToActual`
* / `fitFill` can compute. `ResizeObserver` reports the *layout* (content-box)
* size, which is immune to the CSS `transform` the viewport applies — so the
* measured size is the true unscaled content size at any zoom, never the
* `getBoundingClientRect` post-scale geometry.
*/
export interface CanvasStageContentProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { shallowRef, watch } from 'vue';
import { useElementSize, useForwardExpose } from '@robonen/vue';
import { ViewportContent } from '../zoom-pan';
import { useCanvasStageContext } from './context';
const { as = 'div' } = defineProps<CanvasStageContentProps>();
const ctx = useCanvasStageContext();
const { forwardRef } = useForwardExpose();
// The `ViewportContent` layer is forced to the pane size (`inset: 0`), so it is
// NOT a meaningful content measurement. Instead we measure a tight inner wrapper
// (`position: absolute; top/left: 0` — it shrinks to its content) so the
// intrinsic `<img>` / `<video>` size is what's observed. `useElementSize` reads
// the content-box, which is immune to the viewport's CSS `transform`.
const measureEl = shallowRef<HTMLElement | null>(null);
const { width, height } = useElementSize(measureEl);
watch([width, height], ([w, h]) => {
if (ctx.autoMeasure.value) ctx.setMeasuredContentSize({ width: w, height: h });
}, { immediate: true });
</script>
<template>
<ViewportContent
:ref="forwardRef"
:as="as"
v-bind="{ 'data-canvas-stage-content': '' }"
>
<div
ref="measureEl"
:style="{ position: 'absolute', top: '0', left: '0' }"
v-bind="{ 'data-canvas-stage-content-box': '' }"
>
<slot />
</div>
</ViewportContent>
</template>
@@ -0,0 +1,48 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The inner clipping / measurement box of a `CanvasStage` (mirrors flow's
* `FlowPane`). It renders the zoom-pan `ViewportSurface`, so it already clips the
* content (`overflow: hidden`), positions relatively, disables native touch
* gestures (`touch-action: none`), and reports its live bounding rect into the
* zoom-pan context as the coordinate origin. Kept a *real* element (never
* `as="template"`) so `getBoundingClientRect` measures the actual clip box that
* the fit / 1:1 / fill maths key off — the `CanvasStageRoot` may itself be
* `as="template"`, which is exactly why measurement lives here and not on the
* Root. Rendered by `CanvasStageRoot`; not usually placed directly.
*/
export interface CanvasStagePaneProps extends PrimitiveProps {}
export interface CanvasStagePaneEmits {
/** Fires with the resolved pane element (or `null` on teardown) for measurement. */
pane: [el: HTMLElement | null];
}
</script>
<script setup lang="ts">
import { watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { ViewportSurface } from '../zoom-pan';
const { as = 'div' } = defineProps<CanvasStagePaneProps>();
const emit = defineEmits<CanvasStagePaneEmits>();
const { forwardRef, currentElement } = useForwardExpose();
// Surface the rendered pane element to `CanvasStageRoot` so it can drive the
// fit/actual/fill maths off the real clip box.
watch(currentElement, (el) => {
emit('pane', (el as HTMLElement | undefined) ?? null);
}, { immediate: true });
</script>
<template>
<ViewportSurface
:ref="forwardRef"
:as="as"
v-bind="{ 'data-canvas-stage-pane': '' }"
>
<slot />
</ViewportSurface>
</template>
@@ -0,0 +1,351 @@
<script lang="ts">
import type { Dimensions, Rect, Viewport, ViewportApi, XYPosition } from '../zoom-pan';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Root of a headless pan/zoom **canvas stage** — a thin photo-editing shell over
* the `zoom-pan` viewport that adds the three classic fit modes (**fit** /
* **1:1** / **fill**) on top of pan + zoom, plus automatic content-size
* measurement so those modes work without the consumer hand-feeding dimensions.
*
* It owns the master `Viewport` (two-way via `v-model:viewport`, or uncontrolled
* via `defaultViewport`), renders an internal `ViewportRoot` wired with the same
* model + zoom constraints + the resolved content extent, and builds the
* {@link CanvasStageContext} that wraps the zoom-pan {@link ViewportApi} and adds
* `fitView()` / `zoomToActual()` / `fitFill()` + the reactive content size. The
* combined api is `defineExpose`d so consumers can drive it (and wire their own
* zoom buttons) via a template ref.
*
* Carries `role="application"` (downgraded to `'group'` when keyboard a11y is
* disabled), `aria-roledescription="zoomable canvas"`, and `tabindex 0`; pass an
* `aria-label` via `$attrs`. Mount your `<img>` / `<video>` / `<canvas>` in the
* default slot — it renders inside the single transformed layer.
*/
export interface CanvasStageRootProps extends PrimitiveProps {
/** Uncontrolled initial viewport (ignored when `v-model:viewport` is bound). @default { x: 0, y: 0, zoom: 1 } */
defaultViewport?: Viewport;
/** Minimum zoom level. @default 0.1 */
minZoom?: number;
/** Maximum zoom level. @default 8 */
maxZoom?: number;
/**
* Intrinsic content width in content-space px. When omitted (with
* `contentHeight`) the content element is auto-measured. @default undefined
*/
contentWidth?: number;
/**
* Intrinsic content height in content-space px. When omitted (with
* `contentWidth`) the content element is auto-measured. @default undefined
*/
contentHeight?: number;
/** Fractional inset on each side when fitting, 01. @default 0.1 */
fitPadding?: number;
/** Fit the content into view once the pane + content are measured. @default true */
fitOnReady?: boolean;
/** Multiplicative zoom factor per keyboard zoom-in/out step. @default 1.2 */
zoomStep?: number;
/** Pixel step for arrow-key panning (Shift = ×5). @default 40 */
panStep?: number;
/** Master interactivity switch (lock). @default false */
disabled?: boolean;
/** Disable the keyboard a11y layer (downgrades `role` to `'group'`). @default false */
disableKeyboardA11y?: boolean;
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, watch } from 'vue';
import { useElementBounding, useEventListener, useForwardExpose } from '@robonen/vue';
import { ViewportRoot } from '../zoom-pan';
import CanvasStagePane from './CanvasStagePane.vue';
import CanvasStageContent from './CanvasStageContent.vue';
import { provideCanvasStageContext } from './context';
import type { CanvasStageApi, CanvasStageContext } from './context';
const {
defaultViewport = { x: 0, y: 0, zoom: 1 },
minZoom = 0.1,
maxZoom = 8,
contentWidth = undefined,
contentHeight = undefined,
fitPadding = 0.1,
fitOnReady = true,
zoomStep = 1.2,
panStep = 40,
disabled = false,
disableKeyboardA11y = false,
as = 'div',
} = defineProps<CanvasStageRootProps>();
// ── viewport model (controlled + uncontrolled), delegated to ViewportRoot ─────
// `ViewportRoot` already runs the controlled/uncontrolled `shallowRef` + watch
// dance internally; we simply pass the model straight through so a single
// canonical `Viewport` lives in the zoom-pan layer.
const model = defineModel<Viewport | undefined>('viewport');
// ── content size: explicit props win, else the measured intrinsic size ────────
const autoMeasure = computed(() => contentWidth === undefined || contentHeight === undefined);
const measuredContentSize = shallowRef<Dimensions>({ width: 0, height: 0 });
function setMeasuredContentSize(size: Dimensions): void {
measuredContentSize.value = size;
}
const contentSize = computed<Dimensions>(() => {
if (!autoMeasure.value) return { width: contentWidth!, height: contentHeight! };
return measuredContentSize.value;
});
// The fit target in content space, anchored at the origin.
const contentExtent = computed<Rect>(() => ({
x: 0,
y: 0,
width: contentSize.value.width,
height: contentSize.value.height,
}));
// ── inner ViewportRoot api + pane measurement ─────────────────────────────────
// `ViewportRoot` exposes the imperative `ViewportApi`; we capture it via a
// template ref and build the canvas fit modes on top of it. The pane element is
// the `ViewportSurface` (= `CanvasStagePane`) — its bounding rect is the screen
// origin and the source for the fit/actual/fill maths.
const viewportRef = shallowRef<(ViewportApi & { viewport: Viewport }) | null>(null);
const rootEl = shallowRef<HTMLElement | null>(null);
const paneEl = shallowRef<HTMLElement | null>(null);
function setPaneEl(el: HTMLElement | null): void {
paneEl.value = el;
}
const { width: paneWidth, height: paneHeight } = useElementBounding(paneEl);
const measured = shallowRef(false);
watch([paneWidth, paneHeight, contentSize], () => {
if (!measured.value && paneWidth.value > 0 && paneHeight.value > 0)
measured.value = true;
}, { immediate: true });
/** True once the pane has a non-zero rect — fit maths are meaningful. */
function paneReady(): boolean {
return paneWidth.value > 0 && paneHeight.value > 0;
}
/** The content-space point currently centred in the pane (content centre fallback). */
function contentCentre(): XYPosition {
const ext = contentExtent.value;
return { x: ext.x + ext.width / 2, y: ext.y + ext.height / 2 };
}
/** Centre `target` (content space) at zoom `zoom`, clamped via the zoom-pan api. */
function applyCentred(zoom: number, target: XYPosition): void {
const vp = viewportRef.value;
if (!vp) return;
vp.setViewport({
zoom,
x: paneWidth.value / 2 - target.x * zoom,
y: paneHeight.value / 2 - target.y * zoom,
});
}
// ── canvas fit modes ──────────────────────────────────────────────────────────
function fitView(): void {
const vp = viewportRef.value;
if (!vp || !paneReady()) return;
const ext = contentExtent.value;
if (ext.width === 0 || ext.height === 0) return;
// The zoom-pan `fit` is exactly the "contain" mode (min of the two ratios).
vp.fit(ext, { padding: fitPadding });
}
function zoomToActual(): void {
if (!viewportRef.value || !paneReady()) return;
// 1:1 — one content px per screen px — centred on the content centre.
applyCentred(1, contentCentre());
}
function fitFill(): void {
const ext = contentExtent.value;
if (!viewportRef.value || !paneReady() || ext.width === 0 || ext.height === 0) return;
// "Cover": the LARGER of the two ratios, so the content fills the pane with no
// letterboxing (the opposite choice to `fitView`'s `min`). Padding does not
// apply to a cover.
const zoom = Math.max(paneWidth.value / ext.width, paneHeight.value / ext.height);
applyCentred(zoom, contentCentre());
}
// ── combined api ──────────────────────────────────────────────────────────────
const api: CanvasStageApi = {
getViewport: () => viewportRef.value?.getViewport() ?? { x: 0, y: 0, zoom: 1 },
setViewport: vp => viewportRef.value?.setViewport(vp),
zoomIn: (factor = zoomStep) => viewportRef.value?.zoomIn(factor),
zoomOut: (factor = zoomStep) => viewportRef.value?.zoomOut(factor),
zoomTo: zoom => viewportRef.value?.zoomTo(zoom),
fitView,
zoomToActual,
fitFill,
center: point => viewportRef.value?.center(point),
reset: () => viewportRef.value?.reset(),
};
// Reactive viewport mirror: read straight off the inner `ViewportRoot`'s exposed
// `viewport` computed (reactive through the component instance), falling back to
// the model / default before it resolves.
const viewport = computed<Viewport>(
() => viewportRef.value?.viewport ?? model.value ?? defaultViewport,
);
const context: CanvasStageContext = {
api,
viewport,
contentSize,
contentExtent,
measured,
autoMeasure: toRef(() => autoMeasure.value),
setMeasuredContentSize,
};
provideCanvasStageContext(context);
// ── fit once on ready ─────────────────────────────────────────────────────────
// Wait until both the pane is measured AND the content extent is non-zero (auto
// mode resolves it asynchronously after the first ResizeObserver tick). Runs
// once; focus stays on the Root.
if (fitOnReady) {
const stop = watch(
[measured, contentExtent],
([m, ext]) => {
if (m && ext.width > 0 && ext.height > 0) {
fitView();
stop();
}
},
{ immediate: true },
);
}
// ── keyboard layer (canvas-specific shortcuts) ────────────────────────────────
// Bound on the focusable Root element. zoom-pan's own keyboard layer is disabled
// (`:disable-keyboard`), so CanvasStage is the single source of truth: this lets
// us honour `panStep`/`zoomStep` and map `0`/`1`/`2`/`Home` onto the canvas fit
// modes (zoom-pan's `0`/`Home` would do `zoomTo(1)` / `reset`, not
// `zoomToActual` / `fitView`). Skipped entirely when disabled or when keyboard
// a11y is off. Focus stays on the Root after a programmatic fit (we never move
// it), so the next keypress lands here.
const EDITABLE = /^(?:input|textarea|select)$/i;
function isTyping(): boolean {
const el = document.activeElement as HTMLElement | null;
return !!el && (EDITABLE.test(el.tagName) || el.isContentEditable);
}
function onKeydown(event: KeyboardEvent): void {
if (disabled || disableKeyboardA11y || isTyping()) return;
const step = event.shiftKey ? panStep * 5 : panStep;
const arrows: Record<string, [number, number]> = {
ArrowUp: [0, step],
ArrowDown: [0, -step],
ArrowLeft: [step, 0],
ArrowRight: [-step, 0],
};
const pan = arrows[event.key];
if (pan) {
const vp = api.getViewport();
api.setViewport({ zoom: vp.zoom, x: vp.x + pan[0], y: vp.y + pan[1] });
}
else if (event.key === '+' || event.key === '=') {
api.zoomIn();
}
else if (event.key === '-' || event.key === '_') {
api.zoomOut();
}
else if (event.key === '0') {
zoomToActual();
}
else if (event.key === '1') {
fitView();
}
else if (event.key === '2') {
fitFill();
}
else if (event.key === 'Home') {
api.reset();
}
else {
return; // not ours — let it bubble (e.g. to the page).
}
event.preventDefault();
}
// ── focus-visible reflection ──────────────────────────────────────────────────
const focusVisible = shallowRef(false);
function onFocus(event: FocusEvent): void {
// `:focus-visible` semantics — only reflect keyboard focus, not a pointer grab
// (which would flash a focus ring on every drag-to-pan).
const el = event.currentTarget as HTMLElement | null;
focusVisible.value = !!el?.matches?.(':focus-visible');
}
function onBlur(): void {
focusVisible.value = false;
}
// Bind the a11y listeners on the Root element once it resolves. `currentElement`
// (from `useForwardExpose`) is the rendered Root, which carries `tabindex 0`.
useEventListener(rootEl, 'keydown', onKeydown);
useEventListener(rootEl, 'focus', onFocus);
useEventListener(rootEl, 'blur', onBlur);
// `defineExpose` runs BEFORE `useForwardExpose` so the composable merges these
// bindings (plus props + `$el`) instead of `defineExpose`'s `expose()`
// clobbering them and warning "expose() should be called only once". ORDER IS
// LOAD-BEARING.
defineExpose({
getViewport: api.getViewport,
setViewport: api.setViewport,
zoomIn: api.zoomIn,
zoomOut: api.zoomOut,
zoomTo: api.zoomTo,
fitView: api.fitView,
zoomToActual: api.zoomToActual,
fitFill: api.fitFill,
center: api.center,
reset: api.reset,
contentSize,
});
const { forwardRef } = useForwardExpose();
// Pass-through attrs (`role` / `aria-*` / `data-*` / `tabindex`) the inner
// `ViewportRoot` doesn't declare as typed props are bound as one object via
// `v-bind` so they ride through `$attrs` to the DOM without tripping the strict
// per-prop type check (the codebase convention for forwarding non-prop attrs to
// a typed SFC child).
const rootAttrs = computed<Record<string, unknown>>(() => ({
role: disableKeyboardA11y ? 'group' : 'application',
'aria-roledescription': 'zoomable canvas',
tabindex: 0,
'data-canvas-stage-root': '',
'data-focus-visible': focusVisible.value ? '' : undefined,
}));
</script>
<template>
<ViewportRoot
:ref="(r: any) => { forwardRef(r); viewportRef = r; rootEl = (r?.$el ?? null) as HTMLElement | null; }"
v-model:viewport="model"
:as="as"
:default-viewport="defaultViewport"
:min-zoom="minZoom"
:max-zoom="maxZoom"
:content-extent="contentExtent"
:fit-padding="fitPadding"
:disabled="disabled"
:disable-keyboard="true"
v-bind="rootAttrs"
>
<template #surface>
<CanvasStagePane @pane="setPaneEl">
<CanvasStageContent>
<slot />
</CanvasStageContent>
</CanvasStagePane>
</template>
</ViewportRoot>
</template>
@@ -0,0 +1,77 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* An accessible zoom-level announcer for a `CanvasStage`. Renders a
* `VisuallyHidden` `aria-live="polite"` / `aria-atomic` region that announces the
* current zoom percentage to screen readers. To avoid flooding the live region
* during a pinch / wheel gesture it announces on *settle* — the value is
* debounced, so a burst of per-frame zoom changes collapses into a single
* announcement once motion stops.
*
* The default slot receives the live `{ zoom, percent }` so consumers can ALSO
* render a visible indicator (e.g. a "120 %" badge) with the same value.
*/
export interface CanvasStageZoomIndicatorProps extends PrimitiveProps {
/**
* Build the announced/visible string from the rounded zoom percentage.
* @default (percent) => `${percent}%`
*/
format?: (percent: number) => string;
/** Debounce before announcing, in ms (the "settle" window). @default 200 */
settleDelay?: number;
}
</script>
<script setup lang="ts">
import { shallowRef, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { VisuallyHidden } from '../../utilities/visually-hidden';
import { useCanvasStageContext } from './context';
const {
as = 'div',
format = (percent: number) => `${percent}%`,
settleDelay = 200,
} = defineProps<CanvasStageZoomIndicatorProps>();
const ctx = useCanvasStageContext();
const { forwardRef } = useForwardExpose();
/** Live zoom percentage (rounded), updated every frame for the visible slot. */
const percent = shallowRef(Math.round(ctx.viewport.value.zoom * 100));
/** Debounced announcement — only written once zoom settles. */
const announced = shallowRef('');
let timer: ReturnType<typeof setTimeout> | null = null;
watch(
() => ctx.viewport.value.zoom,
(zoom) => {
percent.value = Math.round(zoom * 100);
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
announced.value = format(percent.value);
timer = null;
}, settleDelay);
},
{ immediate: true },
);
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
data-canvas-stage-zoom-indicator=""
>
<slot
:zoom="ctx.viewport.value.zoom"
:percent="percent"
/>
<VisuallyHidden v-bind="{ 'aria-live': 'polite', 'aria-atomic': 'true' }">
{{ announced }}
</VisuallyHidden>
</Primitive>
</template>
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
/** Wait for `n` real animation frames so layout + ResizeObserver settle. */
function raf(n = 1): Promise<void> {
return new Promise((resolve) => {
let i = 0;
const step = (): void => {
if (++i >= n) resolve();
else requestAnimationFrame(step);
};
requestAnimationFrame(step);
});
}
function mountStage(props: Record<string, unknown> = {}) {
const exposed = ref<any>(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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<any>(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<HTMLElement>('[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();
});
});
@@ -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<Viewport>;
/**
* 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<Dimensions>;
/** The content extent `{ x: 0, y: 0, width, height }` used by the fit modes. */
contentExtent: Ref<Rect>;
/** False until the pane has reported its first non-zero rect. */
measured: Ref<boolean>;
/** Whether the content element should be auto-measured (no explicit size props). */
autoMeasure: Ref<boolean>;
/** `CanvasStageContent` reports its measured intrinsic size here. */
setMeasuredContentSize: (size: Dimensions) => void;
}
const context = useContextFactory<CanvasStageContext>('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;
@@ -0,0 +1,140 @@
<script setup lang="ts">
import {
CanvasStageContent,
CanvasStagePane,
CanvasStageRoot,
CanvasStageZoomIndicator,
} from '@robonen/primitives';
import { useTemplateRef } from 'vue';
const stage = useTemplateRef<InstanceType<typeof CanvasStageRoot>>('stage');
</script>
<template>
<div class="demo-card w-full max-w-2xl space-y-4 p-6 text-fg">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">Canvas stage</span>
</div>
<CanvasStageRoot
ref="stage"
aria-label="Zoomable photo"
:min-zoom="0.2"
:max-zoom="6"
class="stage relative h-80 w-full overflow-hidden rounded-card border border-border bg-bg-inset shadow-(--shadow-card) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<CanvasStagePane class="size-full">
<CanvasStageContent>
<!-- A CSS-gradient "photo" with an intrinsic size so fit modes work. -->
<div class="stage-photo">
<div class="stage-photo__sun" />
<div class="stage-photo__grid" />
<span class="stage-photo__caption">1200 × 800</span>
</div>
</CanvasStageContent>
</CanvasStagePane>
<!-- Visible zoom badge driven by the same indicator value. -->
<CanvasStageZoomIndicator class="stage-badge">
<template #default="{ percent }">{{ percent }}%</template>
</CanvasStageZoomIndicator>
</CanvasStageRoot>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="stage-btn" @click="stage?.zoomIn()">Zoom in</button>
<button type="button" class="stage-btn" @click="stage?.zoomOut()">Zoom out</button>
<button type="button" class="stage-btn" @click="stage?.fitView()">Fit</button>
<button type="button" class="stage-btn" @click="stage?.fitFill()">Fill</button>
<button type="button" class="stage-btn" @click="stage?.zoomToActual()">1:1</button>
<button type="button" class="stage-btn" @click="stage?.reset()">Reset</button>
</div>
<p class="text-xs text-fg-subtle">
Drag to pan, scroll to zoom, or focus the stage and use Arrow keys, +/-, and 0 / 1 / 2 for 1:1 / fit / fill.
</p>
</div>
</template>
<style scoped>
.stage {
cursor: grab;
touch-action: none;
}
.stage:active {
cursor: grabbing;
}
/* Intrinsic-sized gradient "photo". */
.stage-photo {
position: relative;
width: 1200px;
height: 800px;
overflow: hidden;
background:
radial-gradient(circle at 28% 26%, #fde68a 0%, transparent 30%),
linear-gradient(160deg, #6d28d9 0%, #db2777 45%, #f97316 100%);
}
.stage-photo__sun {
position: absolute;
top: 120px;
left: 240px;
width: 220px;
height: 220px;
border-radius: 9999px;
background: radial-gradient(circle, #fffbeb 0%, #fde047 55%, transparent 72%);
filter: blur(2px);
}
.stage-photo__grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgb(255 255 255 / 0.08) 1px, transparent 1px),
linear-gradient(to bottom, rgb(255 255 255 / 0.08) 1px, transparent 1px);
background-size: 80px 80px;
}
.stage-photo__caption {
position: absolute;
bottom: 24px;
right: 28px;
font-family: var(--font-mono);
font-size: 28px;
font-weight: 600;
color: rgb(255 255 255 / 0.85);
letter-spacing: 0.05em;
}
.stage-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
padding: 2px 8px;
border-radius: 9999px;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
color: var(--fg);
background: color-mix(in oklch, var(--bg) 80%, transparent);
border: 1px solid var(--border);
backdrop-filter: blur(4px);
}
.stage-btn {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
color: var(--fg);
background: var(--bg-elevated);
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
.stage-btn:hover {
background: var(--bg-inset);
border-color: var(--border-strong);
}
.stage-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
</style>
@@ -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';
@@ -0,0 +1,78 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The revealed ("after") layer, drawn on top of `CompareSliderBefore` and
* clipped via `clip-path: inset(...)` so that exactly `position`% of it is
* shown. The clip side is driven by the root's combined `flip` flag (so the
* revealed region always sits on the same side as the divider). At `0` / `100`
* the layer is fully hidden / shown with no sub-pixel sliver. Rendered inside
* `CompareSliderRoot` and absolutely positioned to fill it (`inset: 0`); put the
* comparison image / view here.
*/
export interface CompareSliderAfterProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useCompareSliderContext } from './context';
import { clamp } from '@robonen/stdlib';
const { as = 'div' } = defineProps<CompareSliderAfterProps>();
const ctx = useCompareSliderContext();
const { forwardRef } = useForwardExpose();
// `clip-path: inset(top right bottom left)`. Reveal exactly `position`% measured
// from the start edge; the OTHER edge is clipped by `100 - position`%. The
// `flip` flag selects which edge is the start, matching the divider side. Clamp
// to [0, 100] so 0/100 hide/show fully with no 0.5px sliver.
const clipPath = computed<string>(() => {
const p = clamp(ctx.position.value, 0, 100);
const hidden = 100 - p;
const horizontal = ctx.orientation.value === 'horizontal';
const flip = ctx.flip.value;
if (horizontal) {
// No flip → reveal the left portion (clip the right edge).
// Flip → reveal the right portion (clip the left edge).
return flip
? `inset(0% 0% 0% ${hidden}%)`
: `inset(0% ${hidden}% 0% 0%)`;
}
// No flip → reveal the top portion (clip the bottom edge).
// Flip → reveal the bottom portion (clip the top edge).
return flip
? `inset(${hidden}% 0% 0% 0%)`
: `inset(0% 0% ${hidden}% 0%)`;
});
// Stable shape: same keys in the same order for a monomorphic style object.
const style = computed<{
position: string;
top: string;
right: string;
bottom: string;
left: string;
clipPath: string;
}>(() => ({
position: 'absolute',
top: '0',
right: '0',
bottom: '0',
left: '0',
clipPath: clipPath.value,
}));
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:style="style"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
>
<slot :position="ctx.position.value" />
</Primitive>
</template>
@@ -0,0 +1,42 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The base ("before") layer of the comparison — the full, unclipped content
* that sits underneath. Rendered inside `CompareSliderRoot` and absolutely
* positioned to fill it (`inset: 0`). Put the original image / view here; the
* `CompareSliderAfter` layer is drawn on top and clipped to reveal it.
*/
export interface CompareSliderBeforeProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useCompareSliderContext } from './context';
const { as = 'div' } = defineProps<CompareSliderBeforeProps>();
const ctx = useCompareSliderContext();
const { forwardRef } = useForwardExpose();
// Stable shape: same keys in the same order for a monomorphic style object.
const style = {
position: 'absolute',
top: '0',
right: '0',
bottom: '0',
left: '0',
} as const;
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:style="style"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,70 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A thin presentational line marking the split between the before and after
* layers, positioned at the current reveal position. It is purely decorative
* (no role, no keyboard) — the focusable, screen-reader-accessible target is
* `CompareSliderHandle`.
*
* The recommended two-element pattern is a thin `CompareSliderDivider` line for
* the visible seam plus a larger `CompareSliderHandle` "puck" for the grab /
* focus target sitting on top of it (give the handle a wider hit-area via CSS).
* Render the divider as a sibling or wrapper of the handle inside the root.
*/
export interface CompareSliderDividerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useCompareSliderContext } from './context';
const { as = 'span' } = defineProps<CompareSliderDividerProps>();
const ctx = useCompareSliderContext();
const { forwardRef } = useForwardExpose();
// Sit on the divider. Stable shape: same keys in the same order; the `flip` flag
// selects the positioning edge so the line tracks the after-layer clip side.
const style = computed<{
position: string;
left: string | undefined;
right: string | undefined;
top: string | undefined;
bottom: string | undefined;
}>(() => {
const pct = `${ctx.position.value}%`;
const horizontal = ctx.orientation.value === 'horizontal';
const flip = ctx.flip.value;
if (horizontal) {
return {
position: 'absolute',
left: flip ? undefined : pct,
right: flip ? pct : undefined,
top: undefined,
bottom: undefined,
};
}
return {
position: 'absolute',
left: undefined,
right: undefined,
top: flip ? undefined : pct,
bottom: flip ? pct : undefined,
};
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
aria-hidden="true"
:style="style"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
>
<slot :position="ctx.position.value" />
</Primitive>
</template>
@@ -0,0 +1,153 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The keyboard- and screen-reader-accessible divider handle, rendered as
* `role="slider"` with full ARIA value attributes (`aria-valuemin=0`,
* `aria-valuemax=100`, `aria-valuenow=position`). It positions itself at the
* divider and handles keyboard interaction: Arrow keys move the divider toward
* the after/before layer by `keyboardStep` (orientation- and direction-aware),
* Shift+Arrow and Page keys by `keyboardLargeStep`, and Home/End jump to 0/100.
* This is the hit-target / focus element; pair it with a thin presentational
* `CompareSliderDivider` for the visible line. Exposes `position` as a slot prop.
*/
export interface CompareSliderHandleProps extends PrimitiveProps {
/**
* Optional formatter producing this handle's `aria-valuetext`. Overrides the
* root-level `valueText` for this handle when provided. Receives the reveal
* position (0100).
*/
valueText?: (position: number) => string | undefined;
// `aria-label` (and other ARIA attributes) fall through to the rendered
// `role="slider"` element; a default of 'Comparison position' is applied when
// none is supplied.
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed, useAttrs } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useCompareSliderContext } from './context';
const { as = 'span', valueText } = defineProps<CompareSliderHandleProps>();
const ctx = useCompareSliderContext();
const attrs = useAttrs();
const { forwardRef } = useForwardExpose();
// Position the handle at the divider. Stable shape: same keys in the same order
// for a monomorphic style object; unused sides are explicit `undefined`. The
// `flip` flag selects the positioning edge, matching the after-layer clip side.
const style = computed<{
position: string;
left: string | undefined;
right: string | undefined;
top: string | undefined;
bottom: string | undefined;
}>(() => {
const pct = `${ctx.position.value}%`;
const horizontal = ctx.orientation.value === 'horizontal';
const flip = ctx.flip.value;
if (horizontal) {
return {
position: 'absolute',
left: flip ? undefined : pct,
right: flip ? pct : undefined,
top: undefined,
bottom: undefined,
};
}
return {
position: 'absolute',
left: undefined,
right: undefined,
top: flip ? undefined : pct,
bottom: flip ? pct : undefined,
};
});
// Fall back to a generic accessible name when the consumer supplies none.
const accessibleLabel = computed<string | undefined>(() => {
const hasLabel = attrs['aria-label'] !== undefined && attrs['aria-label'] !== null;
const hasLabelledBy = attrs['aria-labelledby'] !== undefined && attrs['aria-labelledby'] !== null;
if (hasLabel || hasLabelledBy) return undefined;
return 'Comparison position';
});
// Humanised `aria-valuetext`: the per-handle `valueText` prop wins, then the
// root-level formatter; a consumer-supplied `aria-valuetext` attr wins over both.
const valueTextStr = computed<string | undefined>(() => {
if (attrs['aria-valuetext'] !== undefined && attrs['aria-valuetext'] !== null) return undefined;
const fmt = valueText ?? ctx.valueText.value;
return fmt ? fmt(ctx.position.value) : undefined;
});
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value) return;
const horizontal = ctx.orientation.value === 'horizontal';
const flip = ctx.flip.value;
const big = event.shiftKey ? ctx.keyboardLargeStep.value : ctx.keyboardStep.value;
let delta: number;
switch (event.key) {
case 'ArrowRight':
// Toward the after layer (increase) unless flipped.
delta = horizontal ? (flip ? -big : big) : 0;
break;
case 'ArrowLeft':
delta = horizontal ? (flip ? big : -big) : 0;
break;
case 'ArrowUp':
// Vertical no-flip reveals the top region; ArrowUp shrinks it (decrease).
delta = horizontal ? 0 : (flip ? big : -big);
break;
case 'ArrowDown':
delta = horizontal ? 0 : (flip ? -big : big);
break;
case 'PageUp':
delta = horizontal
? ctx.keyboardLargeStep.value
: (flip ? ctx.keyboardLargeStep.value : -ctx.keyboardLargeStep.value);
break;
case 'PageDown':
delta = horizontal
? -ctx.keyboardLargeStep.value
: (flip ? -ctx.keyboardLargeStep.value : ctx.keyboardLargeStep.value);
break;
case 'Home':
event.preventDefault();
ctx.setPosition(0);
return;
case 'End':
event.preventDefault();
ctx.setPosition(100);
return;
default:
return;
}
if (delta === 0) return;
event.preventDefault();
ctx.step(delta);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="slider"
:tabindex="ctx.disabled.value ? -1 : 0"
:aria-label="accessibleLabel"
:aria-valuemin="0"
:aria-valuemax="100"
:aria-valuenow="ctx.position.value"
:aria-valuetext="valueTextStr"
:aria-orientation="ctx.orientation.value"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
:style="style"
@keydown="onKeyDown"
>
<slot :position="ctx.position.value" />
</Primitive>
</template>
@@ -0,0 +1,214 @@
<script lang="ts">
import type { CompareSliderDirection, CompareSliderOrientation, CompareSliderValueText } from './context';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A before/after split-reveal slider: two stacked layers (a base
* `CompareSliderBefore` and a clipped `CompareSliderAfter`) with a draggable
* divider that reveals exactly `position`% of the after-layer. The root owns the
* reveal position (controlled via `v-model:position` or uncontrolled via
* `defaultPosition`), clamps it to 0100, and starts a pointer drag on press —
* mapping the pointer's position over the root's box onto the reveal
* percentage. When `hover` is set the divider follows the pointer on hover
* (no press needed). It provides context to `CompareSliderBefore`,
* `CompareSliderAfter`, `CompareSliderHandle`, and `CompareSliderDivider`, and
* supports horizontal/vertical `orientation` plus `dir`/`inverted` direction.
* Reach for it to compare two images, designs, or any two overlaid views.
*/
export interface CompareSliderRootProps extends PrimitiveProps {
/** Orientation. @default 'horizontal' */
orientation?: CompareSliderOrientation;
/** Uncontrolled initial reveal position (0100). @default 50 */
defaultPosition?: number;
/** Disable all interaction. @default false */
disabled?: boolean;
/** Invert the direction of interaction (and the revealed side). @default false */
inverted?: boolean;
/**
* Writing direction. When omitted it is inherited from the nearest
* `ConfigProvider` (falling back to `'ltr'`); an explicit value wins.
*/
dir?: CompareSliderDirection;
/** Position change per Arrow key press. @default 1 */
keyboardStep?: number;
/** Position change per Shift+Arrow / Page key press. @default 10 */
keyboardLargeStep?: number;
/** When true the divider follows the pointer on hover, without a press. @default false */
hover?: boolean;
/**
* Optional formatter producing a human-friendly `aria-valuetext` for the
* handle. Receives the reveal position (0100).
*/
valueText?: CompareSliderValueText;
}
</script>
<script setup lang="ts">
import { computed, ref, toRef, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { provideCompareSliderContext } from './context';
import { useDirection } from '../../utilities/config-provider';
import { usePointerDrag } from '../../internal/pointer-drag';
import { useEventListener, useForwardExpose } from '@robonen/vue';
import { clamp } from '@robonen/stdlib';
const {
orientation = 'horizontal',
defaultPosition = 50,
disabled = false,
inverted = false,
dir,
keyboardStep = 1,
keyboardLargeStep = 10,
hover = false,
valueText,
as = 'div',
} = defineProps<CompareSliderRootProps>();
// Resolve direction: explicit `dir` prop wins, otherwise inherit from the
// nearest `ConfigProvider` (falls back to `'ltr'`).
const direction = useDirection(() => dir);
// `defineModel` drives both controlled (`v-model:position`) and uncontrolled
// modes; in uncontrolled mode `model.value` is `undefined` until first write,
// so `localPosition` below seeds from `defaultPosition`.
const model = defineModel<number>('position');
const localPosition = ref<number>(clamp(model.value ?? defaultPosition, 0, 100));
watch(model, (v) => {
if (v === undefined || v === null) return;
const next = clamp(v, 0, 100);
if (next !== localPosition.value) localPosition.value = next;
});
const position = computed<number>({
get: () => localPosition.value,
set: (v) => {
const next = clamp(v, 0, 100);
if (next === localPosition.value) return;
localPosition.value = next;
// `defineModel` emits `update:position` on write — no manual emit needed.
model.value = next;
},
});
// Combined flip flag — the SAME flag drives BOTH the pointer-offset mapping and
// the after-layer clip side (see context.ts). For horizontal, rtl and inverted
// each flip the start edge; for vertical only `inverted` flips.
const flip = computed<boolean>(() =>
orientation === 'horizontal'
? (direction.value === 'rtl') !== inverted
: inverted,
);
// `defineExpose` MUST precede `useForwardExpose` (else Vue warns "expose()
// called only once" and clobbers the forwarded `$el`/props). `currentElement`
// is consumed at top-level setup below (drag/hover/context), so `defineExpose`
// is hoisted up to here — its dep `position` is already declared above.
defineExpose({ position });
const { forwardRef, currentElement } = useForwardExpose();
// Map a client point over the root's box onto the reveal percentage (0100).
// Guard size === 0 → return 0 (mirror SliderRoot's `getValueFromPointer`).
function positionFromClient(clientX: number, clientY: number, rect?: DOMRect): number {
// During a drag the caller passes the rect snapshotted at onStart (the box
// cannot change mid-drag); hover passes nothing and measures live.
const r = rect ?? currentElement.value?.getBoundingClientRect();
if (!r) return 0;
const horizontal = orientation === 'horizontal';
const size = horizontal ? r.width : r.height;
if (size === 0) return 0;
let offset = horizontal ? clientX - r.left : clientY - r.top;
// The SAME flip flag drives the after-layer clip side, so the divider and the
// revealed region stay in sync.
if (flip.value) offset = size - offset;
return clamp((offset / size) * 100, 0, 100);
}
// Rect snapshotted for the duration of a drag — removes a getBoundingClientRect
// reflow on every onMove frame.
let gestureRect: DOMRect | undefined;
// Drag the divider. `threshold: 0` so a single press jumps to the pointer and
// drags from there (no dead zone). `state.point` is the live client point.
const drag = usePointerDrag(currentElement, {
threshold: 0,
disabled: () => disabled,
preventDefault: true,
onStart: (state) => {
gestureRect = currentElement.value?.getBoundingClientRect();
position.value = positionFromClient(state.point.x, state.point.y, gestureRect);
},
onMove: (state) => {
position.value = positionFromClient(state.point.x, state.point.y, gestureRect);
},
onEnd: () => {
gestureRect = undefined;
},
});
const hovering = ref(false);
// Hover-move: when `hover` is set, the divider follows the pointer over the root
// without a press. A live drag takes precedence (its own pointermove drives the
// position), so skip while dragging.
useEventListener(currentElement, 'pointermove', (event: PointerEvent) => {
if (disabled || !hover || drag.isDragging.value) return;
position.value = positionFromClient(event.clientX, event.clientY);
});
useEventListener(currentElement, 'pointerenter', () => {
if (disabled || !hover) return;
hovering.value = true;
});
useEventListener(currentElement, 'pointerleave', () => {
hovering.value = false;
});
function setPosition(next: number): void {
if (disabled) return;
position.value = next;
}
function stepBy(delta: number): void {
if (disabled) return;
position.value = localPosition.value + delta;
}
// Stable shape: always the same keys in the same order so V8 (and Vue's style
// patcher) sees a monomorphic object.
const rootStyle = computed<{ position: string }>(() => ({ position: 'relative' }));
provideCompareSliderContext({
position,
// `toRef(() => prop)` → `GetterRefImpl`: reactive `Ref` without a
// `ReactiveEffect` / cache, since these are identity passthroughs.
orientation: toRef(() => orientation),
// `direction` is already a `ComputedRef` from `useDirection`; `flip` a `computed`.
direction,
disabled: toRef(() => disabled),
inverted: toRef(() => inverted),
flip,
keyboardStep: toRef(() => keyboardStep),
keyboardLargeStep: toRef(() => keyboardLargeStep),
valueText: toRef(() => valueText),
step: stepBy,
setPosition,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:style="rootStyle"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
:data-orientation="orientation"
:data-dragging="drag.isDragging.value ? '' : undefined"
:data-hovering="hovering ? '' : undefined"
:dir="direction"
>
<slot :position="position" />
</Primitive>
</template>
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(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<number | undefined>(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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<number | undefined>(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%)');
});
});
@@ -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 (0100) 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<T>`, 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, 0100 (percentage of the after-layer shown). */
position: Ref<number>;
orientation: Ref<CompareSliderOrientation>;
direction: Ref<CompareSliderDirection>;
disabled: Ref<boolean>;
inverted: Ref<boolean>;
/**
* 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<boolean>;
/** Single keyboard step (Arrow). */
keyboardStep: Ref<number>;
/** Large keyboard step (Shift+Arrow / Page keys). */
keyboardLargeStep: Ref<number>;
/** Optional formatter for the handle's `aria-valuetext`; `undefined` when unset. */
valueText: Ref<CompareSliderValueText | undefined>;
/** Move the reveal position by `delta` (clamped to 0100). */
step: (delta: number) => void;
/** Set the reveal position to an absolute value (clamped to 0100). */
setPosition: (next: number) => void;
}
const ctx = useContextFactory<CompareSliderContext>('CompareSliderContext');
export const provideCompareSliderContext = ctx.provide;
export const useCompareSliderContext = ctx.inject;
@@ -0,0 +1,123 @@
<script setup lang="ts">
import {
CompareSliderAfter,
CompareSliderBefore,
CompareSliderDivider,
CompareSliderHandle,
CompareSliderRoot,
} from '@robonen/primitives';
import { ref } from 'vue';
const position = ref(50);
const valueText = (p: number) => `${Math.round(p)}% revealed`;
</script>
<template>
<div class="demo-card w-full max-w-md space-y-4 p-6 text-fg">
<div class="flex items-baseline justify-between text-sm">
<span class="font-medium">Before / After</span>
<span class="font-mono text-fg-muted">{{ Math.round(position) }}% after</span>
</div>
<CompareSliderRoot
v-model:position="position"
:value-text="valueText"
class="compare-root group relative aspect-video w-full select-none overflow-hidden rounded-card border border-border shadow-(--shadow-card)"
>
<!-- "Before" layer: cool graded photo -->
<CompareSliderBefore class="compare-before">
<span class="absolute left-3 top-3 rounded-md bg-bg/80 px-2 py-0.5 text-xs font-medium text-fg backdrop-blur">
Before
</span>
</CompareSliderBefore>
<!-- "After" layer: warm graded photo, clipped to the reveal % -->
<CompareSliderAfter class="compare-after">
<span class="absolute right-3 top-3 rounded-md bg-bg/80 px-2 py-0.5 text-xs font-medium text-fg backdrop-blur">
After
</span>
</CompareSliderAfter>
<!-- Seam line -->
<CompareSliderDivider class="compare-divider" />
<!-- Grab / focus target -->
<CompareSliderHandle
aria-label="Comparison position"
class="compare-handle"
>
<span class="compare-handle__grip" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
<polyline points="9 18 3 12 9 6" transform="translate(12 0)" />
</svg>
</span>
</CompareSliderHandle>
</CompareSliderRoot>
<p class="text-xs text-fg-subtle">
Drag the handle (or focus it and use Arrow / Home / End keys) to reveal the after image.
</p>
</div>
</template>
<style scoped>
.compare-root {
cursor: ew-resize;
touch-action: none;
}
/* Two contrasting CSS-gradient "photos" stand in for real images. */
.compare-before :deep(*),
.compare-before {
background: linear-gradient(135deg, #1e3a8a 0%, #0ea5e9 50%, #22d3ee 100%);
}
.compare-before {
background:
radial-gradient(circle at 70% 30%, rgb(255 255 255 / 0.25), transparent 45%),
linear-gradient(135deg, #1e3a8a 0%, #0ea5e9 55%, #22d3ee 100%);
}
.compare-after {
background:
radial-gradient(circle at 30% 70%, rgb(255 255 255 / 0.3), transparent 45%),
linear-gradient(135deg, #b45309 0%, #f97316 55%, #facc15 100%);
}
/* Thin seam line at the split. */
.compare-divider {
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
background: rgb(255 255 255 / 0.9);
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.12);
}
/* Round grab puck centred on the seam. */
.compare-handle {
top: 50%;
display: grid;
place-items: center;
width: 0;
height: 0;
outline: none;
}
.compare-handle__grip {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
color: var(--fg);
background: var(--bg);
border-radius: 9999px;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.3);
transform: translate(-50%, -50%);
transition: transform 0.12s ease;
}
.compare-handle:hover .compare-handle__grip {
transform: translate(-50%, -50%) scale(1.08);
}
.compare-handle:focus-visible .compare-handle__grip {
box-shadow: 0 0 0 3px var(--ring), 0 1px 3px rgb(0 0 0 / 0.3);
}
</style>
@@ -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';
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The crop rectangle surface — the draggable, focusable body of the selection.
* It sizes and positions itself from the Root's rect (× media size in pixel
* units), moves the whole rect on pointer drag (constrained to the media
* bounds), and moves it with the arrow keys when focused. Carries
* `role="group"`, `tabindex 0`, and an overridable `aria-label`. Place
* `CropHandle`s and `CropGrid` inside it. Hidden (renders nothing) while the
* selection is empty.
*/
export interface CropAreaProps extends PrimitiveProps {
/** Accessible label for the crop region. @default 'Crop region' */
label?: string;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useCropContext } from './context';
const { label = 'Crop region', as = 'div' } = defineProps<CropAreaProps>();
const ctx = useCropContext();
const { forwardRef, currentElement } = useForwardExpose();
// Position/size from the rect. In normalized units the rect is already 0..1 so
// we emit percentages; in pixel units we map rect × media-pixel ratio so the
// surface sits over the media regardless of its on-screen scale.
const positionStyle = computed<{
position: string;
left: string;
top: string;
width: string;
height: string;
}>(() => {
const r = ctx.rect.value;
if (r === null) return { position: 'absolute', left: '0', top: '0', width: '0', height: '0' };
const denomW = ctx.units.value === 'normalized' ? 1 : Math.max(ctx.mediaPixels.value.width, 1e-9);
const denomH = ctx.units.value === 'normalized' ? 1 : Math.max(ctx.mediaPixels.value.height, 1e-9);
const rtl = ctx.direction.value === 'rtl';
const leftPct = (rtl ? (denomW - r.x - r.width) : r.x) / denomW * 100;
return {
position: 'absolute',
left: `${leftPct}%`,
top: `${(r.y / denomH) * 100}%`,
width: `${(r.width / denomW) * 100}%`,
height: `${(r.height / denomH) * 100}%`,
};
});
function onPointerDown(event: PointerEvent): void {
if (ctx.disabled.value) return;
// The CropArea element is the media-aligned surface for the move projection.
ctx.beginMove(event, currentElement.value ?? null);
}
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value || ctx.rect.value === null) return;
const rtl = ctx.direction.value === 'rtl';
const stepX = event.shiftKey ? ctx.keyboardLargeStepX.value : ctx.keyboardStepX.value;
const stepY = event.shiftKey ? ctx.keyboardLargeStepY.value : ctx.keyboardStepY.value;
let dx = 0;
let dy = 0;
switch (event.key) {
case 'ArrowLeft':
dx = rtl ? stepX : -stepX;
break;
case 'ArrowRight':
dx = rtl ? -stepX : stepX;
break;
case 'ArrowUp':
dy = -stepY;
break;
case 'ArrowDown':
dy = stepY;
break;
default:
return;
}
event.preventDefault();
ctx.nudgeMove(dx, dy);
}
</script>
<template>
<Primitive
v-if="ctx.rect.value !== null"
:ref="forwardRef"
:as="as"
role="group"
:tabindex="ctx.disabled.value ? -1 : 0"
:aria-label="label"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-cropping="ctx.isCropping.value ? '' : undefined"
:data-dir="ctx.direction.value"
:style="positionStyle"
@pointerdown="onPointerDown"
@keydown="onKeyDown"
>
<slot :rect="ctx.rect.value" />
</Primitive>
</template>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The rule-of-thirds overlay: two vertical and two horizontal guide lines drawn
* across the crop box at the ⅓ and ⅔ marks. Purely presentational
* (`aria-hidden`), it renders only when the Root's `grid` prop is enabled and a
* selection exists. Place it inside `CropArea`. Each line is exposed as a slot
* entry so the consumer can style or replace the lines; the default slot renders
* four absolutely-positioned `<span>`s.
*/
export interface CropGridProps extends PrimitiveProps {
/** Number of columns/rows the grid divides the box into. @default 3 */
divisions?: number;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useCropContext } from './context';
const { divisions = 3, as = 'div' } = defineProps<CropGridProps>();
const ctx = useCropContext();
const { forwardRef } = useForwardExpose();
const visible = computed(() => ctx.grid.value && ctx.rect.value !== null);
// Interior line positions as percentages of the box (e.g. 33.33%, 66.66% for
// thirds). Endpoints (0/100) are the box edges and are omitted.
const lines = computed(() => {
const out: number[] = [];
const n = Math.max(2, Math.round(divisions));
for (let i = 1; i < n; i++) out.push((i / n) * 100);
return out;
});
</script>
<template>
<Primitive
v-if="visible"
:ref="forwardRef"
:as="as"
aria-hidden="true"
data-crop-grid=""
:style="{ position: 'absolute', inset: '0', pointerEvents: 'none' }"
>
<slot :lines="lines">
<Primitive
v-for="(pct, i) in lines"
:key="`v-${i}`"
as="span"
data-orientation="vertical"
:style="{ position: 'absolute', top: '0', bottom: '0', left: `${pct}%`, width: '0' }"
/>
<Primitive
v-for="(pct, i) in lines"
:key="`h-${i}`"
as="span"
data-orientation="horizontal"
:style="{ position: 'absolute', left: '0', right: '0', top: `${pct}%`, height: '0' }"
/>
</slot>
</Primitive>
</template>
@@ -0,0 +1,109 @@
<script lang="ts">
import type { CropHandlePosition } from './utils';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* One of the eight resize handles — four corners and four edge midpoints. Render
* eight of these inside `CropArea`, one per `position`. Each is a native
* `<button type="button">` with an `aria-label` ("Resize top-left", etc.) and
* keyboard edge-resize: arrow keys move that edge/corner with the opposite edge
* fixed (Shift = large step), honouring aspect-ratio, min size, and bounds.
* Dragging resizes the same way. Positioned at its anchor on the crop box edge.
*/
export interface CropHandleProps extends PrimitiveProps {
/** Which of the eight handles this is. */
position: CropHandlePosition;
/** Accessible label override (defaults to "Resize <position>"). */
label?: string;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useCropContext } from './context';
const { position, label = undefined, as = 'button' } = defineProps<CropHandleProps>();
const ctx = useCropContext();
const { forwardRef, currentElement } = useForwardExpose();
const DEFAULT_LABELS: Record<CropHandlePosition, string> = {
'top-left': 'Resize top-left',
top: 'Resize top',
'top-right': 'Resize top-right',
right: 'Resize right',
'bottom-right': 'Resize bottom-right',
bottom: 'Resize bottom',
'bottom-left': 'Resize bottom-left',
left: 'Resize left',
};
const accessibleLabel = computed(() => label ?? DEFAULT_LABELS[position]);
// Anchor the handle on the crop box edge in percentages of the box (the box is
// the CropArea, whose 0%..100% spans the crop rect). RTL mirrors the x anchor.
const anchorStyle = computed<{ position: string; left: string; top: string }>(() => {
const rtl = ctx.direction.value === 'rtl';
let xPct = position.includes('left') ? 0 : position.includes('right') ? 100 : 50;
if (rtl) xPct = 100 - xPct;
const yPct = position.includes('top') ? 0 : position.includes('bottom') ? 100 : 50;
return { position: 'absolute', left: `${xPct}%`, top: `${yPct}%` };
});
function onPointerDown(event: PointerEvent): void {
if (ctx.disabled.value) return;
ctx.beginResize(position, event, currentElement.value ?? null);
}
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value || ctx.rect.value === null) return;
const rtl = ctx.direction.value === 'rtl';
const stepX = event.shiftKey ? ctx.keyboardLargeStepX.value : ctx.keyboardStepX.value;
const stepY = event.shiftKey ? ctx.keyboardLargeStepY.value : ctx.keyboardStepY.value;
let dx = 0;
let dy = 0;
switch (event.key) {
case 'ArrowLeft':
dx = rtl ? stepX : -stepX;
break;
case 'ArrowRight':
dx = rtl ? -stepX : stepX;
break;
case 'ArrowUp':
dy = -stepY;
break;
case 'ArrowDown':
dy = stepY;
break;
default:
return;
}
event.preventDefault();
// Keep the keypress from also reaching CropArea's move handler (handles are
// nested inside the area, so the event would otherwise bubble and move the
// whole rect on top of the resize).
event.stopPropagation();
ctx.nudgeResize(position, dx, dy);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:aria-label="accessibleLabel"
:aria-disabled="ctx.disabled.value || undefined"
:disabled="as === 'button' && ctx.disabled.value ? true : undefined"
:tabindex="ctx.disabled.value ? -1 : 0"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-position="position"
:style="anchorStyle"
@pointerdown="onPointerDown"
@keydown="onKeyDown"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,87 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The dimmed scrim over the media OUTSIDE the crop rectangle. Renders four
* absolutely-positioned rects (top / bottom / left / right of the selection) so
* the consumer can tint the excluded region with a single `background`.
* Presentational (`aria-hidden`, `pointer-events: none`). Place it as a sibling
* of `CropArea`, spanning the media surface. Hidden while the selection is empty.
* The four rects are exposed via the default slot for full styling control.
*/
export interface CropOverlayProps extends PrimitiveProps {}
/** One scrim rect exposed via the default slot: a stable-shape inline style. */
export interface ScrimRect {
key: string;
style: {
position: string;
left: string | undefined;
right: string | undefined;
top: string | undefined;
bottom: string | undefined;
width: string | undefined;
height: string | undefined;
};
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useCropContext } from './context';
const { as = 'div' } = defineProps<CropOverlayProps>();
const ctx = useCropContext();
const { forwardRef } = useForwardExpose();
// The crop rect as fractions of the media (0..1) regardless of `units`, with the
// x axis mirrored under RTL so the scrim lines up with the visual selection.
const frac = computed(() => {
const r = ctx.rect.value;
if (r === null) return null;
const denomW = ctx.units.value === 'normalized' ? 1 : Math.max(ctx.mediaPixels.value.width, 1e-9);
const denomH = ctx.units.value === 'normalized' ? 1 : Math.max(ctx.mediaPixels.value.height, 1e-9);
let left = r.x / denomW;
if (ctx.direction.value === 'rtl') left = (denomW - r.x - r.width) / denomW;
return {
left: left * 100,
top: (r.y / denomH) * 100,
width: (r.width / denomW) * 100,
height: (r.height / denomH) * 100,
};
});
// The four scrim rects (top, bottom, left, right of the selection). Every rect
// emits the same key set (unused sides explicit `undefined`) so the style type
// stays monomorphic.
const rects = computed<ScrimRect[]>(() => {
const f = frac.value;
if (f === null) return [];
const right = f.left + f.width;
const bottom = f.top + f.height;
return [
{ key: 'top', style: { position: 'absolute', left: '0', right: '0', top: '0', bottom: undefined, width: undefined, height: `${f.top}%` } },
{ key: 'bottom', style: { position: 'absolute', left: '0', right: '0', top: `${bottom}%`, bottom: '0', width: undefined, height: undefined } },
{ key: 'left', style: { position: 'absolute', left: '0', right: undefined, top: `${f.top}%`, bottom: undefined, width: `${f.left}%`, height: `${f.height}%` } },
{ key: 'right', style: { position: 'absolute', left: `${right}%`, right: '0', top: `${f.top}%`, bottom: undefined, width: undefined, height: `${f.height}%` } },
];
});
</script>
<template>
<Primitive
v-if="frac !== null"
:ref="forwardRef"
:as="as"
aria-hidden="true"
data-crop-overlay=""
:style="{ position: 'absolute', inset: '0', pointerEvents: 'none' }"
>
<slot :rects="rects">
<Primitive v-for="rect in rects" :key="rect.key" as="span" :data-side="rect.key" :style="rect.style" />
</slot>
</Primitive>
</template>
+392
View File
@@ -0,0 +1,392 @@
<script lang="ts">
import type { CropDirection, CropUnits } from './context';
import type { CropBounds, CropHandlePosition, CropRect } from './utils';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Headless crop selector rendered **over** a media element (`<img>`, `<video>`,
* `<canvas>`). It owns a single crop rectangle — controlled via `v-model` or
* uncontrolled via `defaultValue` — and drives moving, eight-handle resizing,
* aspect-ratio locking, a rule-of-thirds grid, and a draw-from-empty create
* gesture. The rect lives in NORMALIZED `0..1` fractions of the media by default
* (resolution-independent) or in media pixels via `units: 'pixels'`.
*
* Supply the media size with `mediaWidth`/`mediaHeight` for standalone use, or
* mount inside a `CanvasStageRoot` and the Root reads the stage's content size
* automatically (props still win when given). It provides {@link CropContext} to
* `CropArea`, `CropHandle`, `CropGrid`, and `CropOverlay`, and emits `cropCommit`
* when a gesture or keypress settles. Reach for it to let a user pick a sub-rect
* of an image or video (avatar crop, thumbnail framing, redaction region).
*/
export interface CropRootProps extends PrimitiveProps {
/**
* The crop rectangle (two-way via `v-model`). `null` means "no selection
* yet" — the Root falls back to the full frame or stays empty depending on
* `createOnEmpty`.
*/
modelValue?: CropRect | null;
/** Uncontrolled initial rect. @default null */
defaultValue?: CropRect | null;
/** Coordinate space for the rect and the size props. @default 'normalized' */
units?: CropUnits;
/** Locked `width / height` of the crop box (visual ratio), or `null` to resize freely. @default null */
aspectRatio?: number | null;
/** Minimum crop width, in the chosen `units`. @default 0 */
minWidth?: number;
/** Minimum crop height, in the chosen `units`. @default 0 */
minHeight?: number;
/** Media width in pixels. Read from a `CanvasStage` ancestor when omitted. @default undefined */
mediaWidth?: number;
/** Media height in pixels. Read from a `CanvasStage` ancestor when omitted. @default undefined */
mediaHeight?: number;
/** Keep the rect within the media bounds. @default true */
constrain?: boolean;
/** Render the rule-of-thirds grid. @default true */
grid?: boolean;
/** Pointerdown on empty media draws a new rect from zero. @default true */
createOnEmpty?: boolean;
/** Keyboard nudge step, in normalized units (scaled to px when `units: 'pixels'`). @default 0.01 */
keyboardStep?: number;
/** Large keyboard step (Shift+Arrow). @default keyboardStep * 10 */
keyboardLargeStep?: number;
/** Disable all interaction. @default false */
disabled?: boolean;
/** Writing direction (inherited from `ConfigProvider` when omitted). */
dir?: CropDirection;
}
export interface CropRootEmits {
/** Fired when a pointer gesture or keypress settles, with the committed rect (or `null`). */
cropCommit: [rect: CropRect | null];
}
</script>
<script setup lang="ts">
import { computed, onScopeDispose, shallowRef, toRef, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { provideCropContext } from './context';
import { useDirection } from '../../utilities/config-provider';
import { useForwardExpose } from '@robonen/vue';
import { useCanvasStageContext } from '../canvas-stage/context';
import type { CanvasStageContext } from '../canvas-stage/context';
import { createRect, moveRect, normalizeRect, resizeRect, resolveAspectRatio } from './utils';
const {
defaultValue = null,
units = 'normalized',
aspectRatio = null,
minWidth = 0,
minHeight = 0,
mediaWidth = undefined,
mediaHeight = undefined,
constrain = true,
grid = true,
createOnEmpty = true,
keyboardStep = 0.01,
keyboardLargeStep = undefined,
disabled = false,
dir,
as = 'div',
} = defineProps<CropRootProps>();
const emit = defineEmits<CropRootEmits>();
const direction = useDirection(() => dir);
// Optional CanvasStage ancestor — `null` when standalone. The canvas-stage
// factory `inject` throws when no provider exists *and* no fallback is given;
// passing an explicit `null` fallback makes it return `null` so Crop works
// standalone (media size then comes from the `mediaWidth`/`mediaHeight` props).
const stage = useCanvasStageContext(null as unknown as CanvasStageContext);
// Media pixel size: explicit props win; otherwise read the stage content size.
const mediaPixels = computed<CropBounds>(() => {
const w = mediaWidth ?? stage?.contentSize.value.width ?? 0;
const h = mediaHeight ?? stage?.contentSize.value.height ?? 0;
return { width: w, height: h };
});
// Bounds in the rect's units: `{1,1}` normalized, real px otherwise.
const mediaSize = computed<CropBounds>(() => {
if (units === 'normalized') return { width: 1, height: 1 };
return mediaPixels.value;
});
// Resolve the visual aspect ratio into the rect's units (normalized needs the
// media-pixel-aspect correction so the box stays visually square/16:9/etc.).
const resolvedRatio = computed<number | null>(() =>
resolveAspectRatio(aspectRatio, units, mediaPixels.value.width, mediaPixels.value.height),
);
// Scale keyboard steps into the rect's units. In pixel mode the normalized step
// is multiplied by the media dimension on each axis.
const largeBase = computed(() => keyboardLargeStep ?? keyboardStep * 10);
const stepX = computed(() => (units === 'normalized' ? keyboardStep : keyboardStep * mediaPixels.value.width));
const stepY = computed(() => (units === 'normalized' ? keyboardStep : keyboardStep * mediaPixels.value.height));
const largeStepX = computed(() => (units === 'normalized' ? largeBase.value : largeBase.value * mediaPixels.value.width));
const largeStepY = computed(() => (units === 'normalized' ? largeBase.value : largeBase.value * mediaPixels.value.height));
function resizeOptions() {
return {
aspectRatio: resolvedRatio.value,
minWidth,
minHeight,
bounds: mediaSize.value,
constrain,
};
}
// ── model (controlled + uncontrolled) ─────────────────────────────────────────
const model = defineModel<CropRect | null>();
const seed = model.value ?? defaultValue;
const localRect = shallowRef<CropRect | null>(
seed ? normalizeRect(seed, resizeOptions()) : null,
);
watch(model, (v) => {
if (v === undefined) return;
if (v === localRect.value) return;
localRect.value = v === null ? null : normalizeRect(v, resizeOptions());
});
// Re-settle the rect when ratio / units / bounds change (e.g. ratio set on a
// free rect → re-fit; media measured later → clamp into the new bounds).
watch([resolvedRatio, mediaSize, () => constrain, () => minWidth, () => minHeight], () => {
const r = localRect.value;
if (r === null) return;
const next = normalizeRect(r, resizeOptions());
if (next.x !== r.x || next.y !== r.y || next.width !== r.width || next.height !== r.height)
writeRect(next, false);
});
function writeRect(next: CropRect | null, commit: boolean): void {
localRect.value = next;
model.value = next;
if (commit) emit('cropCommit', next);
}
function setRect(next: CropRect | null): void {
if (disabled) return;
writeRect(next === null ? null : normalizeRect(next, resizeOptions()), false);
}
const isEmpty = computed(() => localRect.value === null);
// ── pointer-to-units mapping ──────────────────────────────────────────────────
// The crop surface element (a media-sized container) is the projection origin: a
// client point maps to media-space units via the surface's rect. The stage's own
// zoom/pan is already baked into the surface's on-screen rect, so reading
// `getBoundingClientRect()` is correct standalone AND inside a CanvasStage — no
// extra viewport math needed.
let surfaceRect: DOMRect | null = null;
function clientToUnits(clientX: number, clientY: number): { x: number; y: number } {
const r = surfaceRect;
if (!r || r.width === 0 || r.height === 0) return { x: 0, y: 0 };
let fx = (clientX - r.left) / r.width;
if (direction.value === 'rtl') fx = 1 - fx;
const fy = (clientY - r.top) / r.height;
if (units === 'normalized') return { x: fx, y: fy };
return { x: fx * mediaPixels.value.width, y: fy * mediaPixels.value.height };
}
// ── pointer gesture pipeline ──────────────────────────────────────────────────
// A single capture pipeline handles move / resize / create. Each part calls the
// matching `begin*` on pointerdown; the part passes the media surface element so
// the Root can cache its rect for the client→units projection and capture the
// pointer on it for the gesture's lifetime.
const isCropping = shallowRef(false);
let pointerId = -1;
let mode: 'move' | 'resize' | 'create' | null = null;
let activeHandle: CropHandlePosition | null = null;
let gestureStartRect: CropRect | null = null;
let createOrigin: { x: number; y: number } | null = null;
let downClientX = 0;
let downClientY = 0;
let captureEl: HTMLElement | null = null;
function setLive(next: CropRect): void {
localRect.value = next;
model.value = next;
}
function onPointerMove(event: PointerEvent): void {
if (event.pointerId !== pointerId) return;
if (!isCropping.value) isCropping.value = true;
if (mode === 'move' && gestureStartRect) {
const start = clientToUnits(downClientX, downClientY);
const now = clientToUnits(event.clientX, event.clientY);
setLive(moveRect(gestureStartRect, now.x - start.x, now.y - start.y, mediaSize.value, constrain));
}
else if (mode === 'resize' && gestureStartRect && activeHandle) {
const now = clientToUnits(event.clientX, event.clientY);
setLive(resizeRect(gestureStartRect, activeHandle, now.x, now.y, resizeOptions()));
}
else if (mode === 'create' && createOrigin) {
const now = clientToUnits(event.clientX, event.clientY);
setLive(createRect(createOrigin, now, resizeOptions()));
}
}
function endGesture(commit: boolean): void {
globalThis.removeEventListener('pointermove', onPointerMove);
globalThis.removeEventListener('pointerup', onPointerUp);
globalThis.removeEventListener('pointercancel', onPointerCancel);
captureEl?.releasePointerCapture?.(pointerId);
pointerId = -1;
isCropping.value = false;
const wasCreate = mode === 'create';
mode = null;
activeHandle = null;
gestureStartRect = null;
createOrigin = null;
captureEl = null;
// A create gesture that never grew past zero leaves no selection.
if (wasCreate && localRect.value && localRect.value.width === 0 && localRect.value.height === 0)
localRect.value = model.value = null;
if (commit) emit('cropCommit', localRect.value);
}
function onPointerUp(event: PointerEvent): void {
if (event.pointerId !== pointerId) return;
endGesture(true);
}
function onPointerCancel(event: PointerEvent): void {
if (event.pointerId !== pointerId) return;
endGesture(false);
}
// The three window listeners + pointer capture opened by `startGesture` are torn
// down only on the pointerup/cancel that ends the gesture. If the Root unmounts
// while a gesture is in flight (parent v-if toggles crop off, route change,
// editor panel teardown), that end event never arrives for this scope and the
// listeners leak — their closures retain this (now-dead) instance, its reactive
// state and the detached capture element. Run the same teardown on scope dispose.
onScopeDispose(() => {
if (pointerId !== -1) endGesture(false);
});
function startGesture(event: PointerEvent, surface: HTMLElement | null): void {
// The media-sized surface is the projection origin. A part may pass its own
// element, but the Root element (which wraps the media) is the canonical
// surface, so prefer it when available.
const projection = rootEl.value ?? surface;
surfaceRect = projection ? projection.getBoundingClientRect() : null;
pointerId = event.pointerId;
downClientX = event.clientX;
downClientY = event.clientY;
isCropping.value = true;
captureEl = (event.currentTarget as HTMLElement | null) ?? surface;
captureEl?.setPointerCapture?.(event.pointerId);
globalThis.addEventListener('pointermove', onPointerMove);
globalThis.addEventListener('pointerup', onPointerUp);
globalThis.addEventListener('pointercancel', onPointerCancel);
}
function beginMove(event: PointerEvent, surface: HTMLElement | null): void {
if (disabled || event.button !== 0 || localRect.value === null) return;
event.preventDefault();
mode = 'move';
gestureStartRect = localRect.value;
startGesture(event, surface);
}
function beginResize(handle: CropHandlePosition, event: PointerEvent, surface: HTMLElement | null): void {
if (disabled || event.button !== 0 || localRect.value === null) return;
event.preventDefault();
event.stopPropagation();
mode = 'resize';
activeHandle = handle;
gestureStartRect = localRect.value;
startGesture(event, surface);
}
function beginCreate(event: PointerEvent, surface: HTMLElement | null): void {
if (disabled || event.button !== 0 || !createOnEmpty || localRect.value !== null) return;
event.preventDefault();
mode = 'create';
const projection = rootEl.value ?? surface;
surfaceRect = projection ? projection.getBoundingClientRect() : null;
createOrigin = clientToUnits(event.clientX, event.clientY);
setLive({ x: createOrigin.x, y: createOrigin.y, width: 0, height: 0 });
startGesture(event, surface);
}
// Pointerdown landing on the Root itself (the media surface) starts a
// draw-from-empty create when there is no selection yet. CropArea / CropHandle
// stop propagation on their own presses, so this only fires on bare media.
function onRootPointerDown(event: PointerEvent): void {
if (localRect.value !== null) return;
beginCreate(event, rootEl.value ?? null);
}
// ── keyboard nudges ───────────────────────────────────────────────────────────
function nudgeMove(dx: number, dy: number): void {
if (disabled || localRect.value === null) return;
writeRect(moveRect(localRect.value, dx, dy, mediaSize.value, constrain), true);
}
function nudgeResize(handle: CropHandlePosition, dx: number, dy: number): void {
if (disabled || localRect.value === null) return;
const r = localRect.value;
const left = r.x;
const right = r.x + r.width;
const top = r.y;
const bottom = r.y + r.height;
const isLeft = handle === 'top-left' || handle === 'left' || handle === 'bottom-left';
const isRight = handle === 'top-right' || handle === 'right' || handle === 'bottom-right';
const isTop = handle === 'top-left' || handle === 'top' || handle === 'top-right';
const isBottom = handle === 'bottom-left' || handle === 'bottom' || handle === 'bottom-right';
// Target = the dragged handle's current position + the delta.
const px = isLeft ? left + dx : isRight ? right + dx : (left + right) / 2;
const py = isTop ? top + dy : isBottom ? bottom + dy : (top + bottom) / 2;
writeRect(resizeRect(r, handle, px, py, resizeOptions()), true);
}
provideCropContext({
rect: localRect,
isEmpty,
units: toRef(() => units),
direction,
mediaSize,
mediaPixels,
constrain: toRef(() => constrain),
grid: toRef(() => grid),
disabled: toRef(() => disabled),
aspectRatio: resolvedRatio,
minWidth: toRef(() => minWidth),
minHeight: toRef(() => minHeight),
keyboardStepX: stepX,
keyboardStepY: stepY,
keyboardLargeStepX: largeStepX,
keyboardLargeStepY: largeStepY,
isCropping,
setRect,
nudgeMove,
nudgeResize,
beginMove,
beginResize,
beginCreate,
});
defineExpose({ rect: localRect, isEmpty, setRect });
// `defineExpose` precedes `useForwardExpose` so the composable merges the prior
// bindings instead of clobbering them. `currentElement` is the rendered Root —
// the media-sized surface every gesture projects against.
const { forwardRef, currentElement: rootEl } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:dir="direction"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
:data-empty="isEmpty ? '' : undefined"
:data-cropping="isCropping ? '' : undefined"
@pointerdown="onRootPointerDown"
>
<slot :rect="localRect" :is-empty="isEmpty" :is-cropping="isCropping" />
</Primitive>
</template>
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(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<string, unknown> = {}, initial: CropRect | null = { x: 0.2, y: 0.2, width: 0.4, height: 0.4 }) {
const model = ref<CropRect | null>(initial);
const committed = ref<CropRect | null | undefined>(undefined);
const Harness = defineComponent({
setup() {
const props: Record<string, unknown> = {
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLButtonElement>('[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<HTMLElement>('[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<HTMLElement>('[data-crop-overlay]')!;
expect(overlay).toBeTruthy();
expect(overlay.getAttribute('aria-hidden')).toBe('true');
expect(overlay.querySelectorAll('[data-side]')).toHaveLength(4);
});
});
@@ -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();
});
});
+73
View File
@@ -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<T>` 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<CropRect | null>;
/** Whether `rect` is `null` (no selection yet). */
isEmpty: Ref<boolean>;
/** The active coordinate space. */
units: Ref<CropUnits>;
/** Reading direction (affects arrow-key x sign). */
direction: Ref<CropDirection>;
/** Media bounds in the rect's units (`{1,1}` normalized, media px otherwise). */
mediaSize: Ref<CropBounds>;
/** Media size in pixels — always real px regardless of `units` (for layout). */
mediaPixels: Ref<CropBounds>;
/** Whether the rect is kept within the media bounds. */
constrain: Ref<boolean>;
/** Whether the rule-of-thirds grid is enabled. */
grid: Ref<boolean>;
/** Master interactivity switch. */
disabled: Ref<boolean>;
/** Resolved `width / height` lock in the rect's units, or `null` when free. */
aspectRatio: Ref<number | null>;
/** Minimum width in the rect's units. */
minWidth: Ref<number>;
/** Minimum height in the rect's units. */
minHeight: Ref<number>;
/** Keyboard nudge step on the x axis, in the rect's units. */
keyboardStepX: Ref<number>;
/** Keyboard nudge step on the y axis, in the rect's units. */
keyboardStepY: Ref<number>;
/** Large keyboard nudge step (Shift+Arrow) on the x axis, in the rect's units. */
keyboardLargeStepX: Ref<number>;
/** Large keyboard nudge step (Shift+Arrow) on the y axis, in the rect's units. */
keyboardLargeStepY: Ref<number>;
/** Whether a pointer gesture is currently in progress. */
isCropping: Ref<boolean>;
/** 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>('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;
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import {
CROP_HANDLE_POSITIONS,
CropArea,
CropGrid,
CropHandle,
CropOverlay,
CropRoot,
} from '@robonen/primitives';
import type { CropRect } from '@robonen/primitives';
import { ref } from 'vue';
// Normalized rect: x / y / width / height are fractions 0..1 of the photo.
const crop = ref<CropRect>({ x: 0.18, y: 0.16, width: 0.5, height: 0.5 });
// Aspect-ratio lock variants (width / height of the visual box). null = free.
const ratios = [
{ label: 'Free', value: null },
{ label: '1:1', value: 1 },
{ label: '16:9', value: 16 / 9 },
{ label: '4:5', value: 4 / 5 },
] as const;
const ratio = ref<number | null>(null);
const pct = (n: number) => Math.round(n * 100);
</script>
<template>
<div class="demo-card w-full max-w-xl space-y-4 p-6 text-fg">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold">Crop</h3>
<p class="text-xs text-fg-muted">Drag the region to move, the squares to resize. Rule-of-thirds grid + dimmed surround.</p>
</div>
<!-- Aspect-ratio variant switch -->
<div class="flex items-center gap-1 rounded-lg border border-border bg-bg-inset p-0.5">
<button
v-for="opt in ratios"
:key="opt.label"
type="button"
class="rounded-md px-2 py-1 text-xs font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="ratio === opt.value ? 'bg-accent text-accent-fg shadow-sm' : 'text-fg-muted hover:text-fg'"
@click="ratio = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- The photo: CropRoot is the media-sized, positioned surface laid over it. -->
<CropRoot
v-model="crop"
:aspect-ratio="ratio"
:media-width="640"
:media-height="420"
:min-width="0.1"
:min-height="0.1"
class="relative aspect-[640/420] w-full select-none overflow-hidden rounded-card border border-border outline-none"
style="background: linear-gradient(115deg, oklch(0.7 0.16 200) 0%, oklch(0.66 0.2 285) 45%, oklch(0.74 0.18 25) 100%);"
>
<!-- Decorative 'subject' so the crop has something to frame. -->
<div
class="pointer-events-none absolute left-[42%] top-[34%] h-28 w-28 rounded-full opacity-90 blur-[2px]"
style="background: radial-gradient(circle at 35% 30%, oklch(0.96 0.05 95) 0%, oklch(0.82 0.16 80) 55%, transparent 72%);"
/>
<!-- Dimmed scrim over everything OUTSIDE the crop rect (four sibling rects). -->
<CropOverlay>
<template #default="{ rects }">
<span
v-for="rect in rects"
:key="rect.key"
:style="rect.style"
class="bg-black/55 backdrop-saturate-50"
/>
</template>
</CropOverlay>
<!-- The crop rectangle: draggable body + handles + grid. -->
<CropArea class="absolute cursor-move outline-none ring-1 ring-bg/30 focus-visible:ring-2 focus-visible:ring-ring">
<!-- Selection border -->
<span class="pointer-events-none absolute inset-0 border border-bg/90 shadow-[0_0_0_1px_oklch(0_0_0/0.25)]" />
<!-- Rule-of-thirds grid lines -->
<CropGrid>
<template #default="{ lines }">
<span
v-for="(p, i) in lines"
:key="`v-${i}`"
class="absolute bottom-0 top-0 w-px bg-bg/50"
:style="{ left: `${p}%` }"
/>
<span
v-for="(p, i) in lines"
:key="`h-${i}`"
class="absolute left-0 right-0 h-px bg-bg/50"
:style="{ top: `${p}%` }"
/>
</template>
</CropGrid>
<!-- Eight resize handles as small accent squares on the corners + edges. -->
<CropHandle
v-for="pos in CROP_HANDLE_POSITIONS"
:key="pos"
:position="pos"
class="block h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-[2px] border border-bg bg-accent shadow-sm outline-none transition hover:scale-125 focus-visible:ring-2 focus-visible:ring-ring"
:class="pos === 'left' || pos === 'right' ? 'cursor-ew-resize' : pos === 'top' || pos === 'bottom' ? 'cursor-ns-resize' : pos === 'top-left' || pos === 'bottom-right' ? 'cursor-nwse-resize' : 'cursor-nesw-resize'"
/>
</CropArea>
</CropRoot>
<!-- Normalized crop rect readout. -->
<dl class="grid grid-cols-4 gap-2 text-center">
<div v-for="item in [
{ label: 'X', value: `${pct(crop.x)}%` },
{ label: 'Y', value: `${pct(crop.y)}%` },
{ label: 'W', value: `${pct(crop.width)}%` },
{ label: 'H', value: `${pct(crop.height)}%` },
]" :key="item.label" class="rounded-md border border-border bg-bg-inset py-1.5">
<dt class="text-[10px] font-medium uppercase tracking-wide text-fg-subtle">{{ item.label }}</dt>
<dd class="font-mono text-sm text-fg">{{ item.value }}</dd>
</div>
</dl>
<p class="text-xs text-fg-subtle">
Values are normalized fractions of the photo. Focus the region or a handle and use arrow keys
(<kbd class="rounded border border-border bg-bg px-1 font-mono">Shift</kbd> for larger steps).
</p>
</div>
</template>
+26
View File
@@ -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';
+441
View File
@@ -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);
}
@@ -0,0 +1,84 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The rendered curve of a `CurveEditorRoot`, an SVG `<path>` whose `d` is built
* by sampling `f(x)` across `domainX` (or the per-anchor bezier path in
* `'bezier'` mode) and projecting each sample to pixels. It is decorative
* (`role="presentation"` / `aria-hidden`) — the accessible controls are the
* `CurveEditorPoint` thumbs. The path `d` is also exposed as a slot prop for
* custom rendering (fills, glows).
*/
export interface CurveEditorCurveProps extends PrimitiveProps {
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCurveEditorContext } from './context';
import { useForwardExpose } from '@robonen/vue';
import { buildBezierPath, buildPolylinePath, sampleFnToPolyline } from '../../internal/spline';
const { as = 'path' } = defineProps<CurveEditorCurveProps>();
const ctx = useCurveEditorContext();
/** Project a domain point to pixel space via the axis scales. */
function project(x: number, y: number): { x: number; y: number } {
return { x: ctx.scaleX.scale(x), y: ctx.scaleY.scale(y) };
}
const SAMPLES = 256;
const pathD = computed<string>(() => {
const [x0, x1] = ctx.domainX.value;
if (ctx.interpolation.value === 'bezier') {
// Per-segment cubic: chain `M … C … C …` through projected anchors/handles.
const list = ctx.anchors.value;
if (list.length < 2) return '';
let d = '';
for (let i = 0; i < list.length - 1; i++) {
const a = list[i]!;
const b = list[i + 1]!;
const dx = b.x - a.x;
const c1x = a.outHandle ? a.x + a.outHandle.x : a.x + dx / 3;
const c1y = a.outHandle ? a.y + a.outHandle.y : a.y + (b.y - a.y) / 3;
const c2x = b.inHandle ? b.x + b.inHandle.x : b.x - dx / 3;
const c2y = b.inHandle ? b.y + b.inHandle.y : b.y - (b.y - a.y) / 3;
const p0 = project(a.x, a.y);
const pc1 = project(c1x, c1y);
const pc2 = project(c2x, c2y);
const p3 = project(b.x, b.y);
const seg = buildBezierPath(p0, pc1, pc2, p3);
// Drop the leading `M` on subsequent segments (they continue the path).
d += i === 0 ? seg : seg.replace(/^M[^C]*/, '');
}
return d;
}
// Sampled `y = f(x)` polyline projected to pixels.
const samples = sampleFnToPolyline(x => ctx.sample(x), x0, x1, SAMPLES);
for (let i = 0; i < samples.length; i++) {
const p = samples[i]!;
samples[i] = project(p.x, p.y);
}
return buildPolylinePath(samples);
});
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:d="as === 'path' ? pathD : undefined"
role="presentation"
aria-hidden="true"
fill="none"
data-curve-editor-curve=""
>
<slot :d="pathD" />
</Primitive>
</template>
@@ -0,0 +1,42 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The background gridlines of a `CurveEditorRoot`, drawn from `niceTicks` on
* both axes. Rendered as an SVG `<g>` by default (override via `as`) and marked
* `aria-hidden` — it is decorative. Exposes the projected `xTicks` / `yTicks` as
* slot props so the consumer draws their own lines / labels, and provides a
* `#histogram` slot region behind the curve for tone-curve histograms.
*/
export interface CurveEditorGridProps extends PrimitiveProps {
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCurveEditorContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'g' } = defineProps<CurveEditorGridProps>();
const ctx = useCurveEditorContext();
// `useScale` already projects ticks to pixels (`tick.px`); the x ticks run along
// the horizontal axis, the y ticks along the vertical (value-up) axis.
const xTicks = computed(() => ctx.scaleX.ticks.value);
const yTicks = computed(() => ctx.scaleY.ticks.value);
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
aria-hidden="true"
data-curve-editor-grid=""
>
<slot :x-ticks="xTicks" :y-ticks="yTicks" />
<slot name="histogram" :x-ticks="xTicks" :y-ticks="yTicks" />
</Primitive>
</template>
@@ -0,0 +1,102 @@
<script lang="ts">
import type { CurveEditorAnchor, CurveEditorHandleSide } from './context';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A bezier tangent handle for an anchor, rendered only in `'bezier'`
* interpolation. Dragging it adjusts the anchor's `inHandle` / `outHandle`
* tangent (relative deltas), clamped (in easing / `monotonicX` mode) so the
* cubic segment stays single-valued in x — the handle's x-component can't reach
* past the neighbouring anchor (`dx >= 0`), preventing an S-fold. It is exposed
* as `role="slider"` with a descriptive `aria-label`; pass `aria-hidden` to make
* it purely decorative. Positions itself at the tangent endpoint in pixel space.
*/
export interface CurveEditorHandleProps extends PrimitiveProps {
/** The anchor whose tangent this handle controls. */
anchor: CurveEditorAnchor;
/** Which tangent (`'in'` = incoming, `'out'` = outgoing). */
side: CurveEditorHandleSide;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCurveEditorContext } from './context';
import { usePointerDrag } from '../../internal/pointer-drag';
import { useForwardExpose } from '@robonen/vue';
const { anchor, side, as = 'div' } = defineProps<CurveEditorHandleProps>();
const ctx = useCurveEditorContext();
const isBezier = computed(() => ctx.interpolation.value === 'bezier');
// Default tangent (one-third toward the neighbour) when no handle is set yet, so
// the handle is grabbable before the consumer first drags it.
const handle = computed(() => {
const stored = side === 'in' ? anchor.inHandle : anchor.outHandle;
if (stored) return stored;
const list = ctx.anchors.value;
const i = ctx.indexOf(anchor.id);
const neighbour = side === 'out' ? list[i + 1] : list[i - 1];
if (!neighbour) return { x: 0, y: 0 };
return { x: (neighbour.x - anchor.x) / 3, y: (neighbour.y - anchor.y) / 3 };
});
// Tangent endpoint in domain space → pixels.
const tip = computed(() => {
const h = handle.value;
return {
x: ctx.scaleX.scale(anchor.x + h.x),
y: ctx.scaleY.scale(anchor.y + h.y),
};
});
const positionStyle = computed<{ left: string; top: string }>(() => ({
left: `${tip.value.x}px`,
top: `${tip.value.y}px`,
}));
const ariaLabel = computed(() => side === 'in' ? 'Incoming tangent handle' : 'Outgoing tangent handle');
const { forwardRef, currentElement } = useForwardExpose();
let originX = 0;
let originY = 0;
usePointerDrag(currentElement, {
axis: 'both',
threshold: 0,
disabled: () => ctx.disabled.value || !isBezier.value,
onStart: () => {
originX = tip.value.x;
originY = tip.value.y;
},
onMove: (state) => {
// Invert the dragged tip to domain space, then store the tangent relative to
// the anchor. `updateHandle` clamps for monotonic-x safety. Live update only;
// `anchorsCommit` is emitted once on settle (onCommit), not per rAF frame.
const tipX = ctx.scaleX.invert(originX + state.total.x);
const tipY = ctx.scaleY.invert(originY + state.total.y);
ctx.updateHandle(anchor.id, side, { x: tipX - anchor.x, y: tipY - anchor.y });
},
// Successful pointerup only (never on cancel/abort): emit the settled anchors.
onCommit: () => ctx.commit(),
});
</script>
<template>
<Primitive
v-if="isBezier"
:ref="forwardRef"
:as="as"
role="slider"
:aria-label="ariaLabel"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-side="side"
:style="positionStyle"
@keydown.stop
>
<slot :handle="handle" :x="tip.x" :y="tip.y" />
</Primitive>
</template>
@@ -0,0 +1,227 @@
<script lang="ts">
import type { CurveEditorAnchor } from './context';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* One anchor handle of a `CurveEditorRoot`, rendered as `role="slider"`. A 2D
* control whose single `aria-valuenow` (the output `y`) can't carry both axes,
* so `aria-valuetext` announces the pair as `"input {x}, output {y}"`.
* `aria-valuemin`/`max` describe the output (`y`) domain.
*
* Anchors share one tab-stop (roving focus): Tab moves focus between them, the
* arrow keys nudge the focused anchor. Left/Right nudge `x` by `step`
* (neighbour- and domain-clamped; no-op for fixed endpoints), Up/Down nudge `y`
* (Up = +y), Shift+Arrow uses the large step, PageUp/PageDown jump `y`, Home/End
* move `x` to the domain min/max. Enter adds an anchor at the midpoint to the
* next anchor; Delete/Backspace removes the focused anchor (never an endpoint).
* Double-click also adds, drag moves the anchor (2D, clamped). Exposes the
* anchor and its pixel position as slot props.
*/
export interface CurveEditorPointProps extends PrimitiveProps {
/** The anchor this point renders. */
anchor: CurveEditorAnchor;
/**
* Override the announced `aria-valuetext`. Receives the anchor's `x` and `y`
* in domain space.
*/
valueText?: (x: number, y: number) => string;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, useAttrs, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCurveEditorContext } from './context';
import { usePointerDrag } from '../../internal/pointer-drag';
import { useForwardExpose } from '@robonen/vue';
import { formatAnchorValueText } from './utils';
const { anchor, valueText, as = 'div' } = defineProps<CurveEditorPointProps>();
const ctx = useCurveEditorContext();
const attrs = useAttrs();
const index = computed(() => ctx.indexOf(anchor.id));
const isEndpoint = computed(() => ctx.isEndpoint(anchor.id));
const isActive = computed(() => ctx.activeIndex.value === index.value);
// Pixel position from the axis projections (x horizontal, y value-up vertical).
const pxX = computed(() => ctx.scaleX.scale(anchor.x));
const pxY = computed(() => ctx.scaleY.scale(anchor.y));
const positionStyle = computed<{ left: string; top: string }>(() => ({
left: `${pxX.value}px`,
top: `${pxY.value}px`,
}));
// `aria-valuemin`/`max`/`now` describe the OUTPUT (y) domain — the single axis a
// slider can express; `aria-valuetext` conveys both coordinates.
const domainY = computed(() => ctx.domainY.value);
const ariaValueMin = computed(() => Math.min(domainY.value[0], domainY.value[1]));
const ariaValueMax = computed(() => Math.max(domainY.value[0], domainY.value[1]));
const ariaValueText = computed<string | undefined>(() => {
if (attrs['aria-valuetext'] !== undefined && attrs['aria-valuetext'] !== null) return undefined;
if (valueText) return valueText(anchor.x, anchor.y);
return formatAnchorValueText(anchor.x, anchor.y);
});
// Roving focus: only the active anchor is in the tab order.
const tabindex = computed(() => {
if (ctx.disabled.value) return -1;
return isActive.value ? 0 : -1;
});
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (node) => {
ctx.registerAnchorEl(anchor.id, node ?? null);
});
onBeforeUnmount(() => ctx.registerAnchorEl(anchor.id, null));
// ── pointer drag (2D) ─────────────────────────────────────────────────────
// Map the element-relative pointer to domain x/y via inverse projection. The
// drag tracks the ROOT element's rect (the projections measure that box).
// Capture the anchor's pixel origin at drag start so cumulative drag totals
// (client px, which equal plot px 1:1) project back through the scales without
// needing the plot rect.
let dragOriginX = 0;
let dragOriginY = 0;
usePointerDrag(currentElement, {
axis: 'both',
threshold: 0,
disabled: () => ctx.disabled.value,
onStart: () => {
ctx.setActiveIndex(index.value);
dragOriginX = pxX.value;
dragOriginY = pxY.value;
},
onMove: (state) => {
const x = ctx.scaleX.invert(dragOriginX + state.total.x);
const y = ctx.scaleY.invert(dragOriginY + state.total.y);
// Live update only — the drag commits once on settle (onCommit), so
// `anchorsCommit` fires per the documented "after a drag settles" contract
// rather than once per rAF frame.
ctx.updateAnchor(anchor.id, { x, y });
},
// Successful pointerup only (never on cancel/abort): emit the settled anchors.
onCommit: () => ctx.commit(),
});
// ── keyboard ──────────────────────────────────────────────────────────────
// A keyboard nudge is a discrete settle: emit `anchorsCommit` once, only when
// the anchor actually moved (a clamped no-op at a domain edge does not commit,
// matching the original updateAnchor-only-on-change behaviour).
function nudge(dx: number, dy: number): void {
let changed = false;
if (dx !== 0) changed = ctx.updateAnchor(anchor.id, { x: anchor.x + dx }) || changed;
if (dy !== 0) changed = ctx.updateAnchor(anchor.id, { y: anchor.y + dy }) || changed;
if (changed) ctx.commit();
}
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value) return;
const rtl = ctx.direction.value === 'rtl';
const step = ctx.step.value;
const big = ctx.largeStep.value;
const unit = event.shiftKey ? big : step;
const [dyMin, dyMax] = ctx.domainY.value;
const ySpan = Math.abs(dyMax - dyMin);
const xLocked = ctx.fixedEndpoints.value && isEndpoint.value;
switch (event.key) {
case 'ArrowRight':
event.preventDefault();
if (!xLocked) nudge(rtl ? -unit : unit, 0);
return;
case 'ArrowLeft':
event.preventDefault();
if (!xLocked) nudge(rtl ? unit : -unit, 0);
return;
case 'ArrowUp':
event.preventDefault();
nudge(0, unit);
return;
case 'ArrowDown':
event.preventDefault();
nudge(0, -unit);
return;
case 'PageUp':
event.preventDefault();
if (ctx.updateAnchor(anchor.id, { y: anchor.y + ySpan * 0.1 })) ctx.commit();
return;
case 'PageDown':
event.preventDefault();
if (ctx.updateAnchor(anchor.id, { y: anchor.y - ySpan * 0.1 })) ctx.commit();
return;
case 'Home':
event.preventDefault();
if (!xLocked && ctx.updateAnchor(anchor.id, { x: ctx.domainX.value[0] })) ctx.commit();
return;
case 'End':
event.preventDefault();
if (!xLocked && ctx.updateAnchor(anchor.id, { x: ctx.domainX.value[1] })) ctx.commit();
return;
case 'Enter': {
event.preventDefault();
// Add an anchor between this one and the next (or before, at the last).
const list = ctx.anchors.value;
const i = index.value;
const next = list[i + 1] ?? list[i - 1];
if (!next) return;
const midX = (anchor.x + next.x) / 2;
ctx.addAnchor(midX);
return;
}
case 'Delete':
case 'Backspace':
event.preventDefault();
ctx.removeAnchor(anchor.id);
break;
default:
break;
}
}
function onDblClick(event: MouseEvent): void {
if (ctx.disabled.value) return;
event.preventDefault();
// Double-click on an interior anchor removes it; on an endpoint adds a midpoint.
if (!isEndpoint.value) {
ctx.removeAnchor(anchor.id);
return;
}
const list = ctx.anchors.value;
const i = index.value;
const neighbour = list[i + 1] ?? list[i - 1];
if (neighbour) ctx.addAnchor((anchor.x + neighbour.x) / 2);
}
function onFocus(): void {
if (index.value !== -1) ctx.setActiveIndex(index.value);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="slider"
:tabindex="tabindex"
:aria-valuemin="ariaValueMin"
:aria-valuemax="ariaValueMax"
:aria-valuenow="anchor.y"
:aria-valuetext="ariaValueText"
:aria-orientation="undefined"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-endpoint="isEndpoint ? '' : undefined"
:data-active="isActive ? '' : undefined"
:data-channel="ctx.channel.value"
:style="positionStyle"
@keydown="onKeyDown"
@dblclick="onDblClick"
@focus="onFocus"
>
<slot :anchor="anchor" :x="pxX" :y="pxY" :active="isActive" :endpoint="isEndpoint" />
</Primitive>
</template>
@@ -0,0 +1,428 @@
<script lang="ts">
import type { CurveEditorAnchor, CurveEditorChannel, CurveEditorDirection, CurveEditorHandleSide, CurveEditorInterpolation } from './context';
import type { Point } from '../../internal/utils/geometry';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A headless control-point curve editor: a draggable set of anchors defining a
* single-valued `y = f(x)` curve. It backs both **animation easing curves** (an
* ease over normalized time) and **photo tone curves** (per-RGB-channel output
* remapping), and is the shared engine reused by Levels (gamma) and the future
* KeyframeTrack.
*
* The root owns the anchor array (controlled via `v-model`, uncontrolled via
* `defaultValue`), builds value↔pixel projections for both axes (`useScale`,
* y-axis value-up), and exposes the live evaluator: `sample(x) → y` and
* `toLUT(size)` for applying the curve to pixels. With `monotonicX` (the
* default) anchors are neighbour-clamped so they can never cross in x — easing
* and tone curves both require a function of x. `fixedEndpoints` locks the first
* and last anchor in x. The `interpolation` mode selects monotone (default),
* linear, catmull-rom, or per-anchor bezier handles.
*
* Provides context to `CurveEditorGrid`, `CurveEditorCurve`, `CurveEditorPoint`,
* and `CurveEditorHandle`. The `channel` prop only tags which curve is being
* edited (for styling / the `#channel` slot); consumers render their own RGB
* tabs.
*/
export interface CurveEditorRootProps extends PrimitiveProps {
/** Uncontrolled initial anchors. Seeds the curve when `v-model` is absent. */
defaultValue?: CurveEditorAnchor[];
/**
* How the curve is interpolated between anchors.
* @default 'monotone'
*/
interpolation?: CurveEditorInterpolation;
/** Input (x) domain `[min, max]`. @default [0, 1] */
domainX?: readonly [number, number];
/** Output (y) domain `[min, max]`. @default [0, 1] */
domainY?: readonly [number, number];
/**
* Keep x single-valued: neighbour-clamp anchors so they can't cross in x.
* Easing / tone curves require a function of x. @default true
*/
monotonicX?: boolean;
/** Lock the first and last anchor in x (only y is editable). @default true */
fixedEndpoints?: boolean;
/**
* Tags which curve is being edited (composite `'value'` or per-channel
* `'r'`/`'g'`/`'b'`). Purely cosmetic — exposed for styling / the `#channel`
* slot; consumers render their own channel tabs. @default 'value'
*/
channel?: CurveEditorChannel;
/** Keyboard step for x/y nudges. @default 0.01 */
step?: number;
/** Large keyboard step (Shift+Arrow / Page keys). @default 0.1 */
largeStep?: number;
/** Sample count for the rendered polyline / LUT. @default 256 */
samples?: number;
/** Disable all interaction. @default false */
disabled?: boolean;
/** Writing direction (inherited from `ConfigProvider` when omitted). */
dir?: CurveEditorDirection;
}
export interface CurveEditorRootEmits {
/** Fired after a drag or keypress settles (anchor added / removed / moved). */
anchorsCommit: [anchors: CurveEditorAnchor[]];
/** Fired when an anchor is added. */
anchorAdd: [anchor: CurveEditorAnchor];
/** Fired when an anchor is removed. */
anchorRemove: [anchor: CurveEditorAnchor];
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef, toRef, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { provideCurveEditorContext } from './context';
import { useScale } from '../../internal/scale';
import { useDirection, useId } from '../../utilities/config-provider';
import { useForwardExpose } from '@robonen/vue';
import { buildEvaluator, clampAnchorX, clampAnchorY, sortAnchors } from './utils';
import { toLUT as splineToLUT } from '../../internal/spline';
const {
defaultValue,
interpolation = 'monotone',
domainX = [0, 1] as readonly [number, number],
domainY = [0, 1] as readonly [number, number],
monotonicX = true,
fixedEndpoints = true,
channel = 'value',
step = 0.01,
largeStep = 0.1,
samples = 256,
disabled = false,
dir,
as = 'div',
} = defineProps<CurveEditorRootProps>();
const direction = useDirection(() => dir);
const emit = defineEmits<CurveEditorRootEmits>();
const idBase = useId();
const model = defineModel<CurveEditorAnchor[] | null>();
/** Default curve: a straight identity line across the domain (two endpoints). */
function defaultAnchors(): CurveEditorAnchor[] {
const [x0, x1] = domainX;
const [y0, y1] = domainY;
return [
{ id: `${idBase.value}-0`, x: x0, y: y0 },
{ id: `${idBase.value}-1`, x: x1, y: y1 },
];
}
function seedAnchors(): CurveEditorAnchor[] {
const seed = Array.isArray(model.value) && model.value.length > 0
? model.value
: Array.isArray(defaultValue) && defaultValue.length > 0
? defaultValue
: defaultAnchors();
return sortAnchors(seed);
}
// `shallowRef` — the array is replaced wholesale on every mutation; items are
// plain (non-proxied) so the evaluator/render path reads them cheaply.
const localAnchors = shallowRef<CurveEditorAnchor[]>(seedAnchors());
watch(model, (v) => {
if (v === null || v === undefined) return;
if (v === localAnchors.value) return;
localAnchors.value = sortAnchors(v);
});
const anchors = computed<CurveEditorAnchor[]>({
get: () => localAnchors.value,
set: (v) => {
const sorted = sortAnchors(v);
localAnchors.value = sorted;
model.value = sorted;
},
});
// Auto-incrementing id seed for inserted anchors (stable across the session).
let idCounter = localAnchors.value.length;
function nextId(): string {
return `${idBase.value}-${idCounter++}`;
}
// ── geometry / projections ────────────────────────────────────────────────
// Plot size in pixels; measured from the root element after mount. Degenerate
// `[n, n]` ranges (zero-size pre-mount) are guarded by `scaleLinear` (returns
// range start), so projections never NaN.
const plotWidth = shallowRef(0);
const plotHeight = shallowRef(0);
const scaleX = useScale({
domain: () => domainX,
range: () => [0, plotWidth.value] as const,
orientation: 'horizontal',
clamp: true,
});
// y-axis is value-UP: domain start maps to the bottom (range end).
const scaleY = useScale({
domain: () => domainY,
range: () => [0, plotHeight.value] as const,
orientation: 'vertical',
clamp: true,
});
// ── evaluator ─────────────────────────────────────────────────────────────
const evaluator = computed(() => buildEvaluator(localAnchors.value, interpolation));
function sample(x: number): number {
return evaluator.value(x);
}
function toLUT(size: number = samples): number[] {
const [x0, x1] = domainX;
return splineToLUT(evaluator.value, size, x0, x1);
}
// ── roving focus ──────────────────────────────────────────────────────────
const activeIndex = ref(0);
const anchorEls = new Map<string, HTMLElement | null>();
function registerAnchorEl(id: string, el: HTMLElement | null): void {
if (el) anchorEls.set(id, el);
else anchorEls.delete(id);
}
// id → index map, rebuilt once per `localAnchors` replacement. Parts derive
// their `index`/`isEndpoint` from this O(1) lookup instead of each re-scanning
// the wholesale-replaced array every drag frame (was O(n) per part → O(n^2)
// across the list per committed frame).
const indexById = computed(() => {
const list = localAnchors.value;
const map = new Map<string, number>();
for (let i = 0; i < list.length; i++) map.set(list[i]!.id, i);
return map;
});
function indexOf(id: string): number {
return indexById.value.get(id) ?? -1;
}
function isEndpoint(id: string): boolean {
const i = indexById.value.get(id) ?? -1;
return i === 0 || i === localAnchors.value.length - 1;
}
function focusIndex(index: number): void {
const anchor = localAnchors.value[index];
if (!anchor) return;
anchorEls.get(anchor.id)?.focus();
}
function setActiveIndex(index: number): void {
const count = localAnchors.value.length;
if (count === 0) return;
const clamped = Math.min(Math.max(index, 0), count - 1);
activeIndex.value = clamped;
focusIndex(clamped);
}
function moveFocus(delta: number): void {
const count = localAnchors.value.length;
if (count === 0) return;
const next = ((activeIndex.value + delta) % count + count) % count;
setActiveIndex(next);
}
// Keep the active index valid as anchors are added/removed.
watch(() => localAnchors.value.length, (count) => {
if (count === 0) {
activeIndex.value = 0;
return;
}
if (activeIndex.value > count - 1) activeIndex.value = count - 1;
});
// ── mutations ─────────────────────────────────────────────────────────────
const minGap = computed(() => Math.max(step, (Math.abs(domainX[1] - domainX[0])) * 1e-3));
function commit(): void {
emit('anchorsCommit', localAnchors.value.map(a => ({ ...a })));
}
// Returns whether the anchor actually moved. The live update is decoupled from
// the `anchorsCommit` emit: callers commit on settle (drag end / keypress), not
// per frame. Returning the changed-flag lets discrete callers (keyboard) skip
// committing on a clamped no-op, matching the original "only emit on change".
function updateAnchor(id: string, next: { x?: number; y?: number }): boolean {
if (disabled) return false;
const list = localAnchors.value;
const index = indexOf(id);
if (index === -1) return false;
const current = list[index]!;
let x = current.x;
if (next.x !== undefined) {
x = clampAnchorX(list, index, next.x, {
domainMin: domainX[0],
domainMax: domainX[1],
monotonicX,
fixedEndpoints,
minGap: minGap.value,
});
}
let y = current.y;
if (next.y !== undefined)
y = clampAnchorY(next.y, domainY[0], domainY[1]);
if (x === current.x && y === current.y) return false;
const candidate = list.slice();
candidate[index] = { ...current, x, y };
// x never crosses a neighbour (clampAnchorX guarantees it under monotonicX),
// so order is preserved without a re-sort. Re-sort defensively when monotonicX
// is off (anchors may legitimately reorder).
anchors.value = monotonicX ? candidate : sortAnchors(candidate);
// Track the moved anchor's new index for roving focus.
activeIndex.value = indexOf(id);
return true;
}
function updateHandle(id: string, side: CurveEditorHandleSide, handle: Point): void {
if (disabled || interpolation !== 'bezier') return;
const list = localAnchors.value;
const index = indexOf(id);
if (index === -1) return;
const current = list[index]!;
// Easing requires the segment stay monotone in x: clamp the tangent so its
// x-component never points "backwards" past the adjacent anchor (dx >= 0).
const clamped = clampHandle(list, index, side, handle);
const candidate = list.slice();
candidate[index] = side === 'in'
? { ...current, inHandle: clamped }
: { ...current, outHandle: clamped };
anchors.value = candidate;
// Live update only; the handle drag commits once on settle (see onCommit).
}
/**
* Clamp a bezier tangent so the cubic segment stays single-valued in x
* (`dx >= 0`): the outgoing handle may not reach past the next anchor, the
* incoming handle may not reach before the previous anchor. Prevents the
* S-fold that would make the easing curve multi-valued.
*/
function clampHandle(list: readonly CurveEditorAnchor[], index: number, side: CurveEditorHandleSide, handle: Point): Point {
if (!monotonicX) return handle;
const current = list[index]!;
if (side === 'out') {
const next = list[index + 1];
const maxDx = next ? next.x - current.x : domainX[1] - current.x;
return { x: Math.min(Math.max(handle.x, 0), Math.max(0, maxDx)), y: handle.y };
}
const prev = list[index - 1];
const minDx = prev ? prev.x - current.x : domainX[0] - current.x;
return { x: Math.max(Math.min(handle.x, 0), Math.min(0, minDx)), y: handle.y };
}
function addAnchor(x: number, y?: number): string | undefined {
if (disabled) return undefined;
const lo = Math.min(domainX[0], domainX[1]);
const hi = Math.max(domainX[0], domainX[1]);
const cx = Math.min(Math.max(x, lo), hi);
const cy = clampAnchorY(y ?? sample(cx), domainY[0], domainY[1]);
const id = nextId();
const anchor: CurveEditorAnchor = { id, x: cx, y: cy };
const candidate = sortAnchors([...localAnchors.value, anchor]);
anchors.value = candidate;
setActiveIndex(indexOf(id));
emit('anchorAdd', { ...anchor });
commit();
return id;
}
function removeAnchor(id: string): void {
if (disabled) return;
if (isEndpoint(id)) return;
const index = indexOf(id);
if (index === -1) return;
const removed = localAnchors.value[index]!;
const candidate = localAnchors.value.slice();
candidate.splice(index, 1);
anchors.value = candidate;
setActiveIndex(Math.min(index, candidate.length - 1));
emit('anchorRemove', { ...removed });
commit();
}
provideCurveEditorContext({
anchors,
interpolation: toRef(() => interpolation),
domainX: toRef(() => domainX),
domainY: toRef(() => domainY),
scaleX,
scaleY,
channel: toRef(() => channel),
step: toRef(() => step),
largeStep: toRef(() => largeStep),
monotonicX: toRef(() => monotonicX),
fixedEndpoints: toRef(() => fixedEndpoints),
direction,
disabled: toRef(() => disabled),
activeIndex,
sample,
toLUT,
indexOf,
registerAnchorEl,
isEndpoint,
setActiveIndex,
moveFocus,
commit,
updateAnchor,
updateHandle,
addAnchor,
removeAnchor,
});
defineExpose({ sample, toLUT, anchors, addAnchor, removeAnchor });
// `useForwardExpose` runs AFTER `defineExpose` so it merges the prior bindings
// (plus props + `$el`) instead of clobbering them.
const { forwardRef, currentElement } = useForwardExpose();
// Measure the plot box once the root element resolves, and on resize.
watch(currentElement, (node, _prev, onCleanup) => {
if (!node) return;
const measure = (): void => {
const rect = node.getBoundingClientRect();
plotWidth.value = rect.width;
plotHeight.value = rect.height;
};
measure();
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(measure);
ro.observe(node);
// Disconnect on unmount AND before the next run (currentElement change).
// Without this each re-run stacks a new observer and the last one leaks,
// retaining the root node + the measure closure (+ this component scope).
onCleanup(() => ro.disconnect());
}
}, { immediate: false });
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:dir="direction"
:data-channel="channel"
:data-interpolation="interpolation"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
>
<slot
:anchors="anchors"
:channel="channel"
:interpolation="interpolation"
:sample="sample"
/>
</Primitive>
</template>
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(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<CurveEditorAnchor[] | undefined>(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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<SVGPathElement>('[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();
}
});
});
@@ -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<T>` — `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<CurveEditorAnchor[]>;
/** Active interpolation mode. */
interpolation: Ref<CurveEditorInterpolation>;
/** Input (x) domain `[min, max]`. */
domainX: Ref<readonly [number, number]>;
/** Output (y) domain `[min, max]`. */
domainY: Ref<readonly [number, number]>;
/** 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<CurveEditorChannel>;
/** Keyboard step for x/y nudges. */
step: Ref<number>;
/** Large keyboard step (Shift+Arrow / Page keys). */
largeStep: Ref<number>;
/** Whether x is kept single-valued (neighbour-clamped, anchors can't cross). */
monotonicX: Ref<boolean>;
/** Whether the first/last anchor are locked in x. */
fixedEndpoints: Ref<boolean>;
direction: Ref<CurveEditorDirection>;
disabled: Ref<boolean>;
/** Index of the currently focused anchor (roving focus tab-stop). */
activeIndex: Ref<number>;
/** 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>('CurveEditorContext');
export const provideCurveEditorContext = ctx.provide;
export const useCurveEditorContext = ctx.inject;
@@ -0,0 +1,183 @@
<script setup lang="ts">
import type { CurveEditorAnchor, CurveEditorChannel } from '@robonen/primitives';
import {
CurveEditorCurve,
CurveEditorGrid,
CurveEditorPoint,
CurveEditorRoot,
buildEvaluator,
} from '@robonen/primitives';
import { computed, ref } from 'vue';
// A tone curve with a gentle S-shape: lifted shadows, pulled highlights.
const anchors = ref<CurveEditorAnchor[]>([
{ id: 'a', x: 0, y: 0 },
{ id: 'b', x: 0.28, y: 0.18 },
{ id: 'c', x: 0.72, y: 0.86 },
{ id: 'd', x: 1, y: 1 },
]);
// Mirror the Root's evaluator with the exported `buildEvaluator` so the readout
// table below can show the curve's output at a few fixed inputs (same monotone
// interpolation the Root uses).
const sampleFn = computed(() => buildEvaluator(anchors.value, 'monotone'));
const probes = [0, 0.25, 0.5, 0.75, 1];
const readout = computed(() => probes.map(x => ({ x, y: sampleFn.value(x) })));
// Channel toggle — purely cosmetic here (tags the curve / data-channel), but it
// drives the accent color of the curve and points.
const channel = ref<CurveEditorChannel>('value');
const channels: Array<{ id: CurveEditorChannel; label: string }> = [
{ id: 'value', label: 'Value' },
{ id: 'r', label: 'Red' },
{ id: 'g', label: 'Green' },
{ id: 'b', label: 'Blue' },
];
const channelColor = computed(() => {
switch (channel.value) {
case 'r': return '#ef4444';
case 'g': return '#22c55e';
case 'b': return '#3b82f6';
default: return 'var(--color-accent, #6366f1)';
}
});
</script>
<template>
<div class="demo-card w-full max-w-md space-y-4 bg-bg p-6 text-fg">
<!-- Channel tabs -->
<div class="flex items-center justify-between">
<span class="text-sm font-medium">Tone curve</span>
<div class="flex gap-1 rounded-lg bg-bg-inset p-0.5">
<button
v-for="c in channels"
:key="c.id"
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="channel === c.id
? 'bg-bg-elevated text-fg shadow-(--shadow-card)'
: 'text-fg-muted hover:text-fg'"
@click="channel = c.id"
>
{{ c.label }}
</button>
</div>
</div>
<!-- Plot. The Root measures its OWN box as the plot, so it gets an explicit
size + relative positioning; absolutely-positioned SVG (grid + curve)
and the draggable Point thumbs all read pixel coords from that box. -->
<CurveEditorRoot
v-model="anchors"
:channel="channel"
interpolation="monotone"
:domain-x="[0, 1]"
:domain-y="[0, 1]"
class="relative aspect-square w-full touch-none select-none overflow-hidden rounded-card border border-border bg-bg-inset"
:style="{ '--curve-color': channelColor }"
>
<template #default>
<svg
class="pointer-events-none absolute inset-0 h-full w-full"
aria-hidden="true"
>
<!-- Gridlines from niceTicks on each axis (decorative). -->
<CurveEditorGrid v-slot="{ xTicks, yTicks }">
<line
v-for="t in xTicks"
:key="`x-${t.value}`"
:x1="t.px"
:y1="0"
:x2="t.px"
y2="100%"
class="stroke-border"
stroke-width="1"
/>
<line
v-for="t in yTicks"
:key="`y-${t.value}`"
x1="0"
:y1="t.px"
x2="100%"
:y2="t.px"
class="stroke-border"
stroke-width="1"
/>
</CurveEditorGrid>
<!-- Soft fill under the curve + the curve stroke itself. -->
<CurveEditorCurve v-slot="{ d }" as="g">
<!-- Close the stroked curve down to the bottom for a soft area fill.
`V/H` use unitless user-space px; the huge V is clipped by the
plot's `overflow-hidden`, so it always reaches the bottom edge. -->
<path
v-if="d"
:d="`${d} V 9999 H 0 Z`"
:fill="channelColor"
fill-opacity="0.10"
stroke="none"
/>
<path
:d="d"
fill="none"
:stroke="channelColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</CurveEditorCurve>
</svg>
<!-- Draggable anchor thumbs. Each Point sets its own left/top (px).
The `active`/`endpoint` slot props are scoped to the slot CHILDREN,
so the conditional styling lives on the inner span, not on the
CurveEditorPoint element's own `:class`. -->
<CurveEditorPoint
v-for="a in anchors"
:key="a.id"
v-slot="{ active, endpoint }"
:anchor="a"
class="absolute z-10 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring"
>
<span
class="block h-3.5 w-3.5 rounded-full border-2 bg-bg shadow-sm transition hover:scale-125"
:style="{ borderColor: 'var(--curve-color)' }"
:class="[
active ? 'scale-125 ring-2 ring-ring' : '',
endpoint ? 'rounded-sm' : '',
]"
/>
<span class="sr-only">{{ endpoint ? 'Endpoint' : 'Anchor' }}</span>
</CurveEditorPoint>
</template>
</CurveEditorRoot>
<!-- Sampled readout: f(x) at a few fixed inputs. -->
<div class="space-y-1.5 rounded-card bg-bg-inset p-3">
<div class="flex items-center justify-between text-xs text-fg-subtle">
<span>input x</span>
<span>output f(x)</span>
</div>
<div
v-for="p in readout"
:key="p.x"
class="flex items-center gap-3"
>
<span class="w-8 font-mono text-xs text-fg-muted">{{ p.x.toFixed(2) }}</span>
<div class="relative h-1.5 flex-1 overflow-hidden rounded-full bg-bg-elevated">
<div
class="absolute inset-y-0 left-0 rounded-full"
:style="{ width: `${Math.round(p.y * 100)}%`, backgroundColor: channelColor }"
/>
</div>
<span class="w-10 text-right font-mono text-xs tabular-nums text-fg">{{ p.y.toFixed(3) }}</span>
</div>
</div>
<p class="text-xs text-fg-subtle">
Drag the anchors. Double-click an endpoint to add a point, or an interior
point to remove it. Arrow keys nudge the focused anchor.
</p>
</div>
</template>
@@ -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';
@@ -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;
}
@@ -0,0 +1,90 @@
<script lang="ts">
export type FlowBackgroundVariant = 'dots' | 'lines' | 'cross';
/**
* A grid background drawn as an SVG `<pattern>` that pans and zooms with the
* viewport (the pattern origin shifts by `viewport % (gap·zoom)` and its cell
* scales by `zoom`). Sits behind the viewport layer, ignores pointer events, and
* is fully styleable via `[data-flow-background]` / `currentColor`.
*/
export interface FlowBackgroundProps {
/** Pattern style. @default 'dots' */
variant?: FlowBackgroundVariant;
/** Grid spacing in flow units (single value or `[x, y]`). @default 20 */
gap?: number | [number, number];
/** Dot radius / line thickness in px. @default 1 */
size?: number;
/** Pattern colour. @default 'currentColor' */
color?: string;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useFlowContext } from './context';
const { variant = 'dots', gap = 20, size = 1, color = 'currentColor' } = defineProps<FlowBackgroundProps>();
const ctx = useFlowContext();
const patternId = computed(() => `${ctx.flowId}__bg`);
const gapXY = computed<[number, number]>(() => (Array.isArray(gap) ? gap : [gap, gap]));
const scaled = computed(() => {
const vp = ctx.viewport.value;
return {
w: gapXY.value[0] * vp.zoom,
h: gapXY.value[1] * vp.zoom,
x: vp.x % (gapXY.value[0] * vp.zoom),
y: vp.y % (gapXY.value[1] * vp.zoom),
s: size * vp.zoom,
};
});
// Path for line / cross variants, drawn at the cell origin.
const linePath = computed(() => {
const { w, h } = scaled.value;
return variant === 'cross'
? `M ${w / 2} ${h / 2 - 3} V ${h / 2 + 3} M ${w / 2 - 3} ${h / 2} H ${w / 2 + 3}`
: `M ${w} 0 H 0 V ${h}`;
});
</script>
<template>
<svg
data-flow-background=""
:data-variant="variant"
:style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', color }"
>
<pattern
:id="patternId"
:x="scaled.x"
:y="scaled.y"
:width="scaled.w"
:height="scaled.h"
patternUnits="userSpaceOnUse"
>
<circle
v-if="variant === 'dots'"
:cx="scaled.w / 2"
:cy="scaled.h / 2"
:r="scaled.s"
fill="currentColor"
/>
<path
v-else
:d="linePath"
fill="none"
stroke="currentColor"
:stroke-width="scaled.s"
/>
</pattern>
<rect
x="0"
y="0"
width="100%"
height="100%"
:fill="`url(#${patternId})`"
/>
</svg>
</template>
@@ -0,0 +1,57 @@
<script lang="ts">
/**
* Live preview path drawn while a connection is being dragged from a handle to
* the pointer. Renders nothing when no connection is in progress. Only this
* component (and the hovered handle) subscribes to `connection`, so dragging a
* connection does not re-render nodes or committed edges. Override the visual via
* the default slot.
*/
export type FlowConnectionLineProps = Record<string, never>;
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useFlowContext } from './context';
import { getBezierPath } from './edge-paths';
const ctx = useFlowContext();
const connection = computed(() => ctx.connection.value);
const path = computed(() => {
const c = connection.value;
if (!c.inProgress || !c.fromPosition || !c.toPosition) return null;
const [d] = getBezierPath({
sourceX: c.fromPosition.x,
sourceY: c.fromPosition.y,
sourcePosition: c.fromHandle?.position ?? 'bottom',
targetX: c.toPosition.x,
targetY: c.toPosition.y,
targetPosition: 'top',
});
return d;
});
</script>
<template>
<g
v-if="path"
data-flow-connection-line=""
:data-valid="connection.isValid === true ? '' : undefined"
:data-invalid="connection.isValid === false ? '' : undefined"
>
<slot
:from="connection.fromPosition"
:to="connection.toPosition"
:valid="connection.isValid"
:path="path"
>
<path
:d="path"
fill="none"
stroke="currentColor"
:stroke-width="1"
:style="{ pointerEvents: 'none' }"
/>
</slot>
</g>
</template>
@@ -0,0 +1,59 @@
<script lang="ts">
import type { FlowPanelPosition } from './FlowPanel.vue';
/**
* Zoom-in / zoom-out / fit-view button cluster, hosted in a `FlowPanel`. Headless
* — every button is unstyled with a `data-flow-control` hook and an overridable
* icon slot (`#zoom-in`, `#zoom-out`, `#fit-view`); add more buttons via the
* default slot. Drives the canvas through `useFlow`.
*/
export interface FlowControlsProps {
/** Panel anchor. @default 'bottom-left' */
position?: FlowPanelPosition;
/** Accessible group label. @default 'Flow controls' */
ariaLabel?: string;
}
</script>
<script setup lang="ts">
import FlowPanel from './FlowPanel.vue';
import { useFlow } from './composables/useFlow';
const { position = 'bottom-left', ariaLabel = 'Flow controls' } = defineProps<FlowControlsProps>();
const api = useFlow();
</script>
<template>
<FlowPanel
:position="position"
data-flow-controls=""
role="group"
:aria-label="ariaLabel"
>
<button
type="button"
data-flow-control="zoom-in"
aria-label="Zoom in"
@click="api.zoomIn()"
>
<slot name="zoom-in">+</slot>
</button>
<button
type="button"
data-flow-control="zoom-out"
aria-label="Zoom out"
@click="api.zoomOut()"
>
<slot name="zoom-out"></slot>
</button>
<button
type="button"
data-flow-control="fit-view"
aria-label="Fit view"
@click="api.fitView()"
>
<slot name="fit-view"></slot>
</button>
<slot />
</FlowPanel>
</template>
+181
View File
@@ -0,0 +1,181 @@
<script lang="ts">
import type { CSSProperties, Component } from 'vue';
/**
* One edge. Reads only its own entry from `edgeLookup`, resolves endpoints from
* the source/target nodes' measured handle bounds (falling back to side
* centres), picks the path builder by `edge.type`, and memoizes the `d` string.
* Renders a visible path plus a transparent fat interaction path for hit-testing
* (no JS hit math). Customise via `#edge-<type>` slot or the `edgeTypes` map; the
* slot receives every path builder for full control.
*/
export interface FlowEdgeProps {
/** Edge id; matches the render key. */
id: string;
/** Component map keyed by `edge.type` (forwarded from the renderer). */
edgeTypes?: Record<string, Component>;
}
</script>
<script setup lang="ts">
import { computed, useSlots } from 'vue';
import { useFlowContext } from './context';
import type { EdgeMarkerType } from './types';
import type { PathResult } from './edge-paths';
import { getBezierPath, getMarkerId, getSmoothStepPath, getStepPath, getStraightPath } from './edge-paths';
import { findHandle, getAbsoluteHandlePoint, getDefaultEndpoint } from './utils';
const { id, edgeTypes } = defineProps<FlowEdgeProps>();
const ctx = useFlowContext();
const slots = useSlots();
const edge = computed(() => ctx.edgeLookup.value.get(id));
const sourceNode = computed(() => (edge.value ? ctx.nodeLookup.value.get(edge.value.source) : undefined));
const targetNode = computed(() => (edge.value ? ctx.nodeLookup.value.get(edge.value.target) : undefined));
const selected = computed(() => ctx.selection.value.edges.has(id));
const resolvedType = computed(() => edge.value?.type ?? 'default');
const endpoints = computed(() => {
const e = edge.value;
const s = sourceNode.value;
const t = targetNode.value;
if (!e || !s || !t) return null;
const sHandle = findHandle(s, 'source', e.sourceHandle);
const tHandle = findHandle(t, 'target', e.targetHandle);
const sPos = sHandle?.position ?? s.sourcePosition ?? 'bottom';
const tPos = tHandle?.position ?? t.targetPosition ?? 'top';
const sp = sHandle ? getAbsoluteHandlePoint(s.positionAbsolute, sHandle) : getDefaultEndpoint(s, sPos);
const tp = tHandle ? getAbsoluteHandlePoint(t.positionAbsolute, tHandle) : getDefaultEndpoint(t, tPos);
return { sp, tp, sPos, tPos };
});
const path = computed<PathResult>(() => {
const ep = endpoints.value;
if (!ep) return ['', 0, 0, 0, 0];
const params = {
sourceX: ep.sp.x,
sourceY: ep.sp.y,
sourcePosition: ep.sPos,
targetX: ep.tp.x,
targetY: ep.tp.y,
targetPosition: ep.tPos,
};
switch (resolvedType.value) {
case 'straight': return getStraightPath(params);
case 'step': return getStepPath(params);
case 'smoothstep': return getSmoothStepPath(params);
default: return getBezierPath(params);
}
});
const slotName = computed(() => {
const key = `edge-${resolvedType.value}`;
if (slots[key]) return key;
if (slots['edge']) return 'edge';
return null;
});
const TypeComponent = computed(() => edgeTypes?.[resolvedType.value]);
const slotProps = computed(() => {
const ep = endpoints.value;
const e = edge.value;
const [d, labelX, labelY] = path.value;
return {
id,
source: e?.source,
target: e?.target,
sourceX: ep?.sp.x ?? 0,
sourceY: ep?.sp.y ?? 0,
targetX: ep?.tp.x ?? 0,
targetY: ep?.tp.y ?? 0,
sourcePosition: ep?.sPos,
targetPosition: ep?.tPos,
selected: selected.value,
animated: e?.animated ?? false,
data: e?.data,
label: e?.label,
markerStart: e?.markerStart,
markerEnd: e?.markerEnd,
markerStartUrl: markerStartRef.value,
markerEndUrl: markerEndRef.value,
path: d,
labelX,
labelY,
getBezierPath,
getSmoothStepPath,
getStraightPath,
getStepPath,
};
});
function markerRef(marker: EdgeMarkerType | undefined): string | undefined {
if (!marker) return undefined;
if (typeof marker === 'string') return marker.startsWith('url(') ? marker : `url(#${marker})`;
return `url(#${getMarkerId(marker, ctx.flowId)})`;
}
const markerStartRef = computed(() => markerRef(edge.value?.markerStart));
const markerEndRef = computed(() => markerRef(edge.value?.markerEnd));
// Stable style objects (avoid allocating a fresh object on every edge render,
// which happens for incident edges on every pan/node-drag frame).
const visiblePathStyle = { pointerEvents: 'none' } as const;
// Only two outcomes exist (selectable vs not), so select between two frozen
// constants instead of allocating a fresh { pointerEvents, cursor } object each
// time edge.value identity changes (every drag/pan frame for incident edges).
const INTERACTION_STROKE = { pointerEvents: 'stroke', cursor: 'pointer' } as const;
const INTERACTION_NONE = { pointerEvents: 'none', cursor: 'pointer' } as const;
const interactionPathStyle = computed<CSSProperties>(() =>
edge.value?.selectable === false ? INTERACTION_NONE : INTERACTION_STROKE,
);
function onPointerdown(event: PointerEvent): void {
if (event.button !== 0 || edge.value?.selectable === false || !ctx.elementsSelectable.value) return;
event.stopPropagation();
ctx.selectEdge(id, event.shiftKey || event.metaKey || event.ctrlKey);
}
</script>
<template>
<g
v-if="endpoints"
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, markerStartRef, markerEndRef]"
data-flow-edge=""
:data-id="id"
:data-type="resolvedType"
:data-selected="selected ? '' : undefined"
:data-animated="edge?.animated ? '' : undefined"
>
<slot
v-if="slotName"
:name="slotName"
v-bind="slotProps"
/>
<component
:is="TypeComponent"
v-else-if="TypeComponent"
v-bind="slotProps"
/>
<template v-else>
<path
:d="path[0]"
data-flow-edge-path=""
fill="none"
stroke="currentColor"
:stroke-width="1"
:marker-start="markerStartRef"
:marker-end="markerEndRef"
:style="visiblePathStyle"
/>
<path
:d="path[0]"
fill="none"
stroke="transparent"
:stroke-width="20"
:style="interactionPathStyle"
@pointerdown="onPointerdown"
/>
</template>
</g>
</template>
@@ -0,0 +1,107 @@
<script lang="ts">
import type { Component } from 'vue';
/**
* The single shared `<svg>` for all edges, living inside the viewport transform
* so edge coordinates are plain flow-space numbers. `overflow:visible` lets
* paths draw outside the nominal box; `pointer-events:none` here, re-enabled per
* edge on the fat interaction path. Markers are deduped into one `<defs>`.
* Rendered under the nodes (earlier in DOM) so nodes paint on top.
*/
export interface FlowEdgeRendererProps {
edgeTypes?: Record<string, Component>;
}
</script>
<script setup lang="ts">
import { computed, useSlots } from 'vue';
import { useFlowContext } from './context';
import type { EdgeMarker } from './types';
import { getMarkerId } from './edge-paths';
import FlowEdge from './FlowEdge.vue';
import FlowConnectionLine from './FlowConnectionLine.vue';
defineProps<FlowEdgeRendererProps>();
defineOptions({ inheritAttrs: false });
const ctx = useFlowContext();
const slots = useSlots();
// Stable name list — avoids new dynamic-slot identities per render.
const edgeSlotNames = computed(() => Object.keys(slots).filter(n => n !== 'defs' && n !== 'connection-line'));
/** Deduped set of object markers across all edges, rendered once in `<defs>`. */
const markers = computed(() => {
const map = new Map<string, EdgeMarker>();
for (const edge of ctx.edgeLookup.value.values()) {
for (const marker of [edge.markerStart, edge.markerEnd]) {
if (marker && typeof marker === 'object') {
const mid = getMarkerId(marker, ctx.flowId);
if (!map.has(mid)) map.set(mid, marker);
}
}
}
return [...map.entries()].map(([id, marker]) => ({ id, marker }));
});
</script>
<template>
<svg
data-flow-edges=""
:style="{ position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', overflow: 'visible', pointerEvents: 'none' }"
>
<defs>
<marker
v-for="{ id, marker } in markers"
:id="id"
:key="id"
viewBox="-10 -10 20 20"
:markerWidth="marker.width ?? 12"
:markerHeight="marker.height ?? 12"
:markerUnits="marker.markerUnits ?? 'strokeWidth'"
:orient="marker.orient ?? 'auto-start-reverse'"
refX="0"
refY="0"
>
<polyline
:stroke="marker.color ?? 'currentColor'"
:fill="marker.type === 'arrowclosed' ? (marker.color ?? 'currentColor') : 'none'"
:stroke-width="marker.strokeWidth ?? 1"
stroke-linecap="round"
stroke-linejoin="round"
:points="marker.type === 'arrowclosed' ? '-5,-4 0,0 -5,4 -5,-4' : '-5,-4 0,0 -5,4'"
/>
</marker>
<slot name="defs" />
</defs>
<FlowEdge
v-for="id in ctx.visibleEdgeIds.value"
:id="id"
:key="id"
:edge-types="edgeTypes"
>
<template
v-for="name in edgeSlotNames"
:key="name"
#[name]="sp"
>
<slot
:name="name"
v-bind="sp ?? {}"
/>
</template>
</FlowEdge>
<FlowConnectionLine>
<template
v-if="$slots['connection-line']"
#default="sp"
>
<slot
name="connection-line"
v-bind="sp ?? {}"
/>
</template>
</FlowConnectionLine>
</svg>
</template>
@@ -0,0 +1,91 @@
<script lang="ts">
import type { CSSProperties } from 'vue';
import type { PrimitiveProps } from '../../internal/primitive';
import type { HandleType, IsValidConnection, Position } from './types';
/**
* A connection anchor placed inside a custom node. Registers itself with the
* node sub-context (triggering a re-measure of handle geometry), positions
* itself on the given side by default, and starts a connection on pointerdown.
* The visual (size/colour) is the consumer's via `[data-flow-handle]`; only the
* side positioning is applied inline. `data-handleid` / `data-handletype` /
* `data-handlepos` drive measurement and styling hooks.
*/
export interface FlowHandleProps extends PrimitiveProps {
/** Whether this handle starts (`source`) or ends (`target`) connections. */
type: HandleType;
/** Side of the node the handle sits on. */
position: Position;
/** Handle id; required when a node has multiple handles of one type. */
id?: string | null;
/** Per-handle connect enable (defaults to the node's `connectable`). */
isConnectable?: boolean;
/** Per-handle connection validator (composed with the global one). */
isValidConnection?: IsValidConnection;
}
// Module-level: shared across every handle instance, never mutated (Vue style
// binding only reads). Avoids rebuilding a style object per render/per drag frame.
const BASE: CSSProperties = { position: 'absolute', transform: 'translate(-50%, -50%)', pointerEvents: 'all' };
const POSITION_STYLES: Record<Position, CSSProperties> = {
top: { ...BASE, top: '0', left: '50%' },
bottom: { ...BASE, top: '100%', left: '50%' },
left: { ...BASE, top: '50%', left: '0' },
right: { ...BASE, top: '50%', left: '100%' },
};
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useFlowContext, useFlowNodeContext } from './context';
// `isConnectable` MUST default to `undefined` (not via destructure default):
// Vue coerces an absent Boolean prop to `false`, which would defeat the
// `?? nodeCtx.connectable` fallback and make every handle non-connectable.
const props = withDefaults(defineProps<FlowHandleProps>(), {
id: null,
isConnectable: undefined,
as: 'div',
});
const ctx = useFlowContext();
const nodeCtx = useFlowNodeContext();
const { forwardRef, currentElement } = useForwardExpose();
const connectable = computed(() => (props.isConnectable ?? nodeCtx.connectable.value) && ctx.nodesConnectable.value);
onMounted(() => {
const el = currentElement.value;
if (el) nodeCtx.registerHandle({ id: props.id, type: props.type, position: props.position, element: el });
});
onBeforeUnmount(() => nodeCtx.unregisterHandle(props.id, props.type));
function onPointerdown(event: PointerEvent): void {
if (event.button !== 0 || !connectable.value || !ctx.interactive.value) return;
// Don't let the node start dragging / the pane start panning.
event.stopPropagation();
const el = currentElement.value;
if (!el) return;
ctx.startConnection({ id: props.id, type: props.type, position: props.position, element: el }, nodeCtx.nodeId);
}
const positionStyle = computed(() => POSITION_STYLES[props.position]);
</script>
<template>
<Primitive
:ref="forwardRef"
:as="props.as"
data-flow-handle=""
:data-handleid="props.id ?? ''"
:data-handletype="props.type"
:data-handlepos="props.position"
:data-connectable="connectable ? '' : undefined"
:style="positionStyle"
@pointerdown="onPointerdown"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,113 @@
<script lang="ts">
import type { FlowPanelPosition } from './FlowPanel.vue';
/**
* A scaled overview of the graph with a viewport indicator. Auto-frames all
* nodes plus the current viewport, draws a `<rect>` per node, and (when
* `pannable`) recenters the viewport on click. Node rects expose `data-id` /
* `data-selected` for styling; size and colour are the consumer's.
*/
export interface FlowMiniMapProps {
/** Panel anchor. @default 'bottom-right' */
position?: FlowPanelPosition;
/** Map width in px. @default 200 */
width?: number;
/** Map height in px. @default 150 */
height?: number;
/** Click the map to recenter the viewport. @default true */
pannable?: boolean;
/** Accessible label. @default 'Mini map' */
ariaLabel?: string;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import FlowPanel from './FlowPanel.vue';
import { useFlowContext } from './context';
import { getNodesBounds, visibleFlowRect } from './utils';
const {
position = 'bottom-right',
width = 200,
height = 150,
pannable = true,
ariaLabel = 'Mini map',
} = defineProps<FlowMiniMapProps>();
const ctx = useFlowContext();
const { forwardRef, currentElement } = useForwardExpose();
const nodes = computed(() => [...ctx.nodeLookup.value.values()].filter(n => !n.hidden));
const viewRect = computed(() => visibleFlowRect(ctx.viewport.value, ctx.paneRect.value, 0));
const bounds = computed(() => {
const b = getNodesBounds(nodes.value);
const v = viewRect.value;
const x1 = Math.min(b.x, v.x);
const y1 = Math.min(b.y, v.y);
const x2 = Math.max(b.x + b.width, v.x + v.width);
const y2 = Math.max(b.y + b.height, v.y + v.height);
const w = x2 - x1 || 1;
const h = y2 - y1 || 1;
const pad = Math.max(w, h) * 0.1;
return { x: x1 - pad, y: y1 - pad, width: w + 2 * pad, height: h + 2 * pad };
});
const viewBox = computed(() => `${bounds.value.x} ${bounds.value.y} ${bounds.value.width} ${bounds.value.height}`);
const maskStroke = computed(() => (bounds.value.width / width) * 1.5);
function onPointerdown(event: PointerEvent): void {
if (!pannable) return;
const el = currentElement.value;
if (!el) return;
const r = el.getBoundingClientRect();
const fx = bounds.value.x + ((event.clientX - r.left) / r.width) * bounds.value.width;
const fy = bounds.value.y + ((event.clientY - r.top) / r.height) * bounds.value.height;
const vp = ctx.viewport.value;
const pane = ctx.paneRect.value;
ctx.viewport.value = { zoom: vp.zoom, x: pane.width / 2 - fx * vp.zoom, y: pane.height / 2 - fy * vp.zoom };
}
</script>
<template>
<FlowPanel :position="position">
<svg
:ref="forwardRef"
data-flow-minimap=""
role="img"
:aria-label="ariaLabel"
:width="width"
:height="height"
:viewBox="viewBox"
preserveAspectRatio="xMidYMid meet"
:style="{ cursor: pannable ? 'pointer' : undefined }"
@pointerdown="onPointerdown"
>
<rect
v-for="n in nodes"
:key="n.id"
v-memo="[n.positionAbsolute.x, n.positionAbsolute.y, n.measured.width, n.measured.height, ctx.selection.value.nodes.has(n.id)]"
data-flow-minimap-node=""
:data-id="n.id"
:data-selected="ctx.selection.value.nodes.has(n.id) ? '' : undefined"
:x="n.positionAbsolute.x"
:y="n.positionAbsolute.y"
:width="n.measured.width"
:height="n.measured.height"
fill="currentColor"
/>
<rect
data-flow-minimap-mask=""
:x="viewRect.x"
:y="viewRect.y"
:width="viewRect.width"
:height="viewRect.height"
fill="none"
stroke="currentColor"
:stroke-width="maskStroke"
/>
</svg>
</FlowPanel>
</template>
+168
View File
@@ -0,0 +1,168 @@
<script lang="ts">
import type { Component } from 'vue';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* One node. Reads only its own entry from `nodeLookup` (computed by id) so
* moving another node never re-renders it; `v-memo` short-circuits patches when
* its position/size/selection are unchanged. Positions itself with a plain
* `translate` in flow space (the viewport applies zoom), measures itself once
* via a ResizeObserver, wires drag, and provides `FlowNodeContext` to handles /
* resizer / toolbar. Resolves its renderer from `#node-<type>` slot →
* `nodeTypes[type]` → `#node` slot.
*/
export interface FlowNodeProps extends PrimitiveProps {
/** Node id; matches the render key. */
id: string;
/** Component map keyed by `node.type` (forwarded from the renderer). */
nodeTypes?: Record<string, Component>;
}
</script>
<script setup lang="ts">
import { computed, nextTick, useSlots, watch } from 'vue';
import { useForwardExpose, useResizeObserver } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { provideFlowNodeContext, useFlowContext } from './context';
import type { FlowNodeContext, HandleRegistration } from './context';
import type { HandleType } from './types';
import { useNodeDrag } from './composables/useNodeDrag';
import { getHandleBoundsFromDom } from './utils';
const { id, nodeTypes, as = 'div' } = defineProps<FlowNodeProps>();
const ctx = useFlowContext();
const slots = useSlots();
const { forwardRef, currentElement } = useForwardExpose();
const node = computed(() => ctx.nodeLookup.value.get(id));
const selected = computed(() => ctx.selection.value.nodes.has(id));
const dragging = computed(() => node.value?.dragging ?? false);
const positionAbsolute = computed(() => node.value?.positionAbsolute ?? { x: 0, y: 0 });
const measured = computed(() => node.value?.measured ?? { width: 0, height: 0 });
const connectable = computed(() => ctx.nodesConnectable.value && node.value?.connectable !== false);
const selectable = computed(() => ctx.elementsSelectable.value && node.value?.selectable !== false);
// Position by the ABSOLUTE flow point (parent chain summed) so subflow children
// land inside their parent even though all nodes are flat siblings in the DOM.
const transform = computed(() => `translate(${positionAbsolute.value.x}px, ${positionAbsolute.value.y}px)`);
const zIndex = computed(() => node.value?.zIndex ?? (selected.value || dragging.value ? 1000 : 0));
const resolvedType = computed(() => node.value?.type ?? 'default');
const slotName = computed(() => {
const key = `node-${resolvedType.value}`;
if (slots[key]) return key;
if (slots['node']) return 'node';
return null;
});
const TypeComponent = computed(() => nodeTypes?.[resolvedType.value]);
const slotProps = computed(() => ({
id,
type: resolvedType.value,
data: node.value?.data,
selected: selected.value,
dragging: dragging.value,
connectable: connectable.value,
positionAbsolute: positionAbsolute.value,
width: measured.value.width,
height: measured.value.height,
sourcePosition: node.value?.sourcePosition,
targetPosition: node.value?.targetPosition,
}));
// ── measurement ──────────────────────────────────────────────────────────
function measure(): void {
const el = currentElement.value;
if (!el) return;
const rect = el.getBoundingClientRect();
const zoom = ctx.viewport.value.zoom || 1;
// getBoundingClientRect returns the *scaled* box under the viewport's CSS
// scale; dividing by zoom recovers zoom-independent flow-space geometry.
ctx.setNodeMeasured(id, { width: rect.width / zoom, height: rect.height / zoom }, getHandleBoundsFromDom(el, zoom));
}
useResizeObserver(currentElement, () => measure());
watch(currentElement, (el) => {
if (el) nextTick(measure);
}, { immediate: true });
// ── drag + selection ──────────────────────────────────────────────────────
useNodeDrag(currentElement, ctx, () => id);
function onPointerdown(event: PointerEvent): void {
if (event.button !== 0 || !selectable.value) return;
const additive = event.shiftKey || event.metaKey || event.ctrlKey;
if (!selected.value || additive) ctx.selectNode(id, additive);
}
// ── node sub-context (handles re-trigger measurement when they mount) ───────
const handleIds = new Set<string>();
function registerHandle(reg: HandleRegistration): void {
handleIds.add(`${reg.type}:${reg.id ?? ''}`);
nextTick(measure);
}
function unregisterHandle(handleId: string | null, type: HandleType): void {
handleIds.delete(`${type}:${handleId ?? ''}`);
nextTick(measure);
}
const nodeContext: FlowNodeContext = {
nodeId: id,
node,
positionAbsolute,
measured,
selected,
dragging,
connectable,
nodeRef: currentElement,
registerHandle,
unregisterHandle,
};
provideFlowNodeContext(nodeContext);
</script>
<template>
<Primitive
v-memo="[positionAbsolute.x, positionAbsolute.y, selected, dragging, connectable, measured.width, measured.height, resolvedType, node?.data, node?.width, node?.height]"
:ref="forwardRef"
:as="as"
data-flow-node=""
:data-id="id"
:data-selected="selected ? '' : undefined"
:data-dragging="dragging ? '' : undefined"
:data-selectable="selectable ? '' : undefined"
:data-connectable="connectable ? '' : undefined"
:data-type="resolvedType"
:tabindex="ctx.disableKeyboardA11y.value ? undefined : 0"
:aria-label="node?.ariaLabel"
:style="{
position: 'absolute',
top: '0',
left: '0',
transform,
zIndex,
width: node?.width ? `${node.width}px` : undefined,
height: node?.height ? `${node.height}px` : undefined,
willChange: dragging ? 'transform' : undefined,
pointerEvents: ctx.interactive.value ? undefined : 'none',
}"
@pointerdown="onPointerdown"
>
<slot
v-if="slotName"
:name="slotName"
v-bind="slotProps"
/>
<component
:is="TypeComponent"
v-else-if="TypeComponent"
v-bind="slotProps"
/>
<slot
v-else
name="node-default"
v-bind="slotProps"
/>
</Primitive>
</template>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { Component } from 'vue';
/**
* Iterates the visible node ids and renders one `FlowNode` per id, keyed by id
* so virtualization re-inclusion patches in place. Forwards the `#node-<type>`
* slots and the `nodeTypes` component map down to each node. Renderless (returns
* a fragment of nodes directly into the viewport — no wrapper element).
*/
export interface FlowNodeRendererProps {
nodeTypes?: Record<string, Component>;
}
</script>
<script setup lang="ts">
import { computed, useSlots } from 'vue';
import { useFlowContext } from './context';
import FlowNode from './FlowNode.vue';
defineProps<FlowNodeRendererProps>();
defineOptions({ inheritAttrs: false });
const ctx = useFlowContext();
const slots = useSlots();
// Stable name list (mirrors FlowRoot) — avoids new dynamic-slot identities per render.
const slotNames = computed(() => Object.keys(slots));
</script>
<template>
<FlowNode
v-for="id in ctx.visibleNodeIds.value"
:id="id"
:key="id"
:node-types="nodeTypes"
>
<template
v-for="name in slotNames"
:key="name"
#[name]="sp"
>
<slot
:name="name"
v-bind="sp ?? {}"
/>
</template>
</FlowNode>
</template>
@@ -0,0 +1,173 @@
<script lang="ts">
import type { CSSProperties } from 'vue';
/**
* In-node resize handles (8 control points). Placed inside a custom node, it
* resizes that node by writing explicit `width`/`height` (and `position` for
* top/left edges, so the opposite edge stays fixed). Deltas are converted to
* flow space (`/zoom`), clamped to min/max, and committed through `updateNode`.
* Handles are unstyled — target `[data-flow-resize-handle][data-position]`.
*/
export interface FlowNodeResizerProps {
/** Minimum width in flow units. @default 10 */
minWidth?: number;
/** Maximum width in flow units. @default Infinity */
maxWidth?: number;
/** Minimum height in flow units. @default 10 */
minHeight?: number;
/** Maximum height in flow units. @default Infinity */
maxHeight?: number;
/** Keep the node's aspect ratio while resizing. @default false */
keepAspectRatio?: boolean;
}
interface Control {
position: string;
cursor: string;
style: CSSProperties;
}
</script>
<script setup lang="ts">
import { onScopeDispose } from 'vue';
import { clamp } from '@robonen/stdlib';
import { useFlowContext, useFlowNodeContext } from './context';
import { capturePointer, releasePointer } from './composables/dom';
const {
minWidth = 10,
maxWidth = Number.POSITIVE_INFINITY,
minHeight = 10,
maxHeight = Number.POSITIVE_INFINITY,
keepAspectRatio = false,
} = defineProps<FlowNodeResizerProps>();
const ctx = useFlowContext();
const nodeCtx = useFlowNodeContext();
const CONTROLS: Control[] = [
{ position: 'top-left', cursor: 'nwse-resize', style: { top: '0', left: '0' } },
{ position: 'top', cursor: 'ns-resize', style: { top: '0', left: '50%' } },
{ position: 'top-right', cursor: 'nesw-resize', style: { top: '0', left: '100%' } },
{ position: 'right', cursor: 'ew-resize', style: { top: '50%', left: '100%' } },
{ position: 'bottom-right', cursor: 'nwse-resize', style: { top: '100%', left: '100%' } },
{ position: 'bottom', cursor: 'ns-resize', style: { top: '100%', left: '50%' } },
{ position: 'bottom-left', cursor: 'nesw-resize', style: { top: '100%', left: '0' } },
{ position: 'left', cursor: 'ew-resize', style: { top: '50%', left: '0' } },
];
let pointerId = -1;
let activeEl: HTMLElement | undefined;
let control = '';
let startClientX = 0;
let startClientY = 0;
let startW = 0;
let startH = 0;
let startX = 0;
let startY = 0;
let lastEvent: PointerEvent | null = null;
let rafId: number | null = null;
function flush(): void {
rafId = null;
if (!lastEvent) return;
const node = nodeCtx.node.value;
if (!node) return;
const zoom = ctx.viewport.value.zoom || 1;
const dx = (lastEvent.clientX - startClientX) / zoom;
const dy = (lastEvent.clientY - startClientY) / zoom;
const ratio = startH === 0 ? 1 : startW / startH;
let w = startW;
let h = startH;
if (control.includes('right')) w = startW + dx;
if (control.includes('left')) w = startW - dx;
if (control.includes('bottom')) h = startH + dy;
if (control.includes('top')) h = startH - dy;
w = clamp(w, minWidth, maxWidth);
h = clamp(h, minHeight, maxHeight);
if (keepAspectRatio) {
if (control === 'left' || control === 'right') h = w / ratio;
else if (control === 'top' || control === 'bottom') w = h * ratio;
else h = w / ratio;
}
let x = startX;
let y = startY;
if (control.includes('left')) x = startX + (startW - w);
if (control.includes('top')) y = startY + (startH - h);
ctx.updateNode(node.id, { width: w, height: h, position: { x, y } });
}
function onMove(event: PointerEvent): void {
if (event.pointerId !== pointerId) return;
lastEvent = event;
if (rafId === null) rafId = requestAnimationFrame(flush);
}
function onUp(event: PointerEvent): void {
if (event.pointerId !== pointerId) return;
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
flush();
releasePointer(activeEl, event.pointerId);
globalThis.removeEventListener?.('pointermove', onMove);
globalThis.removeEventListener?.('pointerup', onUp);
pointerId = -1;
activeEl = undefined;
}
function onPointerdown(event: PointerEvent, c: Control): void {
if (event.button !== 0 || !ctx.interactive.value) return;
const node = nodeCtx.node.value;
if (!node) return;
event.stopPropagation();
event.preventDefault();
control = c.position;
pointerId = event.pointerId;
activeEl = event.currentTarget as HTMLElement;
startClientX = event.clientX;
startClientY = event.clientY;
startW = node.measured.width;
startH = node.measured.height;
startX = node.position.x;
startY = node.position.y;
capturePointer(activeEl, event.pointerId);
globalThis.addEventListener?.('pointermove', onMove);
globalThis.addEventListener?.('pointerup', onUp);
}
onScopeDispose(() => {
globalThis.removeEventListener?.('pointermove', onMove);
globalThis.removeEventListener?.('pointerup', onUp);
});
</script>
<template>
<div
v-for="c in CONTROLS"
:key="c.position"
class="nodrag"
data-flow-resize-handle=""
:data-position="c.position"
:style="{
position: 'absolute',
transform: 'translate(-50%, -50%)',
cursor: c.cursor,
touchAction: 'none',
...c.style,
}"
@pointerdown="onPointerdown($event, c)"
>
<slot
:position="c.position"
/>
</div>
</template>
@@ -0,0 +1,91 @@
<script lang="ts">
import type { CSSProperties } from 'vue';
import { Teleport, computed, defineComponent, h, mergeProps } from 'vue';
import { useFlowContext, useFlowNodeContext } from './context';
import type { Position } from './types';
/**
* A contextual toolbar anchored to its node, teleported out of the transformed
* layer so it renders at a constant 1:1 scale regardless of zoom. Position is
* recomputed from the node's absolute rect via `flowToScreen` (fixed
* positioning). Visible when the node is selected by default; override with
* `isVisible`. Placed inside a custom node component.
*
* Implemented as a render function: a `<Teleport>` toggled by `v-if` at an SFC
* template root does not reliably re-subscribe to its visibility source, so the
* conditional teleport is expressed directly here.
*/
export interface FlowNodeToolbarProps {
/** Force visibility; defaults to "visible while the node is selected". */
isVisible?: boolean;
/** Side of the node to anchor to. @default 'top' */
position?: Position;
/** Gap from the node edge in px. @default 8 */
offset?: number;
/** Teleport target. @default 'body' */
to?: string;
}
const TRANSFORMS = (offset: number): Record<Position, string> => ({
top: `translate(-50%, calc(-100% - ${offset}px))`,
bottom: `translate(-50%, ${offset}px)`,
left: `translate(calc(-100% - ${offset}px), -50%)`,
right: `translate(${offset}px, -50%)`,
});
export default defineComponent({
name: 'FlowNodeToolbar',
inheritAttrs: false,
props: {
isVisible: { type: Boolean, default: undefined },
position: { type: String as () => Position, default: 'top' },
offset: { type: Number, default: 8 },
to: { type: String, default: 'body' },
},
setup(props, { slots, attrs }) {
const ctx = useFlowContext();
const nodeCtx = useFlowNodeContext();
const visible = computed(() => (props.isVisible === undefined ? nodeCtx.selected.value : props.isVisible));
const style = computed<CSSProperties>(() => {
const node = nodeCtx.node.value;
if (!node) return { display: 'none' };
const { x, y } = node.positionAbsolute;
const { width, height } = node.measured;
let fx = x + width / 2;
let fy = y + height / 2;
if (props.position === 'top') fy = y;
else if (props.position === 'bottom') fy = y + height;
else if (props.position === 'left') fx = x;
else if (props.position === 'right') fx = x + width;
const a = ctx.flowToScreen({ x: fx, y: fy });
return {
position: 'fixed',
left: `${a.x}px`,
top: `${a.y}px`,
transform: TRANSFORMS(props.offset)[props.position],
pointerEvents: 'all',
zIndex: '1000',
};
});
return () =>
visible.value
? h(Teleport, { to: props.to }, [
h(
'div',
mergeProps(attrs, {
'data-flow-node-toolbar': '',
'data-position': props.position,
style: style.value,
onPointerdown: (e: Event) => e.stopPropagation(),
onWheel: (e: Event) => e.stopPropagation(),
}),
slots.default?.(),
),
])
: null;
},
});
</script>
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The interaction surface. Clips the canvas (`overflow:hidden`), disables native
* touch gestures (`touch-action:none`), reports its bounding rect into the
* context as the screen origin for coordinate math, and hosts the wheel/drag
* pan-zoom, marquee-selection, connection and keyboard layers. Background click
* clears the selection. Rendered by `FlowRoot`; not usually placed directly.
*/
export interface FlowPaneProps extends PrimitiveProps {
/** Drag the empty pane to pan. @default true */
panOnDrag?: boolean;
/** Wheel scroll pans instead of zooms. @default false */
panOnScroll?: boolean;
/** Wheel scroll zooms toward the pointer. @default true */
zoomOnScroll?: boolean;
/** Trackpad pinch zooms. @default true */
zoomOnPinch?: boolean;
/** Double-click zooms in. @default true */
zoomOnDoubleClick?: boolean;
}
</script>
<script setup lang="ts">
import { watchEffect } from 'vue';
import { useElementBounding, useEventListener, useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { VisuallyHidden } from '../../utilities/visually-hidden';
import { useFlowContext } from './context';
import { usePanZoom } from './composables/usePanZoom';
import { useConnection } from './composables/useConnection';
import { useMarquee } from './composables/useMarquee';
import { useKeyboard } from './composables/useKeyboard';
import { useViewportApi } from './composables/useViewportApi';
const {
as = 'div',
panOnDrag = true,
panOnScroll = false,
zoomOnScroll = true,
zoomOnPinch = true,
zoomOnDoubleClick = true,
} = defineProps<FlowPaneProps>();
const ctx = useFlowContext();
const { forwardRef, currentElement } = useForwardExpose();
const { left, top, width, height } = useElementBounding(currentElement, { updateTiming: 'next-frame' });
watchEffect(() => {
ctx.setPaneRect({ left: left.value, top: top.value, width: width.value, height: height.value });
});
const { isPanning } = usePanZoom(currentElement, ctx, {
panOnDrag,
panOnScroll,
zoomOnScroll,
zoomOnPinch,
zoomOnDoubleClick,
});
useConnection(ctx);
const { rect: marquee } = useMarquee(currentElement, ctx);
useKeyboard(currentElement, ctx, useViewportApi(ctx));
useEventListener(currentElement, 'click', (event: MouseEvent) => {
const target = event.target as Element | null;
if (target && !target.closest('[data-flow-node],[data-flow-edge]'))
ctx.clearSelection();
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
data-flow-pane=""
:data-panning="isPanning ? '' : undefined"
:data-interactive="ctx.interactive.value ? '' : undefined"
:role="ctx.disableKeyboardA11y.value ? undefined : 'application'"
:tabindex="ctx.disableKeyboardA11y.value ? undefined : 0"
:style="{ position: 'relative', overflow: 'hidden', touchAction: 'none' }"
>
<slot />
<div
v-if="marquee"
data-flow-selection-rect=""
:style="{
position: 'absolute',
top: '0',
left: '0',
transform: `translate(${marquee.x}px, ${marquee.y}px)`,
width: `${marquee.width}px`,
height: `${marquee.height}px`,
pointerEvents: 'none',
}"
/>
<VisuallyHidden
aria-live="polite"
aria-atomic="true"
>
<slot name="a11y-status" />
</VisuallyHidden>
</Primitive>
</template>
@@ -0,0 +1,60 @@
<script lang="ts">
import type { CSSProperties } from 'vue';
import type { PrimitiveProps } from '../../internal/primitive';
export type FlowPanelPosition
= | 'top-left' | 'top-center' | 'top-right'
| 'bottom-left' | 'bottom-center' | 'bottom-right';
/**
* An absolutely-positioned overlay anchored to a corner/edge of the pane, for
* chrome like controls, legends, or toolbars. Stops pointer/wheel events from
* reaching the pane, so interacting with the panel never pans or zooms. Place
* inside `FlowRoot`'s default slot.
*/
export interface FlowPanelProps extends PrimitiveProps {
/** Anchor position within the pane. @default 'top-left' */
position?: FlowPanelPosition;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
const { position = 'top-left', as = 'div' } = defineProps<FlowPanelProps>();
const { forwardRef } = useForwardExpose();
const style = computed<CSSProperties>(() => {
const [v, h] = position.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all' };
s[v] = '0';
if (h === 'center') {
s.left = '50%';
s.transform = 'translateX(-50%)';
}
else {
s[h] = '0';
}
return s;
});
function stop(event: Event): void {
event.stopPropagation();
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
data-flow-panel=""
:data-position="position"
:style="style"
@pointerdown="stop"
@wheel="stop"
>
<slot />
</Primitive>
</template>
+573
View File
@@ -0,0 +1,573 @@
<script lang="ts">
import type { Component, Ref } from 'vue';
import type { PrimitiveProps } from '../../internal/primitive';
import type {
Connection,
ConnectionMode,
ConnectionState,
Dimensions,
EdgeChange,
FlowEdge,
FlowNode,
HandleType,
InternalNode,
IsValidConnection,
NodeChange,
SelectionMode,
Viewport,
XYPosition,
} from './types';
/**
* Root of the headless flow canvas. Owns node/edge/viewport state (two-way via
* `v-model:nodes` / `v-model:edges` / `v-model:viewport`, or uncontrolled via
* `defaultNodes` / `defaultEdges` / `defaultViewport`), reconciles the public
* arrays into internal `shallowRef` Maps for O(1) reads, and provides
* `FlowContext` to every part. It renders the standard `FlowPane → FlowViewport
* → (edges, nodes)` subtree and exposes the default slot for absolutely-
* positioned chrome (Background / Controls / MiniMap / Panel). Customise node
* and edge rendering with `nodeTypes` / `edgeTypes` component maps or the
* `#node-<type>` / `#edge-<type>` scoped slots. Emits granular `@nodes-change` /
* `@edges-change` alongside `v-model`, so consumers may own their data.
*/
export interface FlowRootProps extends PrimitiveProps {
/** Uncontrolled initial nodes (ignored when `v-model:nodes` is bound). */
defaultNodes?: FlowNode[];
/** Uncontrolled initial edges. */
defaultEdges?: FlowEdge[];
/** Uncontrolled initial viewport. @default { x:0, y:0, zoom:1 } */
defaultViewport?: Viewport;
/** Minimum zoom level. @default 0.5 */
minZoom?: number;
/** Maximum zoom level. @default 2 */
maxZoom?: number;
/** Global drag enable (per-node `draggable` overrides). @default true */
nodesDraggable?: boolean;
/** Global connect enable (per-node `connectable` overrides). @default true */
nodesConnectable?: boolean;
/** Global selection enable. @default true */
elementsSelectable?: boolean;
/** Snap dragged nodes to a grid. @default false */
snapToGrid?: boolean;
/** Grid spacing `[x, y]` for `snapToGrid`. @default [15, 15] */
snapGrid?: [number, number];
/** Connection validity model. @default 'strict' */
connectionMode?: ConnectionMode;
/** Pixel radius for snapping a connection to a nearby handle. @default 20 */
connectionRadius?: number;
/** Marquee inclusion rule. @default 'partial' */
selectionMode?: SelectionMode;
/** Raise z-index of selected nodes. @default true */
elevateNodesOnSelect?: boolean;
/** Component map keyed by `node.type`. Define module-level, never inline. */
nodeTypes?: Record<string, Component>;
/** Component map keyed by `edge.type`. */
edgeTypes?: Record<string, Component>;
/** Default edge type when an edge has none. @default 'default' */
defaultEdgeType?: string;
/** Master interactivity switch (lock). @default true */
interactive?: boolean;
/** Disable the keyboard a11y layer + `role=application`. @default false */
disableKeyboardA11y?: boolean;
/** Global connection validator, overridable per handle. */
isValidConnection?: IsValidConnection;
/** Cull nodes/edges outside the viewport — for large graphs. @default false */
onlyRenderVisibleElements?: boolean;
/** Extra px kept rendered around the viewport when virtualizing. @default 200 */
virtualizationBuffer?: number;
}
export interface FlowRootEmits {
nodesChange: [changes: NodeChange[]];
edgesChange: [changes: EdgeChange[]];
connect: [connection: Connection];
connectStart: [payload: { nodeId: string; handleId: string | null; handleType: HandleType }];
connectEnd: [];
nodeDragStop: [ids: string[]];
selectionChange: [selection: { nodes: string[]; edges: string[] }];
paneClick: [event: PointerEvent];
nodeClick: [id: string, event: PointerEvent];
edgeClick: [id: string, event: PointerEvent];
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
import { useId } from '@robonen/vue';
import FlowPane from './FlowPane.vue';
import FlowViewport from './FlowViewport.vue';
import FlowNodeRenderer from './FlowNodeRenderer.vue';
import FlowEdgeRenderer from './FlowEdgeRenderer.vue';
import { provideFlowContext } from './context';
import type { FlowContext, FlowSelection, HandleRegistration } from './context';
import { flowToScreen, getNodePositionAbsolute, screenToFlow, visibleFlowRect } from './utils';
import { buildConnection, connectionToEdgeId, findClosestHandle, isValidConnection as isValidConnectionGate } from './connection';
import { getVisibleEdgeIds, getVisibleNodeIds } from './virtualization';
import { useViewportApi } from './composables/useViewportApi';
import { useInteractionState } from './composables/useInteractionState';
const {
defaultNodes,
defaultEdges,
defaultViewport,
minZoom = 0.5,
maxZoom = 2,
nodesDraggable = true,
nodesConnectable = true,
elementsSelectable = true,
snapToGrid = false,
snapGrid = [15, 15],
connectionMode = 'strict',
connectionRadius = 20,
selectionMode = 'partial',
interactive = true,
disableKeyboardA11y = false,
isValidConnection,
onlyRenderVisibleElements = false,
virtualizationBuffer = 200,
as = 'div',
} = defineProps<FlowRootProps>();
const emit = defineEmits<FlowRootEmits>();
const slots = useSlots();
const flowId = useId(undefined, 'flow').value;
// ── models (controlled + uncontrolled) ────────────────────────────────────
const localNodes = shallowRef<FlowNode[]>(defaultNodes ? defaultNodes.slice() : []);
const nodes = defineModel<FlowNode[]>('nodes', {
get: external => external ?? localNodes.value,
set: (value) => {
localNodes.value = value;
return value;
},
});
const localEdges = shallowRef<FlowEdge[]>(defaultEdges ? defaultEdges.slice() : []);
const edges = defineModel<FlowEdge[]>('edges', {
get: external => external ?? localEdges.value,
set: (value) => {
localEdges.value = value;
return value;
},
});
const localViewport = shallowRef<Viewport>(defaultViewport ?? { x: 0, y: 0, zoom: 1 });
const viewport = defineModel<Viewport>('viewport', {
get: external => external ?? localViewport.value,
set: (value) => {
localViewport.value = value;
return value;
},
});
// ── derived state (shallow Maps; nodes updated IMMUTABLY — a changed node is
// replaced with a new object so its per-node `computed(()=>lookup.get(id))`
// sees a new identity and re-renders; untouched nodes keep identity so they
// don't. In-place mutation would make that computed short-circuit and the node
// would never visually update). ────────────────────────────────────────────
const nodeLookup = shallowRef(new Map<string, InternalNode>());
const edgeLookup = shallowRef(new Map<string, FlowEdge>());
const selection = shallowRef<FlowSelection>({ nodes: new Set(), edges: new Set() });
const paneRect = shallowRef({ left: 0, top: 0, width: 0, height: 0 });
const isDragging = shallowRef(false);
const draggedIds = new Set<string>();
const isInteracting = useInteractionState(() => viewport.value);
let hasParenting = false;
function reconcileNodes(): void {
// During an active drag the model array is stale on purpose; don't clobber
// the live in-place positions until pointerup commits them.
if (isDragging.value) return;
const arr = nodes.value ?? [];
const map = nodeLookup.value;
const seen = new Set<string>();
hasParenting = false;
for (const n of arr) {
seen.add(n.id);
if (n.parentId) hasParenting = true;
const existing = map.get(n.id);
// Unchanged public node object → keep the existing internal entry (identity
// stable so this node won't re-render on unrelated updates).
if (existing && existing._source === n) continue;
map.set(n.id, {
...n,
measured: existing?.measured ?? { width: n.width ?? 0, height: n.height ?? 0 },
positionAbsolute: { x: n.position.x, y: n.position.y },
handleBounds: existing?.handleBounds ?? null,
dragging: existing?.dragging ?? false,
_source: n,
});
}
for (const id of map.keys()) if (!seen.has(id)) map.delete(id);
// Recompute absolute positions; clone only the entries whose value changed.
for (const node of map.values()) {
const abs = getNodePositionAbsolute(node, map);
if (abs.x !== node.positionAbsolute.x || abs.y !== node.positionAbsolute.y)
map.set(node.id, { ...node, positionAbsolute: abs });
}
triggerRef(nodeLookup);
}
function reconcileEdges(): void {
const arr = edges.value ?? [];
const map = edgeLookup.value;
const seen = new Set<string>();
for (const e of arr) {
seen.add(e.id);
map.set(e.id, e);
}
for (const id of map.keys()) if (!seen.has(id)) map.delete(id);
triggerRef(edgeLookup);
}
watch(nodes, reconcileNodes, { immediate: true });
watch(edges, reconcileEdges, { immediate: true });
const visibleNodeIds = computed(() => {
const all = (nodes.value ?? []).filter(n => !n.hidden);
if (!onlyRenderVisibleElements) return all.map(n => n.id);
const rect = visibleFlowRect(viewport.value!, paneRect.value, virtualizationBuffer);
return getVisibleNodeIds(all, nodeLookup.value, rect);
});
const visibleNodeSet = computed(() => new Set(visibleNodeIds.value));
const visibleEdgeIds = computed(() => {
const all = (edges.value ?? []).filter(e => !e.hidden);
if (!onlyRenderVisibleElements) return all.map(e => e.id);
return getVisibleEdgeIds(all, visibleNodeSet.value);
});
// ── coordinate actions ──────────────────────────────────────────────────────
function toFlow(point: XYPosition): XYPosition {
return screenToFlow(point, viewport.value!, paneRect.value);
}
function toScreen(point: XYPosition): XYPosition {
return flowToScreen(point, viewport.value!, paneRect.value);
}
function setPaneRect(rect: { left: number; top: number; width: number; height: number }): void {
paneRect.value = rect;
}
// ── node mutation ─────────────────────────────────────────────────────────
/**
* Re-derive absolute positions, cloning only the entries whose value changed.
* Hot during a drag of nested nodes (loops every node each frame), so the
* parent-chain sum is inlined to avoid a throwaway `{x,y}` per unchanged node
* (mirrors `getNodePositionAbsolute`, incl. its cycle-guard).
*/
function recomputeAbsolute(map: Map<string, InternalNode>): void {
const ids = hasParenting ? map.keys() : draggedIds;
for (const id of ids) {
const n = map.get(id);
if (!n) continue;
let x = n.position.x;
let y = n.position.y;
let parentId = n.parentId;
let guard = 0;
while (parentId && guard++ < 100) {
const parent = map.get(parentId);
if (!parent) break;
x += parent.position.x;
y += parent.position.y;
parentId = parent.parentId;
}
if (x !== n.positionAbsolute.x || y !== n.positionAbsolute.y)
map.set(id, { ...n, positionAbsolute: { x, y } });
}
}
function updateNodePositions(positions: Map<string, XYPosition>, dragging: boolean): void {
isDragging.value = dragging;
const map = nodeLookup.value;
for (const [id, pos] of positions) {
const n = map.get(id);
if (!n) continue;
// Replace with a NEW object (immutable) so this node's per-node computed
// sees a new identity and re-renders. For root nodes set absolute inline.
const next: InternalNode = { ...n, position: pos, dragging };
if (!n.parentId) next.positionAbsolute = { x: pos.x, y: pos.y };
map.set(id, next);
draggedIds.add(id);
}
if (hasParenting) recomputeAbsolute(map);
triggerRef(nodeLookup);
}
function commitNodeDrag(): void {
isDragging.value = false;
if (draggedIds.size === 0) return;
const ids = [...draggedIds];
const map = nodeLookup.value;
const changes: NodeChange[] = [];
for (const id of ids) {
const n = map.get(id);
if (!n) continue;
map.set(id, { ...n, dragging: false });
changes.push({ type: 'position', id, position: { x: n.position.x, y: n.position.y }, dragging: false });
}
draggedIds.clear();
triggerRef(nodeLookup);
const posById = new Map(changes.map(c => [c.id, (c as Extract<NodeChange, { type: 'position' }>).position!]));
nodes.value = (nodes.value ?? []).map(n => (posById.has(n.id) ? { ...n, position: posById.get(n.id)! } : n));
emit('nodesChange', changes);
emit('nodeDragStop', ids);
}
function setNodeMeasured(id: string, size: Dimensions, handleBounds: InternalNode['handleBounds']): void {
const map = nodeLookup.value;
const n = map.get(id);
if (!n) return;
const sizeChanged = n.measured.width !== size.width || n.measured.height !== size.height;
// New object so the node (and its incident edges, via the node computeds)
// pick up the fresh measurement / handle geometry.
map.set(id, { ...n, measured: sizeChanged ? size : n.measured, handleBounds });
triggerRef(nodeLookup);
}
function updateNode(id: string, patch: Partial<FlowNode>): void {
nodes.value = (nodes.value ?? []).map(n => (n.id === id ? { ...n, ...patch } : n));
const changes: NodeChange[] = [];
if (patch.position) changes.push({ type: 'position', id, position: patch.position });
if (patch.width !== undefined || patch.height !== undefined)
changes.push({ type: 'dimensions', id, dimensions: { width: patch.width ?? 0, height: patch.height ?? 0 } });
if (changes.length) emit('nodesChange', changes);
}
// ── selection ─────────────────────────────────────────────────────────────
function emitSelection(): void {
emit('selectionChange', { nodes: [...selection.value.nodes], edges: [...selection.value.edges] });
}
function selectNode(id: string, additive = false): void {
if (!elementsSelectable) return;
const sel = selection.value;
const nextNodes = additive ? new Set(sel.nodes) : new Set<string>();
const nextEdges = additive ? new Set(sel.edges) : new Set<string>();
if (additive && nextNodes.has(id)) nextNodes.delete(id);
else nextNodes.add(id);
selection.value = { nodes: nextNodes, edges: nextEdges };
emitSelection();
}
function selectEdge(id: string, additive = false): void {
if (!elementsSelectable) return;
const sel = selection.value;
const nextNodes = additive ? new Set(sel.nodes) : new Set<string>();
const nextEdges = additive ? new Set(sel.edges) : new Set<string>();
if (additive && nextEdges.has(id)) nextEdges.delete(id);
else nextEdges.add(id);
selection.value = { nodes: nextNodes, edges: nextEdges };
emitSelection();
}
function setSelection(nodeIds: string[], edgeIds: string[]): void {
selection.value = { nodes: new Set(nodeIds), edges: new Set(edgeIds) };
emitSelection();
}
function clearSelection(): void {
if (selection.value.nodes.size === 0 && selection.value.edges.size === 0) return;
selection.value = { nodes: new Set(), edges: new Set() };
emitSelection();
}
function removeSelected(): void {
const sel = selection.value;
if (sel.nodes.size === 0 && sel.edges.size === 0) return;
const removedNodes = sel.nodes;
const nodeChanges: NodeChange[] = [...removedNodes].map(id => ({ type: 'remove', id }));
const edgeChanges: EdgeChange[] = [];
for (const e of edges.value ?? []) {
if (sel.edges.has(e.id) || removedNodes.has(e.source) || removedNodes.has(e.target))
edgeChanges.push({ type: 'remove', id: e.id });
}
const removedEdges = new Set(edgeChanges.map(c => c.id));
nodes.value = (nodes.value ?? []).filter(n => !removedNodes.has(n.id));
edges.value = (edges.value ?? []).filter(e => !removedEdges.has(e.id));
selection.value = { nodes: new Set(), edges: new Set() };
if (nodeChanges.length) emit('nodesChange', nodeChanges);
if (edgeChanges.length) emit('edgesChange', edgeChanges);
emitSelection();
}
// ── connection (state setters; FSM hit-test wired by useConnection) ─────────
function idleConnection(): ConnectionState {
return {
inProgress: false,
fromNode: null,
fromHandle: null,
fromPosition: null,
fromType: null,
toPosition: null,
toHandle: null,
toNode: null,
isValid: null,
};
}
const connection = shallowRef<ConnectionState>(idleConnection());
function startConnection(from: HandleRegistration, nodeId: string): void {
if (!nodesConnectable || !interactive) return;
const node = nodeLookup.value.get(nodeId);
const handleBound = node?.handleBounds?.[from.type]?.find(h => h.id === from.id) ?? null;
connection.value = {
inProgress: true,
fromNode: nodeId,
fromHandle: handleBound,
fromPosition: handleBound && node
? { x: node.positionAbsolute.x + handleBound.x + handleBound.width / 2, y: node.positionAbsolute.y + handleBound.y + handleBound.height / 2 }
: null,
fromType: from.type,
toPosition: null,
toHandle: null,
toNode: null,
isValid: null,
};
emit('connectStart', { nodeId, handleId: from.id, handleType: from.type });
}
function updateConnection(flowPoint: XYPosition): void {
const c = connection.value;
if (!c.inProgress || !c.fromType || !c.fromNode || !c.fromHandle) return;
const radiusFlow = connectionRadius / (viewport.value!.zoom || 1);
const candidate = findClosestHandle(
flowPoint, nodeLookup.value, c.fromType, c.fromNode, c.fromHandle.id, connectionMode, radiusFlow,
);
let toHandle = null;
let toNode = null;
let isValid: boolean | null = null;
if (candidate) {
toHandle = candidate.handle;
toNode = candidate.nodeId;
const conn = buildConnection(c.fromType, c.fromNode, c.fromHandle, candidate);
isValid = isValidConnectionGate(conn, edges.value ?? [], isValidConnection);
}
connection.value = { ...c, toPosition: flowPoint, toHandle, toNode, isValid };
}
function endConnection(): void {
const c = connection.value;
if (!c.inProgress) return;
if (c.isValid && c.toHandle && c.toNode && c.fromHandle && c.fromNode && c.fromType) {
const target = { nodeId: c.toNode, handle: c.toHandle };
const conn = buildConnection(c.fromType, c.fromNode, c.fromHandle, target);
const newEdge: FlowEdge = { id: connectionToEdgeId(conn), ...conn };
edges.value = [...(edges.value ?? []), newEdge];
emit('connect', conn);
emit('edgesChange', [{ type: 'add', item: newEdge }]);
}
connection.value = idleConnection();
emit('connectEnd');
}
// ── provide ──────────────────────────────────────────────────────────────
const context: FlowContext = {
flowId,
viewport: viewport as unknown as Ref<Viewport>,
paneRect,
minZoom: toRef(() => minZoom),
maxZoom: toRef(() => maxZoom),
isInteracting,
nodesDraggable: toRef(() => nodesDraggable),
nodesConnectable: toRef(() => nodesConnectable),
elementsSelectable: toRef(() => elementsSelectable),
snapToGrid: toRef(() => snapToGrid),
snapGrid: toRef(() => snapGrid),
connectionMode: toRef(() => connectionMode),
connectionRadius: toRef(() => connectionRadius),
selectionMode: toRef(() => selectionMode),
interactive: toRef(() => interactive),
disableKeyboardA11y: toRef(() => disableKeyboardA11y),
nodeLookup,
edgeLookup,
selection,
connection,
visibleNodeIds,
visibleEdgeIds,
screenToFlow: toFlow,
flowToScreen: toScreen,
setPaneRect,
updateNodePositions,
commitNodeDrag,
setNodeMeasured,
updateNode,
isDragging,
selectNode,
selectEdge,
setSelection,
clearSelection,
removeSelected,
startConnection,
updateConnection,
endConnection,
emitNodesChange: changes => emit('nodesChange', changes),
emitEdgesChange: changes => emit('edgesChange', changes),
};
provideFlowContext(context);
// Imperative API, also exposed so consumers can drive the flow via a template ref.
const api = useViewportApi(context);
const nodeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'node' || n.startsWith('node-')));
const edgeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'edge' || n.startsWith('edge-')));
defineExpose({
...api,
viewport,
nodes,
edges,
selection,
selectNode,
selectEdge,
setSelection,
clearSelection,
removeSelected,
});
</script>
<template>
<FlowPane :as="as">
<FlowViewport>
<FlowEdgeRenderer :edge-types="edgeTypes">
<template
v-for="name in edgeSlotNames"
:key="name"
#[name]="sp"
>
<slot
:name="name"
v-bind="sp ?? {}"
/>
</template>
</FlowEdgeRenderer>
<FlowNodeRenderer :node-types="nodeTypes">
<template
v-for="name in nodeSlotNames"
:key="name"
#[name]="sp"
>
<slot
:name="name"
v-bind="sp ?? {}"
/>
</template>
</FlowNodeRenderer>
</FlowViewport>
<slot />
</FlowPane>
</template>
@@ -0,0 +1,52 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The single transformed layer. Every node and the edge `<svg>` live inside it,
* so pan/zoom is one GPU-composited `transform` rather than a per-element
* restyle. `transform-origin:0 0` is required — the coordinate formulas assume
* top-left scaling. `will-change:transform` is toggled on ONLY while interacting
* (never permanently): a pinned hint locks the compositor's raster scale, so the
* cached texture is GPU-upscaled and the graph blurs at high zoom — toggling it
* lets the layer re-rasterise crisply once motion settles (see
* `useInteractionState`). Mirrors `FlowNode`'s per-node drag toggle.
*/
export interface FlowViewportProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useFlowContext } from './context';
const { as = 'div' } = defineProps<FlowViewportProps>();
const ctx = useFlowContext();
const { forwardRef } = useForwardExpose();
const transform = computed(() => {
const vp = ctx.viewport.value;
return `translate(${vp.x}px, ${vp.y}px) scale(${vp.zoom})`;
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
data-flow-viewport=""
:style="{
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
transformOrigin: '0 0',
transform,
willChange: ctx.isInteracting.value ? 'transform' : undefined,
}"
>
<slot />
</Primitive>
</template>
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
delete (globalThis as any).__api;
});
function track<T extends VueWrapper<any>>(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();
});
});
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(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);
});
});
@@ -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<VueWrapper<any>> = [];
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);
});
});
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function raf(): Promise<void> {
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)');
});
});
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(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<string, unknown> = {}) {
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<FlowNode[]>([{ 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<Viewport>({ 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)');
});
});
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(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 <marker>
expect(w.findAll('marker')).toHaveLength(1);
const path = w.find('[data-flow-edge-path]');
expect(path.attributes('marker-end')).toMatch(/^url\(#/);
});
});
@@ -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<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
delete (globalThis as any).__api;
});
function track<T extends VueWrapper<any>>(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);
});
});
@@ -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);
});
});
@@ -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<string, InternalNode> {
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 }));
});
});
@@ -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'));
});
});
@@ -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<Viewport>({ 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<Viewport>({ 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();
}
});
});
@@ -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<string, InternalNode>();
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 });
});
});
@@ -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<string, InternalNode>([
['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<VueWrapper<any>> = [];
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);
});
});
+111
View File
@@ -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<Data = unknown>(changes: Array<NodeChange<Data>>, nodes: Array<FlowNode<Data>>): Array<FlowNode<Data>> {
if (changes.length === 0) return nodes;
const byId = new Map(nodes.map(n => [n.id, n]));
const removed = new Set<string>();
const added: Array<{ item: FlowNode<Data>; 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<Data = unknown>(changes: Array<EdgeChange<Data>>, edges: Array<FlowEdge<Data>>): Array<FlowEdge<Data>> {
if (changes.length === 0) return edges;
const byId = new Map(edges.map(e => [e.id, e]));
const removed = new Set<string>();
const added: Array<{ item: FlowEdge<Data>; 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<Data = unknown>(
edgeOrConnection: Connection | FlowEdge<Data>,
edges: Array<FlowEdge<Data>>,
): Array<FlowEdge<Data>> {
const edge: FlowEdge<Data> = 'id' in edgeOrConnection
? edgeOrConnection
: { id: connectionToEdgeId(edgeOrConnection), ...edgeOrConnection } as FlowEdge<Data>;
if (edges.some(e => e.id === edge.id)) return edges;
return [...edges, edge];
}
@@ -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 */
}
}
@@ -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
* 60120×/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;
}
});
}
@@ -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<Viewport>;
/** Reactive selection sets. */
selection: ShallowRef<FlowSelection>;
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,
};
}

Some files were not shown because too many files have changed in this diff Show More