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
@@ -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']);
});
});