@marver-design/marver 0.15.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 +75 -0
- package/README.md +2 -1
- package/dist/boards-6hKVW42a.mjs +57 -0
- package/dist/boards-BdG1TJwU.mjs +267 -0
- package/dist/{build-DfuTQZlY.mjs → build-Ct0iMoSi.mjs} +68 -29
- package/dist/cli.mjs +13 -4
- package/dist/{daemon-DalgvoA9.mjs → daemon-Cb4JjpgL.mjs} +1 -1
- package/dist/{dev-BxCmeU_H.mjs → dev-CdcIuhZJ.mjs} +4 -4
- package/dist/{init-QKNi9gvF.mjs → init-BQIpIpIv.mjs} +6 -3
- package/dist/{manifest-BzxSMoDB.mjs → manifest-CpbsqQ_v.mjs} +77 -11
- package/dist/{plugin-DJyjmQeh.mjs → plugin-BXyezwfN.mjs} +164 -69
- package/dist/{poster-CbpzSzJu.mjs → poster-DOY7pax8.mjs} +1 -1
- package/dist/{shot-BWhoz6cU.mjs → shot-CwmHO5T4.mjs} +2 -2
- package/docs/publish.md +4 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +4 -246
- package/src/client/shell/BoardList.tsx +408 -0
- package/src/client/shell/ContextMenu.tsx +59 -0
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/store.ts +105 -47
- package/src/client/shell/styles.css +36 -4
- package/src/shared/board-tree.ts +285 -0
- package/templates/AGENTS-embedded.md +18 -5
- package/templates/AGENTS-studio.md +18 -5
- package/templates/instructions/boards.md +73 -2
- package/templates/instructions/craft.md +4 -0
- package/templates/instructions/discover.md +7 -2
- 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,14 @@
|
|
|
1
|
-
import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
1
|
+
import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
|
|
3
2
|
import { useStore, CONFIG, LOCKED_SHELL, PUBLISHED, SOURCE_REVEALED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, hydrateBoardPolicy, landingMode, playsAsSlides, 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, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
11
12
|
import { CommentsController, revealThread } from './Comments.tsx'
|
|
12
13
|
import { poweredByUrl } from '../../shared/utm.ts'
|
|
13
14
|
import { avatarFallback, useComments } from './comments-store.ts'
|
|
@@ -17,60 +18,6 @@ const commentsStore = () => useComments.getState()
|
|
|
17
18
|
|
|
18
19
|
let booted = false // survives Fast Refresh; see the boot effect
|
|
19
20
|
|
|
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
21
|
/** One collapsible scene group in the sidebar. `held` marks a scene that contains a
|
|
75
22
|
* selected frame - a quiet secondary wash so ancestry survives collapsing the group. */
|
|
76
23
|
function SceneGroup({ name, count, held, onPick, onContextMenu, children }: { name: string; count: number; held: boolean; onPick?: () => void; onContextMenu?: (e: ReactMouseEvent) => void; children: ReactNode }) {
|
|
@@ -104,194 +51,6 @@ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Err
|
|
|
104
51
|
}
|
|
105
52
|
}
|
|
106
53
|
|
|
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
54
|
/** Selection toolbar: screen-space overlay above the selected frame - constant size at any
|
|
296
55
|
* zoom. Position derives from --sh-s/tx/ty (written per transform frame in Canvas), so
|
|
297
56
|
* pan/zoom tracking is pure CSS with zero React re-renders. */
|
|
@@ -1017,7 +776,6 @@ export function App() {
|
|
|
1017
776
|
<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
777
|
</div>
|
|
1019
778
|
<div className="sh-panel-scroll">
|
|
1020
|
-
<div className="hd">Boards</div>
|
|
1021
779
|
<BoardList onMenu={cm.open} />
|
|
1022
780
|
<div className="hd" style={{ marginTop: 10 }}>Scenes</div>
|
|
1023
781
|
{scenes.map((sc) => (
|