mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
fix(studio): paste style, the page background, layouts and fill page keep the engine's rules
Paste style wrote the copied fields straight onto the element, although a pasted look is meant to be the style operation the assistant sends. It now runs the engine's own restyle (bookElementStyled): a colour or face pasted by hand takes the element off the theme, so the next theme applied no longer brings the old colour back; a plain fill replaces the paint of the shape it lands on instead of staying hidden under it; and a line takes another line's look without its points, which are its course, not its look. A background picked by hand in the inspector kept the page's link to the theme, so the next theme applied set the page back to the theme's colour. The picker and the background operation now paint through one helper that takes the link away (withSpreadPaintByHand). Giving one page's background to every page carries that page's link along, or takes the others' away when it was painted by hand. A built-in layout, a book's own layout and laying a page out again from its entry ignored locks in the Studio: a locked shape was dropped and a locked photo poured into a slot unlocked, while the assistant's layout operation left both where they were. All four now lay a page out around its locked elements through one function (layoutAroundLocks), which the engine's layout operation uses too. Fill page and fill spread kept the photo in its group, so the background picture was selected and dragged with its caption and listed under the group at the front. It leaves its group as the engine's fill does, and a group left with one member goes.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { bookElementId, spreadPaint, withSpreadPaint, type BookPageSetup } from '@trek/shared';
|
||||
import { bookElementId, spreadPaint, withSpreadPaintByHand, type BookPageSetup } from '@trek/shared';
|
||||
import { Check, CopyCheck, ImageOff } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
@@ -37,9 +37,10 @@ export function StudioBackgroundSection({
|
||||
}) {
|
||||
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
|
||||
const commit = useStudioStore((s) => s.commit);
|
||||
// A colour picked here is the person's, so the page stops following the theme's paper (as the assistant's does).
|
||||
const channel = usePaintChannel((paint) => (doc) => ({
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaint(sp, paint) : sp)),
|
||||
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaintByHand(sp, paint) : sp)),
|
||||
}));
|
||||
|
||||
// "Applied" stands in the button for a moment, so a press that changed pages out of sight is seen to have worked.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread } from '@trek/shared';
|
||||
import { tidyGroups, withoutGroup } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
@@ -44,8 +45,9 @@ function filled(sp: BookSpread, el: BookPhotoElement, page: BookPageSetup, scope
|
||||
w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed,
|
||||
h: page.pageHeight + 2 * bleed,
|
||||
};
|
||||
const photo = { ...el, frame, rotation: 0, fit: 'cover' } as BookElement;
|
||||
return { ...sp, elements: [photo, ...sp.elements.filter((e) => e.id !== el.id)] };
|
||||
// A picture that is the page is nobody's lockup: it leaves its group, as the assistant's fill takes it out.
|
||||
const photo = { ...withoutGroup(el), frame, rotation: 0, fit: 'cover' } as BookElement;
|
||||
return tidyGroups({ ...sp, elements: [photo, ...sp.elements.filter((e) => e.id !== el.id)] });
|
||||
}
|
||||
|
||||
export function StudioPhotoInspector({
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookPhotoElementSchema,
|
||||
spreadPaint,
|
||||
withSpreadPaint,
|
||||
withSpreadPaintByHand,
|
||||
type BookDocument,
|
||||
type BookElement,
|
||||
type BookPageSetup,
|
||||
@@ -77,20 +77,27 @@ export function withoutBackgroundPhoto(spread: BookSpread, page: BookPageSetup):
|
||||
const isCover = (spread: BookSpread) => spread.role === 'cover' || spread.role === 'back';
|
||||
|
||||
/**
|
||||
* Every page given the background of page `from`: its paint and colour,
|
||||
* nothing else. "Every page" is every page of its kind: an inside page
|
||||
* gives its background to the inside of the book, a cover to the covers,
|
||||
* since a cover is designed on its own and an inside page's paper is rarely
|
||||
* what the cover wants.
|
||||
* Every page given the background of page `from`: its paint and colour, and
|
||||
* whether it follows the theme, nothing else. "Every page" is every page of
|
||||
* its kind: an inside page gives its background to the inside of the book, a
|
||||
* cover to the covers, since a cover is designed on its own and an inside
|
||||
* page's paper is rarely what the cover wants. A page that follows the
|
||||
* theme's paper gives that link along; one painted by hand takes the others'
|
||||
* away, so the next theme applied leaves them all as they were made.
|
||||
*/
|
||||
export function backgroundToAll(doc: BookDocument, from: number): BookDocument {
|
||||
const source = doc.spreads[from];
|
||||
if (!source) return doc;
|
||||
const paint = spreadPaint(source);
|
||||
const kind = isCover(source);
|
||||
const link = source.backgroundToken;
|
||||
const given = (sp: BookSpread): BookSpread => {
|
||||
const painted = withSpreadPaintByHand(sp, paint);
|
||||
return link ? { ...painted, backgroundToken: link } : painted;
|
||||
};
|
||||
return {
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => (i === from || isCover(sp) !== kind ? sp : withSpreadPaint(sp, paint))),
|
||||
spreads: doc.spreads.map((sp, i) => (i === from || isCover(sp) !== kind ? sp : given(sp))),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -145,4 +145,35 @@ describe('the Templates panel', () => {
|
||||
fireEvent.click(screen.getByText('journey.studio.tpl.grid4'));
|
||||
expect(spreadOf(1)?.elements.filter((e) => e.kind === 'photo')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('leaves what is locked where it is, as the assistant does, and lays out the rest', () => {
|
||||
const locked = normalizeBookDocument({
|
||||
...book(),
|
||||
spreads: [
|
||||
book().spreads[0],
|
||||
{
|
||||
id: 'sp-in',
|
||||
role: 'inner',
|
||||
background: null,
|
||||
entryId: 4,
|
||||
parked: [],
|
||||
elements: [
|
||||
{ id: 'panel', kind: 'shape', frame: { x: 0, y: 150, w: 420, h: 60 }, locked: true },
|
||||
{ id: 'free', kind: 'photo', frame: { x: 230, y: 20, w: 60, h: 60 }, photoId: 8 },
|
||||
{ id: 'kept', kind: 'photo', frame: { x: 10, y: 20, w: 60, h: 60 }, photoId: 9, locked: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
useStudioStore.getState().load(locked);
|
||||
useStudioStore.getState().setActiveSpread(1);
|
||||
panel();
|
||||
fireEvent.click(screen.getByText('journey.studio.tpl.grid4'));
|
||||
const after = spreadOf(1)?.elements ?? [];
|
||||
expect(after[0]).toEqual(locked.spreads[1]?.elements[0]);
|
||||
expect(after.find((e) => e.id === 'kept')).toEqual(locked.spreads[1]?.elements[2]);
|
||||
const poured = after.filter((e) => e.kind === 'photo' && !e.locked);
|
||||
expect(poured).toHaveLength(4);
|
||||
expect(poured.map((e) => (e.kind === 'photo' ? e.photoId : null))).toContain(8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BookDecoratedTemplate, BookPageSetup, BookSpread, BookTemplate, Jo
|
||||
import {
|
||||
applyBookTemplate as applyTemplate,
|
||||
BOOK_COVER_TEMPLATES as COVER_TEMPLATES,
|
||||
layoutAroundLocks,
|
||||
BOOK_TEMPLATES as TEMPLATES,
|
||||
} from '@trek/shared';
|
||||
import { memo, useDeferredValue, useMemo, useState } from 'react';
|
||||
@@ -208,8 +209,11 @@ export function StudioTemplatesPanel({
|
||||
? layTemplate(sp, tpl, page, { ...ctx, words: studioTemplateWords(title, sp, source, locale) })
|
||||
: null
|
||||
);
|
||||
// A built-in layout arranges what is not locked, as the assistant's does; a decorated one keeps locks itself.
|
||||
const pickBuiltIn = (tpl: BookTemplate) =>
|
||||
lay((sp) => (pageKind(sp) === (tpl.role ?? 'inner') ? applyTemplate(sp, tpl, page) : null));
|
||||
lay((sp) =>
|
||||
pageKind(sp) === (tpl.role ?? 'inner') ? layoutAroundLocks(sp, (free) => applyTemplate(free, tpl, page)) : null
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -145,6 +145,30 @@ describe('the auto layout input', () => {
|
||||
expect(input.path).toEqual([TRACK])
|
||||
})
|
||||
|
||||
it('lays the page out again around what is locked on it', async () => {
|
||||
const { result } = await mountStudio()
|
||||
// A journey without a book opens on a blank one; the auto layout makes the entry's page.
|
||||
act(() => { result.current.relayoutBook() })
|
||||
const kept = shared.bookShapeElementSchema.parse({
|
||||
id: 'kept', kind: 'shape', frame: { x: 5, y: 5, w: 20, h: 20 }, shape: 'rect', locked: true,
|
||||
})
|
||||
const at = useStudioStore.getState().doc?.spreads.findIndex(s => s.role === 'inner' && s.entryId != null) ?? -1
|
||||
expect(at).toBeGreaterThanOrEqual(0)
|
||||
act(() => {
|
||||
useStudioStore.getState().commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((s, i) => (i === at ? { ...s, elements: [kept, ...s.elements] } : s)),
|
||||
}))
|
||||
useStudioStore.getState().setActiveSpread(at)
|
||||
})
|
||||
|
||||
act(() => { result.current.relayoutCurrentSpread() })
|
||||
|
||||
const page = useStudioStore.getState().doc?.spreads[at]
|
||||
expect(page?.elements[0]).toEqual(kept)
|
||||
expect(page?.elements.filter(e => e.locked)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves the path empty when the journey has no track', async () => {
|
||||
vi.mocked(journeyApi.listTracks).mockResolvedValue({ tracks: [] } as never)
|
||||
const { result } = await mountStudio()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router'
|
||||
import type { AutoEntry, AutoInput, AutoPhoto, BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
|
||||
import {
|
||||
autoEntriesWithStops, bookElementId as elementId, bookPageSetupSchema, bookPath, buildBook, distributeGallery,
|
||||
emptyBook, relayoutSpread,
|
||||
emptyBook, layoutAroundLocks, relayoutSpread,
|
||||
} from '@trek/shared'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore'
|
||||
@@ -484,7 +484,8 @@ export function useJourneyStudio() {
|
||||
commit(d => {
|
||||
const sp = d.spreads[activeSpread]
|
||||
if (!sp) return d
|
||||
const next = relayoutSpread(sp, { ...input, page: d.page })
|
||||
// Laid out around what is locked, as the assistant's auto layout leaves it.
|
||||
const next = layoutAroundLocks(sp, free => relayoutSpread(free, { ...input, page: d.page }))
|
||||
if (!next) return d
|
||||
return { ...d, spreads: d.spreads.map((x, i) => (i === activeSpread ? next : x)) }
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { BookDocument, BookElement, BookSpread } from '@trek/shared';
|
||||
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
|
||||
import type { BookDocument, BookElement, BookLineElement, BookSpread, BookTextElement } from '@trek/shared';
|
||||
import {
|
||||
bookLineElementSchema,
|
||||
bookLinePoints,
|
||||
bookPageSetupSchema,
|
||||
bookShapeElementSchema,
|
||||
bookTextElementSchema,
|
||||
} from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useStudioStore } from './studioStore';
|
||||
@@ -294,6 +300,72 @@ describe('copies and the style clipboard', () => {
|
||||
expect(el('t2')).not.toMatchObject({ size: 30 });
|
||||
});
|
||||
|
||||
it('a pasted colour or face takes the element off the theme, as the style operation does', () => {
|
||||
load([
|
||||
text('mine', { color: '#c2410c', font: 'playfair' }),
|
||||
text('linked', { color: '#1a1a1a', token: 'ink', font: 'inter', fontRole: 'body' }),
|
||||
]);
|
||||
store().select(['mine']);
|
||||
store().copyStyle(0);
|
||||
store().select(['linked']);
|
||||
store().pasteStyle(0);
|
||||
const linked = el('linked') as BookTextElement;
|
||||
expect(linked).toMatchObject({ color: '#c2410c', font: 'playfair' });
|
||||
expect(linked.token).toBeUndefined();
|
||||
expect(linked.fontRole).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a pasted plain fill replaces the paint of the shape it lands on', () => {
|
||||
const fade = {
|
||||
type: 'linear' as const,
|
||||
angle: 45,
|
||||
stops: [
|
||||
{ at: 0, color: '#ff0000' },
|
||||
{ at: 1, color: '#0000ff' },
|
||||
],
|
||||
};
|
||||
load([shape('red', 0, 0, { fill: '#dc2626' }), shape('painted', 40, 0, { paint: fade, fill: '#800080' })]);
|
||||
store().select(['red']);
|
||||
store().copyStyle(0);
|
||||
store().select(['painted']);
|
||||
store().pasteStyle(0);
|
||||
expect(el('painted')).toMatchObject({ fill: '#dc2626' });
|
||||
expect('paint' in el('painted')).toBe(false);
|
||||
});
|
||||
|
||||
it("a line takes another line's look and keeps its own course", () => {
|
||||
const route = bookLineElementSchema.parse({
|
||||
id: 'route',
|
||||
kind: 'line',
|
||||
frame: { x: 10, y: 10, w: 100, h: 60 },
|
||||
points: [
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 0.5, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
stroke: '#c2410c',
|
||||
dash: 'dashed',
|
||||
}) as BookElement;
|
||||
const rule = bookLineElementSchema.parse({
|
||||
id: 'rule',
|
||||
kind: 'line',
|
||||
frame: { x: 20, y: 120, w: 100, h: 2 },
|
||||
}) as BookElement;
|
||||
load([route, rule]);
|
||||
const before = bookLinePoints(rule as BookLineElement);
|
||||
store().select(['route']);
|
||||
store().copyStyle(0);
|
||||
store().select(['rule']);
|
||||
store().pasteStyle(0);
|
||||
const pasted = el('rule') as BookLineElement;
|
||||
expect(pasted).toMatchObject({ stroke: '#c2410c', dash: 'dashed' });
|
||||
expect(pasted.points).toHaveLength(2);
|
||||
bookLinePoints(pasted).forEach(([x, y], i) => {
|
||||
expect(x).toBeCloseTo(must(before[i])[0], 1);
|
||||
expect(y).toBeCloseTo(must(before[i])[1], 1);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
arrangeWithinGroup,
|
||||
bookElementId,
|
||||
bookElementStyle,
|
||||
bookElementStyled,
|
||||
commonGroup,
|
||||
distributableCount,
|
||||
distributeElements,
|
||||
@@ -343,7 +344,7 @@ function panelActions(
|
||||
},
|
||||
|
||||
pasteStyle: (spreadIndex) => {
|
||||
const { styleClip, selection } = get();
|
||||
const { styleClip, selection, doc } = get();
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
if (!styleClip || !spread) return;
|
||||
const targets = movable(spread, selection);
|
||||
@@ -355,8 +356,8 @@ function panelActions(
|
||||
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;
|
||||
// The engine's own restyle: a pasted colour unlinks the theme, a pasted fill replaces a paint.
|
||||
return bookElementStyled(e, look, doc?.theme);
|
||||
});
|
||||
return changed ? { ...sp, elements } : sp;
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BookDocument, BookElement, BookFrame, BookLayout, BookSpread } fro
|
||||
import {
|
||||
MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS, applySavedBookLayout as applySavedLayout,
|
||||
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, fitBookTexts, innerSpreadBounds as innerBounds,
|
||||
withThemeLinks,
|
||||
layoutAroundLocks, withThemeLinks,
|
||||
} from '@trek/shared'
|
||||
import { pastedElements, type StudioClipboard } from '../components/Studio/studioClipboard'
|
||||
import { regroupCopies, tidyGroups, withCopies, bookElementId } from '@trek/shared'
|
||||
@@ -284,7 +284,12 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
applyLayout: (spreadIndex, id) => {
|
||||
const layout = get().doc?.layouts?.find(l => l.id === id)
|
||||
if (!layout) return
|
||||
get().commit(d => replaceSpread(d, spreadIndex, sp => applySavedLayout(sp, layout, d.page)))
|
||||
// What is locked stays where it is, as the assistant's layout leaves it; a page it would overfill stays.
|
||||
get().commit(d => {
|
||||
const sp = d.spreads[spreadIndex]
|
||||
const laid = sp ? layoutAroundLocks(sp, free => applySavedLayout(free, layout, d.page)) : null
|
||||
return laid ? replaceSpread(d, spreadIndex, () => laid) : d
|
||||
})
|
||||
set({ selection: [] })
|
||||
},
|
||||
|
||||
|
||||
@@ -452,6 +452,30 @@ describe('filling a page with a picture', () => {
|
||||
const { container } = draw()
|
||||
expect(fillChip(container, 'journey.studio.fillPage')).toBeUndefined()
|
||||
})
|
||||
|
||||
/* Behind everything, the picture is the page and nobody's lockup: it leaves
|
||||
its group, and a group it leaves with one member is no group any more. */
|
||||
it('takes the picture out of its group, and a group of two goes with it', () => {
|
||||
load([text({ group: 'g' }), photo({ photoId: 12, group: 'g' })], ['p1'])
|
||||
const { container } = draw()
|
||||
const chip = fillChip(container, 'journey.studio.fillPage')
|
||||
if (!chip) throw new Error('no fill chip')
|
||||
|
||||
fireEvent.click(chip)
|
||||
|
||||
expect(elements().map(e => [e.id, e.group])).toEqual([['p1', undefined], ['t1', undefined]])
|
||||
})
|
||||
|
||||
it('keeps the rest of a larger group together', () => {
|
||||
load([shape({ group: 'g' }), text({ group: 'g' }), photo({ photoId: 12, group: 'g' })], ['p1'])
|
||||
const { container } = draw()
|
||||
const chip = fillChip(container, 'journey.studio.fillSpread')
|
||||
if (!chip) throw new Error('no fill chip')
|
||||
|
||||
fireEvent.click(chip)
|
||||
|
||||
expect(elements().map(e => [e.id, e.group])).toEqual([['p1', undefined], ['s1', 'g'], ['t1', 'g']])
|
||||
})
|
||||
})
|
||||
|
||||
// ── Shape ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -143,6 +143,22 @@ describe('the same background on every page', () => {
|
||||
const paper = backgroundToAll(book([spread('a'), spread('b', { background: '#000000' })]), 0);
|
||||
expect(paper.spreads[1].background).toBeNull();
|
||||
});
|
||||
|
||||
it("gives every page the source page's link to the theme, or takes theirs away when it was painted by hand", () => {
|
||||
const linked = book([
|
||||
spread('a', { background: '#f4efe6', backgroundToken: 'paper' }),
|
||||
spread('b', { background: '#000000', backgroundToken: 'accent' }),
|
||||
spread('c', { background: '#000000' }),
|
||||
]);
|
||||
const following = backgroundToAll(linked, 0);
|
||||
expect(following.spreads[1]).toMatchObject({ background: '#f4efe6', backgroundToken: 'paper' });
|
||||
expect(following.spreads[2]).toMatchObject({ background: '#f4efe6', backgroundToken: 'paper' });
|
||||
|
||||
const byHand = backgroundToAll(book([spread('a', { background: '#dbeafe' }), ...linked.spreads.slice(1)]), 0);
|
||||
expect(byHand.spreads[1]).toMatchObject({ background: '#dbeafe' });
|
||||
expect(byHand.spreads[1].backgroundToken).toBeUndefined();
|
||||
expect(byHand.spreads[2].backgroundToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the background section', () => {
|
||||
@@ -175,6 +191,17 @@ describe('the background section', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('takes the page off the theme colour it followed once a colour is picked by hand', () => {
|
||||
// A white paper is no colour of its own: the page follows the theme's paper with no background.
|
||||
useStudioStore.getState().load(book([spread('a', { backgroundToken: 'paper' })]));
|
||||
const { container } = draw();
|
||||
fireEvent.click(
|
||||
need([...container.querySelectorAll('.st-paint-tab')].find((b) => b.textContent === 'journey.studio.paint.solid'))
|
||||
);
|
||||
expect(spreads()[0].background).toBe('#111111');
|
||||
expect(spreads()[0].backgroundToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it('offers the entry and the gallery photos, lays one under the page, and takes it away again', () => {
|
||||
const { container } = draw();
|
||||
const thumbs = container.querySelectorAll('.st-bgphoto');
|
||||
|
||||
@@ -150,6 +150,22 @@ describe('studio store: clipboard and layouts', () => {
|
||||
expect(useStudioStore.getState().doc!.layouts).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-STUDIO-STORE-LAYOUT-003: what is locked stays where it is, at its place in the paint order', () => {
|
||||
const panel = { ...shape('panel'), locked: true } as BookElement
|
||||
const pinned = { ...photo('pin', 7, 300), locked: true } as BookElement
|
||||
load({
|
||||
spreads: [spread('a', [photo('p1', 1), text('t1', 'Title', 24)]), spread('b', [panel, photo('q1', 9), pinned])],
|
||||
})
|
||||
expect(useStudioStore.getState().saveLayout(0, 'Layout 1')).toBe(true)
|
||||
const layout = useStudioStore.getState().doc?.layouts?.[0]
|
||||
if (!layout) throw new Error('no layout kept')
|
||||
useStudioStore.getState().applyLayout(1, layout.id)
|
||||
const applied = useStudioStore.getState().doc?.spreads[1].elements ?? []
|
||||
expect(applied[0]).toEqual(panel)
|
||||
expect(applied.find(e => e.id === 'pin')).toEqual(pinned)
|
||||
expect(applied.filter(e => e.kind === 'photo' && !e.locked).map(e => e.kind === 'photo' && e.photoId)).toEqual([9])
|
||||
})
|
||||
|
||||
it('FE-STUDIO-STORE-LAYOUT-002: an empty page is not a layout, and a full book says no', () => {
|
||||
const full = Array.from({ length: MAX_BOOK_LAYOUTS }, (_, i) => layoutFromSpread(spread('x', []), page, `L${i}`))
|
||||
load({ spreads: [spread('a', []), spread('b', [photo('p', 1)])], layouts: full })
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { layoutAroundLocks } from './book-layout-locks';
|
||||
import {
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookShapeElementSchema,
|
||||
bookSpreadSchema,
|
||||
type BookElement,
|
||||
type BookSpread,
|
||||
} from './book.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** A page laid out again around its locked elements, for the layout operation and the Studio alike. */
|
||||
|
||||
const shape = (id: string, over: Record<string, unknown> = {}) =>
|
||||
bookShapeElementSchema.parse({ id, kind: 'shape', frame: { x: 0, y: 0, w: 10, h: 10 }, ...over }) as BookElement;
|
||||
|
||||
const page = (elements: BookElement[], over: Record<string, unknown> = {}): BookSpread =>
|
||||
bookSpreadSchema.parse({ id: 'sp', role: 'inner', elements, ...over });
|
||||
|
||||
/** A layout that throws away what it is given and lays `n` new shapes. */
|
||||
const fresh =
|
||||
(n: number) =>
|
||||
(free: BookSpread): BookSpread => ({
|
||||
...free,
|
||||
groups: undefined,
|
||||
elements: Array.from({ length: n }, (_, i) => shape(`new-${i}`)),
|
||||
});
|
||||
|
||||
describe('a layout around locked elements', () => {
|
||||
it('BKLOCK-001: lays out only what is free, and puts each locked element back at its place in the paint order', () => {
|
||||
const seen: string[][] = [];
|
||||
const out = layoutAroundLocks(
|
||||
page([shape('back', { locked: true }), shape('a'), shape('front', { locked: true }), shape('b')]),
|
||||
(free) => {
|
||||
seen.push(free.elements.map((e) => e.id));
|
||||
return fresh(3)(free);
|
||||
},
|
||||
);
|
||||
expect(seen).toEqual([['a', 'b']]);
|
||||
expect(out?.elements.map((e) => e.id)).toEqual(['back', 'new-0', 'front', 'new-1', 'new-2']);
|
||||
});
|
||||
|
||||
it('BKLOCK-002: keeps the name of a locked group the layout wrote away', () => {
|
||||
const out = layoutAroundLocks(
|
||||
page([shape('m1', { locked: true, group: 'g' }), shape('m2', { locked: true, group: 'g' }), shape('a')], {
|
||||
groups: [{ id: 'g', name: 'Stamp' }],
|
||||
}),
|
||||
fresh(1),
|
||||
);
|
||||
expect(out?.groups).toEqual([{ id: 'g', name: 'Stamp' }]);
|
||||
expect(out?.elements.map((e) => e.id)).toEqual(['m1', 'm2', 'new-0']);
|
||||
});
|
||||
|
||||
it('BKLOCK-003: answers null when the layout lays nothing, or would overfill the page with the locked ones', () => {
|
||||
const locked = page([shape('l', { locked: true }), shape('a')]);
|
||||
expect(layoutAroundLocks(locked, () => null)).toBeNull();
|
||||
expect(layoutAroundLocks(locked, fresh(MAX_SPREAD_ELEMENTS))).toBeNull();
|
||||
expect(layoutAroundLocks(locked, fresh(MAX_SPREAD_ELEMENTS - 1))?.elements).toHaveLength(MAX_SPREAD_ELEMENTS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MAX_SPREAD_ELEMENTS, type BookSpread } from './book.schema';
|
||||
|
||||
/**
|
||||
* A page laid out again around its locked elements.
|
||||
*
|
||||
* A lock protects a finished part of a page while the rest is reworked, so a
|
||||
* layout arranges only what is not locked: every locked element stays where
|
||||
* it is, at its place in the paint order, and a locked group keeps its name
|
||||
* through a layout that writes the page's groups anew. One rule for the
|
||||
* assistant's `layout` operation and for the Studio's layouts, so a lock holds
|
||||
* whichever of them lays the page out.
|
||||
*
|
||||
* `lay` gets the page without its locked elements and answers it laid out,
|
||||
* or null when it lays nothing. The answer is null too when the layout and
|
||||
* the locked elements together would not fit on one page.
|
||||
*/
|
||||
export function layoutAroundLocks(spread: BookSpread, lay: (free: BookSpread) => BookSpread | null): BookSpread | null {
|
||||
const locked = spread.elements.flatMap((el, i) => (el.locked ? [{ el, i }] : []));
|
||||
const next = lay({ ...spread, elements: spread.elements.filter((e) => !e.locked) });
|
||||
if (!next) return null;
|
||||
const elements = next.elements.slice();
|
||||
for (const { el, i } of locked) elements.splice(Math.min(i, elements.length), 0, el);
|
||||
if (elements.length > MAX_SPREAD_ELEMENTS) return null;
|
||||
const have = new Set((next.groups ?? []).map((g) => g.id));
|
||||
const lost = (spread.groups ?? []).filter((g) => !have.has(g.id) && locked.some(({ el }) => el.group === g.id));
|
||||
return { ...next, elements, ...(lost.length ? { groups: [...(next.groups ?? []), ...lost] } : {}) };
|
||||
}
|
||||
@@ -179,6 +179,13 @@ function withProps(el: BookElement, props: Record<string, unknown>, theme?: Book
|
||||
return withThemeLinks(made.kind === 'shape' ? withPaintProps(made, props) : made, props, theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* An element restyled exactly as the `style` operation restyles it, for the
|
||||
* Studio's paste style: one restyle, so a pasted look and the assistant's
|
||||
* keep a page's theme links, a shape's paint and a line's course alike.
|
||||
*/
|
||||
export const bookElementStyled = withProps;
|
||||
|
||||
/**
|
||||
* A shape's paint, kept in step with its fill: a paint given is written
|
||||
* through withShapePaint, and a plain fill or fade given without one replaces
|
||||
|
||||
+10
-18
@@ -2,6 +2,7 @@ import { withRealWeight } from './book-fonts';
|
||||
import { bookGridElements, defaultBookGridBox, findBookGrid, type BookGridDef } from './book-grids';
|
||||
import { tidyGroups } from './book-groups';
|
||||
import { bookElementId } from './book-ids';
|
||||
import { layoutAroundLocks } from './book-layout-locks';
|
||||
import { BOOK_TEXT_STYLES, applyBookTemplate, classifyBookTexts, findBookTemplate } from './book-layouts';
|
||||
import {
|
||||
bookBoundWords,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
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 { withSpreadPaint } from './book-paint';
|
||||
import { withSpreadPaintByHand } from './book-paint';
|
||||
import { applySavedBookLayout, bookLayoutRole } from './book-saved-layouts';
|
||||
import { fitSpreadTexts } from './book-text-fit';
|
||||
import {
|
||||
@@ -128,14 +129,12 @@ function applyOne(spread: BookSpread, op: BookPageOp, env: Env): BookSpread {
|
||||
return opFrame(spread, op, env);
|
||||
case 'remove':
|
||||
return opRemove(spread, op, env);
|
||||
case 'background': {
|
||||
// A colour set by hand stops following the theme's paper.
|
||||
const { backgroundToken: _unlinked, ...rest } = spread;
|
||||
return withSpreadPaint(
|
||||
rest,
|
||||
case 'background':
|
||||
// A colour set by hand stops following the theme's paper, as in the Studio.
|
||||
return withSpreadPaintByHand(
|
||||
spread,
|
||||
op.paint !== undefined ? op.paint : op.color ? { type: 'solid', color: op.color } : null,
|
||||
);
|
||||
}
|
||||
default:
|
||||
return isGroupOp(op) ? applyGroupOp(spread, op, env) : applyElementOp(spread, op, env);
|
||||
}
|
||||
@@ -175,19 +174,12 @@ const spreadWidth = (spread: BookSpread, page: BookPageSetup) => page.pageWidth
|
||||
|
||||
function opLayout(spread: BookSpread, op: Op<'layout'>, env: Env): BookSpread {
|
||||
// A locked element is left where it is, and keeps its place in the paint
|
||||
// order; the layout arranges everything else.
|
||||
const locked = spread.elements.flatMap((el, i) => (el.locked ? [{ el, i }] : []));
|
||||
const free: BookSpread = { ...spread, elements: spread.elements.filter((e) => !e.locked) };
|
||||
const next = layoutFree(free, op, env);
|
||||
const elements = next.elements.slice();
|
||||
for (const { el, i } of locked) elements.splice(Math.min(i, elements.length), 0, el);
|
||||
if (elements.length > MAX_SPREAD_ELEMENTS) {
|
||||
// order; the layout arranges everything else, as in the Studio.
|
||||
const laid = layoutAroundLocks(spread, (free) => layoutFree(free, op, env));
|
||||
if (!laid) {
|
||||
throw failOp('too_many_elements', `the layout and the locked elements exceed ${MAX_SPREAD_ELEMENTS}`, env.opIndex);
|
||||
}
|
||||
// A locked group keeps its name, also through a layout that writes the page's groups anew.
|
||||
const have = new Set((next.groups ?? []).map((g) => g.id));
|
||||
const lost = (spread.groups ?? []).filter((g) => !have.has(g.id) && locked.some(({ el }) => el.group === g.id));
|
||||
return { ...next, elements, ...(lost.length ? { groups: [...(next.groups ?? []), ...lost] } : {}) };
|
||||
return laid;
|
||||
}
|
||||
|
||||
function layoutFree(spread: BookSpread, op: Op<'layout'>, env: Env): BookSpread {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
spreadPaint,
|
||||
withShapePaint,
|
||||
withSpreadPaint,
|
||||
withSpreadPaintByHand,
|
||||
} from './book-paint';
|
||||
import { BOOK_PATTERNS, bookPaintInSchema, bookPaintSchema, type BookPaint } from './book-paint.schema';
|
||||
import { applySavedBookLayout, bookLayoutFromSpread, bookLayoutPreview } from './book-saved-layouts';
|
||||
@@ -28,7 +29,7 @@ import { describeSpread } from './describe-spread';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// BKPAINT-001 to BKPAINT-016
|
||||
// BKPAINT-001 to BKPAINT-017
|
||||
|
||||
/**
|
||||
* Paint: the document's gradients, alpha and patterns, how they stay readable
|
||||
@@ -187,6 +188,18 @@ describe('a page and its paint', () => {
|
||||
const replaced = applySavedBookLayout(spread({ backgroundPaint: linear }), plainLayout, page);
|
||||
expect(['backgroundPaint' in replaced, replaced.background]).toEqual([false, '#111111']);
|
||||
});
|
||||
|
||||
it('BKPAINT-017: a paint set by hand takes the page off its theme colour, Studio and assistant alike', () => {
|
||||
const linked = spread({ background: '#f4efe6', backgroundToken: 'paper' });
|
||||
expect(withSpreadPaint(linked, stripes).backgroundToken).toBe('paper');
|
||||
const painted = withSpreadPaintByHand(linked, stripes);
|
||||
expect(painted).toMatchObject({ backgroundPaint: stripes, background: '#ffeedd' });
|
||||
expect('backgroundToken' in painted).toBe(false);
|
||||
const result = applyBookOps(doc([linked]), { spreadId: 'sp1', ops: [{ op: 'background', color: '#202020' }] });
|
||||
if (!result.ok) throw new Error(result.error.message);
|
||||
expect(result.doc.spreads[0]).toMatchObject({ background: '#202020' });
|
||||
expect(result.doc.spreads[0]?.backgroundToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a paint is in words', () => {
|
||||
|
||||
@@ -140,6 +140,17 @@ export function withSpreadPaint<T extends BookSpread>(spread: T, paint: BookPain
|
||||
return { ...rest, backgroundPaint: paint, background: paintFallbackHex(paint) } as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page painted by hand: this paint, and the page's link to a theme colour
|
||||
* (`backgroundToken`) taken away, so the next theme applied leaves the colour
|
||||
* somebody chose. The `background` operation and the Studio's background
|
||||
* picker both paint a page through it.
|
||||
*/
|
||||
export function withSpreadPaintByHand(spread: BookSpread, paint: BookPaint | null): BookSpread {
|
||||
const { backgroundToken: _unlinked, ...rest } = spread;
|
||||
return withSpreadPaint(rest, paint);
|
||||
}
|
||||
|
||||
/** Every colour a paint is made of, in the order it names them. */
|
||||
export function paintColours(paint: BookPaint | null | undefined): string[] {
|
||||
if (!paint) return [];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { bookElementStyle, bookStyleKeys, pastableStyle } from './book-style-copy';
|
||||
import {
|
||||
bookIconElementSchema,
|
||||
bookLineElementSchema,
|
||||
bookListElementSchema,
|
||||
bookShapeElementSchema,
|
||||
bookTextElementSchema,
|
||||
@@ -35,6 +36,24 @@ describe('copy style', () => {
|
||||
expect(bookStyleKeys('stats')).not.toContain('metrics');
|
||||
});
|
||||
|
||||
it("BKSTY-005: takes a line's look and never its points, which are where it runs", () => {
|
||||
const route = bookLineElementSchema.parse({
|
||||
id: 'r',
|
||||
kind: 'line',
|
||||
frame,
|
||||
points: [
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 0.5, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
stroke: '#c2410c',
|
||||
dash: 'dashed',
|
||||
}) as BookElement;
|
||||
expect(bookStyleKeys('line')).not.toContain('points');
|
||||
expect(bookElementStyle(route).props).toMatchObject({ stroke: '#c2410c', dash: 'dashed' });
|
||||
expect(bookElementStyle(route).props).not.toHaveProperty('points');
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
@@ -5,11 +5,12 @@ 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.
|
||||
* kind (book-element-ops.schema.ts), less the few props that are words or a
|
||||
* course rather than looks (a list's column labels, the figures a stats block
|
||||
* shows, a line's points). So a pasted style is one `style` operation per
|
||||
* element, applied by the same restyle (`bookElementStyled`), 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 {
|
||||
@@ -17,8 +18,11 @@ export interface BookCopiedStyle {
|
||||
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']);
|
||||
/**
|
||||
* Props of `style` that say what an element shows or where it runs, not how it
|
||||
* looks: a line's points are its course, so pasting a look never redraws one.
|
||||
*/
|
||||
const CONTENT_KEYS: ReadonlySet<string> = new Set(['metrics', 'proLabel', 'conLabel', 'points']);
|
||||
|
||||
const STYLE_KEYS = new Map<string, string[]>(
|
||||
bookElementPropsSchema.options.map((o) => [
|
||||
|
||||
@@ -133,6 +133,8 @@ export * from './book/book-line.schema';
|
||||
export * from './book/book-line';
|
||||
export * from './book/book-line-ops.schema';
|
||||
export { bookNewLine, bookLineStyled } from './book/book-ops-line';
|
||||
export { bookElementStyled } from './book/book-ops-elements';
|
||||
export { layoutAroundLocks } from './book/book-layout-locks';
|
||||
export { bookSpreadPhotoIds } from './book/book-photo-refs';
|
||||
export * from './book/book-normalize';
|
||||
export * from './book/book-schema-rev';
|
||||
|
||||
Reference in New Issue
Block a user