@marver-design/marver 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +164 -0
- package/README.md +7 -4
- package/dist/boards-6hKVW42a.mjs +57 -0
- package/dist/boards-BdG1TJwU.mjs +267 -0
- package/dist/{build-ByYafIhj.mjs → build-Ct0iMoSi.mjs} +77 -30
- package/dist/cli.mjs +15 -6
- package/dist/{daemon-Bfucyf1o.mjs → daemon-Cb4JjpgL.mjs} +1 -1
- package/dist/{dev-yZMyQeUj.mjs → dev-CdcIuhZJ.mjs} +14 -4
- package/dist/{init-Dvaso7YO.mjs → init-BQIpIpIv.mjs} +6 -3
- package/dist/{manifest-DvOmglFp.mjs → manifest-CpbsqQ_v.mjs} +94 -17
- package/dist/{plugin-BsmG5i2X.mjs → plugin-BXyezwfN.mjs} +201 -74
- package/dist/poster-DOY7pax8.mjs +143 -0
- package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
- package/dist/{shot-kbR_xzJH.mjs → shot-CwmHO5T4.mjs} +197 -56
- package/docs/live-jam.md +6 -2
- package/docs/publish.md +4 -1
- package/docs/slides.md +9 -3
- package/package.json +1 -1
- package/src/client/content/chart.tsx +62 -24
- package/src/client/content/index.tsx +5 -2
- package/src/client/content/video.tsx +132 -35
- package/src/client/frame-host/bridge.js +6 -1
- package/src/client/shell/App.tsx +33 -247
- package/src/client/shell/BoardList.tsx +408 -0
- package/src/client/shell/ContextMenu.tsx +59 -0
- package/src/client/shell/icons.tsx +7 -0
- package/src/client/shell/store.ts +156 -48
- package/src/client/shell/styles.css +42 -4
- package/src/shared/board-tree.ts +285 -0
- package/templates/AGENTS-embedded.md +35 -7
- package/templates/AGENTS-studio.md +35 -7
- package/templates/instructions/boards.md +120 -7
- package/templates/instructions/craft.md +21 -0
- package/templates/instructions/discover.md +7 -2
- package/templates/instructions/iterate.md +114 -18
- package/templates/instructions/jam.md +18 -2
- package/templates/instructions/review.md +4 -0
- package/templates/instructions/shape.md +16 -2
- package/templates/instructions/slides.md +5 -1
- package/templates/instructions/welcome.md +4 -1
- package/templates/instructions/wireframe.md +3 -0
- package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
|
@@ -12,6 +12,8 @@ import shData from 'virtual:sh-data'
|
|
|
12
12
|
* is where `/` opens - never a synthesized aggregate of a filtered build. */
|
|
13
13
|
const DATA: {
|
|
14
14
|
manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string
|
|
15
|
+
/** the sidebar's folder tree over the published boards (0.16.0 bundles; older ones have none) */
|
|
16
|
+
tree?: TreeItem[]
|
|
15
17
|
/** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
|
|
16
18
|
policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
|
|
17
19
|
} | null = shData
|
|
@@ -123,19 +125,42 @@ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides')
|
|
|
123
125
|
export { cap, humanize } from './labels.ts'
|
|
124
126
|
import { cap, humanize } from './labels.ts'
|
|
125
127
|
import { canAutoReload } from './canvas/ready-watch.ts'
|
|
128
|
+
import { buildTree, flatten, toWire, type TreeItem } from '../../shared/board-tree.ts'
|
|
126
129
|
|
|
127
|
-
/**
|
|
128
|
-
*
|
|
130
|
+
/** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of
|
|
131
|
+
* the folder registry (null = there was no file). */
|
|
132
|
+
export interface TreeBase { boards: Record<string, string>; folders: string | null }
|
|
133
|
+
export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase }
|
|
134
|
+
|
|
135
|
+
/** The sidebar tree: root boards and folders in rank order, each folder's boards inside
|
|
136
|
+
* (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
|
|
137
|
+
* is pinned last by the callers. Throws on transport failure and on a malformed registry
|
|
138
|
+
* (the server's 422 message) - callers keep their last known tree. */
|
|
139
|
+
export async function fetchBoardTree(): Promise<TreeSnapshot> {
|
|
140
|
+
if (DATA) return { tree: DATA.tree ?? DATA.names.filter((n) => n !== 'all-scenes').map((n) => ({ kind: 'board', name: n })), base: { boards: {}, folders: null } }
|
|
141
|
+
const [boards, reg] = await Promise.all([
|
|
142
|
+
fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string }[]>,
|
|
143
|
+
fetch(`${ROUTE}/api/folders`).then(async (r) => {
|
|
144
|
+
const j = await r.json() as { folders?: { name: string; order?: number }[]; sha256?: string | null; error?: string }
|
|
145
|
+
if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`)
|
|
146
|
+
return j
|
|
147
|
+
}),
|
|
148
|
+
])
|
|
149
|
+
return {
|
|
150
|
+
tree: buildTree(boards, reg.folders ?? []),
|
|
151
|
+
base: { boards: Object.fromEntries(boards.map((b) => [b.name, b.sha256])), folders: reg.sha256 ?? null },
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Board names for switchers: the sidebar's reading order (folders flattened depth-first) and
|
|
156
|
+
* the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
|
|
129
157
|
* Throws on transport failure - callers keep their last known list. */
|
|
130
158
|
export async function fetchBoardNames(): Promise<string[]> {
|
|
131
159
|
if (DATA) return DATA.names
|
|
132
|
-
|
|
133
|
-
const curated = list
|
|
134
|
-
.filter((b) => b.name !== 'all-scenes')
|
|
135
|
-
.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || a.name.localeCompare(b.name))
|
|
136
|
-
.map((b) => b.name)
|
|
137
|
-
return [...curated, 'all-scenes']
|
|
160
|
+
return [...flatten((await fetchBoardTree()).tree), 'all-scenes']
|
|
138
161
|
}
|
|
162
|
+
/** Does the switcher carry the auto `all-scenes` board? Always in dev; published only when it shipped. */
|
|
163
|
+
export const HAS_ALL_SCENES = !DATA || DATA.names.includes('all-scenes')
|
|
139
164
|
/** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
|
|
140
165
|
export const boardLabel = (n: string) => humanize(n)
|
|
141
166
|
|
|
@@ -251,6 +276,8 @@ interface State {
|
|
|
251
276
|
playUpdateRevision: string | null // a revision arrived while play is open
|
|
252
277
|
playNav: number // bumps to reload the play stage on demand
|
|
253
278
|
pathPulse: number // bumps on each successful path copy - flashes the toolbar icon into a check
|
|
279
|
+
imagePulse: number // bumps on each successful image copy - same flash, the images-square icon
|
|
280
|
+
imageBusy: boolean // a copy-as-image render is in flight (one at a time)
|
|
254
281
|
|
|
255
282
|
boot(): Promise<boolean>
|
|
256
283
|
applyManifest(m: Manifest): void
|
|
@@ -278,8 +305,9 @@ interface State {
|
|
|
278
305
|
resizeSelected(name: string | null): void
|
|
279
306
|
switchBoard(name: string): Promise<void>
|
|
280
307
|
renameBoard(from: string, to: string): Promise<{ ok: boolean; error?: string }>
|
|
281
|
-
|
|
308
|
+
arrangeBoards(tree: TreeItem[], base: TreeBase): Promise<{ ok: true } | { ok: false; stale: boolean; error?: string }>
|
|
282
309
|
pulsePath(): void
|
|
310
|
+
copyFrameImage(scale: 2 | 4): void
|
|
283
311
|
setScale(s: number): void
|
|
284
312
|
togglePanel(): void
|
|
285
313
|
setTheme(theme: string): void
|
|
@@ -314,8 +342,18 @@ export const useStore = create<State>((set, get) => {
|
|
|
314
342
|
let editRev = 0 // bumps per edit; a stale save response may never clear dirty over a newer edit
|
|
315
343
|
let loadSeq = 0 // stale boot() responses never overwrite a newer board
|
|
316
344
|
let switchSeq = 0 // last click wins when board switches race
|
|
317
|
-
|
|
318
|
-
|
|
345
|
+
// While a board file is being renamed or rewritten by a structural write (rename, the
|
|
346
|
+
// sidebar's tree arrange), autosave must not fire against it (a mustExist PUT would
|
|
347
|
+
// 409-gone and clear dirty, losing the in-flight edit; a stale baseHash would 409 and
|
|
348
|
+
// reload). A COUNT, not a flag: two holders never release each other. Structural writes
|
|
349
|
+
// also run one at a time through `structChain`, so a rename can never interleave a
|
|
350
|
+
// tree write on the same file.
|
|
351
|
+
let saveHolds = 0
|
|
352
|
+
const holdSaves = () => { saveHolds++; clearTimeout(saveTimer) }
|
|
353
|
+
const releaseSaves = () => { if (--saveHolds === 0 && get().dirty) scheduleSave() }
|
|
354
|
+
let structChain: Promise<unknown> = Promise.resolve()
|
|
355
|
+
const structural = <T,>(fn: () => Promise<T>): Promise<T> => { const p = structChain.then(fn, fn); structChain = p.catch(() => {}); return p }
|
|
356
|
+
const scheduleSave = () => { editRev++; if (saveHolds > 0) return; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
|
|
319
357
|
|
|
320
358
|
// One cancelable, BOARD-SCOPED reflow after content measurements settle.
|
|
321
359
|
// The captured board name is the generation guard - a debounce surviving a board
|
|
@@ -564,7 +602,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
564
602
|
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
|
|
565
603
|
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
|
|
566
604
|
panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false,
|
|
567
|
-
pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0,
|
|
605
|
+
pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0, imagePulse: 0, imageBusy: false,
|
|
568
606
|
|
|
569
607
|
async boot() {
|
|
570
608
|
const seq = ++loadSeq
|
|
@@ -613,43 +651,66 @@ export const useStore = create<State>((set, get) => {
|
|
|
613
651
|
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
614
652
|
},
|
|
615
653
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
654
|
+
renameBoard(from, to) {
|
|
655
|
+
return structural(async () => {
|
|
656
|
+
const active = from === get().board
|
|
657
|
+
// Renaming the ACTIVE board renames its file out from under the autosave. Flush any
|
|
658
|
+
// pending write FIRST (switchBoard's pattern), so a mustExist PUT never races the move.
|
|
659
|
+
if (active) {
|
|
660
|
+
let ok = true
|
|
661
|
+
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
662
|
+
if (get().dirty) return { ok: false, error: 'unsaved changes - try again' }
|
|
663
|
+
// hold autosave across the round-trip: an edit landing mid-rename must NOT save to the
|
|
664
|
+
// old name (a mustExist PUT would 409-gone and clear dirty, losing the edit)
|
|
665
|
+
holdSaves()
|
|
666
|
+
}
|
|
667
|
+
// Release the hold and resume autosave. Re-read the LIVE board, not the captured `active`:
|
|
668
|
+
// a board switch may have completed while we awaited, so only the board that is STILL
|
|
669
|
+
// `from` gets renamed to `to` in state (a switched-away board keeps its own name/nodes).
|
|
670
|
+
try {
|
|
671
|
+
let res: Response
|
|
672
|
+
try { res = await postOwner('boards/rename', { from, to }) }
|
|
673
|
+
catch { return { ok: false, error: 'could not reach the dev server' } }
|
|
674
|
+
if (!res.ok) {
|
|
675
|
+
const e = await res.json().catch(() => ({} as { error?: string }))
|
|
676
|
+
return { ok: false, error: e?.error ?? `rename failed (${res.status})` }
|
|
677
|
+
}
|
|
678
|
+
// Content is byte-identical after a file move, so boardHash still matches for the next
|
|
679
|
+
// autosave; only the name (and the URL, via the board-change subscription) changes. Set
|
|
680
|
+
// the new name BEFORE releasing the hold so a resumed save targets `to`.
|
|
681
|
+
if (active && get().board === from) set({ board: to })
|
|
682
|
+
return { ok: true }
|
|
683
|
+
} finally { if (active) releaseSaves() } // whatever happened, autosave resumes
|
|
684
|
+
})
|
|
647
685
|
},
|
|
648
686
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
687
|
+
// The whole sidebar tree in one write: order, membership, the folders themselves. The
|
|
688
|
+
// write rewrites the ACTIVE board's file too (its `order`/`folder`), so it runs like a
|
|
689
|
+
// rename: flush the pending autosave first, hold autosave across the round-trip, then
|
|
690
|
+
// advance boardHash to the hash the server answered - the autosave's CAS token stays
|
|
691
|
+
// true and the watcher's echo is filtered as our own. `base` carries the hashes the
|
|
692
|
+
// sidebar last saw; a 409 (`stale`) means someone else wrote first - refetch and replay.
|
|
693
|
+
arrangeBoards(tree, base) {
|
|
694
|
+
return structural(async () => {
|
|
695
|
+
let ok = true
|
|
696
|
+
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
697
|
+
if (get().dirty) return { ok: false as const, stale: false, error: 'unsaved changes - try again' }
|
|
698
|
+
holdSaves()
|
|
699
|
+
try {
|
|
700
|
+
// the active board's hash is freshest in the store (an autosave may have landed since
|
|
701
|
+
// the sidebar looked); every other board's is the sidebar's
|
|
702
|
+
const active = get().board
|
|
703
|
+
const boards = { ...base.boards, ...(get().boardHash && base.boards[active] !== undefined ? { [active]: get().boardHash } : {}) }
|
|
704
|
+
let res: Response
|
|
705
|
+
try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders } }) }
|
|
706
|
+
catch { return { ok: false as const, stale: false, error: 'could not reach the dev server' } }
|
|
707
|
+
const body = await res.json().catch(() => ({} as { error?: string; sha256?: { boards?: Record<string, string> } }))
|
|
708
|
+
if (!res.ok) return { ok: false as const, stale: res.status === 409, error: body?.error }
|
|
709
|
+
const sha = body?.sha256?.boards?.[get().board]
|
|
710
|
+
if (sha) set({ boardHash: sha })
|
|
711
|
+
return { ok: true as const }
|
|
712
|
+
} finally { releaseSaves() } // whatever happened, autosave resumes
|
|
713
|
+
})
|
|
653
714
|
},
|
|
654
715
|
|
|
655
716
|
applyManifest(m) {
|
|
@@ -1017,6 +1078,53 @@ export const useStore = create<State>((set, get) => {
|
|
|
1017
1078
|
},
|
|
1018
1079
|
setScale(scale) { set({ scale }) },
|
|
1019
1080
|
pulsePath() { set((s) => ({ pathPulse: s.pathPulse + 1 })) },
|
|
1081
|
+
// Copy the ONE selected frame to the clipboard as a PNG - the dev server's headless
|
|
1082
|
+
// renderer (/api/shot, the same picture `marver shot` gives an agent) sized to what the
|
|
1083
|
+
// node shows, at 2x (or 4x). The ClipboardItem takes a PROMISE: clipboard.write runs
|
|
1084
|
+
// inside the click/keydown gesture and the browser waits for the bytes - a plain
|
|
1085
|
+
// await-then-write would have lost the transient activation during the 1-4s render.
|
|
1086
|
+
copyFrameImage(scale) {
|
|
1087
|
+
const s = get()
|
|
1088
|
+
if (PUBLISHED || s.imageBusy || s.selection.length !== 1) return
|
|
1089
|
+
const node = s.nodes.find((n) => n.key === s.selection[0])
|
|
1090
|
+
if (!node || node.missing) return
|
|
1091
|
+
const frame = s.frameFor(node)
|
|
1092
|
+
if (!frame) { s.toast('frame is still indexing - try again in a second'); return }
|
|
1093
|
+
const CI = (globalThis as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
|
|
1094
|
+
if (!CI || !navigator.clipboard?.write || (CI.supports && !CI.supports('image/png'))) { s.toast("this browser can't put images on the clipboard - use Chrome, Edge or Safari"); return }
|
|
1095
|
+
set({ imageBusy: true })
|
|
1096
|
+
const done = () => set({ imageBusy: false })
|
|
1097
|
+
const qs = new URLSearchParams({ frame: frame.id, theme: node.theme, scale: String(scale), w: String(Math.round(node.w)), h: String(Math.round(node.h)), format: 'png' })
|
|
1098
|
+
// the render is serialized server-side behind any CLI/jam shots; a minute is the ceiling
|
|
1099
|
+
// before the UI gives up on this copy (the server's own watchdog is 45s per shot)
|
|
1100
|
+
const ctl = new AbortController()
|
|
1101
|
+
const timer = setTimeout(() => ctl.abort(), 60_000)
|
|
1102
|
+
let meta: { scale?: number; note?: string } = {}
|
|
1103
|
+
const png = fetch(`${ROUTE}/api/shot?${qs}`, { headers: { 'x-mv-c': csrf() }, signal: ctl.signal }).then(async (r) => {
|
|
1104
|
+
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error ?? `shot failed (${r.status})`)
|
|
1105
|
+
try { meta = JSON.parse(atob((r.headers.get('x-mv-shot') ?? '').replace(/-/g, '+').replace(/_/g, '/'))) } catch { /* summary is advisory */ }
|
|
1106
|
+
return r.blob()
|
|
1107
|
+
}).finally(() => clearTimeout(timer))
|
|
1108
|
+
let renderErr = ''
|
|
1109
|
+
png.catch((e: Error) => { renderErr = e.name === 'AbortError' ? 'timed out - the renderer is busy' : e.message })
|
|
1110
|
+
const fail = (err: unknown) => {
|
|
1111
|
+
done()
|
|
1112
|
+
// a prompt clipboard refusal (no gesture, focus lost) must not wait a minute on the
|
|
1113
|
+
// render: abandon the fetch, toast now. A render failure surfaces its own cause.
|
|
1114
|
+
if (!renderErr) ctl.abort()
|
|
1115
|
+
s.toast(renderErr ? `render failed - ${renderErr}` : (err as Error)?.name === 'NotAllowedError' ? 'copy blocked - click the canvas first' : `copy failed - ${(err as Error)?.message ?? err}`)
|
|
1116
|
+
}
|
|
1117
|
+
try {
|
|
1118
|
+
navigator.clipboard.write([new CI({ 'image/png': png })]).then(
|
|
1119
|
+
() => {
|
|
1120
|
+
done()
|
|
1121
|
+
const used = meta.scale ?? scale
|
|
1122
|
+
s.toast(used < scale ? `image copied at ${used}x - frame too tall for ${scale}x` : scale === 4 ? 'image copied (4x)' : 'image copied')
|
|
1123
|
+
set((st) => ({ imagePulse: st.imagePulse + 1 }))
|
|
1124
|
+
},
|
|
1125
|
+
fail)
|
|
1126
|
+
} catch (err) { fail(err) } // a synchronous constructor/type error must not wedge busy
|
|
1127
|
+
},
|
|
1020
1128
|
togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
|
|
1021
1129
|
// global theme = the VIEW preference: persists across boards + reloads, clears
|
|
1022
1130
|
// per-frame pins. Frames declaring meta.theme keep their mode (they only work there).
|
|
@@ -1091,7 +1199,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
1091
1199
|
save() {
|
|
1092
1200
|
// a rename is moving this board's file - defer every save so nothing writes to the OLD
|
|
1093
1201
|
// name mid-move (renameBoard reschedules once the move commits under the new name)
|
|
1094
|
-
if (
|
|
1202
|
+
if (saveHolds > 0) return Promise.resolve(false)
|
|
1095
1203
|
// a resize gesture in flight = torn state (new sizes, pre-recipe positions):
|
|
1096
1204
|
// even a PREVIOUSLY scheduled timer must defer to the gesture-end save
|
|
1097
1205
|
if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) }
|
|
@@ -413,6 +413,12 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
413
413
|
justify-content: center; padding: 0 6px; margin: 0 2px 0 3px }
|
|
414
414
|
.sh-ctx button.on { background: var(--glass-accent-bg); color: var(--glass-accent) }
|
|
415
415
|
.sh-ctx button:hover:not(.on) { background: var(--glass-hover); color: var(--glass-ink) }
|
|
416
|
+
/* multi-select / no-op states read as quiet, not broken */
|
|
417
|
+
.sh-ctx button:disabled { cursor: default; opacity: .45 }
|
|
418
|
+
.sh-ctx button:disabled:hover { background: none; color: var(--glass-ink-2) }
|
|
419
|
+
/* copy-as-image while the headless render runs (1-4s): a slow breathe on the icon */
|
|
420
|
+
.sh-ctx button.busy { opacity: 1; color: var(--glass-accent); animation: sh-ctx-breathe 1.1s ease-in-out infinite }
|
|
421
|
+
@keyframes sh-ctx-breathe { 0%, 100% { opacity: .35 } 50% { opacity: 1 } }
|
|
416
422
|
.sh-ctx .sep { width: 1px; height: 14px; background: var(--glass-brd); margin: 0 3px }
|
|
417
423
|
|
|
418
424
|
/* error / missing cards (inside the frame body) */
|
|
@@ -499,9 +505,38 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
499
505
|
.sh-panel .it.board.drop-before::after { top: -1px }
|
|
500
506
|
.sh-panel .it.board.drop-after::after { bottom: -1px }
|
|
501
507
|
/* inline rename: the input wears the row's own type so the swap is seamless */
|
|
502
|
-
.sh-panel .it.board.editing { cursor: default }
|
|
503
|
-
.sh-panel .it.
|
|
508
|
+
.sh-panel .it.board.editing, .sh-panel .it.folder.editing { cursor: default }
|
|
509
|
+
.sh-panel .it.editing input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none;
|
|
504
510
|
color: var(--glass-ink); font: inherit }
|
|
511
|
+
.sh-panel .it.editing input::placeholder { color: var(--glass-ink-3) }
|
|
512
|
+
/* folders: one level, same row grammar as boards (idle icon tertiary, count tertiary). A folder
|
|
513
|
+
holding the current board wears the ancestor wash so the active board's home survives a
|
|
514
|
+
collapse; its boards indent one step. Folder rows share the board seam rules; dropping INTO a
|
|
515
|
+
folder lights the whole row (wash + brand-blue ring) instead of a seam. */
|
|
516
|
+
.sh-panel .it.folder svg { color: var(--glass-ink-3); flex: none; margin: 0 1px; transition: color .2s ease }
|
|
517
|
+
.sh-panel .it.folder[data-reorderable]:active { cursor: grabbing }
|
|
518
|
+
.sh-panel .it.folder.dragging { opacity: .4 }
|
|
519
|
+
.sh-panel .it.folder.drop-before, .sh-panel .it.folder.drop-after { position: relative }
|
|
520
|
+
.sh-panel .it.folder.drop-before::after, .sh-panel .it.folder.drop-after::after {
|
|
521
|
+
content: ''; position: absolute; left: 6px; right: 6px; height: 3px; border-radius: 3px;
|
|
522
|
+
background: #0088ff; pointer-events: none }
|
|
523
|
+
.sh-panel .it.folder.drop-before::after { top: -1px }
|
|
524
|
+
.sh-panel .it.folder.drop-after::after { bottom: -1px }
|
|
525
|
+
.sh-panel .it.folder.drop-into { background: var(--accent-wash); box-shadow: inset 0 0 0 1.5px #0088ff }
|
|
526
|
+
.sh-panel .it.folder.drop-into svg { color: #0088ff }
|
|
527
|
+
.sh-panel .it.board.in-folder { padding-left: 28px }
|
|
528
|
+
.sh-panel .it.board.draft { cursor: default; opacity: .6 }
|
|
529
|
+
/* seams inside a folder are indented with its rows, so "inside" and "at the root" never draw alike */
|
|
530
|
+
.sh-panel .it.board.in-folder.drop-before::after, .sh-panel .it.board.in-folder.drop-after::after { left: 28px }
|
|
531
|
+
/* the Boards header carries a quiet folder-plus: tertiary, shows on hover of the header row */
|
|
532
|
+
.sh-panel .sh-boards .hd { display: flex; align-items: center; margin-right: 6px }
|
|
533
|
+
.sh-panel .sh-boards .hd span { flex: 1 }
|
|
534
|
+
/* a bare glyph, no button chrome (padding would shrink it): tertiary, pointer, full ink on hover */
|
|
535
|
+
.sh-panel .sh-hd-add { flex: none; color: var(--glass-ink-3); cursor: pointer; outline: none; border-radius: 4px;
|
|
536
|
+
opacity: 0; transition: opacity .15s ease, color .15s ease }
|
|
537
|
+
.sh-panel .sh-boards .hd:hover .sh-hd-add, .sh-panel .sh-hd-add:focus-visible { opacity: 1 }
|
|
538
|
+
.sh-panel .sh-hd-add:hover { color: var(--glass-ink) }
|
|
539
|
+
.sh-panel .sh-hd-add:focus-visible { box-shadow: 0 0 0 2px var(--accent) }
|
|
505
540
|
/* frame rows: one indent in (aligned to the label line), quieter ink */
|
|
506
541
|
.sh-panel .sub { display: flex; align-items: center; height: 28px; padding: 0 8px 0 31px; margin-bottom: 1px;
|
|
507
542
|
color: var(--glass-ink-2); font-size: 13px; font-weight: 400; border-radius: 8px; cursor: pointer }
|
|
@@ -556,8 +591,11 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
556
591
|
cursor: pointer; text-align: left }
|
|
557
592
|
.sh-menu button:hover { background: var(--glass-hover) }
|
|
558
593
|
.sh-menu button span { flex: 1; text-transform: capitalize }
|
|
559
|
-
/* the sidebar right-click menu keeps its labels verbatim (they match the toolbar exactly)
|
|
560
|
-
|
|
594
|
+
/* the sidebar right-click menu keeps its labels verbatim (they match the toolbar exactly);
|
|
595
|
+
it scrolls rather than overflowing the window when the folder list grows long */
|
|
596
|
+
.sh-ctxmenu { max-height: calc(100vh - 16px); max-width: 240px; overflow-y: auto; scrollbar-width: thin }
|
|
597
|
+
.sh-ctxmenu button { flex: none; max-width: 100% }
|
|
598
|
+
.sh-ctxmenu button span { text-transform: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0 }
|
|
561
599
|
.sh-menu .chk { color: var(--glass-accent) }
|
|
562
600
|
.sh-menu kbd { font: 500 10.5px -apple-system, system-ui, sans-serif; color: var(--glass-ink-3); background: none; border: 0 }
|
|
563
601
|
.sh-menu .div { height: 1px; background: var(--glass-brd); margin: 4px 6px }
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board folders - the pure tree shared by the sidebar, the dev API, the build and the
|
|
3
|
+
* tests. Files are the truth: a board says which folder it sits in (`folder` on the
|
|
4
|
+
* board file, ranked among its siblings by `order`), and `design/boards/_folders.json`
|
|
5
|
+
* says which folders exist and where they rank at the root. One level only: folders
|
|
6
|
+
* hold boards, never folders. `all-scenes` never enters the tree - callers pin it last.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The on-disk name grammar shared by boards and folders (a board name is a filename). */
|
|
10
|
+
export const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/
|
|
11
|
+
export const NAME_MAX = 64
|
|
12
|
+
export const isBoardName = (n: unknown): n is string => typeof n === 'string' && n.length >= 1 && n.length <= NAME_MAX && BOARD_NAME.test(n)
|
|
13
|
+
|
|
14
|
+
/** The folder registry beside the boards - underscore = infrastructure, never a board. */
|
|
15
|
+
export const FOLDERS_FILE = '_folders.json'
|
|
16
|
+
/** Is this basename in design/boards/ a board file? `_folders.json`, temp files and any
|
|
17
|
+
* off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
|
|
18
|
+
export const isBoardFile = (f: string): boolean => f.endsWith('.json') && isBoardName(f.slice(0, -5))
|
|
19
|
+
|
|
20
|
+
export type Folder = { kind: 'folder'; name: string; boards: string[]; description?: string }
|
|
21
|
+
export type TreeItem = { kind: 'board'; name: string } | Folder
|
|
22
|
+
|
|
23
|
+
export interface BoardRow { name: string; order?: number; folder?: string }
|
|
24
|
+
export interface FolderRow { name: string; order?: number; description?: string }
|
|
25
|
+
|
|
26
|
+
/** A description off a file: one sentence, trimmed, capped - absent when empty or not a string. */
|
|
27
|
+
export const DESCRIPTION_MAX = 300
|
|
28
|
+
export const readDescription = (v: unknown): string | undefined => {
|
|
29
|
+
if (typeof v !== 'string') return undefined
|
|
30
|
+
const s = v.trim().replace(/\s+/g, ' ').slice(0, DESCRIPTION_MAX)
|
|
31
|
+
return s || undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rank = (o: number | undefined) => (typeof o === 'number' && Number.isFinite(o) ? o : Infinity)
|
|
35
|
+
|
|
36
|
+
/** The registry file's shape. Returns the rows, or a string naming what is wrong - a
|
|
37
|
+
* malformed registry is an ERROR the human must fix (silently reading it as empty would
|
|
38
|
+
* let the next drag overwrite their folders), while a missing file is simply no folders. */
|
|
39
|
+
export function parseFolders(raw: unknown): FolderRow[] | string {
|
|
40
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'expected an object'
|
|
41
|
+
const { version, folders } = raw as { version?: unknown; folders?: unknown }
|
|
42
|
+
if (version !== undefined && version !== 1) return `unsupported version ${String(version)}`
|
|
43
|
+
if (!Array.isArray(folders)) return 'expected a "folders" array'
|
|
44
|
+
const out: FolderRow[] = []
|
|
45
|
+
const seen = new Set<string>()
|
|
46
|
+
for (const f of folders) {
|
|
47
|
+
const name = (f as { name?: unknown })?.name
|
|
48
|
+
if (!isBoardName(name)) return 'a folder needs a name - lowercase letters, numbers and dashes'
|
|
49
|
+
if (seen.has(name)) return `folder "${name}" is listed twice`
|
|
50
|
+
seen.add(name)
|
|
51
|
+
const o = (f as { order?: unknown }).order
|
|
52
|
+
const d = readDescription((f as { description?: unknown }).description)
|
|
53
|
+
out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(d ? { description: d } : {}) })
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Sidebar order from the files. Root: boards with no folder + every folder (registered
|
|
59
|
+
* or implied by a board), ranked by `order` then kind (board before folder) then name.
|
|
60
|
+
* Inside a folder: its boards by `order` then name. Unranked sorts after ranked. */
|
|
61
|
+
export function buildTree(boards: BoardRow[], folders: FolderRow[]): TreeItem[] {
|
|
62
|
+
const folderOrder = new Map<string, number | undefined>()
|
|
63
|
+
const folderDesc = new Map<string, string>()
|
|
64
|
+
for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) { folderOrder.set(f.name, f.order); if (f.description) folderDesc.set(f.name, f.description) }
|
|
65
|
+
const members = new Map<string, BoardRow[]>()
|
|
66
|
+
const rootBoards: BoardRow[] = []
|
|
67
|
+
for (const b of boards) {
|
|
68
|
+
if (!isBoardName(b.name) || b.name === 'all-scenes') continue
|
|
69
|
+
const folder = isBoardName(b.folder) ? b.folder : undefined
|
|
70
|
+
if (!folder) { rootBoards.push(b); continue }
|
|
71
|
+
if (!folderOrder.has(folder)) folderOrder.set(folder, undefined) // implied by the board alone
|
|
72
|
+
const list = members.get(folder) ?? []
|
|
73
|
+
list.push(b)
|
|
74
|
+
members.set(folder, list)
|
|
75
|
+
}
|
|
76
|
+
const byRank = (a: BoardRow, b: BoardRow) => rank(a.order) - rank(b.order) || a.name.localeCompare(b.name)
|
|
77
|
+
type Root = { item: TreeItem; order: number | undefined }
|
|
78
|
+
const root: Root[] = [
|
|
79
|
+
...rootBoards.map((b) => ({ item: { kind: 'board', name: b.name } as TreeItem, order: b.order })),
|
|
80
|
+
...[...folderOrder].map(([name, order]) => ({
|
|
81
|
+
item: { kind: 'folder', name, boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name), ...(folderDesc.has(name) ? { description: folderDesc.get(name) } : {}) } as TreeItem,
|
|
82
|
+
order,
|
|
83
|
+
})),
|
|
84
|
+
]
|
|
85
|
+
root.sort((a, b) => rank(a.order) - rank(b.order) || (a.item.kind === b.item.kind ? 0 : a.item.kind === 'board' ? -1 : 1) || a.item.name.localeCompare(b.item.name))
|
|
86
|
+
return root.map((r) => r.item)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Every board in reading order - the order the switchers and the landing pick use. */
|
|
90
|
+
export function flatten(tree: TreeItem[]): string[] {
|
|
91
|
+
const out: string[] = []
|
|
92
|
+
for (const it of tree) { if (it.kind === 'board') out.push(it.name); else out.push(...it.boards) }
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The wire shape of a tree write (`POST boards/reorder`): plain strings for root boards,
|
|
97
|
+
* `{ folder, boards }` for folders - what the sidebar posts and what the server validates. */
|
|
98
|
+
export type WireItem = string | { folder: string; boards: string[]; description?: string }
|
|
99
|
+
export const toWire = (tree: TreeItem[]): WireItem[] =>
|
|
100
|
+
tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, boards: [...it.boards], ...(it.description ? { description: it.description } : {}) }))
|
|
101
|
+
export const fromWire = (wire: WireItem[]): TreeItem[] =>
|
|
102
|
+
wire.map((w) => (typeof w === 'string' ? { kind: 'board', name: w } : { kind: 'folder', name: w.folder, boards: [...w.boards], ...(w.description ? { description: w.description } : {}) }))
|
|
103
|
+
|
|
104
|
+
/** Validate a wire tree off the network. Returns the error, or null when it is sound:
|
|
105
|
+
* every name on-grammar, `all-scenes` nowhere, no board twice, no folder twice, no
|
|
106
|
+
* nesting (a folder's boards are strings), bounded. */
|
|
107
|
+
export const TREE_MAX_BOARDS = 200
|
|
108
|
+
export const TREE_MAX_FOLDERS = 50
|
|
109
|
+
export function validateWire(wire: unknown): string | null {
|
|
110
|
+
if (!Array.isArray(wire)) return 'invalid tree'
|
|
111
|
+
const boards = new Set<string>(), folders = new Set<string>()
|
|
112
|
+
const board = (n: unknown): string | null => {
|
|
113
|
+
if (!isBoardName(n) || n === 'all-scenes') return 'invalid board name in tree'
|
|
114
|
+
if (boards.has(n)) return `board "${n}" appears twice`
|
|
115
|
+
boards.add(n)
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
for (const w of wire) {
|
|
119
|
+
if (typeof w === 'string') { const e = board(w); if (e) return e; continue }
|
|
120
|
+
if (!w || typeof w !== 'object' || Array.isArray(w)) return 'invalid tree item'
|
|
121
|
+
const { folder, boards: kids, description } = w as { folder?: unknown; boards?: unknown; description?: unknown }
|
|
122
|
+
if (!isBoardName(folder)) return 'invalid folder name in tree'
|
|
123
|
+
if (description !== undefined && (typeof description !== 'string' || description.length > DESCRIPTION_MAX)) return 'invalid folder description'
|
|
124
|
+
if (folders.has(folder)) return `folder "${folder}" appears twice`
|
|
125
|
+
folders.add(folder)
|
|
126
|
+
if (!Array.isArray(kids)) return 'invalid folder in tree'
|
|
127
|
+
for (const k of kids) { const e = board(k); if (e) return e }
|
|
128
|
+
}
|
|
129
|
+
if (boards.size > TREE_MAX_BOARDS || folders.size > TREE_MAX_FOLDERS) return 'tree too large'
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What the human types becomes a slug: "Old stuff" → "old-stuff". Empty when nothing
|
|
134
|
+
* survives - the caller keeps the input open and says so. Always on-grammar or empty. */
|
|
135
|
+
export function slugify(raw: string): string {
|
|
136
|
+
const s = raw.trim().toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-+|-+$/g, '').slice(0, NAME_MAX).replace(/-+$/g, '')
|
|
137
|
+
return isBoardName(s) ? s : ''
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- tree mutations (pure; the sidebar shows the result at once and persists it) ----
|
|
141
|
+
|
|
142
|
+
export const cloneTree = (t: TreeItem[]): TreeItem[] => t.map((it) => (it.kind === 'board' ? { ...it } : { ...it, boards: [...it.boards] })) // a folder's description rides along
|
|
143
|
+
export const rootIndex = (t: TreeItem[], kind: TreeItem['kind'], name: string) => t.findIndex((it) => it.kind === kind && it.name === name)
|
|
144
|
+
export const folderIn = (t: TreeItem[], name: string): Folder | undefined => t.find((it): it is Folder => it.kind === 'folder' && it.name === name)
|
|
145
|
+
export const folderOf = (t: TreeItem[], board: string): string | null => t.find((it): it is Folder => it.kind === 'folder' && it.boards.includes(board))?.name ?? null
|
|
146
|
+
export const boardsIn = (t: TreeItem[]): string[] => flatten(t)
|
|
147
|
+
export const foldersIn = (t: TreeItem[]): string[] => t.filter((it) => it.kind === 'folder').map((it) => it.name)
|
|
148
|
+
|
|
149
|
+
/** Remove a board wherever it sits; returns the list it left (root = null) and its index there. */
|
|
150
|
+
export function takeBoard(t: TreeItem[], board: string): { list: string | null; index: number } | null {
|
|
151
|
+
const ri = rootIndex(t, 'board', board)
|
|
152
|
+
if (ri >= 0) { t.splice(ri, 1); return { list: null, index: ri } }
|
|
153
|
+
for (const it of t) {
|
|
154
|
+
if (it.kind !== 'folder') continue
|
|
155
|
+
const i = it.boards.indexOf(board)
|
|
156
|
+
if (i >= 0) { it.boards.splice(i, 1); return { list: it.name, index: i } }
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What is being dragged, and where it may land: a slot in a list (root = null) or the
|
|
162
|
+
* inside of a folder (appended at its end). */
|
|
163
|
+
export type Drag = { kind: TreeItem['kind']; name: string }
|
|
164
|
+
export type Drop = { list: string | null; index: number } | { into: string }
|
|
165
|
+
|
|
166
|
+
/** The row under the pointer, measured by the DOM and handed here as plain facts. */
|
|
167
|
+
export interface Hit {
|
|
168
|
+
kind: TreeItem['kind']; name: string
|
|
169
|
+
parent: string | null // the folder a board row lives in
|
|
170
|
+
below: boolean // pointer in the lower half of the row
|
|
171
|
+
topEdge: boolean // pointer in the top 30% of the row (folder rows: "before me")
|
|
172
|
+
gutter: boolean // pointer left of a child row's indent (the root gutter = outdent)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The drop target for a drag over a row, or null. Boards land in any slot - root or inside
|
|
176
|
+
* a folder - or INTO a folder; folders land in root slots only. Over a folder's child row the
|
|
177
|
+
* root gutter means "after that folder" (the natural outdent). `all-scenes` = root end. */
|
|
178
|
+
export function resolveDrop(t: TreeItem[], d: Drag, hit: Hit): Drop | null {
|
|
179
|
+
if (hit.kind === 'folder') {
|
|
180
|
+
const fi = rootIndex(t, 'folder', hit.name)
|
|
181
|
+
if (fi < 0) return null
|
|
182
|
+
if (d.kind === 'folder') return { list: null, index: hit.below ? fi + 1 : fi }
|
|
183
|
+
return hit.topEdge ? { list: null, index: fi } : { into: hit.name } // top edge = before; the rest = inside
|
|
184
|
+
}
|
|
185
|
+
if (hit.name === 'all-scenes') return { list: null, index: t.length } // over the pinned last row = root end slot
|
|
186
|
+
if (hit.parent) {
|
|
187
|
+
const pi = rootIndex(t, 'folder', hit.parent)
|
|
188
|
+
if (pi < 0) return null
|
|
189
|
+
if (d.kind === 'folder' || hit.gutter) return { list: null, index: pi + 1 } // after that folder
|
|
190
|
+
const i = folderIn(t, hit.parent)?.boards.indexOf(hit.name) ?? -1
|
|
191
|
+
return i < 0 ? null : { list: hit.parent, index: hit.below ? i + 1 : i }
|
|
192
|
+
}
|
|
193
|
+
const i = rootIndex(t, 'board', hit.name)
|
|
194
|
+
return i < 0 ? null : { list: null, index: hit.below ? i + 1 : i }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A target that would leave the item where it is: nothing to show, nothing to drop. Into
|
|
198
|
+
* the folder a board already sits in means "to its end" - a no-op only when it is last. */
|
|
199
|
+
export function isOwnSlot(t: TreeItem[], d: Drag, target: Drop): boolean {
|
|
200
|
+
if ('into' in target) {
|
|
201
|
+
if (d.kind !== 'board') return true
|
|
202
|
+
const f = folderIn(t, target.into)
|
|
203
|
+
return !!f && f.boards[f.boards.length - 1] === d.name
|
|
204
|
+
}
|
|
205
|
+
let from = -1
|
|
206
|
+
if (d.kind === 'folder') from = target.list === null ? rootIndex(t, 'folder', d.name) : -1
|
|
207
|
+
else if (target.list === null) from = rootIndex(t, 'board', d.name)
|
|
208
|
+
else from = folderIn(t, target.list)?.boards.indexOf(d.name) ?? -1
|
|
209
|
+
return from >= 0 && (target.index === from || target.index === from + 1)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The tree after a drop, or null when the drop is impossible on this tree. */
|
|
213
|
+
export function applyDrop(tree: TreeItem[], d: Drag, target: Drop): TreeItem[] | null {
|
|
214
|
+
const next = cloneTree(tree)
|
|
215
|
+
if (d.kind === 'folder') {
|
|
216
|
+
if ('into' in target || target.list !== null) return null
|
|
217
|
+
const from = rootIndex(next, 'folder', d.name)
|
|
218
|
+
if (from < 0) return null
|
|
219
|
+
const [it] = next.splice(from, 1)
|
|
220
|
+
next.splice(target.index > from ? target.index - 1 : target.index, 0, it!) // removing `from` shifts later slots left
|
|
221
|
+
return next
|
|
222
|
+
}
|
|
223
|
+
const src = takeBoard(next, d.name)
|
|
224
|
+
if (!src) return null
|
|
225
|
+
if ('into' in target) {
|
|
226
|
+
const f = folderIn(next, target.into)
|
|
227
|
+
if (!f) return null
|
|
228
|
+
f.boards.push(d.name)
|
|
229
|
+
return next
|
|
230
|
+
}
|
|
231
|
+
const to = src.list === target.list && target.index > src.index ? target.index - 1 : target.index
|
|
232
|
+
if (target.list === null) { next.splice(Math.min(to, next.length), 0, { kind: 'board', name: d.name }); return next }
|
|
233
|
+
const f = folderIn(next, target.list)
|
|
234
|
+
if (!f) return null
|
|
235
|
+
f.boards.splice(Math.min(to, f.boards.length), 0, d.name)
|
|
236
|
+
return next
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Move a board into a folder (at its end) or to the root at `atRoot` (default: the end). */
|
|
240
|
+
export function moveBoard(tree: TreeItem[], board: string, folder: string | null, atRoot?: number): TreeItem[] | null {
|
|
241
|
+
const next = cloneTree(tree)
|
|
242
|
+
if (!takeBoard(next, board)) return null
|
|
243
|
+
if (folder === null) { next.splice(Math.min(atRoot ?? next.length, next.length), 0, { kind: 'board', name: board }); return next }
|
|
244
|
+
const f = folderIn(next, folder)
|
|
245
|
+
if (!f) return null
|
|
246
|
+
f.boards.push(board)
|
|
247
|
+
return next
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A new folder at root `index`, holding `board` (pulled from wherever it sat) when given. */
|
|
251
|
+
export function createFolder(tree: TreeItem[], name: string, index: number, board?: string): TreeItem[] | null {
|
|
252
|
+
if (foldersIn(tree).includes(name)) return null
|
|
253
|
+
const next = cloneTree(tree)
|
|
254
|
+
const boards: string[] = []
|
|
255
|
+
if (board) { if (!takeBoard(next, board)) return null; boards.push(board) }
|
|
256
|
+
next.splice(Math.min(index, next.length), 0, { kind: 'folder', name, boards })
|
|
257
|
+
return next
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function renameFolder(tree: TreeItem[], from: string, to: string): TreeItem[] | null {
|
|
261
|
+
if (from === to) return cloneTree(tree)
|
|
262
|
+
if (foldersIn(tree).includes(to)) return null
|
|
263
|
+
const next = cloneTree(tree)
|
|
264
|
+
const f = folderIn(next, from)
|
|
265
|
+
if (!f) return null
|
|
266
|
+
f.name = to
|
|
267
|
+
return next
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Folders organise, never own: deleting one puts its boards back at the root, in its slot, in order. */
|
|
271
|
+
export function deleteFolder(tree: TreeItem[], name: string): TreeItem[] | null {
|
|
272
|
+
const next = cloneTree(tree)
|
|
273
|
+
const i = rootIndex(next, 'folder', name)
|
|
274
|
+
if (i < 0) return null
|
|
275
|
+
const f = next[i] as Folder
|
|
276
|
+
next.splice(i, 1, ...f.boards.map((b): TreeItem => ({ kind: 'board', name: b })))
|
|
277
|
+
return next
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Where "Move to new folder" puts the folder: the board's own root slot, or right after the
|
|
281
|
+
* folder it sits in. */
|
|
282
|
+
export const newFolderSlot = (tree: TreeItem[], board: string): number => {
|
|
283
|
+
const parent = folderOf(tree, board)
|
|
284
|
+
return parent ? rootIndex(tree, 'folder', parent) + 1 : Math.max(0, rootIndex(tree, 'board', board))
|
|
285
|
+
}
|