// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useState } from 'react';
import { render } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import type { Slide } from '@openmaic/dsl';
import { EditableSlideCanvas } from '../../src/editing/EditableSlideCanvas';
import type { EditIntent, Selection } from '../../src/editing/types';
import { useViewportSize } from '../../src/hooks/useViewportSize';
// Mock the viewport-fit hook (jsdom reports a zero-size container) so the
// overlay uses a known scale/offset, exactly like the other canvas tests.
vi.mock('../../src/hooks/useViewportSize', () => ({
useViewportSize: vi.fn(),
}));
const textEl = {
id: 'a',
type: 'text',
left: 100,
top: 100,
width: 200,
height: 80,
rotate: 0,
content: 'x',
defaultFontName: 'f',
defaultColor: '#000',
lineHeight: 1,
};
const imageEl = {
id: 'b',
type: 'image',
left: 400,
top: 100,
width: 320,
height: 180,
rotate: 0,
fixedRatio: false,
src: 'x.png',
};
function makeSlide(elements: unknown[] = [textEl, imageEl]): Slide {
return {
id: 's',
viewportSize: 1000,
viewportRatio: 0.5625,
elements,
} as unknown as Slide;
}
const lockedEl = {
id: 'locked1',
type: 'text',
left: 600,
top: 300,
width: 100,
height: 50,
rotate: 0,
content: 'z',
defaultFontName: 'f',
defaultColor: '#000',
lineHeight: 1,
lock: true,
};
const lineEl = {
id: 'line1',
type: 'line',
left: 400,
top: 300,
start: [0, 0],
end: [80, 40],
width: 2,
style: 'solid',
color: '#333',
points: ['', ''],
};
const groupEl = (id: string, left: number) => ({
id,
type: 'text',
left,
top: 100,
width: 100,
height: 60,
rotate: 0,
content: 'g',
defaultFontName: 'f',
defaultColor: '#000',
lineHeight: 1,
groupId: 'G',
});
const hit = (c: HTMLElement, id: string) =>
c.querySelector(`[data-element-id="${id}"]`) as HTMLElement;
const surface = (c: HTMLElement) => c.querySelector('[data-marquee-surface]') as HTMLElement;
const lineBlocker = (c: HTMLElement) => c.querySelector('[data-hit-kind="line"]') as Element;
describe('EditableSlideCanvas — marquee', () => {
beforeEach(() => {
vi.mocked(useViewportSize).mockReturnValue({
viewportStyles: { left: 0, top: 0, width: 1000, height: 562 },
fitScale: 1,
});
});
it('renders a blank-canvas capture surface with touch suppression when the mount is editable', () => {
const { container } = render(
,
);
const s = surface(container);
expect(s).not.toBeNull();
expect(s.style.touchAction).toBe('none');
});
it('a select-only mount renders a capture surface without disabling native touch pan', () => {
// Select-only hosts need blank clear and mouse/pen marquee, but should keep
// native touch scrolling because they have no mutation channel for a touch
// driven editing gesture.
const { container } = render(
,
);
const s = surface(container);
expect(s).not.toBeNull();
expect(s.style.touchAction).toBe('');
// Element hit targets are still present for tap-select.
expect(hit(container, 'a')).not.toBeNull();
});
it('a select-only mount can marquee-select and blank-clear through the capture surface', () => {
const onSel = vi.fn();
const { container, rerender } = render(
,
);
let s = surface(container);
fireEvent.pointerDown(s, { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(s, { pointerId: 1, clientX: 350, clientY: 250 });
fireEvent.pointerUp(s, { pointerId: 1, clientX: 350, clientY: 250 });
expect(onSel).toHaveBeenCalledTimes(1);
expect(onSel).toHaveBeenLastCalledWith({ elementIds: ['a'], primaryId: 'a' });
rerender(
,
);
s = surface(container);
fireEvent.pointerDown(s, { pointerId: 2, clientX: 10, clientY: 10 });
fireEvent.pointerUp(s, { pointerId: 2, clientX: 11, clientY: 11 });
expect(onSel).toHaveBeenCalledTimes(2);
expect(onSel).toHaveBeenLastCalledWith({ elementIds: [] });
});
it('a marquee past threshold REPLACES the selection with what it contains', () => {
const onSel = vi.fn();
const { container } = render(
,
);
const s = surface(container);
// (0,0)→(350,250) wholly contains 'a' ([100,300]×[100,180]); 'b' is outside.
fireEvent.pointerDown(s, { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(s, { pointerId: 1, clientX: 350, clientY: 250 });
// The live marquee box is drawn mid-drag.
expect(container.querySelector('[data-marquee-box]')).not.toBeNull();
fireEvent.pointerUp(s, { pointerId: 1, clientX: 350, clientY: 250 });
expect(onSel).toHaveBeenCalledTimes(1);
expect(onSel).toHaveBeenCalledWith({ elementIds: ['a'], primaryId: 'a' });
// Box removed after release.
expect(container.querySelector('[data-marquee-box]')).toBeNull();
});
it('a sub-threshold blank click clears the selection', () => {
const onSel = vi.fn();
const { container } = render(
,
);
const s = surface(container);
fireEvent.pointerDown(s, { pointerId: 1, clientX: 10, clientY: 10 });
fireEvent.pointerUp(s, { pointerId: 1, clientX: 11, clientY: 11 });
expect(onSel).toHaveBeenCalledTimes(1);
expect(onSel).toHaveBeenCalledWith({ elementIds: [] });
});
});
describe('EditableSlideCanvas — click modifiers', () => {
beforeEach(() => {
vi.mocked(useViewportSize).mockReturnValue({
viewportStyles: { left: 0, top: 0, width: 1000, height: 562 },
fitScale: 1,
});
});
it('a plain click on an unselected element selects only it', () => {
const onSel = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'b'), { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerUp(hit(container, 'b'), { pointerId: 1, clientX: 0, clientY: 0 });
expect(onSel).toHaveBeenCalledWith({ elementIds: ['b'], primaryId: 'b' });
});
it('a Ctrl-click on an unselected element ADDS it to the selection (uniq)', () => {
const onSel = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'b'), {
pointerId: 1,
clientX: 0,
clientY: 0,
ctrlKey: true,
});
fireEvent.pointerUp(hit(container, 'b'), {
pointerId: 1,
clientX: 0,
clientY: 0,
ctrlKey: true,
});
expect(onSel).toHaveBeenCalledWith({ elementIds: ['a', 'b'], primaryId: 'b' });
});
it('a Shift-click on a selected element REMOVES it', () => {
const onSel = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'b'), {
pointerId: 1,
clientX: 0,
clientY: 0,
shiftKey: true,
});
fireEvent.pointerUp(hit(container, 'b'), {
pointerId: 1,
clientX: 0,
clientY: 0,
shiftKey: true,
});
expect(onSel).toHaveBeenCalledWith({ elementIds: ['a'], primaryId: 'a' });
});
it('a Ctrl-click that would empty the selection is a guarded no-op', () => {
const onSel = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'a'), {
pointerId: 1,
clientX: 0,
clientY: 0,
ctrlKey: true,
});
fireEvent.pointerUp(hit(container, 'a'), {
pointerId: 1,
clientX: 0,
clientY: 0,
ctrlKey: true,
});
// Removing the last element would empty the selection → no emit.
expect(onSel).not.toHaveBeenCalled();
});
});
describe('EditableSlideCanvas — multi-drag', () => {
beforeEach(() => {
vi.mocked(useViewportSize).mockReturnValue({
viewportStyles: { left: 0, top: 0, width: 1000, height: 562 },
fitScale: 1,
});
});
it('dragging a selected element in a multi-selection moves ALL and emits ONE updateMany', () => {
const onSel = vi.fn();
const onCh = vi.fn();
const { container } = render(
,
);
// 'a' is already the primary; a plain drag keeps the whole selection.
fireEvent.pointerDown(hit(container, 'a'), { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit(container, 'a'), { pointerId: 1, clientX: 30, clientY: 20 });
fireEvent.pointerUp(hit(container, 'a'), { pointerId: 1, clientX: 30, clientY: 20 });
// Exactly one intent — a single element.updateMany = one host undo entry.
expect(onCh).toHaveBeenCalledTimes(1);
expect(onCh.mock.calls[0][0]).toEqual([
{
type: 'element.updateMany',
updates: [
{ id: 'a', props: { left: 130, top: 120 } },
{ id: 'b', props: { left: 430, top: 120 } },
],
},
]);
// No re-selection: 'a' was already primary.
expect(onSel).not.toHaveBeenCalled();
});
it('a multi-selection shows no operate handles (single-element gestures only)', () => {
const { container } = render(
,
);
expect(container.querySelector('[data-resize-handle]')).toBeNull();
expect(container.querySelector('[data-rotate-handle]')).toBeNull();
});
it('a single-element drag still emits element.update (backward compat)', () => {
const onCh = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'a'), { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit(container, 'a'), { pointerId: 1, clientX: 30, clientY: 20 });
fireEvent.pointerUp(hit(container, 'a'), { pointerId: 1, clientX: 30, clientY: 20 });
expect(onCh).toHaveBeenCalledTimes(1);
expect(onCh.mock.calls[0][0]).toEqual([
{ type: 'element.update', id: 'a', props: { left: 130, top: 120 } },
]);
});
it('a locked element in the selection never moves: the drag set excludes it (single mover → element.update)', () => {
const onCh = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'a'), { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit(container, 'a'), { pointerId: 1, clientX: 30, clientY: 20 });
// Working copy: the locked element's blocker stays put while 'a' follows.
expect(hit(container, 'a').style.left).toBe('130px');
const blocker = container.querySelector('[data-hit-kind="blocker"]') as HTMLElement;
expect(blocker.style.left).toBe('600px');
expect(blocker.style.top).toBe('300px');
fireEvent.pointerUp(hit(container, 'a'), { pointerId: 1, clientX: 30, clientY: 20 });
// Only ONE mover remains → single element.update, and NO entry for locked1.
expect(onCh).toHaveBeenCalledTimes(1);
expect(onCh.mock.calls[0][0]).toEqual([
{ type: 'element.update', id: 'a', props: { left: 130, top: 120 } },
]);
});
it('a locked element in a 3-element selection is absent from the updateMany', () => {
const onCh = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'a'), { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit(container, 'a'), { pointerId: 1, clientX: 30, clientY: 20 });
fireEvent.pointerUp(hit(container, 'a'), { pointerId: 1, clientX: 30, clientY: 20 });
expect(onCh).toHaveBeenCalledTimes(1);
expect(onCh.mock.calls[0][0]).toEqual([
{
type: 'element.updateMany',
updates: [
{ id: 'a', props: { left: 130, top: 120 } },
{ id: 'b', props: { left: 430, top: 120 } },
],
},
]);
});
it('multi-drag pointercancel reverts the working copy without emitting', () => {
const onSel = vi.fn();
const onCh = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'a'), { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit(container, 'a'), { pointerId: 1, clientX: 30, clientY: 20 });
// Live: BOTH selected elements followed the pointer.
expect(hit(container, 'a').style.left).toBe('130px');
expect(hit(container, 'b').style.left).toBe('430px');
fireEvent.pointerCancel(hit(container, 'a'), { pointerId: 1, clientX: 30, clientY: 20 });
// Reverted, and no intent/selection emitted.
expect(hit(container, 'a').style.left).toBe('100px');
expect(hit(container, 'b').style.left).toBe('400px');
expect(onCh).not.toHaveBeenCalled();
expect(onSel).not.toHaveBeenCalled();
});
it('after a modifier click (no drag armed), a plain drag with a NEW pointerId still works', () => {
// A pure selection click never claims the active pointer, so a subsequent
// gesture from a different pointerId must arm normally.
const onCh = vi.fn();
const { container } = render(
,
);
// Modifier click on 'b': selection action only, pointer 1 never claimed.
fireEvent.pointerDown(hit(container, 'b'), {
pointerId: 1,
clientX: 0,
clientY: 0,
ctrlKey: true,
});
fireEvent.pointerUp(hit(container, 'b'), {
pointerId: 1,
clientX: 0,
clientY: 0,
ctrlKey: true,
});
// Plain drag on 'a' with a NEW pointerId commits a move.
fireEvent.pointerDown(hit(container, 'a'), { pointerId: 2, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit(container, 'a'), { pointerId: 2, clientX: 30, clientY: 20 });
fireEvent.pointerUp(hit(container, 'a'), { pointerId: 2, clientX: 30, clientY: 20 });
expect(onCh).toHaveBeenCalledTimes(1);
expect(onCh.mock.calls[0][0]).toEqual([
{ type: 'element.update', id: 'a', props: { left: 130, top: 120 } },
]);
});
});
describe('EditableSlideCanvas — shift axis lock (mid-drag)', () => {
beforeEach(() => {
vi.mocked(useViewportSize).mockReturnValue({
viewportStyles: { left: 0, top: 0, width: 1000, height: 562 },
fitScale: 1,
});
});
it('holding shift during a SINGLE drag locks to the dominant axis', () => {
const onCh = vi.fn();
const { container } = render(
,
);
// Plain pointer-down (shift here would be a selection modifier, no drag);
// shift is engaged on the MOVE. |dx|=30 > |dy|=10 → x is dominant.
fireEvent.pointerDown(hit(container, 'a'), { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit(container, 'a'), {
pointerId: 1,
clientX: 30,
clientY: 10,
shiftKey: true,
});
// Live working copy already reflects the lock: y stays put.
expect(hit(container, 'a').style.left).toBe('130px');
expect(hit(container, 'a').style.top).toBe('100px');
fireEvent.pointerUp(hit(container, 'a'), {
pointerId: 1,
clientX: 30,
clientY: 10,
shiftKey: true,
});
expect(onCh).toHaveBeenCalledTimes(1);
expect(onCh.mock.calls[0][0]).toEqual([
{ type: 'element.update', id: 'a', props: { left: 130, top: 100 } },
]);
});
it('holding shift during a MULTI drag locks the whole set to the dominant axis', () => {
const onCh = vi.fn();
const { container } = render(
,
);
// |dy|=25 > |dx|=10 → y is dominant; x deltas are dropped for BOTH.
fireEvent.pointerDown(hit(container, 'a'), { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit(container, 'a'), {
pointerId: 1,
clientX: 10,
clientY: 25,
shiftKey: true,
});
fireEvent.pointerUp(hit(container, 'a'), {
pointerId: 1,
clientX: 10,
clientY: 25,
shiftKey: true,
});
expect(onCh).toHaveBeenCalledTimes(1);
expect(onCh.mock.calls[0][0]).toEqual([
{
type: 'element.updateMany',
updates: [
{ id: 'a', props: { left: 100, top: 125 } },
{ id: 'b', props: { left: 400, top: 125 } },
],
},
]);
});
});
describe('EditableSlideCanvas — line selection modifiers', () => {
beforeEach(() => {
vi.mocked(useViewportSize).mockReturnValue({
viewportStyles: { left: 0, top: 0, width: 1000, height: 562 },
fitScale: 1,
});
});
it('a Ctrl-click on a line ADDS it to the selection', () => {
const onSel = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(lineBlocker(container), { clientX: 0, clientY: 0, ctrlKey: true });
expect(onSel).toHaveBeenCalledTimes(1);
expect(onSel).toHaveBeenCalledWith({ elementIds: ['a', 'line1'], primaryId: 'line1' });
});
it('a Ctrl-click on a selected line REMOVES it without destroying the selection', () => {
const onSel = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(lineBlocker(container), { clientX: 0, clientY: 0, ctrlKey: true });
expect(onSel).toHaveBeenCalledTimes(1);
// The rest of the selection survives, and the surviving primary is kept.
expect(onSel).toHaveBeenCalledWith({ elementIds: ['a'], primaryId: 'a' });
});
});
describe('EditableSlideCanvas — group cohesion on click', () => {
beforeEach(() => {
vi.mocked(useViewportSize).mockReturnValue({
viewportStyles: { left: 0, top: 0, width: 1000, height: 562 },
fitScale: 1,
});
});
const g1 = groupEl('g1', 100);
const g2 = groupEl('g2', 400);
it('a plain click on a grouped element selects ALL group members (primary = clicked)', () => {
const onSel = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'g2'), { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerUp(hit(container, 'g2'), { pointerId: 1, clientX: 0, clientY: 0 });
expect(onSel).toHaveBeenCalledWith({ elementIds: ['g1', 'g2'], primaryId: 'g2' });
});
it('a modifier-add on a grouped element adds the WHOLE group (uniq)', () => {
const onSel = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'g1'), {
pointerId: 1,
clientX: 0,
clientY: 0,
ctrlKey: true,
});
expect(onSel).toHaveBeenCalledWith({ elementIds: ['a', 'g1', 'g2'], primaryId: 'g1' });
});
it('a modifier-remove on a grouped element removes the WHOLE group', () => {
const onSel = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'g2'), {
pointerId: 1,
clientX: 0,
clientY: 0,
ctrlKey: true,
});
// Both members removed; the surviving primary 'a' is preserved.
expect(onSel).toHaveBeenCalledWith({ elementIds: ['a'], primaryId: 'a' });
});
it('dragging a group member moves the WHOLE group (one updateMany)', () => {
const onSel = vi.fn();
const onCh = vi.fn();
const { container } = render(
,
);
fireEvent.pointerDown(hit(container, 'g1'), { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit(container, 'g1'), { pointerId: 1, clientX: 30, clientY: 20 });
fireEvent.pointerUp(hit(container, 'g1'), { pointerId: 1, clientX: 30, clientY: 20 });
// Selection landed on the whole group at pointer-down…
expect(onSel).toHaveBeenCalledWith({ elementIds: ['g1', 'g2'], primaryId: 'g1' });
// …and the drag translated every member as ONE updateMany.
expect(onCh).toHaveBeenCalledTimes(1);
expect(onCh.mock.calls[0][0]).toEqual([
{
type: 'element.updateMany',
updates: [
{ id: 'g1', props: { left: 130, top: 120 } },
{ id: 'g2', props: { left: 430, top: 120 } },
],
},
]);
});
});
describe('EditableSlideCanvas — marquee + multi-drag integration', () => {
beforeEach(() => {
vi.mocked(useViewportSize).mockReturnValue({
viewportStyles: { left: 0, top: 0, width: 1000, height: 562 },
fitScale: 1,
});
});
/** Controlled host: routes onSelectionChange back into the selection prop. */
function ControlledCanvas({
slide,
onElementsChange,
}: {
slide: Slide;
onElementsChange: (intents: EditIntent[]) => void;
}) {
const [sel, setSel] = useState({ elementIds: [] });
return (
);
}
it('marquee-selects a line + a box, then dragging the box carries the line in the updateMany', () => {
const onCh = vi.fn();
const { container } = render(
,
);
// Marquee (50,50)→(700,500) contains both the box ([100,300]×[100,180])
// and the line's bounds ([400,480]×[300,340]).
const s = surface(container);
fireEvent.pointerDown(s, { pointerId: 1, clientX: 50, clientY: 50 });
fireEvent.pointerMove(s, { pointerId: 1, clientX: 700, clientY: 500 });
fireEvent.pointerUp(s, { pointerId: 1, clientX: 700, clientY: 500 });
// Now drag the box: the whole marquee selection (box + line) translates.
fireEvent.pointerDown(hit(container, 'a'), { pointerId: 2, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit(container, 'a'), { pointerId: 2, clientX: 30, clientY: 20 });
fireEvent.pointerUp(hit(container, 'a'), { pointerId: 2, clientX: 30, clientY: 20 });
expect(onCh).toHaveBeenCalledTimes(1);
expect(onCh.mock.calls[0][0]).toEqual([
{
type: 'element.updateMany',
updates: [
{ id: 'a', props: { left: 130, top: 120 } },
{ id: 'line1', props: { left: 430, top: 320 } },
],
},
]);
});
});