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.
@@ -24,35 +24,35 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||||
<h3 class="m-0 text-sm font-semibold text-(--fg)">Unstyled by design</h3>
|
<h3 class="m-0 text-sm font-semibold text-fg">Unstyled by design</h3>
|
||||||
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
|
<p class="mt-2 mb-0 text-sm text-fg-muted">
|
||||||
No CSS shipped. Primitives render the DOM you ask for and expose state via
|
No CSS shipped. Primitives render the DOM you ask for and expose state via
|
||||||
data attributes, so you bring your own styles — Tailwind, vanilla CSS, anything.
|
data attributes, so you bring your own styles — Tailwind, vanilla CSS, anything.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||||
<h3 class="m-0 text-sm font-semibold text-(--fg)">Accessible out of the box</h3>
|
<h3 class="m-0 text-sm font-semibold text-fg">Accessible out of the box</h3>
|
||||||
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
|
<p class="mt-2 mb-0 text-sm text-fg-muted">
|
||||||
Focus scopes, roving tabindex, visually-hidden labels and correct ARIA roles
|
Focus scopes, roving tabindex, visually-hidden labels and correct ARIA roles
|
||||||
are handled for you. The suite is tested against
|
are handled for you. The suite is tested against
|
||||||
<code>axe-core</code> in a real browser.
|
<code>axe-core</code> in a real browser.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||||
<h3 class="m-0 text-sm font-semibold text-(--fg)">Controlled or uncontrolled</h3>
|
<h3 class="m-0 text-sm font-semibold text-fg">Controlled or uncontrolled</h3>
|
||||||
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
|
<p class="mt-2 mb-0 text-sm text-fg-muted">
|
||||||
Bind state with <code>v-model</code> when you need control, or set a
|
Bind state with <code>v-model</code> when you need control, or set a
|
||||||
<code>defaultValue</code> / <code>defaultOpen</code> and let the primitive
|
<code>defaultValue</code> / <code>defaultOpen</code> and let the primitive
|
||||||
manage itself.
|
manage itself.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
<div class="rounded-xl border border-border bg-bg-elevated p-5">
|
||||||
<h3 class="m-0 text-sm font-semibold text-(--fg)">Composable & polymorphic</h3>
|
<h3 class="m-0 text-sm font-semibold text-fg">Composable & polymorphic</h3>
|
||||||
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
|
<p class="mt-2 mb-0 text-sm text-fg-muted">
|
||||||
Every part takes an <code>as</code> prop, or use <code>as="template"</code>
|
Every part takes an <code>as</code> prop, or use <code>as="template"</code>
|
||||||
to merge behavior onto your own element. Floating UI powers positioning for
|
to merge behavior onto your own element. Floating UI powers positioning for
|
||||||
popovers, tooltips and menus.
|
popovers, tooltips and menus.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint';
|
import { base, compose, imports, stylistic, tests, typescript, vue } from '@robonen/eslint';
|
||||||
|
|
||||||
export default compose(base, typescript, vue, imports, stylistic, {
|
export default compose(base, typescript, vue, imports, stylistic, {
|
||||||
name: 'primitives/overrides',
|
name: 'primitives/overrides',
|
||||||
@@ -6,4 +6,4 @@ export default compose(base, typescript, vue, imports, stylistic, {
|
|||||||
rules: {
|
rules: {
|
||||||
'@stylistic/no-multiple-empty-lines': 'off',
|
'@stylistic/no-multiple-empty-lines': 'off',
|
||||||
},
|
},
|
||||||
});
|
}, tests);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionRoot,
|
AccordionRoot,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from '@primitives/accordion';
|
} from '@primitives/disclosure/accordion';
|
||||||
|
|
||||||
const value = ref<string | string[] | undefined>('a');
|
const value = ref<string | string[] | undefined>('a');
|
||||||
const type = ref<'single' | 'multiple'>('single');
|
const type = ref<'single' | 'multiple'>('single');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import type { CheckedState } from '@primitives/checkbox';
|
import type { CheckedState } from '@primitives/forms/checkbox';
|
||||||
import { CheckboxIndicator, CheckboxRoot } from '@primitives/checkbox';
|
import { CheckboxIndicator, CheckboxRoot } from '@primitives/forms/checkbox';
|
||||||
|
|
||||||
const checked = ref<CheckedState>(false);
|
const checked = ref<CheckedState>(false);
|
||||||
const disabled = ref(false);
|
const disabled = ref(false);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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%)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -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, 0–1. @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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -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 (0–100).
|
||||||
|
*/
|
||||||
|
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 0–100, 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 (0–100). @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 (0–100).
|
||||||
|
*/
|
||||||
|
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 (0–100).
|
||||||
|
// 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%)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { useContextFactory } from '@robonen/vue';
|
||||||
|
|
||||||
|
export type CompareSliderOrientation = 'horizontal' | 'vertical';
|
||||||
|
export type CompareSliderDirection = 'ltr' | 'rtl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatter turning the raw reveal position (0–100) into a human-friendly
|
||||||
|
* string for the handle's `aria-valuetext`. Return `undefined` to omit
|
||||||
|
* `aria-valuetext`.
|
||||||
|
*/
|
||||||
|
export type CompareSliderValueText = (position: number) => string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context shared between `CompareSliderRoot` and its descendants
|
||||||
|
* (`CompareSliderBefore`, `CompareSliderAfter`, `CompareSliderHandle`,
|
||||||
|
* `CompareSliderDivider`).
|
||||||
|
*
|
||||||
|
* Scalar props are exposed as plain `Ref<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, 0–100 (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 0–100). */
|
||||||
|
step: (delta: number) => void;
|
||||||
|
/** Set the reveal position to an absolute value (clamped to 0–100). */
|
||||||
|
setPosition: (next: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = useContextFactory<CompareSliderContext>('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';
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import type { Point } from '../../internal/utils/geometry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An axis-aligned crop rectangle. Lives in whatever coordinate space the
|
||||||
|
* consumer chose via the `units` prop on `CropRoot`:
|
||||||
|
* - `'normalized'` (default): `x`/`y`/`width`/`height` are fractions `0..1` of
|
||||||
|
* the media, so the rect is resolution-independent.
|
||||||
|
* - `'pixels'`: the same fields are media-space pixels.
|
||||||
|
*
|
||||||
|
* The pure helpers in this module operate in a single, consistent space — the
|
||||||
|
* caller picks one and stays in it. They never mix units.
|
||||||
|
*/
|
||||||
|
export interface CropRect {
|
||||||
|
/** Left edge. */
|
||||||
|
x: number;
|
||||||
|
/** Top edge. */
|
||||||
|
y: number;
|
||||||
|
/** Rectangle width (always `>= 0`). */
|
||||||
|
width: number;
|
||||||
|
/** Rectangle height (always `>= 0`). */
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The eight resize handles of a crop rectangle: four corners and four edge
|
||||||
|
* midpoints. Corner handles move two edges, edge handles move one.
|
||||||
|
*/
|
||||||
|
export type CropHandlePosition
|
||||||
|
= | 'top-left'
|
||||||
|
| 'top'
|
||||||
|
| 'top-right'
|
||||||
|
| 'right'
|
||||||
|
| 'bottom-right'
|
||||||
|
| 'bottom'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'left';
|
||||||
|
|
||||||
|
/** The media bounds the crop is clamped against, in the rect's own units. */
|
||||||
|
export interface CropBounds {
|
||||||
|
/** Media width (`1` in normalized units, media pixels otherwise). */
|
||||||
|
width: number;
|
||||||
|
/** Media height (`1` in normalized units, media pixels otherwise). */
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All eight handle positions in a stable, clockwise-from-top-left order. */
|
||||||
|
export const CROP_HANDLE_POSITIONS: readonly CropHandlePosition[] = [
|
||||||
|
'top-left',
|
||||||
|
'top',
|
||||||
|
'top-right',
|
||||||
|
'right',
|
||||||
|
'bottom-right',
|
||||||
|
'bottom',
|
||||||
|
'bottom-left',
|
||||||
|
'left',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Whether a handle moves the top edge. */
|
||||||
|
function movesTop(h: CropHandlePosition): boolean {
|
||||||
|
return h === 'top-left' || h === 'top' || h === 'top-right';
|
||||||
|
}
|
||||||
|
/** Whether a handle moves the bottom edge. */
|
||||||
|
function movesBottom(h: CropHandlePosition): boolean {
|
||||||
|
return h === 'bottom-left' || h === 'bottom' || h === 'bottom-right';
|
||||||
|
}
|
||||||
|
/** Whether a handle moves the left edge. */
|
||||||
|
function movesLeft(h: CropHandlePosition): boolean {
|
||||||
|
return h === 'top-left' || h === 'left' || h === 'bottom-left';
|
||||||
|
}
|
||||||
|
/** Whether a handle moves the right edge. */
|
||||||
|
function movesRight(h: CropHandlePosition): boolean {
|
||||||
|
return h === 'top-right' || h === 'right' || h === 'bottom-right';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp `n` into `[lo, hi]`. (Local copy — keeps `utils.ts` dependency-free.) */
|
||||||
|
function clamp(n: number, lo: number, hi: number): number {
|
||||||
|
return n < lo ? lo : n > hi ? hi : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The smallest box (in the rect's units) that satisfies BOTH `minWidth`/
|
||||||
|
* `minHeight` AND, when `aspectRatio` is set, the ratio. The binding constraint
|
||||||
|
* is whichever forces the larger box — so a tall ratio grows the width to keep
|
||||||
|
* the height-min legal, and vice versa. The crop must never shrink past this.
|
||||||
|
*
|
||||||
|
* @param minWidth Minimum width in the rect's units (`>= 0`).
|
||||||
|
* @param minHeight Minimum height in the rect's units (`>= 0`).
|
||||||
|
* @param aspectRatio Locked `width / height`, or `null` for free resize.
|
||||||
|
*/
|
||||||
|
export function minBox(
|
||||||
|
minWidth: number,
|
||||||
|
minHeight: number,
|
||||||
|
aspectRatio: number | null,
|
||||||
|
): { width: number; height: number } {
|
||||||
|
const mw = Math.max(0, minWidth);
|
||||||
|
const mh = Math.max(0, minHeight);
|
||||||
|
if (aspectRatio === null || !(aspectRatio > 0) || !Number.isFinite(aspectRatio))
|
||||||
|
return { width: mw, height: mh };
|
||||||
|
// Grow whichever dimension is too small to honour the ratio about the other.
|
||||||
|
// width = ratio * height. Pick the larger of (mw, ratio*mh) for the width, then
|
||||||
|
// derive the matching height so both minimums are met.
|
||||||
|
const widthFromHeight = aspectRatio * mh;
|
||||||
|
const width = Math.max(mw, widthFromHeight);
|
||||||
|
const height = width / aspectRatio;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fit an existing rect to `aspectRatio` about its centre (used when the
|
||||||
|
* ratio is set/changed on a free rect), then clamp the result into `bounds`.
|
||||||
|
* The area is preserved as closely as possible: the new box is the
|
||||||
|
* ratio-correct rect whose area matches the old one, centred on the old centre.
|
||||||
|
*
|
||||||
|
* @param rect The current (free) rectangle.
|
||||||
|
* @param aspectRatio Target `width / height` (`> 0`).
|
||||||
|
* @param bounds Media bounds to clamp into.
|
||||||
|
* @param minWidth Minimum width in units.
|
||||||
|
* @param minHeight Minimum height in units.
|
||||||
|
*/
|
||||||
|
export function fitRectToRatio(
|
||||||
|
rect: CropRect,
|
||||||
|
aspectRatio: number,
|
||||||
|
bounds: CropBounds,
|
||||||
|
minWidth: number,
|
||||||
|
minHeight: number,
|
||||||
|
): CropRect {
|
||||||
|
if (!(aspectRatio > 0) || !Number.isFinite(aspectRatio)) return rect;
|
||||||
|
const cx = rect.x + rect.width / 2;
|
||||||
|
const cy = rect.y + rect.height / 2;
|
||||||
|
const area = Math.max(rect.width * rect.height, 1e-9);
|
||||||
|
// w * h = area, w / h = ratio → h = sqrt(area / ratio), w = ratio * h.
|
||||||
|
let height = Math.sqrt(area / aspectRatio);
|
||||||
|
let width = aspectRatio * height;
|
||||||
|
// Never below the combined min box.
|
||||||
|
const min = minBox(minWidth, minHeight, aspectRatio);
|
||||||
|
if (width < min.width) {
|
||||||
|
width = min.width;
|
||||||
|
height = min.height;
|
||||||
|
}
|
||||||
|
// Never exceed the media in either dimension while holding the ratio.
|
||||||
|
const maxW = bounds.width;
|
||||||
|
const maxH = bounds.height;
|
||||||
|
if (width > maxW) {
|
||||||
|
width = maxW;
|
||||||
|
height = width / aspectRatio;
|
||||||
|
}
|
||||||
|
if (height > maxH) {
|
||||||
|
height = maxH;
|
||||||
|
width = height * aspectRatio;
|
||||||
|
}
|
||||||
|
const x = clamp(cx - width / 2, 0, Math.max(0, bounds.width - width));
|
||||||
|
const y = clamp(cy - height / 2, 0, Math.max(0, bounds.height - height));
|
||||||
|
return { x, y, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate (move) `rect` by `dx`/`dy`, optionally clamped so it stays fully
|
||||||
|
* inside `bounds`. Size is never changed — only the origin.
|
||||||
|
*
|
||||||
|
* @param rect The rectangle to move.
|
||||||
|
* @param dx Delta on x in the rect's units.
|
||||||
|
* @param dy Delta on y in the rect's units.
|
||||||
|
* @param bounds Media bounds.
|
||||||
|
* @param constrain When `true`, the rect is kept within `bounds`.
|
||||||
|
*/
|
||||||
|
export function moveRect(
|
||||||
|
rect: CropRect,
|
||||||
|
dx: number,
|
||||||
|
dy: number,
|
||||||
|
bounds: CropBounds,
|
||||||
|
constrain: boolean,
|
||||||
|
): CropRect {
|
||||||
|
let x = rect.x + dx;
|
||||||
|
let y = rect.y + dy;
|
||||||
|
if (constrain) {
|
||||||
|
x = clamp(x, 0, Math.max(0, bounds.width - rect.width));
|
||||||
|
y = clamp(y, 0, Math.max(0, bounds.height - rect.height));
|
||||||
|
}
|
||||||
|
return { x, y, width: rect.width, height: rect.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize `rect` by dragging `handle` to a new pointer position, with the
|
||||||
|
* opposite edge(s) held fixed. Honours `aspectRatio` (paired-dimension
|
||||||
|
* adjustment), the combined `minBox`, and — when `constrain` is set — the media
|
||||||
|
* bounds (clamping the limiting dimension while preserving the ratio).
|
||||||
|
*
|
||||||
|
* The whole computation runs in the rect's units. `px`/`py` are the desired new
|
||||||
|
* position of the dragged handle (the anchor edge stays put). This is the core
|
||||||
|
* of every corner/edge drag and of keyboard edge-resize (with a synthetic
|
||||||
|
* target one step away).
|
||||||
|
*
|
||||||
|
* @param rect The rectangle being resized.
|
||||||
|
* @param handle Which handle is being dragged.
|
||||||
|
* @param px Desired new x of the dragged handle, in units.
|
||||||
|
* @param py Desired new y of the dragged handle, in units.
|
||||||
|
* @param options Aspect, mins, bounds and the constrain flag.
|
||||||
|
*/
|
||||||
|
export function resizeRect(
|
||||||
|
rect: CropRect,
|
||||||
|
handle: CropHandlePosition,
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
options: {
|
||||||
|
aspectRatio: number | null;
|
||||||
|
minWidth: number;
|
||||||
|
minHeight: number;
|
||||||
|
bounds: CropBounds;
|
||||||
|
constrain: boolean;
|
||||||
|
},
|
||||||
|
): CropRect {
|
||||||
|
const { aspectRatio, minWidth, minHeight, bounds, constrain } = options;
|
||||||
|
const ratio = aspectRatio !== null && aspectRatio > 0 && Number.isFinite(aspectRatio)
|
||||||
|
? aspectRatio
|
||||||
|
: null;
|
||||||
|
const min = minBox(minWidth, minHeight, ratio);
|
||||||
|
|
||||||
|
// Fixed (anchor) edges — the opposite edge of whatever the handle moves.
|
||||||
|
const left = rect.x;
|
||||||
|
const right = rect.x + rect.width;
|
||||||
|
const top = rect.y;
|
||||||
|
const bottom = rect.y + rect.height;
|
||||||
|
|
||||||
|
const ml = movesLeft(handle);
|
||||||
|
const mr = movesRight(handle);
|
||||||
|
const mt = movesTop(handle);
|
||||||
|
const mb = movesBottom(handle);
|
||||||
|
|
||||||
|
// 1) Free resize: move the active edges to the pointer, clamp each so the box
|
||||||
|
// keeps a non-negative size at least `min` and (when constrain) stays in
|
||||||
|
// bounds. Anchor edges are the opposite, untouched edges.
|
||||||
|
let newLeft = left;
|
||||||
|
let newRight = right;
|
||||||
|
let newTop = top;
|
||||||
|
let newBottom = bottom;
|
||||||
|
|
||||||
|
if (ml) newLeft = constrain ? clamp(px, 0, right - min.width) : Math.min(px, right - min.width);
|
||||||
|
if (mr) newRight = constrain ? clamp(px, left + min.width, bounds.width) : Math.max(px, left + min.width);
|
||||||
|
if (mt) newTop = constrain ? clamp(py, 0, bottom - min.height) : Math.min(py, bottom - min.height);
|
||||||
|
if (mb) newBottom = constrain ? clamp(py, top + min.height, bounds.height) : Math.max(py, top + min.height);
|
||||||
|
|
||||||
|
let width = newRight - newLeft;
|
||||||
|
let height = newBottom - newTop;
|
||||||
|
|
||||||
|
if (ratio !== null) {
|
||||||
|
// 2) Aspect-locked: the dragged edge(s) drive the PRIMARY dimension; the
|
||||||
|
// paired dimension is derived and grown about the appropriate anchor.
|
||||||
|
const isCorner = (ml || mr) && (mt || mb);
|
||||||
|
if (isCorner) {
|
||||||
|
// Corner: let width lead, derive height (pick the larger so the pointer
|
||||||
|
// is always "inside" the box — feels natural on a corner drag).
|
||||||
|
const hFromW = width / ratio;
|
||||||
|
if (hFromW >= height) {
|
||||||
|
height = hFromW;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
width = height * ratio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (ml || mr) {
|
||||||
|
// Horizontal edge handle: width leads, height follows about the centre.
|
||||||
|
height = width / ratio;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Vertical edge handle: height leads, width follows about the centre.
|
||||||
|
width = height * ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never below the combined min.
|
||||||
|
if (width < min.width) {
|
||||||
|
width = min.width;
|
||||||
|
height = min.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Constrain + ratio: if the derived box would exit the media, clamp the
|
||||||
|
// LIMITING dimension and re-derive the other to preserve the ratio.
|
||||||
|
if (constrain) {
|
||||||
|
// Determine the anchor corner the box grows from.
|
||||||
|
const anchorX = ml ? right : left; // fixed x edge
|
||||||
|
const anchorY = mt ? bottom : top; // fixed y edge
|
||||||
|
// Max width/height available from the anchor toward the drag direction.
|
||||||
|
const availW = ml ? anchorX : bounds.width - anchorX;
|
||||||
|
const availH = mt ? anchorY : bounds.height - anchorY;
|
||||||
|
if (width > availW) {
|
||||||
|
width = availW;
|
||||||
|
height = width / ratio;
|
||||||
|
}
|
||||||
|
if (height > availH) {
|
||||||
|
height = availH;
|
||||||
|
width = height * ratio;
|
||||||
|
}
|
||||||
|
// Re-apply the min after clamping (clamp may have pushed below min when
|
||||||
|
// the media is smaller than the min box — min wins, bounds are honoured
|
||||||
|
// by the final position clamp).
|
||||||
|
if (width < min.width) {
|
||||||
|
width = min.width;
|
||||||
|
height = min.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute the moving edges from the fixed anchors + the (possibly
|
||||||
|
// ratio-adjusted) dimensions. For a centre-anchored edge handle the paired
|
||||||
|
// dimension grows symmetrically about the box centre.
|
||||||
|
if (ml) newLeft = right - width;
|
||||||
|
else if (mr) newRight = left + width;
|
||||||
|
else {
|
||||||
|
// vertical-only handle: centre the width about the existing centre x.
|
||||||
|
const cx = (left + right) / 2;
|
||||||
|
newLeft = cx - width / 2;
|
||||||
|
newRight = cx + width / 2;
|
||||||
|
}
|
||||||
|
if (mt) newTop = bottom - height;
|
||||||
|
else if (mb) newBottom = top + height;
|
||||||
|
else {
|
||||||
|
// horizontal-only handle: centre the height about the existing centre y.
|
||||||
|
const cy = (top + bottom) / 2;
|
||||||
|
newTop = cy - height / 2;
|
||||||
|
newBottom = cy + height / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outX = newLeft;
|
||||||
|
let outY = newTop;
|
||||||
|
let outW = newRight - newLeft;
|
||||||
|
let outH = newBottom - newTop;
|
||||||
|
|
||||||
|
// Final safety clamp: keep the box inside the media when constrained. Shift
|
||||||
|
// (not shrink) so the ratio/size survive; only shrink if the box is wider/
|
||||||
|
// taller than the media itself.
|
||||||
|
if (constrain) {
|
||||||
|
if (outW > bounds.width) outW = bounds.width;
|
||||||
|
if (outH > bounds.height) outH = bounds.height;
|
||||||
|
outX = clamp(outX, 0, Math.max(0, bounds.width - outW));
|
||||||
|
outY = clamp(outY, 0, Math.max(0, bounds.height - outH));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: outX, y: outY, width: outW, height: outH };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fresh rect from a draw-from-empty create gesture: the press `origin`
|
||||||
|
* and the current pointer `point` define opposite corners. Normalised so width
|
||||||
|
* and height are non-negative, then aspect-fitted (anchored at `origin`) and
|
||||||
|
* clamped into bounds. Used by the `createOnEmpty` path.
|
||||||
|
*
|
||||||
|
* @param origin The pointerdown corner, in units.
|
||||||
|
* @param point The current pointer corner, in units.
|
||||||
|
* @param options Aspect, mins, bounds and the constrain flag.
|
||||||
|
*/
|
||||||
|
export function createRect(
|
||||||
|
origin: Point,
|
||||||
|
point: Point,
|
||||||
|
options: {
|
||||||
|
aspectRatio: number | null;
|
||||||
|
minWidth: number;
|
||||||
|
minHeight: number;
|
||||||
|
bounds: CropBounds;
|
||||||
|
constrain: boolean;
|
||||||
|
},
|
||||||
|
): CropRect {
|
||||||
|
// Which handle the pointer is dragging is decided by the sign of the drag, so
|
||||||
|
// we drive `resizeRect` from a zero-area rect anchored at `origin` and let it
|
||||||
|
// do all the ratio/bounds/min work.
|
||||||
|
const dirX = point.x >= origin.x ? 'right' : 'left';
|
||||||
|
const dirY = point.y >= origin.y ? 'bottom' : 'top';
|
||||||
|
const handle = `${dirY === 'bottom' ? 'bottom' : 'top'}-${dirX === 'right' ? 'right' : 'left'}` as CropHandlePosition;
|
||||||
|
const seed: CropRect = { x: origin.x, y: origin.y, width: 0, height: 0 };
|
||||||
|
return resizeRect(seed, handle, point.x, point.y, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp/normalise an arbitrary rect into a legal crop: non-negative size, at
|
||||||
|
* least the combined `minBox`, optionally ratio-fitted, and (when `constrain`)
|
||||||
|
* within `bounds`. Used to sanitise an incoming `modelValue` and to settle a
|
||||||
|
* rect after a units / aspectRatio / bounds change.
|
||||||
|
*
|
||||||
|
* @param rect The candidate rectangle.
|
||||||
|
* @param options Aspect, mins, bounds and the constrain flag.
|
||||||
|
*/
|
||||||
|
export function normalizeRect(
|
||||||
|
rect: CropRect,
|
||||||
|
options: {
|
||||||
|
aspectRatio: number | null;
|
||||||
|
minWidth: number;
|
||||||
|
minHeight: number;
|
||||||
|
bounds: CropBounds;
|
||||||
|
constrain: boolean;
|
||||||
|
},
|
||||||
|
): CropRect {
|
||||||
|
const { aspectRatio, minWidth, minHeight, bounds, constrain } = options;
|
||||||
|
const ratio = aspectRatio !== null && aspectRatio > 0 && Number.isFinite(aspectRatio)
|
||||||
|
? aspectRatio
|
||||||
|
: null;
|
||||||
|
let width = Math.max(0, rect.width);
|
||||||
|
let height = Math.max(0, rect.height);
|
||||||
|
let x = rect.x;
|
||||||
|
let y = rect.y;
|
||||||
|
|
||||||
|
if (ratio !== null) {
|
||||||
|
const fitted = fitRectToRatio({ x, y, width, height }, ratio, bounds, minWidth, minHeight);
|
||||||
|
return fitted;
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = minBox(minWidth, minHeight, ratio);
|
||||||
|
if (width < min.width) width = min.width;
|
||||||
|
if (height < min.height) height = min.height;
|
||||||
|
|
||||||
|
if (constrain) {
|
||||||
|
if (width > bounds.width) width = bounds.width;
|
||||||
|
if (height > bounds.height) height = bounds.height;
|
||||||
|
x = clamp(x, 0, Math.max(0, bounds.width - width));
|
||||||
|
y = clamp(y, 0, Math.max(0, bounds.height - height));
|
||||||
|
}
|
||||||
|
return { x, y, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert between coordinate spaces for an aspect-ratio supplied in display
|
||||||
|
* (pixel) terms. The `aspectRatio` prop is unit-agnostic (`width / height` of
|
||||||
|
* the *crop box*), so in normalized space it must be divided by the media's own
|
||||||
|
* pixel aspect to stay visually correct. Returns the ratio expressed in the
|
||||||
|
* rect's units.
|
||||||
|
*
|
||||||
|
* @param aspectRatio The visual `width / height` the consumer asked for.
|
||||||
|
* @param units The active coordinate space.
|
||||||
|
* @param mediaWidth Media width in pixels (for the normalized correction).
|
||||||
|
* @param mediaHeight Media height in pixels.
|
||||||
|
*/
|
||||||
|
export function resolveAspectRatio(
|
||||||
|
aspectRatio: number | null,
|
||||||
|
units: 'normalized' | 'pixels',
|
||||||
|
mediaWidth: number,
|
||||||
|
mediaHeight: number,
|
||||||
|
): number | null {
|
||||||
|
if (aspectRatio === null || !(aspectRatio > 0) || !Number.isFinite(aspectRatio)) return null;
|
||||||
|
if (units === 'pixels') return aspectRatio;
|
||||||
|
// Normalized: a visual ratio of R means width_px/height_px = R, i.e.
|
||||||
|
// (wx*mediaW)/(hy*mediaH) = R → wx/hy = R * mediaH / mediaW.
|
||||||
|
if (mediaWidth <= 0 || mediaHeight <= 0) return aspectRatio;
|
||||||
|
return aspectRatio * (mediaHeight / mediaWidth);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PrimitiveProps } from '../../internal/primitive';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rendered curve of a `CurveEditorRoot`, an SVG `<path>` whose `d` is built
|
||||||
|
* by sampling `f(x)` across `domainX` (or the per-anchor bezier path in
|
||||||
|
* `'bezier'` mode) and projecting each sample to pixels. It is decorative
|
||||||
|
* (`role="presentation"` / `aria-hidden`) — the accessible controls are the
|
||||||
|
* `CurveEditorPoint` thumbs. The path `d` is also exposed as a slot prop for
|
||||||
|
* custom rendering (fills, glows).
|
||||||
|
*/
|
||||||
|
export interface CurveEditorCurveProps extends PrimitiveProps {
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Primitive } from '../../internal/primitive';
|
||||||
|
import { useCurveEditorContext } from './context';
|
||||||
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
|
import { buildBezierPath, buildPolylinePath, sampleFnToPolyline } from '../../internal/spline';
|
||||||
|
|
||||||
|
const { as = 'path' } = defineProps<CurveEditorCurveProps>();
|
||||||
|
const ctx = useCurveEditorContext();
|
||||||
|
|
||||||
|
/** Project a domain point to pixel space via the axis scales. */
|
||||||
|
function project(x: number, y: number): { x: number; y: number } {
|
||||||
|
return { x: ctx.scaleX.scale(x), y: ctx.scaleY.scale(y) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLES = 256;
|
||||||
|
|
||||||
|
const pathD = computed<string>(() => {
|
||||||
|
const [x0, x1] = ctx.domainX.value;
|
||||||
|
|
||||||
|
if (ctx.interpolation.value === 'bezier') {
|
||||||
|
// Per-segment cubic: chain `M … C … C …` through projected anchors/handles.
|
||||||
|
const list = ctx.anchors.value;
|
||||||
|
if (list.length < 2) return '';
|
||||||
|
let d = '';
|
||||||
|
for (let i = 0; i < list.length - 1; i++) {
|
||||||
|
const a = list[i]!;
|
||||||
|
const b = list[i + 1]!;
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const c1x = a.outHandle ? a.x + a.outHandle.x : a.x + dx / 3;
|
||||||
|
const c1y = a.outHandle ? a.y + a.outHandle.y : a.y + (b.y - a.y) / 3;
|
||||||
|
const c2x = b.inHandle ? b.x + b.inHandle.x : b.x - dx / 3;
|
||||||
|
const c2y = b.inHandle ? b.y + b.inHandle.y : b.y - (b.y - a.y) / 3;
|
||||||
|
const p0 = project(a.x, a.y);
|
||||||
|
const pc1 = project(c1x, c1y);
|
||||||
|
const pc2 = project(c2x, c2y);
|
||||||
|
const p3 = project(b.x, b.y);
|
||||||
|
const seg = buildBezierPath(p0, pc1, pc2, p3);
|
||||||
|
// Drop the leading `M` on subsequent segments (they continue the path).
|
||||||
|
d += i === 0 ? seg : seg.replace(/^M[^C]*/, '');
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sampled `y = f(x)` polyline projected to pixels.
|
||||||
|
const samples = sampleFnToPolyline(x => ctx.sample(x), x0, x1, SAMPLES);
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const p = samples[i]!;
|
||||||
|
samples[i] = project(p.x, p.y);
|
||||||
|
}
|
||||||
|
return buildPolylinePath(samples);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { forwardRef } = useForwardExpose();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
:ref="forwardRef"
|
||||||
|
:as="as"
|
||||||
|
:d="as === 'path' ? pathD : undefined"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
data-curve-editor-curve=""
|
||||||
|
>
|
||||||
|
<slot :d="pathD" />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PrimitiveProps } from '../../internal/primitive';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The background gridlines of a `CurveEditorRoot`, drawn from `niceTicks` on
|
||||||
|
* both axes. Rendered as an SVG `<g>` by default (override via `as`) and marked
|
||||||
|
* `aria-hidden` — it is decorative. Exposes the projected `xTicks` / `yTicks` as
|
||||||
|
* slot props so the consumer draws their own lines / labels, and provides a
|
||||||
|
* `#histogram` slot region behind the curve for tone-curve histograms.
|
||||||
|
*/
|
||||||
|
export interface CurveEditorGridProps extends PrimitiveProps {
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Primitive } from '../../internal/primitive';
|
||||||
|
import { useCurveEditorContext } from './context';
|
||||||
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
|
|
||||||
|
const { as = 'g' } = defineProps<CurveEditorGridProps>();
|
||||||
|
const ctx = useCurveEditorContext();
|
||||||
|
|
||||||
|
// `useScale` already projects ticks to pixels (`tick.px`); the x ticks run along
|
||||||
|
// the horizontal axis, the y ticks along the vertical (value-up) axis.
|
||||||
|
const xTicks = computed(() => ctx.scaleX.ticks.value);
|
||||||
|
const yTicks = computed(() => ctx.scaleY.ticks.value);
|
||||||
|
|
||||||
|
const { forwardRef } = useForwardExpose();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
:ref="forwardRef"
|
||||||
|
:as="as"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-curve-editor-grid=""
|
||||||
|
>
|
||||||
|
<slot :x-ticks="xTicks" :y-ticks="yTicks" />
|
||||||
|
<slot name="histogram" :x-ticks="xTicks" :y-ticks="yTicks" />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CurveEditorAnchor, CurveEditorHandleSide } from './context';
|
||||||
|
import type { PrimitiveProps } from '../../internal/primitive';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bezier tangent handle for an anchor, rendered only in `'bezier'`
|
||||||
|
* interpolation. Dragging it adjusts the anchor's `inHandle` / `outHandle`
|
||||||
|
* tangent (relative deltas), clamped (in easing / `monotonicX` mode) so the
|
||||||
|
* cubic segment stays single-valued in x — the handle's x-component can't reach
|
||||||
|
* past the neighbouring anchor (`dx >= 0`), preventing an S-fold. It is exposed
|
||||||
|
* as `role="slider"` with a descriptive `aria-label`; pass `aria-hidden` to make
|
||||||
|
* it purely decorative. Positions itself at the tangent endpoint in pixel space.
|
||||||
|
*/
|
||||||
|
export interface CurveEditorHandleProps extends PrimitiveProps {
|
||||||
|
/** The anchor whose tangent this handle controls. */
|
||||||
|
anchor: CurveEditorAnchor;
|
||||||
|
/** Which tangent (`'in'` = incoming, `'out'` = outgoing). */
|
||||||
|
side: CurveEditorHandleSide;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Primitive } from '../../internal/primitive';
|
||||||
|
import { useCurveEditorContext } from './context';
|
||||||
|
import { usePointerDrag } from '../../internal/pointer-drag';
|
||||||
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
|
|
||||||
|
const { anchor, side, as = 'div' } = defineProps<CurveEditorHandleProps>();
|
||||||
|
const ctx = useCurveEditorContext();
|
||||||
|
|
||||||
|
const isBezier = computed(() => ctx.interpolation.value === 'bezier');
|
||||||
|
|
||||||
|
// Default tangent (one-third toward the neighbour) when no handle is set yet, so
|
||||||
|
// the handle is grabbable before the consumer first drags it.
|
||||||
|
const handle = computed(() => {
|
||||||
|
const stored = side === 'in' ? anchor.inHandle : anchor.outHandle;
|
||||||
|
if (stored) return stored;
|
||||||
|
const list = ctx.anchors.value;
|
||||||
|
const i = ctx.indexOf(anchor.id);
|
||||||
|
const neighbour = side === 'out' ? list[i + 1] : list[i - 1];
|
||||||
|
if (!neighbour) return { x: 0, y: 0 };
|
||||||
|
return { x: (neighbour.x - anchor.x) / 3, y: (neighbour.y - anchor.y) / 3 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tangent endpoint in domain space → pixels.
|
||||||
|
const tip = computed(() => {
|
||||||
|
const h = handle.value;
|
||||||
|
return {
|
||||||
|
x: ctx.scaleX.scale(anchor.x + h.x),
|
||||||
|
y: ctx.scaleY.scale(anchor.y + h.y),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const positionStyle = computed<{ left: string; top: string }>(() => ({
|
||||||
|
left: `${tip.value.x}px`,
|
||||||
|
top: `${tip.value.y}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ariaLabel = computed(() => side === 'in' ? 'Incoming tangent handle' : 'Outgoing tangent handle');
|
||||||
|
|
||||||
|
const { forwardRef, currentElement } = useForwardExpose();
|
||||||
|
|
||||||
|
let originX = 0;
|
||||||
|
let originY = 0;
|
||||||
|
usePointerDrag(currentElement, {
|
||||||
|
axis: 'both',
|
||||||
|
threshold: 0,
|
||||||
|
disabled: () => ctx.disabled.value || !isBezier.value,
|
||||||
|
onStart: () => {
|
||||||
|
originX = tip.value.x;
|
||||||
|
originY = tip.value.y;
|
||||||
|
},
|
||||||
|
onMove: (state) => {
|
||||||
|
// Invert the dragged tip to domain space, then store the tangent relative to
|
||||||
|
// the anchor. `updateHandle` clamps for monotonic-x safety. Live update only;
|
||||||
|
// `anchorsCommit` is emitted once on settle (onCommit), not per rAF frame.
|
||||||
|
const tipX = ctx.scaleX.invert(originX + state.total.x);
|
||||||
|
const tipY = ctx.scaleY.invert(originY + state.total.y);
|
||||||
|
ctx.updateHandle(anchor.id, side, { x: tipX - anchor.x, y: tipY - anchor.y });
|
||||||
|
},
|
||||||
|
// Successful pointerup only (never on cancel/abort): emit the settled anchors.
|
||||||
|
onCommit: () => ctx.commit(),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
v-if="isBezier"
|
||||||
|
:ref="forwardRef"
|
||||||
|
:as="as"
|
||||||
|
role="slider"
|
||||||
|
:aria-label="ariaLabel"
|
||||||
|
:aria-disabled="ctx.disabled.value || undefined"
|
||||||
|
:data-disabled="ctx.disabled.value ? '' : undefined"
|
||||||
|
:data-side="side"
|
||||||
|
:style="positionStyle"
|
||||||
|
@keydown.stop
|
||||||
|
>
|
||||||
|
<slot :handle="handle" :x="tip.x" :y="tip.y" />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CurveEditorAnchor } from './context';
|
||||||
|
import type { PrimitiveProps } from '../../internal/primitive';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One anchor handle of a `CurveEditorRoot`, rendered as `role="slider"`. A 2D
|
||||||
|
* control whose single `aria-valuenow` (the output `y`) can't carry both axes,
|
||||||
|
* so `aria-valuetext` announces the pair as `"input {x}, output {y}"`.
|
||||||
|
* `aria-valuemin`/`max` describe the output (`y`) domain.
|
||||||
|
*
|
||||||
|
* Anchors share one tab-stop (roving focus): Tab moves focus between them, the
|
||||||
|
* arrow keys nudge the focused anchor. Left/Right nudge `x` by `step`
|
||||||
|
* (neighbour- and domain-clamped; no-op for fixed endpoints), Up/Down nudge `y`
|
||||||
|
* (Up = +y), Shift+Arrow uses the large step, PageUp/PageDown jump `y`, Home/End
|
||||||
|
* move `x` to the domain min/max. Enter adds an anchor at the midpoint to the
|
||||||
|
* next anchor; Delete/Backspace removes the focused anchor (never an endpoint).
|
||||||
|
* Double-click also adds, drag moves the anchor (2D, clamped). Exposes the
|
||||||
|
* anchor and its pixel position as slot props.
|
||||||
|
*/
|
||||||
|
export interface CurveEditorPointProps extends PrimitiveProps {
|
||||||
|
/** The anchor this point renders. */
|
||||||
|
anchor: CurveEditorAnchor;
|
||||||
|
/**
|
||||||
|
* Override the announced `aria-valuetext`. Receives the anchor's `x` and `y`
|
||||||
|
* in domain space.
|
||||||
|
*/
|
||||||
|
valueText?: (x: number, y: number) => string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, useAttrs, watch } from 'vue';
|
||||||
|
import { Primitive } from '../../internal/primitive';
|
||||||
|
import { useCurveEditorContext } from './context';
|
||||||
|
import { usePointerDrag } from '../../internal/pointer-drag';
|
||||||
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
|
import { formatAnchorValueText } from './utils';
|
||||||
|
|
||||||
|
const { anchor, valueText, as = 'div' } = defineProps<CurveEditorPointProps>();
|
||||||
|
const ctx = useCurveEditorContext();
|
||||||
|
const attrs = useAttrs();
|
||||||
|
|
||||||
|
const index = computed(() => ctx.indexOf(anchor.id));
|
||||||
|
const isEndpoint = computed(() => ctx.isEndpoint(anchor.id));
|
||||||
|
const isActive = computed(() => ctx.activeIndex.value === index.value);
|
||||||
|
|
||||||
|
// Pixel position from the axis projections (x horizontal, y value-up vertical).
|
||||||
|
const pxX = computed(() => ctx.scaleX.scale(anchor.x));
|
||||||
|
const pxY = computed(() => ctx.scaleY.scale(anchor.y));
|
||||||
|
|
||||||
|
const positionStyle = computed<{ left: string; top: string }>(() => ({
|
||||||
|
left: `${pxX.value}px`,
|
||||||
|
top: `${pxY.value}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// `aria-valuemin`/`max`/`now` describe the OUTPUT (y) domain — the single axis a
|
||||||
|
// slider can express; `aria-valuetext` conveys both coordinates.
|
||||||
|
const domainY = computed(() => ctx.domainY.value);
|
||||||
|
const ariaValueMin = computed(() => Math.min(domainY.value[0], domainY.value[1]));
|
||||||
|
const ariaValueMax = computed(() => Math.max(domainY.value[0], domainY.value[1]));
|
||||||
|
|
||||||
|
const ariaValueText = computed<string | undefined>(() => {
|
||||||
|
if (attrs['aria-valuetext'] !== undefined && attrs['aria-valuetext'] !== null) return undefined;
|
||||||
|
if (valueText) return valueText(anchor.x, anchor.y);
|
||||||
|
return formatAnchorValueText(anchor.x, anchor.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Roving focus: only the active anchor is in the tab order.
|
||||||
|
const tabindex = computed(() => {
|
||||||
|
if (ctx.disabled.value) return -1;
|
||||||
|
return isActive.value ? 0 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { forwardRef, currentElement } = useForwardExpose();
|
||||||
|
|
||||||
|
watch(currentElement, (node) => {
|
||||||
|
ctx.registerAnchorEl(anchor.id, node ?? null);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => ctx.registerAnchorEl(anchor.id, null));
|
||||||
|
|
||||||
|
// ── pointer drag (2D) ─────────────────────────────────────────────────────
|
||||||
|
// Map the element-relative pointer to domain x/y via inverse projection. The
|
||||||
|
// drag tracks the ROOT element's rect (the projections measure that box).
|
||||||
|
// Capture the anchor's pixel origin at drag start so cumulative drag totals
|
||||||
|
// (client px, which equal plot px 1:1) project back through the scales without
|
||||||
|
// needing the plot rect.
|
||||||
|
let dragOriginX = 0;
|
||||||
|
let dragOriginY = 0;
|
||||||
|
usePointerDrag(currentElement, {
|
||||||
|
axis: 'both',
|
||||||
|
threshold: 0,
|
||||||
|
disabled: () => ctx.disabled.value,
|
||||||
|
onStart: () => {
|
||||||
|
ctx.setActiveIndex(index.value);
|
||||||
|
dragOriginX = pxX.value;
|
||||||
|
dragOriginY = pxY.value;
|
||||||
|
},
|
||||||
|
onMove: (state) => {
|
||||||
|
const x = ctx.scaleX.invert(dragOriginX + state.total.x);
|
||||||
|
const y = ctx.scaleY.invert(dragOriginY + state.total.y);
|
||||||
|
// Live update only — the drag commits once on settle (onCommit), so
|
||||||
|
// `anchorsCommit` fires per the documented "after a drag settles" contract
|
||||||
|
// rather than once per rAF frame.
|
||||||
|
ctx.updateAnchor(anchor.id, { x, y });
|
||||||
|
},
|
||||||
|
// Successful pointerup only (never on cancel/abort): emit the settled anchors.
|
||||||
|
onCommit: () => ctx.commit(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── keyboard ──────────────────────────────────────────────────────────────
|
||||||
|
// A keyboard nudge is a discrete settle: emit `anchorsCommit` once, only when
|
||||||
|
// the anchor actually moved (a clamped no-op at a domain edge does not commit,
|
||||||
|
// matching the original updateAnchor-only-on-change behaviour).
|
||||||
|
function nudge(dx: number, dy: number): void {
|
||||||
|
let changed = false;
|
||||||
|
if (dx !== 0) changed = ctx.updateAnchor(anchor.id, { x: anchor.x + dx }) || changed;
|
||||||
|
if (dy !== 0) changed = ctx.updateAnchor(anchor.id, { y: anchor.y + dy }) || changed;
|
||||||
|
if (changed) ctx.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent): void {
|
||||||
|
if (ctx.disabled.value) return;
|
||||||
|
const rtl = ctx.direction.value === 'rtl';
|
||||||
|
const step = ctx.step.value;
|
||||||
|
const big = ctx.largeStep.value;
|
||||||
|
const unit = event.shiftKey ? big : step;
|
||||||
|
const [dyMin, dyMax] = ctx.domainY.value;
|
||||||
|
const ySpan = Math.abs(dyMax - dyMin);
|
||||||
|
const xLocked = ctx.fixedEndpoints.value && isEndpoint.value;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
event.preventDefault();
|
||||||
|
if (!xLocked) nudge(rtl ? -unit : unit, 0);
|
||||||
|
return;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
event.preventDefault();
|
||||||
|
if (!xLocked) nudge(rtl ? unit : -unit, 0);
|
||||||
|
return;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
nudge(0, unit);
|
||||||
|
return;
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
nudge(0, -unit);
|
||||||
|
return;
|
||||||
|
case 'PageUp':
|
||||||
|
event.preventDefault();
|
||||||
|
if (ctx.updateAnchor(anchor.id, { y: anchor.y + ySpan * 0.1 })) ctx.commit();
|
||||||
|
return;
|
||||||
|
case 'PageDown':
|
||||||
|
event.preventDefault();
|
||||||
|
if (ctx.updateAnchor(anchor.id, { y: anchor.y - ySpan * 0.1 })) ctx.commit();
|
||||||
|
return;
|
||||||
|
case 'Home':
|
||||||
|
event.preventDefault();
|
||||||
|
if (!xLocked && ctx.updateAnchor(anchor.id, { x: ctx.domainX.value[0] })) ctx.commit();
|
||||||
|
return;
|
||||||
|
case 'End':
|
||||||
|
event.preventDefault();
|
||||||
|
if (!xLocked && ctx.updateAnchor(anchor.id, { x: ctx.domainX.value[1] })) ctx.commit();
|
||||||
|
return;
|
||||||
|
case 'Enter': {
|
||||||
|
event.preventDefault();
|
||||||
|
// Add an anchor between this one and the next (or before, at the last).
|
||||||
|
const list = ctx.anchors.value;
|
||||||
|
const i = index.value;
|
||||||
|
const next = list[i + 1] ?? list[i - 1];
|
||||||
|
if (!next) return;
|
||||||
|
const midX = (anchor.x + next.x) / 2;
|
||||||
|
ctx.addAnchor(midX);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Delete':
|
||||||
|
case 'Backspace':
|
||||||
|
event.preventDefault();
|
||||||
|
ctx.removeAnchor(anchor.id);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDblClick(event: MouseEvent): void {
|
||||||
|
if (ctx.disabled.value) return;
|
||||||
|
event.preventDefault();
|
||||||
|
// Double-click on an interior anchor removes it; on an endpoint adds a midpoint.
|
||||||
|
if (!isEndpoint.value) {
|
||||||
|
ctx.removeAnchor(anchor.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = ctx.anchors.value;
|
||||||
|
const i = index.value;
|
||||||
|
const neighbour = list[i + 1] ?? list[i - 1];
|
||||||
|
if (neighbour) ctx.addAnchor((anchor.x + neighbour.x) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocus(): void {
|
||||||
|
if (index.value !== -1) ctx.setActiveIndex(index.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
:ref="forwardRef"
|
||||||
|
:as="as"
|
||||||
|
role="slider"
|
||||||
|
:tabindex="tabindex"
|
||||||
|
:aria-valuemin="ariaValueMin"
|
||||||
|
:aria-valuemax="ariaValueMax"
|
||||||
|
:aria-valuenow="anchor.y"
|
||||||
|
:aria-valuetext="ariaValueText"
|
||||||
|
:aria-orientation="undefined"
|
||||||
|
:aria-disabled="ctx.disabled.value || undefined"
|
||||||
|
:data-disabled="ctx.disabled.value ? '' : undefined"
|
||||||
|
:data-endpoint="isEndpoint ? '' : undefined"
|
||||||
|
:data-active="isActive ? '' : undefined"
|
||||||
|
:data-channel="ctx.channel.value"
|
||||||
|
:style="positionStyle"
|
||||||
|
@keydown="onKeyDown"
|
||||||
|
@dblclick="onDblClick"
|
||||||
|
@focus="onFocus"
|
||||||
|
>
|
||||||
|
<slot :anchor="anchor" :x="pxX" :y="pxY" :active="isActive" :endpoint="isEndpoint" />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CurveEditorAnchor, CurveEditorChannel, CurveEditorDirection, CurveEditorHandleSide, CurveEditorInterpolation } from './context';
|
||||||
|
import type { Point } from '../../internal/utils/geometry';
|
||||||
|
import type { PrimitiveProps } from '../../internal/primitive';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A headless control-point curve editor: a draggable set of anchors defining a
|
||||||
|
* single-valued `y = f(x)` curve. It backs both **animation easing curves** (an
|
||||||
|
* ease over normalized time) and **photo tone curves** (per-RGB-channel output
|
||||||
|
* remapping), and is the shared engine reused by Levels (gamma) and the future
|
||||||
|
* KeyframeTrack.
|
||||||
|
*
|
||||||
|
* The root owns the anchor array (controlled via `v-model`, uncontrolled via
|
||||||
|
* `defaultValue`), builds value↔pixel projections for both axes (`useScale`,
|
||||||
|
* y-axis value-up), and exposes the live evaluator: `sample(x) → y` and
|
||||||
|
* `toLUT(size)` for applying the curve to pixels. With `monotonicX` (the
|
||||||
|
* default) anchors are neighbour-clamped so they can never cross in x — easing
|
||||||
|
* and tone curves both require a function of x. `fixedEndpoints` locks the first
|
||||||
|
* and last anchor in x. The `interpolation` mode selects monotone (default),
|
||||||
|
* linear, catmull-rom, or per-anchor bezier handles.
|
||||||
|
*
|
||||||
|
* Provides context to `CurveEditorGrid`, `CurveEditorCurve`, `CurveEditorPoint`,
|
||||||
|
* and `CurveEditorHandle`. The `channel` prop only tags which curve is being
|
||||||
|
* edited (for styling / the `#channel` slot); consumers render their own RGB
|
||||||
|
* tabs.
|
||||||
|
*/
|
||||||
|
export interface CurveEditorRootProps extends PrimitiveProps {
|
||||||
|
/** Uncontrolled initial anchors. Seeds the curve when `v-model` is absent. */
|
||||||
|
defaultValue?: CurveEditorAnchor[];
|
||||||
|
/**
|
||||||
|
* How the curve is interpolated between anchors.
|
||||||
|
* @default 'monotone'
|
||||||
|
*/
|
||||||
|
interpolation?: CurveEditorInterpolation;
|
||||||
|
/** Input (x) domain `[min, max]`. @default [0, 1] */
|
||||||
|
domainX?: readonly [number, number];
|
||||||
|
/** Output (y) domain `[min, max]`. @default [0, 1] */
|
||||||
|
domainY?: readonly [number, number];
|
||||||
|
/**
|
||||||
|
* Keep x single-valued: neighbour-clamp anchors so they can't cross in x.
|
||||||
|
* Easing / tone curves require a function of x. @default true
|
||||||
|
*/
|
||||||
|
monotonicX?: boolean;
|
||||||
|
/** Lock the first and last anchor in x (only y is editable). @default true */
|
||||||
|
fixedEndpoints?: boolean;
|
||||||
|
/**
|
||||||
|
* Tags which curve is being edited (composite `'value'` or per-channel
|
||||||
|
* `'r'`/`'g'`/`'b'`). Purely cosmetic — exposed for styling / the `#channel`
|
||||||
|
* slot; consumers render their own channel tabs. @default 'value'
|
||||||
|
*/
|
||||||
|
channel?: CurveEditorChannel;
|
||||||
|
/** Keyboard step for x/y nudges. @default 0.01 */
|
||||||
|
step?: number;
|
||||||
|
/** Large keyboard step (Shift+Arrow / Page keys). @default 0.1 */
|
||||||
|
largeStep?: number;
|
||||||
|
/** Sample count for the rendered polyline / LUT. @default 256 */
|
||||||
|
samples?: number;
|
||||||
|
/** Disable all interaction. @default false */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Writing direction (inherited from `ConfigProvider` when omitted). */
|
||||||
|
dir?: CurveEditorDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurveEditorRootEmits {
|
||||||
|
/** Fired after a drag or keypress settles (anchor added / removed / moved). */
|
||||||
|
anchorsCommit: [anchors: CurveEditorAnchor[]];
|
||||||
|
/** Fired when an anchor is added. */
|
||||||
|
anchorAdd: [anchor: CurveEditorAnchor];
|
||||||
|
/** Fired when an anchor is removed. */
|
||||||
|
anchorRemove: [anchor: CurveEditorAnchor];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, shallowRef, toRef, watch } from 'vue';
|
||||||
|
import { Primitive } from '../../internal/primitive';
|
||||||
|
import { provideCurveEditorContext } from './context';
|
||||||
|
import { useScale } from '../../internal/scale';
|
||||||
|
import { useDirection, useId } from '../../utilities/config-provider';
|
||||||
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
|
import { buildEvaluator, clampAnchorX, clampAnchorY, sortAnchors } from './utils';
|
||||||
|
import { toLUT as splineToLUT } from '../../internal/spline';
|
||||||
|
|
||||||
|
const {
|
||||||
|
defaultValue,
|
||||||
|
interpolation = 'monotone',
|
||||||
|
domainX = [0, 1] as readonly [number, number],
|
||||||
|
domainY = [0, 1] as readonly [number, number],
|
||||||
|
monotonicX = true,
|
||||||
|
fixedEndpoints = true,
|
||||||
|
channel = 'value',
|
||||||
|
step = 0.01,
|
||||||
|
largeStep = 0.1,
|
||||||
|
samples = 256,
|
||||||
|
disabled = false,
|
||||||
|
dir,
|
||||||
|
as = 'div',
|
||||||
|
} = defineProps<CurveEditorRootProps>();
|
||||||
|
|
||||||
|
const direction = useDirection(() => dir);
|
||||||
|
const emit = defineEmits<CurveEditorRootEmits>();
|
||||||
|
const idBase = useId();
|
||||||
|
|
||||||
|
const model = defineModel<CurveEditorAnchor[] | null>();
|
||||||
|
|
||||||
|
/** Default curve: a straight identity line across the domain (two endpoints). */
|
||||||
|
function defaultAnchors(): CurveEditorAnchor[] {
|
||||||
|
const [x0, x1] = domainX;
|
||||||
|
const [y0, y1] = domainY;
|
||||||
|
return [
|
||||||
|
{ id: `${idBase.value}-0`, x: x0, y: y0 },
|
||||||
|
{ id: `${idBase.value}-1`, x: x1, y: y1 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedAnchors(): CurveEditorAnchor[] {
|
||||||
|
const seed = Array.isArray(model.value) && model.value.length > 0
|
||||||
|
? model.value
|
||||||
|
: Array.isArray(defaultValue) && defaultValue.length > 0
|
||||||
|
? defaultValue
|
||||||
|
: defaultAnchors();
|
||||||
|
return sortAnchors(seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `shallowRef` — the array is replaced wholesale on every mutation; items are
|
||||||
|
// plain (non-proxied) so the evaluator/render path reads them cheaply.
|
||||||
|
const localAnchors = shallowRef<CurveEditorAnchor[]>(seedAnchors());
|
||||||
|
|
||||||
|
watch(model, (v) => {
|
||||||
|
if (v === null || v === undefined) return;
|
||||||
|
if (v === localAnchors.value) return;
|
||||||
|
localAnchors.value = sortAnchors(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
const anchors = computed<CurveEditorAnchor[]>({
|
||||||
|
get: () => localAnchors.value,
|
||||||
|
set: (v) => {
|
||||||
|
const sorted = sortAnchors(v);
|
||||||
|
localAnchors.value = sorted;
|
||||||
|
model.value = sorted;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-incrementing id seed for inserted anchors (stable across the session).
|
||||||
|
let idCounter = localAnchors.value.length;
|
||||||
|
function nextId(): string {
|
||||||
|
return `${idBase.value}-${idCounter++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── geometry / projections ────────────────────────────────────────────────
|
||||||
|
// Plot size in pixels; measured from the root element after mount. Degenerate
|
||||||
|
// `[n, n]` ranges (zero-size pre-mount) are guarded by `scaleLinear` (returns
|
||||||
|
// range start), so projections never NaN.
|
||||||
|
const plotWidth = shallowRef(0);
|
||||||
|
const plotHeight = shallowRef(0);
|
||||||
|
|
||||||
|
const scaleX = useScale({
|
||||||
|
domain: () => domainX,
|
||||||
|
range: () => [0, plotWidth.value] as const,
|
||||||
|
orientation: 'horizontal',
|
||||||
|
clamp: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// y-axis is value-UP: domain start maps to the bottom (range end).
|
||||||
|
const scaleY = useScale({
|
||||||
|
domain: () => domainY,
|
||||||
|
range: () => [0, plotHeight.value] as const,
|
||||||
|
orientation: 'vertical',
|
||||||
|
clamp: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── evaluator ─────────────────────────────────────────────────────────────
|
||||||
|
const evaluator = computed(() => buildEvaluator(localAnchors.value, interpolation));
|
||||||
|
|
||||||
|
function sample(x: number): number {
|
||||||
|
return evaluator.value(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLUT(size: number = samples): number[] {
|
||||||
|
const [x0, x1] = domainX;
|
||||||
|
return splineToLUT(evaluator.value, size, x0, x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── roving focus ──────────────────────────────────────────────────────────
|
||||||
|
const activeIndex = ref(0);
|
||||||
|
const anchorEls = new Map<string, HTMLElement | null>();
|
||||||
|
|
||||||
|
function registerAnchorEl(id: string, el: HTMLElement | null): void {
|
||||||
|
if (el) anchorEls.set(id, el);
|
||||||
|
else anchorEls.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// id → index map, rebuilt once per `localAnchors` replacement. Parts derive
|
||||||
|
// their `index`/`isEndpoint` from this O(1) lookup instead of each re-scanning
|
||||||
|
// the wholesale-replaced array every drag frame (was O(n) per part → O(n^2)
|
||||||
|
// across the list per committed frame).
|
||||||
|
const indexById = computed(() => {
|
||||||
|
const list = localAnchors.value;
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (let i = 0; i < list.length; i++) map.set(list[i]!.id, i);
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
function indexOf(id: string): number {
|
||||||
|
return indexById.value.get(id) ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEndpoint(id: string): boolean {
|
||||||
|
const i = indexById.value.get(id) ?? -1;
|
||||||
|
return i === 0 || i === localAnchors.value.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusIndex(index: number): void {
|
||||||
|
const anchor = localAnchors.value[index];
|
||||||
|
if (!anchor) return;
|
||||||
|
anchorEls.get(anchor.id)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveIndex(index: number): void {
|
||||||
|
const count = localAnchors.value.length;
|
||||||
|
if (count === 0) return;
|
||||||
|
const clamped = Math.min(Math.max(index, 0), count - 1);
|
||||||
|
activeIndex.value = clamped;
|
||||||
|
focusIndex(clamped);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFocus(delta: number): void {
|
||||||
|
const count = localAnchors.value.length;
|
||||||
|
if (count === 0) return;
|
||||||
|
const next = ((activeIndex.value + delta) % count + count) % count;
|
||||||
|
setActiveIndex(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the active index valid as anchors are added/removed.
|
||||||
|
watch(() => localAnchors.value.length, (count) => {
|
||||||
|
if (count === 0) {
|
||||||
|
activeIndex.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeIndex.value > count - 1) activeIndex.value = count - 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── mutations ─────────────────────────────────────────────────────────────
|
||||||
|
const minGap = computed(() => Math.max(step, (Math.abs(domainX[1] - domainX[0])) * 1e-3));
|
||||||
|
|
||||||
|
function commit(): void {
|
||||||
|
emit('anchorsCommit', localAnchors.value.map(a => ({ ...a })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns whether the anchor actually moved. The live update is decoupled from
|
||||||
|
// the `anchorsCommit` emit: callers commit on settle (drag end / keypress), not
|
||||||
|
// per frame. Returning the changed-flag lets discrete callers (keyboard) skip
|
||||||
|
// committing on a clamped no-op, matching the original "only emit on change".
|
||||||
|
function updateAnchor(id: string, next: { x?: number; y?: number }): boolean {
|
||||||
|
if (disabled) return false;
|
||||||
|
const list = localAnchors.value;
|
||||||
|
const index = indexOf(id);
|
||||||
|
if (index === -1) return false;
|
||||||
|
const current = list[index]!;
|
||||||
|
|
||||||
|
let x = current.x;
|
||||||
|
if (next.x !== undefined) {
|
||||||
|
x = clampAnchorX(list, index, next.x, {
|
||||||
|
domainMin: domainX[0],
|
||||||
|
domainMax: domainX[1],
|
||||||
|
monotonicX,
|
||||||
|
fixedEndpoints,
|
||||||
|
minGap: minGap.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let y = current.y;
|
||||||
|
if (next.y !== undefined)
|
||||||
|
y = clampAnchorY(next.y, domainY[0], domainY[1]);
|
||||||
|
|
||||||
|
if (x === current.x && y === current.y) return false;
|
||||||
|
|
||||||
|
const candidate = list.slice();
|
||||||
|
candidate[index] = { ...current, x, y };
|
||||||
|
// x never crosses a neighbour (clampAnchorX guarantees it under monotonicX),
|
||||||
|
// so order is preserved without a re-sort. Re-sort defensively when monotonicX
|
||||||
|
// is off (anchors may legitimately reorder).
|
||||||
|
anchors.value = monotonicX ? candidate : sortAnchors(candidate);
|
||||||
|
// Track the moved anchor's new index for roving focus.
|
||||||
|
activeIndex.value = indexOf(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHandle(id: string, side: CurveEditorHandleSide, handle: Point): void {
|
||||||
|
if (disabled || interpolation !== 'bezier') return;
|
||||||
|
const list = localAnchors.value;
|
||||||
|
const index = indexOf(id);
|
||||||
|
if (index === -1) return;
|
||||||
|
const current = list[index]!;
|
||||||
|
|
||||||
|
// Easing requires the segment stay monotone in x: clamp the tangent so its
|
||||||
|
// x-component never points "backwards" past the adjacent anchor (dx >= 0).
|
||||||
|
const clamped = clampHandle(list, index, side, handle);
|
||||||
|
|
||||||
|
const candidate = list.slice();
|
||||||
|
candidate[index] = side === 'in'
|
||||||
|
? { ...current, inHandle: clamped }
|
||||||
|
: { ...current, outHandle: clamped };
|
||||||
|
anchors.value = candidate;
|
||||||
|
// Live update only; the handle drag commits once on settle (see onCommit).
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp a bezier tangent so the cubic segment stays single-valued in x
|
||||||
|
* (`dx >= 0`): the outgoing handle may not reach past the next anchor, the
|
||||||
|
* incoming handle may not reach before the previous anchor. Prevents the
|
||||||
|
* S-fold that would make the easing curve multi-valued.
|
||||||
|
*/
|
||||||
|
function clampHandle(list: readonly CurveEditorAnchor[], index: number, side: CurveEditorHandleSide, handle: Point): Point {
|
||||||
|
if (!monotonicX) return handle;
|
||||||
|
const current = list[index]!;
|
||||||
|
if (side === 'out') {
|
||||||
|
const next = list[index + 1];
|
||||||
|
const maxDx = next ? next.x - current.x : domainX[1] - current.x;
|
||||||
|
return { x: Math.min(Math.max(handle.x, 0), Math.max(0, maxDx)), y: handle.y };
|
||||||
|
}
|
||||||
|
const prev = list[index - 1];
|
||||||
|
const minDx = prev ? prev.x - current.x : domainX[0] - current.x;
|
||||||
|
return { x: Math.max(Math.min(handle.x, 0), Math.min(0, minDx)), y: handle.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAnchor(x: number, y?: number): string | undefined {
|
||||||
|
if (disabled) return undefined;
|
||||||
|
const lo = Math.min(domainX[0], domainX[1]);
|
||||||
|
const hi = Math.max(domainX[0], domainX[1]);
|
||||||
|
const cx = Math.min(Math.max(x, lo), hi);
|
||||||
|
const cy = clampAnchorY(y ?? sample(cx), domainY[0], domainY[1]);
|
||||||
|
const id = nextId();
|
||||||
|
const anchor: CurveEditorAnchor = { id, x: cx, y: cy };
|
||||||
|
const candidate = sortAnchors([...localAnchors.value, anchor]);
|
||||||
|
anchors.value = candidate;
|
||||||
|
setActiveIndex(indexOf(id));
|
||||||
|
emit('anchorAdd', { ...anchor });
|
||||||
|
commit();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAnchor(id: string): void {
|
||||||
|
if (disabled) return;
|
||||||
|
if (isEndpoint(id)) return;
|
||||||
|
const index = indexOf(id);
|
||||||
|
if (index === -1) return;
|
||||||
|
const removed = localAnchors.value[index]!;
|
||||||
|
const candidate = localAnchors.value.slice();
|
||||||
|
candidate.splice(index, 1);
|
||||||
|
anchors.value = candidate;
|
||||||
|
setActiveIndex(Math.min(index, candidate.length - 1));
|
||||||
|
emit('anchorRemove', { ...removed });
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
provideCurveEditorContext({
|
||||||
|
anchors,
|
||||||
|
interpolation: toRef(() => interpolation),
|
||||||
|
domainX: toRef(() => domainX),
|
||||||
|
domainY: toRef(() => domainY),
|
||||||
|
scaleX,
|
||||||
|
scaleY,
|
||||||
|
channel: toRef(() => channel),
|
||||||
|
step: toRef(() => step),
|
||||||
|
largeStep: toRef(() => largeStep),
|
||||||
|
monotonicX: toRef(() => monotonicX),
|
||||||
|
fixedEndpoints: toRef(() => fixedEndpoints),
|
||||||
|
direction,
|
||||||
|
disabled: toRef(() => disabled),
|
||||||
|
activeIndex,
|
||||||
|
sample,
|
||||||
|
toLUT,
|
||||||
|
indexOf,
|
||||||
|
registerAnchorEl,
|
||||||
|
isEndpoint,
|
||||||
|
setActiveIndex,
|
||||||
|
moveFocus,
|
||||||
|
commit,
|
||||||
|
updateAnchor,
|
||||||
|
updateHandle,
|
||||||
|
addAnchor,
|
||||||
|
removeAnchor,
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ sample, toLUT, anchors, addAnchor, removeAnchor });
|
||||||
|
|
||||||
|
// `useForwardExpose` runs AFTER `defineExpose` so it merges the prior bindings
|
||||||
|
// (plus props + `$el`) instead of clobbering them.
|
||||||
|
const { forwardRef, currentElement } = useForwardExpose();
|
||||||
|
|
||||||
|
// Measure the plot box once the root element resolves, and on resize.
|
||||||
|
watch(currentElement, (node, _prev, onCleanup) => {
|
||||||
|
if (!node) return;
|
||||||
|
const measure = (): void => {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
plotWidth.value = rect.width;
|
||||||
|
plotHeight.value = rect.height;
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(node);
|
||||||
|
// Disconnect on unmount AND before the next run (currentElement change).
|
||||||
|
// Without this each re-run stacks a new observer and the last one leaks,
|
||||||
|
// retaining the root node + the measure closure (+ this component scope).
|
||||||
|
onCleanup(() => ro.disconnect());
|
||||||
|
}
|
||||||
|
}, { immediate: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
:ref="forwardRef"
|
||||||
|
:as="as"
|
||||||
|
:dir="direction"
|
||||||
|
:data-channel="channel"
|
||||||
|
:data-interpolation="interpolation"
|
||||||
|
:aria-disabled="disabled || undefined"
|
||||||
|
:data-disabled="disabled ? '' : undefined"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
:anchors="anchors"
|
||||||
|
:channel="channel"
|
||||||
|
:interpolation="interpolation"
|
||||||
|
:sample="sample"
|
||||||
|
/>
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import type { VueWrapper } from '@vue/test-utils';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||||
|
import type { CurveEditorAnchor, CurveEditorInterpolation } from '../index';
|
||||||
|
import { CurveEditorCurve, CurveEditorPoint, CurveEditorRoot } from '../index';
|
||||||
|
import { buildEvaluator, clampAnchorX } from '../utils';
|
||||||
|
|
||||||
|
const wrappers: Array<VueWrapper<any>> = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (wrappers.length) wrappers.pop()!.unmount();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function track<T extends VueWrapper<any>>(w: T): T {
|
||||||
|
wrappers.push(w);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void {
|
||||||
|
el.dispatchEvent(new KeyboardEvent('keydown', {
|
||||||
|
key,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
shiftKey: opts.shiftKey ?? false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchorsFromIds(...pts: Array<[number, number]>): CurveEditorAnchor[] {
|
||||||
|
return pts.map(([x, y], i) => ({ id: `a${i}`, x, y }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(n: number, d = 3): number {
|
||||||
|
const f = 10 ** d;
|
||||||
|
return Math.round(n * f) / f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountEditor(
|
||||||
|
opts: Partial<{
|
||||||
|
defaultValue: CurveEditorAnchor[];
|
||||||
|
interpolation: CurveEditorInterpolation;
|
||||||
|
monotonicX: boolean;
|
||||||
|
fixedEndpoints: boolean;
|
||||||
|
step: number;
|
||||||
|
largeStep: number;
|
||||||
|
disabled: boolean;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const model = ref<CurveEditorAnchor[] | undefined>(undefined);
|
||||||
|
const Harness = defineComponent({
|
||||||
|
setup: () => () => h(
|
||||||
|
CurveEditorRoot,
|
||||||
|
{
|
||||||
|
modelValue: model.value,
|
||||||
|
'onUpdate:modelValue': (v: CurveEditorAnchor[] | null | undefined) => {
|
||||||
|
model.value = v ?? undefined;
|
||||||
|
},
|
||||||
|
...opts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: ({ anchors }: { anchors: CurveEditorAnchor[] }) => [
|
||||||
|
h(CurveEditorCurve),
|
||||||
|
...anchors.map(a => h(CurveEditorPoint, { key: a.id, anchor: a })),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const w = track(mount(Harness, { attachTo: document.body }));
|
||||||
|
return { wrapper: w, model };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── unit tests: evaluator / sample logic ────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CurveEditor evaluator (unit)', () => {
|
||||||
|
it('linear interpolates and passes through anchors', () => {
|
||||||
|
const f = buildEvaluator(anchorsFromIds([0, 0], [1, 1]), 'linear');
|
||||||
|
expect(f(0)).toBe(0);
|
||||||
|
expect(f(1)).toBe(1);
|
||||||
|
expect(round(f(0.5))).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('monotone passes through anchors and is non-decreasing for a rising set', () => {
|
||||||
|
const f = buildEvaluator(anchorsFromIds([0, 0], [0.5, 0.2], [1, 1]), 'monotone');
|
||||||
|
expect(round(f(0))).toBe(0);
|
||||||
|
expect(round(f(0.5))).toBe(0.2);
|
||||||
|
expect(round(f(1))).toBe(1);
|
||||||
|
// Monotone (no overshoot): sampling forward never decreases.
|
||||||
|
let prev = -Infinity;
|
||||||
|
for (let i = 0; i <= 20; i++) {
|
||||||
|
const y = f(i / 20);
|
||||||
|
expect(y).toBeGreaterThanOrEqual(prev - 1e-9);
|
||||||
|
prev = y;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catmull-rom passes through its anchors', () => {
|
||||||
|
const f = buildEvaluator(anchorsFromIds([0, 0], [0.5, 0.7], [1, 1]), 'catmull-rom');
|
||||||
|
expect(round(f(0), 2)).toBe(0);
|
||||||
|
expect(round(f(0.5), 2)).toBe(0.7);
|
||||||
|
expect(round(f(1), 2)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bezier with default tangents reproduces a near-linear segment', () => {
|
||||||
|
const f = buildEvaluator(anchorsFromIds([0, 0], [1, 1]), 'bezier');
|
||||||
|
expect(round(f(0))).toBe(0);
|
||||||
|
expect(round(f(1))).toBe(1);
|
||||||
|
expect(round(f(0.5), 2)).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps x outside the domain to the endpoint y', () => {
|
||||||
|
const f = buildEvaluator(anchorsFromIds([0, 0.1], [1, 0.9]), 'monotone');
|
||||||
|
expect(f(-1)).toBe(0.1);
|
||||||
|
expect(f(2)).toBe(0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clampAnchorX keeps an anchor between its neighbours under monotonicX', () => {
|
||||||
|
const list = anchorsFromIds([0, 0], [0.5, 0.5], [1, 1]);
|
||||||
|
// Try to push the middle anchor past the right neighbour.
|
||||||
|
const x = clampAnchorX(list, 1, 5, {
|
||||||
|
domainMin: 0,
|
||||||
|
domainMax: 1,
|
||||||
|
monotonicX: true,
|
||||||
|
fixedEndpoints: true,
|
||||||
|
minGap: 0.01,
|
||||||
|
});
|
||||||
|
expect(x).toBeLessThanOrEqual(1 - 0.01);
|
||||||
|
expect(x).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clampAnchorX pins endpoints when fixedEndpoints', () => {
|
||||||
|
const list = anchorsFromIds([0, 0], [1, 1]);
|
||||||
|
expect(clampAnchorX(list, 0, 0.4, { domainMin: 0, domainMax: 1, monotonicX: true, fixedEndpoints: true, minGap: 0.01 })).toBe(0);
|
||||||
|
expect(clampAnchorX(list, 1, 0.4, { domainMin: 0, domainMax: 1, monotonicX: true, fixedEndpoints: true, minGap: 0.01 })).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── component tests (browser mode) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CurveEditor component', () => {
|
||||||
|
it('renders anchors as role=slider with aria-valuetext conveying input + output', async () => {
|
||||||
|
mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 0.5]) });
|
||||||
|
await nextTick();
|
||||||
|
const sliders = document.querySelectorAll<HTMLElement>('[role="slider"]');
|
||||||
|
expect(sliders.length).toBe(2);
|
||||||
|
const last = sliders[1]!;
|
||||||
|
expect(last.getAttribute('aria-valuenow')).toBe('0.5');
|
||||||
|
expect(last.getAttribute('aria-valuetext')).toBe('input 1, output 0.5');
|
||||||
|
expect(last.getAttribute('aria-valuemin')).toBe('0');
|
||||||
|
expect(last.getAttribute('aria-valuemax')).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes sample() and toLUT() that pass through anchors', async () => {
|
||||||
|
const { wrapper } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [0.5, 0.2], [1, 1]), interpolation: 'monotone' });
|
||||||
|
await nextTick();
|
||||||
|
const root = wrapper.findComponent(CurveEditorRoot);
|
||||||
|
const sample = (root.vm as any).sample as (x: number) => number;
|
||||||
|
expect(round(sample(0))).toBe(0);
|
||||||
|
expect(round(sample(0.5))).toBe(0.2);
|
||||||
|
expect(round(sample(1))).toBe(1);
|
||||||
|
const lut = (root.vm as any).toLUT(16) as number[];
|
||||||
|
expect(lut.length).toBe(16);
|
||||||
|
expect(round(lut[0]!)).toBe(0);
|
||||||
|
expect(round(lut[lut.length - 1]!)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowUp increases y on the focused anchor', async () => {
|
||||||
|
const { model } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 0.5]), step: 0.1 });
|
||||||
|
await nextTick();
|
||||||
|
const slider = document.querySelectorAll<HTMLElement>('[role="slider"]')[1]!;
|
||||||
|
keydown(slider, 'ArrowUp');
|
||||||
|
await nextTick();
|
||||||
|
expect(round(model.value![1]!.y)).toBe(0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowRight increases x; endpoints are x-locked when fixedEndpoints', async () => {
|
||||||
|
const { model } = mountEditor({
|
||||||
|
defaultValue: anchorsFromIds([0, 0], [0.5, 0.5], [1, 1]),
|
||||||
|
step: 0.1,
|
||||||
|
fixedEndpoints: true,
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
const sliders = document.querySelectorAll<HTMLElement>('[role="slider"]');
|
||||||
|
// Interior anchor moves in x.
|
||||||
|
keydown(sliders[1]!, 'ArrowRight');
|
||||||
|
await nextTick();
|
||||||
|
expect(round(model.value![1]!.x)).toBe(0.6);
|
||||||
|
// Endpoint x is locked.
|
||||||
|
keydown(sliders[2]!, 'ArrowRight');
|
||||||
|
await nextTick();
|
||||||
|
expect(round(model.value![2]!.x)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('monotonicX prevents an anchor from crossing its neighbour', async () => {
|
||||||
|
const { model } = mountEditor({
|
||||||
|
defaultValue: anchorsFromIds([0, 0], [0.5, 0.5], [1, 1]),
|
||||||
|
step: 0.1,
|
||||||
|
monotonicX: true,
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
const mid = document.querySelectorAll<HTMLElement>('[role="slider"]')[1]!;
|
||||||
|
// Hammer ArrowRight far past the right neighbour at x=1.
|
||||||
|
for (let i = 0; i < 20; i++) keydown(mid, 'ArrowRight');
|
||||||
|
await nextTick();
|
||||||
|
expect(model.value![1]!.x).toBeLessThan(1);
|
||||||
|
// Order is preserved.
|
||||||
|
expect(model.value![0]!.x).toBeLessThan(model.value![1]!.x);
|
||||||
|
expect(model.value![1]!.x).toBeLessThan(model.value![2]!.x);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shift+Arrow uses the large step', async () => {
|
||||||
|
const { model } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 0.5]), step: 0.01, largeStep: 0.1 });
|
||||||
|
await nextTick();
|
||||||
|
const slider = document.querySelectorAll<HTMLElement>('[role="slider"]')[1]!;
|
||||||
|
keydown(slider, 'ArrowUp', { shiftKey: true });
|
||||||
|
await nextTick();
|
||||||
|
expect(round(model.value![1]!.y)).toBe(0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Enter adds an anchor at the midpoint, Delete removes an interior anchor', async () => {
|
||||||
|
const { model } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 1]) });
|
||||||
|
await nextTick();
|
||||||
|
const first = document.querySelectorAll<HTMLElement>('[role="slider"]')[0]!;
|
||||||
|
keydown(first, 'Enter');
|
||||||
|
await nextTick();
|
||||||
|
expect(model.value!.length).toBe(3);
|
||||||
|
expect(round(model.value![1]!.x)).toBe(0.5);
|
||||||
|
// Remove the new interior anchor.
|
||||||
|
const interior = document.querySelectorAll<HTMLElement>('[role="slider"]')[1]!;
|
||||||
|
keydown(interior, 'Delete');
|
||||||
|
await nextTick();
|
||||||
|
expect(model.value!.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Delete does not remove an endpoint', async () => {
|
||||||
|
mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 1]) });
|
||||||
|
await nextTick();
|
||||||
|
const first = document.querySelectorAll<HTMLElement>('[role="slider"]')[0]!;
|
||||||
|
keydown(first, 'Delete');
|
||||||
|
await nextTick();
|
||||||
|
// The endpoint is never removed: both anchors still render.
|
||||||
|
expect(document.querySelectorAll('[role="slider"]').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roving focus: only the active anchor is tabbable', async () => {
|
||||||
|
mountEditor({ defaultValue: anchorsFromIds([0, 0], [0.5, 0.5], [1, 1]) });
|
||||||
|
await nextTick();
|
||||||
|
const sliders = document.querySelectorAll<HTMLElement>('[role="slider"]');
|
||||||
|
// Active index defaults to 0.
|
||||||
|
expect(sliders[0]!.tabIndex).toBe(0);
|
||||||
|
expect(sliders[1]!.tabIndex).toBe(-1);
|
||||||
|
expect(sliders[2]!.tabIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled: tabindex=-1 and keys do nothing', async () => {
|
||||||
|
const { model } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 0.5]), disabled: true });
|
||||||
|
await nextTick();
|
||||||
|
const slider = document.querySelectorAll<HTMLElement>('[role="slider"]')[1]!;
|
||||||
|
expect(slider.tabIndex).toBe(-1);
|
||||||
|
keydown(slider, 'ArrowUp');
|
||||||
|
await nextTick();
|
||||||
|
// No mutation occurred (model never written).
|
||||||
|
expect(model.value).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the curve as an aria-hidden path with a non-empty d', async () => {
|
||||||
|
mountEditor({ defaultValue: anchorsFromIds([0, 0], [1, 1]) });
|
||||||
|
await nextTick();
|
||||||
|
const path = document.querySelector<SVGPathElement>('[data-curve-editor-curve]')!;
|
||||||
|
expect(path).toBeTruthy();
|
||||||
|
expect(path.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interpolation modes each produce a curve through the anchors', async () => {
|
||||||
|
for (const interpolation of ['linear', 'monotone', 'catmull-rom', 'bezier'] as const) {
|
||||||
|
const { wrapper } = mountEditor({ defaultValue: anchorsFromIds([0, 0], [0.5, 0.4], [1, 1]), interpolation });
|
||||||
|
await nextTick();
|
||||||
|
const sample = (wrapper.findComponent(CurveEditorRoot).vm as any).sample as (x: number) => number;
|
||||||
|
expect(round(sample(0), 2)).toBe(0);
|
||||||
|
expect(round(sample(0.5), 2)).toBe(0.4);
|
||||||
|
expect(round(sample(1), 2)).toBe(1);
|
||||||
|
wrapper.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
* 60–120×/sec. pointerup flushes the pending point synchronously first so the
|
||||||
|
* committed connection reflects the final cursor position.
|
||||||
|
*/
|
||||||
|
export function useConnection(ctx: FlowContext): void {
|
||||||
|
let rafId: number | null = null;
|
||||||
|
let pending: XYPosition | null = null;
|
||||||
|
|
||||||
|
function flush(): void {
|
||||||
|
rafId = null;
|
||||||
|
if (pending) {
|
||||||
|
ctx.updateConnection(pending);
|
||||||
|
pending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(event: PointerEvent): void {
|
||||||
|
if (!ctx.connection.value.inProgress) return;
|
||||||
|
pending = ctx.screenToFlow({ x: event.clientX, y: event.clientY });
|
||||||
|
if (rafId === null) rafId = requestAnimationFrame(flush);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp(): void {
|
||||||
|
if (!ctx.connection.value.inProgress) return;
|
||||||
|
// Apply the last pointer position before committing.
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
ctx.endConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listeners stay bound to the window for the component's life and are
|
||||||
|
// auto-removed on scope dispose. SSR-safe: the window default no-ops without a
|
||||||
|
// `window`. The handlers no-op unless a connection is active.
|
||||||
|
useEventListener('pointermove', onMove);
|
||||||
|
useEventListener('pointerup', onUp);
|
||||||
|
|
||||||
|
// `useEventListener` owns the listeners; we still cancel any pending RAF here.
|
||||||
|
onScopeDispose(() => {
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||