mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
87c73e50fb
A new book was a cover, a spread and a back cover, so it opened straight onto pages 2 and 3. A bound book does not: the inside of the cover is blank, and the first thing facing the reader is a single right-hand page, page 1. The same at the end, where a single left-hand page sits before the back cover. Both are laid down now, empty, in a new book and in a laid-out one, and they are fixed the way the covers are: an inner spread only ever goes between them. The page numbers follow. They were counted from a spread's position in the array, which assumed the spread after the cover was the first, and three places did that sum on their own. One function now counts along the book, one page for a single leaf and two for a spread, so the sheet on screen and the label in the print view and the folio on the page all say the same number. A book with a first page starts at 1; a book from before the first page existed has none and keeps the numbers it always had.
377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import type { BookDocument, BookElement, BookSpread } from '@trek/shared'
|
|
import { useStudioStore } from '../../../src/store/studioStore'
|
|
|
|
/**
|
|
* Spread management (#1973).
|
|
*
|
|
* The rule everything else follows from: the cover and the back cover are fixed
|
|
* points. A book has exactly one of each, they are single pages, and no amount
|
|
* of dragging in the rail should be able to produce a book with two covers or
|
|
* none. Every operation here refuses on anything that is not an inner spread.
|
|
*/
|
|
|
|
const el = (id: string): BookElement => ({
|
|
id, kind: 'shape', frame: { x: 0, y: 0, w: 10, h: 10 },
|
|
rotation: 0, opacity: 1, locked: false,
|
|
shape: 'rect', fill: '#000000', gradient: 'none',
|
|
stroke: null, strokeWidth: 0, strokeStyle: 'solid', radius: 0,
|
|
} as BookElement)
|
|
|
|
const spread = (id: string, role: BookSpread['role'], elements: BookElement[] = []): BookSpread => ({
|
|
id, role, background: null, elements, parked: [], entryId: null,
|
|
})
|
|
|
|
function book(...spreads: BookSpread[]): BookDocument {
|
|
return {
|
|
version: 1, title: 'T',
|
|
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
|
spreads,
|
|
} as BookDocument
|
|
}
|
|
|
|
const store = () => useStudioStore.getState()
|
|
const roles = () => store().doc!.spreads.map(s => s.role)
|
|
const ids = () => store().doc!.spreads.map(s => s.id)
|
|
|
|
beforeEach(() => {
|
|
useStudioStore.getState().load(book(
|
|
spread('cover', 'cover'),
|
|
spread('a', 'inner'),
|
|
spread('b', 'inner'),
|
|
spread('c', 'inner'),
|
|
spread('back', 'back'),
|
|
))
|
|
})
|
|
|
|
describe('canEditSpread', () => {
|
|
it('is true for an inner spread and false for the covers', () => {
|
|
expect(store().canEditSpread(0)).toBe(false)
|
|
expect(store().canEditSpread(1)).toBe(true)
|
|
expect(store().canEditSpread(4)).toBe(false)
|
|
})
|
|
|
|
it('is false past either end rather than throwing', () => {
|
|
expect(store().canEditSpread(-1)).toBe(false)
|
|
expect(store().canEditSpread(99)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('addSpread', () => {
|
|
it('inserts after the given spread and selects it', () => {
|
|
store().addSpread(1)
|
|
expect(ids()).toEqual(['cover', 'a', expect.any(String), 'b', 'c', 'back'])
|
|
expect(store().activeSpread).toBe(2)
|
|
})
|
|
|
|
it('inserts an inner spread, whatever it was asked to follow', () => {
|
|
store().addSpread(0)
|
|
expect(roles()[1]).toBe('inner')
|
|
})
|
|
|
|
it('starts the new spread empty', () => {
|
|
store().addSpread(1)
|
|
const made = store().doc!.spreads[2]
|
|
expect(made.elements).toEqual([])
|
|
expect(made.parked).toEqual([])
|
|
expect(made.entryId).toBeNull()
|
|
})
|
|
|
|
/*
|
|
* The one that keeps the book a book: a page inserted behind the back cover
|
|
* is a page nobody would ever turn to.
|
|
*/
|
|
it('never lands behind the back cover', () => {
|
|
store().addSpread(4)
|
|
expect(roles()[roles().length - 1]).toBe('back')
|
|
expect(roles()).toEqual(['cover', 'inner', 'inner', 'inner', 'inner', 'back'])
|
|
})
|
|
|
|
it('gives the new spread an id of its own', () => {
|
|
store().addSpread(1)
|
|
expect(new Set(ids()).size).toBe(ids().length)
|
|
})
|
|
|
|
it('is one undo step', () => {
|
|
store().addSpread(1)
|
|
expect(store().doc!.spreads).toHaveLength(6)
|
|
store().undo()
|
|
expect(store().doc!.spreads).toHaveLength(5)
|
|
})
|
|
})
|
|
|
|
describe('duplicateSpread', () => {
|
|
beforeEach(() => {
|
|
useStudioStore.getState().load(book(
|
|
spread('cover', 'cover'),
|
|
spread('a', 'inner', [el('e1'), el('e2')]),
|
|
spread('back', 'back'),
|
|
))
|
|
})
|
|
|
|
it('copies the contents directly after the original', () => {
|
|
store().duplicateSpread(1)
|
|
expect(store().doc!.spreads).toHaveLength(4)
|
|
expect(store().doc!.spreads[2].elements).toHaveLength(2)
|
|
expect(store().activeSpread).toBe(2)
|
|
})
|
|
|
|
/*
|
|
* Two elements sharing an id would make selection ambiguous and every lookup
|
|
* that follows it wrong — including delete, which would take both.
|
|
*/
|
|
it('gives the copied elements fresh ids', () => {
|
|
store().duplicateSpread(1)
|
|
const [original, copy] = [store().doc!.spreads[1], store().doc!.spreads[2]]
|
|
expect(copy.id).not.toBe(original.id)
|
|
for (const e of copy.elements) {
|
|
expect(original.elements.map(o => o.id)).not.toContain(e.id)
|
|
}
|
|
})
|
|
|
|
it('refuses on the cover and on the back cover', () => {
|
|
store().duplicateSpread(0)
|
|
store().duplicateSpread(2)
|
|
expect(store().doc!.spreads).toHaveLength(3)
|
|
})
|
|
})
|
|
|
|
describe('removeSpread', () => {
|
|
it('takes the spread out', () => {
|
|
store().removeSpread(2)
|
|
expect(ids()).toEqual(['cover', 'a', 'c', 'back'])
|
|
})
|
|
|
|
it('refuses on the cover and on the back cover', () => {
|
|
store().removeSpread(0)
|
|
store().removeSpread(4)
|
|
expect(store().doc!.spreads).toHaveLength(5)
|
|
expect(roles()).toEqual(['cover', 'inner', 'inner', 'inner', 'back'])
|
|
})
|
|
|
|
it('lands on the spread that took its place', () => {
|
|
store().setActiveSpread(2)
|
|
store().removeSpread(2)
|
|
expect(store().activeSpread).toBe(2)
|
|
expect(ids()[2]).toBe('c')
|
|
})
|
|
|
|
it('clears the selection, since what was selected is gone', () => {
|
|
store().select(['e1'])
|
|
store().removeSpread(1)
|
|
expect(store().selection).toEqual([])
|
|
})
|
|
|
|
it('is undoable', () => {
|
|
store().removeSpread(2)
|
|
store().undo()
|
|
expect(ids()).toEqual(['cover', 'a', 'b', 'c', 'back'])
|
|
})
|
|
})
|
|
|
|
describe('moveSpread', () => {
|
|
it('swaps with the spread before it', () => {
|
|
store().moveSpread(2, -1)
|
|
expect(ids()).toEqual(['cover', 'b', 'a', 'c', 'back'])
|
|
expect(store().activeSpread).toBe(1)
|
|
})
|
|
|
|
it('swaps with the spread after it', () => {
|
|
store().moveSpread(1, 1)
|
|
expect(ids()).toEqual(['cover', 'b', 'a', 'c', 'back'])
|
|
expect(store().activeSpread).toBe(2)
|
|
})
|
|
|
|
/*
|
|
* Refusing at the ends is what keeps the covers in place without any of the
|
|
* callers having to know about roles.
|
|
*/
|
|
it('will not move an inner spread past the cover', () => {
|
|
store().moveSpread(1, -1)
|
|
expect(ids()).toEqual(['cover', 'a', 'b', 'c', 'back'])
|
|
})
|
|
|
|
it('will not move an inner spread past the back cover', () => {
|
|
store().moveSpread(3, 1)
|
|
expect(ids()).toEqual(['cover', 'a', 'b', 'c', 'back'])
|
|
})
|
|
|
|
it('will not move the covers themselves', () => {
|
|
store().moveSpread(0, 1)
|
|
store().moveSpread(4, -1)
|
|
expect(ids()).toEqual(['cover', 'a', 'b', 'c', 'back'])
|
|
})
|
|
|
|
it('is one undo step', () => {
|
|
store().moveSpread(1, 1)
|
|
store().undo()
|
|
expect(ids()).toEqual(['cover', 'a', 'b', 'c', 'back'])
|
|
})
|
|
})
|
|
|
|
describe('a book with no covers at all', () => {
|
|
beforeEach(() => {
|
|
useStudioStore.getState().load(book(spread('a', 'inner'), spread('b', 'inner')))
|
|
})
|
|
|
|
it('still adds at the end', () => {
|
|
store().addSpread(1)
|
|
expect(store().doc!.spreads).toHaveLength(3)
|
|
expect(store().activeSpread).toBe(2)
|
|
})
|
|
|
|
it('lets the last spread be deleted, leaving an empty book rather than failing', () => {
|
|
store().removeSpread(1)
|
|
store().removeSpread(0)
|
|
expect(store().doc!.spreads).toEqual([])
|
|
expect(store().activeSpread).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('inserting a spread that arrived from outside', () => {
|
|
beforeEach(() => {
|
|
useStudioStore.getState().load(book(
|
|
spread('cover', 'cover'),
|
|
spread('a', 'inner'),
|
|
spread('back', 'back'),
|
|
))
|
|
})
|
|
|
|
const imported = () => spread('from-a-file', 'inner', [el('x'), el('y')])
|
|
|
|
it('lands inside the covers and becomes the spread being edited', () => {
|
|
store().insertSpread(1, imported())
|
|
expect(roles()).toEqual(['cover', 'inner', 'inner', 'back'])
|
|
expect(store().activeSpread).toBe(2)
|
|
})
|
|
|
|
it('never lands in front of the cover, whatever index it is given', () => {
|
|
store().insertSpread(-1, imported())
|
|
expect(roles()[0]).toBe('cover')
|
|
})
|
|
|
|
it('never lands behind the back cover', () => {
|
|
store().insertSpread(99, imported())
|
|
expect(roles()[roles().length - 1]).toBe('back')
|
|
})
|
|
|
|
it('mints fresh ids, so nothing collides with what is already there', () => {
|
|
store().insertSpread(1, imported())
|
|
const all = store().doc!.spreads.flatMap(s => s.elements.map(e => e.id))
|
|
expect(new Set(all).size).toBe(all.length)
|
|
expect(ids()).not.toContain('from-a-file')
|
|
})
|
|
|
|
it('arrives as an inner spread even if the file claimed otherwise', () => {
|
|
store().insertSpread(1, { ...imported(), role: 'cover' as const })
|
|
expect(roles()).toEqual(['cover', 'inner', 'inner', 'back'])
|
|
})
|
|
|
|
it('is one undo step', () => {
|
|
store().insertSpread(1, imported())
|
|
store().undo()
|
|
expect(ids()).toEqual(['cover', 'a', 'back'])
|
|
})
|
|
})
|
|
|
|
describe('turning an element', () => {
|
|
beforeEach(() => {
|
|
useStudioStore.getState().load(book(spread('a', 'inner', [el('one'), el('two')])))
|
|
})
|
|
|
|
const rotationOf = (id: string) => store().doc!.spreads[0].elements.find(e => e.id === id)!.rotation
|
|
|
|
it('turns the one it was given and leaves the rest alone', () => {
|
|
store().setRotation(0, 'one', 45)
|
|
expect(rotationOf('one')).toBe(45)
|
|
expect(rotationOf('two')).toBe(0)
|
|
})
|
|
|
|
/*
|
|
* A drag calls this on every pointer move. If each call were its own undo
|
|
* step, undoing a turn would take a hundred presses — so the gesture owns the
|
|
* step, and this does not touch the stack by itself.
|
|
*/
|
|
it('is not an undo step on its own', () => {
|
|
store().setRotation(0, 'one', 45)
|
|
expect(store().canUndo()).toBe(false)
|
|
})
|
|
|
|
it('is one undo step for the whole gesture', () => {
|
|
store().beginGesture()
|
|
for (const deg of [5, 10, 15, 20]) store().setRotation(0, 'one', deg)
|
|
store().endGesture()
|
|
expect(rotationOf('one')).toBe(20)
|
|
store().undo()
|
|
expect(rotationOf('one')).toBe(0)
|
|
})
|
|
|
|
it('ignores an id that is not on the spread', () => {
|
|
store().setRotation(0, 'nope', 45)
|
|
expect(rotationOf('one')).toBe(0)
|
|
})
|
|
})
|
|
|
|
/*
|
|
* The single first and last pages are fixed the way the covers are (#2317):
|
|
* a bound book opens onto one right-hand page and closes on one left-hand
|
|
* page, and an inner spread may only ever go between them.
|
|
*/
|
|
describe('a book with a first and a last page', () => {
|
|
beforeEach(() => {
|
|
useStudioStore.getState().load(book(
|
|
spread('cover', 'cover'),
|
|
spread('first', 'first'),
|
|
spread('a', 'inner'),
|
|
spread('last', 'last'),
|
|
spread('back', 'back'),
|
|
))
|
|
})
|
|
|
|
it('treats them as fixed points, not as inner spreads', () => {
|
|
expect(store().canEditSpread(1)).toBe(false)
|
|
expect(store().canEditSpread(3)).toBe(false)
|
|
expect(store().canEditSpread(2)).toBe(true)
|
|
})
|
|
|
|
it('adds after the first page when asked to add at the cover or the first page', () => {
|
|
store().addSpread(0)
|
|
expect(roles()).toEqual(['cover', 'first', 'inner', 'inner', 'last', 'back'])
|
|
expect(store().activeSpread).toBe(2)
|
|
})
|
|
|
|
it('never lands on or behind the last page', () => {
|
|
store().addSpread(3)
|
|
expect(roles()).toEqual(['cover', 'first', 'inner', 'inner', 'last', 'back'])
|
|
store().addSpread(9)
|
|
expect(roles()).toEqual(['cover', 'first', 'inner', 'inner', 'inner', 'last', 'back'])
|
|
})
|
|
|
|
it('inserts a spread inside them too', () => {
|
|
store().insertSpread(0, spread('x', 'inner'))
|
|
expect(roles()).toEqual(['cover', 'first', 'inner', 'inner', 'last', 'back'])
|
|
})
|
|
|
|
it('will not move an inner spread onto either of them', () => {
|
|
store().moveSpread(2, -1)
|
|
store().moveSpread(2, 1)
|
|
expect(ids()).toEqual(['cover', 'first', 'a', 'last', 'back'])
|
|
})
|
|
|
|
it('will not move, copy or delete them', () => {
|
|
store().moveSpread(1, 1)
|
|
store().moveSpread(3, -1)
|
|
store().duplicateSpread(1)
|
|
store().removeSpread(3)
|
|
expect(ids()).toEqual(['cover', 'first', 'a', 'last', 'back'])
|
|
})
|
|
|
|
it('adds the first inner spread of an empty book between them', () => {
|
|
useStudioStore.getState().load(book(
|
|
spread('cover', 'cover'), spread('first', 'first'), spread('last', 'last'), spread('back', 'back'),
|
|
))
|
|
store().addSpread(-1)
|
|
expect(roles()).toEqual(['cover', 'first', 'inner', 'last', 'back'])
|
|
})
|
|
})
|