@marver-design/marver 0.15.0 → 0.16.1
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 +101 -0
- package/README.md +2 -1
- package/dist/boards-BmxcT3Lc.mjs +290 -0
- package/dist/boards-PuVzw5Wp.mjs +62 -0
- package/dist/{build-DfuTQZlY.mjs → build-B4yPgFNF.mjs} +72 -29
- package/dist/cli.mjs +33 -13
- package/dist/{daemon-DalgvoA9.mjs → daemon-Bbh_jmui.mjs} +1 -1
- package/dist/{dev-BxCmeU_H.mjs → dev-DH2W7Ffw.mjs} +4 -4
- package/dist/{init-QKNi9gvF.mjs → init-B7YhcN2o.mjs} +6 -3
- package/dist/{manifest-BzxSMoDB.mjs → manifest-CaslQIAO.mjs} +175 -12
- package/dist/{plugin-DJyjmQeh.mjs → plugin-BeBGu3gH.mjs} +239 -82
- package/dist/{poster-CbpzSzJu.mjs → poster-CoyobbGW.mjs} +1 -1
- package/dist/{shot-BWhoz6cU.mjs → shot-z-d-zMzf.mjs} +2 -2
- package/docs/publish.md +4 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +45 -259
- package/src/client/shell/BoardList.tsx +438 -0
- package/src/client/shell/ContextMenu.tsx +59 -0
- package/src/client/shell/LockedApp.tsx +2 -1
- package/src/client/shell/Play.tsx +1 -0
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/labels.ts +4 -4
- package/src/client/shell/store.ts +156 -51
- package/src/client/shell/styles.css +40 -4
- package/src/shared/board-tree.ts +343 -0
- package/templates/AGENTS-embedded.md +25 -5
- package/templates/AGENTS-studio.md +25 -5
- package/templates/instructions/boards.md +91 -2
- package/templates/instructions/craft.md +4 -0
- package/templates/instructions/discover.md +20 -3
- package/templates/instructions/iterate.md +5 -4
- package/templates/instructions/review.md +4 -0
- package/templates/instructions/shape.md +1 -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
package/src/client/shell/App.tsx
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
|
|
2
|
+
import { useStore, CONFIG, LOCKED_SHELL, PUBLISHED, SOURCE_REVEALED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, hydrateBoardPolicy, landingMode, playsAsSlides, sceneLabel, type FrameEntry } from './store.ts'
|
|
3
|
+
import { BoardList } from './BoardList.tsx'
|
|
4
|
+
import { ContextMenu, copyToClipboard, framePath, useContextMenu, type MenuItem } from './ContextMenu.tsx'
|
|
4
5
|
import { Tip } from './Tip.tsx'
|
|
5
6
|
import { PKG, ROUTE } from '../const.ts'
|
|
6
7
|
import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
|
|
7
8
|
import { frameByWindow } from './canvas/frame-registry.ts'
|
|
8
9
|
import { enterFocus, enterPlay, enterSlides, playCtl, PlayOverlay } from './Play.tsx'
|
|
9
10
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
10
|
-
import {
|
|
11
|
+
import { CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, ImagesSquareIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
12
|
+
import { humanize as slugLabel, readTitle } from '../../shared/board-tree.ts'
|
|
11
13
|
import { CommentsController, revealThread } from './Comments.tsx'
|
|
12
14
|
import { poweredByUrl } from '../../shared/utm.ts'
|
|
13
15
|
import { avatarFallback, useComments } from './comments-store.ts'
|
|
@@ -17,72 +19,32 @@ const commentsStore = () => useComments.getState()
|
|
|
17
19
|
|
|
18
20
|
let booted = false // survives Fast Refresh; see the boot effect
|
|
19
21
|
|
|
20
|
-
/** Copy text to the clipboard, toasting the outcome. Success is confirmed out loud; a
|
|
21
|
-
* blocked clipboard (no user gesture / permission) says so instead of failing silently. */
|
|
22
|
-
function copyToClipboard(text: string, okMsg: string) {
|
|
23
|
-
const { toast } = useStore.getState()
|
|
24
|
-
navigator.clipboard.writeText(text).then(() => toast(okMsg), () => toast('copy blocked - click the canvas first'))
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** The address a frame copies - the same string from the sidebar right-click, the floating
|
|
28
|
-
* toolbar, and ⇧P: the board it sits on, the frame id, and its file. */
|
|
29
|
-
// stripped build: the file column would only show the opaque token, so the
|
|
30
|
-
// copy keeps what is still honest - the board and the frame id
|
|
31
|
-
const framePath = (board: string, f: { id: string; file: string }) =>
|
|
32
|
-
SOURCE_REVEALED ? `board: ${board} · frame: ${f.id} (${f.file})` : `board: ${board} · frame: ${f.id}`
|
|
33
|
-
|
|
34
|
-
type MenuItem = { label: string; icon: ReactNode; onClick: () => void }
|
|
35
|
-
type MenuState = { x: number; y: number; items: MenuItem[] }
|
|
36
|
-
|
|
37
|
-
/** A right-click menu for the sidebar. One instance lives in App; `open(e, items)` positions
|
|
38
|
-
* it at the cursor, CLAMPED into the viewport. It closes on outside pointerdown, on pick, and
|
|
39
|
-
* on Escape - the Escape listener runs in CAPTURE phase so it does not also trip App's global
|
|
40
|
-
* keydown (which would clear the selection/laser underneath the menu). */
|
|
41
|
-
function useContextMenu() {
|
|
42
|
-
const [menu, setMenu] = useState<MenuState | null>(null)
|
|
43
|
-
const open = (e: { preventDefault(): void; clientX: number; clientY: number }, items: MenuItem[]) => {
|
|
44
|
-
e.preventDefault()
|
|
45
|
-
const MENU_W = 184
|
|
46
|
-
const h = items.length * 32 + 12
|
|
47
|
-
setMenu({ x: Math.min(e.clientX, window.innerWidth - MENU_W - 8), y: Math.min(e.clientY, window.innerHeight - h - 8), items })
|
|
48
|
-
}
|
|
49
|
-
return { menu, open, close: () => setMenu(null) }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function ContextMenu({ menu, close }: { menu: MenuState | null; close: () => void }) {
|
|
53
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (!menu) return
|
|
56
|
-
const onDown = (e: PointerEvent) => { if (!ref.current?.contains(e.target as globalThis.Node)) close() }
|
|
57
|
-
const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close() } }
|
|
58
|
-
window.addEventListener('pointerdown', onDown)
|
|
59
|
-
window.addEventListener('keydown', onEsc, true) // capture: beat App's bubble-phase Escape
|
|
60
|
-
return () => { window.removeEventListener('pointerdown', onDown); window.removeEventListener('keydown', onEsc, true) }
|
|
61
|
-
}, [menu])
|
|
62
|
-
const app = document.querySelector('.sh-app')
|
|
63
|
-
if (!menu || !app) return null
|
|
64
|
-
return createPortal(
|
|
65
|
-
<div className="sh-menu sh-ctxmenu" ref={ref} style={{ left: menu.x, top: menu.y }}>
|
|
66
|
-
{menu.items.map((it) => (
|
|
67
|
-
<button key={it.label} onClick={() => { it.onClick(); close() }}>{it.icon}<span>{it.label}</span></button>
|
|
68
|
-
))}
|
|
69
|
-
</div>,
|
|
70
|
-
app,
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
22
|
/** One collapsible scene group in the sidebar. `held` marks a scene that contains a
|
|
75
|
-
* selected frame - a quiet secondary wash so ancestry survives collapsing the group.
|
|
76
|
-
|
|
23
|
+
* selected frame - a quiet secondary wash so ancestry survives collapsing the group.
|
|
24
|
+
* `renaming` swaps the label for an input: the scene's TITLE (its brief's front matter) -
|
|
25
|
+
* the directory never moves from here. */
|
|
26
|
+
function SceneGroup({ name, label, count, held, renaming, onRename, onPick, onContextMenu, children }: { name: string; label: string; count: number; held: boolean; renaming?: boolean; onRename?: (title: string | null) => void; onPick?: () => void; onContextMenu?: (e: ReactMouseEvent) => void; children: ReactNode }) {
|
|
77
27
|
const [open, setOpen] = useState(true)
|
|
28
|
+
const done = useRef(false) // Enter then blur: one commit
|
|
29
|
+
if (renaming) { done.current = false }
|
|
30
|
+
const finish = (v: string | null) => { if (done.current) return; done.current = true; onRename?.(v) }
|
|
78
31
|
return (
|
|
79
32
|
<div>
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
33
|
+
{renaming ? (
|
|
34
|
+
<div className="it editing">
|
|
35
|
+
<CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
|
|
36
|
+
<input autoFocus defaultValue={label} spellCheck={false} onFocus={(e) => e.currentTarget.select()}
|
|
37
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); finish(e.currentTarget.value) } else if (e.key === 'Escape') { e.preventDefault(); finish(null) } }}
|
|
38
|
+
onBlur={(e) => finish(e.currentTarget.value)} />
|
|
39
|
+
</div>
|
|
40
|
+
) : (
|
|
41
|
+
<button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)} onContextMenu={onContextMenu}>
|
|
42
|
+
<CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
|
|
43
|
+
{/* the NAME selects every frame in the scene; the caret/row still collapses */}
|
|
44
|
+
<span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{label || '(root)'}</span>
|
|
45
|
+
<small>{count}</small>
|
|
46
|
+
</button>
|
|
47
|
+
)}
|
|
86
48
|
{open && children}
|
|
87
49
|
</div>
|
|
88
50
|
)
|
|
@@ -104,194 +66,6 @@ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Err
|
|
|
104
66
|
}
|
|
105
67
|
}
|
|
106
68
|
|
|
107
|
-
/** Boards live flat in the sidebar - always visible, one click to switch. The list
|
|
108
|
-
* refreshes on mount, window focus, and a slow poll so agent-created board files
|
|
109
|
-
* appear without a reload. Active board = accent icon + wash, same language as scenes. */
|
|
110
|
-
const BOARD_NAME_RE = /^[a-z0-9][a-z0-9-]*$/
|
|
111
|
-
|
|
112
|
-
function BoardList({ onMenu }: { onMenu: (e: { preventDefault(): void; clientX: number; clientY: number }, items: MenuItem[]) => void }) {
|
|
113
|
-
const board = useStore((s) => s.board)
|
|
114
|
-
const [names, setNames] = useState<string[]>(['all-scenes'])
|
|
115
|
-
const [editing, setEditing] = useState<string | null>(null)
|
|
116
|
-
const [drag, setDrag] = useState<string | null>(null) // board being dragged
|
|
117
|
-
// insertion index in the CURATED list (0..len): one value per gap, so the divider lands
|
|
118
|
-
// once between two boards - not twice (A's bottom-half and B's top-half were separate spots)
|
|
119
|
-
const [dropIndex, setDropIndex] = useState<number | null>(null)
|
|
120
|
-
// genRef invalidates every async setNames: a mutation bumps it, so a poll/fetch that
|
|
121
|
-
// STARTED earlier can never clobber a fresh optimistic reorder or a just-renamed list.
|
|
122
|
-
const genRef = useRef(0)
|
|
123
|
-
const busyRef = useRef(0) // reorders in flight; while >0 polls hold off
|
|
124
|
-
const commitBusy = useRef(false) // guards Enter+blur firing two renames
|
|
125
|
-
const chainRef = useRef<Promise<unknown>>(Promise.resolve()) // serializes reorder POSTs
|
|
126
|
-
// reorder runs on pointer events, NOT native drag-and-drop: native DnD hands the cursor to
|
|
127
|
-
// the OS (arrow/move), so a grabbing hand can't persist. Owning the gesture lets us hold
|
|
128
|
-
// the grabbing cursor for the whole drag via a body class.
|
|
129
|
-
const gestureRef = useRef<{ pointerId: number; startX: number; startY: number; name: string; dragging: boolean; el: HTMLElement } | null>(null)
|
|
130
|
-
const refresh = () => {
|
|
131
|
-
if (busyRef.current > 0) return // a reorder is mid-flight; its optimistic order stands until it settles
|
|
132
|
-
const gen = genRef.current
|
|
133
|
-
fetchBoardNames().then((list) => { if (gen === genRef.current) setNames(list) }).catch(() => { /* keep last known */ })
|
|
134
|
-
}
|
|
135
|
-
useEffect(() => {
|
|
136
|
-
refresh()
|
|
137
|
-
const t = setInterval(refresh, 8000)
|
|
138
|
-
window.addEventListener('focus', refresh)
|
|
139
|
-
return () => { clearInterval(t); window.removeEventListener('focus', refresh) }
|
|
140
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
141
|
-
}, [])
|
|
142
|
-
const pick = async (name: string) => {
|
|
143
|
-
if (name === useStore.getState().board) return
|
|
144
|
-
await useStore.getState().switchBoard(name)
|
|
145
|
-
setTimeout(() => canvasCtl.fitAll(), 60)
|
|
146
|
-
}
|
|
147
|
-
const commit = async (old: string, raw: string) => {
|
|
148
|
-
if (commitBusy.current) return // Enter already fired this; ignore the follow-up blur
|
|
149
|
-
const next = raw.trim()
|
|
150
|
-
if (!next || next === old) { setEditing(null); return }
|
|
151
|
-
if (!BOARD_NAME_RE.test(next) || next.length > 64 || next === 'all-scenes' || names.includes(next)) {
|
|
152
|
-
useStore.getState().toast('use a free name - lowercase letters, numbers and dashes'); return // stay editing
|
|
153
|
-
}
|
|
154
|
-
commitBusy.current = true
|
|
155
|
-
try {
|
|
156
|
-
const r = await useStore.getState().renameBoard(old, next)
|
|
157
|
-
if (r.ok) { setEditing(null); genRef.current++; refresh() }
|
|
158
|
-
else useStore.getState().toast(r.error ?? 'rename failed') // stay editing
|
|
159
|
-
} finally { commitBusy.current = false }
|
|
160
|
-
}
|
|
161
|
-
// the INSERTION INDEX (in the curated list, 0..len) nearest the pointer - one value per
|
|
162
|
-
// gap, so a single divider lands between two boards instead of one per row-half
|
|
163
|
-
const indexAt = (x: number, y: number): number | null => {
|
|
164
|
-
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-board-row]') as HTMLElement | null
|
|
165
|
-
const name = el?.dataset.board
|
|
166
|
-
if (!name) return null
|
|
167
|
-
const curated = names.filter((n) => n !== 'all-scenes')
|
|
168
|
-
if (name === 'all-scenes') return curated.length // over the pinned last row = end slot
|
|
169
|
-
const ci = curated.indexOf(name)
|
|
170
|
-
if (ci < 0) return null
|
|
171
|
-
const r = el!.getBoundingClientRect()
|
|
172
|
-
return y > r.top + r.height / 2 ? ci + 1 : ci
|
|
173
|
-
}
|
|
174
|
-
const resetPointer = () => {
|
|
175
|
-
const g = gestureRef.current
|
|
176
|
-
gestureRef.current = null
|
|
177
|
-
if (g) { try { g.el.releasePointerCapture(g.pointerId) } catch { /* already released */ } }
|
|
178
|
-
document.body.classList.remove('sh-board-dragging')
|
|
179
|
-
setDrag(null)
|
|
180
|
-
setDropIndex(null)
|
|
181
|
-
}
|
|
182
|
-
// move the dragged board to the insertion index and persist the new order optimistically
|
|
183
|
-
const applyReorder = (dragName: string, dropIndex: number) => {
|
|
184
|
-
if (dragName === 'all-scenes') return
|
|
185
|
-
const curated = names.filter((n) => n !== 'all-scenes')
|
|
186
|
-
const from = curated.indexOf(dragName)
|
|
187
|
-
if (from < 0) return
|
|
188
|
-
const to = dropIndex > from ? dropIndex - 1 : dropIndex // removing `from` shifts later indices left
|
|
189
|
-
if (to === from) return // dropped in its own slot - no move
|
|
190
|
-
curated.splice(from, 1)
|
|
191
|
-
curated.splice(to, 0, dragName)
|
|
192
|
-
const prev = names
|
|
193
|
-
genRef.current++
|
|
194
|
-
const gen = genRef.current
|
|
195
|
-
busyRef.current++ // hold polls off until this settles
|
|
196
|
-
setNames(names.includes('all-scenes') ? [...curated, 'all-scenes'] : curated)
|
|
197
|
-
chainRef.current = chainRef.current.then(async () => {
|
|
198
|
-
try {
|
|
199
|
-
const ok = await useStore.getState().reorderBoards(curated)
|
|
200
|
-
if (gen !== genRef.current) return // a newer drop superseded this one
|
|
201
|
-
if (!ok) { setNames(prev); useStore.getState().toast('could not save order') }
|
|
202
|
-
} catch { if (gen === genRef.current) { setNames(prev); useStore.getState().toast('could not save order') } }
|
|
203
|
-
finally { busyRef.current-- }
|
|
204
|
-
})
|
|
205
|
-
}
|
|
206
|
-
const onBoardPointerDown = (e: ReactPointerEvent<HTMLButtonElement>, n: string) => {
|
|
207
|
-
if (e.button !== 0) return // left button only; right-click opens the menu
|
|
208
|
-
const el = e.currentTarget
|
|
209
|
-
try { el.setPointerCapture(e.pointerId) } catch { /* capture can fail on rapid input */ }
|
|
210
|
-
gestureRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, name: n, dragging: false, el }
|
|
211
|
-
}
|
|
212
|
-
const onBoardPointerMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
213
|
-
const g = gestureRef.current
|
|
214
|
-
if (!g || g.pointerId !== e.pointerId) return
|
|
215
|
-
if (!g.dragging) {
|
|
216
|
-
if (Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < 5) return // click vs drag threshold
|
|
217
|
-
g.dragging = true
|
|
218
|
-
setDrag(g.name)
|
|
219
|
-
document.body.classList.add('sh-board-dragging') // holds the grabbing cursor for the whole drag
|
|
220
|
-
}
|
|
221
|
-
const idx = indexAt(e.clientX, e.clientY)
|
|
222
|
-
// hide the seam at the dragged board's OWN slot (index from or from+1 = no move)
|
|
223
|
-
const from = names.filter((nm) => nm !== 'all-scenes').indexOf(g.name)
|
|
224
|
-
setDropIndex(idx === null || idx === from || idx === from + 1 ? null : idx)
|
|
225
|
-
}
|
|
226
|
-
const onBoardPointerUp = (e: ReactPointerEvent<HTMLButtonElement>, n: string) => {
|
|
227
|
-
const g = gestureRef.current
|
|
228
|
-
if (!g || g.pointerId !== e.pointerId) return
|
|
229
|
-
const dragged = g.dragging
|
|
230
|
-
const idx = dragged ? indexAt(e.clientX, e.clientY) : null
|
|
231
|
-
resetPointer()
|
|
232
|
-
if (dragged) { if (idx !== null) applyReorder(n, idx) }
|
|
233
|
-
else void pick(n) // a tap switches boards (the trailing mouse click is ignored)
|
|
234
|
-
}
|
|
235
|
-
// cancel a drag on Escape (capture phase, so the app's global Escape never sees it) or focus loss
|
|
236
|
-
useEffect(() => {
|
|
237
|
-
const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape' && gestureRef.current?.dragging) { e.preventDefault(); e.stopPropagation(); resetPointer() } }
|
|
238
|
-
const onBlur = () => { if (gestureRef.current) resetPointer() }
|
|
239
|
-
window.addEventListener('keydown', onEsc, true)
|
|
240
|
-
window.addEventListener('blur', onBlur)
|
|
241
|
-
return () => { window.removeEventListener('keydown', onEsc, true); window.removeEventListener('blur', onBlur); resetPointer() }
|
|
242
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
243
|
-
}, [])
|
|
244
|
-
const menuFor = (n: string): MenuItem[] => [
|
|
245
|
-
{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`board: ${n}`, 'path copied') },
|
|
246
|
-
...(!PUBLISHED && n !== 'all-scenes' ? [{ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setEditing(n) }] : []),
|
|
247
|
-
]
|
|
248
|
-
const curated = names.filter((nm) => nm !== 'all-scenes')
|
|
249
|
-
return (
|
|
250
|
-
<>
|
|
251
|
-
{names.map((n) => {
|
|
252
|
-
if (editing === n) return (
|
|
253
|
-
<div key={n} className="it board editing">
|
|
254
|
-
{n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
|
|
255
|
-
<input autoFocus defaultValue={n} spellCheck={false}
|
|
256
|
-
onFocus={(e) => e.currentTarget.select()}
|
|
257
|
-
onKeyDown={(e) => {
|
|
258
|
-
if (e.key === 'Enter') { e.preventDefault(); void commit(n, e.currentTarget.value) }
|
|
259
|
-
else if (e.key === 'Escape') { e.preventDefault(); setEditing(null) }
|
|
260
|
-
}}
|
|
261
|
-
onBlur={(e) => { if (editing === n) void commit(n, e.currentTarget.value) }} />
|
|
262
|
-
</div>
|
|
263
|
-
)
|
|
264
|
-
const canDrag = !PUBLISHED && n !== 'all-scenes'
|
|
265
|
-
// ONE seam per gap, from the insertion index: drop-before the row AT that index, or
|
|
266
|
-
// drop-after the last curated row when the index is the end. Overlay (::after), so no
|
|
267
|
-
// layout shift. Never on the row being dragged.
|
|
268
|
-
const ci = curated.indexOf(n)
|
|
269
|
-
const dropCls = drag == null || dropIndex == null || n === drag ? ''
|
|
270
|
-
: ci === dropIndex ? ' drop-before'
|
|
271
|
-
: dropIndex === curated.length && ci === curated.length - 1 ? ' drop-after'
|
|
272
|
-
: ''
|
|
273
|
-
return (
|
|
274
|
-
<button key={n} data-board-row data-board={n} data-reorderable={canDrag || undefined}
|
|
275
|
-
className={`it board${n === board ? ' cur' : ''}${drag === n ? ' dragging' : ''}${dropCls}`}
|
|
276
|
-
// draggable rows switch on the pointer tap (onPointerUp), so their trailing mouse
|
|
277
|
-
// click (detail >= 1) must be ignored to avoid a double switch; keyboard clicks
|
|
278
|
-
// (detail === 0) and non-draggable rows (all-scenes) switch here as normal
|
|
279
|
-
onClick={(e) => { if (!canDrag || e.detail === 0) void pick(n) }}
|
|
280
|
-
onContextMenu={(e) => onMenu(e, menuFor(n))}
|
|
281
|
-
onPointerDown={canDrag ? (e) => onBoardPointerDown(e, n) : undefined}
|
|
282
|
-
onPointerMove={canDrag ? onBoardPointerMove : undefined}
|
|
283
|
-
onPointerUp={canDrag ? (e) => onBoardPointerUp(e, n) : undefined}
|
|
284
|
-
onPointerCancel={canDrag ? () => resetPointer() : undefined}
|
|
285
|
-
onLostPointerCapture={canDrag ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
|
|
286
|
-
{n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
|
|
287
|
-
<span>{boardLabel(n)}</span>
|
|
288
|
-
</button>
|
|
289
|
-
)
|
|
290
|
-
})}
|
|
291
|
-
</>
|
|
292
|
-
)
|
|
293
|
-
}
|
|
294
|
-
|
|
295
69
|
/** Selection toolbar: screen-space overlay above the selected frame - constant size at any
|
|
296
70
|
* zoom. Position derives from --sh-s/tx/ty (written per transform frame in Canvas), so
|
|
297
71
|
* pan/zoom tracking is pure CSS with zero React re-renders. */
|
|
@@ -566,6 +340,7 @@ export function App() {
|
|
|
566
340
|
const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
|
|
567
341
|
const [pillOpen, setPillOpen] = useState(true)
|
|
568
342
|
const cm = useContextMenu() // shared sidebar right-click menu (boards + scenes)
|
|
343
|
+
const [sceneNaming, setSceneNaming] = useState<string | null>(null) // the scene whose title is being edited inline
|
|
569
344
|
|
|
570
345
|
// boot honors the deep link: board before load, play mode after it.
|
|
571
346
|
// Selection + camera intent are restored by the Canvas boot effect. The module-level
|
|
@@ -672,9 +447,10 @@ export function App() {
|
|
|
672
447
|
return () => { window.removeEventListener('popstate', onPop); window.removeEventListener('hashchange', onPop) }
|
|
673
448
|
}, [])
|
|
674
449
|
|
|
675
|
-
// page title follows the open board
|
|
450
|
+
// page title follows the open board (and its title, when one is set)
|
|
676
451
|
const board = useStore((s) => s.board)
|
|
677
|
-
|
|
452
|
+
const titles = useStore((s) => s.boardTitles)
|
|
453
|
+
useEffect(() => { document.title = board ? `${boardLabel(board)} - Marver` : 'Marver' }, [board, titles])
|
|
678
454
|
|
|
679
455
|
// favicon follows the mode: blue pack in design mode, purple pack in interact.
|
|
680
456
|
// The links are rebuilt (not toggled) so the set stays deterministic; the .ico is
|
|
@@ -725,6 +501,8 @@ export function App() {
|
|
|
725
501
|
useEffect(() => {
|
|
726
502
|
if (!import.meta.hot) return
|
|
727
503
|
import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
|
|
504
|
+
// a brief's title or description changed with the frames intact: relabel, never re-key
|
|
505
|
+
import.meta.hot.on('sh:scenes', (m: any) => useStore.getState().setScenes(m?.scenes))
|
|
728
506
|
// Live Jam presence: the daemon broadcasts the set of frames Marver is editing.
|
|
729
507
|
// Camera-safe by construction - this only toggles a glow class, never moves the view.
|
|
730
508
|
import.meta.hot.on('sh:jam-activity', (m: any) => useStore.getState().setWorking(Array.isArray(m?.frames) ? m.frames.filter((x: unknown) => typeof x === 'string') : []))
|
|
@@ -1017,19 +795,27 @@ export function App() {
|
|
|
1017
795
|
<Tip side="bottom" label={<><b>Collapse panel</b><span>⌘\</span></>}><button className="sh-ibtn" onClick={togglePanel} tabIndex={panelOpen ? 0 : -1}><PanelFilledIcon size={17} /></button></Tip>
|
|
1018
796
|
</div>
|
|
1019
797
|
<div className="sh-panel-scroll">
|
|
1020
|
-
<div className="hd">Boards</div>
|
|
1021
798
|
<BoardList onMenu={cm.open} />
|
|
1022
799
|
<div className="hd" style={{ marginTop: 10 }}>Scenes</div>
|
|
1023
800
|
{scenes.map((sc) => (
|
|
1024
|
-
<SceneGroup key={sc.name} name={sc.name} count={sc.frames}
|
|
801
|
+
<SceneGroup key={sc.name} name={sc.name} label={sceneLabel(sc.name)} count={sc.frames}
|
|
1025
802
|
held={frames.some((f) => f.scene === sc.name && selFrames.has(f.id))}
|
|
803
|
+
renaming={sceneNaming === sc.name}
|
|
804
|
+
onRename={(raw) => {
|
|
805
|
+
setSceneNaming(null)
|
|
806
|
+
const title = raw === null ? undefined : readTitle(raw)
|
|
807
|
+
if (!title || title === sceneLabel(sc.name)) return // Escape, empty, or unchanged = never mind
|
|
808
|
+
if (scenes.some((o) => o.name !== sc.name && sceneLabel(o.name) === title)) { toast(`a scene called "${title}" already exists`); return }
|
|
809
|
+
// typing what the directory reads as anyway clears the title - the brief stays clean
|
|
810
|
+
useStore.getState().renameScene(sc.name, title === slugLabel(sc.name) ? '' : title).then((r) => { if (!r.ok) toast(r.error ?? 'rename failed') })
|
|
811
|
+
}}
|
|
1026
812
|
onContextMenu={(e) => cm.open(e, [{
|
|
1027
813
|
label: 'Copy path',
|
|
1028
814
|
icon: <SignpostIcon size={15} />,
|
|
1029
815
|
onClick: () => copyToClipboard(sc.name
|
|
1030
816
|
? `board: ${useStore.getState().board} · scene: ${sc.name} (design/scenes/${sc.name}/)`
|
|
1031
817
|
: `board: ${useStore.getState().board} · scene: (root)`, 'path copied'),
|
|
1032
|
-
}])}
|
|
818
|
+
}, ...(!PUBLISHED && sc.name ? [{ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setSceneNaming(sc.name) }] : [])])}
|
|
1033
819
|
onPick={() => {
|
|
1034
820
|
const keys = nodes.filter((n) => frames.some((f) => f.scene === sc.name && f.id === n.frame) && !n.missing).map((n) => n.key)
|
|
1035
821
|
if (!keys.length) return
|