Files
TREK/client/src/store/studioStore.ts
T

462 lines
19 KiB
TypeScript

import { create } from 'zustand'
import type { BookAlignTo, BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared'
import {
MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS, applySavedBookLayout as applySavedLayout,
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, fitBookTexts, innerSpreadBounds as innerBounds,
layoutAroundLocks, withThemeLinks,
} from '@trek/shared'
import { pastedElements, type StudioClipboard } from '../components/Studio/studioClipboard'
import { regroupCopies, tidyGroups, withCopies, bookElementId } from '@trek/shared'
import { studioLayerActions, type StudioLayerActions } from './studioLayers'
/**
* 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 extends StudioLayerActions {
doc: BookDocument | null
selection: string[]
activeSpread: number
past: BookDocument[]
future: BookDocument[]
/** The document as it was when the current gesture started. */
gestureBase: BookDocument | null
/** Elements copied for pasting onto any page of the book (#2316). Kept across page switches. */
clipboard: StudioClipboard | null
/**
* Counts the times something was not added because the page already holds
* MAX_SPREAD_ELEMENTS. The Studio says so once per count (StudioNotices), so
* a full page no longer swallows a click without a word.
*/
refusedFull: number
/** Notes that an addition was refused because the page is full. */
refuseFull: () => void
/**
* What the arrange section lines things up with, as last picked: kept for the
* session, so a new selection or another kind of element does not reset it.
* Null until somebody picks; the section then chooses by how much is selected.
*/
alignTo: BookAlignTo | null
setAlignTo: (to: BookAlignTo) => void
/**
* The page a chat card points at, outlined in the page rail (journey-studio.md
* 4.2). Not part of the book or its history; opening a book clears it.
*/
hintSpread: string | null
/**
* Whether the open book is held as it is. True for a book a newer Studio
* saved (useBookStore's `newer`): this build has already dropped what
* it cannot read from its copy, so every edit, undo and redo is refused
* rather than shown as work that can never be saved. Loading and taking a
* remote version still work, so the page stays current while it is shown.
*/
readOnly: boolean
setReadOnly: (readOnly: boolean) => void
load: (doc: BookDocument) => void
/**
* Take a version saved somewhere else (another editor, the assistant) into
* the open book without throwing the user out of it.
*
* `load` is for opening: back to the cover, history gone. Here the page stays
* the one the user was on (found by its spread id, wherever it moved to),
* what is still on that page stays selected, and the document it replaces
* becomes an undo step, so Ctrl+Z takes an assistant's change back.
*
* `undoable: false` is for a save by somebody else: the page and selection
* stay, but the history goes, as with `load`. Otherwise their whole save would
* sit one Ctrl+Z away, and undoing what the user took for their own last step
* would quietly write the older book over another person's work.
*/
adoptRemote: (doc: BookDocument, opts?: { undoable?: boolean }) => void
setActiveSpread: (i: number) => void
setHintSpread: (spreadId: string | null) => 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
/** One element onto a page, selected; false, and nothing changed, when the page holds all it can. */
addElement: (spreadIndex: number, el: BookElement) => boolean
removeElements: (spreadIndex: number, ids: string[]) => void
/** Copy elements of a spread to the clipboard (#2316). */
copy: (spreadIndex: number, ids: string[]) => void
/** Put the clipboard onto a spread, as one undo step, and select what arrived. */
paste: (spreadIndex: number) => void
/** Keep a spread's arrangement as one of the book's layouts (#2316). Returns false when the book is full. */
saveLayout: (spreadIndex: number, name: string) => boolean
removeLayout: (id: string) => void
/** Lay a spread out on a saved layout, keeping its pictures and words. */
applyLayout: (spreadIndex: number, id: string) => 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 (`innerBounds`) and which spreads may move at
// all (`isMovableSpread`) are shared with the server's book tools
// (shared/src/book/book-pages.ts), so the assistant keeps the same pages fixed.
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) => ({
// Groups, layers, align, nudge, duplicate and paste style: studioLayers.ts.
...studioLayerActions(set, get),
doc: null,
selection: [],
activeSpread: 0,
past: [],
future: [],
gestureBase: null,
clipboard: null,
refusedFull: 0,
refuseFull: () => set(st => ({ refusedFull: st.refusedFull + 1 })),
alignTo: null,
setAlignTo: to => set({ alignTo: to }),
hintSpread: null,
readOnly: false,
setReadOnly: readOnly => set({ readOnly, gestureBase: null }),
load: doc => set({ doc, selection: [], enteredGroup: null, activeSpread: 0, past: [], future: [], gestureBase: null, hintSpread: null }),
adoptRemote: (doc, opts) => set(s => {
if (!s.doc) return { doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null }
if (doc === s.doc) return {}
const spreadId = s.doc.spreads[s.activeSpread]?.id
const found = spreadId ? doc.spreads.findIndex(sp => sp.id === spreadId) : -1
// A page that is gone (deleted elsewhere) leaves the user where it was, or on the last page.
const activeSpread = found >= 0 ? found : Math.max(0, Math.min(s.activeSpread, doc.spreads.length - 1))
const onPage = new Set((doc.spreads[activeSpread]?.elements ?? []).map(e => e.id))
// A gesture that has already moved is replaced half done, so the step to go
// back to is where it started, not the half-finished frame.
const replaced = s.gestureBase ?? s.doc
return {
doc,
activeSpread,
selection: found >= 0 ? s.selection.filter(id => onPage.has(id)) : [],
enteredGroup: found >= 0 ? s.enteredGroup : null,
past: opts?.undoable === false ? [] : [...s.past, replaced].slice(-HISTORY_LIMIT),
// A new branch: redoing what was undone before this arrived would write over it.
future: [],
// An open gesture carries on from the new document, so closing it records
// that, not the old one a second time.
gestureBase: s.gestureBase ? doc : null,
}
}),
setActiveSpread: i => set({ activeSpread: i, selection: [], enteredGroup: null }),
setHintSpread: spreadId => set({ hintSpread: spreadId }),
// Nothing selected is outside every group too.
select: ids => set(ids.length ? { selection: ids } : { selection: ids, enteredGroup: null }),
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,
}
}),
// A text set to fit its frame is solved after every change (book-text-fit.ts in shared).
commit: fn => set(s => {
if (!s.doc || s.readOnly) return {}
const next = fitBookTexts(fn(s.doc), 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 && !s.readOnly ? { doc: fitBookTexts(fn(s.doc), s.doc) } : {})),
// A colour or face set by hand stops following the theme; a link set here takes the theme's value.
updateElement: (spreadIndex, id, patch) => get().apply(doc =>
replaceSpread(doc, spreadIndex, sp => ({
...sp,
elements: sp.elements.map(e => (e.id === id ? withThemeLinks({ ...e, ...patch } as BookElement, patch, doc.theme) : 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) => {
// The page's own cap, as paste and duplicate keep it: what would make the book unsaveable is not added.
const target = get().doc?.spreads[spreadIndex]
if (!target) return false
if (target.elements.length >= MAX_SPREAD_ELEMENTS) {
get().refuseFull()
return false
}
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] })
return true
},
removeElements: (spreadIndex, ids) => {
// A group left with one member is no group any more.
get().commit(doc => replaceSpread(doc, spreadIndex, sp => tidyGroups({
...sp,
elements: sp.elements.filter(e => !ids.includes(e.id)),
})))
set({ selection: [], enteredGroup: null })
},
copy: (spreadIndex, ids) => {
const sp = get().doc?.spreads[spreadIndex]
if (!sp) return
const elements = sp.elements.filter(e => ids.includes(e.id))
if (elements.length) set({ clipboard: { fromSpreadId: sp.id, elements } })
},
paste: spreadIndex => {
const { clipboard, doc } = get()
const target = doc?.spreads[spreadIndex]
if (!clipboard || !doc || !target) return
// The spread's own cap: a paste that would push it past what the contract
// accepts takes what fits rather than making the book unsaveable.
const room = Math.max(0, MAX_SPREAD_ELEMENTS - target.elements.length)
const source = doc.spreads.find(sp => sp.id === clipboard.fromSpreadId)
// A copied group arrives as a group of its own, never as part of one already on the page.
if (room === 0) {
get().refuseFull()
return
}
const copies = regroupCopies(pastedElements(clipboard, target, doc.page).slice(0, room), source, bookElementId)
if (!copies.elements.length) return
get().commit(d => replaceSpread(d, spreadIndex, sp => withCopies(sp, copies)))
set({ selection: copies.elements.map(e => e.id), enteredGroup: null })
},
saveLayout: (spreadIndex, name) => {
const doc = get().doc
const sp = doc?.spreads[spreadIndex]
if (!doc || !sp || !sp.elements.length) return false
if ((doc.layouts ?? []).length >= MAX_BOOK_LAYOUTS) return false
const layout: BookLayout = layoutFromSpread(sp, doc.page, name)
get().commit(d => ({ ...d, layouts: [...(d.layouts ?? []), layout] }))
return true
},
removeLayout: id => get().commit(d => ({ ...d, layouts: (d.layouts ?? []).filter(l => l.id !== id) })),
applyLayout: (spreadIndex, id) => {
const layout = get().doc?.layouts?.find(l => l.id === id)
if (!layout) return
// 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: [] })
},
/*
* ── 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 => isMovableSpread(get().doc?.spreads[index]),
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 || s.readOnly) return {}
return { doc: prev, past: s.past.slice(0, -1), future: [s.doc, ...s.future].slice(0, HISTORY_LIMIT), selection: [], enteredGroup: null }
}),
redo: () => set(s => {
const next = s.future[0]
if (!next || !s.doc || s.readOnly) return {}
return { doc: next, future: s.future.slice(1), past: [...s.past, s.doc].slice(-HISTORY_LIMIT), selection: [], enteredGroup: null }
}),
canUndo: () => !get().readOnly && get().past.length > 0,
canRedo: () => !get().readOnly && get().future.length > 0,
}))