mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
8fd46c4c18
Small requests from the discussions, each built end to end: planner and map (cycling mode, date first headings, clear a day, stay times per day, off-route stops, map lock, compact unplanned markers, pending stays, cable car, transit quick picks), places (location filter, category from OSM, open in maps, geo: link, image from an attachment, enrichment on file import, places near a pin, place name language, Amap first in China), lists and costs (packed counter, inherited sharing, Markdown to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned places, travel only, without photos), journey (photo zoom, drafts, photo order, trip suggestion, day jump), atlas (region counter, region bucket list, places per country), vacay half company holidays, studio copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for a first start restore.
415 lines
16 KiB
TypeScript
415 lines
16 KiB
TypeScript
import { create } from 'zustand'
|
|
import type { BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared'
|
|
import { MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS } from '@trek/shared'
|
|
import { pastedElements, type StudioClipboard } from '../components/Studio/studioClipboard'
|
|
import { applySavedLayout, layoutFromSpread } from '../components/Studio/savedLayouts'
|
|
|
|
/**
|
|
* 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
|
|
/** Elements copied for pasting onto any page of the book (#2316). Kept across page switches. */
|
|
clipboard: StudioClipboard | 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
|
|
/** 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: 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,
|
|
clipboard: 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 }
|
|
})),
|
|
|
|
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 copies = pastedElements(clipboard, target, doc.page).slice(0, room)
|
|
if (!copies.length) return
|
|
get().commit(d => replaceSpread(d, spreadIndex, sp => ({ ...sp, elements: [...sp.elements, ...copies] })))
|
|
set({ selection: copies.map(e => e.id) })
|
|
},
|
|
|
|
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
|
|
get().commit(d => replaceSpread(d, spreadIndex, sp => applySavedLayout(sp, layout, d.page)))
|
|
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 => {
|
|
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,
|
|
}))
|