@1agh/maude 1.3.1 → 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 +8 -0
- package/apps/studio/canvas-lib.tsx +25 -10
- package/apps/studio/client/app.jsx +2 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +75 -62
- package/apps/studio/client/photo-knobs.jsx +3 -3
- package/apps/studio/comments-overlay.tsx +86 -2
- package/apps/studio/context.ts +93 -1
- package/apps/studio/dist/client.bundle.js +524 -524
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/http.ts +4 -1
- 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 +32 -0
- package/apps/studio/sync/transaction-client.ts +3 -0
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/ws.ts +9 -2
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +15 -2
- package/plugins/flow/README.md +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Which SVG an annotation edit names as its base (DDR-241 accepted revisions).
|
|
2
|
+
//
|
|
3
|
+
// The project merges an annotation proposal element by element against the
|
|
4
|
+
// text it was derived from. The canvas used to send the CANONICAL
|
|
5
|
+
// re-serialization of the strokes it held — fine while the stored text is
|
|
6
|
+
// canonical too, and silently fatal when it is not: an element written by an
|
|
7
|
+
// older serializer, a hand edit or any other writer then looks changed on BOTH
|
|
8
|
+
// sides, the merge refuses it as a conflict, and the person's edit is reverted
|
|
9
|
+
// without a word (plan T31, L09 arrowheads on a non-canonical arrow).
|
|
10
|
+
//
|
|
11
|
+
// An edit made on exactly the state this canvas last RECEIVED names the text
|
|
12
|
+
// it received — the project then sees "nothing changed underneath", accepts
|
|
13
|
+
// the edit whole, and the stored text becomes canonical as a side effect. Any
|
|
14
|
+
// other state (edits stacked before the echo returns) is this canvas's own
|
|
15
|
+
// canonical output, which is what the project holds after accepting it.
|
|
16
|
+
|
|
17
|
+
import { type Stroke, strokesToSvg } from './annotations-model.ts';
|
|
18
|
+
|
|
19
|
+
export interface ReceivedAnnotations {
|
|
20
|
+
/** The SVG text exactly as the project delivered it. */
|
|
21
|
+
raw: string;
|
|
22
|
+
/** The same state as this client serializes it. */
|
|
23
|
+
canon: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function receivedAnnotations(raw: string, strokes: readonly Stroke[]): ReceivedAnnotations {
|
|
27
|
+
return { raw, canon: strokesToSvg(strokes) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function annotationEditBase(
|
|
31
|
+
before: readonly Stroke[],
|
|
32
|
+
received: ReceivedAnnotations | null
|
|
33
|
+
): string {
|
|
34
|
+
const canon = strokesToSvg(before);
|
|
35
|
+
return received && received.canon === canon ? received.raw : canon;
|
|
36
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
annotationEditBase,
|
|
3
|
+
type ReceivedAnnotations,
|
|
4
|
+
receivedAnnotations,
|
|
5
|
+
} from './annotation-edit-base.ts';
|
|
1
6
|
import { createAnnotationEchoGuard, observeAnnotationSnapshots } from './annotations-sync.ts';
|
|
2
7
|
/**
|
|
3
8
|
* @file annotations-layer.tsx — FigJam-style annotation overlay
|
|
@@ -1157,6 +1162,9 @@ export function AnnotationsLayer() {
|
|
|
1157
1162
|
// peer undo/delete can legitimately return to any previously rendered SVG.
|
|
1158
1163
|
const annotationEchoRef = useRef(createAnnotationEchoGuard());
|
|
1159
1164
|
const annotationsChangedRef = useRef(false);
|
|
1165
|
+
// The annotations as the project last delivered them — the base an edit on
|
|
1166
|
+
// exactly that state names (annotation-edit-base.ts).
|
|
1167
|
+
const receivedRef = useRef<ReceivedAnnotations | null>(null);
|
|
1160
1168
|
useEffect(() => {
|
|
1161
1169
|
const file = deriveFile();
|
|
1162
1170
|
fileRef.current = file;
|
|
@@ -1169,6 +1177,7 @@ export function AnnotationsLayer() {
|
|
|
1169
1177
|
.then((text) => {
|
|
1170
1178
|
if (cancelled || annotationsChangedRef.current) return;
|
|
1171
1179
|
const loaded = svgToStrokes(text);
|
|
1180
|
+
receivedRef.current = receivedAnnotations(text, loaded);
|
|
1172
1181
|
if (loaded.length) {
|
|
1173
1182
|
setStrokesState(loaded);
|
|
1174
1183
|
}
|
|
@@ -1186,8 +1195,9 @@ export function AnnotationsLayer() {
|
|
|
1186
1195
|
if (!collab) return;
|
|
1187
1196
|
return observeAnnotationSnapshots(collab.doc, (svg, writeId) => {
|
|
1188
1197
|
annotationsChangedRef.current = true;
|
|
1189
|
-
if (annotationEchoRef.current.isOwn(svg, writeId)) return;
|
|
1190
1198
|
const incoming = svgToStrokes(svg);
|
|
1199
|
+
receivedRef.current = receivedAnnotations(svg, incoming);
|
|
1200
|
+
if (annotationEchoRef.current.isOwn(svg, writeId)) return;
|
|
1191
1201
|
setStrokesState((prev) => reconcileForeignEcho(prev, incoming));
|
|
1192
1202
|
});
|
|
1193
1203
|
}, [collab]);
|
|
@@ -1243,7 +1253,7 @@ export function AnnotationsLayer() {
|
|
|
1243
1253
|
const persistableBefore = before.some(isEphemeralHref)
|
|
1244
1254
|
? before.filter((s) => !isEphemeralHref(s))
|
|
1245
1255
|
: before;
|
|
1246
|
-
const base =
|
|
1256
|
+
const base = annotationEditBase(persistableBefore, receivedRef.current);
|
|
1247
1257
|
// Phase 8 Task 5 — record the SVG we just authored locally so the
|
|
1248
1258
|
// server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
|
|
1249
1259
|
// Y.Map.observe) doesn't trigger a redundant setStrokesState.
|
|
@@ -4984,6 +4994,7 @@ function AnnotationContextMenu({
|
|
|
4984
4994
|
<button
|
|
4985
4995
|
type="button"
|
|
4986
4996
|
role="menuitem"
|
|
4997
|
+
data-action={id}
|
|
4987
4998
|
disabled={opts?.disabled}
|
|
4988
4999
|
className={`dc-menu-item${opts?.destructive ? ' is-destructive' : ''}`}
|
|
4989
5000
|
onClick={() => {
|
package/apps/studio/api.ts
CHANGED
|
@@ -3966,6 +3966,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3966
3966
|
);
|
|
3967
3967
|
for (const artifact of canvasArtifacts({ rel: fromRel, paths })) {
|
|
3968
3968
|
if (artifact.kind !== 'slug-keyed') continue; // primary/siblings already moved with the dir
|
|
3969
|
+
if (artifact.carryOnMove === false) {
|
|
3970
|
+
// The old slug's CRDT cache, stamped "moved away" — the same drop as
|
|
3971
|
+
// moveCanvas; carried, the new document opens retired.
|
|
3972
|
+
await rm(artifact.abs, { force: true }).catch((err) => {
|
|
3973
|
+
console.warn(`[move] could not drop the stale doc cache: ${(err as Error).message}`);
|
|
3974
|
+
});
|
|
3975
|
+
continue;
|
|
3976
|
+
}
|
|
3969
3977
|
const dest = relocatedName(artifact, fromRel, toRel, paths);
|
|
3970
3978
|
if (path.resolve(dest) === path.resolve(artifact.abs)) continue;
|
|
3971
3979
|
try {
|
|
@@ -3138,32 +3138,47 @@ const ASSET_REF_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
|
|
|
3138
3138
|
* baked, the element's `src`/`href` is a `data:` URL that no longer contains
|
|
3139
3139
|
* the original asset path, so the substring match alone would lose track of
|
|
3140
3140
|
* it on the very next edit. */
|
|
3141
|
-
function
|
|
3141
|
+
function photoRefOf(el: Element): string {
|
|
3142
|
+
return el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
/**
|
|
3146
|
+
* Is the element's `data-photo-asset` tag still true? The tag is this bridge's
|
|
3147
|
+
* memory of a node it baked — while baked, the node shows a `data:` URL; left
|
|
3148
|
+
* alone, it shows the asset. Once the canvas points the node at something
|
|
3149
|
+
* else (Replace… on the image), the memory is stale: the node is no longer
|
|
3150
|
+
* this photo, and acting on the tag would put the old picture back over the
|
|
3151
|
+
* new one on the machine that made the replace (plan T31/L12).
|
|
3152
|
+
*/
|
|
3153
|
+
function photoTagCurrent(el: Element, tagged: string): boolean {
|
|
3154
|
+
const ref = photoRefOf(el);
|
|
3155
|
+
return ref.startsWith('data:') || ref.includes(tagged);
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
export function findPhotoEl(asset: string): Element | null {
|
|
3142
3159
|
if (typeof document === 'undefined') return null;
|
|
3143
3160
|
// Scoped to img/image (not a bare `[data-photo-asset]` attribute selector) —
|
|
3144
3161
|
// the canvas iframe is untrusted content (DDR-054); an authored canvas could
|
|
3145
3162
|
// otherwise stamp the tag on an arbitrary element to redirect a bake.
|
|
3146
3163
|
for (const n of document.querySelectorAll('img[data-photo-asset], image[data-photo-asset]')) {
|
|
3147
|
-
if (n.getAttribute('data-photo-asset')
|
|
3164
|
+
if (n.getAttribute('data-photo-asset') !== asset) continue;
|
|
3165
|
+
if (photoTagCurrent(n, asset)) return n;
|
|
3166
|
+
n.removeAttribute('data-photo-asset');
|
|
3148
3167
|
}
|
|
3149
3168
|
for (const n of document.querySelectorAll('img, image')) {
|
|
3150
|
-
|
|
3151
|
-
n.getAttribute('src') || n.getAttribute('href') || n.getAttribute('xlink:href') || '';
|
|
3152
|
-
if (src.includes(asset)) return n;
|
|
3169
|
+
if (photoRefOf(n).includes(asset)) return n;
|
|
3153
3170
|
}
|
|
3154
3171
|
return null;
|
|
3155
3172
|
}
|
|
3156
3173
|
|
|
3157
|
-
function extractAssetRef(el: Element): string | null {
|
|
3174
|
+
export function extractAssetRef(el: Element): string | null {
|
|
3158
3175
|
// Only trust `data-photo-asset` when it actually has the `assets/<sha8>.<ext>`
|
|
3159
3176
|
// shape — the tag is attacker-controllable (untrusted canvas content,
|
|
3160
3177
|
// DDR-054), and an unshaped value would otherwise ride unbounded into
|
|
3161
3178
|
// `_active.json`/the WS broadcast via inspect.ts's `enrich()`.
|
|
3162
3179
|
const tagged = el.getAttribute('data-photo-asset');
|
|
3163
|
-
if (tagged && ASSET_REF_RE.test(tagged)) return tagged;
|
|
3164
|
-
|
|
3165
|
-
el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
|
|
3166
|
-
return ref.match(ASSET_REF_RE)?.[0] ?? null;
|
|
3180
|
+
if (tagged && ASSET_REF_RE.test(tagged) && photoTagCurrent(el, tagged)) return tagged;
|
|
3181
|
+
return photoRefOf(el).match(ASSET_REF_RE)?.[0] ?? null;
|
|
3167
3182
|
}
|
|
3168
3183
|
|
|
3169
3184
|
function setPhotoElSrc(el: Element, url: string): void {
|
|
@@ -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}
|
|
@@ -7080,6 +7081,7 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
7080
7081
|
<button
|
|
7081
7082
|
type="button"
|
|
7082
7083
|
className="st-cp-sechd"
|
|
7084
|
+
data-testid={`inspector-section-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
|
|
7083
7085
|
aria-expanded={!!open[name]}
|
|
7084
7086
|
onClick={() => setOpen((o) => ({ ...o, [name]: !o[name] }))}
|
|
7085
7087
|
>
|
|
@@ -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 && (
|
|
@@ -338,6 +338,11 @@ export function CommentsOverlay(): React.ReactNode {
|
|
|
338
338
|
// safe because they carry identical content; the Y.Array path just
|
|
339
339
|
// reaches us first (no 800 ms debounce delay).
|
|
340
340
|
const collab = useCollab();
|
|
341
|
+
// Who is looking — the same identity a new comment is signed with (the
|
|
342
|
+
// project's git user on a desktop, the vouched member in a cloud cell).
|
|
343
|
+
// Asked directly: this overlay also runs in the comment-mount bundle, where
|
|
344
|
+
// the collab context is another module's and reads as null.
|
|
345
|
+
const me = useViewerName();
|
|
341
346
|
useEffect(() => {
|
|
342
347
|
if (!collab) return;
|
|
343
348
|
const arr = collab.doc.getArray<OverlayComment>('comments');
|
|
@@ -566,6 +571,11 @@ export function CommentsOverlay(): React.ReactNode {
|
|
|
566
571
|
onPatch={(patch) => handlePatch(focused.id, patch)}
|
|
567
572
|
onDelete={() => handleDelete(focused.id)}
|
|
568
573
|
onReply={(body) => handleReply(focused.id, body)}
|
|
574
|
+
mine={
|
|
575
|
+
!isReadOnlyCanvas() &&
|
|
576
|
+
!!(me ?? collab?.myName) &&
|
|
577
|
+
(focused.author ?? '').trim() === (me ?? collab?.myName ?? '').trim()
|
|
578
|
+
}
|
|
569
579
|
/>
|
|
570
580
|
);
|
|
571
581
|
})()}
|
|
@@ -1048,13 +1058,33 @@ function CommentComposer({
|
|
|
1048
1058
|
// directly to `/_api/comments/<id>/reply` because that endpoint exists only on
|
|
1049
1059
|
// Bun runtime and lives in `http.ts`.
|
|
1050
1060
|
|
|
1051
|
-
|
|
1061
|
+
let viewerName: Promise<string | null> | null = null;
|
|
1062
|
+
/** The viewer's display identity, asked once per document. */
|
|
1063
|
+
function useViewerName(): string | null {
|
|
1064
|
+
const [name, setName] = useState<string | null>(null);
|
|
1065
|
+
useEffect(() => {
|
|
1066
|
+
if (typeof fetch === 'undefined') return;
|
|
1067
|
+
viewerName ??= fetch('/_api/git-user')
|
|
1068
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
1069
|
+
.then((j) => (typeof j?.name === 'string' && j.name.trim() ? j.name.trim() : null))
|
|
1070
|
+
.catch(() => null);
|
|
1071
|
+
let alive = true;
|
|
1072
|
+
void viewerName.then((n) => alive && setName(n));
|
|
1073
|
+
return () => {
|
|
1074
|
+
alive = false;
|
|
1075
|
+
};
|
|
1076
|
+
}, []);
|
|
1077
|
+
return name;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export function CommentThread({
|
|
1052
1081
|
comment,
|
|
1053
1082
|
sequence,
|
|
1054
1083
|
onClose,
|
|
1055
1084
|
onPatch,
|
|
1056
1085
|
onDelete,
|
|
1057
1086
|
onReply,
|
|
1087
|
+
mine = false,
|
|
1058
1088
|
}: {
|
|
1059
1089
|
comment: OverlayComment;
|
|
1060
1090
|
sequence: number;
|
|
@@ -1062,12 +1092,23 @@ function CommentThread({
|
|
|
1062
1092
|
onPatch: (patch: Record<string, unknown>) => void;
|
|
1063
1093
|
onDelete: () => void;
|
|
1064
1094
|
onReply: (body: string) => Promise<boolean>;
|
|
1095
|
+
/** The person looking wrote this comment — they may edit its text. */
|
|
1096
|
+
mine?: boolean;
|
|
1065
1097
|
}) {
|
|
1066
1098
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
|
1067
1099
|
const replyRef = useRef<HTMLTextAreaElement | null>(null);
|
|
1068
1100
|
const rafRef = useRef<number | null>(null);
|
|
1069
1101
|
const [reply, setReply] = useState('');
|
|
1070
1102
|
const [sending, setSending] = useState(false);
|
|
1103
|
+
// Editing your own comment: the text in place, saved as a patch (the same
|
|
1104
|
+
// channel resolve/reopen use). null = not editing.
|
|
1105
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
1106
|
+
const saveDraft = useCallback(() => {
|
|
1107
|
+
const v = (draft ?? '').trim();
|
|
1108
|
+
if (!v) return;
|
|
1109
|
+
if (v !== comment.text) onPatch({ text: v });
|
|
1110
|
+
setDraft(null);
|
|
1111
|
+
}, [draft, comment.text, onPatch]);
|
|
1071
1112
|
|
|
1072
1113
|
// Live anchor — popover tracks the pin via rAF so it stays glued to its
|
|
1073
1114
|
// target through pan / zoom (FigJam parity). Writing to the dialog style
|
|
@@ -1176,7 +1217,40 @@ function CommentThread({
|
|
|
1176
1217
|
{selectorChip ? <code className="cm-thread__selector">{selectorChip}</code> : null}
|
|
1177
1218
|
</div>
|
|
1178
1219
|
|
|
1179
|
-
|
|
1220
|
+
{draft === null ? (
|
|
1221
|
+
<div className="cm-thread__body">{renderBodyWithMentions(comment.text)}</div>
|
|
1222
|
+
) : (
|
|
1223
|
+
<div className="cm-thread__reply-form">
|
|
1224
|
+
<MentionAwareTextarea
|
|
1225
|
+
className="cm-thread__reply-textarea"
|
|
1226
|
+
value={draft}
|
|
1227
|
+
placeholder="Edit your comment… ⌘↵ to save"
|
|
1228
|
+
onChange={setDraft}
|
|
1229
|
+
onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
1230
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
1231
|
+
e.preventDefault();
|
|
1232
|
+
saveDraft();
|
|
1233
|
+
}
|
|
1234
|
+
}}
|
|
1235
|
+
rows={3}
|
|
1236
|
+
ariaLabel="Edit comment"
|
|
1237
|
+
/>
|
|
1238
|
+
<div className="cm-thread__reply-actions">
|
|
1239
|
+
<button type="button" className="cm-btn" onClick={() => setDraft(null)}>
|
|
1240
|
+
Cancel
|
|
1241
|
+
</button>
|
|
1242
|
+
<button
|
|
1243
|
+
type="button"
|
|
1244
|
+
className="cm-btn cm-btn--primary"
|
|
1245
|
+
data-testid="comment-edit-save"
|
|
1246
|
+
disabled={!draft.trim()}
|
|
1247
|
+
onClick={saveDraft}
|
|
1248
|
+
>
|
|
1249
|
+
Save
|
|
1250
|
+
</button>
|
|
1251
|
+
</div>
|
|
1252
|
+
</div>
|
|
1253
|
+
)}
|
|
1180
1254
|
|
|
1181
1255
|
{(comment.thread ?? []).map((r) => (
|
|
1182
1256
|
<div className="cm-thread__reply" key={r.id}>
|
|
@@ -1218,6 +1292,16 @@ function CommentThread({
|
|
|
1218
1292
|
</div>
|
|
1219
1293
|
|
|
1220
1294
|
<div className="cm-thread__actions">
|
|
1295
|
+
{mine && draft === null ? (
|
|
1296
|
+
<button
|
|
1297
|
+
type="button"
|
|
1298
|
+
className="cm-btn"
|
|
1299
|
+
data-testid="comment-edit"
|
|
1300
|
+
onClick={() => setDraft(comment.text)}
|
|
1301
|
+
>
|
|
1302
|
+
Edit
|
|
1303
|
+
</button>
|
|
1304
|
+
) : null}
|
|
1221
1305
|
{comment.status === 'resolved' ? (
|
|
1222
1306
|
<button type="button" className="cm-btn" onClick={() => onPatch({ status: 'open' })}>
|
|
1223
1307
|
↺ Reopen
|