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.
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
import { create } from 'zustand'
|
|
import type { BookDocument, BookElement, BookFrame, BookSpread } from '@trek/shared'
|
|
|
|
/**
|
|
* The book being edited.
|
|
*
|
|
* History is snapshot-based rather than patch-based, and deliberately so: a
|
|
* document is a few hundred kilobytes of plain JSON at most (the schema caps it
|
|
* at 150 spreads of 60 elements), and a snapshot stack that cannot possibly
|
|
* disagree with the document beats a patch stack that can. It is capped so a
|
|
* long session does not grow without limit.
|
|
*
|
|
* Granularity is the part that matters for how it *feels*: a drag is one undo
|
|
* step, not four hundred. Callers open a gesture before the first move and close
|
|
* it on release; only that pair touches the stack.
|
|
*/
|
|
|
|
const HISTORY_LIMIT = 60
|
|
|
|
interface StudioState {
|
|
doc: BookDocument | null
|
|
selection: string[]
|
|
activeSpread: number
|
|
past: BookDocument[]
|
|
future: BookDocument[]
|
|
/** The document as it was when the current gesture started. */
|
|
gestureBase: BookDocument | null
|
|
|
|
load: (doc: BookDocument) => void
|
|
setActiveSpread: (i: number) => void
|
|
select: (ids: string[]) => void
|
|
toggleSelect: (id: string, additive: boolean) => void
|
|
|
|
beginGesture: () => void
|
|
endGesture: () => void
|
|
/** A single change that is its own undo step. */
|
|
commit: (fn: (doc: BookDocument) => BookDocument) => void
|
|
/** A change inside an open gesture — does not touch the stack by itself. */
|
|
apply: (fn: (doc: BookDocument) => BookDocument) => void
|
|
|
|
updateElement: (spreadIndex: number, id: string, patch: Partial<BookElement>) => void
|
|
setFrame: (spreadIndex: number, id: string, frame: BookFrame) => void
|
|
/**
|
|
* Turn one element, in degrees.
|
|
*
|
|
* `apply`, not `commit`, for the same reason setFrame is: a drag is one undo
|
|
* step, and the gesture that drives this calls it on every pointer move.
|
|
*/
|
|
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
|
|
|
|
/** Insert an empty spread after `index`, and select it. */
|
|
addSpread: (index: number) => void
|
|
/**
|
|
* Put a spread that already has contents into the book after `index`.
|
|
*
|
|
* Separate from addSpread because the thing being inserted comes from
|
|
* outside the document — an imported file — and arrives complete. Fresh ids
|
|
* are minted here rather than trusted from the file: two elements sharing an
|
|
* id would confuse selection and every lookup after it.
|
|
*/
|
|
insertSpread: (index: number, spread: BookSpread) => void
|
|
/** Copy a spread, contents and all, directly after it. */
|
|
duplicateSpread: (index: number) => void
|
|
removeSpread: (index: number) => void
|
|
/** Move a spread one place towards the front or the back of the book. */
|
|
moveSpread: (index: number, dir: -1 | 1) => void
|
|
/** Whether a spread may be moved, deleted or duplicated at all. */
|
|
canEditSpread: (index: number) => boolean
|
|
|
|
undo: () => void
|
|
redo: () => void
|
|
canUndo: () => boolean
|
|
canRedo: () => boolean
|
|
}
|
|
|
|
/**
|
|
* Where an inner spread may go: after the cover and the first page, before
|
|
* the last page and the back cover.
|
|
*
|
|
* Read from the document each time rather than assumed, because a book from
|
|
* before the single first page existed has none, and a book with no covers at
|
|
* all (the tests build those) has nothing to stay inside of. The lower bound
|
|
* is not decoration either: a book with no inner spreads yet reports its last
|
|
* inner one as -1, which asked for a page at 0 — in front of the cover, where
|
|
* it could not be moved back from, because a move only ever swaps with
|
|
* another inner spread.
|
|
*/
|
|
function innerBounds(spreads: readonly BookSpread[]): { first: number; limit: number } {
|
|
let first = 0
|
|
for (let i = 0; i < spreads.length; i++) {
|
|
const role = spreads[i].role
|
|
if (role === 'cover' || role === 'first') first = i + 1
|
|
else break
|
|
}
|
|
let limit = spreads.length
|
|
for (let i = spreads.length - 1; i >= 0; i--) {
|
|
const role = spreads[i].role
|
|
if (role === 'back' || role === 'last') limit = i
|
|
else break
|
|
}
|
|
return { first, limit: Math.max(first, limit) }
|
|
}
|
|
|
|
function replaceSpread(doc: BookDocument, index: number, fn: (s: BookSpread) => BookSpread): BookDocument {
|
|
const spreads = doc.spreads.slice()
|
|
if (!spreads[index]) return doc
|
|
spreads[index] = fn(spreads[index])
|
|
return { ...doc, spreads }
|
|
}
|
|
|
|
export const useStudioStore = create<StudioState>((set, get) => ({
|
|
doc: null,
|
|
selection: [],
|
|
activeSpread: 0,
|
|
past: [],
|
|
future: [],
|
|
gestureBase: null,
|
|
|
|
load: doc => set({ doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null }),
|
|
setActiveSpread: i => set({ activeSpread: i, selection: [] }),
|
|
select: ids => set({ selection: ids }),
|
|
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] }
|
|
}),
|
|
|
|
beginGesture: () => set(s => ({ gestureBase: s.doc })),
|
|
endGesture: () => set(s => {
|
|
// Nothing actually changed — a click that missed, a drag of zero pixels.
|
|
if (!s.gestureBase || s.gestureBase === s.doc) return { gestureBase: null }
|
|
return {
|
|
past: [...s.past, s.gestureBase].slice(-HISTORY_LIMIT),
|
|
future: [],
|
|
gestureBase: null,
|
|
}
|
|
}),
|
|
|
|
commit: fn => set(s => {
|
|
if (!s.doc) return {}
|
|
const next = fn(s.doc)
|
|
if (next === s.doc) return {}
|
|
return { doc: next, past: [...s.past, s.doc].slice(-HISTORY_LIMIT), future: [] }
|
|
}),
|
|
|
|
apply: fn => set(s => (s.doc ? { doc: fn(s.doc) } : {})),
|
|
|
|
updateElement: (spreadIndex, id, patch) => get().apply(doc =>
|
|
replaceSpread(doc, spreadIndex, sp => ({
|
|
...sp,
|
|
elements: sp.elements.map(e => (e.id === id ? ({ ...e, ...patch } as BookElement) : e)),
|
|
}))),
|
|
|
|
setFrame: (spreadIndex, id, frame) => get().apply(doc =>
|
|
replaceSpread(doc, spreadIndex, sp => ({
|
|
...sp,
|
|
elements: sp.elements.map(e => (e.id === id ? { ...e, frame } : e)),
|
|
}))),
|
|
|
|
setRotation: (spreadIndex, id, rotation) => get().apply(doc =>
|
|
replaceSpread(doc, spreadIndex, sp => ({
|
|
...sp,
|
|
elements: sp.elements.map(e => (e.id === id ? { ...e, rotation } : e)),
|
|
}))),
|
|
|
|
addElement: (spreadIndex, el) => {
|
|
get().commit(doc =>
|
|
replaceSpread(doc, spreadIndex, sp => ({ ...sp, elements: [...sp.elements, el] })))
|
|
/*
|
|
* Selected on arrival.
|
|
*
|
|
* Everything you would want to do next is in the inspector, and the
|
|
* inspector shows the selection: placing a day counter and then hunting for
|
|
* it on the page to click it before its settings appear is a step nobody
|
|
* asked for. Duplicating already worked this way; placing did not.
|
|
*/
|
|
set({ selection: [el.id] })
|
|
},
|
|
|
|
removeElements: (spreadIndex, ids) => {
|
|
get().commit(doc => replaceSpread(doc, spreadIndex, sp => ({
|
|
...sp,
|
|
elements: sp.elements.filter(e => !ids.includes(e.id)),
|
|
})))
|
|
set({ selection: [] })
|
|
},
|
|
|
|
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 }
|
|
})),
|
|
|
|
/*
|
|
* ── Spread management ────────────────────────────────────────────────
|
|
*
|
|
* The cover and the back cover are fixed points: a book has exactly one of
|
|
* each, they are single pages rather than spreads, and the layouts panel
|
|
* already refuses to touch them. The single first and last pages inside
|
|
* them are fixed the same way (#2317): a bound book opens onto one
|
|
* right-hand page and closes on one left-hand page, and moving either into
|
|
* the middle of the book would put a half-width page between two spreads.
|
|
* So everything here operates on the inner spreads between them, and
|
|
* `canEditSpread` is the one place that decides what counts as inner — the
|
|
* rail, the menu and the store all ask it rather than each testing `role`
|
|
* for themselves.
|
|
*/
|
|
canEditSpread: index => {
|
|
const sp = get().doc?.spreads[index]
|
|
return !!sp && sp.role === 'inner'
|
|
},
|
|
|
|
addSpread: index => {
|
|
const doc = get().doc
|
|
if (!doc) return
|
|
/*
|
|
* After the given spread, and inside the covers at both ends.
|
|
*
|
|
* The lower bound is not decoration: a book with no inner spreads yet
|
|
* reports its last inner one as -1, which asked for a page at 0 — in front
|
|
* of the cover, where it could not be moved back from either, because a
|
|
* move only ever swaps with another inner spread. The first page of an
|
|
* empty book is the one this catches.
|
|
*/
|
|
const { first, limit } = innerBounds(doc.spreads)
|
|
const at = Math.min(Math.max(index + 1, first), limit)
|
|
get().commit(d => ({
|
|
...d,
|
|
spreads: [
|
|
...d.spreads.slice(0, at),
|
|
{
|
|
id: `sp-${Math.random().toString(36).slice(2, 9)}`,
|
|
role: 'inner' as const,
|
|
background: null,
|
|
elements: [],
|
|
parked: [],
|
|
entryId: null,
|
|
},
|
|
...d.spreads.slice(at),
|
|
],
|
|
}))
|
|
set({ activeSpread: at, selection: [] })
|
|
},
|
|
|
|
insertSpread: (index, spread) => {
|
|
const doc = get().doc
|
|
if (!doc) return
|
|
// Same bounds as addSpread: inside the covers at both ends.
|
|
const { first, limit } = innerBounds(doc.spreads)
|
|
const at = Math.min(Math.max(index + 1, first), limit)
|
|
get().commit(d => ({
|
|
...d,
|
|
spreads: [
|
|
...d.spreads.slice(0, at),
|
|
{
|
|
...spread,
|
|
id: `sp-${Math.random().toString(36).slice(2, 9)}`,
|
|
role: 'inner' as const,
|
|
elements: spread.elements.map(e => ({ ...e, id: `${e.kind[0]}-${Math.random().toString(36).slice(2, 9)}` })),
|
|
},
|
|
...d.spreads.slice(at),
|
|
],
|
|
}))
|
|
set({ activeSpread: at, selection: [] })
|
|
},
|
|
|
|
duplicateSpread: index => {
|
|
const doc = get().doc
|
|
if (!doc || !get().canEditSpread(index)) return
|
|
const source = doc.spreads[index]
|
|
const at = index + 1
|
|
get().commit(d => ({
|
|
...d,
|
|
spreads: [
|
|
...d.spreads.slice(0, at),
|
|
{
|
|
...source,
|
|
id: `sp-${Math.random().toString(36).slice(2, 9)}`,
|
|
// Fresh ids throughout: two elements sharing one id would confuse
|
|
// selection and every lookup that follows it.
|
|
elements: source.elements.map(e => ({ ...e, id: `${e.kind[0]}-${Math.random().toString(36).slice(2, 9)}` })),
|
|
parked: source.parked.map(e => ({ ...e, id: `${e.kind[0]}-${Math.random().toString(36).slice(2, 9)}` })),
|
|
},
|
|
...d.spreads.slice(at),
|
|
],
|
|
}))
|
|
set({ activeSpread: at, selection: [] })
|
|
},
|
|
|
|
removeSpread: index => {
|
|
const doc = get().doc
|
|
if (!doc || !get().canEditSpread(index)) return
|
|
get().commit(d => ({ ...d, spreads: d.spreads.filter((_, i) => i !== index) }))
|
|
// Land on the spread that took its place, or the one before it if the
|
|
// deleted spread was the last inner one.
|
|
const next = get().doc?.spreads ?? []
|
|
set({ activeSpread: Math.min(index, Math.max(0, next.length - 1)), selection: [] })
|
|
},
|
|
|
|
moveSpread: (index, dir) => {
|
|
const doc = get().doc
|
|
if (!doc || !get().canEditSpread(index)) return
|
|
const target = index + dir
|
|
// Only ever swaps with another inner spread, so the cover and back cover
|
|
// keep their places without needing to be special-cased anywhere else.
|
|
if (!get().canEditSpread(target)) return
|
|
get().commit(d => {
|
|
const spreads = d.spreads.slice()
|
|
const [moved] = spreads.splice(index, 1)
|
|
spreads.splice(target, 0, moved)
|
|
return { ...d, spreads }
|
|
})
|
|
set({ activeSpread: target, selection: [] })
|
|
},
|
|
|
|
undo: () => set(s => {
|
|
const prev = s.past[s.past.length - 1]
|
|
if (!prev || !s.doc) return {}
|
|
return { doc: prev, past: s.past.slice(0, -1), future: [s.doc, ...s.future].slice(0, HISTORY_LIMIT), selection: [] }
|
|
}),
|
|
|
|
redo: () => set(s => {
|
|
const next = s.future[0]
|
|
if (!next || !s.doc) return {}
|
|
return { doc: next, future: s.future.slice(1), past: [...s.past, s.doc].slice(-HISTORY_LIMIT), selection: [] }
|
|
}),
|
|
|
|
canUndo: () => get().past.length > 0,
|
|
canRedo: () => get().future.length > 0,
|
|
}))
|