mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user