feat(studio): groups, a layers panel, marquee, nudge, multi-resize, align and paste style

Elements can be grouped on a page and picked, moved, resized, aligned and
restacked as one. A layers panel lists the page front to back with lock,
visibility, rename and drag to restack. The sheet gains marquee selection,
arrow-key nudge, one box that resizes a multi-selection with Shift holding
the proportions, and the design-tool shortcuts. The inspector lines a
selection up and spaces it out, and copies and pastes a style.

The assistant gets the same through the group, ungroup, name and hide
operations, and describe_book_page lists the groups. The format gains the
optional group, name and hidden fields and a spread's group names, so
BOOK_SCHEMA_REV goes to 2.
This commit is contained in:
Maurice
2026-10-23 19:31:14 +02:00
parent 8c0707f34c
commit d8213bfa42
102 changed files with 5374 additions and 259 deletions
+2 -1
View File
@@ -498,7 +498,8 @@ export function SpreadView({
<SpreadPaint paint={spread.backgroundPaint} width={w} height={page.pageHeight} bleed={page.bleed} idPrefix={spread.id} />
)}
{spread.elements.map(el => (
{/* A hidden layer stays in the layers panel and off the page, here and in print. */}
{spread.elements.filter(el => !el.hidden).map(el => (
<ElementView
key={el.id}
el={el}
@@ -0,0 +1,172 @@
import type { BookAlignEdge, BookAlignTo, BookSpread } from '@trek/shared';
import { distributableCount, isWholeGroup } from '@trek/shared';
import {
AlignCenterHorizontal,
AlignCenterVertical,
AlignEndHorizontal,
AlignEndVertical,
AlignHorizontalDistributeCenter,
AlignStartHorizontal,
AlignStartVertical,
AlignVerticalDistributeCenter,
Group,
Paintbrush,
Pipette,
Ungroup,
type LucideIcon,
} from 'lucide-react';
import { useState } from 'react';
import { useStudioStore } from '../../store/studioStore';
import { Tooltip } from '../shared/Tooltip';
import { Choice, Section } from './StudioControls';
import { useStudioShortcut } from './studioShortcut';
/**
* Align, distribute, group and the style clipboard, for whatever is selected.
*
* The align bar lines a multi-selection up within its own box by default, and
* a single element (or one whole group) against its page, since there is
* nothing else for it to line up with. Groups move as one box throughout
* (shared/src/book/book-arrange.ts). Every press is one undo step.
*/
const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
{ edge: 'left', icon: AlignStartVertical, labelKey: 'journey.studio.layers.alignLeft' },
{ edge: 'center', icon: AlignCenterVertical, labelKey: 'journey.studio.layers.alignCenter' },
{ edge: 'right', icon: AlignEndVertical, labelKey: 'journey.studio.layers.alignRight' },
{ edge: 'top', icon: AlignStartHorizontal, labelKey: 'journey.studio.layers.alignTop' },
{ edge: 'middle', icon: AlignCenterHorizontal, labelKey: 'journey.studio.layers.alignMiddle' },
{ edge: 'bottom', icon: AlignEndHorizontal, labelKey: 'journey.studio.layers.alignBottom' },
];
/** How many things move as one in a selection: a group counts once. */
function unitCount(spread: BookSpread, ids: string[]): number {
return distributableCount(
spread,
spread.elements.filter((e) => ids.includes(e.id))
);
}
function IconButton({
label,
icon: Icon,
onClick,
disabled,
shortcut,
}: {
label: string;
icon: LucideIcon;
onClick: () => void;
disabled?: boolean;
shortcut?: readonly string[];
}) {
return (
<Tooltip label={label} shortcut={shortcut}>
<button type="button" className="st-chip is-icon" onClick={onClick} disabled={disabled} aria-label={label}>
<Icon size={14} />
</button>
</Tooltip>
);
}
export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number; t: (k: string) => string }) {
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
const selection = useStudioStore((s) => s.selection);
const hasStyle = useStudioStore((s) => s.styleClip !== null);
const align = useStudioStore((s) => s.align);
const distribute = useStudioStore((s) => s.distribute);
const group = useStudioStore((s) => s.group);
const ungroup = useStudioStore((s) => s.ungroup);
const copyStyle = useStudioStore((s) => s.copyStyle);
const pasteStyle = useStudioStore((s) => s.pasteStyle);
const keys = useStudioShortcut();
const [chosenTo, setChosenTo] = useState<BookAlignTo | null>(null);
if (!spread || !selection.length) return null;
const sel = spread.elements.filter((e) => selection.includes(e.id));
const movable = sel.filter((e) => !e.locked).map((e) => e.id);
const units = unitCount(spread, movable);
// One thing has nothing to line up with but its page.
const to: BookAlignTo = units < 2 ? (chosenTo === 'safe' ? 'safe' : 'page') : (chosenTo ?? 'selection');
const grouped = sel.some((e) => e.group);
const canGroup = movable.length >= 2 && !isWholeGroup(spread, movable);
const asGroup = selection.length > 1 && isWholeGroup(spread, selection);
return (
// Open for a multi-selection, where lining up is what comes next; folded for one element.
<Section key={units > 1 ? 'many' : 'one'} label={t('journey.studio.layers.align')} defaultOpen={units > 1}>
<div className="st-row">
{ALIGN.map((a) => (
<IconButton
key={a.edge}
label={t(a.labelKey)}
icon={a.icon}
disabled={!movable.length}
onClick={() => align(spreadIndex, a.edge, to)}
/>
))}
<span className="st-row-sep" aria-hidden />
<IconButton
label={t('journey.studio.layers.distributeH')}
icon={AlignHorizontalDistributeCenter}
disabled={units < 3}
onClick={() => distribute(spreadIndex, 'horizontal')}
/>
<IconButton
label={t('journey.studio.layers.distributeV')}
icon={AlignVerticalDistributeCenter}
disabled={units < 3}
onClick={() => distribute(spreadIndex, 'vertical')}
/>
</div>
<div className="st-arrange-to">
<span className="st-line-label">{t('journey.studio.layers.alignTo')}</span>
<Choice<BookAlignTo>
value={to}
ariaLabel={t('journey.studio.layers.alignTo')}
options={[
{ value: 'selection' as const, label: t('journey.studio.layers.toSelection'), disabled: units < 2 },
{ value: 'page' as const, label: t('journey.studio.layers.toPage') },
{ value: 'safe' as const, label: t('journey.studio.layers.toSafe') },
]}
onPick={setChosenTo}
/>
</div>
<div className="st-row st-arrange-acts">
{(canGroup || !grouped) && (
<IconButton
label={t('journey.studio.layers.groupAction')}
icon={Group}
disabled={!canGroup}
shortcut={keys('G')}
onClick={() => group(spreadIndex)}
/>
)}
{grouped && (
<IconButton
label={t('journey.studio.layers.ungroup')}
icon={Ungroup}
shortcut={keys('⇧', 'G')}
onClick={() => ungroup(spreadIndex)}
/>
)}
<span className="st-row-sep" aria-hidden />
<IconButton
label={t('journey.studio.layers.copyStyle')}
icon={Pipette}
shortcut={keys('alt', 'C')}
onClick={() => copyStyle(spreadIndex)}
/>
<IconButton
label={t('journey.studio.layers.pasteStyle')}
icon={Paintbrush}
disabled={!hasStyle || !movable.length}
shortcut={keys('alt', 'V')}
onClick={() => pasteStyle(spreadIndex)}
/>
</div>
{asGroup && <p className="st-hint st-arrange-hint">{t('journey.studio.layers.groupHint')}</p>}
</Section>
);
}
@@ -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<T>(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> = {}): 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(
<StudioCanvas spread={spread} spreadIndex={0} page={page} zoom={1} pxPerMm={PX} bookView={false} dropLabel="" />
);
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();
});
});
+38 -57
View File
@@ -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<HandleId, { left: string; top: string; cursor: string }> = {
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<string | null>(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 (
<div
@@ -284,7 +255,8 @@ export function StudioCanvas({
height: `${page.pageHeight}mm`,
transform: `scale(${zoom})`,
}}
onPointerDown={() => select([])}
// A press on bare paper draws a marquee; without a drag it clears the selection.
onPointerDown={startPaper}
>
<SpreadView spread={spread} page={page} folios={folios} big={zoom > 0.34} showGuides dropLabel={dropLabel} />
@@ -294,11 +266,15 @@ export function StudioCanvas({
{/* Hit targets sit above the page so a photo's own <img> 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 => (
<div
key={el.id}
onPointerDown={e => { 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 => (
<div
key={el.id}
className="st-select"
className={selectClass(many, el, enteredGroup)}
style={{
left: el.frame.x * scaled,
top: el.frame.y * scaled,
@@ -432,17 +408,22 @@ export function StudioCanvas({
</div>
))}
{sel.length === 1 && !dragging && !el.locked && HANDLES.map(h => (
<span
key={h}
className={`st-handle ${h.length === 2 ? 'is-corner' : h === 'n' || h === 's' ? 'is-h' : 'is-v'}`}
style={{ left: HANDLE_POS[h].left, top: HANDLE_POS[h].top, cursor: HANDLE_POS[h].cursor }}
onPointerDown={e => startResize(e, h)}
/>
))}
{sel.length === 1 && !dragging && !el.locked && <ResizeHandles onResize={startResize} />}
</div>
))}
{/*
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 && (
<SelectionBox box={box} scaled={scaled} asGroup={asGroup} handles={!dragging && anyMovable} onResize={startResize} />
)}
{marquee && <MarqueeRect rect={marquee} scaled={scaled} />}
{/*
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({
<button type="button" onClick={() => duplicate(spreadIndex, selection)} title={t('journey.studio.duplicate')}>
<Copy size={14} />
</button>
{sel.length === 1 && (
{(sel.length === 1 || asGroup) && (
<>
<button type="button" onClick={() => raise(spreadIndex, sel[0].id, 'front')} title={t('journey.studio.toFront')}>
<button type="button" onClick={() => raise(spreadIndex, selection, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button type="button" onClick={() => raise(spreadIndex, sel[0].id, 'back')} title={t('journey.studio.toBack')}>
<button type="button" onClick={() => raise(spreadIndex, selection, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</>
@@ -3,7 +3,7 @@ import { Minus, Quote, Search, Square, X } from 'lucide-react'
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, SHAPE_PATHS } from './shapes'
import { BOOK_GRIDS as GRIDS, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared'
import { BOOK_GRIDS as GRIDS, bookElementId, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared'
import { PanelHead } from './StudioPanelHead'
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
@@ -16,7 +16,8 @@ import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLib
* hold them separately stops being faster to hit than a word would be.
*/
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
// The book's own ids, the ones the layouts and the assistant mint (shared/src/book/book-ids.ts).
const uid = bookElementId
/** How many icons the grid draws at a time. Two screens' worth at four across. */
const ICON_PAGE = 120
@@ -21,6 +21,8 @@ import { WeightLine } from './StudioTypeControls'
import { italicTitle } from './fontPicker'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
import { StudioArrangeSection } from './StudioArrangeSection'
import { selectionTitle } from './studioLayerRows'
/** How many icons the swap grid offers at once. A search narrows it below this. */
const ICON_CHOICES = 60
@@ -240,7 +242,7 @@ export function StudioInspector({
*/}
<div className="st-panel-head st-inspector-head">
<span className="st-what">
{one ? t(`journey.studio.kind.${el.kind}`) : t('journey.studio.multiple')}
{one ? t(`journey.studio.kind.${el.kind}`) : selectionTitle(spread, selection, t)}
{sel.length > 1 && <em>{sel.length}</em>}
</span>
<span className="st-head-acts">
@@ -659,18 +661,20 @@ export function StudioInspector({
</Section>
)}
<StudioArrangeSection spreadIndex={spreadIndex} t={t} />
<Section label={t('journey.studio.arrange')} defaultOpen={false}>
<div className="st-row">
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'front')} title={t('journey.studio.toFront')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'up')} title={t('journey.studio.forward')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'up')} title={t('journey.studio.forward')}>
<ArrowUp size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'down')} title={t('journey.studio.backward')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'down')} title={t('journey.studio.backward')}>
<ArrowDown size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'back')} title={t('journey.studio.toBack')}>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</div>
@@ -0,0 +1,202 @@
import type { BookDocument, BookElement } from '@trek/shared';
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { StudioArrangeSection } from './StudioArrangeSection';
import { selectionTitle } from './studioLayerRows';
import { StudioLayersPanel } from './StudioLayersPanel';
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
const t = (k: string) => k;
/** The value, or a failed test when it is missing. */
function must<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error('expected a value');
return value;
}
const shape = (id: string, x: number, over: Partial<BookElement> = {}): BookElement =>
bookShapeElementSchema.parse({
id,
kind: 'shape',
frame: { x, y: 10, w: 20, h: 20 },
shape: 'rect',
...over,
}) as BookElement;
function load(elements: BookElement[], groups?: { id: string; name?: string }[]) {
useStudioStore.getState().load({
version: 1,
title: 'T',
page,
spreads: [{ id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null, groups }],
} as BookDocument);
useStudioStore.setState({ enteredGroup: null, styleClip: null, readOnly: false });
}
const store = () => useStudioStore.getState();
const el = (id: string) => must(must(must(store().doc).spreads[0]).elements.find((e) => e.id === id));
const order = () => must(must(store().doc).spreads[0]).elements.map((e) => e.id);
const book = () => {
const title = bookTextElementSchema.parse({
id: 't',
kind: 'text',
frame: { x: 0, y: 0, w: 50, h: 10 },
text: 'Reykjavik',
}) as BookElement;
load(
[shape('a', 0), shape('g1', 40, { group: 'g' }), shape('g2', 80, { group: 'g' }), title],
[{ id: 'g', name: 'Pair' }]
);
};
const rows = (container: HTMLElement) => Array.from(container.querySelectorAll<HTMLElement>('[data-layer-key]'));
beforeEach(book);
describe('the layers panel', () => {
it('lists the page front to back, a group with its members, a text by its words', () => {
const { container } = render(<StudioLayersPanel t={t} />);
expect(rows(container).map((r) => r.dataset.layerKey)).toEqual(['t', 'g:g', 'g2', 'g1', 'a']);
expect(screen.getByText('Reykjavik')).toBeInTheDocument();
expect(screen.getByText('Pair')).toBeInTheDocument();
});
it('selects a group from its row, a member alone inside it, and an element with shift added', () => {
const { container } = render(<StudioLayersPanel t={t} />);
fireEvent.click(must(rows(container)[1]));
expect(store().selection).toEqual(['g1', 'g2']);
fireEvent.click(must(rows(container)[2]));
expect([store().selection, store().enteredGroup]).toEqual([['g2'], 'g']);
fireEvent.click(must(rows(container)[4]), { shiftKey: true });
expect(store().selection).toEqual(['g2', 'a']);
});
it('hides, shows and locks a layer from its row, and a group from its own', () => {
const { container } = render(<StudioLayersPanel t={t} />);
const aRow = must(rows(container)[4]);
fireEvent.click(aRow.querySelector('[aria-label="journey.studio.layers.hide"]') as Element);
expect(el('a').hidden).toBe(true);
expect(must(rows(container)[4]).className).toContain('is-hidden');
fireEvent.click(must(rows(container)[4]).querySelector('[aria-label="journey.studio.layers.show"]') as Element);
expect(el('a').hidden).toBeUndefined();
fireEvent.click(must(rows(container)[1]).querySelector('[aria-label="journey.studio.lock"]') as Element);
expect([el('g1').locked, el('g2').locked]).toEqual([true, true]);
// Toggling a row does not select it.
expect(store().selection).toEqual([]);
});
it('renames on a double-click, Enter keeps it and Escape leaves the name as it was', () => {
const { container } = render(<StudioLayersPanel t={t} />);
fireEvent.doubleClick(screen.getByText('Pair'));
const input = container.querySelector('.st-layer-input') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'Sun and sea' } });
fireEvent.blur(input);
expect(must(must(store().doc).spreads[0]).groups).toEqual([{ id: 'g', name: 'Sun and sea' }]);
fireEvent.doubleClick(screen.getByText('Reykjavik'));
const again = container.querySelector('.st-layer-input') as HTMLInputElement;
fireEvent.keyDown(again, { key: 'Escape' });
expect(container.querySelector('.st-layer-input')).toBeNull();
expect(el('t').name).toBeUndefined();
});
it('folds a group away and opens it again', () => {
const { container } = render(<StudioLayersPanel t={t} />);
fireEvent.click(container.querySelector('[aria-label="journey.studio.layers.collapse"]') as Element);
expect(rows(container).map((r) => r.dataset.layerKey)).toEqual(['t', 'g:g', 'a']);
fireEvent.click(container.querySelector('[aria-label="journey.studio.layers.expand"]') as Element);
expect(rows(container)).toHaveLength(5);
});
it('restacks a row dragged over another, as one undo step', () => {
const { container } = render(<StudioLayersPanel t={t} />);
const list = container.querySelector('.st-layers') as HTMLElement;
// Rows 20 pixels tall from the top, front to back.
rows(container).forEach((row, i) => {
row.getBoundingClientRect = () =>
({
top: i * 20,
bottom: i * 20 + 20,
height: 20,
left: 0,
right: 100,
width: 100,
x: 0,
y: i * 20,
toJSON: () => ({}),
}) as DOMRect;
});
fireEvent.pointerDown(must(rows(container)[4]), { clientY: 90, button: 0 });
fireEvent.pointerMove(list, { clientY: 60 });
fireEvent.pointerMove(list, { clientY: 3 });
expect(must(rows(container)[0]).className).toContain('is-drop-above');
fireEvent.pointerUp(list, { clientY: 3 });
expect(order()).toEqual(['g1', 'g2', 't', 'a']);
store().undo();
expect(order()).toEqual(['a', 'g1', 'g2', 't']);
});
it('groups and ungroups the selection from its foot, and says when the page is empty', () => {
render(<StudioLayersPanel t={t} />);
store().select(['a', 't']);
fireEvent.click(screen.getByText('journey.studio.layers.groupAction'));
expect(el('a').group).toBeDefined();
fireEvent.click(screen.getByText('journey.studio.layers.ungroup'));
expect(el('a').group).toBeUndefined();
load([]);
render(<StudioLayersPanel t={t} />);
expect(screen.getAllByText('journey.studio.layers.empty').length).toBeGreaterThan(0);
});
it('offers no drag, lock or visibility to a book held read-only', () => {
useStudioStore.setState({ readOnly: true });
const { container } = render(<StudioLayersPanel t={t} />);
expect(container.querySelector('.st-layer-acts')).toBeNull();
expect(container.querySelector('.st-layers-foot')).toBeNull();
});
});
describe('the align section', () => {
it('aligns a multi-selection within itself and distributes three', () => {
store().select(['a', 'g1', 'g2', 't']);
render(<StudioArrangeSection spreadIndex={0} t={t} />);
fireEvent.click(screen.getByLabelText('journey.studio.layers.alignTop'));
expect([el('a').frame.y, el('t').frame.y]).toEqual([0, 0]);
const steps = store().past.length;
fireEvent.click(screen.getByLabelText('journey.studio.layers.distributeH'));
expect(store().past.length).toBe(steps + 1);
});
it('aligns a single element against its page, and copies and pastes style', () => {
store().select(['a']);
render(<StudioArrangeSection spreadIndex={0} t={t} />);
// Folded for one element: lining up is not what one element usually needs next.
fireEvent.click(screen.getByText('journey.studio.layers.align'));
expect(screen.getByRole('radio', { name: 'journey.studio.layers.toSelection' })).toBeDisabled();
fireEvent.click(screen.getByLabelText('journey.studio.layers.alignRight'));
expect(el('a').frame.x).toBe(190);
expect(screen.getByLabelText('journey.studio.layers.pasteStyle')).toBeDisabled();
fireEvent.click(screen.getByLabelText('journey.studio.layers.copyStyle'));
expect(store().styleClip?.kind).toBe('shape');
});
it('offers group for a loose selection and ungroup for a group, named in the inspector head', () => {
store().select(['g1', 'g2']);
const spread = must(store().doc).spreads[0];
expect(selectionTitle(spread, ['g1', 'g2'], t)).toBe('Pair');
expect(selectionTitle(spread, ['a', 'g1'], t)).toBe('journey.studio.multiple');
render(<StudioArrangeSection spreadIndex={0} t={t} />);
expect(screen.getByText('journey.studio.layers.groupHint')).toBeInTheDocument();
fireEvent.click(screen.getByLabelText('journey.studio.layers.ungroup'));
expect(el('g1').group).toBeUndefined();
});
it('draws nothing without a selection', () => {
const { container } = render(<StudioArrangeSection spreadIndex={0} t={t} />);
expect(container.innerHTML).toBe('');
});
});
@@ -0,0 +1,432 @@
import type { BookElement } from '@trek/shared';
import { isWholeGroup } from '@trek/shared';
import {
Award,
ChartColumn,
ChevronRight,
Eye,
EyeOff,
Globe,
GripVertical,
Group,
ImageIcon,
List,
Lock,
MapIcon,
Shapes,
Sparkles,
Square,
Type,
Ungroup,
Unlock,
type LucideIcon,
} from 'lucide-react';
import { useRef, useState } from 'react';
import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-layers.css';
import { Tooltip } from '../shared/Tooltip';
import { PanelHead } from './StudioPanelHead';
import { dropOn, layerLabel, layerRows, rowIds, type LayerDrop, type LayerRow } from './studioLayerRows';
import { useStudioShortcut } from './studioShortcut';
/**
* The layers of the open page, front to back (journey-studio WP6).
*
* One row per element and one per group, its members indented under it. A row
* selects what it stands for; a member's row selects the member alone, inside
* its group, the way a double-click on the sheet does. Rows drag to restack:
* the upper half of a row puts what is dragged in front of it, the lower half
* behind it, and a member's row takes it into that group. Lock and visibility
* sit at the end of every row, always shown when they are on so a locked or
* hidden layer can be told at a glance; a hidden layer is dimmed, not removed,
* since this panel is the way back to it. Every change is one undo step.
*/
const KIND_ICONS: Record<string, LucideIcon> = {
photo: ImageIcon,
text: Type,
shape: Square,
icon: Sparkles,
map: MapIcon,
stats: ChartColumn,
countries: Globe,
badge: Award,
list: List,
};
/** How far, in pixels, a press on a row travels before it is a drag rather than a click. */
const DRAG_PX = 4;
interface Drag {
ids: string[];
startY: number;
active: boolean;
drop: LayerDrop | null;
/** The row the pointer is over and which half, for the insertion line. */
overKey: string | null;
upper: boolean;
}
type T = (k: string, params?: Record<string, string | number>) => string;
export function StudioLayersPanel({ t }: { t: T }) {
const spreadIndex = useStudioStore((s) => s.activeSpread);
const spread = useStudioStore((s) => s.doc?.spreads[s.activeSpread]);
const selection = useStudioStore((s) => s.selection);
const enteredGroup = useStudioStore((s) => s.enteredGroup);
const readOnly = useStudioStore((s) => s.readOnly);
const pick = useStudioStore((s) => s.pick);
const selectGroup = useStudioStore((s) => s.selectGroup);
const enterGroup = useStudioStore((s) => s.enterGroup);
const restack = useStudioStore((s) => s.restack);
const setHidden = useStudioStore((s) => s.setHidden);
const setLocked = useStudioStore((s) => s.setLocked);
const renameLayer = useStudioStore((s) => s.renameLayer);
const group = useStudioStore((s) => s.group);
const ungroup = useStudioStore((s) => s.ungroup);
const keys = useStudioShortcut();
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const [renaming, setRenaming] = useState<string | null>(null);
const [drag, setDrag] = useState<Drag | null>(null);
const list = useRef<HTMLDivElement>(null);
/** A drag ends in a click on the row it started from; that click is not a choice. */
const justDragged = useRef(false);
if (!spread) return null;
const rows = layerRows(spread, collapsed);
const selected = new Set(selection);
const movable = spread.elements.filter((e) => selected.has(e.id) && !e.locked);
const canGroup =
movable.length >= 2 &&
!isWholeGroup(
spread,
movable.map((e) => e.id)
);
const canUngroup = spread.elements.some((e) => selected.has(e.id) && e.group);
const toggleCollapsed = (groupId: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
return next;
});
const choose = (row: LayerRow, additive: boolean) => {
if (justDragged.current) {
justDragged.current = false;
return;
}
if (row.type === 'group') selectGroup(spreadIndex, row.groupId, additive);
else if (row.inGroup && !additive) enterGroup(spreadIndex, row.el.id);
else pick(spreadIndex, row.el.id, additive);
};
/** What a drag from this row carries: the selection when the row is in it, else what the row stands for. */
const dragIds = (row: LayerRow) => {
const own = rowIds(row);
return own.every((id) => selected.has(id))
? spread.elements.filter((e) => selected.has(e.id)).map((e) => e.id)
: own;
};
const rowAt = (clientY: number): { row: LayerRow; upper: boolean } | null => {
const nodes = list.current?.querySelectorAll<HTMLElement>('[data-layer-key]') ?? [];
for (const node of nodes) {
const r = node.getBoundingClientRect();
if (clientY < r.top || clientY > r.bottom) continue;
const row = rows.find((x) => x.key === node.dataset.layerKey);
if (row) return { row, upper: clientY < r.top + r.height / 2 };
}
return null;
};
const onPointerDown = (e: React.PointerEvent, row: LayerRow) => {
if (e.button !== 0 || readOnly) return;
if ((e.target as HTMLElement).closest('button, input')) return;
setDrag({ ids: dragIds(row), startY: e.clientY, active: false, drop: null, overKey: null, upper: true });
};
const onPointerMove = (e: React.PointerEvent) => {
if (!drag) return;
if (!drag.active && Math.abs(e.clientY - drag.startY) < DRAG_PX) return;
if (!drag.active) {
try {
list.current?.setPointerCapture?.(e.pointerId);
} catch {
// A pointer that is gone already cannot be captured; the drag still follows the list.
}
}
const at = rowAt(e.clientY);
const over = at && !rowIds(at.row).some((id) => drag.ids.includes(id)) ? at : null;
setDrag({
...drag,
active: true,
drop: over ? dropOn(over.row, over.upper) : null,
overKey: over?.row.key ?? null,
upper: over?.upper ?? true,
});
};
const onPointerUp = (e: React.PointerEvent) => {
if (!drag) return;
try {
list.current?.releasePointerCapture?.(e.pointerId);
} catch {
// Released already, or never taken: a click that did not move.
}
justDragged.current = drag.active;
if (drag.active && drag.drop) restack(spreadIndex, drag.ids, drag.drop.anchorId, drag.drop.where, drag.drop.join);
setDrag(null);
};
const commitName = (row: LayerRow, value: string) => {
setRenaming(null);
const current = row.type === 'group' ? (row.name ?? '') : (row.el.name ?? '');
if (value.trim() === current) return;
renameLayer(spreadIndex, row.type === 'group' ? { groupId: row.groupId } : { elementId: row.el.id }, value);
};
return (
<>
<PanelHead label={t('journey.studio.layers')} count={spread.elements.length} />
<div className="st-panel-scroll st-layers-scroll">
{rows.length === 0 ? (
<p className="st-hint">{t('journey.studio.layers.empty')}</p>
) : (
<>
<p className="st-hint st-layers-hint">{t('journey.studio.layers.hint')}</p>
<div
ref={list}
className={`st-layers ${drag?.active ? 'is-dragging' : ''}`}
role="tree"
aria-label={t('journey.studio.layers')}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={() => setDrag(null)}
>
{rows.map((row) => (
<LayerRowView
key={row.key}
row={row}
t={t}
selected={rowIds(row).every((id) => selected.has(id))}
inside={row.type === 'element' && row.inGroup && row.el.group === enteredGroup}
collapsed={row.type === 'group' && collapsed.has(row.groupId)}
renaming={renaming === row.key}
dragged={!!drag?.active && rowIds(row).every((id) => drag.ids.includes(id))}
dropAt={drag?.active && drag.overKey === row.key ? (drag.upper ? 'above' : 'below') : null}
readOnly={readOnly}
onPointerDown={(e) => onPointerDown(e, row)}
onChoose={(additive) => choose(row, additive)}
onToggleCollapsed={() => row.type === 'group' && toggleCollapsed(row.groupId)}
onRename={() => !readOnly && setRenaming(row.key)}
onCommitName={(v) => commitName(row, v)}
onCancelName={() => setRenaming(null)}
onLock={(locked) => setLocked(spreadIndex, rowIds(row), locked)}
onHide={(hidden) => setHidden(spreadIndex, rowIds(row), hidden)}
/>
))}
</div>
</>
)}
</div>
{!readOnly && rows.length > 0 && (
<div className="st-layers-foot">
<Tooltip label={t('journey.studio.layers.groupAction')} shortcut={keys('G')} placement="top">
<button type="button" className="st-chip" disabled={!canGroup} onClick={() => group(spreadIndex)}>
<Group size={14} />
{t('journey.studio.layers.groupAction')}
</button>
</Tooltip>
<Tooltip label={t('journey.studio.layers.ungroup')} shortcut={keys('⇧', 'G')} placement="top">
<button type="button" className="st-chip" disabled={!canUngroup} onClick={() => ungroup(spreadIndex)}>
<Ungroup size={14} />
{t('journey.studio.layers.ungroup')}
</button>
</Tooltip>
</div>
)}
</>
);
}
interface RowProps {
row: LayerRow;
t: T;
selected: boolean;
inside: boolean;
collapsed: boolean;
renaming: boolean;
dragged: boolean;
dropAt: 'above' | 'below' | null;
readOnly: boolean;
onPointerDown: (e: React.PointerEvent) => void;
onChoose: (additive: boolean) => void;
onToggleCollapsed: () => void;
onRename: () => void;
onCommitName: (value: string) => void;
onCancelName: () => void;
onLock: (locked: boolean) => void;
onHide: (hidden: boolean) => void;
}
function rowState(row: LayerRow): { locked: boolean; hidden: boolean } {
const els: BookElement[] = row.type === 'group' ? row.members : [row.el];
return { locked: els.every((e) => e.locked), hidden: els.every((e) => e.hidden) };
}
/** The row's classes: what it is, whether it is picked, hidden, dragged, or where a drop would land. */
function rowClasses(p: RowProps, hidden: boolean): string {
const { row } = p;
return [
'st-layer',
row.type === 'group' ? 'is-group' : '',
row.type === 'element' && row.inGroup ? 'is-member' : '',
p.selected ? 'is-on' : '',
p.inside ? 'is-inside' : '',
hidden ? 'is-hidden' : '',
p.dragged ? 'is-dragged' : '',
p.dropAt ? `is-drop-${p.dropAt}` : '',
]
.filter(Boolean)
.join(' ');
}
/** What the row says it is, and whether that is a name somebody gave it. */
function rowLabel(row: LayerRow, t: T): { label: string; named: boolean } {
if (row.type === 'group') return { label: row.name || t('journey.studio.layers.group'), named: !!row.name };
return { label: layerLabel(row.el, t(`journey.studio.kind.${row.el.kind}`)), named: !!row.el.name };
}
function LayerRowView(p: RowProps) {
const { row, t } = p;
const { locked, hidden } = rowState(p.row);
const Icon = row.type === 'group' ? Group : (KIND_ICONS[row.el.kind] ?? Shapes);
const { label, named } = rowLabel(row, t);
return (
<div
className={rowClasses(p, hidden)}
data-layer-key={row.key}
role="treeitem"
aria-selected={p.selected}
aria-expanded={row.type === 'group' ? !p.collapsed : undefined}
onPointerDown={p.onPointerDown}
onClick={(e) => p.onChoose(e.shiftKey || e.metaKey || e.ctrlKey)}
>
{!p.readOnly && <GripVertical size={12} className="st-layer-grip" aria-hidden />}
{row.type === 'group' && <RowCaret collapsed={p.collapsed} onToggle={p.onToggleCollapsed} t={t} />}
<Icon size={13} className="st-layer-icon" aria-hidden />
<RowName
label={label}
named={named}
renaming={p.renaming}
t={t}
onRename={p.onRename}
onCommit={p.onCommitName}
onCancel={p.onCancelName}
/>
{row.type === 'group' && <span className="st-layer-count">{row.members.length}</span>}
{!p.readOnly && <RowToggles locked={locked} hidden={hidden} t={t} onLock={p.onLock} onHide={p.onHide} />}
</div>
);
}
/** A click that does something on its own and must not also select the row. */
const own = (fn: () => void) => (e: React.MouseEvent) => {
e.stopPropagation();
fn();
};
function RowCaret({ collapsed, onToggle, t }: { collapsed: boolean; onToggle: () => void; t: T }) {
return (
<button
type="button"
className={`st-layer-caret ${collapsed ? '' : 'is-open'}`}
onClick={own(onToggle)}
aria-label={t(collapsed ? 'journey.studio.layers.expand' : 'journey.studio.layers.collapse')}
>
<ChevronRight size={12} />
</button>
);
}
function RowName(p: {
label: string;
named: boolean;
renaming: boolean;
t: T;
onRename: () => void;
onCommit: (value: string) => void;
onCancel: () => void;
}) {
if (!p.renaming) {
return (
<span
className={`st-layer-name ${p.named ? '' : 'is-auto'}`}
title={p.t('journey.studio.layers.rename')}
onDoubleClick={own(p.onRename)}
>
{p.label}
</span>
);
}
return (
<input
className="st-input st-layer-input"
autoFocus
defaultValue={p.named ? p.label : ''}
placeholder={p.label}
maxLength={60}
aria-label={p.t('journey.studio.layers.rename')}
onClick={(e) => e.stopPropagation()}
onBlur={(e) => p.onCommit(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key !== 'Escape') return;
// Escape here leaves the name as it was, and must not close Studio.
e.stopPropagation();
e.preventDefault();
p.onCancel();
}}
/>
);
}
function RowToggles(p: {
locked: boolean;
hidden: boolean;
t: T;
onLock: (locked: boolean) => void;
onHide: (hidden: boolean) => void;
}) {
const lockLabel = p.t(p.locked ? 'journey.studio.unlock' : 'journey.studio.lock');
const hideLabel = p.t(p.hidden ? 'journey.studio.layers.show' : 'journey.studio.layers.hide');
return (
<span className="st-layer-acts">
<button
type="button"
className={`st-act ${p.locked ? 'is-on is-kept' : ''}`}
onClick={own(() => p.onLock(!p.locked))}
title={lockLabel}
aria-label={lockLabel}
aria-pressed={p.locked}
>
{p.locked ? <Lock size={12} /> : <Unlock size={12} />}
</button>
<button
type="button"
className={`st-act ${p.hidden ? 'is-on is-kept' : ''}`}
onClick={own(() => p.onHide(!p.hidden))}
title={hideLabel}
aria-label={hideLabel}
aria-pressed={p.hidden}
>
{p.hidden ? <EyeOff size={12} /> : <Eye size={12} />}
</button>
</span>
);
}
@@ -0,0 +1,60 @@
import type { BookFrame } from '@trek/shared';
import { HANDLES, HANDLE_POS, type HandleId } from './studioGestures';
/**
* The selection chrome the sheet draws above the page for more than one
* element: one box with the eight resize handles around all of them, dashed
* when it is exactly one group, and the marquee while one is being drawn.
* Screen pixels, like every outline on the sheet, so a line stays a hairline
* at any zoom. The sheet is laid out in millimetres from its left edge in
* every reading direction, which is why these sit at physical offsets.
*/
/** The rectangle of a frame on screen. */
const onScreen = (f: BookFrame, scaled: number) => ({
left: f.x * scaled, // rtl-lint-disable: the sheet is measured from its left edge
top: f.y * scaled,
width: f.w * scaled,
height: f.h * scaled,
});
/** The eight resize handles of a box, corners round and edges as short bars. */
export function ResizeHandles({ onResize }: { onResize: (e: React.PointerEvent, handle: HandleId) => void }) {
return (
<>
{HANDLES.map((h) => {
const kind = h.length === 2 ? 'is-corner' : h === 'n' || h === 's' ? 'is-h' : 'is-v';
const at = HANDLE_POS[h];
const place = { left: at.x, top: at.y, cursor: at.cursor }; // rtl-lint-disable: a corner of the box, not a side of the text
return <span key={h} className={`st-handle ${kind}`} style={place} onPointerDown={(e) => onResize(e, h)} />;
})}
</>
);
}
/** One box around a multi-selection, with handles that resize all of it (Shift keeps the proportions). */
export function SelectionBox({
box,
scaled,
asGroup,
handles,
onResize,
}: {
box: BookFrame;
scaled: number;
asGroup: boolean;
handles: boolean;
onResize: (e: React.PointerEvent, handle: HandleId) => void;
}) {
return (
<div className={`st-select-box ${asGroup ? 'is-group' : ''}`} style={onScreen(box, scaled)}>
{handles && <ResizeHandles onResize={onResize} />}
</div>
);
}
/** The marquee being drawn. */
export function MarqueeRect({ rect, scaled }: { rect: BookFrame; scaled: number }) {
return <div className="st-marquee" style={onScreen(rect, scaled)} />;
}
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Camera, Check, ChevronDown, ImagePlus, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate,
Camera, Check, ChevronDown, ImagePlus, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, Layers, LayoutTemplate,
Plus, Search, Shapes, Sticker, Trash2, Upload, X,
} from 'lucide-react'
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
@@ -22,6 +22,7 @@ import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDe
import { PanelHead as Head } from './StudioPanelHead'
import { StudioElementsPanel } from './StudioElementsPanel'
import { StudioStickersPanel } from './StudioStickersPanel'
import { StudioLayersPanel } from './StudioLayersPanel'
import { StudioTravelPanel } from './StudioTravelPanel'
import { StudioSavedLayouts } from './StudioSavedLayouts'
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
@@ -40,7 +41,7 @@ import type { StudioUploader } from './studioUpload'
* costs 52px and gives the panel the room a photo browser actually needs.
*/
type Section = 'pages' | 'content' | 'elements' | 'stickers' | 'travel' | 'templates'
type Section = 'pages' | 'content' | 'elements' | 'stickers' | 'travel' | 'templates' | 'layers'
export interface JourneySource {
entries: {
@@ -122,6 +123,7 @@ export function StudioSidebar({
{ id: 'stickers', icon: Sticker, labelKey: 'journey.studio.stickers' },
{ id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' },
{ id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' },
{ id: 'layers', icon: Layers, labelKey: 'journey.studio.layers' },
]
return (
@@ -160,6 +162,7 @@ export function StudioSidebar({
onToggleStop={onToggleStop}
/>
)}
{section === 'layers' && <StudioLayersPanel t={t} />}
{section === 'templates' && <TemplatesPanel page={page} pxPerMm={pxPerMm} t={t} onOpenContent={() => setSection('content')} />}
</aside>
</>
@@ -0,0 +1,89 @@
import type { BookElement, BookSpread } from '@trek/shared';
import { bookShapeElementSchema } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { MIN_FRAME_MM, boxOf, mapFrame, marqueeHits, rectBetween, resizeBox } from './studioGestures';
const base = { x: 10, y: 10, w: 40, h: 20 };
describe('resizeBox', () => {
it('moves the dragged edges and keeps the opposite ones', () => {
expect(resizeBox(base, 'se', { dx: 10, dy: 5 }, false)).toEqual({ x: 10, y: 10, w: 50, h: 25 });
expect(resizeBox(base, 'nw', { dx: 10, dy: 5 }, false)).toEqual({ x: 20, y: 15, w: 30, h: 15 });
expect(resizeBox(base, 'e', { dx: 10, dy: 99 }, false)).toEqual({ x: 10, y: 10, w: 50, h: 20 });
expect(resizeBox(base, 'n', { dx: 99, dy: -10 }, false)).toEqual({ x: 10, y: 0, w: 40, h: 30 });
});
it('never turns a box inside out, stopping at the minimum against the fixed edge', () => {
expect(resizeBox(base, 'w', { dx: 100, dy: 0 }, false)).toEqual({
x: 50 - MIN_FRAME_MM,
y: 10,
w: MIN_FRAME_MM,
h: 20,
});
expect(resizeBox(base, 's', { dx: 0, dy: -100 }, false).h).toBe(MIN_FRAME_MM);
});
it('keeps the proportions with Shift, led by the larger move', () => {
expect(resizeBox(base, 'se', { dx: 40, dy: 0 }, true)).toEqual({ x: 10, y: 10, w: 80, h: 40 });
expect(resizeBox(base, 'se', { dx: 0, dy: 20 }, true)).toEqual({ x: 10, y: 10, w: 80, h: 40 });
const nw = resizeBox(base, 'nw', { dx: -40, dy: 0 }, true);
expect(nw).toEqual({ x: -30, y: -10, w: 80, h: 40 });
});
it('grows the other side about the middle on an edge handle with Shift', () => {
expect(resizeBox(base, 'e', { dx: 40, dy: 0 }, true)).toEqual({ x: 10, y: 0, w: 80, h: 40 });
expect(resizeBox(base, 's', { dx: 0, dy: 20 }, true)).toEqual({ x: -10, y: 10, w: 80, h: 40 });
});
it('holds the minimum on both sides when the proportions are kept', () => {
const tiny = resizeBox(base, 'se', { dx: -100, dy: -100 }, true);
expect(tiny.h).toBeGreaterThanOrEqual(MIN_FRAME_MM);
expect(tiny.w / tiny.h).toBeCloseTo(2);
});
});
describe('carrying frames along', () => {
it('scales a frame inside a box with it', () => {
const from = { x: 0, y: 0, w: 100, h: 50 };
const to = { x: 10, y: 10, w: 200, h: 100 };
expect(mapFrame({ x: 50, y: 25, w: 20, h: 10 }, from, to)).toEqual({ x: 110, y: 60, w: 40, h: 20 });
expect(mapFrame(from, from, to)).toEqual(to);
});
it('measures the box around frames and the rectangle between two points', () => {
expect(boxOf([base, { x: 0, y: 40, w: 5, h: 5 }])).toEqual({ x: 0, y: 10, w: 50, h: 35 });
expect(rectBetween(30, 40, 10, 5)).toEqual({ x: 10, y: 5, w: 20, h: 35 });
});
});
describe('marqueeHits', () => {
const el = (id: string, x: number, y: number, w: number, h: number, over: Partial<BookElement> = {}) =>
bookShapeElementSchema.parse({ id, kind: 'shape', frame: { x, y, w, h }, shape: 'rect', ...over }) as BookElement;
const spread = {
id: 'sp',
role: 'inner',
background: null,
parked: [],
entryId: null,
elements: [
el('bg', -3, -3, 426, 216),
el('a', 10, 10, 20, 20),
el('g1', 60, 10, 20, 20, { group: 'g' }),
el('g2', 150, 150, 20, 20, { group: 'g' }),
el('locked', 12, 12, 5, 5, { locked: true }),
el('hidden', 12, 12, 5, 5, { hidden: true }),
],
} as BookSpread;
it('catches what it touches with its whole group, not a page-sized element around it', () => {
expect(marqueeHits(spread, { x: 0, y: 0, w: 70, h: 40 })).toEqual(['a', 'g1', 'g2']);
});
it('leaves locked and hidden elements out, and a zero rectangle catches nothing', () => {
expect(marqueeHits(spread, { x: 5, y: 5, w: 10, h: 10 })).toEqual(['a']);
// Drawn wholly inside an element, it is the element's background case: nothing.
expect(marqueeHits(spread, { x: 11, y: 11, w: 2, h: 2 })).toEqual([]);
expect(marqueeHits(spread, { x: 11, y: 11, w: 0, h: 0 })).toEqual([]);
});
});
@@ -0,0 +1,160 @@
import type { BookElement, BookFrame, BookSpread } from '@trek/shared';
import { expandToGroups, isWholeGroup } from '@trek/shared';
/**
* The geometry behind the sheet's gestures that is worth testing on its own:
* resizing a box from a handle (with the aspect held on Shift), carrying the
* frames inside it along, and what a marquee picks.
*/
export type HandleId = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
/** About the smallest thing worth having on a page, in millimetres. */
export const MIN_FRAME_MM = 4;
/** The box around frames. */
export function boxOf(frames: readonly BookFrame[]): BookFrame {
const x = Math.min(...frames.map((f) => f.x));
const y = Math.min(...frames.map((f) => f.y));
const right = Math.max(...frames.map((f) => f.x + f.w));
const bottom = Math.max(...frames.map((f) => f.y + f.h));
return { x, y, w: right - x, h: bottom - y };
}
/**
* A box dragged by one of its handles by `dx`/`dy` millimetres.
*
* The opposite edge stays where it was, and a box never turns inside out: it
* stops at the minimum size against that edge. With `keepAspect` a corner
* keeps the box's proportions, following whichever of the two directions the
* pointer went further in, and an edge handle grows the other side about the
* middle, the way design tools do it.
*/
export function resizeBox(
base: BookFrame,
handle: HandleId,
by: { dx: number; dy: number },
keepAspect: boolean
): BookFrame {
const side = sidesOf(handle);
const w = base.w + (side.east ? by.dx : side.west ? -by.dx : 0);
const h = base.h + (side.south ? by.dy : side.north ? -by.dy : 0);
const size =
keepAspect && base.w > 0 && base.h > 0
? heldSize(base, side, w, h)
: { w: Math.max(w, MIN_FRAME_MM), h: Math.max(h, MIN_FRAME_MM) };
return placed(base, side, size);
}
interface Sides {
west: boolean;
east: boolean;
north: boolean;
south: boolean;
}
const sidesOf = (handle: HandleId): Sides => ({
west: handle.includes('w'),
east: handle.includes('e'),
north: handle.includes('n'),
south: handle.includes('s'),
});
/** The size with the base's proportions kept, never under the minimum on either side. */
function heldSize(base: BookFrame, side: Sides, w: number, h: number): { w: number; h: number } {
const ratio = base.w / base.h;
const across = side.west || side.east;
const down = side.north || side.south;
// On a corner, whichever way the pointer went further leads and the other follows it.
const widthLeads = across && (!down || Math.abs(w / base.w - 1) >= Math.abs(h / base.h - 1));
const width = Math.max(widthLeads ? w : h * ratio, MIN_FRAME_MM, MIN_FRAME_MM * ratio);
return { w: width, h: width / ratio };
}
/** Where the resized box sits: against the edges that did not move, centred on an axis no handle moved. */
function placed(base: BookFrame, side: Sides, size: { w: number; h: number }): BookFrame {
const x = side.west ? base.x + base.w - size.w : side.east ? base.x : base.x + (base.w - size.w) / 2;
const y = side.north ? base.y + base.h - size.h : side.south ? base.y : base.y + (base.h - size.h) / 2;
return { x, y, w: size.w, h: size.h };
}
/** A frame inside `from` carried into `to`, scaled with it. */
export function mapFrame(frame: BookFrame, from: BookFrame, to: BookFrame): BookFrame {
const sx = from.w > 0 ? to.w / from.w : 1;
const sy = from.h > 0 ? to.h / from.h : 1;
return {
x: to.x + (frame.x - from.x) * sx,
y: to.y + (frame.y - from.y) * sy,
w: Math.max(frame.w * sx, 0.5),
h: Math.max(frame.h * sy, 0.5),
};
}
/** The rectangle between two points, whichever way it was dragged. */
export function rectBetween(ax: number, ay: number, bx: number, by: number): BookFrame {
return { x: Math.min(ax, bx), y: Math.min(ay, by), w: Math.abs(bx - ax), h: Math.abs(by - ay) };
}
const intersects = (a: BookFrame, b: BookFrame) =>
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
const contains = (outer: BookFrame, inner: BookFrame) =>
outer.x <= inner.x &&
outer.y <= inner.y &&
outer.x + outer.w >= inner.x + inner.w &&
outer.y + outer.h >= inner.y + inner.h;
/**
* What a marquee picks: every element it touches, with its whole group.
*
* Locked and hidden elements are left out, as in every design tool. So is an
* element that holds the whole marquee inside it: a page-sized photo behind
* everything would otherwise be caught by every marquee drawn over it, which
* is the one thing nobody drawing one ever wants.
*/
export function marqueeHits(spread: BookSpread, rect: BookFrame): string[] {
if (rect.w <= 0 && rect.h <= 0) return [];
const hits = spread.elements
.filter((e) => !e.locked && !e.hidden)
.filter((e) => intersects(rect, e.frame) && !contains(e.frame, rect))
.map((e) => e.id);
return expandToGroups(spread, hits);
}
export const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
/** Where each handle sits on its box, as percentages from its top left corner, and the cursor it shows. */
type HandlePlace = Record<'x' | 'y' | 'cursor', string>;
export const HANDLE_POS: Record<HandleId, HandlePlace> = {
nw: { x: '0%', y: '0%', cursor: 'nwse-resize' },
n: { x: '50%', y: '0%', cursor: 'ns-resize' },
ne: { x: '100%', y: '0%', cursor: 'nesw-resize' },
e: { x: '100%', y: '50%', cursor: 'ew-resize' },
se: { x: '100%', y: '100%', cursor: 'nwse-resize' },
s: { x: '50%', y: '100%', cursor: 'ns-resize' },
sw: { x: '0%', y: '100%', cursor: 'nesw-resize' },
w: { x: '0%', y: '50%', cursor: 'ew-resize' },
};
/** What the selection is, for the chrome: several, their box, one whole group, anything movable. */
export function selectionShape(spread: BookSpread, sel: readonly BookElement[]) {
const many = sel.length > 1;
return {
many,
box: many ? boxOf(sel.map((e) => e.frame)) : null,
asGroup:
many &&
isWholeGroup(
spread,
sel.map((e) => e.id)
),
anyMovable: sel.some((e) => !e.locked),
};
}
/** The outline class of one selected element: quieter as a member of several, marked when picked inside its group. */
export function selectClass(many: boolean, el: BookElement, enteredGroup: string | null): string {
const inside = el.group !== undefined && el.group === enteredGroup;
return ['st-select', many ? 'is-member' : '', inside ? 'is-inside' : ''].filter(Boolean).join(' ');
}
@@ -0,0 +1,91 @@
import type { BookElement, BookSpread } from '@trek/shared';
import { bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { dropOn, layerLabel, layerRows, rowIds } from './studioLayerRows';
import { shortcutKeys } from './studioShortcut';
/** The value, or a failed test when it is missing. */
function must<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error('expected a value');
return value;
}
const shape = (id: string, over: Partial<BookElement> = {}) =>
bookShapeElementSchema.parse({
id,
kind: 'shape',
frame: { x: 0, y: 0, w: 10, h: 10 },
shape: 'rect',
...over,
}) as BookElement;
const spread = {
id: 'sp',
role: 'inner',
background: null,
parked: [],
entryId: null,
elements: [shape('back'), shape('m1', { group: 'g' }), shape('m2', { group: 'g' }), shape('front')],
groups: [{ id: 'g', name: 'Pair' }],
} as BookSpread;
describe('layerRows', () => {
it('lists the page front to back, a group with its members under it', () => {
const rows = layerRows(spread, new Set());
expect(rows.map((r) => r.key)).toEqual(['front', 'g:g', 'm2', 'm1', 'back']);
const group = must(rows[1]);
expect(group.type === 'group' && group.name).toBe('Pair');
expect(rowIds(group)).toEqual(['m2', 'm1']);
expect(rowIds(must(rows[0]))).toEqual(['front']);
});
it('shows a folded group as its row alone', () => {
expect(layerRows(spread, new Set(['g'])).map((r) => r.key)).toEqual(['front', 'g:g', 'back']);
});
});
describe('dropOn', () => {
const rows = layerRows(spread, new Set());
it('puts what is dragged in front of or behind an element row', () => {
expect(dropOn(must(rows[0]), true)).toEqual({ anchorId: 'front', where: 'above', join: false });
expect(dropOn(must(rows[4]), false)).toEqual({ anchorId: 'back', where: 'below', join: false });
});
it('takes it into a group on a member row, and beside the whole group on the group row', () => {
expect(dropOn(must(rows[2]), false)).toEqual({ anchorId: 'm2', where: 'below', join: true });
expect(dropOn(must(rows[1]), true)).toEqual({ anchorId: 'm2', where: 'above', join: false });
expect(dropOn(must(rows[1]), false)).toEqual({ anchorId: 'm1', where: 'below', join: false });
});
});
describe('layerLabel', () => {
it('uses the name, then a text’s first words, then the kind', () => {
expect(layerLabel(shape('a', { name: 'Sun' }), 'Shape')).toBe('Sun');
expect(layerLabel(shape('a'), 'Shape')).toBe('Shape');
const words = bookTextElementSchema.parse({
id: 't',
kind: 'text',
frame: { x: 0, y: 0, w: 1, h: 1 },
text: '\n Reykjavik \nmore',
});
expect(layerLabel(words as BookElement, 'Text')).toBe('Reykjavik');
const long = bookTextElementSchema.parse({
id: 't',
kind: 'text',
frame: { x: 0, y: 0, w: 1, h: 1 },
text: 'x'.repeat(60),
});
expect(layerLabel(long as BookElement, 'Text')).toHaveLength(40);
const empty = bookTextElementSchema.parse({ id: 't', kind: 'text', frame: { x: 0, y: 0, w: 1, h: 1 }, text: ' ' });
expect(layerLabel(empty as BookElement, 'Text')).toBe('Text');
});
});
describe('shortcutKeys', () => {
it('spells the command and option keys for the platform', () => {
expect(shortcutKeys(true, 'Ctrl', ['⇧', 'G'])).toEqual(['⌘', '⇧', 'G']);
expect(shortcutKeys(false, 'Strg', ['alt', 'C'])).toEqual(['Strg', 'Alt', 'C']);
expect(shortcutKeys(true, 'Ctrl', ['alt', 'V'])).toEqual(['⌘', '⌥', 'V']);
});
});
@@ -0,0 +1,93 @@
import type { BookElement, BookSpread } from '@trek/shared';
import { bookGroupName, commonGroup, groupMembers, isWholeGroup } from '@trek/shared';
/**
* The layers panel's rows, front to back: what the panel draws and where a
* drag lands. Kept apart from the panel so the order and the drop rules can
* be tested without drawing it.
*/
export type LayerRow =
| { type: 'group'; key: string; groupId: string; name: string | undefined; members: BookElement[] }
| { type: 'element'; key: string; el: BookElement; inGroup: boolean };
/**
* Every element front to back, a group's members under its row. A collapsed
* group shows its row alone. Groups are kept together in the paint order
* (book-groups.ts), so the members found first, from the front, are the whole
* group.
*/
export function layerRows(spread: BookSpread, collapsed: ReadonlySet<string>): LayerRow[] {
const rows: LayerRow[] = [];
const seen = new Set<string>();
for (const el of spread.elements.slice().reverse()) {
if (!el.group) {
rows.push({ type: 'element', key: el.id, el, inGroup: false });
continue;
}
if (seen.has(el.group)) continue;
seen.add(el.group);
const members = groupMembers(spread, el.group).reverse();
rows.push({
type: 'group',
key: `g:${el.group}`,
groupId: el.group,
name: bookGroupName(spread, el.group),
members,
});
if (collapsed.has(el.group)) continue;
for (const m of members) rows.push({ type: 'element', key: m.id, el: m, inGroup: true });
}
return rows;
}
/** The elements a row stands for: a group's members, or the one element. */
export function rowIds(row: LayerRow): string[] {
return row.type === 'group' ? row.members.map((m) => m.id) : [row.el.id];
}
/** Where a drop lands in the paint order, for the store's `restack`. */
export interface LayerDrop {
anchorId: string;
where: 'above' | 'below';
join: boolean;
}
/**
* What letting go over a row does. Over the upper half of a row the dragged
* layers go in front of it, over the lower half behind it. A member of a
* group takes them into its group; a group's own row puts them in front of or
* behind the whole group.
*/
export function dropOn(row: LayerRow, upper: boolean): LayerDrop {
if (row.type === 'group') {
const front = row.members[0] as BookElement;
const back = row.members[row.members.length - 1] as BookElement;
return upper
? { anchorId: front.id, where: 'above', join: false }
: { anchorId: back.id, where: 'below', join: false };
}
return { anchorId: row.el.id, where: upper ? 'above' : 'below', join: row.inGroup };
}
/** What the panel calls an element: its own name, a text's first words, or its kind. */
export function layerLabel(el: BookElement, kindLabel: string): string {
if (el.name) return el.name;
if (el.kind === 'text') {
const first = el.text
.split('\n')
.find((l) => l.trim())
?.trim();
if (first) return first.length > 40 ? `${first.slice(0, 39)}…` : first;
}
return kindLabel;
}
/** What the inspector's head calls the selection: a whole group by its name, otherwise "Several". */
export function selectionTitle(spread: BookSpread | undefined, selection: string[], t: (k: string) => string): string {
if (spread && isWholeGroup(spread, selection)) {
const group = commonGroup(spread, selection);
return (group && bookGroupName(spread, group)) || t('journey.studio.layers.group');
}
return t('journey.studio.multiple');
}
@@ -0,0 +1,19 @@
import { useTranslation } from '../../i18n';
import { isApplePlatform } from '../../utils/applePlatform';
/**
* The keys of a Studio shortcut as the tooltip shows them: ⌘ and ⌥ on a Mac,
* Ctrl and Alt elsewhere, always with the command key first. `alt` stands for
* the option key so callers do not each decide how to spell it.
*/
export function shortcutKeys(apple: boolean, ctrl: string, keys: readonly string[]): string[] {
const mod = apple ? '⌘' : ctrl;
return [mod, ...keys.map((k) => (k === 'alt' ? (apple ? '⌥' : 'Alt') : k))];
}
/** `keys('⇧', 'G')` reads as ⌘ ⇧ G or Ctrl ⇧ G, for the platform the page runs on. */
export function useStudioShortcut(): (...keys: string[]) => string[] {
const { t } = useTranslation();
const apple = isApplePlatform();
return (...keys) => shortcutKeys(apple, t('assistant.launcher.ctrlKey'), keys);
}
@@ -1,6 +1,8 @@
import { useCallback, useRef, useState } from 'react'
import type { BookElement, BookFrame, BookPageSetup, BookSpread } from '@trek/shared'
import { expandToGroups } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { MIN_FRAME_MM, boxOf, mapFrame, marqueeHits, rectBetween, resizeBox, type HandleId } from './studioGestures'
/**
* Moving and resizing on the sheet.
@@ -27,11 +29,31 @@ export interface Guide {
at: number
}
export type HandleId = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
export type { HandleId }
/** How far, in screen pixels, a press has to travel before it draws a marquee. */
const MARQUEE_START_PX = 3
interface Gesture {
kind: 'move' | 'resize' | 'rotate'
kind: 'move' | 'resize' | 'rotate' | 'marquee'
handle?: HandleId
/** The box around the frames being resized, as it was. */
baseBox?: BookFrame
/**
* A marquee: where it started on the spread, in millimetres, and what was
* selected before it when shift was held (a marquee with shift adds).
*/
originX?: number
originY?: number
keep?: string[]
/** Whether the pointer has travelled: far enough for a marquee to count, or at all for a move. */
moved?: boolean
/**
* What a press on one of several selected elements narrows the selection
* to when it lets go without moving: the element, or its group. A drag
* moves all of them; a click picks the one, as in every design tool.
*/
narrowTo?: string[]
startX: number
startY: number
/** Frames as they were when the gesture started, by element id. */
@@ -120,6 +142,41 @@ function bestSnap(edges: number[], targets: number[], tolMm: number): { delta: n
return best
}
/**
* A resized box with the edges being dragged snapped onto the nearest line,
* and the lines it snapped to added to `guides`. A box never turns inside out,
* snapped or not: it stops at the minimum against the edge that stays.
*/
function snapBox(
box: BookFrame,
h: HandleId,
base: BookFrame,
targets: { xs: number[]; ys: number[]; tolMm: number },
guides: Guide[],
): BookFrame {
let { x, y, w } = box
let hh = box.h
if (h.includes('w')) {
const s = bestSnap([x], targets.xs, targets.tolMm)
if (s) { w += x - s.at; x = s.at; guides.push({ axis: 'x', at: s.at }) }
}
if (h.includes('e')) {
const s = bestSnap([x + w], targets.xs, targets.tolMm)
if (s) { w = s.at - x; guides.push({ axis: 'x', at: s.at }) }
}
if (h.includes('n')) {
const s = bestSnap([y], targets.ys, targets.tolMm)
if (s) { hh += y - s.at; y = s.at; guides.push({ axis: 'y', at: s.at }) }
}
if (h.includes('s')) {
const s = bestSnap([y + hh], targets.ys, targets.tolMm)
if (s) { hh = s.at - y; guides.push({ axis: 'y', at: s.at }) }
}
if (w < MIN_FRAME_MM) { if (h.includes('w')) x = base.x + base.w - MIN_FRAME_MM; w = MIN_FRAME_MM }
if (hh < MIN_FRAME_MM) { if (h.includes('n')) y = base.y + base.h - MIN_FRAME_MM; hh = MIN_FRAME_MM }
return { x, y, w, h: hh }
}
export function useSpreadInteraction(opts: {
spread: BookSpread | null
spreadIndex: number
@@ -129,7 +186,7 @@ export function useSpreadInteraction(opts: {
}) {
const { spread, spreadIndex, page, pxPerMm } = opts
const selection = useStudioStore(s => s.selection)
const toggleSelect = useStudioStore(s => s.toggleSelect)
const pick = useStudioStore(s => s.pick)
const select = useStudioStore(s => s.select)
const setFrame = useStudioStore(s => s.setFrame)
const setRotation = useStudioStore(s => s.setRotation)
@@ -137,8 +194,12 @@ export function useSpreadInteraction(opts: {
const endGesture = useStudioStore(s => s.endGesture)
const gesture = useRef<Gesture | null>(null)
/** Whether letting go of a marquee that never moved clears the selection. */
const clearOnClick = useRef(false)
const [guides, setGuides] = useState<Guide[]>([])
const [dragging, setDragging] = useState(false)
/** The marquee being drawn, in millimetres on the spread. */
const [marquee, setMarquee] = useState<BookFrame | null>(null)
const tolMm = SNAP_PX / Math.max(pxPerMm, 0.0001)
@@ -149,11 +210,21 @@ export function useSpreadInteraction(opts: {
} catch {
// The element can be gone already — releasing a capture is best-effort.
}
const g = gesture.current
gesture.current = null
setGuides([])
setDragging(false)
setMarquee(null)
// A marquee changes the selection only. A press on bare paper that never
// became one clears the selection, as a click there always did.
if (g.kind === 'marquee') {
if (!g.moved && clearOnClick.current) select([])
clearOnClick.current = false
return
}
if (g.kind === 'move' && !g.moved && g.narrowTo) select(g.narrowTo)
endGesture()
}, [endGesture])
}, [endGesture, select])
const onPointerMove = useCallback((e: React.PointerEvent) => {
const g = gesture.current
@@ -162,6 +233,18 @@ export function useSpreadInteraction(opts: {
const dxMm = (e.clientX - g.startX) / pxPerMm
const dyMm = (e.clientY - g.startY) / pxPerMm
if (g.kind === 'marquee') {
if (!g.moved && Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < MARQUEE_START_PX) return
g.moved = true
const rect = rectBetween(g.originX ?? 0, g.originY ?? 0, (g.originX ?? 0) + dxMm, (g.originY ?? 0) + dyMm)
setMarquee(rect)
const hits = marqueeHits(spread, rect)
const keep = g.keep ?? []
select([...keep, ...hits.filter(id => !keep.includes(id))])
return
}
const moving = new Set(g.base.keys())
const { xs, ys } = snapTargets(spread, page, moving)
const nextGuides: Guide[] = []
@@ -195,6 +278,7 @@ export function useSpreadInteraction(opts: {
}
if (g.kind === 'move') {
if (dxMm !== 0 || dyMm !== 0) g.moved = true
// Snap on the union of all moving edges, so a multi-selection lines up as
// a block rather than each element fighting for its own line.
const movedX: number[] = []
@@ -216,46 +300,28 @@ export function useSpreadInteraction(opts: {
h: f.h,
})
}
} else if (g.handle) {
} else if (g.handle && g.baseBox) {
/*
* One box for the whole selection, however many are in it.
*
* The box is resized and every frame inside it is carried along, so a
* multi-selection or a group grows as one picture rather than each
* element stretching about its own corner. With one element the box is
* its frame and this is the plain resize it always was. Shift keeps the
* proportions; the snap then stays out of it, since an edge pulled onto
* a line would bend the ratio Shift is holding.
*/
const h = g.handle
for (const [id, f] of g.base) {
let { x, y, w, hh } = { x: f.x, y: f.y, w: f.w, hh: f.h }
const keep = e.shiftKey
let box = resizeBox(g.baseBox, h, { dx: dxMm, dy: dyMm }, keep)
if (h.includes('w')) { x = f.x + dxMm; w = f.w - dxMm }
if (h.includes('e')) { w = f.w + dxMm }
if (h.includes('n')) { y = f.y + dyMm; hh = f.h - dyMm }
if (h.includes('s')) { hh = f.h + dyMm }
if (!keep) box = snapBox(box, h, g.baseBox, { xs, ys, tolMm }, nextGuides)
// Snap the edge actually being dragged, not the whole box.
if (h.includes('w')) {
const s = bestSnap([x], xs, tolMm)
if (s) { w += x - s.at; x = s.at; nextGuides.push({ axis: 'x', at: s.at }) }
}
if (h.includes('e')) {
const s = bestSnap([x + w], xs, tolMm)
if (s) { w = s.at - x; nextGuides.push({ axis: 'x', at: s.at }) }
}
if (h.includes('n')) {
const s = bestSnap([y], ys, tolMm)
if (s) { hh += y - s.at; y = s.at; nextGuides.push({ axis: 'y', at: s.at }) }
}
if (h.includes('s')) {
const s = bestSnap([y + hh], ys, tolMm)
if (s) { hh = s.at - y; nextGuides.push({ axis: 'y', at: s.at }) }
}
// A frame may not turn inside out. 4mm is about the smallest thing
// worth having on a page.
const MIN = 4
if (w < MIN) { if (h.includes('w')) x = f.x + f.w - MIN; w = MIN }
if (hh < MIN) { if (h.includes('n')) y = f.y + f.h - MIN; hh = MIN }
setFrame(spreadIndex, id, { x, y, w, h: hh })
}
for (const [id, f] of g.base) setFrame(spreadIndex, id, mapFrame(f, g.baseBox, box))
}
setGuides(nextGuides)
}, [spread, page, pxPerMm, tolMm, setFrame, setRotation, spreadIndex])
}, [spread, page, pxPerMm, tolMm, setFrame, setRotation, spreadIndex, select])
const framesFor = useCallback((ids: string[]) => {
const map = new Map<string, BookFrame>()
@@ -264,14 +330,44 @@ export function useSpreadInteraction(opts: {
return map
}, [spread])
/**
* Draw a marquee from where the press landed. `keep` is what stays selected
* besides what it catches: the selection before it when shift is held.
* `clear` says whether letting go without dragging clears the selection,
* which is what a click on bare paper does.
*/
const startMarquee = useCallback((e: React.PointerEvent, keep: string[] | undefined, clear: boolean) => {
const stage = (e.currentTarget as Element).closest('.st-stage')
const rect = stage?.getBoundingClientRect()
;(e.target as Element).setPointerCapture?.(e.pointerId)
clearOnClick.current = clear
gesture.current = {
kind: 'marquee',
startX: e.clientX,
startY: e.clientY,
originX: (e.clientX - (rect?.left ?? 0)) / pxPerMm,
originY: (e.clientY - (rect?.top ?? 0)) / pxPerMm,
keep,
moved: false,
base: new Map(),
pointerId: e.pointerId,
}
}, [pxPerMm])
/** A press on bare paper: a marquee, or with no drag, nothing selected. */
const startPaper = useCallback((e: React.PointerEvent) => {
const additive = e.shiftKey || e.metaKey || e.ctrlKey
startMarquee(e, additive ? useStudioStore.getState().selection : undefined, !additive)
}, [startMarquee])
const startMove = useCallback((e: React.PointerEvent, el: BookElement) => {
e.stopPropagation()
const additive = e.shiftKey || e.metaKey || e.ctrlKey
const ids = selection.includes(el.id)
? (additive ? selection.filter(x => x !== el.id) : selection)
: (additive ? [...selection, el.id] : [el.id])
const before = useStudioStore.getState().selection
if (additive) { toggleSelect(el.id, true) } else if (!selection.includes(el.id)) { select([el.id]) }
// Group-aware: a click picks the element's whole group, or the element
// alone inside a group stepped into with a double-click (studioLayers.ts).
pick(spreadIndex, el.id, additive)
/*
* Locked means "cannot be moved", not "cannot be reached".
@@ -280,10 +376,24 @@ export function useSpreadInteraction(opts: {
* not be picked, so the inspector never showed it, so the unlock button was
* unreachable and the lock could not be undone by any means short of undo.
* Selection happens first and then the gesture stops here.
*
* A press on a locked element that goes on to drag draws a marquee
* instead: a locked page-sized photo is where a marquee over a page
* usually has to start.
*/
if (el.locked) return
if (el.locked) {
startMarquee(e, additive ? before : undefined, false)
return
}
if (additive) return
const targets = ids.length ? ids : [el.id]
// What moves is the selection as the press left it, less what is locked.
const state = useStudioStore.getState()
const onPage = state.doc?.spreads[spreadIndex]?.elements ?? spread?.elements ?? []
const targets = onPage.filter(x => state.selection.includes(x.id) && !x.locked).map(x => x.id)
const own = el.group && el.group === state.enteredGroup
? [el.id]
: expandToGroups({ elements: onPage } as BookSpread, [el.id])
;(e.target as Element).setPointerCapture?.(e.pointerId)
beginGesture()
@@ -291,11 +401,12 @@ export function useSpreadInteraction(opts: {
kind: 'move',
startX: e.clientX,
startY: e.clientY,
base: framesFor(targets),
base: framesFor(targets.length ? targets : [el.id]),
narrowTo: state.selection.length > own.length ? own : undefined,
pointerId: e.pointerId,
}
setDragging(true)
}, [selection, toggleSelect, select, beginGesture, framesFor])
}, [pick, spreadIndex, spread, startMarquee, beginGesture, framesFor])
/**
* Take hold of the rotation handle.
@@ -340,6 +451,9 @@ export function useSpreadInteraction(opts: {
const startResize = useCallback((e: React.PointerEvent, handle: HandleId) => {
e.stopPropagation()
const unlocked = (spread?.elements ?? []).filter(el => selection.includes(el.id) && !el.locked).map(el => el.id)
const base = framesFor(unlocked)
if (!base.size) return
;(e.target as Element).setPointerCapture?.(e.pointerId)
beginGesture()
gesture.current = {
@@ -347,11 +461,12 @@ export function useSpreadInteraction(opts: {
handle,
startX: e.clientX,
startY: e.clientY,
base: framesFor(selection),
base,
baseBox: boxOf([...base.values()]),
pointerId: e.pointerId,
}
setDragging(true)
}, [selection, beginGesture, framesFor])
}, [spread, selection, beginGesture, framesFor])
return { guides, dragging, startMove, startResize, startRotate, onPointerMove, finish }
return { guides, dragging, marquee, startMove, startPaper, startResize, startRotate, onPointerMove, finish }
}
@@ -0,0 +1,178 @@
import type { BookSpread } from '@trek/shared';
import { useEffect } from 'react';
import { useStudioStore } from '../../store/studioStore';
/**
* The sheet's keyboard.
*
* Every shortcut here is one a design tool has taught people already, and none
* of them takes a key away from the browser while somebody is typing: in a
* field, a text area or a text being edited on the page, the keys are the
* field's. Undo and redo are the Studio page's own (useJourneyStudio.ts), and
* every action below is one undo step there.
*
* - Delete or Backspace: remove what is selected and not locked.
* - Ctrl+C, Ctrl+V: copy and paste elements, onto any page (#2316).
* - Ctrl+D: duplicate. Ctrl+A: select everything on the page.
* - Ctrl+G, Ctrl+Shift+G: group and ungroup.
* - Ctrl+Alt+C, Ctrl+Alt+V: copy and paste style.
* - Ctrl+] and Ctrl+[: forward and backward, with Shift to the front and back.
* - Arrows: nudge 1 mm, 10 mm with Shift. Holding one down is one undo step.
* - Escape inside a group: back to the whole group selected.
*
* Ctrl is Cmd on a Mac throughout.
*/
const NUDGE_MM = 1;
const NUDGE_BIG_MM = 10;
const ARROWS: Record<string, [number, number]> = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
};
/** Whether a key press belongs to whatever has the focus rather than to the sheet. */
function isTypingTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el) return false;
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable;
}
/** Which of the deletable: on the page, selected, unlocked. */
const deletable = (spread: BookSpread | null, selection: string[]): string[] =>
(spread?.elements ?? []).filter((e) => selection.includes(e.id) && !e.locked).map((e) => e.id);
type Store = ReturnType<typeof useStudioStore.getState>;
/**
* One shortcut with Ctrl (Cmd on a Mac): whether it is this press, and what it
* does. The first that matches runs; a press none of them takes stays the
* browser's.
*/
interface Shortcut {
matches: (e: KeyboardEvent, key: string, store: Store, spread: BookSpread | null) => boolean;
run: (e: KeyboardEvent, store: Store, spreadIndex: number) => void;
}
const has = (store: Store) => store.selection.length > 0;
const BRACKETS = new Set([']', '[', '}', '{']);
const SHORTCUTS: Shortcut[] = [
// Ctrl+Alt is AltGr on many keyboards: only the two letters it does not type are taken.
{ matches: (e, key, s) => e.altKey && key === 'c' && has(s), run: (_e, s, i) => s.copyStyle(i) },
{
matches: (e, key, s) => e.altKey && key === 'v' && has(s) && s.styleClip !== null,
run: (_e, s, i) => s.pasteStyle(i),
},
{
matches: (e, key, s) => !e.altKey && key === 'g' && has(s),
run: (e, s, i) => (e.shiftKey ? s.ungroup(i) : s.group(i)),
},
{
matches: (e, key, s) => !e.altKey && !e.shiftKey && key === 'd' && has(s),
run: (_e, s, i) => s.duplicate(i, s.selection),
},
{
matches: (e, key, _s, spread) => !e.altKey && !e.shiftKey && key === 'a' && !!spread?.elements.length,
run: (_e, s, i) => s.selectAll(i),
},
{
matches: (e, _key, s) => !e.altKey && BRACKETS.has(e.key) && has(s),
run: (e, s, i) => {
const up = e.key === ']' || e.key === '}';
const far = up ? 'front' : 'back';
s.raise(i, s.selection, e.shiftKey ? far : up ? 'forward' : 'backward');
},
},
// Copy and paste across pages (#2316). Text selected on the page is the browser's to copy, not the book's.
{
matches: (e, key, s) => !e.altKey && key === 'c' && has(s) && !window.getSelection()?.toString(),
run: (_e, s, i) => s.copy(i, s.selection),
},
{ matches: (e, key, s) => !e.altKey && key === 'v' && s.clipboard !== null, run: (_e, s, i) => s.paste(i) },
];
/** A shortcut with Ctrl (Cmd on a Mac): answers whether it was one of ours. */
function command(e: KeyboardEvent, spreadIndex: number, spread: BookSpread | null): boolean {
const store = useStudioStore.getState();
const key = e.key.toLowerCase();
const hit = SHORTCUTS.find((sc) => sc.matches(e, key, store, spread));
hit?.run(e, store, spreadIndex);
return !!hit;
}
/** The arrow a press is, when it moves the selection: no modifier but Shift, something selected. */
function nudgeOf(e: KeyboardEvent, store: Store): [number, number] | null {
const arrow = ARROWS[e.key];
if (!arrow || !store.selection.length || e.ctrlKey || e.metaKey || e.altKey) return null;
const step = e.shiftKey ? NUDGE_BIG_MM : NUDGE_MM;
return [arrow[0] * step, arrow[1] * step];
}
/** Delete or Backspace: what is selected and not locked goes. Answers whether the key was taken. */
function deleteSelection(e: KeyboardEvent, store: Store, spreadIndex: number, spread: BookSpread | null): boolean {
if ((e.key !== 'Delete' && e.key !== 'Backspace') || !store.selection.length) return false;
e.preventDefault();
const ids = deletable(spread, store.selection);
if (ids.length) store.removeElements(spreadIndex, ids);
return true;
}
export function useStudioKeys(opts: { spreadIndex: number; editing: boolean }) {
const { spreadIndex, editing } = opts;
useEffect(() => {
/** The arrow keys hold one gesture open while any of them is down, so holding one is one undo step. */
let nudging = false;
const stopNudge = () => {
if (!nudging) return;
nudging = false;
useStudioStore.getState().endGesture();
};
const onKey = (e: KeyboardEvent) => {
if (editing || isTypingTarget(e.target)) return;
const store = useStudioStore.getState();
// Read when the key comes, not when the effect ran: a nudge changes the
// page, and an effect that re-ran on it would close the gesture each step.
const spread = store.doc?.spreads[spreadIndex] ?? null;
if (e.key === 'Escape') {
// Out of the group first; the Studio page's own Escape sees it was taken.
if (store.leaveGroup(spreadIndex)) e.preventDefault();
return;
}
if (deleteSelection(e, store, spreadIndex, spread)) return;
const nudge = nudgeOf(e, store);
if (nudge) {
e.preventDefault();
if (store.readOnly) return;
if (!nudging) {
nudging = true;
store.beginGesture();
}
store.nudge(spreadIndex, nudge[0], nudge[1]);
return;
}
if (!(e.metaKey || e.ctrlKey)) return;
if (command(e, spreadIndex, spread)) e.preventDefault();
};
const onKeyUp = (e: KeyboardEvent) => {
if (ARROWS[e.key]) stopNudge();
};
// Capture, so a step out of a group is decided before the page's Escape clears the selection.
window.addEventListener('keydown', onKey, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('blur', stopNudge);
return () => {
stopNudge();
window.removeEventListener('keydown', onKey, true);
window.removeEventListener('keyup', onKeyUp, true);
window.removeEventListener('blur', stopNudge);
};
}, [spreadIndex, editing]);
}
@@ -593,7 +593,8 @@ export function useJourneyStudio() {
if (e.shiftKey) redo(); else undo()
return
}
if (e.key !== 'Escape' || typing) return
// Taken already: the sheet stepped out of a group with it (useStudioKeys).
if (e.key !== 'Escape' || typing || e.defaultPrevented) return
// An editor's Escape clears the selection first; only a second one leaves.
if (selection.length) {
e.preventDefault()
+306
View File
@@ -0,0 +1,306 @@
import type { BookDocument, BookElement, BookSpread } from '@trek/shared';
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { useStudioStore } from './studioStore';
/**
* Groups, layers and the editor's productivity actions in the Studio store
* (studioLayers.ts): what a click selects, every action as one undo step,
* locked elements left where they are.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
const shape = (id: string, x: number, y: number, over: Partial<BookElement> = {}): BookElement =>
bookShapeElementSchema.parse({
id,
kind: 'shape',
frame: { x, y, w: 20, h: 20 },
shape: 'rect',
...over,
}) as BookElement;
const text = (id: string, over: Partial<BookElement> = {}): BookElement =>
bookTextElementSchema.parse({
id,
kind: 'text',
frame: { x: 10, y: 10, w: 60, h: 20 },
text: 'Hi',
...over,
}) as BookElement;
function load(elements: BookElement[], over: Partial<BookSpread> = {}, more: BookSpread[] = []) {
useStudioStore.getState().load({
version: 1,
title: 'T',
page,
spreads: [{ id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null, ...over }, ...more],
} as BookDocument);
}
const store = () => useStudioStore.getState();
/** The value, or a failed test when it is missing. */
function must<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error('expected a value');
return value;
}
const els = (i = 0) => must(must(store().doc).spreads[i]).elements;
const spread = (i = 0) => must(must(store().doc).spreads[i]);
const el = (id: string) => must(els().find((e) => e.id === id));
const order = () => els().map((e) => e.id);
const grouped = () => [
shape('a', 10, 10),
shape('g1', 50, 10, { group: 'g' }),
shape('g2', 80, 30, { group: 'g' }),
shape('b', 150, 10),
];
beforeEach(() => {
load([]);
// The style clipboard outlives a page on purpose; each test starts without one.
useStudioStore.setState({ styleClip: null, enteredGroup: null });
});
describe('what a click selects', () => {
it('picks a whole group, and one element alone', () => {
load(grouped());
store().pick(0, 'g2', false);
expect(store().selection).toEqual(['g1', 'g2']);
store().pick(0, 'a', false);
expect(store().selection).toEqual(['a']);
});
it('adds with shift, and takes out what is all in already', () => {
load(grouped());
store().pick(0, 'a', false);
store().pick(0, 'g1', true);
expect(store().selection).toEqual(['a', 'g1', 'g2']);
store().pick(0, 'g2', true);
expect(store().selection).toEqual(['a']);
});
it('keeps a multi-selection when one of it is pressed, so it can be dragged', () => {
load(grouped());
store().select(['a', 'b']);
store().pick(0, 'b', false);
expect(store().selection).toEqual(['a', 'b']);
});
it('steps into a group and out again', () => {
load(grouped());
store().enterGroup(0, 'g2');
expect([store().selection, store().enteredGroup]).toEqual([['g2'], 'g']);
store().pick(0, 'g1', false);
expect(store().selection).toEqual(['g1']);
expect(store().leaveGroup(0)).toBe(true);
expect([store().selection, store().enteredGroup]).toEqual([['g1', 'g2'], null]);
expect(store().leaveGroup(0)).toBe(false);
// An ungrouped element cannot be stepped into.
store().enterGroup(0, 'a');
expect(store().enteredGroup).toBeNull();
});
it('a click outside the group stepped into steps out, and clearing the selection does too', () => {
load(grouped());
store().enterGroup(0, 'g1');
store().pick(0, 'a', false);
expect(store().enteredGroup).toBeNull();
store().enterGroup(0, 'g1');
store().select([]);
expect(store().enteredGroup).toBeNull();
});
it('selects a group from its row, and everything not hidden', () => {
load([...grouped(), shape('h', 0, 0, { hidden: true })]);
store().selectGroup(0, 'g', false);
expect(store().selection).toEqual(['g1', 'g2']);
store().select(['a']);
store().selectGroup(0, 'g', true);
expect(store().selection).toEqual(['a', 'g1', 'g2']);
store().selectAll(0);
expect(store().selection).toEqual(['a', 'g1', 'g2', 'b']);
});
});
describe('group and ungroup', () => {
it('groups the unlocked part of the selection as one undo step, and ungroups it again', () => {
load([shape('a', 0, 0), shape('b', 30, 0), shape('c', 60, 0, { locked: true })]);
store().select(['a', 'b', 'c']);
store().group(0);
expect(el('a').group).toBeDefined();
expect(el('a').group).toBe(el('b').group);
expect(el('c').group).toBeUndefined();
expect(store().selection).toEqual(['a', 'b']);
store().ungroup(0);
expect(el('a').group).toBeUndefined();
store().undo();
expect(el('a').group).toBeDefined();
store().undo();
expect(el('a').group).toBeUndefined();
});
it('does nothing for one element, a whole group, or a selection without groups', () => {
load(grouped());
const before = store().doc;
store().select(['a']);
store().group(0);
store().select(['g1', 'g2']);
store().group(0);
store().select(['a', 'b']);
store().ungroup(0);
expect(store().doc).toBe(before);
});
});
describe('moving', () => {
it('nudges what is selected and unlocked, one undo step for a held key', () => {
load([shape('a', 10, 10), shape('l', 10, 10, { locked: true })]);
store().select(['a', 'l']);
store().beginGesture();
store().nudge(0, 1, 0);
store().nudge(0, 1, 0);
store().nudge(0, 0, 10);
store().endGesture();
expect(el('a').frame).toMatchObject({ x: 12, y: 20 });
expect(el('l').frame).toMatchObject({ x: 10, y: 10 });
store().undo();
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
});
it('aligns and distributes, moving a group as one box', () => {
load(grouped());
store().select(['a', 'g1', 'g2', 'b']);
store().align(0, 'top', 'selection');
expect([el('a').frame.y, el('g1').frame.y, el('g2').frame.y]).toEqual([10, 10, 30]);
store().distribute(0, 'horizontal');
// a 10..30, group 50..100, b 150..170: gaps of 35, so the group starts at 65.
expect([el('g1').frame.x, el('g2').frame.x]).toEqual([65, 95]);
store().undo();
expect(el('g1').frame.x).toBe(50);
});
it('leaves alignment alone with nothing movable, and distribute with fewer than three', () => {
load([shape('a', 10, 10, { locked: true }), shape('b', 40, 40)]);
const before = store().doc;
store().select(['a']);
store().align(0, 'left', 'page');
store().select(['a', 'b']);
store().distribute(0, 'horizontal');
expect(store().doc).toBe(before);
});
it('raises a group whole, and a member inside its group only', () => {
load(grouped());
store().raise(0, ['g1'], 'back');
expect(order()).toEqual(['g1', 'g2', 'a', 'b']);
store().enterGroup(0, 'g1');
store().raise(0, ['g1'], 'up');
expect(order()).toEqual(['g2', 'g1', 'a', 'b']);
store().raise(0, 'b', 'down');
expect(order()).toEqual(['g2', 'g1', 'b', 'a']);
});
});
describe('the layers panel', () => {
it('restacks a layer in front of or behind another, joining a group when dropped on a member', () => {
load(grouped());
store().restack(0, ['a'], 'b', 'above', false);
expect(order()).toEqual(['g1', 'g2', 'b', 'a']);
store().restack(0, ['a'], 'g1', 'below', true);
expect(order()).toEqual(['a', 'g1', 'g2', 'b']);
expect(el('a').group).toBe('g');
// Dragged out beside an ungrouped element, it leaves the group.
store().restack(0, ['a'], 'b', 'above', false);
expect(el('a').group).toBeUndefined();
});
it('moves a whole group without breaking it, and refuses to drop a layer on itself', () => {
load(grouped());
store().restack(0, ['g1', 'g2'], 'b', 'above', false);
expect(order()).toEqual(['a', 'b', 'g1', 'g2']);
expect(el('g1').group).toBe('g');
const before = store().doc;
store().restack(0, ['a'], 'a', 'above', false);
store().restack(0, ['a'], 'missing', 'above', false);
expect(store().doc).toBe(before);
});
it('hides, locks and names, each as one undo step, and a hidden layer leaves the selection', () => {
load(grouped());
store().select(['a', 'b']);
store().setHidden(0, ['a'], true);
expect(el('a').hidden).toBe(true);
expect(store().selection).toEqual(['b']);
store().setLocked(0, ['g1', 'g2'], true);
expect([el('g1').locked, el('g2').locked]).toEqual([true, true]);
store().renameLayer(0, { elementId: 'b' }, 'Sun');
store().renameLayer(0, { groupId: 'g' }, 'Lockup');
expect(el('b').name).toBe('Sun');
expect(spread().groups).toEqual([{ id: 'g', name: 'Lockup' }]);
store().undo();
expect(spread().groups).toBeUndefined();
store().setHidden(0, ['a'], false);
expect('hidden' in el('a')).toBe(false);
});
});
describe('copies and the style clipboard', () => {
it('duplicates a group as a new group beside it, selected', () => {
load(grouped(), { groups: [{ id: 'g', name: 'Lockup' }] });
store().duplicate(0, ['g1', 'g2']);
const copies = els().slice(4);
expect(copies.map((e) => e.frame.x)).toEqual([54, 84]);
expect(must(copies[0]).group).toBe(must(copies[1]).group);
expect(must(copies[0]).group).not.toBe('g');
expect(store().selection).toEqual(copies.map((e) => e.id));
expect(spread().groups?.map((g) => g.name)).toEqual(['Lockup', 'Lockup']);
});
it('pastes a copied group onto another page as a group of its own', () => {
const other: BookSpread = { id: 'sp2', role: 'inner', background: null, elements: [], parked: [], entryId: null };
load(grouped(), {}, [other]);
store().copy(0, ['g1', 'g2']);
store().paste(1);
const pasted = els(1);
expect(pasted).toHaveLength(2);
expect(must(pasted[0]).group).toBe(must(pasted[1]).group);
expect(must(pasted[0]).group).not.toBe('g');
});
it('a delete that leaves a group of one dissolves it', () => {
load(grouped());
store().removeElements(0, ['g1']);
expect(el('g2').group).toBeUndefined();
});
it('copies the look of one element and pastes it onto others, unlocked only, as one step', () => {
load([
text('t1', { size: 30, color: '#ff0000' }),
text('t2'),
text('t3', { locked: true }),
shape('s', 0, 0, { opacity: 1 }),
]);
store().select(['t1']);
store().copyStyle(0);
expect(store().styleClip?.kind).toBe('text');
store().select(['t2', 't3', 's']);
store().pasteStyle(0);
expect(el('t2')).toMatchObject({ size: 30, color: '#ff0000', text: 'Hi' });
expect(el('t3')).not.toMatchObject({ size: 30 });
store().undo();
expect(el('t2')).not.toMatchObject({ size: 30 });
});
it('pastes nothing without a copied style, or onto a kind that shares nothing with it', () => {
load([shape('s', 0, 0), text('t')]);
const before = store().doc;
store().select(['s']);
store().pasteStyle(0);
useStudioStore.setState({ styleClip: { kind: 'icon', props: { lineWidth: 2 } } });
store().pasteStyle(0);
expect(store().doc).toBe(before);
});
});
+396
View File
@@ -0,0 +1,396 @@
import type {
BookAlignEdge,
BookAlignTo,
BookArrangeTo,
BookCopiedStyle,
BookDistributeAxis,
BookDocument,
BookElement,
BookSpread,
} from '@trek/shared';
import {
MAX_SPREAD_ELEMENTS,
alignElements,
arrangeElements,
arrangeWithinGroup,
bookElementId,
bookElementStyle,
commonGroup,
distributableCount,
distributeElements,
expandToGroups,
gatherGroups,
groupElements,
groupMembers,
isWholeGroup,
pastableStyle,
regroupCopies,
renameElement,
renameGroup,
setElementsHidden,
tidyGroups,
ungroupElements,
withCopies,
withoutGroup,
} from '@trek/shared';
/**
* Groups, layers and the editor's productivity actions (journey-studio WP6).
*
* The geometry and the group rules are the book engine's own
* (shared/src/book/book-groups.ts, book-arrange.ts, book-style-copy.ts): the
* `group`, `align`, `distribute`, `arrange` and `style` operations the
* assistant sends run the same functions, so a press here and an assistant's
* change move a page the same way. What lives here is only how the Studio
* holds them: which page, what is selected, and one undo step per action.
*/
/** The part of the Studio store these actions read and write. */
interface Host {
doc: BookDocument | null;
selection: string[];
enteredGroup: string | null;
styleClip: BookCopiedStyle | null;
commit: (fn: (doc: BookDocument) => BookDocument) => void;
apply: (fn: (doc: BookDocument) => BookDocument) => void;
}
type Set = (partial: Partial<Host> | ((s: Host) => Partial<Host>)) => void;
type Get = () => Host;
export interface StudioLayerActions {
/**
* The group the user has stepped into with a double-click, so a click picks
* one member rather than the whole group. Null outside every group.
*/
enteredGroup: string | null;
/** A copied look, for paste style (Ctrl+Alt+C, Ctrl+Alt+V). Kept across pages. */
styleClip: BookCopiedStyle | null;
/**
* What a click on an element selects: its whole group, or the element alone
* inside the group stepped into. `additive` adds it to the selection, or takes
* it out when it is all in already.
*/
pick: (spreadIndex: number, id: string, additive: boolean) => void;
/** A whole group selected, from its row in the layers panel; `additive` adds it. */
selectGroup: (spreadIndex: number, groupId: string, additive: boolean) => void;
/** Step into an element's group and select the element alone (a double-click). */
enterGroup: (spreadIndex: number, id: string) => void;
/** Step out of the group again, with the whole group selected (Escape). Answers whether it did. */
leaveGroup: (spreadIndex: number) => boolean;
/** Every element on the page that is not hidden (Ctrl+A). */
selectAll: (spreadIndex: number) => void;
/** Move the unlocked part of the selection by millimetres, inside an open gesture (arrow keys). */
nudge: (spreadIndex: number, dx: number, dy: number) => void;
/** The selection, one group (Ctrl+G). Locked elements stay out of it. */
group: (spreadIndex: number) => void;
/** Every group in the selection back to its members (Ctrl+Shift+G). */
ungroup: (spreadIndex: number) => void;
/** The selection lined up; locked elements stay where they are. */
align: (spreadIndex: number, edge: BookAlignEdge, to: BookAlignTo) => void;
/** The selection spaced evenly, three elements or groups at least. */
distribute: (spreadIndex: number, axis: BookDistributeAxis) => void;
/** Elements, or whole groups, moved in the paint order. */
raise: (spreadIndex: number, ids: string | readonly string[], to: BookArrangeTo | 'up' | 'down') => void;
/**
* Elements dragged to another place in the layers list: in front of
* (`above`) or behind (`below`) the anchor. Dropped onto a member of a group
* they join that group; dropped beside a group or an ungrouped element they
* are on their own.
*/
restack: (
spreadIndex: number,
ids: readonly string[],
anchorId: string,
where: 'above' | 'below',
join: boolean
) => void;
setHidden: (spreadIndex: number, ids: readonly string[], hidden: boolean) => void;
setLocked: (spreadIndex: number, ids: readonly string[], locked: boolean) => void;
renameLayer: (spreadIndex: number, target: { elementId: string } | { groupId: string }, name: string) => void;
/** Copy the look of the first selected element. */
copyStyle: (spreadIndex: number) => void;
/** Paste the copied look onto the selection, what each kind can take, as one undo step. */
pasteStyle: (spreadIndex: number) => void;
/** Copies of the selection beside it, groups copied as new groups, selected. */
duplicate: (spreadIndex: number, ids: readonly string[]) => void;
}
function onSpread(doc: BookDocument, index: number, fn: (s: BookSpread) => BookSpread): BookDocument {
const spread = doc.spreads[index];
if (!spread) return doc;
const next = fn(spread);
if (next === spread) return doc;
const spreads = doc.spreads.slice();
spreads[index] = next;
return { ...doc, spreads };
}
const spreadOf = (get: Get, index: number) => get().doc?.spreads[index];
/** The selected elements on a spread that may change: on the page and unlocked. */
function movable(spread: BookSpread, ids: readonly string[]): BookElement[] {
return spread.elements.filter((e) => ids.includes(e.id) && !e.locked);
}
/** The element `id` selects with a click, given the group stepped into. */
function clickTarget(spread: BookSpread, id: string, entered: string | null): string[] {
const el = spread.elements.find((e) => e.id === id);
if (!el) return [];
return el.group && el.group === entered ? [id] : expandToGroups(spread, [id]);
}
const OFFSET = 4;
/** What a click, a double-click, a row or Ctrl+A selects. */
function selectionActions(
set: Set,
get: Get
): Pick<StudioLayerActions, 'pick' | 'selectGroup' | 'enterGroup' | 'leaveGroup' | 'selectAll'> {
return {
pick: (spreadIndex, id, additive) => {
const spread = spreadOf(get, spreadIndex);
if (!spread) return;
const { selection, enteredGroup } = get();
const target = clickTarget(spread, id, enteredGroup);
const el = spread.elements.find((e) => e.id === id);
// A click outside the group stepped into steps out of it.
const entered = el?.group && el.group === enteredGroup ? enteredGroup : null;
if (additive) {
const all = target.every((t) => selection.includes(t));
const next = all
? selection.filter((s) => !target.includes(s))
: [...selection, ...target.filter((t) => !selection.includes(t))];
set({ selection: next, enteredGroup: entered });
return;
}
// Pressing on what is already selected keeps it, so a multi-selection can be dragged.
if (target.every((t) => selection.includes(t))) {
set({ enteredGroup: entered });
return;
}
set({ selection: target, enteredGroup: entered });
},
selectGroup: (spreadIndex, groupId, additive) => {
const spread = spreadOf(get, spreadIndex);
if (!spread) return;
const ids = groupMembers(spread, groupId).map((e) => e.id);
const selection = get().selection;
set({ selection: additive ? [...selection.filter((s) => !ids.includes(s)), ...ids] : ids, enteredGroup: null });
},
enterGroup: (spreadIndex, id) => {
const el = spreadOf(get, spreadIndex)?.elements.find((e) => e.id === id);
if (!el?.group) return;
set({ selection: [id], enteredGroup: el.group });
},
leaveGroup: (spreadIndex) => {
const spread = spreadOf(get, spreadIndex);
const { enteredGroup } = get();
if (!spread || !enteredGroup) return false;
set({ selection: expandToGroups(spread, get().selection), enteredGroup: null });
return true;
},
selectAll: (spreadIndex) => {
const spread = spreadOf(get, spreadIndex);
if (!spread) return;
set({ selection: spread.elements.filter((e) => !e.hidden).map((e) => e.id), enteredGroup: null });
},
};
}
/** Moving, grouping, lining up and restacking. */
function arrangeActions(
set: Set,
get: Get
): Pick<StudioLayerActions, 'nudge' | 'group' | 'ungroup' | 'align' | 'distribute' | 'raise' | 'restack'> {
const commitOn = (index: number, fn: (s: BookSpread) => BookSpread) =>
get().commit((doc) => onSpread(doc, index, fn));
return {
nudge: (spreadIndex, dx, dy) => {
const ids = get().selection;
get().apply((doc) =>
onSpread(doc, spreadIndex, (sp) => {
const moving = new Set(movable(sp, ids).map((e) => e.id));
if (!moving.size) return sp;
const round = (v: number) => Math.round(v * 100) / 100;
const elements = sp.elements.map((e) =>
moving.has(e.id) ? { ...e, frame: { ...e.frame, x: round(e.frame.x + dx), y: round(e.frame.y + dy) } } : e
);
return { ...sp, elements };
})
);
},
group: (spreadIndex) => {
const spread = spreadOf(get, spreadIndex);
if (!spread) return;
const ids = movable(spread, get().selection).map((e) => e.id);
if (ids.length < 2 || isWholeGroup(spread, ids)) return;
const groupId = bookElementId('g');
commitOn(spreadIndex, (sp) => groupElements(sp, ids, groupId));
set({ selection: ids, enteredGroup: null });
},
ungroup: (spreadIndex) => {
const spread = spreadOf(get, spreadIndex);
if (!spread) return;
const selection = get().selection;
const groups = new Set(
spread.elements.filter((e) => selection.includes(e.id) && e.group).map((e) => e.group as string)
);
if (!groups.size) return;
commitOn(spreadIndex, (sp) => [...groups].reduce((acc, g) => ungroupElements(acc, g), sp));
set({ enteredGroup: null });
},
align: (spreadIndex, edge, to) => {
const spread = spreadOf(get, spreadIndex);
if (!spread) return;
const els = movable(spread, get().selection);
if (!els.length) return;
const doc = get().doc;
if (!doc) return;
commitOn(spreadIndex, (sp) => alignElements(sp, doc.page, els, edge, to));
},
distribute: (spreadIndex, axis) => {
const spread = spreadOf(get, spreadIndex);
if (!spread) return;
const els = movable(spread, get().selection);
if (distributableCount(spread, els) < 3) return;
commitOn(spreadIndex, (sp) => distributeElements(sp, els, axis));
},
raise: (spreadIndex, ids, to) => {
const list = typeof ids === 'string' ? [ids] : ids;
const spread = spreadOf(get, spreadIndex);
if (!spread || !list.length) return;
const step: BookArrangeTo = to === 'up' ? 'forward' : to === 'down' ? 'backward' : to;
// A group member moved alone inside its group; anything else takes its whole group along.
const entered = get().enteredGroup;
if (entered !== null && commonGroup(spread, list) === entered) {
commitOn(spreadIndex, (sp) => arrangeWithinGroup(sp, entered, list, step));
return;
}
const chosen = expandToGroups(spread, list);
commitOn(spreadIndex, (sp) => {
const next = arrangeElements(sp, chosen, step);
return next.elements.every((e, i) => e === sp.elements[i]) ? sp : next;
});
},
restack: (spreadIndex, ids, anchorId, where, join) => {
const spread = spreadOf(get, spreadIndex);
const anchor = spread?.elements.find((e) => e.id === anchorId);
if (!spread || !anchor || ids.includes(anchorId)) return;
commitOn(spreadIndex, (sp) => {
const moving = sp.elements.filter((e) => ids.includes(e.id));
const rest = sp.elements.filter((e) => !ids.includes(e.id));
const at = rest.findIndex((e) => e.id === anchorId) + (where === 'above' ? 1 : 0);
// A whole group dragged keeps itself together; a member dragged alone joins or leaves.
const whole = new Set(
moving
.filter((e) => e.group && groupMembers(sp, e.group).every((m) => ids.includes(m.id)))
.map((e) => e.group)
);
const placed = moving.map((e) => {
if (join && anchor.group) return { ...e, group: anchor.group };
if (!e.group || whole.has(e.group)) return e;
return withoutGroup(e);
});
const elements = [...rest.slice(0, at), ...placed, ...rest.slice(at)];
return tidyGroups(gatherGroups({ ...sp, elements }));
});
},
};
}
/** The layers panel's toggles and names, the style clipboard and duplicate. */
function panelActions(
set: Set,
get: Get
): Pick<StudioLayerActions, 'setHidden' | 'setLocked' | 'renameLayer' | 'copyStyle' | 'pasteStyle' | 'duplicate'> {
const commitOn = (index: number, fn: (s: BookSpread) => BookSpread) =>
get().commit((doc) => onSpread(doc, index, fn));
return {
setHidden: (spreadIndex, ids, hidden) => {
commitOn(spreadIndex, (sp) => setElementsHidden(sp, ids, hidden));
// A hidden element cannot be seen to be selected; it leaves the selection.
if (hidden) set((s) => ({ selection: s.selection.filter((id) => !ids.includes(id)) }));
},
setLocked: (spreadIndex, ids, locked) =>
commitOn(spreadIndex, (sp) => ({
...sp,
elements: sp.elements.map((e) => (ids.includes(e.id) ? { ...e, locked } : e)),
})),
renameLayer: (spreadIndex, target, name) =>
commitOn(spreadIndex, (sp) =>
'groupId' in target ? renameGroup(sp, target.groupId, name) : renameElement(sp, target.elementId, name)
),
copyStyle: (spreadIndex) => {
const spread = spreadOf(get, spreadIndex);
const el = spread?.elements.find((e) => get().selection.includes(e.id));
if (el) set({ styleClip: bookElementStyle(el) });
},
pasteStyle: (spreadIndex) => {
const { styleClip, selection } = get();
const spread = spreadOf(get, spreadIndex);
if (!styleClip || !spread) return;
const targets = movable(spread, selection);
if (!targets.length) return;
commitOn(spreadIndex, (sp) => {
let changed = false;
const elements = sp.elements.map((e) => {
if (!targets.some((t) => t.id === e.id)) return e;
const look = pastableStyle(styleClip, e.kind);
if (!look) return e;
changed = true;
const props = Object.fromEntries(Object.entries(look).filter(([k]) => k !== 'kind'));
return { ...e, ...props } as BookElement;
});
return changed ? { ...sp, elements } : sp;
});
},
duplicate: (spreadIndex, ids) => {
const spread = spreadOf(get, spreadIndex);
if (!spread) return;
const doc = get().doc;
if (!doc) return;
const chosen = spread.elements.filter((e) => ids.includes(e.id));
// The page's own cap: copies that would make the book unsaveable are not made.
if (!chosen.length || spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) return;
const copies = chosen.map(
(e) =>
({
...e,
id: bookElementId(e.kind.charAt(0)),
frame: { ...e.frame, x: e.frame.x + OFFSET, y: e.frame.y + OFFSET },
}) as BookElement
);
const regrouped = regroupCopies(copies, spread, bookElementId);
commitOn(spreadIndex, (sp) => withCopies(sp, regrouped));
set({ selection: regrouped.elements.map((e) => e.id), enteredGroup: null });
},
};
}
export function studioLayerActions(set: Set, get: Get): StudioLayerActions {
return {
enteredGroup: null,
styleClip: null,
...selectionActions(set, get),
...arrangeActions(set, get),
...panelActions(set, get),
};
}
+21 -46
View File
@@ -5,6 +5,8 @@ import {
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, innerSpreadBounds as innerBounds,
} from '@trek/shared'
import { pastedElements, type StudioClipboard } from '../components/Studio/studioClipboard'
import { regroupCopies, tidyGroups, withCopies, bookElementId } from '@trek/shared'
import { studioLayerActions, type StudioLayerActions } from './studioLayers'
/**
* The book being edited.
@@ -22,7 +24,7 @@ import { pastedElements, type StudioClipboard } from '../components/Studio/studi
const HISTORY_LIMIT = 60
interface StudioState {
interface StudioState extends StudioLayerActions {
doc: BookDocument | null
selection: string[]
activeSpread: number
@@ -86,8 +88,6 @@ interface StudioState {
setRotation: (spreadIndex: number, id: string, rotation: number) => void
addElement: (spreadIndex: number, el: BookElement) => void
removeElements: (spreadIndex: number, ids: string[]) => void
duplicate: (spreadIndex: number, ids: string[]) => void
raise: (spreadIndex: number, id: string, to: 'front' | 'back' | 'up' | 'down') => void
/** Copy elements of a spread to the clipboard (#2316). */
copy: (spreadIndex: number, ids: string[]) => void
/** Put the clipboard onto a spread, as one undo step, and select what arrived. */
@@ -136,6 +136,8 @@ function replaceSpread(doc: BookDocument, index: number, fn: (s: BookSpread) =>
}
export const useStudioStore = create<StudioState>((set, get) => ({
// Groups, layers, align, nudge, duplicate and paste style: studioLayers.ts.
...studioLayerActions(set, get),
doc: null,
selection: [],
activeSpread: 0,
@@ -147,7 +149,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
readOnly: false,
setReadOnly: readOnly => set({ readOnly, gestureBase: null }),
load: doc => set({ doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null, hintSpread: null }),
load: doc => set({ doc, selection: [], enteredGroup: null, activeSpread: 0, past: [], future: [], gestureBase: null, hintSpread: null }),
adoptRemote: (doc, opts) => set(s => {
if (!s.doc) return { doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null }
if (doc === s.doc) return {}
@@ -163,6 +165,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
doc,
activeSpread,
selection: found >= 0 ? s.selection.filter(id => onPage.has(id)) : [],
enteredGroup: found >= 0 ? s.enteredGroup : null,
past: opts?.undoable === false ? [] : [...s.past, replaced].slice(-HISTORY_LIMIT),
// A new branch: redoing what was undone before this arrived would write over it.
future: [],
@@ -171,9 +174,10 @@ export const useStudioStore = create<StudioState>((set, get) => ({
gestureBase: s.gestureBase ? doc : null,
}
}),
setActiveSpread: i => set({ activeSpread: i, selection: [] }),
setActiveSpread: i => set({ activeSpread: i, selection: [], enteredGroup: null }),
setHintSpread: spreadId => set({ hintSpread: spreadId }),
select: ids => set({ selection: ids }),
// Nothing selected is outside every group too.
select: ids => set(ids.length ? { selection: ids } : { selection: ids, enteredGroup: null }),
toggleSelect: (id, additive) => set(s => {
if (!additive) return { selection: [id] }
return { selection: s.selection.includes(id) ? s.selection.filter(x => x !== id) : [...s.selection, id] }
@@ -232,45 +236,14 @@ export const useStudioStore = create<StudioState>((set, get) => ({
},
removeElements: (spreadIndex, ids) => {
get().commit(doc => replaceSpread(doc, spreadIndex, sp => ({
// A group left with one member is no group any more.
get().commit(doc => replaceSpread(doc, spreadIndex, sp => tidyGroups({
...sp,
elements: sp.elements.filter(e => !ids.includes(e.id)),
})))
set({ selection: [] })
set({ selection: [], enteredGroup: null })
},
duplicate: (spreadIndex, ids) => {
// Offset the copy so it is visibly a second thing rather than an exact
// overlay you cannot tell apart from the original.
const OFFSET = 4
const made: string[] = []
get().commit(doc => replaceSpread(doc, spreadIndex, sp => {
const copies = sp.elements
.filter(e => ids.includes(e.id))
.map(e => {
const id = `${e.kind[0]}-${Math.random().toString(36).slice(2, 9)}`
made.push(id)
return { ...e, id, frame: { ...e.frame, x: e.frame.x + OFFSET, y: e.frame.y + OFFSET } }
})
return { ...sp, elements: [...sp.elements, ...copies] }
}))
if (made.length) set({ selection: made })
},
raise: (spreadIndex, id, to) => get().commit(doc =>
replaceSpread(doc, spreadIndex, sp => {
const i = sp.elements.findIndex(e => e.id === id)
if (i < 0) return sp
const els = sp.elements.slice()
const [el] = els.splice(i, 1)
const at = to === 'front' ? els.length
: to === 'back' ? 0
: to === 'up' ? Math.min(els.length, i + 1)
: Math.max(0, i - 1)
els.splice(at, 0, el)
return { ...sp, elements: els }
})),
copy: (spreadIndex, ids) => {
const sp = get().doc?.spreads[spreadIndex]
if (!sp) return
@@ -285,10 +258,12 @@ export const useStudioStore = create<StudioState>((set, get) => ({
// The spread's own cap: a paste that would push it past what the contract
// accepts takes what fits rather than making the book unsaveable.
const room = Math.max(0, MAX_SPREAD_ELEMENTS - target.elements.length)
const copies = pastedElements(clipboard, target, doc.page).slice(0, room)
if (!copies.length) return
get().commit(d => replaceSpread(d, spreadIndex, sp => ({ ...sp, elements: [...sp.elements, ...copies] })))
set({ selection: copies.map(e => e.id) })
const source = doc.spreads.find(sp => sp.id === clipboard.fromSpreadId)
// A copied group arrives as a group of its own, never as part of one already on the page.
const copies = regroupCopies(pastedElements(clipboard, target, doc.page).slice(0, room), source, bookElementId)
if (!copies.elements.length) return
get().commit(d => replaceSpread(d, spreadIndex, sp => withCopies(sp, copies)))
set({ selection: copies.elements.map(e => e.id), enteredGroup: null })
},
saveLayout: (spreadIndex, name) => {
@@ -432,13 +407,13 @@ export const useStudioStore = create<StudioState>((set, get) => ({
undo: () => set(s => {
const prev = s.past[s.past.length - 1]
if (!prev || !s.doc || s.readOnly) return {}
return { doc: prev, past: s.past.slice(0, -1), future: [s.doc, ...s.future].slice(0, HISTORY_LIMIT), selection: [] }
return { doc: prev, past: s.past.slice(0, -1), future: [s.doc, ...s.future].slice(0, HISTORY_LIMIT), selection: [], enteredGroup: null }
}),
redo: () => set(s => {
const next = s.future[0]
if (!next || !s.doc || s.readOnly) return {}
return { doc: next, future: s.future.slice(1), past: [...s.past, s.doc].slice(-HISTORY_LIMIT), selection: [] }
return { doc: next, future: s.future.slice(1), past: [...s.past, s.doc].slice(-HISTORY_LIMIT), selection: [], enteredGroup: null }
}),
canUndo: () => !get().readOnly && get().past.length > 0,
+286
View File
@@ -0,0 +1,286 @@
/* ============================================================================
TREK Studio: groups, multi-selection and the layers panel.
Beside studio.css rather than in it, which has reached its size budget. The
same rules apply: every selector is scoped under `.trek-dash`, colours come
from the dashboard tokens, and selection chrome is drawn in screen pixels
above the page, never on it.
============================================================================ */
/* ----------------- multi-selection on the sheet ----------------- */
/*
* A member of a multi-selection: a single quiet ring, so the box around all
* of them is the one thing that reads as "this is what you are holding".
*/
.trek-dash .st-select.is-member {
box-shadow:
0 0 0 1px color-mix(in oklch, var(--accent) 70%, transparent),
0 0 0 2px rgba(255, 255, 255, 0.7); /* the light ring keeps the line findable on a dark photo */
}
/* The one element picked inside a group stands out against its dashed box. */
.trek-dash .st-select.is-inside:not(.is-member) {
box-shadow:
0 0 0 1.5px var(--accent),
0 0 0 2.5px rgba(255, 255, 255, 0.92),
0 0 0 6px color-mix(in oklch, var(--accent) 18%, transparent);
}
.trek-dash .st-select-box {
position: absolute;
pointer-events: none;
border-radius: 1px;
box-shadow:
0 0 0 1.5px var(--accent),
0 0 0 2.5px rgba(255, 255, 255, 0.92);
}
/* A whole group: dashed, so it reads as a group before anything is done to it. */
.trek-dash .st-select-box.is-group {
box-shadow: 0 0 0 2.5px rgba(255, 255, 255, 0.85);
outline: 1.5px dashed var(--accent);
outline-offset: 0;
}
.trek-dash .st-marquee {
position: absolute;
pointer-events: none;
border: 1px solid var(--accent);
background: color-mix(in oklch, var(--accent) 10%, transparent);
border-radius: 1px;
}
/* ----------------- inspector: align and arrange ----------------- */
.trek-dash .st-row-sep {
width: 1px;
height: 18px;
align-self: center;
margin: 0 3px;
background: var(--line-2);
}
.trek-dash .st-arrange-to {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.trek-dash .st-arrange-to .st-line-label {
width: auto;
}
.trek-dash .st-arrange-acts {
margin-top: 10px;
}
.trek-dash .st-section-body > .st-arrange-hint {
padding: 8px 0 0;
}
.trek-dash .st-chip:disabled {
opacity: 0.4;
cursor: default;
background: transparent;
}
/* ----------------- the layers panel ----------------- */
.trek-dash .st-layers-scroll {
flex: 1;
}
.trek-dash .st-layers-hint {
padding: 0 2px 8px;
}
.trek-dash .st-layers {
display: flex;
flex-direction: column;
gap: 1px;
user-select: none;
-webkit-user-select: none;
}
.trek-dash .st-layers.is-dragging {
cursor: grabbing;
}
.trek-dash .st-layer {
position: relative;
display: flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding-inline: 4px 2px;
border-radius: 8px;
color: var(--ink-2);
font-size: calc(12.5px * var(--fs-scale-caption, 1));
cursor: default;
transition:
background 0.12s,
color 0.12s,
opacity 0.12s;
}
.trek-dash .st-layer:hover {
background: color-mix(in oklch, var(--ink) 6%, transparent);
color: var(--ink);
}
.trek-dash .st-layer.is-on {
background: color-mix(in oklch, var(--accent) 13%, transparent);
color: var(--ink);
}
.trek-dash .st-layer.is-inside {
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--accent) 45%, transparent);
}
.trek-dash .st-layer.is-group {
font-weight: 600;
color: var(--ink);
}
.trek-dash .st-layer.is-member {
padding-inline-start: 22px;
}
/* The line that joins a group's members to its row. */
.trek-dash .st-layer.is-member::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: 13px;
width: 1px;
background: var(--line-2);
}
/* Dimmed, not removed: this panel is the way back to a hidden layer. */
.trek-dash .st-layer.is-hidden .st-layer-name,
.trek-dash .st-layer.is-hidden .st-layer-icon {
opacity: 0.45;
}
.trek-dash .st-layer.is-dragged {
opacity: 0.4;
}
/* Where the drop lands: a line on the edge it goes to. */
.trek-dash .st-layer.is-drop-above::after,
.trek-dash .st-layer.is-drop-below::after {
content: '';
position: absolute;
inset-inline: 4px;
height: 2px;
border-radius: 2px;
background: var(--accent);
pointer-events: none;
}
.trek-dash .st-layer.is-drop-above::after {
top: -1.5px;
}
.trek-dash .st-layer.is-drop-below::after {
bottom: -1.5px;
}
.trek-dash .st-layer-grip {
flex: none;
color: var(--ink-3);
opacity: 0;
cursor: grab;
transition: opacity 0.12s;
}
.trek-dash .st-layer:hover .st-layer-grip {
opacity: 0.8;
}
.trek-dash .st-layer-caret {
flex: none;
display: grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 5px;
color: var(--ink-3);
transition:
transform 0.16s cubic-bezier(0.23, 1, 0.32, 1),
background 0.12s;
}
.trek-dash .st-layer-caret:hover {
background: color-mix(in oklch, var(--ink) 9%, transparent);
color: var(--ink);
}
.trek-dash .st-layer-caret.is-open {
transform: rotate(90deg);
}
[dir='rtl'] .trek-dash .st-layer-caret:not(.is-open) {
transform: scaleX(-1);
}
.trek-dash .st-layer-icon {
flex: none;
color: var(--ink-3);
}
.trek-dash .st-layer.is-on .st-layer-icon {
color: var(--accent);
}
.trek-dash .st-layer-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trek-dash .st-layer-name.is-auto {
font-weight: inherit;
}
.trek-dash .st-layer-input {
flex: 1;
min-width: 0;
height: 24px;
padding: 0 7px;
font-size: inherit;
}
.trek-dash .st-layer-count {
flex: none;
font-size: calc(11px * var(--fs-scale-caption, 1));
font-weight: 500;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.trek-dash .st-layer-acts {
flex: none;
display: flex;
align-items: center;
gap: 0;
}
.trek-dash .st-layer-acts .st-act {
width: 24px;
height: 24px;
opacity: 0;
transition:
opacity 0.12s,
background 0.12s,
color 0.12s;
}
/* Shown on hover and while focused, and always when on, so a locked or hidden layer reads at a glance. */
.trek-dash .st-layer:hover .st-layer-acts .st-act,
.trek-dash .st-layer-acts .st-act:focus-visible,
.trek-dash .st-layer-acts .st-act.is-kept {
opacity: 1;
}
.trek-dash .st-layers-foot {
flex: none;
display: flex;
gap: 6px;
padding: 10px 12px 12px;
border-top: 1px solid var(--line);
}
.trek-dash .st-layers-foot .st-chip {
flex: 1;
justify-content: center;
gap: 6px;
}
@media (prefers-reduced-motion: reduce) {
.trek-dash .st-layer,
.trek-dash .st-layer-caret,
.trek-dash .st-layer-acts .st-act {
transition: none;
}
}
/* Touch has no hover: the row's tools stay in view. */
@media (hover: none) {
.trek-dash .st-layer-acts .st-act,
.trek-dash .st-layer-grip {
opacity: 1;
}
}