Files
openmaic/OpenMAIC/packages/@openmaic/editor/test/react/EditableSlideCanvas.multiselect.test.tsx
2026-08-16 14:58:47 +08:00

871 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useState } from 'react';
import { cleanup, render } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import type { Slide } from '@openmaic/dsl';
import { EditableSlideCanvas } from '../../src/react/EditableSlideCanvas';
import type { EditIntent, Selection } from '../../src/react/types';
import { useViewportSize } from '@openmaic/renderer';
// 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('@openmaic/renderer', async (importOriginal) => ({
...(await importOriginal<typeof import('@openmaic/renderer')>()),
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}"]`) ??
c.querySelector(`[data-select-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;
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: [] }}
onSelectionChange={vi.fn()}
onElementsChange={vi.fn()}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: [] }}
onSelectionChange={vi.fn()}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: [] }}
onSelectionChange={onSel}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={onSel}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: [] }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
/>,
);
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: [] });
});
it('does not render, hit-test, outline, or marquee-select hidden elements', () => {
const onSel = vi.fn();
const { container } = render(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
hiddenElementIds={['a']}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
/>,
);
expect(container.querySelector('#slide-element-a')).toBeNull();
expect(hit(container, 'a')).toBeNull();
expect(container.querySelector('[data-selection-border]')).toBeNull();
const 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).toHaveBeenLastCalledWith({ 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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
/>,
);
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('clicking a visible group member selects the complete source group, including hidden members', () => {
const onSel = vi.fn();
const { container } = render(
<EditableSlideCanvas
slide={makeSlide([groupEl('g1', 100), groupEl('g2', 300)])}
scale={1}
hiddenElementIds={['g2']}
selection={{ elementIds: [] }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
/>,
);
expect(hit(container, 'g2')).toBeNull();
fireEvent.pointerDown(hit(container, 'g1'), { pointerId: 1, clientX: 100, clientY: 100 });
fireEvent.pointerUp(hit(container, 'g1'), { pointerId: 1, clientX: 100, clientY: 100 });
expect(onSel).toHaveBeenCalledWith({ elementIds: ['g1', 'g2'], primaryId: 'g1' });
});
it('a Ctrl-click on an unselected element ADDS it to the selection (uniq)', () => {
const onSel = vi.fn();
const { container } = render(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a', 'b'], primaryId: 'b' }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a', 'b'], primaryId: 'a' }}
onSelectionChange={onSel}
onElementsChange={onCh}
snapping={false}
/>,
);
// '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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a', 'b'], primaryId: 'a' }}
onSelectionChange={vi.fn()}
onElementsChange={vi.fn()}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={vi.fn()}
onElementsChange={onCh}
snapping={false}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide([textEl, lockedEl])}
scale={1}
selection={{ elementIds: ['locked1', 'a'], primaryId: 'a' }}
onSelectionChange={vi.fn()}
onElementsChange={onCh}
snapping={false}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide([textEl, imageEl, lockedEl])}
scale={1}
selection={{ elementIds: ['a', 'b', 'locked1'], primaryId: 'a' }}
onSelectionChange={vi.fn()}
onElementsChange={onCh}
snapping={false}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a', 'b'], primaryId: 'a' }}
onSelectionChange={onSel}
onElementsChange={onCh}
snapping={false}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={vi.fn()}
onElementsChange={onCh}
snapping={false}
/>,
);
// 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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={vi.fn()}
onElementsChange={onCh}
snapping={false}
/>,
);
// 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(
<EditableSlideCanvas
slide={makeSlide()}
scale={1}
selection={{ elementIds: ['a', 'b'], primaryId: 'a' }}
onSelectionChange={vi.fn()}
onElementsChange={onCh}
snapping={false}
/>,
);
// |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(
<EditableSlideCanvas
slide={makeSlide([textEl, lineEl])}
scale={1}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
snapping={false}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide([textEl, lineEl])}
scale={1}
selection={{ elementIds: ['a', 'line1'], primaryId: 'a' }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
snapping={false}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide([g1, g2, textEl])}
scale={1}
selection={{ elementIds: [] }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
snapping={false}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide([g1, g2, textEl])}
scale={1}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
snapping={false}
/>,
);
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(
<EditableSlideCanvas
slide={makeSlide([g1, g2, textEl])}
scale={1}
selection={{ elementIds: ['a', 'g1', 'g2'], primaryId: 'a' }}
onSelectionChange={onSel}
onElementsChange={vi.fn()}
snapping={false}
/>,
);
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, rerender } = render(
<EditableSlideCanvas
slide={makeSlide([g1, g2, textEl])}
scale={1}
selection={{ elementIds: [] }}
onSelectionChange={onSel}
onElementsChange={onCh}
snapping={false}
/>,
);
fireEvent.pointerDown(hit(container, 'g1'), { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerUp(hit(container, 'g1'), { pointerId: 1, clientX: 0, clientY: 0 });
// The first body click selects the whole group.
expect(onSel).toHaveBeenCalledWith({ elementIds: ['g1', 'g2'], primaryId: 'g1' });
rerender(
<EditableSlideCanvas
slide={makeSlide([g1, g2, textEl])}
scale={1}
selection={{ elementIds: ['g1', 'g2'], primaryId: 'g1' }}
onSelectionChange={onSel}
onElementsChange={onCh}
snapping={false}
/>,
);
// A following drag from the selected border moves the whole group.
fireEvent.pointerDown(hit(container, 'g1'), { pointerId: 2, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit(container, 'g1'), { pointerId: 2, clientX: 30, clientY: 20 });
fireEvent.pointerUp(hit(container, 'g1'), { pointerId: 2, clientX: 30, clientY: 20 });
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<Selection>({ elementIds: [] });
return (
<EditableSlideCanvas
slide={slide}
scale={1}
selection={sel}
onSelectionChange={setSel}
onElementsChange={onElementsChange}
snapping={false}
/>
);
}
it('marquee-selects a line + a box, then dragging the box carries the line in the updateMany', () => {
const onCh = vi.fn();
const { container } = render(
<ControlledCanvas slide={makeSlide([textEl, lineEl])} onElementsChange={onCh} />,
);
// 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 } },
],
},
]);
});
});