@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
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
|
2
|
+
import { useStore, HAS_ALL_SCENES, PUBLISHED, boardLabel, fetchBoardTree, type TreeBase } from './store.ts'
|
|
3
|
+
import { canvasCtl } from './canvas/Canvas.tsx'
|
|
4
|
+
import { Tip } from './Tip.tsx'
|
|
5
|
+
import { copyToClipboard, type MenuItem, type MenuOpener } from './ContextMenu.tsx'
|
|
6
|
+
import { ArrowLineUpIcon, CardsIcon, CardsThreeIcon, FolderIcon, FolderMinusIcon, FolderOpenIcon, FolderPlusIcon, PencilSimpleIcon, SignpostIcon } from './icons.tsx'
|
|
7
|
+
import {
|
|
8
|
+
applyDrop, boardsIn, createFolder, deleteFolder, folderOf, foldersIn, isBoardName, isOwnSlot, moveBoard, newFolderSlot, renameFolder, resolveDrop, rootIndex, slugify,
|
|
9
|
+
type Drag, type Drop, type TreeItem,
|
|
10
|
+
} from '../../shared/board-tree.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Boards live at the top of the sidebar - always visible, one click to switch - in ONE level
|
|
14
|
+
* of folders. The tree (shared/board-tree.ts) comes from the board files' `order`/`folder`
|
|
15
|
+
* fields plus the `_folders.json` registry; every mutation here (drag, new/rename/delete
|
|
16
|
+
* folder, move) is one optimistic tree write through `arrangeBoards`, replayed once on a
|
|
17
|
+
* 409 (someone else wrote first). The list refreshes on mount, window focus, a slow poll and
|
|
18
|
+
* every `sh:boards` broadcast, so agent-written boards and folders appear without a reload.
|
|
19
|
+
* Active board = accent icon + wash, same language as scenes.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CLOSED_KEY = 'mv-folders-closed' // collapsed folders are a viewer preference, never data
|
|
23
|
+
const readClosed = (): Record<string, true> => { try { return JSON.parse(localStorage.getItem(CLOSED_KEY) ?? '{}') } catch { return {} } }
|
|
24
|
+
const INDENT = 28 // px a board row indents inside a folder; left of it is the root gutter
|
|
25
|
+
|
|
26
|
+
/** The inline input: renaming a board or a folder, or naming a NEW folder that does not exist
|
|
27
|
+
* yet - drawn at root `index`, optionally with `board` already inside it. */
|
|
28
|
+
type Naming = { kind: 'board' | 'folder'; name: string } | { kind: 'new'; index: number; board?: string }
|
|
29
|
+
/** A mutation as intent: applied to whichever tree is current, so a 409 can replay it. */
|
|
30
|
+
type Intent = (tree: TreeItem[]) => TreeItem[] | null
|
|
31
|
+
|
|
32
|
+
export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
33
|
+
const board = useStore((s) => s.board)
|
|
34
|
+
const [tree, setTree] = useState<TreeItem[]>([])
|
|
35
|
+
const [naming, setNaming] = useState<Naming | null>(null)
|
|
36
|
+
const [closed, setClosed] = useState<Record<string, true>>(readClosed)
|
|
37
|
+
const [drag, setDrag] = useState<Drag | null>(null)
|
|
38
|
+
const [drop, setDrop] = useState<Drop | null>(null)
|
|
39
|
+
// What the sidebar shows = the CONFIRMED tree (the last read of the files) with every
|
|
40
|
+
// unconfirmed intent projected on top, in order. A read never loses an optimistic move
|
|
41
|
+
// (the queue re-applies over whatever came back) and a failure never rolls back to another
|
|
42
|
+
// unconfirmed state - only ever to the files. Reads are latest-wins by sequence.
|
|
43
|
+
const confirmedRef = useRef<TreeItem[]>([])
|
|
44
|
+
const baseRef = useRef<TreeBase>({ boards: {}, folders: null }) // the hashes the confirmed tree was read from
|
|
45
|
+
const queueRef = useRef<Intent[]>([]) // applied on screen, not yet written
|
|
46
|
+
const flushing = useRef(false)
|
|
47
|
+
const loadSeq = useRef(0)
|
|
48
|
+
const commitBusy = useRef(false) // guards Enter+blur firing two commits
|
|
49
|
+
const lastErr = useRef('') // a server-side error (malformed registry, symlinked dir) toasts once per message
|
|
50
|
+
// drag runs on pointer events, NOT native drag-and-drop: native DnD hands the cursor to
|
|
51
|
+
// the OS (arrow/move), so a grabbing hand can't persist. Owning the gesture lets us hold
|
|
52
|
+
// the grabbing cursor for the whole drag via a body class.
|
|
53
|
+
const gestureRef = useRef<{ pointerId: number; startX: number; startY: number; item: Drag; dragging: boolean; el: HTMLElement } | null>(null)
|
|
54
|
+
const treeRef = useRef(tree)
|
|
55
|
+
treeRef.current = tree
|
|
56
|
+
const namingRef = useRef(naming) // commit reads the LIVE naming state, never a stale closure (Escape, then a late blur)
|
|
57
|
+
namingRef.current = naming
|
|
58
|
+
|
|
59
|
+
/** The confirmed tree with the queue projected on top - what the human sees. */
|
|
60
|
+
const show = () => {
|
|
61
|
+
let t = confirmedRef.current
|
|
62
|
+
for (const intent of queueRef.current) t = intent(t) ?? t
|
|
63
|
+
treeRef.current = t
|
|
64
|
+
setTree(t)
|
|
65
|
+
}
|
|
66
|
+
const load = async (): Promise<boolean> => {
|
|
67
|
+
const seq = ++loadSeq.current
|
|
68
|
+
try {
|
|
69
|
+
const snap = await fetchBoardTree()
|
|
70
|
+
if (seq !== loadSeq.current) return false // an older read landing late never overwrites a newer one
|
|
71
|
+
confirmedRef.current = snap.tree
|
|
72
|
+
baseRef.current = snap.base
|
|
73
|
+
lastErr.current = ''
|
|
74
|
+
show()
|
|
75
|
+
return true
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// a server-side error (a malformed registry, a symlinked boards dir) is worth saying
|
|
78
|
+
// out loud, once; transport failures keep the last known tree quietly
|
|
79
|
+
const msg = e instanceof Error ? e.message : ''
|
|
80
|
+
if (/design\/boards/.test(msg) && msg !== lastErr.current) { lastErr.current = msg; useStore.getState().toast(msg) }
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// an external read (poll, focus, sh:boards) never lands MID-FLUSH: a snapshot taken between
|
|
85
|
+
// a write's commit and its answer would project the same batch twice; it waits for the drain
|
|
86
|
+
const pendingRef = useRef(false)
|
|
87
|
+
const refresh = () => { if (flushing.current) { pendingRef.current = true; return } void load() }
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
refresh()
|
|
90
|
+
const t = setInterval(refresh, 8000)
|
|
91
|
+
window.addEventListener('focus', refresh)
|
|
92
|
+
// an agent (or another tab) adding, writing or deleting a board file or the registry
|
|
93
|
+
// broadcasts sh:boards (coalesced server-side): a `folder` edit shows in ~300 ms, not 8 s
|
|
94
|
+
import.meta.hot?.on('sh:boards', refresh)
|
|
95
|
+
return () => { clearInterval(t); window.removeEventListener('focus', refresh); import.meta.hot?.off('sh:boards', refresh) }
|
|
96
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
97
|
+
}, [])
|
|
98
|
+
|
|
99
|
+
const setOpen = (folder: string, open: boolean) => {
|
|
100
|
+
setClosed((c) => {
|
|
101
|
+
const next = { ...c }
|
|
102
|
+
if (open) delete next[folder]; else next[folder] = true
|
|
103
|
+
try { localStorage.setItem(CLOSED_KEY, JSON.stringify(next)) } catch { /* private mode */ }
|
|
104
|
+
return next
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
const pick = async (name: string) => {
|
|
108
|
+
if (name === useStore.getState().board) return
|
|
109
|
+
await useStore.getState().switchBoard(name)
|
|
110
|
+
setTimeout(() => canvasCtl.fitAll(), 60)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The one write path: queue the intent, show it at once, persist. The flush takes EVERY
|
|
114
|
+
* queued intent as one batch over the confirmed tree and writes the result; a 409 (a
|
|
115
|
+
* concurrent agent or tab wrote first) re-reads the files and replays the whole batch on
|
|
116
|
+
* what is there now, once. A terminal failure drops the batch, says so, and re-reads -
|
|
117
|
+
* the screen returns to the files, never to another unconfirmed state. Returns false when
|
|
118
|
+
* the intent cannot apply to what is shown (nothing queued). */
|
|
119
|
+
const mutate = (intent: Intent): boolean => {
|
|
120
|
+
if (!intent(treeRef.current)) return false
|
|
121
|
+
queueRef.current.push(intent)
|
|
122
|
+
show()
|
|
123
|
+
void flush()
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
const flush = async () => {
|
|
127
|
+
if (flushing.current) return
|
|
128
|
+
flushing.current = true
|
|
129
|
+
try {
|
|
130
|
+
while (queueRef.current.length) {
|
|
131
|
+
const batch = [...queueRef.current]
|
|
132
|
+
// an intent the fresh tree no longer admits (its board or folder vanished) is dropped
|
|
133
|
+
// and said out loud, never silently counted as done
|
|
134
|
+
const reduce = (t: TreeItem[]) => {
|
|
135
|
+
let dropped = 0
|
|
136
|
+
const tree = batch.reduce<TreeItem[]>((acc, i) => { const n = i(acc); if (!n) dropped++; return n ?? acc }, t)
|
|
137
|
+
if (dropped) useStore.getState().toast(dropped === batch.length ? 'that move no longer applies' : 'some moves no longer apply')
|
|
138
|
+
return tree
|
|
139
|
+
}
|
|
140
|
+
const done = () => { queueRef.current.splice(0, batch.length); show() } // written, or given up on - off the screen's projection either way
|
|
141
|
+
try {
|
|
142
|
+
let r = await useStore.getState().arrangeBoards(reduce(confirmedRef.current), baseRef.current)
|
|
143
|
+
if (!r.ok && r.stale) {
|
|
144
|
+
if (!(await load())) { done(); useStore.getState().toast('boards changed - try again'); break }
|
|
145
|
+
r = await useStore.getState().arrangeBoards(reduce(confirmedRef.current), baseRef.current)
|
|
146
|
+
}
|
|
147
|
+
done()
|
|
148
|
+
if (!r.ok) { useStore.getState().toast(r.error ?? 'could not save order'); await load(); break }
|
|
149
|
+
await load() // the write moved the hashes on; the next batch needs the true base
|
|
150
|
+
} catch { done(); useStore.getState().toast('could not save order'); await load(); break }
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
flushing.current = false
|
|
154
|
+
if (queueRef.current.length) void flush()
|
|
155
|
+
else if (pendingRef.current) { pendingRef.current = false; void load() }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- inline naming (rename a board, rename a folder, name a new folder) ----
|
|
160
|
+
const commit = async (raw: string) => {
|
|
161
|
+
const n = namingRef.current
|
|
162
|
+
if (!n || commitBusy.current) return // Enter already fired this (or Escape cancelled); ignore the follow-up blur
|
|
163
|
+
commitBusy.current = true
|
|
164
|
+
try {
|
|
165
|
+
if (n.kind === 'board') {
|
|
166
|
+
const next = raw.trim()
|
|
167
|
+
if (!next || next === n.name) { setNaming(null); return }
|
|
168
|
+
if (!isBoardName(next) || next === 'all-scenes' || boardsIn(tree).includes(next)) {
|
|
169
|
+
useStore.getState().toast('use a free name - lowercase letters, numbers and dashes'); return // stay editing
|
|
170
|
+
}
|
|
171
|
+
const r = await useStore.getState().renameBoard(n.name, next)
|
|
172
|
+
if (r.ok) { setNaming(null); refresh() }
|
|
173
|
+
else useStore.getState().toast(r.error ?? 'rename failed') // stay editing
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
// folders: what the human types becomes a slug ("Old stuff" -> old-stuff, shown "Old Stuff");
|
|
177
|
+
// an empty entry means never mind
|
|
178
|
+
if (!raw.trim() || (n.kind === 'folder' && slugify(raw) === n.name)) { setNaming(null); return }
|
|
179
|
+
const slug = slugify(raw)
|
|
180
|
+
if (!slug) { useStore.getState().toast('use letters, numbers and dashes'); return }
|
|
181
|
+
if (foldersIn(tree).includes(slug)) { useStore.getState().toast(`a folder named "${slug}" already exists`); return }
|
|
182
|
+
setNaming(null)
|
|
183
|
+
if (n.kind === 'folder') {
|
|
184
|
+
const wasClosed = !!closed[n.name]
|
|
185
|
+
setOpen(n.name, true); setOpen(slug, !wasClosed) // the collapsed flag follows the name, and only that
|
|
186
|
+
mutate((t) => renameFolder(t, n.name, slug))
|
|
187
|
+
} else if (n.kind === 'new') {
|
|
188
|
+
setOpen(slug, true) // a new folder opens, whatever an old namesake left behind
|
|
189
|
+
if (!mutate((t) => createFolder(t, slug, n.index, n.board))) useStore.getState().toast('that board is gone - nothing changed')
|
|
190
|
+
}
|
|
191
|
+
} finally { commitBusy.current = false }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- drag and drop: one pointer gesture for board rows and folder rows ----
|
|
195
|
+
/** The drop target under the pointer for the item being dragged, or null. The DOM hit becomes
|
|
196
|
+
* plain facts (which row, which half, the top edge, the root gutter) for the pure resolver. */
|
|
197
|
+
const dropAt = (x: number, y: number, d: Drag): Drop | null => {
|
|
198
|
+
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-board-row],[data-folder-row]') as HTMLElement | null
|
|
199
|
+
if (!el) return null
|
|
200
|
+
const r = el.getBoundingClientRect()
|
|
201
|
+
const hit = {
|
|
202
|
+
kind: (el.hasAttribute('data-folder-row') ? 'folder' : 'board') as Drag['kind'],
|
|
203
|
+
name: el.dataset.folderRow ?? el.dataset.board ?? '',
|
|
204
|
+
parent: el.hasAttribute('data-folder-row') ? null : (el.dataset.folder ?? null),
|
|
205
|
+
below: y > r.top + r.height / 2,
|
|
206
|
+
topEdge: y < r.top + r.height * 0.3,
|
|
207
|
+
gutter: x < r.left + INDENT,
|
|
208
|
+
}
|
|
209
|
+
const target = resolveDrop(treeRef.current, d, hit)
|
|
210
|
+
return target && !isOwnSlot(treeRef.current, d, target) ? target : null
|
|
211
|
+
}
|
|
212
|
+
const resetPointer = () => {
|
|
213
|
+
const g = gestureRef.current
|
|
214
|
+
gestureRef.current = null
|
|
215
|
+
if (g) { try { g.el.releasePointerCapture(g.pointerId) } catch { /* already released */ } }
|
|
216
|
+
document.body.classList.remove('sh-board-dragging')
|
|
217
|
+
setDrag(null)
|
|
218
|
+
setDrop(null)
|
|
219
|
+
}
|
|
220
|
+
const onPointerDown = (e: ReactPointerEvent<HTMLButtonElement>, item: Drag) => {
|
|
221
|
+
if (e.button !== 0) return // left button only; right-click opens the menu
|
|
222
|
+
const el = e.currentTarget
|
|
223
|
+
try { el.setPointerCapture(e.pointerId) } catch { /* capture can fail on rapid input */ }
|
|
224
|
+
gestureRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, item, dragging: false, el }
|
|
225
|
+
}
|
|
226
|
+
const onPointerMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
227
|
+
const g = gestureRef.current
|
|
228
|
+
if (!g || g.pointerId !== e.pointerId) return
|
|
229
|
+
if (!g.dragging) {
|
|
230
|
+
if (Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < 5) return // click vs drag threshold
|
|
231
|
+
g.dragging = true
|
|
232
|
+
setDrag(g.item)
|
|
233
|
+
document.body.classList.add('sh-board-dragging') // holds the grabbing cursor for the whole drag
|
|
234
|
+
}
|
|
235
|
+
setDrop(dropAt(e.clientX, e.clientY, g.item))
|
|
236
|
+
}
|
|
237
|
+
const onPointerUp = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
238
|
+
const g = gestureRef.current
|
|
239
|
+
if (!g || g.pointerId !== e.pointerId) return
|
|
240
|
+
// the item is the one the gesture STARTED on - never the row that happens to receive the
|
|
241
|
+
// up (capture can be lost, a refresh can re-key the dragged row)
|
|
242
|
+
const item = g.item
|
|
243
|
+
const dragged = g.dragging
|
|
244
|
+
const target = dragged ? dropAt(e.clientX, e.clientY, item) : null
|
|
245
|
+
resetPointer()
|
|
246
|
+
if (dragged) {
|
|
247
|
+
if (!target) return
|
|
248
|
+
if ('into' in target) setOpen(target.into, true) // show where it landed
|
|
249
|
+
mutate((t) => applyDrop(t, item, target))
|
|
250
|
+
}
|
|
251
|
+
else if (item.kind === 'board') void pick(item.name) // a tap switches boards (the trailing mouse click is ignored)
|
|
252
|
+
else setOpen(item.name, !!closed[item.name]) // a tap on a folder toggles it
|
|
253
|
+
}
|
|
254
|
+
// cancel a drag on Escape (capture phase, so the app's global Escape never sees it) or focus loss
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape' && gestureRef.current?.dragging) { e.preventDefault(); e.stopPropagation(); resetPointer() } }
|
|
257
|
+
const onBlur = () => { if (gestureRef.current) resetPointer() }
|
|
258
|
+
window.addEventListener('keydown', onEsc, true)
|
|
259
|
+
window.addEventListener('blur', onBlur)
|
|
260
|
+
return () => { window.removeEventListener('keydown', onEsc, true); window.removeEventListener('blur', onBlur); resetPointer() }
|
|
261
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
262
|
+
}, [])
|
|
263
|
+
|
|
264
|
+
// ---- menus (flat lists, no submenus) ----
|
|
265
|
+
const newFolderAt = (index: number, withBoard?: string) => setNaming({ kind: 'new', index, board: withBoard })
|
|
266
|
+
const boardMenu = (n: string, parent: string | null): MenuItem[] => {
|
|
267
|
+
const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`board: ${n}`, 'path copied') }]
|
|
268
|
+
if (PUBLISHED || n === 'all-scenes') return items
|
|
269
|
+
items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'board', name: n }) })
|
|
270
|
+
// the new folder takes the board's own slot (or the slot after its current folder)
|
|
271
|
+
items.push({ label: 'Move to new folder', icon: <FolderPlusIcon size={15} />, onClick: () => newFolderAt(newFolderSlot(treeRef.current, n), n) })
|
|
272
|
+
// moving into an EXISTING folder is a drag, not a menu - the list stays short
|
|
273
|
+
if (parent) items.push({ label: 'Move to top level', icon: <ArrowLineUpIcon size={15} />, onClick: () => mutate((t) => { const p = folderOf(t, n); return moveBoard(t, n, null, p ? rootIndex(t, 'folder', p) + 1 : undefined) }) })
|
|
274
|
+
return items
|
|
275
|
+
}
|
|
276
|
+
const folderMenu = (f: string, boards: string[]): MenuItem[] => {
|
|
277
|
+
const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`folder: ${f} (boards: ${boards.join(', ') || 'none'})`, 'path copied') }]
|
|
278
|
+
if (PUBLISHED) return items
|
|
279
|
+
items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'folder', name: f }) })
|
|
280
|
+
// folders organise, never own: deleting one puts its boards back at the top level, in order
|
|
281
|
+
items.push({ label: 'Delete folder', icon: <FolderMinusIcon size={15} />, onClick: () => { setOpen(f, true); mutate((t) => deleteFolder(t, f)) } })
|
|
282
|
+
return items
|
|
283
|
+
}
|
|
284
|
+
/** Right-click on the sidebar itself (the Boards header, gaps between rows, the blank space
|
|
285
|
+
* under the lists): New folder, appended at the end. Rows keep their own menus. */
|
|
286
|
+
const blankMenu = (e: { preventDefault(): void; clientX: number; clientY: number; target: EventTarget | null }) => {
|
|
287
|
+
if (PUBLISHED) return
|
|
288
|
+
if ((e.target as HTMLElement).closest('[data-board-row],[data-folder-row],.editing,.sh-hd-add')) return
|
|
289
|
+
onMenuRef.current(e, [{ label: 'New folder', icon: <FolderPlusIcon size={15} />, onClick: () => newFolderAt(treeRef.current.length) }])
|
|
290
|
+
}
|
|
291
|
+
const onMenuRef = useRef(onMenu)
|
|
292
|
+
onMenuRef.current = onMenu
|
|
293
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
const panel = rootRef.current?.closest('.sh-panel')
|
|
296
|
+
const scroll = rootRef.current?.closest('.sh-panel-scroll')
|
|
297
|
+
if (!panel || !scroll) return
|
|
298
|
+
const h = (e: Event) => { if (e.target === scroll || e.target === panel) blankMenu(e as MouseEvent) } // only the panel's own blank space
|
|
299
|
+
panel.addEventListener('contextmenu', h)
|
|
300
|
+
return () => panel.removeEventListener('contextmenu', h)
|
|
301
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
302
|
+
}, [])
|
|
303
|
+
|
|
304
|
+
// ---- rows ----
|
|
305
|
+
const input = (defaultValue: string, placeholder?: string) => (
|
|
306
|
+
<input autoFocus defaultValue={defaultValue} placeholder={placeholder} spellCheck={false}
|
|
307
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
308
|
+
onKeyDown={(e) => {
|
|
309
|
+
if (e.key === 'Enter') { e.preventDefault(); void commit(e.currentTarget.value) }
|
|
310
|
+
else if (e.key === 'Escape') { e.preventDefault(); setNaming(null) }
|
|
311
|
+
}}
|
|
312
|
+
onBlur={(e) => { if (namingRef.current) void commit(e.currentTarget.value) }} />
|
|
313
|
+
)
|
|
314
|
+
// ONE seam per gap, from the insertion index: drop-before the row AT that index, or drop-after
|
|
315
|
+
// the last row of a folder's list. Overlay (::after), so no layout shift. Never on the row
|
|
316
|
+
// being dragged. The root end slot draws before the pinned all-scenes row, never after a
|
|
317
|
+
// folder header (that seam would read as "inside").
|
|
318
|
+
const seam = (list: string | null, index: number, last: boolean): string => {
|
|
319
|
+
if (!drag || !drop || 'into' in drop || drop.list !== list) return ''
|
|
320
|
+
return drop.index === index ? ' drop-before' : last && drop.index === index + 1 ? ' drop-after' : ''
|
|
321
|
+
}
|
|
322
|
+
const boardRow = (n: string, parent: string | null, index: number, last: boolean) => {
|
|
323
|
+
if (naming?.kind === 'board' && naming.name === n) return (
|
|
324
|
+
<div key={n} className={`it board editing${parent ? ' in-folder' : ''}`}><CardsIcon size={14} />{input(n)}</div>
|
|
325
|
+
)
|
|
326
|
+
const canDrag = !PUBLISHED && n !== 'all-scenes'
|
|
327
|
+
const item: Drag = { kind: 'board', name: n }
|
|
328
|
+
const dragging = drag?.kind === 'board' && drag.name === n
|
|
329
|
+
const dropCls = dragging ? '' : n === 'all-scenes' ? (drop && !('into' in drop) && drop.list === null && drop.index === tree.length ? ' drop-before' : '') : seam(parent, index, last)
|
|
330
|
+
return (
|
|
331
|
+
<button key={`${parent ?? ''}/${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-reorderable={canDrag || undefined}
|
|
332
|
+
className={`it board${parent ? ' in-folder' : ''}${n === board ? ' cur' : ''}${dragging ? ' dragging' : ''}${dropCls}`}
|
|
333
|
+
// draggable rows switch on the pointer tap (onPointerUp), so their trailing mouse
|
|
334
|
+
// click (detail >= 1) must be ignored to avoid a double switch; keyboard clicks
|
|
335
|
+
// (detail === 0) and non-draggable rows (all-scenes, published) switch here as normal
|
|
336
|
+
onClick={(e) => { if (!canDrag || e.detail === 0) void pick(n) }}
|
|
337
|
+
onContextMenu={(e) => onMenu(e, boardMenu(n, parent))}
|
|
338
|
+
onPointerDown={canDrag ? (e) => onPointerDown(e, item) : undefined}
|
|
339
|
+
onPointerMove={canDrag ? onPointerMove : undefined}
|
|
340
|
+
onPointerUp={canDrag ? onPointerUp : undefined}
|
|
341
|
+
onPointerCancel={canDrag ? () => resetPointer() : undefined}
|
|
342
|
+
onLostPointerCapture={canDrag ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
|
|
343
|
+
{n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
|
|
344
|
+
<span>{boardLabel(n)}</span>
|
|
345
|
+
</button>
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
const folderRow = (f: string, boards: string[], index: number): ReactNode[] => {
|
|
349
|
+
const open = !closed[f]
|
|
350
|
+
const rows: ReactNode[] = []
|
|
351
|
+
if (naming?.kind === 'folder' && naming.name === f) {
|
|
352
|
+
rows.push(<div key={`f:${f}`} className="it folder editing">{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}{input(f)}</div>)
|
|
353
|
+
} else {
|
|
354
|
+
const item: Drag = { kind: 'folder', name: f }
|
|
355
|
+
const dragging = drag?.kind === 'folder' && drag.name === f
|
|
356
|
+
const into = !!drag && !!drop && 'into' in drop && drop.into === f
|
|
357
|
+
rows.push(
|
|
358
|
+
<button key={`f:${f}`} data-folder-row={f} data-reorderable={!PUBLISHED || undefined}
|
|
359
|
+
className={`it folder${boards.includes(board) ? ' held' : ''}${dragging ? ' dragging' : ''}${into ? ' drop-into' : ''}${dragging ? '' : seam(null, index, false)}`}
|
|
360
|
+
onClick={(e) => { if (PUBLISHED || e.detail === 0) setOpen(f, !open) }}
|
|
361
|
+
onContextMenu={(e) => onMenu(e, folderMenu(f, boards))}
|
|
362
|
+
onPointerDown={!PUBLISHED ? (e) => onPointerDown(e, item) : undefined}
|
|
363
|
+
onPointerMove={!PUBLISHED ? onPointerMove : undefined}
|
|
364
|
+
onPointerUp={!PUBLISHED ? onPointerUp : undefined}
|
|
365
|
+
onPointerCancel={!PUBLISHED ? () => resetPointer() : undefined}
|
|
366
|
+
onLostPointerCapture={!PUBLISHED ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
|
|
367
|
+
{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}
|
|
368
|
+
<span>{boardLabel(f)}</span>
|
|
369
|
+
<small>{boards.length}</small>
|
|
370
|
+
</button>,
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
if (open) boards.forEach((b, i) => rows.push(boardRow(b, f, i, i === boards.length - 1)))
|
|
374
|
+
return rows
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// a new folder being named is drawn at its future slot, its board (if any) already inside;
|
|
378
|
+
// that board leaves its usual row for the duration
|
|
379
|
+
const draft = naming?.kind === 'new' ? (naming.board && !boardsIn(tree).includes(naming.board) ? { ...naming, board: undefined } : naming) : null // a board deleted mid-naming leaves the draft
|
|
380
|
+
const rows: ReactNode[] = []
|
|
381
|
+
const draftRows = draft ? [
|
|
382
|
+
<div key="f:new" className="it folder editing"><FolderOpenIcon size={14} />{input('', 'Folder name')}</div>,
|
|
383
|
+
...(draft.board ? [<div key="new/board" className={`it board in-folder draft${draft.board === board ? ' cur' : ''}`}><CardsIcon size={14} /><span>{boardLabel(draft.board)}</span></div>] : []),
|
|
384
|
+
] : []
|
|
385
|
+
tree.forEach((it, i) => {
|
|
386
|
+
if (draft && draft.index === i) rows.push(...draftRows)
|
|
387
|
+
if (it.kind === 'board') { if (draft?.board !== it.name) rows.push(boardRow(it.name, null, i, false)) }
|
|
388
|
+
else rows.push(...folderRow(it.name, draft?.board ? it.boards.filter((b) => b !== draft.board) : it.boards, i))
|
|
389
|
+
})
|
|
390
|
+
if (draft && draft.index >= tree.length) rows.push(...draftRows)
|
|
391
|
+
if (HAS_ALL_SCENES) rows.push(boardRow('all-scenes', null, -1, false)) // a published bundle without it shows none
|
|
392
|
+
return (
|
|
393
|
+
<div className="sh-boards" ref={rootRef} onContextMenu={(e: ReactMouseEvent) => blankMenu(e)}>
|
|
394
|
+
<div className="hd">
|
|
395
|
+
<span>Boards</span>
|
|
396
|
+
{/* the quiet way in: a folder-plus on the header (the right-click menu is the other) */}
|
|
397
|
+
{!PUBLISHED && (
|
|
398
|
+
<Tip side="bottom" label="New folder">
|
|
399
|
+
<FolderPlusIcon size={17} className="sh-hd-add" role="button" tabIndex={0} aria-label="New folder"
|
|
400
|
+
onClick={() => newFolderAt(treeRef.current.length)}
|
|
401
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); newFolderAt(treeRef.current.length) } }} />
|
|
402
|
+
</Tip>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
{rows}
|
|
406
|
+
</div>
|
|
407
|
+
)
|
|
408
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { useStore, SOURCE_REVEALED } from './store.ts'
|
|
4
|
+
|
|
5
|
+
/** Copy text to the clipboard, toasting the outcome. Success is confirmed out loud; a
|
|
6
|
+
* blocked clipboard (no user gesture / permission) says so instead of failing silently. */
|
|
7
|
+
export function copyToClipboard(text: string, okMsg: string) {
|
|
8
|
+
const { toast } = useStore.getState()
|
|
9
|
+
navigator.clipboard.writeText(text).then(() => toast(okMsg), () => toast('copy blocked - click the canvas first'))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The address a frame copies - the same string from the sidebar right-click, the floating
|
|
13
|
+
* toolbar, and ⇧P: the board it sits on, the frame id, and its file. */
|
|
14
|
+
// stripped build: the file column would only show the opaque token, so the
|
|
15
|
+
// copy keeps what is still honest - the board and the frame id
|
|
16
|
+
export const framePath = (board: string, f: { id: string; file: string }) =>
|
|
17
|
+
SOURCE_REVEALED ? `board: ${board} · frame: ${f.id} (${f.file})` : `board: ${board} · frame: ${f.id}`
|
|
18
|
+
|
|
19
|
+
export type MenuItem = { label: string; icon: ReactNode; onClick: () => void }
|
|
20
|
+
export type MenuState = { x: number; y: number; items: MenuItem[] }
|
|
21
|
+
export type MenuOpener = (e: { preventDefault(): void; clientX: number; clientY: number }, items: MenuItem[]) => void
|
|
22
|
+
|
|
23
|
+
/** A right-click menu for the sidebar. One instance lives in App; `open(e, items)` positions
|
|
24
|
+
* it at the cursor, CLAMPED into the viewport. It closes on outside pointerdown, on pick, and
|
|
25
|
+
* on Escape - the Escape listener runs in CAPTURE phase so it does not also trip App's global
|
|
26
|
+
* keydown (which would clear the selection/laser underneath the menu). */
|
|
27
|
+
export function useContextMenu() {
|
|
28
|
+
const [menu, setMenu] = useState<MenuState | null>(null)
|
|
29
|
+
const open: MenuOpener = (e, items) => {
|
|
30
|
+
e.preventDefault()
|
|
31
|
+
const MENU_W = 240 // the CSS max-width; labels ellipsize inside it
|
|
32
|
+
// a menu taller than the window (many "Move to …" folders) pins to the top edge and scrolls
|
|
33
|
+
const h = Math.min(items.length * 32 + 12, window.innerHeight - 16)
|
|
34
|
+
setMenu({ x: Math.max(8, Math.min(e.clientX, window.innerWidth - MENU_W - 8)), y: Math.max(8, Math.min(e.clientY, window.innerHeight - h - 8)), items })
|
|
35
|
+
}
|
|
36
|
+
return { menu, open, close: () => setMenu(null) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ContextMenu({ menu, close }: { menu: MenuState | null; close: () => void }) {
|
|
40
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!menu) return
|
|
43
|
+
const onDown = (e: PointerEvent) => { if (!ref.current?.contains(e.target as globalThis.Node)) close() }
|
|
44
|
+
const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close() } }
|
|
45
|
+
window.addEventListener('pointerdown', onDown)
|
|
46
|
+
window.addEventListener('keydown', onEsc, true) // capture: beat App's bubble-phase Escape
|
|
47
|
+
return () => { window.removeEventListener('pointerdown', onDown); window.removeEventListener('keydown', onEsc, true) }
|
|
48
|
+
}, [menu])
|
|
49
|
+
const app = document.querySelector('.sh-app')
|
|
50
|
+
if (!menu || !app) return null
|
|
51
|
+
return createPortal(
|
|
52
|
+
<div className="sh-menu sh-ctxmenu" ref={ref} style={{ left: menu.x, top: menu.y }}>
|
|
53
|
+
{menu.items.map((it) => (
|
|
54
|
+
<button key={it.label} onClick={() => { it.onClick(); close() }}>{it.icon}<span>{it.label}</span></button>
|
|
55
|
+
))}
|
|
56
|
+
</div>,
|
|
57
|
+
app,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -38,6 +38,7 @@ export const DeviceTabletIcon = icon(<path d="M192,24H64A24,24,0,0,0,40,48V208a2
|
|
|
38
38
|
export const LaptopIcon = icon(<path d="M232,168h-8V72a24,24,0,0,0-24-24H56A24,24,0,0,0,32,72v96H24a8,8,0,0,0-8,8v16a24,24,0,0,0,24,24H216a24,24,0,0,0,24-24V176A8,8,0,0,0,232,168ZM48,72a8,8,0,0,1,8-8H200a8,8,0,0,1,8,8v96H48ZM224,192a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8v-8H224ZM152,88a8,8,0,0,1-8,8H112a8,8,0,0,1,0-16h32A8,8,0,0,1,152,88Z" />)
|
|
39
39
|
export const MonitorIcon = icon(<path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8Zm-48,48a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,224Z" />)
|
|
40
40
|
export const TvIcon = icon(<path d="M216,64H147.31l34.35-34.34a8,8,0,1,0-11.32-11.32L128,60.69,85.66,18.34A8,8,0,0,0,74.34,29.66L108.69,64H40A16,16,0,0,0,24,80V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V80A16,16,0,0,0,216,64Zm0,136H40V80H216V200Z" />)
|
|
41
|
+
export const ImagesSquareIcon = icon(<path d="M208,32H80A16,16,0,0,0,64,48V64H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V192h16a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM80,48H208v69.38l-16.7-16.7a16,16,0,0,0-22.62,0L93.37,176H80Zm96,160H48V80H64v96a16,16,0,0,0,16,16h96Zm32-32H116l64-64,28,28v36Zm-88-64A24,24,0,1,0,96,88,24,24,0,0,0,120,112Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,120,80Z" />)
|
|
41
42
|
export const SignpostIcon = icon(<path d="M246,106.65,212.33,69.3A16,16,0,0,0,200.44,64H136V32a8,8,0,0,0-16,0V64H40A16,16,0,0,0,24,80v64a16,16,0,0,0,16,16h80v64a8,8,0,0,0,16,0V160h64.44a16,16,0,0,0,11.89-5.3L246,117.35A8,8,0,0,0,246,106.65ZM200.44,144H40V80H200.44l28.8,32Z" />)
|
|
42
43
|
export const ParallelogramDuoIcon = icon(<><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1" /><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z" /></>)
|
|
43
44
|
/** The marver mark, SOLID fill (for the Marver avatar - a filled white parallelogram on accent). */
|
|
@@ -89,3 +90,9 @@ export function deviceIcon(name: string | null, size = 14) {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
export const BrowsersIcon = icon(<path d="M216,40H72A16,16,0,0,0,56,56V72H40A16,16,0,0,0,24,88V200a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V184h16a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM184,88v16H40V88Zm0,112H40V120H184v80Zm32-32H200V88a16,16,0,0,0-16-16H72V56H216Z" />)
|
|
93
|
+
/** Sidebar folders (Phosphor regular): closed, open, plus (new), minus (delete). */
|
|
94
|
+
export const FolderIcon = icon(<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM40,56H92.69l16,16H40ZM216,200H40V88H216Z" />)
|
|
95
|
+
export const FolderOpenIcon = icon(<path d="M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V208h0a8,8,0,0,0,8,8H211.1a8,8,0,0,0,7.59-5.47l28.49-85.47A16.05,16.05,0,0,0,245,110.64ZM93.34,64,123.2,86.4A8,8,0,0,0,128,88h72v16H69.77a16,16,0,0,0-15.18,10.94L40,158.7V64Zm112,136H43.1l26.67-80H232Z" />)
|
|
96
|
+
export const FolderPlusIcon = icon(<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM92.69,56l16,16H40V56ZM216,200H40V88H216Zm-88-88a8,8,0,0,1,8,8v16h16a8,8,0,0,1,0,16H136v16a8,8,0,0,1-16,0V152H104a8,8,0,0,1,0-16h16V120A8,8,0,0,1,128,112Z" />)
|
|
97
|
+
export const FolderMinusIcon = icon(<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM92.69,56l16,16H40V56ZM216,200H40V88H216ZM104,136h48a8,8,0,0,1,0,16H104a8,8,0,0,1,0-16Z" />)
|
|
98
|
+
export const ArrowLineUpIcon = icon(<path d="M205.66,138.34a8,8,0,0,1-11.32,11.32L136,91.31V224a8,8,0,0,1-16,0V91.31L61.66,149.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0ZM216,32H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z" />)
|