@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
|
@@ -1,31 +1,85 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Video
|
|
2
|
+
* Video - poster-first, glass-controlled, in EVERY kind of frame.
|
|
3
3
|
*
|
|
4
4
|
* At rest there is NO <video> element and no media fetch: the poster <img>
|
|
5
|
-
* plus a play glyph IS the
|
|
6
|
-
* pin the frame live).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* plus a play glyph IS the frame (lean-DOM safe - a <video> element would
|
|
6
|
+
* pin the frame live). The real element mounts with the glass control strip
|
|
7
|
+
* (play/pause, seekable progress, mute, fullscreen, auto-hiding) in two ways:
|
|
8
|
+
* - anywhere a frame is live (interact mode, play/present, focus, a
|
|
9
|
+
* published prototype): the poster is the play button - one click mounts
|
|
10
|
+
* the player and starts it, inside that gesture, so sound is allowed;
|
|
11
|
+
* - in slides mode (useSlidePlay): the player mounts on its own, the deck's
|
|
12
|
+
* entrance contract.
|
|
13
|
+
* `autoplay` is the third shape - an ambient loop (muted, no strip) for a hero
|
|
14
|
+
* or a product mockup; it mounts the element as soon as the frame is live,
|
|
15
|
+
* which keeps that frame live on the canvas (the serializer marks <video>
|
|
16
|
+
* degraded) - an explicit, per-video choice.
|
|
17
|
+
* Leaving (play flips off / unmount) pauses everything.
|
|
10
18
|
*
|
|
11
|
-
* Sources: a design-asset path
|
|
12
|
-
*
|
|
13
|
-
*
|
|
19
|
+
* Sources: a design-asset path or a public https direct-file URL (mp4/webm;
|
|
20
|
+
* HLS where the browser supports it natively). The poster is the frame at
|
|
21
|
+
* rest: authored, or - omitted on a local clip - the generated
|
|
22
|
+
* `<clip>.poster.png` beside it (server/poster.ts renders it; the primitive
|
|
23
|
+
* asks the dev server once when the file is missing). `ratio` is the CSS aspect-ratio of the box
|
|
24
|
+
* (default 16 / 9; "9 / 16" for a vertical clip inside a phone screen).
|
|
14
25
|
*/
|
|
15
|
-
import { useEffect, useRef, useState } from 'react'
|
|
26
|
+
import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'
|
|
27
|
+
import { ROUTE } from '../const.ts'
|
|
16
28
|
import { assetUrl } from './md.ts'
|
|
17
29
|
import { useSlidePlay } from './slide.tsx'
|
|
18
30
|
|
|
19
31
|
const isRemote = (s: string) => /^https:\/\//.test(s)
|
|
20
32
|
const resolve = (s: string): string | null => (isRemote(s) ? s : assetUrl(s))
|
|
33
|
+
/** The generated poster's conventional name (server/poster.ts owns the rule). */
|
|
34
|
+
const posterNameFor = (src: string) => `${src}.poster.png`
|
|
35
|
+
|
|
36
|
+
// One generation request per clip per document: the dev server renders the poster from
|
|
37
|
+
// the clip's own first moments (server/poster.ts) and the <img> reloads. A published canvas
|
|
38
|
+
// has no such API (build already generated the file); a failed request just keeps the card.
|
|
39
|
+
const requested = new Map<string, Promise<boolean>>()
|
|
40
|
+
const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
|
|
41
|
+
// in-flight probes + generations, for the shot renderer's settle (like __mvLodBusy): a
|
|
42
|
+
// screenshot taken while a poster is still being rendered would show the empty box
|
|
43
|
+
let busy = 0
|
|
44
|
+
if (typeof window !== 'undefined') (window as { __mvPosterBusy?: () => number }).__mvPosterBusy = () => busy
|
|
45
|
+
const requestPoster = (src: string): Promise<boolean> => {
|
|
46
|
+
let p = requested.get(src)
|
|
47
|
+
if (!p) {
|
|
48
|
+
p = (async () => {
|
|
49
|
+
// the owner gate wants the double-submit cookie echoed; a frame host opened cold has not
|
|
50
|
+
// made an API GET yet, so prime it with the cheapest one first
|
|
51
|
+
if (!csrf()) await fetch(`${ROUTE}/api/policy`).catch(() => null)
|
|
52
|
+
const r = await fetch(`${ROUTE}/api/poster?src=${encodeURIComponent(src)}`, { headers: { 'x-mv-c': csrf() } }).catch(() => null)
|
|
53
|
+
return !!r?.ok
|
|
54
|
+
})()
|
|
55
|
+
requested.set(src, p)
|
|
56
|
+
// a failure is not forever: the next mount (a fix, Chrome installed) may ask again
|
|
57
|
+
void p.then((ok) => { if (!ok) requested.delete(src) })
|
|
58
|
+
}
|
|
59
|
+
return p
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Is the human in the canvas's interact mode on this frame? The bridge mirrors sh:interactive
|
|
63
|
+
* on the document. Absent everywhere else (play, focus, published) - only the true->false
|
|
64
|
+
* transition matters: it disarms a playing clip when the mode is left. */
|
|
65
|
+
const subscribeInteractive = (cb: () => void) => {
|
|
66
|
+
if (typeof document === 'undefined') return () => {}
|
|
67
|
+
const mo = new MutationObserver(cb)
|
|
68
|
+
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-sh-interactive'] })
|
|
69
|
+
return () => mo.disconnect()
|
|
70
|
+
}
|
|
71
|
+
const readInteractive = () => typeof document !== 'undefined' && document.documentElement.hasAttribute('data-sh-interactive')
|
|
72
|
+
const useInteractive = (): boolean => useSyncExternalStore(subscribeInteractive, readInteractive, () => false)
|
|
21
73
|
|
|
22
74
|
const VIDEO_CSS = `
|
|
23
|
-
.mv-video { position: relative; width: 100%; border-radius: 14px; overflow: hidden; background: #000; aspect-ratio: 16 / 9 }
|
|
75
|
+
.mv-video { position: relative; width: 100%; border-radius: 14px; overflow: hidden; background: #000; aspect-ratio: var(--mv-video-ratio, 16 / 9) }
|
|
76
|
+
.mv-video.armed { cursor: pointer }
|
|
77
|
+
.mv-video.armed:hover .glyph span { background: rgba(16, 16, 20, .75) }
|
|
24
78
|
.mv-video > img, .mv-video > video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block }
|
|
25
79
|
.mv-video .glyph { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none }
|
|
26
80
|
.mv-video .glyph span { width: 72px; height: 72px; border-radius: 999px; display: flex; align-items: center; justify-content: center;
|
|
27
81
|
background: rgba(16, 16, 20, .55); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, .28) }
|
|
28
|
-
.mv-video .glyph svg { margin-left:
|
|
82
|
+
.mv-video .glyph svg { margin-left: 2px }
|
|
29
83
|
.mv-video .strip { position: absolute; left: 10px; right: 10px; bottom: 10px; display: flex; align-items: center; gap: 10px;
|
|
30
84
|
padding: 8px 12px; border-radius: 999px; background: rgba(16, 16, 20, .55); backdrop-filter: blur(12px);
|
|
31
85
|
border: 1px solid rgba(255, 255, 255, .22); color: #fff; opacity: 1; transition: opacity .25s }
|
|
@@ -46,42 +100,88 @@ const ensureCss = () => {
|
|
|
46
100
|
|
|
47
101
|
const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
|
|
48
102
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
<svg width=
|
|
103
|
+
// Phosphor icons (play/pause in the fill weight, speaker and corners regular), path data
|
|
104
|
+
// inlined from @phosphor-icons/core - MIT (c) Phosphor Icons - the same way the shell's
|
|
105
|
+
// icons.tsx does it: no icon dependency reaches the host.
|
|
106
|
+
const P = ({ d, size }: { d: string; size: number }) => (
|
|
107
|
+
<svg width={size} height={size} viewBox="0 0 256 256" fill="#fff" aria-hidden><path d={d} /></svg>
|
|
54
108
|
)
|
|
109
|
+
const PLAY = 'M240,128a15.74,15.74,0,0,1-7.6,13.51L88.32,229.65a16,16,0,0,1-16.2.3A15.86,15.86,0,0,1,64,216.13V39.87a15.86,15.86,0,0,1,8.12-13.82,16,16,0,0,1,16.2.3L232.4,114.49A15.74,15.74,0,0,1,240,128Z'
|
|
110
|
+
const PAUSE = 'M216,48V208a16,16,0,0,1-16,16H160a16,16,0,0,1-16-16V48a16,16,0,0,1,16-16h40A16,16,0,0,1,216,48ZM96,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V48A16,16,0,0,0,96,32Z'
|
|
111
|
+
const SPEAKER = 'M155.51,24.81a8,8,0,0,0-8.42.88L77.25,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H77.25l69.84,54.31A8,8,0,0,0,160,224V32A8,8,0,0,0,155.51,24.81ZM32,96H72v64H32ZM144,207.64,88,164.09V91.91l56-43.55Zm54-106.08a40,40,0,0,1,0,52.88,8,8,0,0,1-12-10.58,24,24,0,0,0,0-31.72,8,8,0,0,1,12-10.58ZM248,128a79.9,79.9,0,0,1-20.37,53.34,8,8,0,0,1-11.92-10.67,64,64,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67A79.83,79.83,0,0,1,248,128Z'
|
|
112
|
+
const SPEAKER_SLASH = 'M53.92,34.62A8,8,0,1,0,42.08,45.38L73.55,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H77.25l69.84,54.31A8,8,0,0,0,160,224V175.09l42.08,46.29a8,8,0,1,0,11.84-10.76ZM32,96H72v64H32ZM144,207.64,88,164.09V95.89l56,61.6Zm42-63.77a24,24,0,0,0,0-31.72,8,8,0,1,1,12-10.57,40,40,0,0,1,0,52.88,8,8,0,0,1-12-10.59Zm-80.16-76a8,8,0,0,1,1.4-11.23l39.85-31A8,8,0,0,1,160,32v74.83a8,8,0,0,1-16,0V48.36l-26.94,21A8,8,0,0,1,105.84,67.91ZM248,128a79.9,79.9,0,0,1-20.37,53.34,8,8,0,0,1-11.92-10.67,64,64,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67A79.83,79.83,0,0,1,248,128Z'
|
|
113
|
+
const CORNERS = 'M216,48V88a8,8,0,0,1-16,0V56H168a8,8,0,0,1,0-16h40A8,8,0,0,1,216,48ZM88,200H56V168a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H88a8,8,0,0,0,0-16Zm120-40a8,8,0,0,0-8,8v32H168a8,8,0,0,0,0,16h40a8,8,0,0,0,8-8V168A8,8,0,0,0,208,160ZM88,40H48a8,8,0,0,0-8,8V88a8,8,0,0,0,16,0V56H88a8,8,0,0,0,0-16Z'
|
|
114
|
+
const PlayGlyph = ({ size = 30 }: { size?: number }) => <P d={PLAY} size={size} />
|
|
115
|
+
const PauseGlyph = ({ size = 18 }: { size?: number }) => <P d={PAUSE} size={size} />
|
|
55
116
|
|
|
56
|
-
export function Video({ src, poster }: { src: string; poster?: string }) {
|
|
117
|
+
export function Video({ src, poster, ratio, autoplay = false }: { src: string; poster?: string; ratio?: string; autoplay?: boolean }) {
|
|
57
118
|
ensureCss()
|
|
58
119
|
const play = useSlidePlay()
|
|
59
|
-
const
|
|
120
|
+
const interactive = useInteractive()
|
|
121
|
+
const [armed, setArmed] = useState(false) // the poster was clicked in a live frame
|
|
122
|
+
// leaving interact mode disarms: the clip pauses (Player unmounts) and the frame returns to
|
|
123
|
+
// its poster, so the canvas can serialize it lean again
|
|
124
|
+
useEffect(() => { if (!interactive) setArmed(false) }, [interactive])
|
|
125
|
+
// poster omitted on a local clip: the generated one, by convention. `gen` walks
|
|
126
|
+
// probing -> ready (the file exists, or was just rendered - reloaded with a cache-buster)
|
|
127
|
+
// or failed (the card). The <img> mounts only once ready: no broken-image glyph while the
|
|
128
|
+
// dev server renders the poster, and a shot taken meanwhile waits on __mvPosterBusy.
|
|
129
|
+
const generated = !poster && !isRemote(src) ? posterNameFor(src) : null
|
|
130
|
+
const [gen, setGen] = useState<'probing' | 'ready' | 'failed'>(generated ? 'probing' : 'ready')
|
|
131
|
+
const [bust, setBust] = useState(0)
|
|
132
|
+
const posterUrl = poster ? resolve(poster) : generated ? resolve(generated) : null
|
|
133
|
+
const posterSrc = posterUrl && bust ? `${posterUrl}?v=${bust}` : posterUrl
|
|
60
134
|
const srcUrl = resolve(src)
|
|
61
|
-
const
|
|
135
|
+
const style = ratio ? ({ '--mv-video-ratio': ratio } as CSSProperties) : undefined
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!generated || !posterUrl) { setGen('ready'); return }
|
|
138
|
+
let alive = true, pending = true
|
|
139
|
+
setGen('probing'); setBust(0)
|
|
140
|
+
busy++
|
|
141
|
+
const done = () => { if (pending) { pending = false; busy-- } }
|
|
142
|
+
const probe = new Image()
|
|
143
|
+
const settle = (state: 'ready' | 'failed', rebust = false) => { done(); if (!alive) return; if (rebust) setBust(Date.now()); setGen(state) }
|
|
144
|
+
probe.onload = () => settle('ready')
|
|
145
|
+
probe.onerror = () => {
|
|
146
|
+
// not there yet: ask the dev server to render it (once per clip per document)
|
|
147
|
+
void requestPoster(src).then((ok) => settle(ok ? 'ready' : 'failed', ok))
|
|
148
|
+
}
|
|
149
|
+
probe.src = posterUrl
|
|
150
|
+
return () => { alive = false; probe.onload = probe.onerror = null; done() }
|
|
151
|
+
}, [generated, posterUrl, src])
|
|
152
|
+
const bad = !srcUrl || (!isRemote(src) && !posterUrl) || gen === 'failed'
|
|
62
153
|
if (bad) {
|
|
63
154
|
return (
|
|
64
155
|
<div className="mv-block mv-imgerr">
|
|
65
156
|
<b>video unavailable</b>
|
|
66
157
|
<span>{src}</span>
|
|
67
|
-
<span className="dim">{!srcUrl ? 'must be a design/assets/ path or an https file URL' :
|
|
158
|
+
<span className="dim">{!srcUrl ? 'must be a design/assets/ path or an https file URL' : `no poster - add poster="…", or put ${generated} beside the clip (the dev server renders it when Chrome is installed)`}</span>
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
// Ambient: a muted loop, no chrome - the author accepted that this frame stays live.
|
|
163
|
+
if (autoplay) {
|
|
164
|
+
return (
|
|
165
|
+
<div className="mv-block mv-video" style={style}>
|
|
166
|
+
<video src={srcUrl!} poster={gen === 'ready' ? posterSrc ?? undefined : undefined} autoPlay muted loop playsInline preload="auto" />
|
|
68
167
|
</div>
|
|
69
168
|
)
|
|
70
169
|
}
|
|
71
|
-
// AT REST: the poster and nothing else - no <video>, no fetch, still.
|
|
72
|
-
|
|
170
|
+
// AT REST: the poster and nothing else - no <video>, no fetch, still. In a lean cover the
|
|
171
|
+
// click never fires (no script); in a live frame it arms the player inside the gesture.
|
|
172
|
+
if (!play && !armed) {
|
|
73
173
|
return (
|
|
74
|
-
<div className="mv-block mv-video">
|
|
75
|
-
{
|
|
174
|
+
<div className="mv-block mv-video armed" style={style} role="button" aria-label="Play video" onClick={() => setArmed(true)}>
|
|
175
|
+
{posterSrc && gen === 'ready' ? <img src={posterSrc} alt="" loading="lazy" /> : null}
|
|
76
176
|
<div className="glyph"><span><PlayGlyph /></span></div>
|
|
77
177
|
</div>
|
|
78
178
|
)
|
|
79
179
|
}
|
|
80
|
-
return <Player src={srcUrl!} poster={
|
|
180
|
+
return <Player src={srcUrl!} poster={gen === 'ready' ? posterSrc ?? undefined : undefined} style={style} autoStart={armed} />
|
|
81
181
|
}
|
|
82
182
|
|
|
83
183
|
/** The glass strip - deliberately four controls and a clock, nothing more. */
|
|
84
|
-
function Player({ src, poster }: { src: string; poster?: string }) {
|
|
184
|
+
function Player({ src, poster, style, autoStart = false }: { src: string; poster?: string; style?: CSSProperties; autoStart?: boolean }) {
|
|
85
185
|
const ref = useRef<HTMLVideoElement>(null)
|
|
86
186
|
const [playing, setPlaying] = useState(false)
|
|
87
187
|
const [muted, setMuted] = useState(false)
|
|
@@ -95,9 +195,11 @@ function Player({ src, poster }: { src: string; poster?: string }) {
|
|
|
95
195
|
idleTimer.current = setTimeout(() => setIdle(true), 2200)
|
|
96
196
|
}
|
|
97
197
|
useEffect(() => () => { if (idleTimer.current) clearTimeout(idleTimer.current); ref.current?.pause() }, [])
|
|
198
|
+
// armed by a click on the poster: start now - still inside that gesture's activation
|
|
199
|
+
useEffect(() => { if (autoStart) { void ref.current?.play().catch(() => {}); poke() } }, [autoStart])
|
|
98
200
|
const toggle = () => { const v = ref.current; if (!v) return; if (v.paused) { void v.play(); poke() } else v.pause() }
|
|
99
201
|
return (
|
|
100
|
-
<div className="mv-block mv-video" onPointerMove={poke} onClick={(e) => { if (e.target === ref.current) toggle() }}>
|
|
202
|
+
<div className="mv-block mv-video" style={style} onPointerMove={poke} onClick={(e) => { if (e.target === ref.current) toggle() }}>
|
|
101
203
|
<video ref={ref} src={src} poster={poster} preload="metadata" muted={muted} playsInline
|
|
102
204
|
onPlay={() => { setPlaying(true); poke() }} onPause={() => { setPlaying(false); setIdle(false) }}
|
|
103
205
|
onTimeUpdate={(e) => setT(e.currentTarget.currentTime)}
|
|
@@ -110,15 +212,10 @@ function Player({ src, poster }: { src: string; poster?: string }) {
|
|
|
110
212
|
onChange={(e) => { const v = ref.current; if (v) { v.currentTime = Number(e.target.value); setT(v.currentTime) } }} />
|
|
111
213
|
<span className="t">{fmt(dur)}</span>
|
|
112
214
|
<button aria-label={muted ? 'Unmute' : 'Mute'} onClick={() => setMuted((m) => !m)}>
|
|
113
|
-
<
|
|
114
|
-
<path d="M11 5 6 9 H3 v6 h3 l5 4 Z" fill="#fff" stroke="none" />
|
|
115
|
-
{muted ? <path d="m16 9 6 6 M22 9 l-6 6" /> : <path d="M15.5 8.5 a5 5 0 0 1 0 7 M18.5 6 a9 9 0 0 1 0 12" />}
|
|
116
|
-
</svg>
|
|
215
|
+
<P d={muted ? SPEAKER_SLASH : SPEAKER} size={18} />
|
|
117
216
|
</button>
|
|
118
217
|
<button aria-label="Fullscreen" onClick={(e) => void (e.currentTarget.closest('.mv-video') as HTMLElement | null)?.requestFullscreen?.()}>
|
|
119
|
-
<
|
|
120
|
-
<path d="M8 3 H4 a1 1 0 0 0 -1 1 v4 M16 3 h4 a1 1 0 0 1 1 1 v4 M8 21 H4 a1 1 0 0 1 -1 -1 v-4 M16 21 h4 a1 1 0 0 0 1 -1 v-4" />
|
|
121
|
-
</svg>
|
|
218
|
+
<P d={CORNERS} size={18} />
|
|
122
219
|
</button>
|
|
123
220
|
</div>
|
|
124
221
|
</div>
|
|
@@ -69,7 +69,12 @@ window.addEventListener('message', (e) => {
|
|
|
69
69
|
if (e.origin && e.origin !== location.origin) return // a navigated frame must not spoof commands
|
|
70
70
|
if (e?.data?.type === 'sh:set-theme') setTheme(e.data.theme)
|
|
71
71
|
// B0.2: interact/play target owns its own wheel; passive frames forward it to the canvas
|
|
72
|
-
if (e?.data?.type === 'sh:interactive') {
|
|
72
|
+
if (e?.data?.type === 'sh:interactive') {
|
|
73
|
+
interactiveOn = !!e.data.on
|
|
74
|
+
// mirrored on the document so content primitives can observe it (Video disarms its
|
|
75
|
+
// player when the human leaves interact mode - a playing clip must not outlive the mode)
|
|
76
|
+
document.documentElement.toggleAttribute('data-sh-interactive', interactiveOn)
|
|
77
|
+
}
|
|
73
78
|
})
|
|
74
79
|
|
|
75
80
|
if (isHtmlFrame) {
|
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. */
|
|
@@ -323,6 +82,17 @@ function SelectionBar() {
|
|
|
323
82
|
const t = setTimeout(() => setCopied(false), 1400)
|
|
324
83
|
return () => clearTimeout(t)
|
|
325
84
|
}, [pathPulse])
|
|
85
|
+
// copy-as-image: same flash contract (imagePulse bumps only on a real clipboard write),
|
|
86
|
+
// plus a busy state for the 1-4s headless render - the icon dims and the button locks
|
|
87
|
+
const imagePulse = useStore((s) => s.imagePulse)
|
|
88
|
+
const imageBusy = useStore((s) => s.imageBusy)
|
|
89
|
+
const [copiedImage, setCopiedImage] = useState(false)
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!imagePulse) return
|
|
92
|
+
setCopiedImage(true)
|
|
93
|
+
const t = setTimeout(() => setCopiedImage(false), 1400)
|
|
94
|
+
return () => clearTimeout(t)
|
|
95
|
+
}, [imagePulse])
|
|
326
96
|
if (!node || !frame || node.missing) return null
|
|
327
97
|
// anchor: centered over the bounding box of ALL selected frames, above the topmost
|
|
328
98
|
const selNodes = nodes.filter((n) => selection.includes(n.key))
|
|
@@ -401,6 +171,17 @@ function SelectionBar() {
|
|
|
401
171
|
() => toast('copy blocked - click the canvas first'))
|
|
402
172
|
}}>{copied ? <CheckIcon size={15} /> : <SignpostIcon size={15} />}</button>
|
|
403
173
|
</Tip>
|
|
174
|
+
{/* copy as image: dev only (the renderer is the dev server's headless Chrome) and one
|
|
175
|
+
frame at a time (a clipboard holds one image). I = 2x, Shift+I = 4x. */}
|
|
176
|
+
{!PUBLISHED && (
|
|
177
|
+
<Tip label={multi ? 'Select one frame to copy as image' : <><b>Copy as image</b><span className="k">⇧i</span></>}>
|
|
178
|
+
<button className={`icon${imageBusy ? ' busy' : ''}`} disabled={multi || imageBusy} aria-label="Copy as image"
|
|
179
|
+
data-state={copiedImage ? 'copied' : imageBusy ? 'busy' : 'idle'}
|
|
180
|
+
onClick={(e) => useStore.getState().copyFrameImage(e.shiftKey ? 4 : 2)}>
|
|
181
|
+
{copiedImage ? <CheckIcon size={15} /> : <ImagesSquareIcon size={15} />}
|
|
182
|
+
</button>
|
|
183
|
+
</Tip>
|
|
184
|
+
)}
|
|
404
185
|
</div>
|
|
405
186
|
)
|
|
406
187
|
}
|
|
@@ -835,7 +616,11 @@ export function App() {
|
|
|
835
616
|
// keyboard: t tidy · d theme · ⌘\ panel · Escape exit · shift+0/1/2 zoom
|
|
836
617
|
useEffect(() => {
|
|
837
618
|
const onKey = (e: KeyboardEvent) => {
|
|
838
|
-
|
|
619
|
+
// anything that edits text owns its keys: inputs, textareas, selects, contenteditable (the
|
|
620
|
+
// comment composer), and an IME mid-composition
|
|
621
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return
|
|
622
|
+
if (e.target instanceof HTMLElement && e.target.isContentEditable) return
|
|
623
|
+
if (e.isComposing) return
|
|
839
624
|
const s = useStore.getState()
|
|
840
625
|
if (s.play) return // play mode owns the keyboard (Play.tsx)
|
|
841
626
|
if ((e.metaKey || e.ctrlKey) && e.key === '\\') { e.preventDefault(); togglePanel(); return }
|
|
@@ -878,6 +663,8 @@ export function App() {
|
|
|
878
663
|
c.setShowAnchor(!c.showAnchor)
|
|
879
664
|
toast(c.showAnchor ? 'laser comment off' : 'laser comment on')
|
|
880
665
|
}
|
|
666
|
+
// I = copy the selected frame as a 2x PNG · Shift+I = 4x (dev only; one frame at a time)
|
|
667
|
+
if ((e.key === 'i' || e.key === 'I') && !PUBLISHED && !e.altKey && !e.repeat) { s.copyFrameImage(e.shiftKey ? 4 : 2); return }
|
|
881
668
|
if (e.key === 'P' && e.shiftKey && s.selection.length) {
|
|
882
669
|
const paths = s.selection
|
|
883
670
|
.map((k) => { const n = s.nodes.find((x) => x.key === k); const f = n && s.frameFor(n); return f ? framePath(s.board, f) : undefined })
|
|
@@ -989,7 +776,6 @@ export function App() {
|
|
|
989
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>
|
|
990
777
|
</div>
|
|
991
778
|
<div className="sh-panel-scroll">
|
|
992
|
-
<div className="hd">Boards</div>
|
|
993
779
|
<BoardList onMenu={cm.open} />
|
|
994
780
|
<div className="hd" style={{ marginTop: 10 }}>Scenes</div>
|
|
995
781
|
{scenes.map((sc) => (
|