@1agh/maude 1.3.0 → 1.4.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/apps/studio/annotation-edit-base.ts +36 -0
- package/apps/studio/annotations-layer.tsx +13 -2
- package/apps/studio/api.ts +51 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +77 -0
- package/apps/studio/canvas-lib.tsx +25 -10
- package/apps/studio/canvas-list-watch.ts +33 -2
- package/apps/studio/client/app.jsx +99 -4
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/panels/TimelinePanel.jsx +75 -62
- package/apps/studio/client/photo-knobs.jsx +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +1 -0
- package/apps/studio/collab/awareness-bridge.ts +65 -14
- package/apps/studio/comments-overlay.tsx +86 -2
- package/apps/studio/context.ts +93 -1
- package/apps/studio/dist/client.bundle.js +619 -619
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/accepted-guard.ts +36 -0
- package/apps/studio/hmr-broadcast.ts +34 -1
- package/apps/studio/http.ts +15 -1
- package/apps/studio/managed-projects.ts +22 -0
- package/apps/studio/server.ts +3 -0
- package/apps/studio/sync/accepted-link.ts +3 -0
- package/apps/studio/sync/file-plane.ts +15 -0
- package/apps/studio/sync/index.ts +138 -12
- package/apps/studio/sync/presentation.ts +30 -0
- package/apps/studio/sync/status.ts +5 -1
- package/apps/studio/sync/transaction-client.ts +29 -0
- package/apps/studio/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +72 -0
- package/apps/studio/ws.ts +9 -2
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +48 -7
- package/plugins/flow/.claude-plugin/config.schema.json +15 -2
- package/plugins/flow/README.md +1 -1
|
@@ -32,7 +32,7 @@ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
|
|
|
32
32
|
// same "pull only pure logic into the client bundle" shape as the imports
|
|
33
33
|
// above (a type-only SyncStatusSnapshot import that Bun erases).
|
|
34
34
|
import { syncPresentation } from '../sync/presentation.ts';
|
|
35
|
-
import { canvasUrl } from './canvas-url.js';
|
|
35
|
+
import { canvasTokenRefreshDelay, canvasUrl, setLiveCanvasToken } from './canvas-url.js';
|
|
36
36
|
import { applyEditRequest } from './apply-edit-request.ts';
|
|
37
37
|
import { createIndexLoader } from './index-loader.ts';
|
|
38
38
|
import {
|
|
@@ -3245,6 +3245,7 @@ function Sidebar({
|
|
|
3245
3245
|
<button
|
|
3246
3246
|
type="button"
|
|
3247
3247
|
className={'st-tree-sec-hd' + (isRootOver ? ' is-drop-target' : '')}
|
|
3248
|
+
data-testid={`tree-section-${g.label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
|
|
3248
3249
|
onClick={() => onToggleSection(g.label, defaultOpen)}
|
|
3249
3250
|
aria-expanded={sectionOpen}
|
|
3250
3251
|
aria-dropeffect={canDropOnRoot ? 'move' : undefined}
|
|
@@ -4684,6 +4685,28 @@ function Viewport({
|
|
|
4684
4685
|
useEffect(() => {
|
|
4685
4686
|
if (previewPath) previewRef.current?.focus();
|
|
4686
4687
|
}, [previewPath]);
|
|
4688
|
+
// An open canvas keeps the URL it was opened with. The capability in it is
|
|
4689
|
+
// re-minted on a timer (canvas-url.js); letting that change the `src` of a
|
|
4690
|
+
// live iframe would reload every open canvas every few minutes. The fresh
|
|
4691
|
+
// capability reaches an open canvas by message instead, and a frame that is
|
|
4692
|
+
// (re)created — a new tab, a Retry, a config change — is built with it.
|
|
4693
|
+
// Only while the frame is open: a closed tab reopened later is a new frame
|
|
4694
|
+
// and must not inherit a capability that may have expired meanwhile.
|
|
4695
|
+
const srcCache = useRef(new Map());
|
|
4696
|
+
const openPaths = new Set(tabs.map((t) => t.path));
|
|
4697
|
+
for (const [key, entry] of srcCache.current) {
|
|
4698
|
+
if (!openPaths.has(entry.path)) srcCache.current.delete(key);
|
|
4699
|
+
}
|
|
4700
|
+
const stableSrc = (path) => {
|
|
4701
|
+
const bare = canvasUrl(path, { ...cfg, canvasToken: undefined });
|
|
4702
|
+
const key = `${path}#${canvasReloadNonce}|${bare}`;
|
|
4703
|
+
let entry = srcCache.current.get(key);
|
|
4704
|
+
if (!entry) {
|
|
4705
|
+
entry = { path, src: canvasUrl(path, cfg) };
|
|
4706
|
+
srcCache.current.set(key, entry);
|
|
4707
|
+
}
|
|
4708
|
+
return entry.src;
|
|
4709
|
+
};
|
|
4687
4710
|
// One observable word for "what is the canvas pane actually doing" — the
|
|
4688
4711
|
// top-frame signal the #115 E2E scenario waits on, and the only place these
|
|
4689
4712
|
// three states are named together. `ready` is `dgn:'loaded'`-backed, so it
|
|
@@ -4776,7 +4799,7 @@ function Viewport({
|
|
|
4776
4799
|
// path exactly as before.
|
|
4777
4800
|
key={`${t.path}#${canvasReloadNonce}`}
|
|
4778
4801
|
ref={(el) => registerIframe(t.path, el)}
|
|
4779
|
-
src={
|
|
4802
|
+
src={stableSrc(t.path)}
|
|
4780
4803
|
className={t.path === activePath ? 'active' : ''}
|
|
4781
4804
|
data-path={t.path}
|
|
4782
4805
|
data-testid={t.path === activePath ? 'canvas-frame' : undefined}
|
|
@@ -5353,6 +5376,7 @@ function CommentsPanel({
|
|
|
5353
5376
|
type="button"
|
|
5354
5377
|
className={'st-cm-filter' + (filter === 'all' ? ' is-active' : '')}
|
|
5355
5378
|
role="tab"
|
|
5379
|
+
data-testid="comment-filter-all"
|
|
5356
5380
|
aria-selected={filter === 'all'}
|
|
5357
5381
|
onClick={() => setFilter('all')}
|
|
5358
5382
|
>
|
|
@@ -5362,6 +5386,7 @@ function CommentsPanel({
|
|
|
5362
5386
|
type="button"
|
|
5363
5387
|
className={'st-cm-filter' + (filter === 'open' ? ' is-active' : '')}
|
|
5364
5388
|
role="tab"
|
|
5389
|
+
data-testid="comment-filter-open"
|
|
5365
5390
|
aria-selected={filter === 'open'}
|
|
5366
5391
|
onClick={() => setFilter('open')}
|
|
5367
5392
|
>
|
|
@@ -5371,6 +5396,7 @@ function CommentsPanel({
|
|
|
5371
5396
|
type="button"
|
|
5372
5397
|
className={'st-cm-filter' + (filter === 'resolved' ? ' is-active' : '')}
|
|
5373
5398
|
role="tab"
|
|
5399
|
+
data-testid="comment-filter-resolved"
|
|
5374
5400
|
aria-selected={filter === 'resolved'}
|
|
5375
5401
|
onClick={() => setFilter('resolved')}
|
|
5376
5402
|
>
|
|
@@ -5402,6 +5428,7 @@ function CommentsPanel({
|
|
|
5402
5428
|
{g.comments.map((c) => (
|
|
5403
5429
|
<div
|
|
5404
5430
|
key={c.id}
|
|
5431
|
+
data-testid={`comment-item-${c.id}`}
|
|
5405
5432
|
className={
|
|
5406
5433
|
'st-comment' +
|
|
5407
5434
|
(c.status === 'resolved' ? ' is-resolved' : '') +
|
|
@@ -7054,6 +7081,7 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
7054
7081
|
<button
|
|
7055
7082
|
type="button"
|
|
7056
7083
|
className="st-cp-sechd"
|
|
7084
|
+
data-testid={`inspector-section-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
|
|
7057
7085
|
aria-expanded={!!open[name]}
|
|
7058
7086
|
onClick={() => setOpen((o) => ({ ...o, [name]: !o[name] }))}
|
|
7059
7087
|
>
|
|
@@ -9578,8 +9606,10 @@ function InspectorPanel({
|
|
|
9578
9606
|
};
|
|
9579
9607
|
// An annotation-image has no element tabs; force Photo. Otherwise honor the
|
|
9580
9608
|
// requested tab, but drop off a stale 'photo' tab when the new selection isn't
|
|
9581
|
-
// photo-eligible
|
|
9582
|
-
|
|
9609
|
+
// photo-eligible — onto CSS, the editing tab a fresh selection opens on (Stage
|
|
9610
|
+
// C). Landing on read-only Inspect meant that after touching a sticker, the
|
|
9611
|
+
// next element you clicked showed facts instead of the controls to change it.
|
|
9612
|
+
const effTab = photoOnly ? 'photo' : tab === 'photo' && !photoTarget ? 'css' : tab;
|
|
9583
9613
|
const tabBtn = (id, label, icon) => (
|
|
9584
9614
|
<button
|
|
9585
9615
|
type="button"
|
|
@@ -10806,6 +10836,71 @@ function App() {
|
|
|
10806
10836
|
const wsRef = useRef(null);
|
|
10807
10837
|
const iframesRef = useRef(new Map());
|
|
10808
10838
|
|
|
10839
|
+
// THE CANVAS CAPABILITY OUTLIVES NO TAB (cloud only). It expires 15 minutes
|
|
10840
|
+
// after the proxy minted it (render-token.mjs) and cannot be revoked, so it
|
|
10841
|
+
// stays short — which left every cloud tab open longer than that with
|
|
10842
|
+
// canvases that stopped updating: a teammate's edit re-imports the canvas
|
|
10843
|
+
// module, and the canvas origin answered 401. Re-mint it well inside the
|
|
10844
|
+
// lifetime (every `/_config` answer carries a fresh one), hand it to each
|
|
10845
|
+
// open canvas, and let new frames be built with it. On waking a tab that
|
|
10846
|
+
// slept past the cadence, do it at once. Desktops have no capability and
|
|
10847
|
+
// never start the timer.
|
|
10848
|
+
useEffect(() => {
|
|
10849
|
+
if (!cfg?.canvasToken) return undefined;
|
|
10850
|
+
let stopped = false;
|
|
10851
|
+
let timer = null;
|
|
10852
|
+
let dueAt = 0;
|
|
10853
|
+
const schedule = (token) => {
|
|
10854
|
+
clearTimeout(timer);
|
|
10855
|
+
const delay = canvasTokenRefreshDelay(token);
|
|
10856
|
+
dueAt = Date.now() + delay;
|
|
10857
|
+
timer = setTimeout(refresh, delay);
|
|
10858
|
+
};
|
|
10859
|
+
async function refresh() {
|
|
10860
|
+
let token = null;
|
|
10861
|
+
try {
|
|
10862
|
+
const res = await fetch('/_config', { cache: 'no-store', credentials: 'same-origin' });
|
|
10863
|
+
if (res.ok) token = (await res.json())?.canvasToken;
|
|
10864
|
+
} catch {
|
|
10865
|
+
/* retried below; the current capability may still be valid */
|
|
10866
|
+
}
|
|
10867
|
+
if (stopped) return;
|
|
10868
|
+
if (typeof token !== 'string' || !token) {
|
|
10869
|
+
clearTimeout(timer);
|
|
10870
|
+
dueAt = Date.now() + 30_000;
|
|
10871
|
+
timer = setTimeout(refresh, 30_000);
|
|
10872
|
+
return;
|
|
10873
|
+
}
|
|
10874
|
+
setLiveCanvasToken(token);
|
|
10875
|
+
// To the canvas origin only, never '*': a frame the canvas content
|
|
10876
|
+
// navigated elsewhere must not be handed the capability.
|
|
10877
|
+
let target = null;
|
|
10878
|
+
try {
|
|
10879
|
+
target = new URL(cfg.canvasOrigin, location.href).origin;
|
|
10880
|
+
} catch {}
|
|
10881
|
+
if (target) {
|
|
10882
|
+
for (const el of iframesRef.current.values()) {
|
|
10883
|
+
try {
|
|
10884
|
+
el.contentWindow?.postMessage({ dgn: 'canvas-cap', t: token }, target);
|
|
10885
|
+
} catch {}
|
|
10886
|
+
}
|
|
10887
|
+
}
|
|
10888
|
+
schedule(token);
|
|
10889
|
+
}
|
|
10890
|
+
schedule(cfg.canvasToken);
|
|
10891
|
+
// A background tab's timers are throttled; one woken past its due time
|
|
10892
|
+
// re-mints at once rather than on the throttled tick.
|
|
10893
|
+
const onVisible = () => {
|
|
10894
|
+
if (document.visibilityState === 'visible' && Date.now() >= dueAt) refresh();
|
|
10895
|
+
};
|
|
10896
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
10897
|
+
return () => {
|
|
10898
|
+
stopped = true;
|
|
10899
|
+
clearTimeout(timer);
|
|
10900
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
10901
|
+
};
|
|
10902
|
+
}, [cfg?.canvasToken, cfg?.canvasOrigin]);
|
|
10903
|
+
|
|
10809
10904
|
// Phase 5.1 — postMessage bridge from menubar dropdowns to the canvas iframe.
|
|
10810
10905
|
// The canvas-shell listens for these `dgn:*` messages and dispatches into the
|
|
10811
10906
|
// matching local provider (annotations visibility / both selection stores /
|
|
@@ -9,6 +9,43 @@
|
|
|
9
9
|
// default DS's tokens — whose ladder is scoped to a different `.<rootClass>`
|
|
10
10
|
// subtree — so every `var(--*)` went undefined and the canvas rendered white.
|
|
11
11
|
|
|
12
|
+
// THE CANVAS CAPABILITY EXPIRES (render-token.mjs: 15 minutes) and cannot be
|
|
13
|
+
// revoked, so it is short on purpose. A tab open longer than that must carry a
|
|
14
|
+
// fresh one or its canvases stop loading and stop updating — every module
|
|
15
|
+
// re-import after a teammate's edit answers 401. The shell re-mints it on a
|
|
16
|
+
// timer (`refreshCanvasToken` in app.jsx) and records it here; a URL built
|
|
17
|
+
// after that carries the fresh capability. `null` until the first refresh:
|
|
18
|
+
// the one in `/_config` is then current.
|
|
19
|
+
let liveCanvasToken = null;
|
|
20
|
+
export function setLiveCanvasToken(token) {
|
|
21
|
+
liveCanvasToken = typeof token === 'string' && token ? token : null;
|
|
22
|
+
}
|
|
23
|
+
export function currentCanvasToken(cfg) {
|
|
24
|
+
return cfg?.canvasToken ? (liveCanvasToken ?? cfg.canvasToken) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* When to re-mint a capability: at 60% of what is left of it, read off the
|
|
29
|
+
* token's own expiry claim (`e`, ms — render-token.mjs), so the cadence
|
|
30
|
+
* follows the hub's lifetime instead of a copy of it. Never sooner than 30 s
|
|
31
|
+
* (a skewed clock cannot turn this into a busy loop) and never later than
|
|
32
|
+
* 10 minutes; an unreadable token gets the ceiling.
|
|
33
|
+
*/
|
|
34
|
+
export function canvasTokenRefreshDelay(token, now = Date.now()) {
|
|
35
|
+
const MIN = 30_000;
|
|
36
|
+
const MAX = 10 * 60_000;
|
|
37
|
+
try {
|
|
38
|
+
const body = String(token).slice(0, String(token).lastIndexOf('.'));
|
|
39
|
+
const b64 = body.replace(/-/g, '+').replace(/_/g, '/');
|
|
40
|
+
const claims = JSON.parse(atob(b64 + '==='.slice((b64.length + 3) % 4)));
|
|
41
|
+
const left = Number(claims?.e) - now;
|
|
42
|
+
if (!Number.isFinite(left)) return MAX;
|
|
43
|
+
return Math.min(MAX, Math.max(MIN, Math.floor(left * 0.6)));
|
|
44
|
+
} catch {
|
|
45
|
+
return MAX;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
12
49
|
export function urlOf(p) {
|
|
13
50
|
return '/' + p.split('/').map(encodeURIComponent).join('/');
|
|
14
51
|
}
|
|
@@ -47,7 +84,8 @@ export function canvasUrl(p, cfg, opts) {
|
|
|
47
84
|
// origin: a cookie scoped widely enough to cover it would be readable by the
|
|
48
85
|
// untrusted canvas content itself, which is the one thing the DDR-054 split
|
|
49
86
|
// exists to prevent — so the capability lives in the URL instead.
|
|
50
|
-
|
|
87
|
+
const token = currentCanvasToken(cfg);
|
|
88
|
+
if (token) params.set('t', token);
|
|
51
89
|
const ds0 = cfg?.designSystems?.[0];
|
|
52
90
|
// Specimen detection: anything under `system/<ds>/preview/` belongs to that
|
|
53
91
|
// specific DS, so it must render with *that* DS's tokens — not always the
|
|
@@ -137,6 +137,24 @@ function fmtTime(frame, fps) {
|
|
|
137
137
|
return `${whole}.${String(cs).padStart(2, '0')}s`;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Drag state its own release handler reads synchronously. The releases used
|
|
142
|
+
* to commit from INSIDE a state updater (`setX((d) => { commit(d); … })`), and
|
|
143
|
+
* React is free to run an updater more than once — StrictMode does it on
|
|
144
|
+
* purpose, and a render replayed against a concurrent update may too — so one
|
|
145
|
+
* drag could write its clip twice. Updaters passed to `set` run once, here,
|
|
146
|
+
* against the live value; the release commits outside any updater.
|
|
147
|
+
*/
|
|
148
|
+
function useLiveState(initial) {
|
|
149
|
+
const [state, setState] = useState(initial);
|
|
150
|
+
const live = useRef(initial);
|
|
151
|
+
const set = useCallback((next) => {
|
|
152
|
+
live.current = typeof next === 'function' ? next(live.current) : next;
|
|
153
|
+
setState(live.current);
|
|
154
|
+
}, []);
|
|
155
|
+
return [state, set, live];
|
|
156
|
+
}
|
|
157
|
+
|
|
140
158
|
// The frame axis starts AFTER the fixed label gutter.
|
|
141
159
|
const LABEL_GUTTER = 96;
|
|
142
160
|
// Padding the .tl-scroll container puts around the tracks (see 3-shell-maude.css).
|
|
@@ -353,10 +371,10 @@ export default function TimelinePanel({
|
|
|
353
371
|
|
|
354
372
|
// -------------------------------------------------------------------------
|
|
355
373
|
// Drag machines (retime / move) — px-per-frame based.
|
|
356
|
-
const [retimeDrag, setRetimeDrag] =
|
|
357
|
-
const [moveDrag, setMoveDrag] =
|
|
374
|
+
const [retimeDrag, setRetimeDrag, retimeLive] = useLiveState(null);
|
|
375
|
+
const [moveDrag, setMoveDrag, moveLive] = useLiveState(null);
|
|
358
376
|
// Task 6 — magnetic storyline reorder drag: { index, slot, targetSlot, dx, pxf }.
|
|
359
|
-
const [reorderDrag, setReorderDrag] =
|
|
377
|
+
const [reorderDrag, setReorderDrag, reorderLive] = useLiveState(null);
|
|
360
378
|
// Debate verdict (feedback): a momentary accent rule at the frame a drag just
|
|
361
379
|
// snapped to — magnetism is otherwise invisible. { frame } | null.
|
|
362
380
|
const [snapFlash, setSnapFlash] = useState(null);
|
|
@@ -374,7 +392,7 @@ export default function TimelinePanel({
|
|
|
374
392
|
const [commentMode, setCommentMode] = useState(false);
|
|
375
393
|
// Dogfood (2026-07-30) — vertical layer reorder: dragging an overlay row's
|
|
376
394
|
// lane chip up/down re-stacks the standalone clips (z-order = doc order).
|
|
377
|
-
const [layerDrag, setLayerDrag] =
|
|
395
|
+
const [layerDrag, setLayerDrag, layerLive] = useLiveState(null);
|
|
378
396
|
// Right-click clip context menu: { index, x, y }.
|
|
379
397
|
const [ctxMenu, setCtxMenu] = useState(null);
|
|
380
398
|
// Task 23 — the open comment-thread popover: { id, x, y } | null.
|
|
@@ -384,7 +402,7 @@ export default function TimelinePanel({
|
|
|
384
402
|
// a seam chip click.
|
|
385
403
|
const [inspector, setInspector] = useState(null);
|
|
386
404
|
// Task 11 — left-edge in-point trim drag: { index, ref, startX, pxf, delta }.
|
|
387
|
-
const [trimInDrag, setTrimInDrag] =
|
|
405
|
+
const [trimInDrag, setTrimInDrag, trimInLive] = useLiveState(null);
|
|
388
406
|
// True after a body-drag actually moved — suppresses the click-to-select.
|
|
389
407
|
const movedRef = useRef(false);
|
|
390
408
|
// Drag the top edge to resize the panel — pointer-capture + window listeners
|
|
@@ -508,10 +526,9 @@ export default function TimelinePanel({
|
|
|
508
526
|
setRetimeDrag((d) => (d ? { ...d, curDur, atClamp } : d));
|
|
509
527
|
};
|
|
510
528
|
const up = () => {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
});
|
|
529
|
+
const d = retimeLive.current;
|
|
530
|
+
setRetimeDrag(null);
|
|
531
|
+
if (d && d.curDur !== d.startDur) onRetime?.(d.ref ?? d.index, { durationInFrames: d.curDur });
|
|
515
532
|
};
|
|
516
533
|
window.addEventListener('pointermove', move);
|
|
517
534
|
window.addEventListener('pointerup', up, { once: true });
|
|
@@ -537,25 +554,23 @@ export default function TimelinePanel({
|
|
|
537
554
|
setMoveDrag((d) => (d ? { ...d, curFrom, overStory } : d));
|
|
538
555
|
};
|
|
539
556
|
const up = (e) => {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
return null;
|
|
558
|
-
});
|
|
557
|
+
const d = moveLive.current;
|
|
558
|
+
setMoveDrag(null);
|
|
559
|
+
if (!d) return;
|
|
560
|
+
// Dropping the clip ON the storyline lane moves it between layers
|
|
561
|
+
// (the layers model); otherwise commit the horizontal from-move.
|
|
562
|
+
const story = document.querySelector('.tl-storyline-track')?.getBoundingClientRect();
|
|
563
|
+
if (
|
|
564
|
+
story &&
|
|
565
|
+
onClipVerb &&
|
|
566
|
+
e.clientY >= story.top &&
|
|
567
|
+
e.clientY <= story.bottom &&
|
|
568
|
+
movedRef.current
|
|
569
|
+
) {
|
|
570
|
+
onClipVerb(d.ref ?? { index: d.index }, 'to-storyline', {});
|
|
571
|
+
} else if (d.curFrom !== d.startFrom) {
|
|
572
|
+
onRetime?.(d.ref ?? d.index, { from: d.curFrom });
|
|
573
|
+
}
|
|
559
574
|
};
|
|
560
575
|
window.addEventListener('pointermove', move);
|
|
561
576
|
window.addEventListener('pointerup', up, { once: true });
|
|
@@ -576,10 +591,9 @@ export default function TimelinePanel({
|
|
|
576
591
|
setTrimInDrag((d) => (d ? { ...d, delta } : d));
|
|
577
592
|
};
|
|
578
593
|
const up = () => {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
});
|
|
594
|
+
const d = trimInLive.current;
|
|
595
|
+
setTrimInDrag(null);
|
|
596
|
+
if (d && d.delta) onClipVerb?.(d.ref, 'trim-in', { deltaFrames: d.delta });
|
|
583
597
|
};
|
|
584
598
|
window.addEventListener('pointermove', move);
|
|
585
599
|
window.addEventListener('pointerup', up, { once: true });
|
|
@@ -646,33 +660,33 @@ export default function TimelinePanel({
|
|
|
646
660
|
setReorderDrag((d) => (d ? { ...d, dx, targetSlot: target } : d));
|
|
647
661
|
};
|
|
648
662
|
const up = (e) => {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
}
|
|
663
|
+
const d = reorderLive.current;
|
|
664
|
+
setReorderDrag(null);
|
|
665
|
+
if (!d) return;
|
|
666
|
+
if (movedRef.current && onClipVerb) {
|
|
667
|
+
const track = document.querySelector('.tl-storyline-track')?.getBoundingClientRect();
|
|
668
|
+
if (track && e.clientY < track.top - 6) {
|
|
669
|
+
const beat = sequences[d.index];
|
|
670
|
+
if (beat) {
|
|
671
|
+
onClipVerb(refFor(beat, d.index), 'to-overlay', {});
|
|
672
|
+
return;
|
|
658
673
|
}
|
|
659
674
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
675
|
+
}
|
|
676
|
+
if (d.targetSlot !== d.slot) {
|
|
677
|
+
const moved = story[d.slot];
|
|
678
|
+
// targetSlot counts positions among the OTHER beats — map to a ref
|
|
679
|
+
// beat + before/after in the original order.
|
|
680
|
+
const others = story.filter((_, k) => k !== d.slot);
|
|
681
|
+
if (moved && others.length) {
|
|
682
|
+
if (d.targetSlot <= 0) {
|
|
683
|
+
onReorderMove?.(refFor(moved.s, moved.i), refFor(others[0].s, others[0].i), 'before');
|
|
684
|
+
} else {
|
|
685
|
+
const ref = others[Math.min(d.targetSlot, others.length) - 1];
|
|
686
|
+
onReorderMove?.(refFor(moved.s, moved.i), refFor(ref.s, ref.i), 'after');
|
|
672
687
|
}
|
|
673
688
|
}
|
|
674
|
-
|
|
675
|
-
});
|
|
689
|
+
}
|
|
676
690
|
};
|
|
677
691
|
window.addEventListener('pointermove', move);
|
|
678
692
|
window.addEventListener('pointerup', up, { once: true });
|
|
@@ -701,13 +715,12 @@ export default function TimelinePanel({
|
|
|
701
715
|
setLayerDrag((d) => (d ? { ...d, curTarget: target } : d));
|
|
702
716
|
};
|
|
703
717
|
const up = () => {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
});
|
|
718
|
+
const d = layerLive.current;
|
|
719
|
+
setLayerDrag(null);
|
|
720
|
+
if (d && d.curTarget !== d.docIdx) {
|
|
721
|
+
const seq = sequences[d.index];
|
|
722
|
+
if (seq) onClipVerb?.(refFor(seq, d.index), 'layer-order', { toIndex: d.curTarget });
|
|
723
|
+
}
|
|
711
724
|
};
|
|
712
725
|
window.addEventListener('pointermove', move);
|
|
713
726
|
window.addEventListener('pointerup', up, { once: true });
|
|
@@ -317,7 +317,7 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
|
|
|
317
317
|
|
|
318
318
|
<Section
|
|
319
319
|
title="Duotone"
|
|
320
|
-
right={<Toggle checked={duo.enabled} onChange={(v) => setSection('duotone', { enabled: v }, true)} label="on" />}
|
|
320
|
+
right={<Toggle checked={duo.enabled} onChange={(v) => setSection('duotone', { enabled: v }, true)} label="on" ariaLabel="Duotone on" />}
|
|
321
321
|
onReset={() => clearSection('duotone')}
|
|
322
322
|
>
|
|
323
323
|
{duo.enabled && (
|
|
@@ -337,7 +337,7 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
|
|
|
337
337
|
|
|
338
338
|
<Section
|
|
339
339
|
title="Grain"
|
|
340
|
-
right={<Toggle checked={grain.enabled} onChange={(v) => setSection('grain', { enabled: v }, true)} label="on" />}
|
|
340
|
+
right={<Toggle checked={grain.enabled} onChange={(v) => setSection('grain', { enabled: v }, true)} label="on" ariaLabel="Grain on" />}
|
|
341
341
|
onReset={() => clearSection('grain')}
|
|
342
342
|
>
|
|
343
343
|
{grain.enabled && (
|
|
@@ -358,7 +358,7 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
|
|
|
358
358
|
|
|
359
359
|
<Section
|
|
360
360
|
title="Pattern"
|
|
361
|
-
right={<Toggle checked={pat.enabled} onChange={(v) => setSection('pattern', { enabled: v }, true)} label="on" />}
|
|
361
|
+
right={<Toggle checked={pat.enabled} onChange={(v) => setSection('pattern', { enabled: v }, true)} label="on" ariaLabel="Pattern on" />}
|
|
362
362
|
onReset={() => clearSection('pattern')}
|
|
363
363
|
>
|
|
364
364
|
{pat.enabled && (
|
|
@@ -2734,6 +2734,7 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2734
2734
|
.sp-note-dot { width: 7px; height: 7px; margin-top: 3px; border-radius: var(--radius-pill); background: var(--status-warn); flex: none; }
|
|
2735
2735
|
.sp-note-dot.is-synced { background: var(--presence-online); }
|
|
2736
2736
|
.sp-note-dot.is-refused, .sp-note-dot.is-stalled, .sp-note-dot.is-offline { background: var(--status-error); }
|
|
2737
|
+
.sp-note > .btn { flex: none; white-space: nowrap; }
|
|
2737
2738
|
.sp-sect-attention { color: var(--status-error); }
|
|
2738
2739
|
.sp-list { list-style: none; margin: 0; padding: 0; }
|
|
2739
2740
|
.sp-row { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); min-width: 0; }
|
|
@@ -27,8 +27,45 @@
|
|
|
27
27
|
// originating clientID — the random 32-bit ids make local↔remote collisions
|
|
28
28
|
// negligible.
|
|
29
29
|
|
|
30
|
+
import * as encoding from 'lib0/encoding';
|
|
30
31
|
import type { Awareness } from 'y-protocols/awareness';
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
applyAwarenessUpdate,
|
|
34
|
+
encodeAwarenessUpdate,
|
|
35
|
+
removeAwarenessStates,
|
|
36
|
+
} from 'y-protocols/awareness';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A departed relayed client, as the hub can carry it.
|
|
40
|
+
*
|
|
41
|
+
* A peer leaving is a REMOVAL — a null awareness state. The hub (Hocuspocus 4)
|
|
42
|
+
* runs every incoming awareness frame through a scratch Awareness for its
|
|
43
|
+
* `beforeHandleAwareness` hook and re-encodes only the states that scratch
|
|
44
|
+
* still HOLDS, so a null state is dropped on the way in: a tab closing behind
|
|
45
|
+
* this studio never left anyone else's presence until the 30 s awareness
|
|
46
|
+
* timeout (plan T31/L19). The one thing it forwards is a state, so a departure
|
|
47
|
+
* crosses as this marker with a newer clock; every bridge turns it back into a
|
|
48
|
+
* real removal on its room. It carries no `name`, so a client that predates
|
|
49
|
+
* the marker drops it at the trust boundary (`sanitizeForeignState`) instead
|
|
50
|
+
* of drawing a nameless avatar.
|
|
51
|
+
*/
|
|
52
|
+
export const DEPARTED = Object.freeze({ __left: true });
|
|
53
|
+
|
|
54
|
+
function isDeparted(state: unknown): boolean {
|
|
55
|
+
return !!state && typeof state === 'object' && (state as { __left?: unknown }).__left === true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Encode a departure for each of `clients`, one clock past what `from` knows. */
|
|
59
|
+
function encodeDepartures(from: Awareness, clients: readonly number[]): Uint8Array {
|
|
60
|
+
const enc = encoding.createEncoder();
|
|
61
|
+
encoding.writeVarUint(enc, clients.length);
|
|
62
|
+
for (const clientID of clients) {
|
|
63
|
+
encoding.writeVarUint(enc, clientID);
|
|
64
|
+
encoding.writeVarUint(enc, (from.meta.get(clientID)?.clock ?? 0) + 1);
|
|
65
|
+
encoding.writeVarString(enc, JSON.stringify(DEPARTED));
|
|
66
|
+
}
|
|
67
|
+
return encoding.toUint8Array(enc);
|
|
68
|
+
}
|
|
32
69
|
|
|
33
70
|
interface AwarenessChange {
|
|
34
71
|
added: number[];
|
|
@@ -49,22 +86,36 @@ export function bridgeAwareness(a: Awareness, b: Awareness): () => void {
|
|
|
49
86
|
// so it relays; a change this bridge applied carries BRIDGE, so it stops.
|
|
50
87
|
const BRIDGE = { awarenessBridge: true };
|
|
51
88
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
89
|
+
// Room → hub. A client that left the room leaves as a DEPARTED marker (see
|
|
90
|
+
// above) — never as the null state the hub would drop. Everything else
|
|
91
|
+
// relays as it is.
|
|
92
|
+
const aToB = ({ added, updated, removed }: AwarenessChange, origin: unknown) => {
|
|
93
|
+
if (origin === BRIDGE) return;
|
|
94
|
+
const present = added.concat(updated);
|
|
95
|
+
if (present.length > 0) applyAwarenessUpdate(b, encodeAwarenessUpdate(a, present), BRIDGE);
|
|
96
|
+
const gone = removed.filter((id) => id !== b.clientID && b.getStates().has(id));
|
|
97
|
+
if (gone.length > 0) applyAwarenessUpdate(b, encodeDepartures(b, gone), BRIDGE);
|
|
98
|
+
};
|
|
99
|
+
// Hub → room. A DEPARTED marker is a removal here; a real removal (the hub's
|
|
100
|
+
// own timeout, a peer's socket closing) relays as ever.
|
|
101
|
+
const bToA = ({ added, updated, removed }: AwarenessChange, origin: unknown) => {
|
|
102
|
+
if (origin === BRIDGE) return;
|
|
103
|
+
const states = b.getStates();
|
|
104
|
+
const departed = added.concat(updated).filter((id) => isDeparted(states.get(id)));
|
|
105
|
+
const live = added.concat(updated).filter((id) => !isDeparted(states.get(id)));
|
|
106
|
+
const changed = live.concat(removed);
|
|
107
|
+
if (changed.length > 0) applyAwarenessUpdate(a, encodeAwarenessUpdate(b, changed), BRIDGE);
|
|
108
|
+
const leaving = departed.filter((id) => a.getStates().has(id));
|
|
109
|
+
if (leaving.length > 0) removeAwarenessStates(a, leaving, BRIDGE);
|
|
110
|
+
};
|
|
63
111
|
|
|
64
|
-
// Initial state exchange — push each side's current states to the other
|
|
112
|
+
// Initial state exchange — push each side's current states to the other
|
|
113
|
+
// (departed markers are not a presence).
|
|
65
114
|
const aClients = Array.from(a.getStates().keys());
|
|
66
115
|
if (aClients.length > 0) applyAwarenessUpdate(b, encodeAwarenessUpdate(a, aClients), BRIDGE);
|
|
67
|
-
const bClients = Array.from(b.getStates().
|
|
116
|
+
const bClients = Array.from(b.getStates().entries())
|
|
117
|
+
.filter(([, state]) => !isDeparted(state))
|
|
118
|
+
.map(([id]) => id);
|
|
68
119
|
if (bClients.length > 0) applyAwarenessUpdate(a, encodeAwarenessUpdate(b, bClients), BRIDGE);
|
|
69
120
|
|
|
70
121
|
a.on('update', aToB);
|