mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
84e18e4954
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.
177 lines
10 KiB
TypeScript
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)
|
|
})
|
|
})
|