mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02a12ce918 | |||
| 2f4644d434 |
@@ -53,7 +53,7 @@
|
||||
"components/Studio/PageNumbers.tsx": 1,
|
||||
"components/Studio/PeerCursors.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 8,
|
||||
"components/Studio/StudioCanvas.tsx": 22,
|
||||
"components/Studio/StudioCanvas.tsx": 12,
|
||||
"components/Studio/StudioElementsPanel.tsx": 1,
|
||||
"components/Studio/StudioExport.tsx": 1,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1,
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
"src/components/Settings/NotificationsTab.test.tsx": 1,
|
||||
"src/components/Settings/PasskeysSection.tsx": 1,
|
||||
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
|
||||
"src/components/Studio/StudioCanvas.tsx": 1,
|
||||
"src/components/Studio/StudioInspector.tsx": 1,
|
||||
"src/components/Todo/todoListModel.ts": 1,
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||
|
||||
@@ -485,7 +485,8 @@ export function SpreadView({
|
||||
overflow: print ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{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'
|
||||
@@ -6,11 +6,14 @@ import { PeerCursors } from './PeerCursors'
|
||||
import type { PeerCursor } from './useBookPresence'
|
||||
import { bookElementId as elementId } from '@trek/shared'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
|
||||
import { useSpreadInteraction } 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'
|
||||
|
||||
const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
|
||||
|
||||
/**
|
||||
* What a delete may actually take out of the selection.
|
||||
@@ -37,17 +40,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.
|
||||
*
|
||||
@@ -97,10 +89,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)
|
||||
@@ -140,45 +131,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
|
||||
@@ -271,7 +242,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} />
|
||||
|
||||
@@ -281,11 +253,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`,
|
||||
@@ -379,7 +355,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,
|
||||
@@ -419,17 +395,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
|
||||
@@ -450,12 +431,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
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Choice, Line, NumField, Section, Switch } from './StudioControls'
|
||||
import { FontButtons, WeightLine } from './StudioTypeControls'
|
||||
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
|
||||
@@ -233,7 +235,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">
|
||||
@@ -691,18 +693,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', () => {
|
||||
store().select(['a', 't']);
|
||||
render(<StudioLayersPanel t={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, Trash2, Upload, X,
|
||||
} from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { PanelHead as Head } from './StudioPanelHead'
|
||||
import { StudioElementsPanel } from './StudioElementsPanel'
|
||||
import { StudioLayersPanel } from './StudioLayersPanel'
|
||||
import { StudioTravelPanel } from './StudioTravelPanel'
|
||||
import { StudioSavedLayouts } from './StudioSavedLayouts'
|
||||
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
|
||||
@@ -39,7 +40,7 @@ import type { StudioUploader } from './studioUpload'
|
||||
* costs 52px and gives the panel the room a photo browser actually needs.
|
||||
*/
|
||||
|
||||
type Section = 'pages' | 'content' | 'elements' | 'travel' | 'templates'
|
||||
type Section = 'pages' | 'content' | 'elements' | 'travel' | 'templates' | 'layers'
|
||||
|
||||
export interface JourneySource {
|
||||
entries: {
|
||||
@@ -120,6 +121,7 @@ export function StudioSidebar({
|
||||
{ id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' },
|
||||
{ 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 (
|
||||
@@ -157,6 +159,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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { BookDocument, BookRecord } from '@trek/shared'
|
||||
import { BOOK_SCHEMA_REV } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Autosaving a Studio book (#1973).
|
||||
@@ -852,12 +853,14 @@ describe('a book a newer Studio saved', () => {
|
||||
})
|
||||
|
||||
/* A book of today goes out exactly as it went out before the revision existed. */
|
||||
it('FE-STUDIO-REV-006: sends a book of today without a revision in it', async () => {
|
||||
it('FE-STUDIO-REV-006: stamps a book with the revision this Studio writes, and none while that is 1', async () => {
|
||||
const { result } = await mount()
|
||||
|
||||
act(() => { result.current.queueSave(doc('a'), 'T') })
|
||||
await tick()
|
||||
|
||||
expect(api.saveBook.mock.calls[0][1].document).not.toHaveProperty('schemaRev')
|
||||
const sent = api.saveBook.mock.calls[0][1].document
|
||||
if (BOOK_SCHEMA_REV > 1) expect(sent.schemaRev).toBe(BOOK_SCHEMA_REV)
|
||||
else expect(sent).not.toHaveProperty('schemaRev')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -177,7 +177,7 @@ export class JourneyBookMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'edit_book_page',
|
||||
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, fonts, strokes and the like), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures) and add_grid (a photo grid as empty frames in a box). A locked element refuses any operation that would change it but lock. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, fonts, strokes and the like), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures), add_grid (a photo grid as empty frames in a box), group and ungroup (two or more elements picked, moved, aligned and arranged as one, such as a title lockup; align and distribute move a group as one box), name (an element or a group, as the layers panel shows it) and hide (left off the page and out of print, or shown again). A locked element refuses any operation that would change it but lock, ungroup, name and hide. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...editBookPageArgs },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: journeyAddonOn,
|
||||
|
||||
@@ -28,11 +28,14 @@ import { MikroORM } from '@mikro-orm/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { bookWriteResultSchema } from '@trek/shared';
|
||||
import { BOOK_SCHEMA_REV, bookWriteResultSchema } from '@trek/shared';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
/** A format revision newer than this build reads. */
|
||||
const NEWER = BOOK_SCHEMA_REV + 1;
|
||||
|
||||
const BOOK_TOOLS = [
|
||||
'get_book',
|
||||
'describe_book_page',
|
||||
@@ -209,7 +212,7 @@ describe('Studio book over MCP and REST', () => {
|
||||
.get(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
const row = await findRow(orm, JourneyBooks, { journey: journey.id });
|
||||
const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: 2 });
|
||||
const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: NEWER });
|
||||
await updateRows(orm, JourneyBooks, { journey: journey.id }, { document: newer });
|
||||
|
||||
const saved = await request(app.getHttpServer())
|
||||
@@ -220,7 +223,7 @@ describe('Studio book over MCP and REST', () => {
|
||||
expect(saved.body).toEqual({
|
||||
error: 'Book was saved by a newer version of TREK Studio',
|
||||
code: 'book_schema_newer',
|
||||
schemaRev: 2,
|
||||
schemaRev: NEWER,
|
||||
});
|
||||
|
||||
const tool = await callTool(owner.id, 'edit_book_page', {
|
||||
@@ -249,12 +252,12 @@ describe('Studio book over MCP and REST', () => {
|
||||
const saved = await request(app.getHttpServer())
|
||||
.put(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ title: before.body.book.title, document: { ...document, schemaRev: 2 }, baseVersion: 1 });
|
||||
.send({ title: before.body.book.title, document: { ...document, schemaRev: NEWER }, baseVersion: 1 });
|
||||
expect(saved.status).toBe(409);
|
||||
expect(saved.body).toEqual({
|
||||
error: 'Book was saved by a newer version of TREK Studio',
|
||||
code: 'book_schema_newer',
|
||||
schemaRev: 2,
|
||||
schemaRev: NEWER,
|
||||
});
|
||||
expect(sent()).toEqual([]);
|
||||
const after = await findRow(orm, JourneyBooks, { journey: journey.id });
|
||||
|
||||
@@ -31,10 +31,13 @@ import {
|
||||
import { createTestJourneyBooksRepo } from '../../helpers/journey-share-repos';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow';
|
||||
import { normalizeBookDocument } from '@trek/shared';
|
||||
import { BOOK_SCHEMA_REV, normalizeBookDocument } from '@trek/shared';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
/** A format revision newer than this build reads. */
|
||||
const NEWER = BOOK_SCHEMA_REV + 1;
|
||||
|
||||
vi.mock('../../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
||||
const db = createSnapshotTestDb();
|
||||
@@ -563,7 +566,12 @@ describe('editBook', () => {
|
||||
|
||||
const [tool, studio] = await Promise.all([
|
||||
catchDomainError(() => books.editBook(journey.id, user.id, edited('tool'), { expectedVersion: 1 })),
|
||||
books.saveBook(journey.id, user.id, { title: 'T', document: doc('studio'), baseVersion: 1 }),
|
||||
// The Studio stamps what it saves with its revision, as the book tools do (useBookStore).
|
||||
books.saveBook(journey.id, user.id, {
|
||||
title: 'T',
|
||||
document: { ...doc('studio'), schemaRev: BOOK_SCHEMA_REV },
|
||||
baseVersion: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
const toolLost = tool instanceof DomainError && tool.getStatus() === 409;
|
||||
@@ -646,7 +654,7 @@ describe('format revisions', () => {
|
||||
thrown instanceof DomainError ? [thrown.getStatus(), thrown.code, thrown.toBody().code] : thrown;
|
||||
|
||||
it('JBOOK-011: refuses a save from a writer older than the stored book with 409 and the code, writing and sending nothing', async () => {
|
||||
const { user, journey } = await storedAt(2);
|
||||
const { user, journey } = await storedAt(NEWER);
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const thrown = await catchDomainError(() =>
|
||||
@@ -655,11 +663,11 @@ describe('format revisions', () => {
|
||||
expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
|
||||
expect(thrown instanceof DomainError && thrown.toBody()).toMatchObject({
|
||||
error: 'Book was saved by a newer version of TREK Studio',
|
||||
schemaRev: 2,
|
||||
schemaRev: NEWER,
|
||||
});
|
||||
expect(rec.sent).toEqual([]);
|
||||
const stored = await books.getBook(journey.id, user.id);
|
||||
expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', 2]);
|
||||
expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', NEWER]);
|
||||
} finally {
|
||||
rec.restore();
|
||||
}
|
||||
@@ -697,7 +705,7 @@ describe('format revisions', () => {
|
||||
});
|
||||
|
||||
it('JBOOK-014: the book tools leave a book this server cannot read whole alone, before any edit runs', async () => {
|
||||
const { user, journey } = await storedAt(2);
|
||||
const { user, journey } = await storedAt(NEWER);
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const edit = vi.fn(() => ({ title: 'T', document: normalizeBookDocument(doc('tool')), result: null }));
|
||||
@@ -716,7 +724,7 @@ describe('format revisions', () => {
|
||||
const { user, journey } = await storedAt(1);
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: 2 });
|
||||
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: NEWER });
|
||||
const thrown = await catchDomainError(() =>
|
||||
books.editBook(journey.id, user.id, () => ({ title: 'T', document: ahead, result: null })),
|
||||
);
|
||||
|
||||
@@ -34,7 +34,12 @@ import { makeUser } from '../../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { bookDesignAnswerSchema, bookDesignApplyResultSchema, type BookDesignAnswer } from '@trek/shared';
|
||||
import {
|
||||
BOOK_SCHEMA_REV,
|
||||
bookDesignAnswerSchema,
|
||||
bookDesignApplyResultSchema,
|
||||
type BookDesignAnswer,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -327,7 +332,7 @@ describe('taking a design back', () => {
|
||||
const applied = await designs.apply(journey.id, owner.id, { designId: answer.design_id });
|
||||
const revisionId = applied.revision_id ?? 0;
|
||||
const kept = await findRow(orm, JourneyBookRevisions, { id: revisionId });
|
||||
const newer = JSON.stringify({ ...JSON.parse(String(kept?.document)), schemaRev: 2 });
|
||||
const newer = JSON.stringify({ ...JSON.parse(String(kept?.document)), schemaRev: BOOK_SCHEMA_REV + 1 });
|
||||
await updateRows(orm, JourneyBookRevisions, { id: revisionId }, { document: newer });
|
||||
t.realtime.reset();
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
alignElements,
|
||||
arrangeElements,
|
||||
arrangeWithinGroup,
|
||||
boundsOfFrames,
|
||||
distributableCount,
|
||||
distributeElements,
|
||||
} from './book-arrange';
|
||||
import {
|
||||
bookPageSetupSchema,
|
||||
bookShapeElementSchema,
|
||||
bookSpreadSchema,
|
||||
type BookElement,
|
||||
type BookSpread,
|
||||
} from './book.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Paint order, alignment and spacing, with groups moving as one. The page is
|
||||
* the default square book: 210 mm pages, the fold at 210, 5 mm safe margin.
|
||||
*/
|
||||
|
||||
const pageSetup = bookPageSetupSchema.parse({});
|
||||
|
||||
const box = (id: string, x: number, y: number, w = 20, h = 20, over: Partial<BookElement> = {}) =>
|
||||
bookShapeElementSchema.parse({ id, kind: 'shape', frame: { x, y, w, h }, shape: 'rect', ...over }) as BookElement;
|
||||
|
||||
const spread = (elements: BookElement[]): BookSpread => bookSpreadSchema.parse({ id: 'sp', elements });
|
||||
const ids = (sp: BookSpread) => sp.elements.map((e) => e.id);
|
||||
const at = (sp: BookSpread, id: string) => sp.elements.find((e) => e.id === id)?.frame;
|
||||
const pick = (sp: BookSpread, list: string[]) => sp.elements.filter((e) => list.includes(e.id));
|
||||
|
||||
describe('paint order', () => {
|
||||
const sp = spread([
|
||||
box('a', 0, 0),
|
||||
box('g1', 0, 0, 20, 20, { group: 'g' }),
|
||||
box('g2', 0, 0, 20, 20, { group: 'g' }),
|
||||
box('b', 0, 0),
|
||||
]);
|
||||
|
||||
it('BKARR-001: to front and to back keep the moved ones in their order, and a group together', () => {
|
||||
expect(ids(arrangeElements(sp, ['a'], 'front'))).toEqual(['g1', 'g2', 'b', 'a']);
|
||||
expect(ids(arrangeElements(sp, ['b'], 'back'))).toEqual(['b', 'a', 'g1', 'g2']);
|
||||
// One member moved to the front takes its group along.
|
||||
expect(ids(arrangeElements(sp, ['g1'], 'front'))).toEqual(['a', 'b', 'g1', 'g2']);
|
||||
});
|
||||
|
||||
it('BKARR-002: forward and backward step one place', () => {
|
||||
// A step goes past a whole group, never into it.
|
||||
expect(ids(arrangeElements(sp, ['a'], 'forward'))).toEqual(['g1', 'g2', 'a', 'b']);
|
||||
expect(ids(arrangeElements(sp, ['g2'], 'backward'))).toEqual(['g1', 'g2', 'a', 'b']);
|
||||
expect(arrangeElements(sp, ['b'], 'front')).toBe(sp);
|
||||
expect(ids(arrangeElements(spread([box('a', 0, 0), box('b', 0, 0), box('c', 0, 0)]), ['a'], 'forward'))).toEqual([
|
||||
'b',
|
||||
'a',
|
||||
'c',
|
||||
]);
|
||||
expect(ids(arrangeElements(spread([box('a', 0, 0), box('b', 0, 0), box('c', 0, 0)]), ['c'], 'backward'))).toEqual([
|
||||
'a',
|
||||
'c',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('BKARR-003: inside a group, a member moves among the members only', () => {
|
||||
const grouped = spread([
|
||||
box('a', 0, 0),
|
||||
box('m1', 0, 0, 20, 20, { group: 'g' }),
|
||||
box('m2', 0, 0, 20, 20, { group: 'g' }),
|
||||
box('m3', 0, 0, 20, 20, { group: 'g' }),
|
||||
box('b', 0, 0),
|
||||
]);
|
||||
expect(ids(arrangeWithinGroup(grouped, 'g', ['m1'], 'front'))).toEqual(['a', 'm2', 'm3', 'm1', 'b']);
|
||||
expect(ids(arrangeWithinGroup(grouped, 'g', ['m3'], 'back'))).toEqual(['a', 'm3', 'm1', 'm2', 'b']);
|
||||
expect(arrangeWithinGroup(grouped, 'g', ['m3'], 'front')).toBe(grouped);
|
||||
expect(arrangeWithinGroup(grouped, 'g', ['m3'], 'front').elements[3]?.group).toBe('g');
|
||||
});
|
||||
});
|
||||
|
||||
describe('align and distribute', () => {
|
||||
it('BKARR-004: lines single elements up on the edge of their box', () => {
|
||||
const sp = spread([box('a', 10, 10), box('b', 40, 30)]);
|
||||
const left = alignElements(sp, pageSetup, sp.elements, 'left', 'selection');
|
||||
expect([at(left, 'a')?.x, at(left, 'b')?.x]).toEqual([10, 10]);
|
||||
const bottom = alignElements(sp, pageSetup, sp.elements, 'bottom', 'selection');
|
||||
expect([at(bottom, 'a')?.y, at(bottom, 'b')?.y]).toEqual([30, 30]);
|
||||
// The other axis is left exactly as it was.
|
||||
expect(at(left, 'b')?.y).toBe(30);
|
||||
});
|
||||
|
||||
it('BKARR-005: moves a group as one box, and lines a group up inside itself when it is all that is picked', () => {
|
||||
const sp = spread([
|
||||
box('a', 100, 10),
|
||||
box('g1', 10, 50, 20, 20, { group: 'g' }),
|
||||
box('g2', 40, 60, 20, 20, { group: 'g' }),
|
||||
]);
|
||||
const right = alignElements(sp, pageSetup, sp.elements, 'right', 'selection');
|
||||
// The group's box (10..60) goes to the right edge 120 as one: 60 to the right.
|
||||
expect([at(right, 'g1')?.x, at(right, 'g2')?.x, at(right, 'a')?.x]).toEqual([70, 100, 100]);
|
||||
const inside = alignElements(sp, pageSetup, pick(sp, ['g1', 'g2']), 'top', 'selection');
|
||||
expect([at(inside, 'g1')?.y, at(inside, 'g2')?.y]).toEqual([50, 50]);
|
||||
});
|
||||
|
||||
it('BKARR-006: aligns against the page under an element, and its safe area', () => {
|
||||
const sp = spread([box('r', pageSetup.pageWidth + 90, 10)]);
|
||||
const centred = alignElements(sp, pageSetup, sp.elements, 'center', 'page');
|
||||
expect(at(centred, 'r')?.x).toBe(pageSetup.pageWidth + (pageSetup.pageWidth - 20) / 2);
|
||||
const safe = alignElements(sp, pageSetup, sp.elements, 'top', 'safe');
|
||||
expect(at(safe, 'r')?.y).toBe(pageSetup.safe);
|
||||
const middle = alignElements(sp, pageSetup, sp.elements, 'middle', 'page');
|
||||
expect(at(middle, 'r')?.y).toBe((pageSetup.pageHeight - 20) / 2);
|
||||
});
|
||||
|
||||
it('BKARR-007: spaces units evenly, a group counted once, or by a fixed gap', () => {
|
||||
const sp = spread([
|
||||
box('a', 0, 0),
|
||||
box('g1', 30, 0, 10, 10, { group: 'g' }),
|
||||
box('g2', 45, 0, 10, 10, { group: 'g' }),
|
||||
box('c', 100, 0),
|
||||
]);
|
||||
expect(distributableCount(sp, sp.elements)).toBe(3);
|
||||
const even = distributeElements(sp, sp.elements, 'horizontal');
|
||||
// Span 0..120, sizes 20 + 25 + 20, so two gaps of 27.5; the group moves as one.
|
||||
expect([at(even, 'g1')?.x, at(even, 'g2')?.x, at(even, 'c')?.x]).toEqual([47.5, 62.5, 100]);
|
||||
const fixed = distributeElements(sp, sp.elements, 'horizontal', 5);
|
||||
expect([at(fixed, 'g1')?.x, at(fixed, 'c')?.x]).toEqual([25, 55]);
|
||||
const down = distributeElements(
|
||||
spread([box('a', 0, 0), box('b', 0, 50), box('c', 0, 30)]),
|
||||
spread([box('a', 0, 0), box('b', 0, 50), box('c', 0, 30)]).elements,
|
||||
'vertical',
|
||||
);
|
||||
expect(at(down, 'c')?.y).toBe(25);
|
||||
});
|
||||
|
||||
it('BKARR-008: leaves a page with fewer than three units as it is, and measures a box', () => {
|
||||
const sp = spread([box('a', 0, 0), box('b', 50, 0)]);
|
||||
expect(distributeElements(sp, sp.elements, 'horizontal')).toBe(sp);
|
||||
expect(
|
||||
boundsOfFrames([
|
||||
{ x: 1, y: 2, w: 3, h: 4 },
|
||||
{ x: 10, y: 0, w: 1, h: 1 },
|
||||
]),
|
||||
).toEqual({ x: 1, y: 0, w: 10, h: 6 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { commonGroup, gatherGroups } from './book-groups';
|
||||
import type { BookElement, BookFrame, BookPageSetup, BookSpread } from './book.schema';
|
||||
|
||||
/**
|
||||
* Paint order, alignment and spacing of the elements of a page.
|
||||
*
|
||||
* The `arrange`, `align` and `distribute` operations of the book engine run
|
||||
* these (book-ops-elements.ts), and the Studio's layer buttons, align bar and
|
||||
* layers panel run the same engine, so a press and an assistant's operation
|
||||
* move a page the same way.
|
||||
*
|
||||
* Groups move as one: aligning two groups lines up their boxes, not every
|
||||
* member on the same edge, and distributing spaces the boxes. When every named
|
||||
* element is in the one group, the members line up among themselves, which is
|
||||
* how the inside of a lockup is set.
|
||||
*/
|
||||
|
||||
export type BookArrangeTo = 'front' | 'forward' | 'backward' | 'back';
|
||||
export type BookAlignEdge = 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom';
|
||||
export type BookAlignTo = 'selection' | 'page' | 'safe';
|
||||
export type BookDistributeAxis = 'horizontal' | 'vertical';
|
||||
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
/**
|
||||
* The page in paint order as the things that move as one: a group's members
|
||||
* together (they are next to each other, see `gatherGroups`), every other
|
||||
* element alone.
|
||||
*/
|
||||
function paintUnits(spread: BookSpread): BookElement[][] {
|
||||
const units: BookElement[][] = [];
|
||||
for (const el of gatherGroups(spread).elements) {
|
||||
const last = units[units.length - 1];
|
||||
if (el.group && last?.[0]?.group === el.group) last.push(el);
|
||||
else units.push([el]);
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
/**
|
||||
* The named elements moved in the paint order, keeping their order among
|
||||
* themselves. A group moves whole, and a step forward or backward goes past a
|
||||
* whole group at once, never into it.
|
||||
*/
|
||||
export function arrangeElements(spread: BookSpread, ids: readonly string[], to: BookArrangeTo): BookSpread {
|
||||
const chosen = new Set(ids);
|
||||
const units = paintUnits(spread);
|
||||
const isChosen = (u: BookElement[]) => u.some((e) => chosen.has(e.id));
|
||||
let next: BookElement[][];
|
||||
if (to === 'front') next = [...units.filter((u) => !isChosen(u)), ...units.filter(isChosen)];
|
||||
else if (to === 'back') next = [...units.filter(isChosen), ...units.filter((u) => !isChosen(u))];
|
||||
else {
|
||||
next = units.slice();
|
||||
const step = to === 'forward' ? 1 : -1;
|
||||
const order = next.map((_, i) => i);
|
||||
if (step === 1) order.reverse();
|
||||
for (const i of order) {
|
||||
const here = next[i];
|
||||
const there = next[i + step];
|
||||
if (!here || !there || !isChosen(here) || isChosen(there)) continue;
|
||||
next[i] = there;
|
||||
next[i + step] = here;
|
||||
}
|
||||
}
|
||||
const elements = next.flat();
|
||||
return elements.every((e, i) => e === spread.elements[i]) ? spread : { ...spread, elements };
|
||||
}
|
||||
|
||||
/**
|
||||
* Members of one group moved in the paint order among the group's own members
|
||||
* only, the group staying where it is on the page: what "to front" does to an
|
||||
* element picked inside a group.
|
||||
*/
|
||||
export function arrangeWithinGroup(
|
||||
spread: BookSpread,
|
||||
groupId: string,
|
||||
ids: readonly string[],
|
||||
to: BookArrangeTo,
|
||||
): BookSpread {
|
||||
const slots: number[] = [];
|
||||
spread.elements.forEach((e, i) => {
|
||||
if (e.group === groupId) slots.push(i);
|
||||
});
|
||||
const members = slots.map((i) => spread.elements[i] as BookElement);
|
||||
const reordered = arrangeElements({ ...spread, elements: members.map(withoutGroupId) }, ids, to).elements;
|
||||
const byId = new Map(members.map((m) => [m.id, m]));
|
||||
const elements = spread.elements.slice();
|
||||
slots.forEach((slot, k) => {
|
||||
const moved = reordered[k];
|
||||
if (moved) elements[slot] = byId.get(moved.id) as BookElement;
|
||||
});
|
||||
return elements.every((e, i) => e === spread.elements[i]) ? spread : { ...spread, elements };
|
||||
}
|
||||
|
||||
/** A member taken out of its group for reordering among the members alone, so gathering leaves them be. */
|
||||
function withoutGroupId(el: BookElement): BookElement {
|
||||
const next = { ...el };
|
||||
delete next.group;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The box around frames. */
|
||||
export function boundsOfFrames(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 };
|
||||
}
|
||||
|
||||
/** The page a box's centre is on, or its safe area. */
|
||||
function pageBox(spread: BookSpread, page: BookPageSetup, f: BookFrame, safe: boolean): BookFrame {
|
||||
const onRight = spread.role === 'inner' && f.x + f.w / 2 >= page.pageWidth;
|
||||
const x = onRight ? page.pageWidth : 0;
|
||||
const inset = safe ? page.safe : 0;
|
||||
return { x: x + inset, y: inset, w: page.pageWidth - inset * 2, h: page.pageHeight - inset * 2 };
|
||||
}
|
||||
|
||||
/** Where a box goes to line up on an edge of another. */
|
||||
function alignedAt(f: BookFrame, box: BookFrame, edge: BookAlignEdge): { x: number; y: number } {
|
||||
switch (edge) {
|
||||
case 'left':
|
||||
return { x: box.x, y: f.y };
|
||||
case 'center':
|
||||
return { x: box.x + (box.w - f.w) / 2, y: f.y };
|
||||
case 'right':
|
||||
return { x: box.x + box.w - f.w, y: f.y };
|
||||
case 'top':
|
||||
return { x: f.x, y: box.y };
|
||||
case 'middle':
|
||||
return { x: f.x, y: box.y + (box.h - f.h) / 2 };
|
||||
case 'bottom':
|
||||
return { x: f.x, y: box.y + box.h - f.h };
|
||||
}
|
||||
}
|
||||
|
||||
/** What moves as one: a group's named members together, every other element alone. */
|
||||
interface Unit {
|
||||
members: BookElement[];
|
||||
box: BookFrame;
|
||||
}
|
||||
|
||||
function unitsOf(spread: BookSpread, els: readonly BookElement[]): Unit[] {
|
||||
const ids = els.map((e) => e.id);
|
||||
// All in one group: the members are the units, so the inside of a group can be set.
|
||||
const inside = commonGroup(spread, ids) !== null;
|
||||
const byKey = new Map<string, BookElement[]>();
|
||||
for (const el of els) {
|
||||
const key = !inside && el.group ? `g:${el.group}` : `e:${el.id}`;
|
||||
byKey.set(key, [...(byKey.get(key) ?? []), el]);
|
||||
}
|
||||
return [...byKey.values()].map((members) => ({ members, box: boundsOfFrames(members.map((m) => m.frame)) }));
|
||||
}
|
||||
|
||||
/** Every member of a unit moved by the same offset, rounded as the document stores it. */
|
||||
function shifted(unit: Unit, dx: number, dy: number): BookElement[] {
|
||||
// An axis that does not move is left exactly as it was, unrounded or not.
|
||||
return unit.members.map((e) => {
|
||||
const x = dx === 0 ? e.frame.x : round2(e.frame.x + dx);
|
||||
const y = dy === 0 ? e.frame.y : round2(e.frame.y + dy);
|
||||
return { ...e, frame: { ...e.frame, x, y } } as BookElement;
|
||||
});
|
||||
}
|
||||
|
||||
function replaceAll(spread: BookSpread, next: readonly BookElement[]): BookSpread {
|
||||
const byId = new Map(next.map((e) => [e.id, e]));
|
||||
return { ...spread, elements: spread.elements.map((e) => byId.get(e.id) ?? e) };
|
||||
}
|
||||
|
||||
/** The named elements lined up on an edge or a centre line of their box, their page, or its safe area. */
|
||||
export function alignElements(
|
||||
spread: BookSpread,
|
||||
page: BookPageSetup,
|
||||
els: readonly BookElement[],
|
||||
edge: BookAlignEdge,
|
||||
to: BookAlignTo,
|
||||
): BookSpread {
|
||||
const units = unitsOf(spread, els);
|
||||
const selection = boundsOfFrames(units.map((u) => u.box));
|
||||
const next = units.flatMap((u) => {
|
||||
const box = to === 'selection' ? selection : pageBox(spread, page, u.box, to === 'safe');
|
||||
const at = alignedAt(u.box, box, edge);
|
||||
return shifted(u, at.x - u.box.x, at.y - u.box.y);
|
||||
});
|
||||
return replaceAll(spread, next);
|
||||
}
|
||||
|
||||
/** How many units a distribute has: it needs three. */
|
||||
export function distributableCount(spread: BookSpread, els: readonly BookElement[]): number {
|
||||
return unitsOf(spread, els).length;
|
||||
}
|
||||
|
||||
/** Three or more units spaced evenly between the outer two, or by a fixed gap in millimetres. */
|
||||
export function distributeElements(
|
||||
spread: BookSpread,
|
||||
els: readonly BookElement[],
|
||||
axis: BookDistributeAxis,
|
||||
gap?: number,
|
||||
): BookSpread {
|
||||
const units = unitsOf(spread, els);
|
||||
if (units.length < 3) return spread;
|
||||
const across = axis === 'horizontal';
|
||||
const start = (f: BookFrame) => (across ? f.x : f.y);
|
||||
const size = (f: BookFrame) => (across ? f.w : f.h);
|
||||
const sorted = units.slice().sort((a, b) => start(a.box) - start(b.box));
|
||||
const first = sorted[0] as Unit;
|
||||
const last = sorted[sorted.length - 1] as Unit;
|
||||
const total = sorted.reduce((sum, u) => sum + size(u.box), 0);
|
||||
const span = start(last.box) + size(last.box) - start(first.box);
|
||||
const space = gap ?? (span - total) / (sorted.length - 1);
|
||||
let cursor = start(first.box);
|
||||
const next = sorted.flatMap((u) => {
|
||||
const delta = round2(cursor) - start(u.box);
|
||||
cursor += size(u.box) + space;
|
||||
return across ? shifted(u, delta, 0) : shifted(u, 0, delta);
|
||||
});
|
||||
return replaceAll(spread, next);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { compareCodeUnits } from '../common/compare';
|
||||
import { contrastOfLuminance, deltaE, greyLuminance, luminance, parseHex, requiredContrast } from './book-colour';
|
||||
import { visibleSpread } from './book-groups';
|
||||
import type { BookPhotoInfo } from './book-ops-core';
|
||||
import { BOOK_OP_LIMITS, type BookFinding } from './book-ops.schema';
|
||||
import type { BookDocument, BookElement, BookFrame, BookPageSetup, BookPhotoElement, BookSpread } from './book.schema';
|
||||
@@ -298,7 +299,7 @@ export function designFindings(
|
||||
page: BookPageSetup,
|
||||
photos?: readonly BookPhotoInfo[],
|
||||
): BookFinding[] {
|
||||
const ctx: Ctx = { spread, page, photos, out: [] };
|
||||
const ctx: Ctx = { spread: visibleSpread(spread), page, photos, out: [] };
|
||||
contrastAndBusy(ctx);
|
||||
smallText(ctx);
|
||||
crowded(ctx);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bookVisiblePart, sortBookFindings } from './book-findings-design';
|
||||
import { visibleSpread } from './book-groups';
|
||||
import type { BookPhotoInfo } from './book-ops-core';
|
||||
import type { BookFinding } from './book-ops.schema';
|
||||
import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread, BookTextElement } from './book.schema';
|
||||
@@ -248,6 +249,8 @@ function overlaps(ctx: Ctx): void {
|
||||
const a = content[i] as BookElement;
|
||||
const b = content[j] as BookElement;
|
||||
if (a.kind !== b.kind) continue;
|
||||
// Set over each other inside one group on purpose: a lockup, a framed caption.
|
||||
if (a.group !== undefined && a.group === b.group) continue;
|
||||
const ba = elementBox(a);
|
||||
const bb = elementBox(b);
|
||||
const shared = overlapArea(ba, bb);
|
||||
@@ -493,7 +496,8 @@ export function spreadFindings(
|
||||
photos?: readonly BookPhotoInfo[],
|
||||
): BookFinding[] {
|
||||
const ctx: Ctx = {
|
||||
spread,
|
||||
// A hidden element is not on the page, so nothing about it is a fault of the page.
|
||||
spread: visibleSpread(spread),
|
||||
page,
|
||||
W: page.pageWidth * (spread.role === 'inner' ? 2 : 1),
|
||||
H: page.pageHeight,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { bookElementIdSchema } from './book-op-ids';
|
||||
import { BOOK_LAYER_NAME_MAX } from './book.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The layer operations of a book page: group, ungroup, name and hide. They
|
||||
* join the page operations of book-ops.schema.ts beside the element ones, run
|
||||
* in the one engine (book-ops-groups.ts) the Studio's layers panel and its
|
||||
* Ctrl+G use too.
|
||||
*/
|
||||
|
||||
/** A group's id as the page has it (`g-...`), never invented. */
|
||||
export const bookGroupIdSchema = z.string().regex(/^[\w-]{1,40}$/);
|
||||
|
||||
const layerName = z.string().max(BOOK_LAYER_NAME_MAX);
|
||||
|
||||
export const BOOK_GROUP_OP_SCHEMAS = [
|
||||
/**
|
||||
* Make two or more elements one group, picked, moved, aligned and arranged
|
||||
* as one. Elements already in a group leave it for the new one.
|
||||
*/
|
||||
z.strictObject({
|
||||
op: z.literal('group'),
|
||||
elementIds: z.array(bookElementIdSchema).min(2).max(30),
|
||||
name: layerName
|
||||
.describe('What the group is called in the layers panel, such as "Title lockup". Optional.')
|
||||
.optional(),
|
||||
}),
|
||||
/** Put a group's members back on their own, where they are. */
|
||||
z.strictObject({ op: z.literal('ungroup'), groupId: bookGroupIdSchema }),
|
||||
/** Name an element or a group in the layers panel; an empty name clears it. */
|
||||
z
|
||||
.strictObject({
|
||||
op: z.literal('name'),
|
||||
elementId: bookElementIdSchema.optional(),
|
||||
groupId: bookGroupIdSchema.optional(),
|
||||
name: layerName,
|
||||
})
|
||||
.refine((v) => (v.elementId === undefined) !== (v.groupId === undefined), {
|
||||
message: 'name an elementId or a groupId, not both',
|
||||
}),
|
||||
/** Hide elements (kept on the page's layer list, left out of the page and its print) or show them again. */
|
||||
z.strictObject({
|
||||
op: z.literal('hide'),
|
||||
elementIds: z.array(bookElementIdSchema).min(1).max(30),
|
||||
hidden: z.boolean(),
|
||||
}),
|
||||
] as const;
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
bookGroupName,
|
||||
commonGroup,
|
||||
expandToGroups,
|
||||
gatherGroups,
|
||||
groupElements,
|
||||
groupMembers,
|
||||
isWholeGroup,
|
||||
regroupCopies,
|
||||
renameElement,
|
||||
renameGroup,
|
||||
setElementsHidden,
|
||||
spreadGroupIds,
|
||||
tidyGroups,
|
||||
ungroupElements,
|
||||
visibleSpread,
|
||||
withCopies,
|
||||
withoutGroup,
|
||||
} from './book-groups';
|
||||
import { bookShapeElementSchema, bookSpreadSchema, type BookElement, type BookSpread } from './book.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Groups on a page: membership on the elements, names on the spread, at least
|
||||
* two members each, kept next to each other in the paint order.
|
||||
*/
|
||||
|
||||
const shape = (id: string, over: Partial<BookElement> = {}) =>
|
||||
bookShapeElementSchema.parse({
|
||||
id,
|
||||
kind: 'shape',
|
||||
frame: { x: 10, y: 10, w: 20, h: 20 },
|
||||
shape: 'rect',
|
||||
...over,
|
||||
}) as BookElement;
|
||||
|
||||
const page = (elements: BookElement[], over: Partial<BookSpread> = {}): BookSpread =>
|
||||
bookSpreadSchema.parse({ id: 'sp', elements, ...over });
|
||||
|
||||
const order = (sp: BookSpread) => sp.elements.map((e) => `${e.id}${e.group ? `:${e.group}` : ''}`);
|
||||
|
||||
describe('reading groups', () => {
|
||||
const sp = page(
|
||||
[
|
||||
shape('a', { group: 'g1' }),
|
||||
shape('b'),
|
||||
shape('c', { group: 'g1' }),
|
||||
shape('d', { group: 'g2' }),
|
||||
shape('e', { group: 'g2' }),
|
||||
],
|
||||
{ groups: [{ id: 'g1', name: 'Lockup' }] },
|
||||
);
|
||||
|
||||
it('BKGRP-001: lists members, names and the groups on the page in paint order', () => {
|
||||
expect(groupMembers(sp, 'g1').map((e) => e.id)).toEqual(['a', 'c']);
|
||||
expect(bookGroupName(sp, 'g1')).toBe('Lockup');
|
||||
expect(bookGroupName(sp, 'g2')).toBeUndefined();
|
||||
expect(spreadGroupIds(sp)).toEqual(['g1', 'g2']);
|
||||
});
|
||||
|
||||
it('BKGRP-002: a click on one member picks the whole group, and an ungrouped element alone', () => {
|
||||
expect(expandToGroups(sp, ['c'])).toEqual(['a', 'c']);
|
||||
expect(expandToGroups(sp, ['b'])).toEqual(['b']);
|
||||
expect(expandToGroups(sp, ['b', 'e'])).toEqual(['b', 'd', 'e']);
|
||||
});
|
||||
|
||||
it('BKGRP-003: knows the one group a selection is in, and whether it is the whole of it', () => {
|
||||
expect(commonGroup(sp, ['a', 'c'])).toBe('g1');
|
||||
expect(commonGroup(sp, ['a', 'd'])).toBeNull();
|
||||
expect(commonGroup(sp, ['b'])).toBeNull();
|
||||
expect(commonGroup(sp, [])).toBeNull();
|
||||
expect(isWholeGroup(sp, ['a', 'c'])).toBe(true);
|
||||
expect(isWholeGroup(sp, ['a'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('making and undoing groups', () => {
|
||||
it('BKGRP-004: gathers a group at the place of its topmost member, keeping their order', () => {
|
||||
const sp = page([shape('a', { group: 'g' }), shape('b'), shape('c', { group: 'g' }), shape('d')]);
|
||||
expect(order(gatherGroups(sp))).toEqual(['b', 'a:g', 'c:g', 'd']);
|
||||
const tidy = page([shape('a', { group: 'g' }), shape('c', { group: 'g' }), shape('d')]);
|
||||
expect(gatherGroups(tidy)).toBe(tidy);
|
||||
const none = page([shape('a')]);
|
||||
expect(gatherGroups(none)).toBe(none);
|
||||
});
|
||||
|
||||
it('BKGRP-005: groups elements under a new id and a trimmed name, gathered together', () => {
|
||||
const sp = groupElements(page([shape('a'), shape('b'), shape('c')]), ['a', 'c'], 'g-new', ' Title ');
|
||||
expect(order(sp)).toEqual(['b', 'a:g-new', 'c:g-new']);
|
||||
expect(sp.groups).toEqual([{ id: 'g-new', name: 'Title' }]);
|
||||
const unnamed = groupElements(page([shape('a'), shape('b')]), ['a', 'b'], 'g2');
|
||||
expect(unnamed.groups).toBeUndefined();
|
||||
});
|
||||
|
||||
it('BKGRP-006: an element taken into a new group leaves its old one, which dissolves when one is left', () => {
|
||||
const sp = page([shape('a', { group: 'old' }), shape('b', { group: 'old' }), shape('c')], {
|
||||
groups: [{ id: 'old', name: 'Old' }],
|
||||
});
|
||||
const next = groupElements(sp, ['b', 'c'], 'new');
|
||||
expect(order(next)).toEqual(['a', 'b:new', 'c:new']);
|
||||
expect(next.groups).toBeUndefined();
|
||||
});
|
||||
|
||||
it('BKGRP-007: ungroups, leaving the members where they are and the name gone', () => {
|
||||
const sp = page([shape('a', { group: 'g' }), shape('b', { group: 'g' }), shape('c')], {
|
||||
groups: [{ id: 'g', name: 'X' }],
|
||||
});
|
||||
const next = ungroupElements(sp, 'g');
|
||||
expect(order(next)).toEqual(['a', 'b', 'c']);
|
||||
expect(next.groups).toBeUndefined();
|
||||
expect('group' in (next.elements[0] as object)).toBe(false);
|
||||
});
|
||||
|
||||
it('BKGRP-008: tidies a lone member and a name without a group, and leaves an orderly page as it is', () => {
|
||||
const sp = page([shape('a', { group: 'g' }), shape('b')], {
|
||||
groups: [
|
||||
{ id: 'g', name: 'Lone' },
|
||||
{ id: 'gone', name: 'Gone' },
|
||||
],
|
||||
});
|
||||
const next = tidyGroups(sp);
|
||||
expect(order(next)).toEqual(['a', 'b']);
|
||||
expect(next.groups).toBeUndefined();
|
||||
const fine = page([shape('a', { group: 'g' }), shape('b', { group: 'g' })], { groups: [{ id: 'g', name: 'Y' }] });
|
||||
expect(tidyGroups(fine)).toBe(fine);
|
||||
const keeps = tidyGroups(
|
||||
page([shape('a', { group: 'g' }), shape('b', { group: 'g' })], {
|
||||
groups: [
|
||||
{ id: 'g', name: 'Y' },
|
||||
{ id: 'x', name: 'Z' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(keeps.groups).toEqual([{ id: 'g', name: 'Y' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layer names and visibility', () => {
|
||||
it('BKGRP-009: names an element or a group, and an empty name clears it', () => {
|
||||
const sp = page([shape('a', { group: 'g' }), shape('b', { group: 'g' })]);
|
||||
const named = renameElement(sp, 'a', ' Sun ');
|
||||
expect(named.elements[0]?.name).toBe('Sun');
|
||||
expect('name' in (renameElement(named, 'a', ' ').elements[0] as object)).toBe(false);
|
||||
const group = renameGroup(sp, 'g', 'Sky');
|
||||
expect(group.groups).toEqual([{ id: 'g', name: 'Sky' }]);
|
||||
expect(renameGroup(renameGroup(group, 'g', 'Sea'), 'g', '').groups).toBeUndefined();
|
||||
expect(renameGroup(group, 'g', 'Sea').groups).toEqual([{ id: 'g', name: 'Sea' }]);
|
||||
});
|
||||
|
||||
it('BKGRP-010: hides and shows, a shown element storing no flag, and the print leaves the hidden out', () => {
|
||||
const sp = page([shape('a'), shape('b')]);
|
||||
const hidden = setElementsHidden(sp, ['a'], true);
|
||||
expect(hidden.elements[0]?.hidden).toBe(true);
|
||||
expect(visibleSpread(hidden).elements.map((e) => e.id)).toEqual(['b']);
|
||||
expect(visibleSpread(sp)).toBe(sp);
|
||||
const shown = setElementsHidden(hidden, ['a'], false);
|
||||
expect('hidden' in (shown.elements[0] as object)).toBe(false);
|
||||
});
|
||||
|
||||
it('BKGRP-011: a document from before groups reads with none of the new fields set', () => {
|
||||
const sp = page([shape('a')]);
|
||||
expect(sp.groups).toBeUndefined();
|
||||
expect(Object.keys(sp.elements[0] as object)).not.toEqual(expect.arrayContaining(['group']));
|
||||
// A broken name list is dropped, never the page.
|
||||
const odd = bookSpreadSchema.parse({ id: 'sp', elements: [], groups: 'nonsense' });
|
||||
expect(odd.groups).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('copies', () => {
|
||||
let n = 0;
|
||||
const newId = (p: string) => `${p}-c${++n}`;
|
||||
|
||||
it('BKGRP-012: copies of a group become a group of their own, its name carried along', () => {
|
||||
const source = page([shape('a', { group: 'g' }), shape('b', { group: 'g' }), shape('c', { group: 'h' })], {
|
||||
groups: [{ id: 'g', name: 'Lockup' }],
|
||||
});
|
||||
const copies = source.elements.map((e) => ({ ...e, id: `${e.id}2` }));
|
||||
const out = regroupCopies(copies, source, newId);
|
||||
const [a, b, c] = out.elements;
|
||||
expect(a?.group).toBe(b?.group);
|
||||
expect(a?.group).not.toBe('g');
|
||||
// c was the only copy of its group: it goes alone.
|
||||
expect(c && 'group' in c).toBe(false);
|
||||
expect(out.groups).toEqual([{ id: a?.group, name: 'Lockup' }]);
|
||||
const target = withCopies(page([shape('z')]), out);
|
||||
expect(target.elements.map((e) => e.id)).toEqual(['z', 'a2', 'b2', 'c2']);
|
||||
expect(target.groups).toEqual(out.groups);
|
||||
});
|
||||
|
||||
it('BKGRP-013: copies without groups, or without a source to name them from, stay plain', () => {
|
||||
const plain = regroupCopies([shape('a')], undefined, newId);
|
||||
expect(plain.groups).toEqual([]);
|
||||
const unnamed = regroupCopies([shape('a', { group: 'g' }), shape('b', { group: 'g' })], undefined, newId);
|
||||
expect(unnamed.groups).toEqual([]);
|
||||
expect(unnamed.elements[0]?.group).toBe(unnamed.elements[1]?.group);
|
||||
expect(withCopies(page([]), plain).groups).toBeUndefined();
|
||||
expect(withoutGroup(shape('x', { group: 'g' })).group).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { BookElement, BookGroup, BookSpread } from './book.schema';
|
||||
|
||||
/**
|
||||
* Groups on a page, and the layer fields that come with them.
|
||||
*
|
||||
* ── What a group is ──────────────────────────────────────────────────────
|
||||
*
|
||||
* A flat set of elements of one spread that are picked, moved, aligned and
|
||||
* arranged as one. Membership lives on the elements (`group`, an id that is
|
||||
* only meaningful on its own spread); the spread's `groups` list holds what a
|
||||
* group is called. There is no nesting: grouping elements that are already in
|
||||
* groups makes one new group of all of them, which is what a page needs and
|
||||
* what a model can describe without a tree.
|
||||
*
|
||||
* Two rules keep every reader simple:
|
||||
* - a group has at least two members on the page (`tidyGroups` dissolves the
|
||||
* rest, so a lone element never carries a group nobody can see);
|
||||
* - its members are next to each other in the paint order (`gatherGroups`),
|
||||
* so the layers panel can show a group as one row without lying about what
|
||||
* is in front of what.
|
||||
*
|
||||
* The Studio and the book engine (book-ops-groups.ts) both call these, so a
|
||||
* group made by hand and one made by the assistant are the same thing.
|
||||
*/
|
||||
|
||||
/** Every member of a group, in paint order. */
|
||||
export function groupMembers(spread: BookSpread, groupId: string): BookElement[] {
|
||||
return spread.elements.filter((e) => e.group === groupId);
|
||||
}
|
||||
|
||||
/** What a group is called, or undefined when it has no name. */
|
||||
export function bookGroupName(spread: BookSpread, groupId: string): string | undefined {
|
||||
return spread.groups?.find((g) => g.id === groupId)?.name;
|
||||
}
|
||||
|
||||
/** The groups of a spread that have members on the page, in the paint order of their lowest member. */
|
||||
export function spreadGroupIds(spread: BookSpread): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const el of spread.elements) if (el.group) seen.add(el.group);
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids named plus every other member of their groups, in paint order.
|
||||
*
|
||||
* What a click on a grouped element picks, and what a marquee that touches one
|
||||
* member of a group picks: the group is the thing on the page.
|
||||
*/
|
||||
export function expandToGroups(spread: BookSpread, ids: readonly string[]): string[] {
|
||||
const named = new Set(ids);
|
||||
const groups = new Set<string>();
|
||||
for (const el of spread.elements) if (named.has(el.id) && el.group) groups.add(el.group);
|
||||
return spread.elements
|
||||
.filter((e) => named.has(e.id) || (e.group !== undefined && groups.has(e.group)))
|
||||
.map((e) => e.id);
|
||||
}
|
||||
|
||||
/** The one group every named element is in, or null when they are not all in the same one. */
|
||||
export function commonGroup(spread: BookSpread, ids: readonly string[]): string | null {
|
||||
const els = spread.elements.filter((e) => ids.includes(e.id));
|
||||
const first = els[0]?.group;
|
||||
if (!first || !els.every((e) => e.group === first)) return null;
|
||||
return first;
|
||||
}
|
||||
|
||||
/** Whether the named ids are exactly the members of one group, no more and no fewer. */
|
||||
export function isWholeGroup(spread: BookSpread, ids: readonly string[]): boolean {
|
||||
const group = commonGroup(spread, ids);
|
||||
return group !== null && groupMembers(spread, group).length === new Set(ids).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* The elements moved so every group's members are next to each other, at the
|
||||
* place of the group's topmost member, keeping their order among themselves.
|
||||
* The same spread object when nothing had to move.
|
||||
*/
|
||||
export function gatherGroups(spread: BookSpread): BookSpread {
|
||||
const lastIndex = new Map<string, number>();
|
||||
spread.elements.forEach((e, i) => {
|
||||
if (e.group) lastIndex.set(e.group, i);
|
||||
});
|
||||
if (!lastIndex.size) return spread;
|
||||
const out: BookElement[] = [];
|
||||
spread.elements.forEach((e, i) => {
|
||||
if (!e.group) {
|
||||
out.push(e);
|
||||
return;
|
||||
}
|
||||
if (lastIndex.get(e.group) !== i) return;
|
||||
out.push(...groupMembers(spread, e.group));
|
||||
});
|
||||
return out.every((e, i) => e === spread.elements[i]) ? spread : { ...spread, elements: out };
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups with fewer than two members on the page dissolved, and names of
|
||||
* groups that are gone dropped. The same spread object when all was in order.
|
||||
*/
|
||||
export function tidyGroups(spread: BookSpread): BookSpread {
|
||||
const count = new Map<string, number>();
|
||||
for (const e of spread.elements) if (e.group) count.set(e.group, (count.get(e.group) ?? 0) + 1);
|
||||
const lonely = (e: BookElement) => e.group !== undefined && (count.get(e.group) ?? 0) < 2;
|
||||
const elements = spread.elements.some(lonely)
|
||||
? spread.elements.map((e) => (lonely(e) ? withoutGroup(e) : e))
|
||||
: spread.elements;
|
||||
const live = new Set([...count].filter(([, n]) => n >= 2).map(([id]) => id));
|
||||
const groups = spread.groups?.filter((g) => live.has(g.id));
|
||||
const groupsChanged = spread.groups !== undefined && groups?.length !== spread.groups.length;
|
||||
if (elements === spread.elements && !groupsChanged) return spread;
|
||||
const next: BookSpread = { ...spread, elements };
|
||||
if (groupsChanged) {
|
||||
if (groups?.length) next.groups = groups;
|
||||
else delete next.groups;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** An element without its group. */
|
||||
export function withoutGroup<E extends BookElement>(el: E): E {
|
||||
const next = { ...el };
|
||||
delete next.group;
|
||||
return next;
|
||||
}
|
||||
|
||||
function withName(groups: readonly BookGroup[] | undefined, id: string, name: string | undefined): BookGroup[] {
|
||||
const rest = (groups ?? []).filter((g) => g.id !== id);
|
||||
return name ? [...rest, { id, name }] : rest;
|
||||
}
|
||||
|
||||
function setGroups(spread: BookSpread, groups: BookGroup[]): BookSpread {
|
||||
const next: BookSpread = { ...spread, groups };
|
||||
if (!groups.length) delete next.groups;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The named elements made one new group, gathered at the place of the topmost
|
||||
* of them. Elements that were in other groups leave them (and a group left with
|
||||
* one member dissolves). `name` is the group's name, none for an unnamed one.
|
||||
*/
|
||||
export function groupElements(spread: BookSpread, ids: readonly string[], groupId: string, name?: string): BookSpread {
|
||||
const chosen = new Set(ids);
|
||||
const elements = spread.elements.map((e) => (chosen.has(e.id) ? { ...e, group: groupId } : e));
|
||||
const trimmed = name?.trim();
|
||||
return tidyGroups(gatherGroups(setGroups({ ...spread, elements }, withName(spread.groups, groupId, trimmed))));
|
||||
}
|
||||
|
||||
/** A group's members back on their own, where they are in the paint order. */
|
||||
export function ungroupElements(spread: BookSpread, groupId: string): BookSpread {
|
||||
const elements = spread.elements.map((e) => (e.group === groupId ? withoutGroup(e) : e));
|
||||
return tidyGroups({ ...spread, elements });
|
||||
}
|
||||
|
||||
/** A group named, or its name cleared with an empty one. */
|
||||
export function renameGroup(spread: BookSpread, groupId: string, name: string): BookSpread {
|
||||
return setGroups(spread, withName(spread.groups, groupId, name.trim() || undefined));
|
||||
}
|
||||
|
||||
/** An element named, or its name cleared with an empty one. */
|
||||
export function renameElement(spread: BookSpread, id: string, name: string): BookSpread {
|
||||
const trimmed = name.trim();
|
||||
const elements = spread.elements.map((e) => {
|
||||
if (e.id !== id) return e;
|
||||
const next = { ...e };
|
||||
if (trimmed) next.name = trimmed;
|
||||
else delete next.name;
|
||||
return next;
|
||||
});
|
||||
return { ...spread, elements };
|
||||
}
|
||||
|
||||
/** Elements shown or hidden. `hidden: false` drops the field, so a shown element stores what it stored before. */
|
||||
export function setElementsHidden(spread: BookSpread, ids: readonly string[], hidden: boolean): BookSpread {
|
||||
const chosen = new Set(ids);
|
||||
const elements = spread.elements.map((e) => {
|
||||
if (!chosen.has(e.id)) return e;
|
||||
const next = { ...e };
|
||||
if (hidden) next.hidden = true;
|
||||
else delete next.hidden;
|
||||
return next;
|
||||
});
|
||||
return { ...spread, elements };
|
||||
}
|
||||
|
||||
/** The spread as it prints: without its hidden elements. The same object when none is hidden. */
|
||||
export function visibleSpread(spread: BookSpread): BookSpread {
|
||||
if (!spread.elements.some((e) => e.hidden)) return spread;
|
||||
return { ...spread, elements: spread.elements.filter((e) => !e.hidden) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies of elements given groups of their own.
|
||||
*
|
||||
* A copy that kept its source's group id would join the original group on a
|
||||
* duplicate, or another page's group of the same id on a paste. Each group
|
||||
* among the copies gets a fresh id; its name comes along from `source` (the
|
||||
* spread the copies came from), so a copied lockup is still called what it was.
|
||||
* A copy that is the only one of its group among the copies goes alone.
|
||||
*/
|
||||
export function regroupCopies(
|
||||
copies: readonly BookElement[],
|
||||
source: BookSpread | undefined,
|
||||
newId: (prefix: string) => string,
|
||||
): { elements: BookElement[]; groups: BookGroup[] } {
|
||||
const count = new Map<string, number>();
|
||||
for (const e of copies) if (e.group) count.set(e.group, (count.get(e.group) ?? 0) + 1);
|
||||
const fresh = new Map<string, string>();
|
||||
const groups: BookGroup[] = [];
|
||||
for (const [old, n] of count) {
|
||||
if (n < 2) continue;
|
||||
const id = newId('g');
|
||||
fresh.set(old, id);
|
||||
const name = source ? bookGroupName(source, old) : undefined;
|
||||
groups.push(name ? { id, name } : { id });
|
||||
}
|
||||
const elements = copies.map((e) => {
|
||||
if (!e.group) return e;
|
||||
const id = fresh.get(e.group);
|
||||
return id ? { ...e, group: id } : withoutGroup(e);
|
||||
});
|
||||
return { elements, groups: groups.filter((g) => g.name !== undefined) };
|
||||
}
|
||||
|
||||
/** Copies added on top of a spread with the names of their new groups. */
|
||||
export function withCopies(
|
||||
spread: BookSpread,
|
||||
copied: { elements: readonly BookElement[]; groups: readonly BookGroup[] },
|
||||
): BookSpread {
|
||||
const next = { ...spread, elements: [...spread.elements, ...copied.elements] };
|
||||
return copied.groups.length ? { ...next, groups: [...(spread.groups ?? []), ...copied.groups] } : next;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export const BOOK_OP_ERROR_CODES = [
|
||||
'icon_unknown',
|
||||
'not_enough_elements',
|
||||
'layouts_full',
|
||||
'group_not_found',
|
||||
] as const;
|
||||
export type BookOpErrorCode = (typeof BOOK_OP_ERROR_CODES)[number];
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { relayoutSpread } from './auto/auto-book';
|
||||
import type { AutoEntry, AutoInput } from './auto/auto-types';
|
||||
import { alignElements, arrangeElements, distributableCount, distributeElements } from './book-arrange';
|
||||
import { bookElementPropsSchema, type BOOK_ELEMENT_OP_SCHEMAS, type BookNewElement } from './book-element-ops.schema';
|
||||
import { bookGridElements, defaultBookGridBox, findBookGrid } from './book-grids';
|
||||
import { regroupCopies, tidyGroups, withCopies, withoutGroup } from './book-groups';
|
||||
import {
|
||||
bookBoundWords,
|
||||
failOp,
|
||||
@@ -400,94 +402,26 @@ function opLock(spread: BookSpread, op: Op<'lock'>, env: ElementOpEnv): BookSpre
|
||||
);
|
||||
}
|
||||
|
||||
// ── arrange ────────────────────────────────────────────────────────────────
|
||||
|
||||
function opArrange(spread: BookSpread, op: Op<'arrange'>, env: ElementOpEnv): BookSpread {
|
||||
const chosen = new Set(namedElements(spread, op.elementIds, env).map((e) => e.id));
|
||||
const pick = (want: boolean) => spread.elements.filter((e) => chosen.has(e.id) === want);
|
||||
if (op.to === 'front') return { ...spread, elements: [...pick(false), ...pick(true)] };
|
||||
if (op.to === 'back') return { ...spread, elements: [...pick(true), ...pick(false)] };
|
||||
const els = spread.elements.slice();
|
||||
const step = op.to === 'forward' ? 1 : -1;
|
||||
const order = els.map((_, i) => i);
|
||||
if (step === 1) order.reverse();
|
||||
for (const i of order) {
|
||||
const here = els[i];
|
||||
const there = els[i + step];
|
||||
if (!here || !there || !chosen.has(here.id) || chosen.has(there.id)) continue;
|
||||
els[i] = there;
|
||||
els[i + step] = here;
|
||||
}
|
||||
return { ...spread, elements: els };
|
||||
}
|
||||
|
||||
// ── align, distribute ──────────────────────────────────────────────────────
|
||||
// ── arrange, align, distribute (book-arrange.ts) ───────────────────────────
|
||||
|
||||
/** A frame in millimetres, as the book types it, so a zod-inferred `el.frame` passes in every compile mode. */
|
||||
type Box = BookFrame;
|
||||
|
||||
function boundsOf(frames: readonly Box[]): Box {
|
||||
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 };
|
||||
}
|
||||
|
||||
/** The page an element's centre is on, or its safe area. */
|
||||
function pageBox(spread: BookSpread, page: BookPageSetup, f: Box, safe: boolean): Box {
|
||||
const onRight = isInner(spread) && f.x + f.w / 2 >= page.pageWidth;
|
||||
const x = onRight ? page.pageWidth : 0;
|
||||
const inset = safe ? page.safe : 0;
|
||||
return { x: x + inset, y: inset, w: page.pageWidth - inset * 2, h: page.pageHeight - inset * 2 };
|
||||
}
|
||||
|
||||
function alignedFrame(f: Box, box: Box, edge: Op<'align'>['edge']): BookFrame {
|
||||
switch (edge) {
|
||||
case 'left':
|
||||
return { ...f, x: round2(box.x) };
|
||||
case 'center':
|
||||
return { ...f, x: round2(box.x + (box.w - f.w) / 2) };
|
||||
case 'right':
|
||||
return { ...f, x: round2(box.x + box.w - f.w) };
|
||||
case 'top':
|
||||
return { ...f, y: round2(box.y) };
|
||||
case 'middle':
|
||||
return { ...f, y: round2(box.y + (box.h - f.h) / 2) };
|
||||
case 'bottom':
|
||||
return { ...f, y: round2(box.y + box.h - f.h) };
|
||||
}
|
||||
function opArrange(spread: BookSpread, op: Op<'arrange'>, env: ElementOpEnv): BookSpread {
|
||||
const chosen = namedElements(spread, op.elementIds, env).map((e) => e.id);
|
||||
return arrangeElements(spread, chosen, op.to);
|
||||
}
|
||||
|
||||
function opAlign(spread: BookSpread, op: Op<'align'>, env: ElementOpEnv): BookSpread {
|
||||
const els = namedElements(spread, op.elementIds, env);
|
||||
const selection = boundsOf(els.map((e) => e.frame));
|
||||
const next = els.map((e) => {
|
||||
const box = op.to === 'selection' ? selection : pageBox(spread, env.page, e.frame, op.to === 'safe');
|
||||
return { ...e, frame: alignedFrame(e.frame, box, op.edge) } as BookElement;
|
||||
});
|
||||
return replaceAll(spread, next);
|
||||
return alignElements(spread, env.page, namedElements(spread, op.elementIds, env), op.edge, op.to);
|
||||
}
|
||||
|
||||
function opDistribute(spread: BookSpread, op: Op<'distribute'>, env: ElementOpEnv): BookSpread {
|
||||
const els = namedElements(spread, op.elementIds, env);
|
||||
if (els.length < 3) throw failOp('not_enough_elements', 'distribute needs three different elements', env.opIndex);
|
||||
const across = op.axis === 'horizontal';
|
||||
const start = (f: Box) => (across ? f.x : f.y);
|
||||
const size = (f: Box) => (across ? f.w : f.h);
|
||||
const sorted = els.slice().sort((a, b) => start(a.frame) - start(b.frame));
|
||||
const first = sorted[0] as BookElement;
|
||||
const last = sorted[sorted.length - 1] as BookElement;
|
||||
const total = sorted.reduce((sum, e) => sum + size(e.frame), 0);
|
||||
const span = start(last.frame) + size(last.frame) - start(first.frame);
|
||||
const gap = op.gap ?? (span - total) / (sorted.length - 1);
|
||||
let cursor = start(first.frame);
|
||||
const next = sorted.map((e) => {
|
||||
const at = round2(cursor);
|
||||
cursor += size(e.frame) + gap;
|
||||
return { ...e, frame: across ? { ...e.frame, x: at } : { ...e.frame, y: at } } as BookElement;
|
||||
});
|
||||
return replaceAll(spread, next);
|
||||
if (distributableCount(spread, els) < 3) {
|
||||
throw failOp('not_enough_elements', 'distribute needs three different elements or groups', env.opIndex);
|
||||
}
|
||||
return distributeElements(spread, els, op.axis, op.gap);
|
||||
}
|
||||
|
||||
// ── duplicate, fill, copy_from ─────────────────────────────────────────────
|
||||
@@ -503,7 +437,7 @@ function opDuplicate(spread: BookSpread, op: Op<'duplicate'>, env: ElementOpEnv)
|
||||
const frame = { ...e.frame, x: round2(e.frame.x + dx), y: round2(e.frame.y + dy) };
|
||||
return { ...e, id: env.newId(prefixOf(e)), frame } as BookElement;
|
||||
});
|
||||
return { ...spread, elements: [...spread.elements, ...copies] };
|
||||
return withCopies(spread, regroupCopies(copies, spread, env.newId));
|
||||
}
|
||||
|
||||
/** The inspector's fill: the photo to the bleed of the page its centre is on, or of the spread, at the back. */
|
||||
@@ -521,8 +455,9 @@ function opFill(spread: BookSpread, op: Op<'fill'>, env: ElementOpEnv): BookSpre
|
||||
w: (spanning ? 2 : 1) * pageWidth + 2 * bleed,
|
||||
h: pageHeight + 2 * bleed,
|
||||
};
|
||||
const filled: BookElement = { ...el, frame, rotation: 0, fit: 'cover' };
|
||||
return { ...spread, elements: [filled, ...spread.elements.filter((e) => e.id !== el.id)] };
|
||||
// A picture that is the page is nobody's lockup: it leaves its group.
|
||||
const filled: BookElement = { ...withoutGroup(el), frame, rotation: 0, fit: 'cover' };
|
||||
return tidyGroups({ ...spread, elements: [filled, ...spread.elements.filter((e) => e.id !== el.id)] });
|
||||
}
|
||||
|
||||
/** Which page of its spread an element is on: the left (or only) page is 0, the right one starts at the fold. */
|
||||
@@ -541,7 +476,7 @@ function opCopyFrom(spread: BookSpread, op: Op<'copy_from'>, env: ElementOpEnv):
|
||||
const x = sameKind ? e.frame.x : e.frame.x - pageOrigin(source, env.page, e.frame) + target;
|
||||
return { ...e, id: env.newId(prefixOf(e)), frame: { ...e.frame, x: round2(x) } } as BookElement;
|
||||
});
|
||||
return { ...spread, elements: [...spread.elements, ...copies] };
|
||||
return withCopies(spread, regroupCopies(copies, source, env.newId));
|
||||
}
|
||||
|
||||
// ── refresh_travel, add_grid ───────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { spreadFindings } from './book-findings';
|
||||
import { designFindings } from './book-findings-design';
|
||||
import { applyBookOps } from './book-ops';
|
||||
import { isGroupOp } from './book-ops-groups';
|
||||
import { bookPageOpSchema, type BookPageOpInput } from './book-ops.schema';
|
||||
import { blankBook } from './book-pages';
|
||||
import {
|
||||
bookPhotoElementSchema,
|
||||
bookShapeElementSchema,
|
||||
bookSpreadSchema,
|
||||
bookTextElementSchema,
|
||||
type BookDocument,
|
||||
type BookElement,
|
||||
type BookSpread,
|
||||
} from './book.schema';
|
||||
import { describeSpread } from './describe-spread';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* The layer operations the assistant sends, `group`, `ungroup`, `name` and
|
||||
* `hide`, run by the one engine, and what grouping changes about the other
|
||||
* operations, the findings and the words describe_book_page reads out.
|
||||
*/
|
||||
|
||||
let seq = 0;
|
||||
const newId = (p: string) => `${p}-n${++seq}`;
|
||||
|
||||
type Frame = { x: number; y: number; w: number; h: number };
|
||||
|
||||
const text = (id: string, words: string, frame: Frame = { x: 230, y: 120, w: 160, h: 40 }, over = {}) =>
|
||||
bookTextElementSchema.parse({ id, kind: 'text', frame, text: words, size: 10, ...over }) as BookElement;
|
||||
const shape = (id: string, x: number, y: number, over = {}) =>
|
||||
bookShapeElementSchema.parse({
|
||||
id,
|
||||
kind: 'shape',
|
||||
frame: { x, y, w: 20, h: 20 },
|
||||
shape: 'rect',
|
||||
...over,
|
||||
}) as BookElement;
|
||||
const photo = (id: string, frame: Frame, over = {}) =>
|
||||
bookPhotoElementSchema.parse({ id, kind: 'photo', frame, photoId: 1, ...over }) as BookElement;
|
||||
|
||||
function book(elements: BookElement[], over: Partial<BookSpread> = {}): BookDocument {
|
||||
const doc = blankBook({ title: 'Iceland', newId: (p) => `${p}-x` });
|
||||
const ids = ['sp-cover', 'sp-first', 'sp-in', 'sp-last', 'sp-back'];
|
||||
const spreads = doc.spreads.map((s, i) => ({ ...s, id: ids[i] as string }));
|
||||
spreads[2] = { ...(spreads[2] as BookSpread), elements, ...over };
|
||||
return { ...doc, spreads };
|
||||
}
|
||||
|
||||
const inner = (doc: BookDocument) => doc.spreads[2] as BookSpread;
|
||||
const run = (doc: BookDocument, ops: BookPageOpInput[]) => applyBookOps(doc, { spreadId: 'sp-in', ops }, { newId });
|
||||
|
||||
function ok(result: ReturnType<typeof applyBookOps>): BookDocument {
|
||||
if (!result.ok) throw new Error(`refused: ${result.error.code} ${result.error.message}`);
|
||||
expect(bookSpreadSchema.safeParse(inner(result.doc)).success).toBe(true);
|
||||
return result.doc;
|
||||
}
|
||||
const refusal = (result: ReturnType<typeof applyBookOps>) => (result.ok ? null : result.error.code);
|
||||
const order = (doc: BookDocument) => inner(doc).elements.map((e) => `${e.id}${e.group ? ':g' : ''}`);
|
||||
|
||||
describe('the layer operations', () => {
|
||||
const page = () => book([shape('a', 230, 20), text('t', 'Title'), shape('b', 300, 20), shape('c', 330, 20)]);
|
||||
|
||||
it('BKGOPS-001: groups elements under a new id and name, gathered together at the topmost', () => {
|
||||
const doc = ok(run(page(), [{ op: 'group', elementIds: ['a', 'b'], name: 'Sun and sea' }]));
|
||||
expect(order(doc)).toEqual(['t', 'a:g', 'b:g', 'c']);
|
||||
const groupId = inner(doc).elements[1]?.group;
|
||||
expect(groupId).toMatch(/^g-n\d+$/);
|
||||
expect(inner(doc).groups).toEqual([{ id: groupId, name: 'Sun and sea' }]);
|
||||
});
|
||||
|
||||
it('BKGOPS-002: refuses a locked element, a missing one, and one element twice', () => {
|
||||
const locked = book([shape('a', 230, 20, { locked: true }), shape('b', 300, 20)]);
|
||||
expect(refusal(run(locked, [{ op: 'group', elementIds: ['a', 'b'] }]))).toBe('locked');
|
||||
expect(refusal(run(page(), [{ op: 'group', elementIds: ['a', 'zz'] }]))).toBe('element_not_found');
|
||||
expect(refusal(run(page(), [{ op: 'group', elementIds: ['a', 'a'] }]))).toBe('not_enough_elements');
|
||||
});
|
||||
|
||||
it('BKGOPS-003: ungroups, names and hides, locked elements too, and says which group is missing', () => {
|
||||
const grouped = ok(run(page(), [{ op: 'group', elementIds: ['a', 'b'] }]));
|
||||
const groupId = inner(grouped).elements.find((e) => e.id === 'a')?.group as string;
|
||||
const named = ok(
|
||||
run(grouped, [
|
||||
{ op: 'name', groupId, name: 'Pair' },
|
||||
{ op: 'name', elementId: 't', name: 'Heading' },
|
||||
]),
|
||||
);
|
||||
expect(inner(named).groups).toEqual([{ id: groupId, name: 'Pair' }]);
|
||||
expect(inner(named).elements.find((e) => e.id === 't')?.name).toBe('Heading');
|
||||
const apart = ok(run(named, [{ op: 'ungroup', groupId }]));
|
||||
expect(order(apart)).toEqual(['t', 'a', 'b', 'c']);
|
||||
expect(inner(apart).groups).toBeUndefined();
|
||||
expect(refusal(run(apart, [{ op: 'ungroup', groupId }]))).toBe('group_not_found');
|
||||
expect(refusal(run(apart, [{ op: 'name', groupId: 'g-none', name: 'x' }]))).toBe('group_not_found');
|
||||
expect(refusal(run(apart, [{ op: 'name', elementId: 'zz', name: 'x' }]))).toBe('element_not_found');
|
||||
|
||||
const pinned = book([shape('a', 230, 20, { locked: true })]);
|
||||
const hidden = ok(run(pinned, [{ op: 'hide', elementIds: ['a'], hidden: true }]));
|
||||
expect(inner(hidden).elements[0]?.hidden).toBe(true);
|
||||
const shown = ok(run(hidden, [{ op: 'hide', elementIds: ['a'], hidden: false }]));
|
||||
expect('hidden' in (inner(shown).elements[0] as object)).toBe(false);
|
||||
expect(refusal(run(pinned, [{ op: 'hide', elementIds: ['zz'], hidden: true }]))).toBe('element_not_found');
|
||||
});
|
||||
|
||||
it('BKGOPS-004: the contract wants an element or a group to name, and two elements to group', () => {
|
||||
expect(bookPageOpSchema.safeParse({ op: 'name', name: 'x' }).success).toBe(false);
|
||||
expect(bookPageOpSchema.safeParse({ op: 'name', name: 'x', elementId: 'a', groupId: 'g' }).success).toBe(false);
|
||||
expect(bookPageOpSchema.safeParse({ op: 'group', elementIds: ['a'] }).success).toBe(false);
|
||||
expect(bookPageOpSchema.safeParse({ op: 'name', name: 'x'.repeat(61), elementId: 'a' }).success).toBe(false);
|
||||
expect(isGroupOp({ op: 'group' })).toBe(true);
|
||||
expect(isGroupOp({ op: 'align' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the other operations, with groups', () => {
|
||||
const grouped = () =>
|
||||
book([shape('a', 230, 20, { group: 'g' }), shape('b', 260, 40, { group: 'g' }), shape('c', 330, 20)], {
|
||||
groups: [{ id: 'g', name: 'Lockup' }],
|
||||
});
|
||||
|
||||
it('BKGOPS-005: a duplicate of a group is a new group of the same name, never part of the old one', () => {
|
||||
const doc = ok(run(grouped(), [{ op: 'duplicate', elementIds: ['a', 'b'] }]));
|
||||
const els = inner(doc).elements;
|
||||
const copies = els.slice(3);
|
||||
expect(copies[0]?.group).toBe(copies[1]?.group);
|
||||
expect(copies[0]?.group).not.toBe('g');
|
||||
expect(inner(doc).groups).toEqual([
|
||||
{ id: 'g', name: 'Lockup' },
|
||||
{ id: copies[0]?.group, name: 'Lockup' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('BKGOPS-006: copy_from brings a group as a group of its own', () => {
|
||||
const doc = grouped();
|
||||
const withSource = {
|
||||
...doc,
|
||||
spreads: doc.spreads.map((s, i) => (i === 3 ? { ...inner(doc), id: 'sp-last', role: 'last' as const } : s)),
|
||||
};
|
||||
const empty = {
|
||||
...withSource,
|
||||
spreads: withSource.spreads.map((s, i) => (i === 2 ? { ...s, elements: [], groups: undefined } : s)),
|
||||
};
|
||||
const done = ok(
|
||||
applyBookOps(
|
||||
empty,
|
||||
{ spreadId: 'sp-in', ops: [{ op: 'copy_from', spreadId: 'sp-last', elementIds: ['a', 'b'] }] },
|
||||
{ newId },
|
||||
),
|
||||
);
|
||||
const [a, b] = inner(done).elements;
|
||||
expect(a?.group).toBe(b?.group);
|
||||
expect(a?.group).not.toBe('g');
|
||||
expect(inner(done).groups?.[0]?.name).toBe('Lockup');
|
||||
});
|
||||
|
||||
it('BKGOPS-007: removing a member leaves no group of one; aligning moves the group as one box', () => {
|
||||
const removed = ok(run(grouped(), [{ op: 'remove', elementIds: ['a'], park: false }]));
|
||||
expect(order(removed)).toEqual(['b', 'c']);
|
||||
expect(inner(removed).groups).toBeUndefined();
|
||||
const aligned = ok(run(grouped(), [{ op: 'align', elementIds: ['a', 'b', 'c'], edge: 'top' }]));
|
||||
const ys = inner(aligned).elements.map((e) => e.frame.y);
|
||||
// The group's box starts at 20 already, so it stays; c is at 20 too.
|
||||
expect(ys).toEqual([20, 40, 20]);
|
||||
const arranged = ok(run(grouped(), [{ op: 'arrange', elementIds: ['c'], to: 'back' }]));
|
||||
expect(order(arranged)).toEqual(['c', 'a:g', 'b:g']);
|
||||
});
|
||||
|
||||
it('BKGOPS-008: distribute counts a group once', () => {
|
||||
const doc = book([
|
||||
shape('a', 0, 0),
|
||||
shape('g1', 50, 0, { group: 'g' }),
|
||||
shape('g2', 60, 0, { group: 'g' }),
|
||||
shape('c', 200, 0),
|
||||
]);
|
||||
const spaced = ok(run(doc, [{ op: 'distribute', elementIds: ['a', 'g1', 'g2', 'c'], axis: 'horizontal' }]));
|
||||
const x = (id: string) => inner(spaced).elements.find((e) => e.id === id)?.frame.x;
|
||||
// Span 0..220, sizes 20 + 30 + 20, so gaps of 75; the group keeps its shape.
|
||||
expect([x('g1'), x('g2'), x('c')]).toEqual([95, 105, 200]);
|
||||
const two = book([shape('g1', 50, 0, { group: 'g' }), shape('g2', 60, 0, { group: 'g' }), shape('c', 200, 0)]);
|
||||
expect(refusal(run(two, [{ op: 'distribute', elementIds: ['g1', 'g2', 'c'], axis: 'horizontal' }]))).toBe(
|
||||
'not_enough_elements',
|
||||
);
|
||||
});
|
||||
|
||||
it('BKGOPS-009: a picture filled to the page leaves its group', () => {
|
||||
const doc = book([
|
||||
photo('p', { x: 230, y: 20, w: 50, h: 40 }, { group: 'g' }),
|
||||
shape('s', 300, 20, { group: 'g' }),
|
||||
]);
|
||||
const filled = ok(run(doc, [{ op: 'fill', elementId: 'p', scope: 'page' }]));
|
||||
expect(inner(filled).elements.every((e) => e.group === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a model reads of groups', () => {
|
||||
it('BKGOPS-010: describe_book_page names the groups, their members, the hidden and the named', () => {
|
||||
const doc = book(
|
||||
[
|
||||
text('t1', 'Iceland', undefined, { group: 'g-1', name: 'Title' }),
|
||||
text('t2', 'June 2026', { x: 230, y: 170, w: 160, h: 20 }, { group: 'g-1' }),
|
||||
shape('s', 300, 20, { hidden: true }),
|
||||
],
|
||||
{ groups: [{ id: 'g-1', name: 'Title lockup' }] },
|
||||
);
|
||||
const page = describeSpread(doc, 'sp-in');
|
||||
expect(page?.text).toContain(
|
||||
'Groups, each picked, moved, aligned and arranged as one: g-1 "Title lockup" (t1, t2)',
|
||||
);
|
||||
expect(page?.text).toContain('in group g-1');
|
||||
expect(page?.text).toContain('named "Title"');
|
||||
expect(page?.text).toContain('hidden (not on the page or in print)');
|
||||
expect(page?.elements[0]).toMatchObject({ group: 'g-1', name: 'Title' });
|
||||
expect(page?.elements[2]).toMatchObject({ hidden: true });
|
||||
expect(describeSpread(book([shape('s', 300, 20)]), 'sp-in')?.text).not.toContain('Groups,');
|
||||
});
|
||||
|
||||
it('BKGOPS-011: two texts set over each other inside one group are a lockup, not an accident', () => {
|
||||
const over = [
|
||||
text('t1', 'Iceland', { x: 230, y: 120, w: 160, h: 40 }),
|
||||
text('t2', 'Island', { x: 235, y: 125, w: 160, h: 40 }),
|
||||
];
|
||||
const loose = spreadFindings(inner(book(over)), book(over).page);
|
||||
expect(loose.some((f) => f.code === 'overlap')).toBe(true);
|
||||
const lockup = over.map((e) => ({ ...e, group: 'g' }) as BookElement);
|
||||
expect(spreadFindings(inner(book(lockup)), book(lockup).page).some((f) => f.code === 'overlap')).toBe(false);
|
||||
});
|
||||
|
||||
it('BKGOPS-012: a hidden element is not on the page, so the checks leave it out', () => {
|
||||
const els = [
|
||||
photo('p1', { x: 230, y: 20, w: 80, h: 60 }),
|
||||
photo('p2', { x: 240, y: 30, w: 80, h: 60 }, { hidden: true }),
|
||||
];
|
||||
const doc = book(els);
|
||||
expect(spreadFindings(inner(doc), doc.page).some((f) => f.elementIds?.includes('p2'))).toBe(false);
|
||||
expect(designFindings(inner(doc), doc.page).some((f) => f.elementIds?.includes('p2'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { BOOK_GROUP_OP_SCHEMAS } from './book-group-ops.schema';
|
||||
import {
|
||||
groupElements,
|
||||
groupMembers,
|
||||
renameElement,
|
||||
renameGroup,
|
||||
setElementsHidden,
|
||||
ungroupElements,
|
||||
} from './book-groups';
|
||||
import { failOp, pageElement, unlockedElement } from './book-ops-core';
|
||||
import type { BookSpread } from './book.schema';
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
/**
|
||||
* What `group`, `ungroup`, `name` and `hide` do to a page, for `applyBookOps`.
|
||||
*
|
||||
* Grouping moves the members next to each other in the paint order, so a
|
||||
* locked element refuses it like any other change. Ungrouping, naming and
|
||||
* hiding move nothing on the page and take locked elements too, as the
|
||||
* Studio's layers panel does: a locked background can still be hidden to get
|
||||
* at what is behind it.
|
||||
*/
|
||||
|
||||
type GroupOp = z.infer<(typeof BOOK_GROUP_OP_SCHEMAS)[number]>;
|
||||
|
||||
interface GroupOpEnv {
|
||||
newId: (prefix: string) => string;
|
||||
opIndex: number;
|
||||
}
|
||||
|
||||
const BOOK_GROUP_OP_NAMES: ReadonlySet<string> = new Set(['group', 'ungroup', 'name', 'hide']);
|
||||
|
||||
export function isGroupOp(op: { op: string }): op is GroupOp {
|
||||
return BOOK_GROUP_OP_NAMES.has(op.op);
|
||||
}
|
||||
|
||||
function existingGroup(spread: BookSpread, groupId: string, opIndex: number): void {
|
||||
if (!groupMembers(spread, groupId).length) {
|
||||
throw failOp('group_not_found', `no group ${groupId} on this page`, opIndex);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyGroupOp(spread: BookSpread, op: GroupOp, env: GroupOpEnv): BookSpread {
|
||||
switch (op.op) {
|
||||
case 'group': {
|
||||
const ids = [...new Set(op.elementIds)];
|
||||
for (const id of ids) unlockedElement(pageElement(spread, id, env.opIndex), env.opIndex);
|
||||
if (ids.length < 2) throw failOp('not_enough_elements', 'a group needs two different elements', env.opIndex);
|
||||
return groupElements(spread, ids, env.newId('g'), op.name);
|
||||
}
|
||||
case 'ungroup':
|
||||
existingGroup(spread, op.groupId, env.opIndex);
|
||||
return ungroupElements(spread, op.groupId);
|
||||
case 'name':
|
||||
if (op.groupId !== undefined) {
|
||||
existingGroup(spread, op.groupId, env.opIndex);
|
||||
return renameGroup(spread, op.groupId, op.name);
|
||||
}
|
||||
pageElement(spread, op.elementId ?? '', env.opIndex);
|
||||
return renameElement(spread, op.elementId ?? '', op.name);
|
||||
case 'hide': {
|
||||
const ids = [...new Set(op.elementIds)];
|
||||
for (const id of ids) pageElement(spread, id, env.opIndex);
|
||||
return setElementsHidden(spread, ids, op.hidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { idSchema } from '../common/primitives.schema';
|
||||
import { BOOK_ELEMENT_OP_SCHEMAS, TEXT_STYLE_FIELDS, bindingNamesItsSource } from './book-element-ops.schema';
|
||||
import { BOOK_GROUP_OP_SCHEMAS } from './book-group-ops.schema';
|
||||
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES } from './book-layouts';
|
||||
import {
|
||||
BOOK_BINDING_HINT,
|
||||
@@ -191,6 +192,7 @@ export const bookPageOpSchema = z.discriminatedUnion('op', [
|
||||
/** The page colour; null is the paper. */
|
||||
z.strictObject({ op: z.literal('background'), color: bookSpreadSchema.shape.background.unwrap() }),
|
||||
...BOOK_ELEMENT_OP_SCHEMAS,
|
||||
...BOOK_GROUP_OP_SCHEMAS,
|
||||
]);
|
||||
export type BookPageOp = z.infer<typeof bookPageOpSchema>;
|
||||
export type BookPageOpInput = z.input<typeof bookPageOpSchema>;
|
||||
@@ -452,6 +454,10 @@ export const bookElementSummarySchema = z.object({
|
||||
photoId: z.number().int().nullable().optional(),
|
||||
text: z.string().max(80).optional(),
|
||||
binding: z.string().optional(),
|
||||
/** The group it is in, its layer name, and whether it is hidden; absent when not. */
|
||||
group: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
});
|
||||
export type BookElementSummary = z.infer<typeof bookElementSummarySchema>;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bookGridElements, defaultBookGridBox, findBookGrid, type BookGridDef } from './book-grids';
|
||||
import { tidyGroups } from './book-groups';
|
||||
import { bookElementId } from './book-ids';
|
||||
import { BOOK_TEXT_STYLES, applyBookTemplate, classifyBookTexts, findBookTemplate } from './book-layouts';
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type BookPhotoInfo,
|
||||
} from './book-ops-core';
|
||||
import { applyElementOp, autoRelayout } from './book-ops-elements';
|
||||
import { applyGroupOp, isGroupOp } from './book-ops-groups';
|
||||
import { BOOK_OP_LIMITS, bookPageOpSchema, type BookPageOp } from './book-ops.schema';
|
||||
import { applySavedBookLayout, bookLayoutRole } from './book-saved-layouts';
|
||||
import {
|
||||
@@ -86,6 +88,8 @@ export function applyBookOps(
|
||||
env.opIndex = i;
|
||||
spread = applyOne(spread, op, env);
|
||||
});
|
||||
// A removal or a layout can leave a group with one member: it goes, with its name.
|
||||
spread = tidyGroups(spread);
|
||||
checkedSpread(spread, null);
|
||||
const spreads = doc.spreads.slice();
|
||||
spreads[index] = spread;
|
||||
@@ -120,7 +124,7 @@ function applyOne(spread: BookSpread, op: BookPageOp, env: Env): BookSpread {
|
||||
case 'background':
|
||||
return { ...spread, background: op.color };
|
||||
default:
|
||||
return applyElementOp(spread, op, env);
|
||||
return isGroupOp(op) ? applyGroupOp(spread, op, env) : applyElementOp(spread, op, env);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,9 +38,12 @@ const doc = (over: Record<string, unknown> = {}) => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
/** A revision newer than this build: what an older reader meets. */
|
||||
const NEXT = BOOK_SCHEMA_REV + 1;
|
||||
|
||||
describe('the revision a document was written by', () => {
|
||||
it('BKREV-001: reads 1 for every document from before the field, and for anything that is not a whole number', () => {
|
||||
expect(BOOK_SCHEMA_REV).toBe(1);
|
||||
expect(Number.isInteger(BOOK_SCHEMA_REV) && BOOK_SCHEMA_REV >= 1).toBe(true);
|
||||
expect(bookSchemaRev(doc())).toBe(1);
|
||||
expect(bookSchemaRev(null)).toBe(1);
|
||||
expect(bookSchemaRev('{"schemaRev":4}')).toBe(1);
|
||||
@@ -59,15 +62,16 @@ describe('the revision a document was written by', () => {
|
||||
it('BKREV-003: leaves a book saved today as it was, with no revision written into it', () => {
|
||||
const today = normalizeBookDocument(doc());
|
||||
expect('schemaRev' in today).toBe(false);
|
||||
expect(stampBookSchemaRev(today)).toBe(today);
|
||||
expect(JSON.stringify(stampBookSchemaRev(today))).toBe(JSON.stringify(today));
|
||||
// A writer of revision 1 writes nothing; a later one stamps its own (BKREV-006).
|
||||
expect(stampBookSchemaRev(today, 1)).toBe(today);
|
||||
expect(JSON.stringify(stampBookSchemaRev(today, 1))).toBe(JSON.stringify(today));
|
||||
});
|
||||
});
|
||||
|
||||
describe('a reader older than the document', () => {
|
||||
it('BKREV-004: knows the document is newer than it, read from the raw value even when the rest will not parse', () => {
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: 2 }))).toBe(true);
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: 1 }))).toBe(false);
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: NEXT }))).toBe(true);
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: BOOK_SCHEMA_REV }))).toBe(false);
|
||||
expect(isBookNewerThanReader(doc())).toBe(false);
|
||||
// A reader of a later revision reads the same document as its own.
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: 2 }), 2)).toBe(false);
|
||||
@@ -77,12 +81,12 @@ describe('a reader older than the document', () => {
|
||||
|
||||
it('BKREV-005: reads a newer book as far as it can and says so, so nothing it dropped is saved', () => {
|
||||
const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
|
||||
const raw = doc({ schemaRev: 2 });
|
||||
const raw = doc({ schemaRev: NEXT });
|
||||
const spread = (raw.spreads as { elements: unknown[] }[])[0];
|
||||
if (spread) spread.elements.push(sticker);
|
||||
const read = readBookDocument(raw);
|
||||
expect(read.newer).toBe(true);
|
||||
expect(read.document.schemaRev).toBe(2);
|
||||
expect(read.document.schemaRev).toBe(NEXT);
|
||||
expect(read.document.spreads[0]?.elements.map((e) => e.id)).toEqual(['t1']);
|
||||
expect(readBookDocument(doc()).newer).toBe(false);
|
||||
});
|
||||
@@ -99,10 +103,10 @@ describe('a reader older than the document', () => {
|
||||
describe('a save request carrying a newer book', () => {
|
||||
it('BKREV-007: reaches the server as its revision alone, so the save is refused there rather than at the pipe', () => {
|
||||
const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
|
||||
const raw = doc({ schemaRev: 2 });
|
||||
const raw = doc({ schemaRev: NEXT });
|
||||
const spread = (raw.spreads as { elements: unknown[] }[])[0];
|
||||
if (spread) spread.elements.push(sticker);
|
||||
expect(bookSaveRequestSchema.parse({ title: 'T', document: raw }).document).toEqual({ schemaRev: 2 });
|
||||
expect(bookSaveRequestSchema.parse({ title: 'T', document: raw }).document).toEqual({ schemaRev: NEXT });
|
||||
// A book of today still reads whole, revision or none.
|
||||
const today = bookSaveRequestSchema.parse({ title: 'T', document: doc() }).document;
|
||||
expect(today).toEqual(bookDocumentSchema.parse(doc()));
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { BookDocument } from './book.schema';
|
||||
* saves only once that is above 1, so a book saved today stores exactly what
|
||||
* it stored before this existed.
|
||||
*/
|
||||
export const BOOK_SCHEMA_REV = 1;
|
||||
export const BOOK_SCHEMA_REV = 2;
|
||||
|
||||
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
|
||||
export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { bookElementStyle, bookStyleKeys, pastableStyle } from './book-style-copy';
|
||||
import {
|
||||
bookIconElementSchema,
|
||||
bookListElementSchema,
|
||||
bookShapeElementSchema,
|
||||
bookTextElementSchema,
|
||||
type BookElement,
|
||||
} from './book.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** Copy and paste style: the look `style` may set, carried onto any kind that takes it. */
|
||||
|
||||
const frame = { x: 0, y: 0, w: 40, h: 20 };
|
||||
const text = (over = {}) =>
|
||||
bookTextElementSchema.parse({ id: 't', kind: 'text', frame, text: 'Hi', ...over }) as BookElement;
|
||||
const icon = (over = {}) =>
|
||||
bookIconElementSchema.parse({ id: 'i', kind: 'icon', frame, name: 'Sun', ...over }) as BookElement;
|
||||
const shape = (over = {}) =>
|
||||
bookShapeElementSchema.parse({ id: 's', kind: 'shape', frame, shape: 'rect', ...over }) as BookElement;
|
||||
|
||||
describe('copy style', () => {
|
||||
it('BKSTY-001: takes the look of an element and none of what it says or where it is', () => {
|
||||
const style = bookElementStyle(text({ size: 24, color: '#ff0000', weight: 700, opacity: 0.5 }));
|
||||
expect(style.kind).toBe('text');
|
||||
expect(style.props).toMatchObject({ size: 24, color: '#ff0000', weight: 700, opacity: 0.5 });
|
||||
expect(style.props).not.toHaveProperty('text');
|
||||
expect(style.props).not.toHaveProperty('frame');
|
||||
});
|
||||
|
||||
it('BKSTY-002: leaves out the props of `style` that are words rather than looks', () => {
|
||||
const list = bookListElementSchema.parse({ id: 'l', kind: 'list', frame, proLabel: 'Good' }) as BookElement;
|
||||
expect(bookStyleKeys('list')).not.toContain('proLabel');
|
||||
expect(bookElementStyle(list).props).not.toHaveProperty('proLabel');
|
||||
expect(bookStyleKeys('stats')).not.toContain('metrics');
|
||||
});
|
||||
|
||||
it('BKSTY-003: pastes all of it onto the same kind, and what both have onto another', () => {
|
||||
const style = bookElementStyle(text({ size: 24, color: '#ff0000', opacity: 0.5 }));
|
||||
expect(pastableStyle(style, 'text')).toMatchObject({ kind: 'text', size: 24, color: '#ff0000' });
|
||||
// An icon has a colour and an opacity, no type size.
|
||||
const onIcon = pastableStyle(style, 'icon');
|
||||
expect(onIcon).toEqual({ kind: 'icon', color: '#ff0000', opacity: 0.5 });
|
||||
});
|
||||
|
||||
it('BKSTY-004: answers null when the two kinds share nothing that was set', () => {
|
||||
const style = { kind: 'icon' as const, props: { lineWidth: 2 } };
|
||||
expect(pastableStyle(style, 'shape')).toBeNull();
|
||||
expect(pastableStyle(bookElementStyle(shape({ fill: '#00ff00' })), 'shape')).toMatchObject({ fill: '#00ff00' });
|
||||
expect(pastableStyle(bookElementStyle(icon({ color: '#123456' })), 'icon')).toMatchObject({ color: '#123456' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { bookElementPropsSchema, type BookElementProps } from './book-element-ops.schema';
|
||||
import type { BookElement } from './book.schema';
|
||||
|
||||
/**
|
||||
* Copy and paste style: the look of one element taken onto others.
|
||||
*
|
||||
* The look is exactly what the `style` operation may set for the element's
|
||||
* kind (book-element-ops.schema.ts), less the few props that are words rather
|
||||
* than looks (a list's column labels, the figures a stats block shows). So a
|
||||
* pasted style is one `style` operation per element, the same thing the
|
||||
* assistant sends, and onto an element of another kind it carries what both
|
||||
* kinds have: a text's colour onto an icon, the opacity onto anything.
|
||||
*/
|
||||
|
||||
export interface BookCopiedStyle {
|
||||
kind: BookElement['kind'];
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Props of `style` that say what an element shows, not how it looks. */
|
||||
const CONTENT_KEYS: ReadonlySet<string> = new Set(['metrics', 'proLabel', 'conLabel']);
|
||||
|
||||
const STYLE_KEYS = new Map<string, string[]>(
|
||||
bookElementPropsSchema.options.map((o) => [
|
||||
o.shape.kind.value,
|
||||
Object.keys(o.shape).filter((k) => k !== 'kind' && !CONTENT_KEYS.has(k)),
|
||||
]),
|
||||
);
|
||||
|
||||
/** The look fields an element of a kind takes from a pasted style. */
|
||||
export function bookStyleKeys(kind: BookElement['kind']): readonly string[] {
|
||||
return STYLE_KEYS.get(kind) ?? [];
|
||||
}
|
||||
|
||||
/** The look of an element, as the clipboard keeps it. */
|
||||
export function bookElementStyle(el: BookElement): BookCopiedStyle {
|
||||
const source = el as unknown as Record<string, unknown>;
|
||||
const props: Record<string, unknown> = {};
|
||||
for (const key of bookStyleKeys(el.kind)) if (source[key] !== undefined) props[key] = source[key];
|
||||
return { kind: el.kind, props };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `style` operation's `set` for pasting a copied look onto an element of
|
||||
* `kind`: what both kinds have, or null when they share nothing.
|
||||
*/
|
||||
export function pastableStyle(style: BookCopiedStyle, kind: BookElement['kind']): BookElementProps | null {
|
||||
const set: Record<string, unknown> = { kind };
|
||||
let any = false;
|
||||
for (const key of bookStyleKeys(kind)) {
|
||||
if (style.props[key] === undefined) continue;
|
||||
set[key] = style.props[key];
|
||||
any = true;
|
||||
}
|
||||
if (!any) return null;
|
||||
const parsed = bookElementPropsSchema.safeParse(set);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
@@ -167,6 +167,10 @@ export type BookShapeId = (typeof BOOK_SHAPES)[number];
|
||||
export const BOOK_FONTS_IDS = ['sans', 'inter', 'serif', 'garamond', 'playfair', 'display', 'bebas'] as const;
|
||||
export type BookFontFamily = (typeof BOOK_FONTS_IDS)[number];
|
||||
|
||||
/** How long a group id and a layer's name may be. */
|
||||
export const BOOK_GROUP_ID_MAX = 40;
|
||||
export const BOOK_LAYER_NAME_MAX = 60;
|
||||
|
||||
export const bookFrameSchema = z.object({
|
||||
/** Millimetres from the left edge of the spread. Negative means bleed. */
|
||||
x: mm,
|
||||
@@ -184,6 +188,16 @@ const elementBase = {
|
||||
rotation: z.number().finite().default(0),
|
||||
opacity: z.number().min(0).max(1).default(1),
|
||||
locked: z.boolean().default(false),
|
||||
/**
|
||||
* The group the element belongs to on its spread, by the group's id (see
|
||||
* book-groups.ts). Absent for an element on its own. Optional rather than
|
||||
* defaulted, so a book without groups stores what it stored before.
|
||||
*/
|
||||
group: z.string().min(1).max(BOOK_GROUP_ID_MAX).optional(),
|
||||
/** What the layers panel calls it. Absent means the name of its kind. */
|
||||
name: z.string().max(BOOK_LAYER_NAME_MAX).optional(),
|
||||
/** Kept in the layers panel but left off the page, on screen and in print. */
|
||||
hidden: z.boolean().optional(),
|
||||
};
|
||||
|
||||
export const bookPhotoElementSchema = z.object({
|
||||
@@ -811,6 +825,13 @@ export type BookBadgeElement = z.infer<typeof bookBadgeElementSchema>;
|
||||
export type BookListElement = z.infer<typeof bookListElementSchema>;
|
||||
export type BookUnits = z.infer<typeof bookUnitsSchema>;
|
||||
|
||||
/** A named group of a spread. */
|
||||
export const bookGroupSchema = z.object({
|
||||
id: z.string().min(1).max(BOOK_GROUP_ID_MAX),
|
||||
name: z.string().max(BOOK_LAYER_NAME_MAX).optional(),
|
||||
});
|
||||
export type BookGroup = z.infer<typeof bookGroupSchema>;
|
||||
|
||||
export const bookSpreadSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
/**
|
||||
@@ -839,6 +860,12 @@ export const bookSpreadSchema = z.object({
|
||||
parked: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
|
||||
/** The journey entry this spread was generated from, if any. */
|
||||
entryId: z.number().int().nullable().default(null),
|
||||
/**
|
||||
* The names of the spread's groups. Membership is on the elements (`group`);
|
||||
* a group without a row here is simply unnamed. A list that does not read is
|
||||
* dropped rather than the page.
|
||||
*/
|
||||
groups: z.array(bookGroupSchema).max(MAX_SPREAD_ELEMENTS).optional().catch(undefined),
|
||||
});
|
||||
export type BookSpread = z.infer<typeof bookSpreadSchema>;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { photoDpi, spreadFindings } from './book-findings';
|
||||
import { designFindings, sortBookFindings } from './book-findings-design';
|
||||
import { BOOK_GRIDS } from './book-grids';
|
||||
import { bookGroupName, groupMembers, spreadGroupIds } from './book-groups';
|
||||
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES, type BookTemplate } from './book-layouts';
|
||||
import type { BookPhotoInfo } from './book-ops-core';
|
||||
import type {
|
||||
@@ -143,11 +144,26 @@ function elementLine(el: BookElement, i: number, photos: readonly BookPhotoInfo[
|
||||
el.rotation ? `turned ${n(el.rotation)} deg` : '',
|
||||
el.opacity < 1 ? `opacity ${n(el.opacity)}` : '',
|
||||
el.locked ? 'locked' : '',
|
||||
el.hidden ? 'hidden (not on the page or in print)' : '',
|
||||
el.group ? `in group ${el.group}` : '',
|
||||
el.name ? `named "${head(el.name, 40)}"` : '',
|
||||
].filter(Boolean);
|
||||
const tail = extra.length ? `; ${extra.join(', ')}` : '';
|
||||
return `${i + 1}. ${el.kind} ${el.id} at ${n(x)}, ${n(y)}, ${n(w)} x ${n(h)}: ${elementWords(el, photos)}${tail}`;
|
||||
}
|
||||
|
||||
/** The page's groups by id, with their names and members, so a model can move or align one as a whole. */
|
||||
function groupsLine(spread: BookSpread): string | null {
|
||||
const ids = spreadGroupIds(spread);
|
||||
if (!ids.length) return null;
|
||||
const rows = ids.map((id) => {
|
||||
const name = bookGroupName(spread, id);
|
||||
const members = groupMembers(spread, id).map((e) => e.id);
|
||||
return `${id}${name ? ` "${head(name, 40)}"` : ''} (${members.join(', ')})`;
|
||||
});
|
||||
return `Groups, each picked, moved, aligned and arranged as one: ${rows.join('; ')}`;
|
||||
}
|
||||
|
||||
function parkedLine(spread: BookSpread): string | null {
|
||||
if (!spread.parked.length) return null;
|
||||
const items = spread.parked
|
||||
@@ -188,6 +204,9 @@ function elementSummary(el: BookElement): BookElementSummary {
|
||||
...(el.kind === 'photo' ? { photoId: el.photoId } : {}),
|
||||
...(el.kind === 'text' ? { text: head(el.text, 80) } : {}),
|
||||
...(el.kind === 'text' && el.binding ? { binding: el.binding.source } : {}),
|
||||
...(el.group ? { group: el.group } : {}),
|
||||
...(el.name ? { name: el.name } : {}),
|
||||
...(el.hidden ? { hidden: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,6 +245,8 @@ export function describeSpread(
|
||||
} else {
|
||||
lines.push('The page is empty.');
|
||||
}
|
||||
const groups = groupsLine(spread);
|
||||
if (groups) lines.push(groups);
|
||||
const parked = parkedLine(spread);
|
||||
if (parked) lines.push(parked);
|
||||
lines.push(findings.length ? 'Findings:' : 'Findings: none.');
|
||||
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'الطبقات',
|
||||
'journey.studio.layers.empty': 'لا شيء في هذه الصفحة بعد. ما تضيفه يظهر هنا، من الأمام إلى الخلف.',
|
||||
'journey.studio.layers.hint':
|
||||
'من الأمام إلى الخلف. اسحب صفًا لإعادة ترتيبه، وانقر نقرًا مزدوجًا على اسم لإعادة تسميته.',
|
||||
'journey.studio.layers.group': 'مجموعة',
|
||||
'journey.studio.layers.groupAction': 'تجميع',
|
||||
'journey.studio.layers.ungroup': 'فك التجميع',
|
||||
'journey.studio.layers.hide': 'إخفاء',
|
||||
'journey.studio.layers.show': 'إظهار',
|
||||
'journey.studio.layers.rename': 'إعادة تسمية',
|
||||
'journey.studio.layers.expand': 'إظهار المحتوى',
|
||||
'journey.studio.layers.collapse': 'طي',
|
||||
'journey.studio.layers.align': 'محاذاة',
|
||||
'journey.studio.layers.alignLeft': 'محاذاة الحواف اليسرى',
|
||||
'journey.studio.layers.alignCenter': 'توسيط أفقي',
|
||||
'journey.studio.layers.alignRight': 'محاذاة الحواف اليمنى',
|
||||
'journey.studio.layers.alignTop': 'محاذاة الحواف العلوية',
|
||||
'journey.studio.layers.alignMiddle': 'توسيط عمودي',
|
||||
'journey.studio.layers.alignBottom': 'محاذاة الحواف السفلية',
|
||||
'journey.studio.layers.alignTo': 'بالنسبة إلى',
|
||||
'journey.studio.layers.toSelection': 'التحديد',
|
||||
'journey.studio.layers.toPage': 'الصفحة',
|
||||
'journey.studio.layers.toSafe': 'المنطقة الآمنة',
|
||||
'journey.studio.layers.distributeH': 'توزيع متساوٍ أفقيًا',
|
||||
'journey.studio.layers.distributeV': 'توزيع متساوٍ عموديًا',
|
||||
'journey.studio.layers.copyStyle': 'نسخ النمط',
|
||||
'journey.studio.layers.pasteStyle': 'لصق النمط',
|
||||
'journey.studio.layers.groupHint': 'انقر نقرًا مزدوجًا على عنصر للعمل داخل المجموعة.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Laylar',
|
||||
'journey.studio.layers.empty': 'Bu səhifədə hələ heç nə yoxdur. Əlavə etdiyin hər şey burada, öndən arxaya görünür.',
|
||||
'journey.studio.layers.hint':
|
||||
'Öndən arxaya. Sıranı dəyişmək üçün sətri sürüşdür, adı dəyişmək üçün ada iki dəfə klik et.',
|
||||
'journey.studio.layers.group': 'Qrup',
|
||||
'journey.studio.layers.groupAction': 'Qruplaşdır',
|
||||
'journey.studio.layers.ungroup': 'Qrupu ləğv et',
|
||||
'journey.studio.layers.hide': 'Gizlət',
|
||||
'journey.studio.layers.show': 'Göstər',
|
||||
'journey.studio.layers.rename': 'Adını dəyiş',
|
||||
'journey.studio.layers.expand': 'Məzmunu göstər',
|
||||
'journey.studio.layers.collapse': 'Yığ',
|
||||
'journey.studio.layers.align': 'Düzləndir',
|
||||
'journey.studio.layers.alignLeft': 'Sol kənarları düzləndir',
|
||||
'journey.studio.layers.alignCenter': 'Üfüqi mərkəzləşdir',
|
||||
'journey.studio.layers.alignRight': 'Sağ kənarları düzləndir',
|
||||
'journey.studio.layers.alignTop': 'Yuxarı kənarları düzləndir',
|
||||
'journey.studio.layers.alignMiddle': 'Şaquli mərkəzləşdir',
|
||||
'journey.studio.layers.alignBottom': 'Aşağı kənarları düzləndir',
|
||||
'journey.studio.layers.alignTo': 'Nəyə görə',
|
||||
'journey.studio.layers.toSelection': 'Seçim',
|
||||
'journey.studio.layers.toPage': 'Səhifə',
|
||||
'journey.studio.layers.toSafe': 'Təhlükəsiz sahə',
|
||||
'journey.studio.layers.distributeH': 'Üfüqi bərabər payla',
|
||||
'journey.studio.layers.distributeV': 'Şaquli bərabər payla',
|
||||
'journey.studio.layers.copyStyle': 'Üslubu kopyala',
|
||||
'journey.studio.layers.pasteStyle': 'Üslubu yapışdır',
|
||||
'journey.studio.layers.groupHint': 'Qrupun içində işləmək üçün elementə iki dəfə klik et.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Camadas',
|
||||
'journey.studio.layers.empty':
|
||||
'Ainda não há nada nesta página. O que você adicionar aparece aqui, da frente para trás.',
|
||||
'journey.studio.layers.hint':
|
||||
'Da frente para trás. Arraste uma linha para reordenar, clique duas vezes num nome para renomear.',
|
||||
'journey.studio.layers.group': 'Grupo',
|
||||
'journey.studio.layers.groupAction': 'Agrupar',
|
||||
'journey.studio.layers.ungroup': 'Desagrupar',
|
||||
'journey.studio.layers.hide': 'Ocultar',
|
||||
'journey.studio.layers.show': 'Mostrar',
|
||||
'journey.studio.layers.rename': 'Renomear',
|
||||
'journey.studio.layers.expand': 'Mostrar conteúdo',
|
||||
'journey.studio.layers.collapse': 'Recolher',
|
||||
'journey.studio.layers.align': 'Alinhar',
|
||||
'journey.studio.layers.alignLeft': 'Alinhar bordas esquerdas',
|
||||
'journey.studio.layers.alignCenter': 'Centralizar na horizontal',
|
||||
'journey.studio.layers.alignRight': 'Alinhar bordas direitas',
|
||||
'journey.studio.layers.alignTop': 'Alinhar bordas superiores',
|
||||
'journey.studio.layers.alignMiddle': 'Centralizar na vertical',
|
||||
'journey.studio.layers.alignBottom': 'Alinhar bordas inferiores',
|
||||
'journey.studio.layers.alignTo': 'Em relação a',
|
||||
'journey.studio.layers.toSelection': 'Seleção',
|
||||
'journey.studio.layers.toPage': 'Página',
|
||||
'journey.studio.layers.toSafe': 'Área de segurança',
|
||||
'journey.studio.layers.distributeH': 'Distribuir na horizontal',
|
||||
'journey.studio.layers.distributeV': 'Distribuir na vertical',
|
||||
'journey.studio.layers.copyStyle': 'Copiar estilo',
|
||||
'journey.studio.layers.pasteStyle': 'Colar estilo',
|
||||
'journey.studio.layers.groupHint': 'Clique duas vezes num elemento para trabalhar dentro do grupo.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Capes',
|
||||
'journey.studio.layers.empty':
|
||||
'Encara no hi ha res en aquesta pàgina. El que hi afegeixis apareix aquí, de davant cap enrere.',
|
||||
'journey.studio.layers.hint':
|
||||
'De davant cap enrere. Arrossega una fila per reordenar-la, fes doble clic en un nom per canviar-lo.',
|
||||
'journey.studio.layers.group': 'Grup',
|
||||
'journey.studio.layers.groupAction': 'Agrupa',
|
||||
'journey.studio.layers.ungroup': 'Desagrupa',
|
||||
'journey.studio.layers.hide': 'Amaga',
|
||||
'journey.studio.layers.show': 'Mostra',
|
||||
'journey.studio.layers.rename': 'Canvia el nom',
|
||||
'journey.studio.layers.expand': 'Mostra el contingut',
|
||||
'journey.studio.layers.collapse': 'Plega',
|
||||
'journey.studio.layers.align': 'Alinea',
|
||||
'journey.studio.layers.alignLeft': 'Alinea les vores esquerres',
|
||||
'journey.studio.layers.alignCenter': 'Centra horitzontalment',
|
||||
'journey.studio.layers.alignRight': 'Alinea les vores dretes',
|
||||
'journey.studio.layers.alignTop': 'Alinea les vores superiors',
|
||||
'journey.studio.layers.alignMiddle': 'Centra verticalment',
|
||||
'journey.studio.layers.alignBottom': 'Alinea les vores inferiors',
|
||||
'journey.studio.layers.alignTo': 'Respecte a',
|
||||
'journey.studio.layers.toSelection': 'Selecció',
|
||||
'journey.studio.layers.toPage': 'Pàgina',
|
||||
'journey.studio.layers.toSafe': 'Zona segura',
|
||||
'journey.studio.layers.distributeH': 'Reparteix en horitzontal',
|
||||
'journey.studio.layers.distributeV': 'Reparteix en vertical',
|
||||
'journey.studio.layers.copyStyle': 'Copia l’estil',
|
||||
'journey.studio.layers.pasteStyle': 'Enganxa l’estil',
|
||||
'journey.studio.layers.groupHint': 'Fes doble clic en un element per treballar dins del grup.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Vrstvy',
|
||||
'journey.studio.layers.empty': 'Na této stránce zatím nic není. Co přidáte, objeví se tady, odpředu dozadu.',
|
||||
'journey.studio.layers.hint': 'Odpředu dozadu. Přetažením řádku změníte pořadí, dvojklikem na název ho přejmenujete.',
|
||||
'journey.studio.layers.group': 'Skupina',
|
||||
'journey.studio.layers.groupAction': 'Seskupit',
|
||||
'journey.studio.layers.ungroup': 'Zrušit seskupení',
|
||||
'journey.studio.layers.hide': 'Skrýt',
|
||||
'journey.studio.layers.show': 'Zobrazit',
|
||||
'journey.studio.layers.rename': 'Přejmenovat',
|
||||
'journey.studio.layers.expand': 'Zobrazit obsah',
|
||||
'journey.studio.layers.collapse': 'Sbalit',
|
||||
'journey.studio.layers.align': 'Zarovnat',
|
||||
'journey.studio.layers.alignLeft': 'Zarovnat levé okraje',
|
||||
'journey.studio.layers.alignCenter': 'Vystředit vodorovně',
|
||||
'journey.studio.layers.alignRight': 'Zarovnat pravé okraje',
|
||||
'journey.studio.layers.alignTop': 'Zarovnat horní okraje',
|
||||
'journey.studio.layers.alignMiddle': 'Vystředit svisle',
|
||||
'journey.studio.layers.alignBottom': 'Zarovnat dolní okraje',
|
||||
'journey.studio.layers.alignTo': 'Vzhledem k',
|
||||
'journey.studio.layers.toSelection': 'Výběr',
|
||||
'journey.studio.layers.toPage': 'Stránka',
|
||||
'journey.studio.layers.toSafe': 'Bezpečná zóna',
|
||||
'journey.studio.layers.distributeH': 'Rozmístit rovnoměrně vodorovně',
|
||||
'journey.studio.layers.distributeV': 'Rozmístit rovnoměrně svisle',
|
||||
'journey.studio.layers.copyStyle': 'Kopírovat styl',
|
||||
'journey.studio.layers.pasteStyle': 'Vložit styl',
|
||||
'journey.studio.layers.groupHint': 'Dvojklikem na prvek budete pracovat uvnitř skupiny.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Ebenen',
|
||||
'journey.studio.layers.empty':
|
||||
'Auf dieser Seite ist noch nichts. Was du hinzufügst, erscheint hier, von vorn nach hinten.',
|
||||
'journey.studio.layers.hint':
|
||||
'Von vorn nach hinten. Zeile ziehen zum Umsortieren, Namen doppelklicken zum Umbenennen.',
|
||||
'journey.studio.layers.group': 'Gruppe',
|
||||
'journey.studio.layers.groupAction': 'Gruppieren',
|
||||
'journey.studio.layers.ungroup': 'Gruppierung aufheben',
|
||||
'journey.studio.layers.hide': 'Ausblenden',
|
||||
'journey.studio.layers.show': 'Einblenden',
|
||||
'journey.studio.layers.rename': 'Umbenennen',
|
||||
'journey.studio.layers.expand': 'Inhalt zeigen',
|
||||
'journey.studio.layers.collapse': 'Zuklappen',
|
||||
'journey.studio.layers.align': 'Ausrichten',
|
||||
'journey.studio.layers.alignLeft': 'Linke Kanten ausrichten',
|
||||
'journey.studio.layers.alignCenter': 'Horizontal zentrieren',
|
||||
'journey.studio.layers.alignRight': 'Rechte Kanten ausrichten',
|
||||
'journey.studio.layers.alignTop': 'Obere Kanten ausrichten',
|
||||
'journey.studio.layers.alignMiddle': 'Vertikal zentrieren',
|
||||
'journey.studio.layers.alignBottom': 'Untere Kanten ausrichten',
|
||||
'journey.studio.layers.alignTo': 'Bezogen auf',
|
||||
'journey.studio.layers.toSelection': 'Auswahl',
|
||||
'journey.studio.layers.toPage': 'Seite',
|
||||
'journey.studio.layers.toSafe': 'Schutzzone',
|
||||
'journey.studio.layers.distributeH': 'Waagerecht gleichmäßig verteilen',
|
||||
'journey.studio.layers.distributeV': 'Senkrecht gleichmäßig verteilen',
|
||||
'journey.studio.layers.copyStyle': 'Stil kopieren',
|
||||
'journey.studio.layers.pasteStyle': 'Stil einfügen',
|
||||
'journey.studio.layers.groupHint': 'Doppelklicke ein Element, um innerhalb der Gruppe zu arbeiten.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Layers',
|
||||
'journey.studio.layers.empty': 'Nothing on this page yet. Whatever you add appears here, front to back.',
|
||||
'journey.studio.layers.hint': 'Front to back. Drag a row to restack it, double-click a name to rename it.',
|
||||
'journey.studio.layers.group': 'Group',
|
||||
'journey.studio.layers.groupAction': 'Group',
|
||||
'journey.studio.layers.ungroup': 'Ungroup',
|
||||
'journey.studio.layers.hide': 'Hide',
|
||||
'journey.studio.layers.show': 'Show',
|
||||
'journey.studio.layers.rename': 'Rename',
|
||||
'journey.studio.layers.expand': 'Show what is inside',
|
||||
'journey.studio.layers.collapse': 'Fold away',
|
||||
'journey.studio.layers.align': 'Align',
|
||||
'journey.studio.layers.alignLeft': 'Align left edges',
|
||||
'journey.studio.layers.alignCenter': 'Centre horizontally',
|
||||
'journey.studio.layers.alignRight': 'Align right edges',
|
||||
'journey.studio.layers.alignTop': 'Align top edges',
|
||||
'journey.studio.layers.alignMiddle': 'Centre vertically',
|
||||
'journey.studio.layers.alignBottom': 'Align bottom edges',
|
||||
'journey.studio.layers.alignTo': 'Relative to',
|
||||
'journey.studio.layers.toSelection': 'Selection',
|
||||
'journey.studio.layers.toPage': 'Page',
|
||||
'journey.studio.layers.toSafe': 'Safe area',
|
||||
'journey.studio.layers.distributeH': 'Space evenly across',
|
||||
'journey.studio.layers.distributeV': 'Space evenly down',
|
||||
'journey.studio.layers.copyStyle': 'Copy style',
|
||||
'journey.studio.layers.pasteStyle': 'Paste style',
|
||||
'journey.studio.layers.groupHint': 'Double-click an element to work inside the group.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Capas',
|
||||
'journey.studio.layers.empty':
|
||||
'Todavía no hay nada en esta página. Lo que añadas aparece aquí, de delante hacia atrás.',
|
||||
'journey.studio.layers.hint':
|
||||
'De delante hacia atrás. Arrastra una fila para reordenarla, haz doble clic en un nombre para cambiarlo.',
|
||||
'journey.studio.layers.group': 'Grupo',
|
||||
'journey.studio.layers.groupAction': 'Agrupar',
|
||||
'journey.studio.layers.ungroup': 'Desagrupar',
|
||||
'journey.studio.layers.hide': 'Ocultar',
|
||||
'journey.studio.layers.show': 'Mostrar',
|
||||
'journey.studio.layers.rename': 'Cambiar nombre',
|
||||
'journey.studio.layers.expand': 'Mostrar contenido',
|
||||
'journey.studio.layers.collapse': 'Plegar',
|
||||
'journey.studio.layers.align': 'Alinear',
|
||||
'journey.studio.layers.alignLeft': 'Alinear bordes izquierdos',
|
||||
'journey.studio.layers.alignCenter': 'Centrar horizontalmente',
|
||||
'journey.studio.layers.alignRight': 'Alinear bordes derechos',
|
||||
'journey.studio.layers.alignTop': 'Alinear bordes superiores',
|
||||
'journey.studio.layers.alignMiddle': 'Centrar verticalmente',
|
||||
'journey.studio.layers.alignBottom': 'Alinear bordes inferiores',
|
||||
'journey.studio.layers.alignTo': 'Respecto a',
|
||||
'journey.studio.layers.toSelection': 'Selección',
|
||||
'journey.studio.layers.toPage': 'Página',
|
||||
'journey.studio.layers.toSafe': 'Zona segura',
|
||||
'journey.studio.layers.distributeH': 'Repartir en horizontal',
|
||||
'journey.studio.layers.distributeV': 'Repartir en vertical',
|
||||
'journey.studio.layers.copyStyle': 'Copiar estilo',
|
||||
'journey.studio.layers.pasteStyle': 'Pegar estilo',
|
||||
'journey.studio.layers.groupHint': 'Haz doble clic en un elemento para trabajar dentro del grupo.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Kihid',
|
||||
'journey.studio.layers.empty': 'Sellel lehel pole veel midagi. Mida lisad, ilmub siia, eest taha.',
|
||||
'journey.studio.layers.hint': 'Eest taha. Lohista rida järjekorra muutmiseks, topeltklõpsa nimel selle muutmiseks.',
|
||||
'journey.studio.layers.group': 'Rühm',
|
||||
'journey.studio.layers.groupAction': 'Rühmita',
|
||||
'journey.studio.layers.ungroup': 'Tühista rühmitus',
|
||||
'journey.studio.layers.hide': 'Peida',
|
||||
'journey.studio.layers.show': 'Näita',
|
||||
'journey.studio.layers.rename': 'Nimeta ümber',
|
||||
'journey.studio.layers.expand': 'Näita sisu',
|
||||
'journey.studio.layers.collapse': 'Ahenda',
|
||||
'journey.studio.layers.align': 'Joonda',
|
||||
'journey.studio.layers.alignLeft': 'Joonda vasakud servad',
|
||||
'journey.studio.layers.alignCenter': 'Tsentreeri rõhtsalt',
|
||||
'journey.studio.layers.alignRight': 'Joonda paremad servad',
|
||||
'journey.studio.layers.alignTop': 'Joonda ülemised servad',
|
||||
'journey.studio.layers.alignMiddle': 'Tsentreeri püstsalt',
|
||||
'journey.studio.layers.alignBottom': 'Joonda alumised servad',
|
||||
'journey.studio.layers.alignTo': 'Võrreldes',
|
||||
'journey.studio.layers.toSelection': 'Valik',
|
||||
'journey.studio.layers.toPage': 'Leht',
|
||||
'journey.studio.layers.toSafe': 'Turvaala',
|
||||
'journey.studio.layers.distributeH': 'Jaota ühtlaselt rõhtsalt',
|
||||
'journey.studio.layers.distributeV': 'Jaota ühtlaselt püstsalt',
|
||||
'journey.studio.layers.copyStyle': 'Kopeeri stiil',
|
||||
'journey.studio.layers.pasteStyle': 'Kleebi stiil',
|
||||
'journey.studio.layers.groupHint': 'Topeltklõpsa elemendil, et rühma sees töötada.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Calques',
|
||||
'journey.studio.layers.empty':
|
||||
'Rien sur cette page pour l’instant. Ce que vous ajoutez apparaît ici, du premier plan à l’arrière-plan.',
|
||||
'journey.studio.layers.hint':
|
||||
'Du premier plan à l’arrière-plan. Faites glisser une ligne pour la déplacer, double-cliquez sur un nom pour le renommer.',
|
||||
'journey.studio.layers.group': 'Groupe',
|
||||
'journey.studio.layers.groupAction': 'Grouper',
|
||||
'journey.studio.layers.ungroup': 'Dissocier',
|
||||
'journey.studio.layers.hide': 'Masquer',
|
||||
'journey.studio.layers.show': 'Afficher',
|
||||
'journey.studio.layers.rename': 'Renommer',
|
||||
'journey.studio.layers.expand': 'Afficher le contenu',
|
||||
'journey.studio.layers.collapse': 'Replier',
|
||||
'journey.studio.layers.align': 'Aligner',
|
||||
'journey.studio.layers.alignLeft': 'Aligner les bords gauches',
|
||||
'journey.studio.layers.alignCenter': 'Centrer horizontalement',
|
||||
'journey.studio.layers.alignRight': 'Aligner les bords droits',
|
||||
'journey.studio.layers.alignTop': 'Aligner les bords supérieurs',
|
||||
'journey.studio.layers.alignMiddle': 'Centrer verticalement',
|
||||
'journey.studio.layers.alignBottom': 'Aligner les bords inférieurs',
|
||||
'journey.studio.layers.alignTo': 'Par rapport à',
|
||||
'journey.studio.layers.toSelection': 'Sélection',
|
||||
'journey.studio.layers.toPage': 'Page', // same-as-en
|
||||
'journey.studio.layers.toSafe': 'Zone de sécurité',
|
||||
'journey.studio.layers.distributeH': 'Répartir horizontalement',
|
||||
'journey.studio.layers.distributeV': 'Répartir verticalement',
|
||||
'journey.studio.layers.copyStyle': 'Copier le style',
|
||||
'journey.studio.layers.pasteStyle': 'Coller le style',
|
||||
'journey.studio.layers.groupHint': 'Double-cliquez sur un élément pour travailler dans le groupe.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Επίπεδα',
|
||||
'journey.studio.layers.empty':
|
||||
'Δεν υπάρχει τίποτα σε αυτή τη σελίδα ακόμη. Ό,τι προσθέτετε εμφανίζεται εδώ, από μπροστά προς τα πίσω.',
|
||||
'journey.studio.layers.hint':
|
||||
'Από μπροστά προς τα πίσω. Σύρετε μια γραμμή για αλλαγή σειράς, κάντε διπλό κλικ σε ένα όνομα για μετονομασία.',
|
||||
'journey.studio.layers.group': 'Ομάδα',
|
||||
'journey.studio.layers.groupAction': 'Ομαδοποίηση',
|
||||
'journey.studio.layers.ungroup': 'Κατάργηση ομάδας',
|
||||
'journey.studio.layers.hide': 'Απόκρυψη',
|
||||
'journey.studio.layers.show': 'Εμφάνιση',
|
||||
'journey.studio.layers.rename': 'Μετονομασία',
|
||||
'journey.studio.layers.expand': 'Εμφάνιση περιεχομένου',
|
||||
'journey.studio.layers.collapse': 'Σύμπτυξη',
|
||||
'journey.studio.layers.align': 'Στοίχιση',
|
||||
'journey.studio.layers.alignLeft': 'Στοίχιση αριστερών άκρων',
|
||||
'journey.studio.layers.alignCenter': 'Οριζόντιο κεντράρισμα',
|
||||
'journey.studio.layers.alignRight': 'Στοίχιση δεξιών άκρων',
|
||||
'journey.studio.layers.alignTop': 'Στοίχιση πάνω άκρων',
|
||||
'journey.studio.layers.alignMiddle': 'Κατακόρυφο κεντράρισμα',
|
||||
'journey.studio.layers.alignBottom': 'Στοίχιση κάτω άκρων',
|
||||
'journey.studio.layers.alignTo': 'Σε σχέση με',
|
||||
'journey.studio.layers.toSelection': 'Επιλογή',
|
||||
'journey.studio.layers.toPage': 'Σελίδα',
|
||||
'journey.studio.layers.toSafe': 'Ασφαλής ζώνη',
|
||||
'journey.studio.layers.distributeH': 'Ομοιόμορφη κατανομή οριζόντια',
|
||||
'journey.studio.layers.distributeV': 'Ομοιόμορφη κατανομή κατακόρυφα',
|
||||
'journey.studio.layers.copyStyle': 'Αντιγραφή στυλ',
|
||||
'journey.studio.layers.pasteStyle': 'Επικόλληση στυλ',
|
||||
'journey.studio.layers.groupHint': 'Κάντε διπλό κλικ σε ένα στοιχείο για να δουλέψετε μέσα στην ομάδα.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Rétegek',
|
||||
'journey.studio.layers.empty': 'Ezen az oldalon még nincs semmi. Amit hozzáadsz, itt jelenik meg, elölről hátrafelé.',
|
||||
'journey.studio.layers.hint':
|
||||
'Elölről hátrafelé. Húzz egy sort az átrendezéshez, kattints duplán egy névre az átnevezéshez.',
|
||||
'journey.studio.layers.group': 'Csoport',
|
||||
'journey.studio.layers.groupAction': 'Csoportosítás',
|
||||
'journey.studio.layers.ungroup': 'Csoport bontása',
|
||||
'journey.studio.layers.hide': 'Elrejtés',
|
||||
'journey.studio.layers.show': 'Megjelenítés',
|
||||
'journey.studio.layers.rename': 'Átnevezés',
|
||||
'journey.studio.layers.expand': 'Tartalom megjelenítése',
|
||||
'journey.studio.layers.collapse': 'Összecsukás',
|
||||
'journey.studio.layers.align': 'Igazítás',
|
||||
'journey.studio.layers.alignLeft': 'Bal szélek igazítása',
|
||||
'journey.studio.layers.alignCenter': 'Vízszintes középre',
|
||||
'journey.studio.layers.alignRight': 'Jobb szélek igazítása',
|
||||
'journey.studio.layers.alignTop': 'Felső szélek igazítása',
|
||||
'journey.studio.layers.alignMiddle': 'Függőleges középre',
|
||||
'journey.studio.layers.alignBottom': 'Alsó szélek igazítása',
|
||||
'journey.studio.layers.alignTo': 'Viszonyítás',
|
||||
'journey.studio.layers.toSelection': 'Kijelölés',
|
||||
'journey.studio.layers.toPage': 'Oldal',
|
||||
'journey.studio.layers.toSafe': 'Védőzóna',
|
||||
'journey.studio.layers.distributeH': 'Egyenletes elosztás vízszintesen',
|
||||
'journey.studio.layers.distributeV': 'Egyenletes elosztás függőlegesen',
|
||||
'journey.studio.layers.copyStyle': 'Stílus másolása',
|
||||
'journey.studio.layers.pasteStyle': 'Stílus beillesztése',
|
||||
'journey.studio.layers.groupHint': 'Kattints duplán egy elemre, hogy a csoporton belül dolgozz.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Lapisan',
|
||||
'journey.studio.layers.empty':
|
||||
'Belum ada apa pun di halaman ini. Yang kamu tambahkan muncul di sini, dari depan ke belakang.',
|
||||
'journey.studio.layers.hint':
|
||||
'Dari depan ke belakang. Seret baris untuk mengubah urutan, klik dua kali nama untuk mengganti namanya.',
|
||||
'journey.studio.layers.group': 'Grup',
|
||||
'journey.studio.layers.groupAction': 'Kelompokkan',
|
||||
'journey.studio.layers.ungroup': 'Pisahkan grup',
|
||||
'journey.studio.layers.hide': 'Sembunyikan',
|
||||
'journey.studio.layers.show': 'Tampilkan',
|
||||
'journey.studio.layers.rename': 'Ganti nama',
|
||||
'journey.studio.layers.expand': 'Tampilkan isi',
|
||||
'journey.studio.layers.collapse': 'Ciutkan',
|
||||
'journey.studio.layers.align': 'Ratakan',
|
||||
'journey.studio.layers.alignLeft': 'Ratakan tepi kiri',
|
||||
'journey.studio.layers.alignCenter': 'Tengahkan mendatar',
|
||||
'journey.studio.layers.alignRight': 'Ratakan tepi kanan',
|
||||
'journey.studio.layers.alignTop': 'Ratakan tepi atas',
|
||||
'journey.studio.layers.alignMiddle': 'Tengahkan tegak',
|
||||
'journey.studio.layers.alignBottom': 'Ratakan tepi bawah',
|
||||
'journey.studio.layers.alignTo': 'Terhadap',
|
||||
'journey.studio.layers.toSelection': 'Pilihan',
|
||||
'journey.studio.layers.toPage': 'Halaman',
|
||||
'journey.studio.layers.toSafe': 'Area aman',
|
||||
'journey.studio.layers.distributeH': 'Sebar rata mendatar',
|
||||
'journey.studio.layers.distributeV': 'Sebar rata tegak',
|
||||
'journey.studio.layers.copyStyle': 'Salin gaya',
|
||||
'journey.studio.layers.pasteStyle': 'Tempel gaya',
|
||||
'journey.studio.layers.groupHint': 'Klik dua kali sebuah elemen untuk bekerja di dalam grup.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Livelli',
|
||||
'journey.studio.layers.empty':
|
||||
'Ancora niente su questa pagina. Ciò che aggiungi compare qui, dal primo piano allo sfondo.',
|
||||
'journey.studio.layers.hint':
|
||||
'Dal primo piano allo sfondo. Trascina una riga per riordinarla, fai doppio clic su un nome per rinominarlo.',
|
||||
'journey.studio.layers.group': 'Gruppo',
|
||||
'journey.studio.layers.groupAction': 'Raggruppa',
|
||||
'journey.studio.layers.ungroup': 'Separa',
|
||||
'journey.studio.layers.hide': 'Nascondi',
|
||||
'journey.studio.layers.show': 'Mostra',
|
||||
'journey.studio.layers.rename': 'Rinomina',
|
||||
'journey.studio.layers.expand': 'Mostra il contenuto',
|
||||
'journey.studio.layers.collapse': 'Comprimi',
|
||||
'journey.studio.layers.align': 'Allinea',
|
||||
'journey.studio.layers.alignLeft': 'Allinea i bordi sinistri',
|
||||
'journey.studio.layers.alignCenter': 'Centra in orizzontale',
|
||||
'journey.studio.layers.alignRight': 'Allinea i bordi destri',
|
||||
'journey.studio.layers.alignTop': 'Allinea i bordi superiori',
|
||||
'journey.studio.layers.alignMiddle': 'Centra in verticale',
|
||||
'journey.studio.layers.alignBottom': 'Allinea i bordi inferiori',
|
||||
'journey.studio.layers.alignTo': 'Rispetto a',
|
||||
'journey.studio.layers.toSelection': 'Selezione',
|
||||
'journey.studio.layers.toPage': 'Pagina',
|
||||
'journey.studio.layers.toSafe': 'Area di sicurezza',
|
||||
'journey.studio.layers.distributeH': 'Distribuisci in orizzontale',
|
||||
'journey.studio.layers.distributeV': 'Distribuisci in verticale',
|
||||
'journey.studio.layers.copyStyle': 'Copia stile',
|
||||
'journey.studio.layers.pasteStyle': 'Incolla stile',
|
||||
'journey.studio.layers.groupHint': 'Fai doppio clic su un elemento per lavorare dentro il gruppo.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'レイヤー',
|
||||
'journey.studio.layers.empty':
|
||||
'このページにはまだ何もありません。追加したものが前面から背面の順にここに表示されます。',
|
||||
'journey.studio.layers.hint':
|
||||
'前面から背面の順です。行をドラッグして並べ替え、名前をダブルクリックして名前を変更できます。',
|
||||
'journey.studio.layers.group': 'グループ',
|
||||
'journey.studio.layers.groupAction': 'グループ化',
|
||||
'journey.studio.layers.ungroup': 'グループ解除',
|
||||
'journey.studio.layers.hide': '非表示',
|
||||
'journey.studio.layers.show': '表示',
|
||||
'journey.studio.layers.rename': '名前を変更',
|
||||
'journey.studio.layers.expand': '中身を表示',
|
||||
'journey.studio.layers.collapse': '折りたたむ',
|
||||
'journey.studio.layers.align': '整列',
|
||||
'journey.studio.layers.alignLeft': '左端を揃える',
|
||||
'journey.studio.layers.alignCenter': '水平方向の中央に揃える',
|
||||
'journey.studio.layers.alignRight': '右端を揃える',
|
||||
'journey.studio.layers.alignTop': '上端を揃える',
|
||||
'journey.studio.layers.alignMiddle': '垂直方向の中央に揃える',
|
||||
'journey.studio.layers.alignBottom': '下端を揃える',
|
||||
'journey.studio.layers.alignTo': '基準',
|
||||
'journey.studio.layers.toSelection': '選択範囲',
|
||||
'journey.studio.layers.toPage': 'ページ',
|
||||
'journey.studio.layers.toSafe': '安全領域',
|
||||
'journey.studio.layers.distributeH': '左右に均等に配置',
|
||||
'journey.studio.layers.distributeV': '上下に均等に配置',
|
||||
'journey.studio.layers.copyStyle': 'スタイルをコピー',
|
||||
'journey.studio.layers.pasteStyle': 'スタイルを貼り付け',
|
||||
'journey.studio.layers.groupHint': '要素をダブルクリックすると、グループの中で作業できます。',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': '레이어',
|
||||
'journey.studio.layers.empty':
|
||||
'이 페이지에는 아직 아무것도 없습니다. 추가한 항목이 앞에서 뒤 순서로 여기에 표시됩니다.',
|
||||
'journey.studio.layers.hint': '앞에서 뒤 순서입니다. 행을 끌어 순서를 바꾸고, 이름을 두 번 클릭해 이름을 바꿉니다.',
|
||||
'journey.studio.layers.group': '그룹',
|
||||
'journey.studio.layers.groupAction': '그룹화',
|
||||
'journey.studio.layers.ungroup': '그룹 해제',
|
||||
'journey.studio.layers.hide': '숨기기',
|
||||
'journey.studio.layers.show': '보이기',
|
||||
'journey.studio.layers.rename': '이름 바꾸기',
|
||||
'journey.studio.layers.expand': '안에 있는 항목 보기',
|
||||
'journey.studio.layers.collapse': '접기',
|
||||
'journey.studio.layers.align': '정렬',
|
||||
'journey.studio.layers.alignLeft': '왼쪽 가장자리 맞춤',
|
||||
'journey.studio.layers.alignCenter': '가로 가운데 맞춤',
|
||||
'journey.studio.layers.alignRight': '오른쪽 가장자리 맞춤',
|
||||
'journey.studio.layers.alignTop': '위쪽 가장자리 맞춤',
|
||||
'journey.studio.layers.alignMiddle': '세로 가운데 맞춤',
|
||||
'journey.studio.layers.alignBottom': '아래쪽 가장자리 맞춤',
|
||||
'journey.studio.layers.alignTo': '기준',
|
||||
'journey.studio.layers.toSelection': '선택 영역',
|
||||
'journey.studio.layers.toPage': '페이지',
|
||||
'journey.studio.layers.toSafe': '안전 영역',
|
||||
'journey.studio.layers.distributeH': '가로로 균등 분배',
|
||||
'journey.studio.layers.distributeV': '세로로 균등 분배',
|
||||
'journey.studio.layers.copyStyle': '스타일 복사',
|
||||
'journey.studio.layers.pasteStyle': '스타일 붙여넣기',
|
||||
'journey.studio.layers.groupHint': '요소를 두 번 클릭하면 그룹 안에서 작업할 수 있습니다.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Lagen',
|
||||
'journey.studio.layers.empty': 'Nog niets op deze pagina. Wat je toevoegt, verschijnt hier, van voor naar achter.',
|
||||
'journey.studio.layers.hint':
|
||||
'Van voor naar achter. Sleep een rij om de volgorde te wijzigen, dubbelklik op een naam om die te wijzigen.',
|
||||
'journey.studio.layers.group': 'Groep',
|
||||
'journey.studio.layers.groupAction': 'Groeperen',
|
||||
'journey.studio.layers.ungroup': 'Groep opheffen',
|
||||
'journey.studio.layers.hide': 'Verbergen',
|
||||
'journey.studio.layers.show': 'Tonen',
|
||||
'journey.studio.layers.rename': 'Naam wijzigen',
|
||||
'journey.studio.layers.expand': 'Inhoud tonen',
|
||||
'journey.studio.layers.collapse': 'Inklappen',
|
||||
'journey.studio.layers.align': 'Uitlijnen',
|
||||
'journey.studio.layers.alignLeft': 'Linkerranden uitlijnen',
|
||||
'journey.studio.layers.alignCenter': 'Horizontaal centreren',
|
||||
'journey.studio.layers.alignRight': 'Rechterranden uitlijnen',
|
||||
'journey.studio.layers.alignTop': 'Bovenranden uitlijnen',
|
||||
'journey.studio.layers.alignMiddle': 'Verticaal centreren',
|
||||
'journey.studio.layers.alignBottom': 'Onderranden uitlijnen',
|
||||
'journey.studio.layers.alignTo': 'Ten opzichte van',
|
||||
'journey.studio.layers.toSelection': 'Selectie',
|
||||
'journey.studio.layers.toPage': 'Pagina',
|
||||
'journey.studio.layers.toSafe': 'Veilige zone',
|
||||
'journey.studio.layers.distributeH': 'Horizontaal gelijk verdelen',
|
||||
'journey.studio.layers.distributeV': 'Verticaal gelijk verdelen',
|
||||
'journey.studio.layers.copyStyle': 'Stijl kopiëren',
|
||||
'journey.studio.layers.pasteStyle': 'Stijl plakken',
|
||||
'journey.studio.layers.groupHint': 'Dubbelklik op een element om binnen de groep te werken.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Warstwy',
|
||||
'journey.studio.layers.empty':
|
||||
'Na tej stronie nic jeszcze nie ma. To, co dodasz, pojawi się tutaj, od przodu do tyłu.',
|
||||
'journey.studio.layers.hint':
|
||||
'Od przodu do tyłu. Przeciągnij wiersz, aby zmienić kolejność, kliknij dwukrotnie nazwę, aby ją zmienić.',
|
||||
'journey.studio.layers.group': 'Grupa',
|
||||
'journey.studio.layers.groupAction': 'Grupuj',
|
||||
'journey.studio.layers.ungroup': 'Rozgrupuj',
|
||||
'journey.studio.layers.hide': 'Ukryj',
|
||||
'journey.studio.layers.show': 'Pokaż',
|
||||
'journey.studio.layers.rename': 'Zmień nazwę',
|
||||
'journey.studio.layers.expand': 'Pokaż zawartość',
|
||||
'journey.studio.layers.collapse': 'Zwiń',
|
||||
'journey.studio.layers.align': 'Wyrównaj',
|
||||
'journey.studio.layers.alignLeft': 'Wyrównaj lewe krawędzie',
|
||||
'journey.studio.layers.alignCenter': 'Wyśrodkuj w poziomie',
|
||||
'journey.studio.layers.alignRight': 'Wyrównaj prawe krawędzie',
|
||||
'journey.studio.layers.alignTop': 'Wyrównaj górne krawędzie',
|
||||
'journey.studio.layers.alignMiddle': 'Wyśrodkuj w pionie',
|
||||
'journey.studio.layers.alignBottom': 'Wyrównaj dolne krawędzie',
|
||||
'journey.studio.layers.alignTo': 'Względem',
|
||||
'journey.studio.layers.toSelection': 'Zaznaczenie',
|
||||
'journey.studio.layers.toPage': 'Strona',
|
||||
'journey.studio.layers.toSafe': 'Strefa bezpieczna',
|
||||
'journey.studio.layers.distributeH': 'Rozmieść równo w poziomie',
|
||||
'journey.studio.layers.distributeV': 'Rozmieść równo w pionie',
|
||||
'journey.studio.layers.copyStyle': 'Kopiuj styl',
|
||||
'journey.studio.layers.pasteStyle': 'Wklej styl',
|
||||
'journey.studio.layers.groupHint': 'Kliknij dwukrotnie element, aby pracować wewnątrz grupy.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Слои',
|
||||
'journey.studio.layers.empty':
|
||||
'На этой странице пока ничего нет. Всё, что вы добавите, появится здесь, спереди назад.',
|
||||
'journey.studio.layers.hint':
|
||||
'Спереди назад. Перетащите строку, чтобы изменить порядок, дважды щёлкните имя, чтобы переименовать.',
|
||||
'journey.studio.layers.group': 'Группа',
|
||||
'journey.studio.layers.groupAction': 'Сгруппировать',
|
||||
'journey.studio.layers.ungroup': 'Разгруппировать',
|
||||
'journey.studio.layers.hide': 'Скрыть',
|
||||
'journey.studio.layers.show': 'Показать',
|
||||
'journey.studio.layers.rename': 'Переименовать',
|
||||
'journey.studio.layers.expand': 'Показать содержимое',
|
||||
'journey.studio.layers.collapse': 'Свернуть',
|
||||
'journey.studio.layers.align': 'Выравнивание',
|
||||
'journey.studio.layers.alignLeft': 'Выровнять по левому краю',
|
||||
'journey.studio.layers.alignCenter': 'Центрировать по горизонтали',
|
||||
'journey.studio.layers.alignRight': 'Выровнять по правому краю',
|
||||
'journey.studio.layers.alignTop': 'Выровнять по верхнему краю',
|
||||
'journey.studio.layers.alignMiddle': 'Центрировать по вертикали',
|
||||
'journey.studio.layers.alignBottom': 'Выровнять по нижнему краю',
|
||||
'journey.studio.layers.alignTo': 'Относительно',
|
||||
'journey.studio.layers.toSelection': 'Выделение',
|
||||
'journey.studio.layers.toPage': 'Страница',
|
||||
'journey.studio.layers.toSafe': 'Безопасная зона',
|
||||
'journey.studio.layers.distributeH': 'Распределить по горизонтали',
|
||||
'journey.studio.layers.distributeV': 'Распределить по вертикали',
|
||||
'journey.studio.layers.copyStyle': 'Копировать стиль',
|
||||
'journey.studio.layers.pasteStyle': 'Вставить стиль',
|
||||
'journey.studio.layers.groupHint': 'Дважды щёлкните элемент, чтобы работать внутри группы.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Vrstvy',
|
||||
'journey.studio.layers.empty': 'Na tejto stránke zatiaľ nič nie je. Čo pridáte, objaví sa tu, spredu dozadu.',
|
||||
'journey.studio.layers.hint':
|
||||
'Spredu dozadu. Potiahnutím riadka zmeníte poradie, dvojklikom na názov ho premenujete.',
|
||||
'journey.studio.layers.group': 'Skupina',
|
||||
'journey.studio.layers.groupAction': 'Zoskupiť',
|
||||
'journey.studio.layers.ungroup': 'Zrušiť zoskupenie',
|
||||
'journey.studio.layers.hide': 'Skryť',
|
||||
'journey.studio.layers.show': 'Zobraziť',
|
||||
'journey.studio.layers.rename': 'Premenovať',
|
||||
'journey.studio.layers.expand': 'Zobraziť obsah',
|
||||
'journey.studio.layers.collapse': 'Zbaliť',
|
||||
'journey.studio.layers.align': 'Zarovnať',
|
||||
'journey.studio.layers.alignLeft': 'Zarovnať ľavé okraje',
|
||||
'journey.studio.layers.alignCenter': 'Vycentrovať vodorovne',
|
||||
'journey.studio.layers.alignRight': 'Zarovnať pravé okraje',
|
||||
'journey.studio.layers.alignTop': 'Zarovnať horné okraje',
|
||||
'journey.studio.layers.alignMiddle': 'Vycentrovať zvisle',
|
||||
'journey.studio.layers.alignBottom': 'Zarovnať dolné okraje',
|
||||
'journey.studio.layers.alignTo': 'Vzhľadom na',
|
||||
'journey.studio.layers.toSelection': 'Výber',
|
||||
'journey.studio.layers.toPage': 'Stránka',
|
||||
'journey.studio.layers.toSafe': 'Bezpečná zóna',
|
||||
'journey.studio.layers.distributeH': 'Rozmiestniť rovnomerne vodorovne',
|
||||
'journey.studio.layers.distributeV': 'Rozmiestniť rovnomerne zvisle',
|
||||
'journey.studio.layers.copyStyle': 'Kopírovať štýl',
|
||||
'journey.studio.layers.pasteStyle': 'Vložiť štýl',
|
||||
'journey.studio.layers.groupHint': 'Dvojklikom na prvok budete pracovať vnútri skupiny.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Lager',
|
||||
'journey.studio.layers.empty': 'Inget på den här sidan än. Det du lägger till visas här, framifrån och bakåt.',
|
||||
'journey.studio.layers.hint':
|
||||
'Framifrån och bakåt. Dra en rad för att ändra ordningen, dubbelklicka på ett namn för att byta det.',
|
||||
'journey.studio.layers.group': 'Grupp',
|
||||
'journey.studio.layers.groupAction': 'Gruppera',
|
||||
'journey.studio.layers.ungroup': 'Dela upp grupp',
|
||||
'journey.studio.layers.hide': 'Dölj',
|
||||
'journey.studio.layers.show': 'Visa',
|
||||
'journey.studio.layers.rename': 'Byt namn',
|
||||
'journey.studio.layers.expand': 'Visa innehållet',
|
||||
'journey.studio.layers.collapse': 'Fäll ihop',
|
||||
'journey.studio.layers.align': 'Justera',
|
||||
'journey.studio.layers.alignLeft': 'Justera vänsterkanter',
|
||||
'journey.studio.layers.alignCenter': 'Centrera vågrätt',
|
||||
'journey.studio.layers.alignRight': 'Justera högerkanter',
|
||||
'journey.studio.layers.alignTop': 'Justera överkanter',
|
||||
'journey.studio.layers.alignMiddle': 'Centrera lodrätt',
|
||||
'journey.studio.layers.alignBottom': 'Justera underkanter',
|
||||
'journey.studio.layers.alignTo': 'I förhållande till',
|
||||
'journey.studio.layers.toSelection': 'Markering',
|
||||
'journey.studio.layers.toPage': 'Sida',
|
||||
'journey.studio.layers.toSafe': 'Skyddszon',
|
||||
'journey.studio.layers.distributeH': 'Fördela jämnt vågrätt',
|
||||
'journey.studio.layers.distributeV': 'Fördela jämnt lodrätt',
|
||||
'journey.studio.layers.copyStyle': 'Kopiera stil',
|
||||
'journey.studio.layers.pasteStyle': 'Klistra in stil',
|
||||
'journey.studio.layers.groupHint': 'Dubbelklicka på ett element för att arbeta inuti gruppen.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'เลเยอร์',
|
||||
'journey.studio.layers.empty': 'หน้านี้ยังไม่มีอะไร สิ่งที่คุณเพิ่มจะแสดงที่นี่ จากหน้าไปหลัง',
|
||||
'journey.studio.layers.hint': 'จากหน้าไปหลัง ลากแถวเพื่อจัดลำดับใหม่ ดับเบิลคลิกที่ชื่อเพื่อเปลี่ยนชื่อ',
|
||||
'journey.studio.layers.group': 'กลุ่ม',
|
||||
'journey.studio.layers.groupAction': 'จัดกลุ่ม',
|
||||
'journey.studio.layers.ungroup': 'ยกเลิกกลุ่ม',
|
||||
'journey.studio.layers.hide': 'ซ่อน',
|
||||
'journey.studio.layers.show': 'แสดง',
|
||||
'journey.studio.layers.rename': 'เปลี่ยนชื่อ',
|
||||
'journey.studio.layers.expand': 'แสดงสิ่งที่อยู่ข้างใน',
|
||||
'journey.studio.layers.collapse': 'ยุบ',
|
||||
'journey.studio.layers.align': 'จัดแนว',
|
||||
'journey.studio.layers.alignLeft': 'จัดขอบซ้ายให้ตรงกัน',
|
||||
'journey.studio.layers.alignCenter': 'จัดกึ่งกลางแนวนอน',
|
||||
'journey.studio.layers.alignRight': 'จัดขอบขวาให้ตรงกัน',
|
||||
'journey.studio.layers.alignTop': 'จัดขอบบนให้ตรงกัน',
|
||||
'journey.studio.layers.alignMiddle': 'จัดกึ่งกลางแนวตั้ง',
|
||||
'journey.studio.layers.alignBottom': 'จัดขอบล่างให้ตรงกัน',
|
||||
'journey.studio.layers.alignTo': 'เทียบกับ',
|
||||
'journey.studio.layers.toSelection': 'ส่วนที่เลือก',
|
||||
'journey.studio.layers.toPage': 'หน้า',
|
||||
'journey.studio.layers.toSafe': 'พื้นที่ปลอดภัย',
|
||||
'journey.studio.layers.distributeH': 'กระจายเท่ากันแนวนอน',
|
||||
'journey.studio.layers.distributeV': 'กระจายเท่ากันแนวตั้ง',
|
||||
'journey.studio.layers.copyStyle': 'คัดลอกสไตล์',
|
||||
'journey.studio.layers.pasteStyle': 'วางสไตล์',
|
||||
'journey.studio.layers.groupHint': 'ดับเบิลคลิกที่องค์ประกอบเพื่อทำงานภายในกลุ่ม',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Katmanlar',
|
||||
'journey.studio.layers.empty': 'Bu sayfada henüz bir şey yok. Eklediğin her şey burada, önden arkaya görünür.',
|
||||
'journey.studio.layers.hint':
|
||||
'Önden arkaya. Sırayı değiştirmek için bir satırı sürükle, yeniden adlandırmak için bir ada çift tıkla.',
|
||||
'journey.studio.layers.group': 'Grup',
|
||||
'journey.studio.layers.groupAction': 'Grupla',
|
||||
'journey.studio.layers.ungroup': 'Grubu çöz',
|
||||
'journey.studio.layers.hide': 'Gizle',
|
||||
'journey.studio.layers.show': 'Göster',
|
||||
'journey.studio.layers.rename': 'Yeniden adlandır',
|
||||
'journey.studio.layers.expand': 'İçeriği göster',
|
||||
'journey.studio.layers.collapse': 'Daralt',
|
||||
'journey.studio.layers.align': 'Hizala',
|
||||
'journey.studio.layers.alignLeft': 'Sol kenarları hizala',
|
||||
'journey.studio.layers.alignCenter': 'Yatay ortala',
|
||||
'journey.studio.layers.alignRight': 'Sağ kenarları hizala',
|
||||
'journey.studio.layers.alignTop': 'Üst kenarları hizala',
|
||||
'journey.studio.layers.alignMiddle': 'Dikey ortala',
|
||||
'journey.studio.layers.alignBottom': 'Alt kenarları hizala',
|
||||
'journey.studio.layers.alignTo': 'Göre',
|
||||
'journey.studio.layers.toSelection': 'Seçim',
|
||||
'journey.studio.layers.toPage': 'Sayfa',
|
||||
'journey.studio.layers.toSafe': 'Güvenli alan',
|
||||
'journey.studio.layers.distributeH': 'Yatayda eşit dağıt',
|
||||
'journey.studio.layers.distributeV': 'Dikeyde eşit dağıt',
|
||||
'journey.studio.layers.copyStyle': 'Stili kopyala',
|
||||
'journey.studio.layers.pasteStyle': 'Stili yapıştır',
|
||||
'journey.studio.layers.groupHint': 'Grubun içinde çalışmak için bir öğeye çift tıkla.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Шари',
|
||||
'journey.studio.layers.empty': 'На цій сторінці ще нічого немає. Усе, що ви додасте, з’явиться тут, спереду назад.',
|
||||
'journey.studio.layers.hint':
|
||||
'Спереду назад. Перетягніть рядок, щоб змінити порядок, двічі клацніть назву, щоб перейменувати.',
|
||||
'journey.studio.layers.group': 'Група',
|
||||
'journey.studio.layers.groupAction': 'Згрупувати',
|
||||
'journey.studio.layers.ungroup': 'Розгрупувати',
|
||||
'journey.studio.layers.hide': 'Сховати',
|
||||
'journey.studio.layers.show': 'Показати',
|
||||
'journey.studio.layers.rename': 'Перейменувати',
|
||||
'journey.studio.layers.expand': 'Показати вміст',
|
||||
'journey.studio.layers.collapse': 'Згорнути',
|
||||
'journey.studio.layers.align': 'Вирівнювання',
|
||||
'journey.studio.layers.alignLeft': 'Вирівняти за лівим краєм',
|
||||
'journey.studio.layers.alignCenter': 'Центрувати по горизонталі',
|
||||
'journey.studio.layers.alignRight': 'Вирівняти за правим краєм',
|
||||
'journey.studio.layers.alignTop': 'Вирівняти за верхнім краєм',
|
||||
'journey.studio.layers.alignMiddle': 'Центрувати по вертикалі',
|
||||
'journey.studio.layers.alignBottom': 'Вирівняти за нижнім краєм',
|
||||
'journey.studio.layers.alignTo': 'Відносно',
|
||||
'journey.studio.layers.toSelection': 'Виділення',
|
||||
'journey.studio.layers.toPage': 'Сторінка',
|
||||
'journey.studio.layers.toSafe': 'Безпечна зона',
|
||||
'journey.studio.layers.distributeH': 'Розподілити по горизонталі',
|
||||
'journey.studio.layers.distributeV': 'Розподілити по вертикалі',
|
||||
'journey.studio.layers.copyStyle': 'Копіювати стиль',
|
||||
'journey.studio.layers.pasteStyle': 'Вставити стиль',
|
||||
'journey.studio.layers.groupHint': 'Двічі клацніть елемент, щоб працювати всередині групи.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** The Studio's layers panel, groups, align and the style clipboard. */
|
||||
const studioLayers: TranslationStrings = {
|
||||
'journey.studio.layers': 'Lớp',
|
||||
'journey.studio.layers.empty': 'Trang này chưa có gì. Những gì bạn thêm sẽ hiện ở đây, từ trước ra sau.',
|
||||
'journey.studio.layers.hint': 'Từ trước ra sau. Kéo một hàng để sắp xếp lại, nhấp đúp vào tên để đổi tên.',
|
||||
'journey.studio.layers.group': 'Nhóm',
|
||||
'journey.studio.layers.groupAction': 'Nhóm lại',
|
||||
'journey.studio.layers.ungroup': 'Bỏ nhóm',
|
||||
'journey.studio.layers.hide': 'Ẩn',
|
||||
'journey.studio.layers.show': 'Hiện',
|
||||
'journey.studio.layers.rename': 'Đổi tên',
|
||||
'journey.studio.layers.expand': 'Hiện nội dung',
|
||||
'journey.studio.layers.collapse': 'Thu gọn',
|
||||
'journey.studio.layers.align': 'Căn chỉnh',
|
||||
'journey.studio.layers.alignLeft': 'Căn mép trái',
|
||||
'journey.studio.layers.alignCenter': 'Căn giữa theo chiều ngang',
|
||||
'journey.studio.layers.alignRight': 'Căn mép phải',
|
||||
'journey.studio.layers.alignTop': 'Căn mép trên',
|
||||
'journey.studio.layers.alignMiddle': 'Căn giữa theo chiều dọc',
|
||||
'journey.studio.layers.alignBottom': 'Căn mép dưới',
|
||||
'journey.studio.layers.alignTo': 'So với',
|
||||
'journey.studio.layers.toSelection': 'Vùng chọn',
|
||||
'journey.studio.layers.toPage': 'Trang',
|
||||
'journey.studio.layers.toSafe': 'Vùng an toàn',
|
||||
'journey.studio.layers.distributeH': 'Dàn đều theo chiều ngang',
|
||||
'journey.studio.layers.distributeV': 'Dàn đều theo chiều dọc',
|
||||
'journey.studio.layers.copyStyle': 'Sao chép kiểu',
|
||||
'journey.studio.layers.pasteStyle': 'Dán kiểu',
|
||||
'journey.studio.layers.groupHint': 'Nhấp đúp vào một phần tử để làm việc bên trong nhóm.',
|
||||
};
|
||||
|
||||
export default studioLayers;
|
||||
@@ -58,6 +58,7 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioLayers from './studioLayers';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -144,5 +145,6 @@ const locale = {
|
||||
...assistantVacay,
|
||||
...assistantJourney,
|
||||
...assistantPacking,
|
||||
...studioLayers,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user