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>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
<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>
<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
data attributes, so you bring your own styles Tailwind, vanilla CSS, anything.
</p>
</div>
<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>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
<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>
<p class="mt-2 mb-0 text-sm text-fg-muted">
Focus scopes, roving tabindex, visually-hidden labels and correct ARIA roles
are handled for you. The suite is tested against
<code>axe-core</code> in a real browser.
</p>
</div>
<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>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
<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>
<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
<code>defaultValue</code> / <code>defaultOpen</code> and let the primitive
manage itself.
</p>
</div>
<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>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
<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>
<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>
to merge behavior onto your own element. Floating UI powers positioning for
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, {
name: 'primitives/overrides',
@@ -6,4 +6,4 @@ export default compose(base, typescript, vue, imports, stylistic, {
rules: {
'@stylistic/no-multiple-empty-lines': 'off',
},
});
}, tests);
@@ -5,7 +5,7 @@ import {
AccordionItem,
AccordionRoot,
AccordionTrigger,
} from '@primitives/accordion';
} from '@primitives/disclosure/accordion';
const value = ref<string | string[] | undefined>('a');
const type = ref<'single' | 'multiple'>('single');
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import type { CheckedState } from '@primitives/checkbox';
import { CheckboxIndicator, CheckboxRoot } from '@primitives/checkbox';
import type { CheckedState } from '@primitives/forms/checkbox';
import { CheckboxIndicator, CheckboxRoot } from '@primitives/forms/checkbox';
const checked = ref<CheckedState>(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 valuepixel 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