}
+
+ );
+}
diff --git a/client/src/components/Studio/StudioCanvas.groups.test.tsx b/client/src/components/Studio/StudioCanvas.groups.test.tsx
new file mode 100644
index 000000000..3d6f8b9b7
--- /dev/null
+++ b/client/src/components/Studio/StudioCanvas.groups.test.tsx
@@ -0,0 +1,281 @@
+import type { BookDocument, BookElement } from '@trek/shared';
+import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { fireEvent, render } from '../../../tests/helpers/render';
+import { useStudioStore } from '../../store/studioStore';
+import { StudioCanvas } from './StudioCanvas';
+
+/**
+ * The sheet with groups and multi-selections (journey-studio WP6): a click
+ * picks a group, a double-click steps into it, a marquee picks what it
+ * touches, one box resizes a multi-selection, and the keyboard nudges,
+ * groups, duplicates and steps back out.
+ */
+
+const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
+const PX = 96 / 25.4;
+
+/** The value, or a failed test when it is missing. */
+function must(value: T | null | undefined): T {
+ if (value === null || value === undefined) throw new Error('expected a value');
+ return value;
+}
+
+const shape = (id: string, x: number, y: number, over: Partial = {}): BookElement =>
+ bookShapeElementSchema.parse({
+ id,
+ kind: 'shape',
+ frame: { x, y, w: 20, h: 20 },
+ shape: 'rect',
+ ...over,
+ }) as BookElement;
+
+function load(elements: BookElement[]) {
+ useStudioStore.getState().load({
+ version: 1,
+ title: 'T',
+ page,
+ spreads: [{ id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null }],
+ } as BookDocument);
+}
+
+function draw() {
+ const spread = must(useStudioStore.getState().doc).spreads[0] ?? null;
+ const view = render(
+
+ );
+ const stage = view.container.querySelector('.st-stage') as HTMLElement;
+ const sheet = view.container.querySelector('.st-sheet') as HTMLElement;
+ // The hit targets are the last children of the sheet, one per element shown, in paint order.
+ const targets = () => Array.from(sheet.children).slice(-elements().filter((e) => !e.hidden).length) as HTMLElement[];
+ return { ...view, stage, sheet, targets };
+}
+
+const store = () => useStudioStore.getState();
+const elements = () => must(must(store().doc).spreads[0]).elements;
+const el = (id: string) => must(elements().find((e) => e.id === id));
+
+const grouped = () => [shape('a', 10, 10), shape('g1', 60, 10, { group: 'g' }), shape('g2', 90, 40, { group: 'g' })];
+
+beforeEach(() => {
+ load([]);
+ useStudioStore.setState({ enteredGroup: null, styleClip: null });
+});
+
+describe('groups on the sheet', () => {
+ it('a click on a member picks the whole group, drawn as one dashed box with handles', () => {
+ load(grouped());
+ const { container, stage, targets } = draw();
+ fireEvent.pointerDown(must(targets()[1]));
+ fireEvent.pointerUp(stage);
+ expect(store().selection).toEqual(['g1', 'g2']);
+ const box = container.querySelector('.st-select-box') as HTMLElement;
+ expect(box.className).toContain('is-group');
+ expect(box.querySelectorAll('.st-handle')).toHaveLength(8);
+ expect(container.querySelectorAll('.st-select.is-member')).toHaveLength(2);
+ });
+
+ it('a double-click steps into the group and picks the member alone', () => {
+ load(grouped());
+ const { container, stage, targets } = draw();
+ fireEvent.pointerDown(must(targets()[2]));
+ fireEvent.pointerUp(stage);
+ fireEvent.doubleClick(must(targets()[2]));
+ expect(store().selection).toEqual(['g2']);
+ expect(store().enteredGroup).toBe('g');
+ expect(container.querySelector('.st-select.is-inside')).not.toBeNull();
+ });
+
+ it('a second double-click on a text inside its group edits it', () => {
+ const words = bookTextElementSchema.parse({
+ id: 't',
+ kind: 'text',
+ frame: { x: 10, y: 10, w: 60, h: 20 },
+ text: 'Hi',
+ group: 'g',
+ }) as BookElement;
+ load([words, shape('s', 100, 100, { group: 'g' })]);
+ const { container, targets } = draw();
+ fireEvent.doubleClick(must(targets()[0]));
+ expect(container.querySelector('.st-inline-edit')).toBeNull();
+ fireEvent.doubleClick(must(targets()[0]));
+ expect(container.querySelector('.st-inline-edit')).not.toBeNull();
+ });
+
+ it('draws no target for a hidden element', () => {
+ load([shape('a', 10, 10), shape('h', 50, 50, { hidden: true })]);
+ const { sheet } = draw();
+ const hits = Array.from(sheet.children).filter((c) => (c as HTMLElement).style.cursor === 'move');
+ expect(hits).toHaveLength(1);
+ });
+});
+
+describe('pressing on a selection', () => {
+ it('drags all of a multi-selection, and a click without a drag narrows it to the one pressed', () => {
+ load([shape('a', 10, 10), shape('b', 60, 10)]);
+ store().select(['a', 'b']);
+ const { stage, targets } = draw();
+ fireEvent.pointerDown(must(targets()[0]), { clientX: 0, clientY: 0 });
+ fireEvent.pointerMove(stage, { clientX: 10 * PX, clientY: 0 });
+ fireEvent.pointerUp(stage);
+ expect(store().selection).toEqual(['a', 'b']);
+ expect(el('a').frame.x).toBeCloseTo(20);
+ expect(el('b').frame.x).toBeCloseTo(70);
+
+ fireEvent.pointerDown(must(targets()[1]), { clientX: 0, clientY: 0 });
+ fireEvent.pointerUp(stage);
+ expect(store().selection).toEqual(['b']);
+ });
+});
+
+describe('the marquee', () => {
+ it('picks what it touches, with its group, and is drawn while it is dragged', () => {
+ load(grouped());
+ const { container, stage, sheet } = draw();
+ fireEvent.pointerDown(sheet, { clientX: 0, clientY: 0 });
+ fireEvent.pointerMove(stage, { clientX: 70 * PX, clientY: 25 * PX });
+ expect(container.querySelector('.st-marquee')).not.toBeNull();
+ expect(store().selection).toEqual(['a', 'g1', 'g2']);
+ fireEvent.pointerUp(stage);
+ expect(container.querySelector('.st-marquee')).toBeNull();
+ expect(store().selection).toEqual(['a', 'g1', 'g2']);
+ });
+
+ it('adds to the selection with shift, and a click on bare paper clears it', () => {
+ load([shape('a', 10, 10), shape('b', 100, 100)]);
+ const { stage, sheet } = draw();
+ store().select(['b']);
+ fireEvent.pointerDown(sheet, { clientX: 0, clientY: 0, shiftKey: true });
+ fireEvent.pointerMove(stage, { clientX: 40 * PX, clientY: 40 * PX, shiftKey: true });
+ fireEvent.pointerUp(stage);
+ expect(store().selection).toEqual(['b', 'a']);
+ fireEvent.pointerDown(sheet, { clientX: 5, clientY: 5 });
+ fireEvent.pointerUp(stage);
+ expect(store().selection).toEqual([]);
+ });
+
+ it('starts from a locked element dragged, and leaves the locked one out', () => {
+ load([shape('bg', 0, 0, { locked: true, frame: { x: 0, y: 0, w: 200, h: 200 } }), shape('a', 10, 10)]);
+ const { stage, targets } = draw();
+ fireEvent.pointerDown(must(targets()[0]), { clientX: 1, clientY: 1 });
+ fireEvent.pointerMove(stage, { clientX: 40 * PX, clientY: 40 * PX });
+ fireEvent.pointerUp(stage);
+ expect(store().selection).toEqual(['a']);
+ expect(el('bg').frame).toMatchObject({ x: 0, y: 0 });
+ });
+});
+
+describe('resizing a multi-selection', () => {
+ const grab = () => {
+ load([shape('a', 10, 10), shape('b', 50, 30)]);
+ store().select(['a', 'b']);
+ const view = draw();
+ const handles = Array.from(view.container.querySelectorAll('.st-select-box .st-handle')) as HTMLElement[];
+ // The handles run nw, n, ne, e, se, s, sw, w.
+ return { ...view, se: must(handles[4]) };
+ };
+
+ it('scales every frame inside one box', () => {
+ const { stage, se } = grab();
+ fireEvent.pointerDown(se, { clientX: 0, clientY: 0 });
+ fireEvent.pointerMove(stage, { clientX: 60 * PX, clientY: 0 });
+ fireEvent.pointerUp(stage);
+ // The box 10..70 grows to 10..130 across, so everything doubles that way.
+ expect(el('a').frame.w).toBeCloseTo(40);
+ expect(el('b').frame.x).toBeCloseTo(90);
+ expect(el('b').frame.h).toBeCloseTo(20);
+ store().undo();
+ expect(el('a').frame).toMatchObject({ w: 20, h: 20 });
+ });
+
+ it('keeps the proportions with shift, so every element keeps its own', () => {
+ const { stage, se } = grab();
+ fireEvent.pointerDown(se, { clientX: 0, clientY: 0 });
+ fireEvent.pointerMove(stage, { clientX: 120 * PX, clientY: 0, shiftKey: true });
+ fireEvent.pointerUp(stage);
+ // 60 by 40 to 180 by 120: three times either way.
+ expect(el('a').frame.w).toBeCloseTo(60);
+ expect(el('a').frame.h).toBeCloseTo(60);
+ expect(el('b').frame.y).toBeCloseTo(70);
+ });
+
+ it('leaves a locked member where it is', () => {
+ load([shape('a', 10, 10), shape('l', 50, 30, { locked: true })]);
+ store().select(['a', 'l']);
+ const { container, stage } = draw();
+ const se = Array.from(container.querySelectorAll('.st-select-box .st-handle'))[4] as HTMLElement;
+ fireEvent.pointerDown(se, { clientX: 0, clientY: 0 });
+ fireEvent.pointerMove(stage, { clientX: 20 * PX, clientY: 20 * PX });
+ fireEvent.pointerUp(stage);
+ expect(el('l').frame).toMatchObject({ x: 50, y: 30, w: 20, h: 20 });
+ expect(el('a').frame.w).toBeCloseTo(40);
+ });
+});
+
+describe('the keyboard', () => {
+ it('nudges by a millimetre and ten with shift, one undo step while held', () => {
+ load([shape('a', 10, 10)]);
+ store().select(['a']);
+ draw();
+ fireEvent.keyDown(document, { key: 'ArrowRight' });
+ fireEvent.keyDown(document, { key: 'ArrowRight', repeat: true });
+ fireEvent.keyDown(document, { key: 'ArrowDown', shiftKey: true });
+ fireEvent.keyUp(document, { key: 'ArrowDown' });
+ expect(el('a').frame).toMatchObject({ x: 12, y: 20 });
+ store().undo();
+ expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
+ });
+
+ it('groups, ungroups, duplicates and selects everything', () => {
+ load([shape('a', 10, 10), shape('b', 40, 10)]);
+ draw();
+ fireEvent.keyDown(document, { key: 'a', ctrlKey: true });
+ expect(store().selection).toEqual(['a', 'b']);
+ fireEvent.keyDown(document, { key: 'g', ctrlKey: true });
+ expect(el('a').group).toBeDefined();
+ fireEvent.keyDown(document, { key: 'G', ctrlKey: true, shiftKey: true });
+ expect(el('a').group).toBeUndefined();
+ fireEvent.keyDown(document, { key: 'd', ctrlKey: true });
+ expect(elements()).toHaveLength(4);
+ });
+
+ it('copies and pastes style, and arranges with the brackets', () => {
+ load([shape('a', 10, 10, { fill: '#ff0000' }), shape('b', 40, 10)]);
+ draw();
+ store().select(['a']);
+ fireEvent.keyDown(document, { key: 'c', ctrlKey: true, altKey: true });
+ store().select(['b']);
+ fireEvent.keyDown(document, { key: 'v', ctrlKey: true, altKey: true });
+ expect(el('b')).toMatchObject({ fill: '#ff0000' });
+ fireEvent.keyDown(document, { key: '[', ctrlKey: true });
+ expect(elements().map((e) => e.id)).toEqual(['b', 'a']);
+ fireEvent.keyDown(document, { key: '}', ctrlKey: true, shiftKey: true });
+ expect(elements().map((e) => e.id)).toEqual(['a', 'b']);
+ });
+
+ it('steps out of a group with Escape, telling the page not to clear the selection', () => {
+ load(grouped());
+ draw();
+ store().enterGroup(0, 'g1');
+ const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
+ document.dispatchEvent(escape);
+ expect(escape.defaultPrevented).toBe(true);
+ expect(store().selection).toEqual(['g1', 'g2']);
+ const again = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
+ document.dispatchEvent(again);
+ expect(again.defaultPrevented).toBe(false);
+ });
+
+ it('leaves the keys to a field being typed in', () => {
+ load([shape('a', 10, 10)]);
+ store().select(['a']);
+ draw();
+ const input = document.createElement('input');
+ document.body.appendChild(input);
+ fireEvent.keyDown(input, { key: 'ArrowRight' });
+ fireEvent.keyDown(input, { key: 'a', ctrlKey: true });
+ expect(el('a').frame.x).toBe(10);
+ input.remove();
+ });
+});
diff --git a/client/src/components/Studio/StudioCanvas.tsx b/client/src/components/Studio/StudioCanvas.tsx
index ff56af76f..e38d2f724 100644
--- a/client/src/components/Studio/StudioCanvas.tsx
+++ b/client/src/components/Studio/StudioCanvas.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useState } from 'react'
import { ChevronsDown, ChevronsUp, Copy, Lock, RotateCcw, RotateCw, Trash2, Unlock } from 'lucide-react'
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
import { SpreadFold, SpreadView } from './SpreadView'
@@ -7,11 +7,14 @@ import type { PeerCursor } from './useBookPresence'
import { bookElementId as elementId } from '@trek/shared'
import { fontStack, nearestWeight } from './bookFonts'
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
+import { useStudioKeys } from './useStudioKeys'
+import { selectClass, selectionShape } from './studioGestures'
+import { MarqueeRect, ResizeHandles, SelectionBox } from './StudioSelectionChrome'
+import '../../styles/studio-layers.css'
import { useStudioStore } from '../../store/studioStore'
import { useTranslation } from '../../i18n'
import { STICKER_DRAG_TYPE, stickerDropped } from './stickerLibrary'
-const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
/**
* What a delete may actually take out of the selection.
@@ -38,17 +41,6 @@ const ROTATE_CORNERS = [
{ id: 'sw', left: '0%', top: '100%' },
] as const
-const HANDLE_POS: Record = {
- nw: { left: '0%', top: '0%', cursor: 'nwse-resize' },
- n: { left: '50%', top: '0%', cursor: 'ns-resize' },
- ne: { left: '100%', top: '0%', cursor: 'nesw-resize' },
- e: { left: '100%', top: '50%', cursor: 'ew-resize' },
- se: { left: '100%', top: '100%', cursor: 'nwse-resize' },
- s: { left: '50%', top: '100%', cursor: 'ns-resize' },
- sw: { left: '0%', top: '100%', cursor: 'nesw-resize' },
- w: { left: '0%', top: '50%', cursor: 'ew-resize' },
-}
-
/**
* The sheet plus everything you do to it.
*
@@ -98,10 +90,9 @@ export function StudioCanvas({
const select = useStudioStore(s => s.select)
const removeElements = useStudioStore(s => s.removeElements)
const duplicate = useStudioStore(s => s.duplicate)
- const copy = useStudioStore(s => s.copy)
- const paste = useStudioStore(s => s.paste)
- const hasClipboard = useStudioStore(s => !!s.clipboard)
const raise = useStudioStore(s => s.raise)
+ const enteredGroup = useStudioStore(s => s.enteredGroup)
+ const enterGroup = useStudioStore(s => s.enterGroup)
const commit = useStudioStore(s => s.commit)
const addElement = useStudioStore(s => s.addElement)
const [dropTarget, setDropTarget] = useState(null)
@@ -141,45 +132,25 @@ export function StudioCanvas({
const frameUnder = (x: number, y: number) => spread?.elements.find(el =>
el.kind === 'photo'
&& !el.locked
+ && !el.hidden
&& x >= el.frame.x && x <= el.frame.x + el.frame.w
&& y >= el.frame.y && y <= el.frame.y + el.frame.h,
) ?? null
const scaled = pxPerMm * zoom
- const { guides, dragging, startMove, startResize, startRotate, onPointerMove, finish } = useSpreadInteraction({
+ const { guides, dragging, marquee, startMove, startPaper, startResize, startRotate, onPointerMove, finish } = useSpreadInteraction({
spread, spreadIndex, page, pxPerMm: scaled,
})
- useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- const tag = (e.target as HTMLElement)?.tagName
- if (tag === 'INPUT' || tag === 'TEXTAREA') return
- if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) {
- e.preventDefault()
- const ids = deletable(spread, selection)
- if (ids.length) removeElements(spreadIndex, ids)
- }
- // Copy and paste across pages (#2316). Text selected on the page is the
- // browser's to copy, not the book's.
- if (!(e.metaKey || e.ctrlKey) || e.altKey) return
- const key = e.key.toLowerCase()
- if (key === 'c' && selection.length && !window.getSelection()?.toString()) {
- e.preventDefault()
- copy(spreadIndex, selection)
- } else if (key === 'v' && hasClipboard) {
- e.preventDefault()
- paste(spreadIndex)
- }
- }
- window.addEventListener('keydown', onKey)
- return () => window.removeEventListener('keydown', onKey)
- }, [selection, spread, spreadIndex, removeElements, copy, paste, hasClipboard])
+ useStudioKeys({ spreadIndex, editing: editing !== null })
if (!spread) return null
const single = spread.role !== 'inner'
const sheetW = single ? page.pageWidth : page.pageWidth * 2
- const sel = spread.elements.filter(e => selection.includes(e.id))
+ const sel = spread.elements.filter(e => selection.includes(e.id) && !e.hidden)
+ /** One box with handles around a multi-selection; a whole group reads as one thing. */
+ const { many, box, asGroup, anyMovable } = selectionShape(spread, sel)
return (
select([])}
+ // A press on bare paper draws a marquee; without a drag it clears the selection.
+ onPointerDown={startPaper}
>
0.34} showGuides dropLabel={dropLabel} />
@@ -294,11 +266,15 @@ export function StudioCanvas({
{/* Hit targets sit above the page so a photo's own never eats the
gesture, and so a locked element simply is not grabbable. */}
- {spread.elements.map(el => (
+ {spread.elements.filter(el => !el.hidden).map(el => (
{ if (editing !== el.id) startMove(e, el) }}
- onDoubleClick={() => { if (el.kind === 'text' && !el.locked) setEditing(el.id) }}
+ onDoubleClick={() => {
+ // Into the group first, the way design tools do it; a second one edits a text.
+ if (el.group && enteredGroup !== el.group) { enterGroup(spreadIndex, el.id); return }
+ if (el.kind === 'text' && !el.locked) setEditing(el.id)
+ }}
style={{
position: 'absolute',
left: `${el.frame.x}mm`,
@@ -392,7 +368,7 @@ export function StudioCanvas({
{sel.map(el => (
))}
+ {/*
+ A multi-selection as one box: one set of handles that resizes them
+ together (Shift keeps the proportions), the members outlined lightly
+ inside it. A whole group's box is dashed, so a group reads as a group
+ before anything is done to it.
+ */}
+ {box && (
+
+ )}
+
+ {marquee && }
+
{/*
The quick bar, the way Canva and Figma do it: the three or four things
you reach for constantly, right where you are already looking, instead
@@ -463,12 +444,12 @@ export function StudioCanvas({
- {sel.length === 1 && (
+ {(sel.length === 1 || asGroup) && (
<>
-