Files
TREK/client/tests/unit/studio/studioClipboardLayouts.test.ts
T
Maurice 84e18e4954 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.
2026-10-24 04:06:27 +00:00

177 lines
10 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import type { BookDocument, BookElement, BookPageSetup, BookSpread } from '@trek/shared'
import {
MAX_BOOK_LAYOUTS, bookBadgeElementSchema, bookLayoutSchema, bookPageSetupSchema, bookPhotoElementSchema,
bookShapeElementSchema, bookTextElementSchema,
applySavedBookLayout as applySavedLayout, bookLayoutFromSpread as layoutFromSpread,
bookLayoutPreview as layoutPreview, bookLayoutRole as layoutRole, nextBookLayoutName as nextLayoutName,
} from '@trek/shared'
import { pastedElements } from '../../../src/components/Studio/studioClipboard'
import { useStudioStore } from '../../../src/store/studioStore'
/**
* Copying between pages and a book's own layouts (#2316).
*
* What matters is what lands in the document: fresh ids on every copy, because
* the book is edited by several people at once; nothing pasted off the paper;
* and a layout that keeps the design while the page it is applied to keeps its
* pictures, its words and its own day and coordinates.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 })
const text = (id: string, words: string, size = 10, over: Record<string, unknown> = {}): BookElement =>
bookTextElementSchema.parse({ id, kind: 'text', frame: { x: 20, y: 20, w: 80, h: 20 }, text: words, size, ...over }) as BookElement
const photo = (id: string, photoId: number | null, x = 10): BookElement =>
bookPhotoElementSchema.parse({ id, kind: 'photo', frame: { x, y: 60, w: 60, h: 60 }, photoId, frameStyle: 'polaroid' }) as BookElement
const shape = (id: string): BookElement =>
bookShapeElementSchema.parse({ id, kind: 'shape', frame: { x: 0, y: 150, w: 420, h: 60 } }) as BookElement
const badge = (id: string, variant: string, words: string): BookElement =>
bookBadgeElementSchema.parse({ id, kind: 'badge', variant, frame: { x: 5, y: 5, w: 30, h: 8 }, text: words }) as BookElement
const spread = (id: string, elements: BookElement[], role: BookSpread['role'] = 'inner'): BookSpread =>
({ id, role, background: null, elements, parked: [], entryId: null })
function load(doc: Partial<BookDocument>) {
useStudioStore.getState().load({ version: 1, title: 'T', page, spreads: [], ...doc } as BookDocument)
}
describe('pastedElements', () => {
it('FE-STUDIO-CLIP-001: every paste gets fresh ids and arrives unlocked where it was', () => {
const clip = { fromSpreadId: 'a', elements: [{ ...photo('p1', 5), locked: true } as BookElement] }
const first = pastedElements(clip, spread('b', []), page)
const second = pastedElements(clip, spread('b', []), page)
expect(first[0].id).not.toBe('p1')
expect(first[0].id).not.toBe(second[0].id)
expect(first[0]).toMatchObject({ locked: false, frame: { x: 10, y: 60, w: 60, h: 60 } })
})
it('FE-STUDIO-CLIP-002: back on its own page the copy steps aside, onto a single page it is pulled inside', () => {
const onRight = { ...photo('p1', 5, 300) } as BookElement
const same = pastedElements({ fromSpreadId: 'a', elements: [onRight] }, spread('a', []), page)
expect(same[0].frame).toMatchObject({ x: 304, y: 64 })
const cover = pastedElements({ fromSpreadId: 'a', elements: [onRight] }, spread('c', [], 'cover'), page)
expect(cover[0].frame).toMatchObject({ x: 150, w: 60 })
const wide = pastedElements({ fromSpreadId: 'a', elements: [shape('s1')] }, spread('c', [], 'cover'), page)
expect(wide[0].frame).toMatchObject({ x: 0, w: 210 })
})
})
describe('saved layouts', () => {
it('FE-STUDIO-LAYOUT-001: a kept layout is the design without the pictures, and reads through the contract', () => {
const layout = layoutFromSpread(spread('a', [photo('p1', 5), text('t1', 'Harbour', 22)]), page, 'Layout 1')
expect(layout.elements[0]).toMatchObject({ kind: 'photo', photoId: null, frameStyle: 'polaroid' })
expect(layout).toMatchObject({ role: 'inner', pageWidth: 210, pageHeight: 210, name: 'Layout 1' })
expect(bookLayoutSchema.safeParse(layout).success).toBe(true)
expect(layoutRole(spread('c', [], 'cover'))).toBe('single')
})
it('FE-STUDIO-LAYOUT-002: names count on past the ones taken', () => {
const l = (name: string) => ({ ...layoutFromSpread(spread('a', []), page, name) })
expect(nextLayoutName([], 'Layout')).toBe('Layout 1')
expect(nextLayoutName([l('Layout 2')], 'Layout')).toBe('Layout 3')
})
it('FE-STUDIO-LAYOUT-003: applying keeps the page\'s pictures, words and marks, in the layout\'s design', () => {
const drawn = spread('a', [
shape('s1'), photo('p1', 1), photo('p2', 2, 80),
text('t-title', 'Old title', 24), text('t-body', 'Old story', 10, { binding: { source: 'entry.story' } }),
text('t-label', 'CHAPTER', 8), badge('b1', 'day', 'DAY 1'),
])
const layout = layoutFromSpread(drawn, page, 'Layout 1')
const target = spread('b', [
photo('q1', 11), text('u-title', 'Reykjavík', 22), badge('c1', 'day', 'DAY 2'),
])
const out = applySavedLayout(target, layout, page)
const kinds = out.elements.map(e => e.kind)
expect(kinds).toEqual(['shape', 'photo', 'photo', 'text', 'text', 'badge'])
const photos = out.elements.filter(e => e.kind === 'photo') as Extract<BookElement, { kind: 'photo' }>[]
expect(photos.map(p => p.photoId)).toEqual([11, null])
expect(photos[0].frameStyle).toBe('polaroid')
const texts = out.elements.filter(e => e.kind === 'text') as Extract<BookElement, { kind: 'text' }>[]
// The title takes the largest box; the bound story box had nothing to take and is left out; the label stays.
expect(texts.map(t => [t.text, t.size])).toEqual([['Reykjavík', 24], ['CHAPTER', 8]])
const day = out.elements.find(e => e.kind === 'badge') as Extract<BookElement, { kind: 'badge' }>
expect(day.text).toBe('DAY 2')
expect(new Set(out.elements.map(e => e.id)).size).toBe(out.elements.length)
expect(out.elements.some(e => ['s1', 'p1', 't-title', 'b1'].includes(e.id))).toBe(false)
})
it('FE-STUDIO-LAYOUT-004: what the layout has no room for is parked, and a new trim scales the design', () => {
const layout = { ...layoutFromSpread(spread('a', [photo('p1', 1)]), page, 'L'), pageWidth: 105, pageHeight: 105 }
const out = applySavedLayout(spread('b', [photo('q1', 11), photo('q2', 12), text('u', 'Words')]), layout, page)
expect(out.elements[0].frame).toMatchObject({ x: 20, y: 120, w: 120, h: 120 })
expect(out.parked.map(e => (e.kind === 'photo' ? e.photoId : e.kind))).toEqual([12, 'text'])
})
it('FE-STUDIO-LAYOUT-005: the card draws the layout on this book\'s page, as a single page when it is one', () => {
const layout = { ...layoutFromSpread(spread('c', [photo('p1', 1)], 'cover'), page, 'L'), pageWidth: 105 }
const preview = layoutPreview(layout, page)
expect(preview.role).toBe('first')
expect(preview.elements[0].frame.x).toBe(20)
})
})
describe('studio store: clipboard and layouts', () => {
beforeEach(() => useStudioStore.setState({ clipboard: null }))
it('FE-STUDIO-STORE-CLIP-001: copy on one page, paste on another, as one undo step with the copy selected', () => {
load({ spreads: [spread('a', [photo('p1', 5), text('t1', 'Keep')]), spread('b', [])] })
const s = useStudioStore.getState()
s.copy(0, ['p1'])
s.paste(1)
const after = useStudioStore.getState()
const pasted = after.doc!.spreads[1].elements
expect(pasted).toHaveLength(1)
expect(pasted[0]).toMatchObject({ kind: 'photo', photoId: 5 })
expect(after.selection).toEqual([pasted[0].id])
after.undo()
expect(useStudioStore.getState().doc!.spreads[1].elements).toHaveLength(0)
})
it('FE-STUDIO-STORE-CLIP-002: nothing copied, nothing pasted; an unknown id copies nothing', () => {
load({ spreads: [spread('a', [photo('p1', 5)])] })
const s = useStudioStore.getState()
s.paste(0)
s.copy(0, ['missing'])
expect(useStudioStore.getState().clipboard).toBeNull()
expect(useStudioStore.getState().doc!.spreads[0].elements).toHaveLength(1)
})
it('FE-STUDIO-STORE-LAYOUT-001: save, apply to another page, delete', () => {
load({ spreads: [spread('a', [photo('p1', 1), text('t1', 'Title', 24)]), spread('b', [photo('q1', 9)])] })
expect(useStudioStore.getState().saveLayout(0, 'Layout 1')).toBe(true)
const layout = useStudioStore.getState().doc!.layouts![0]
useStudioStore.getState().applyLayout(1, layout.id)
const applied = useStudioStore.getState().doc!.spreads[1].elements
expect(applied.map(e => e.kind)).toEqual(['photo', 'text'])
expect(applied[0]).toMatchObject({ photoId: 9 })
useStudioStore.getState().removeLayout(layout.id)
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 })
expect(useStudioStore.getState().saveLayout(0, 'Empty')).toBe(false)
expect(useStudioStore.getState().saveLayout(1, 'One too many')).toBe(false)
expect(useStudioStore.getState().doc!.layouts).toHaveLength(MAX_BOOK_LAYOUTS)
})
})