@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.
@@ -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 = strokesToSvg(persistableBefore);
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={() => {
@@ -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 findPhotoEl(asset: string): Element | null {
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') === asset) return n;
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
- const src =
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
- const ref =
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] = useState(null);
357
- const [moveDrag, setMoveDrag] = useState(null);
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] = useState(null);
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] = useState(null);
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] = useState(null);
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
- setRetimeDrag((d) => {
512
- if (d && d.curDur !== d.startDur) onRetime?.(d.ref ?? d.index, { durationInFrames: d.curDur });
513
- return null;
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
- setMoveDrag((d) => {
541
- if (d) {
542
- // Dropping the clip ON the storyline lane moves it between layers
543
- // (the layers model); otherwise commit the horizontal from-move.
544
- const story = document.querySelector('.tl-storyline-track')?.getBoundingClientRect();
545
- if (
546
- story &&
547
- onClipVerb &&
548
- e.clientY >= story.top &&
549
- e.clientY <= story.bottom &&
550
- movedRef.current
551
- ) {
552
- onClipVerb(d.ref ?? { index: d.index }, 'to-storyline', {});
553
- } else if (d.curFrom !== d.startFrom) {
554
- onRetime?.(d.ref ?? d.index, { from: d.curFrom });
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
- setTrimInDrag((d) => {
580
- if (d && d.delta) onClipVerb?.(d.ref, 'trim-in', { deltaFrames: d.delta });
581
- return null;
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
- setReorderDrag((d) => {
650
- if (d && movedRef.current && onClipVerb) {
651
- const story = document.querySelector('.tl-storyline-track')?.getBoundingClientRect();
652
- if (story && e.clientY < story.top - 6) {
653
- const beat = sequences[d.index];
654
- if (beat) {
655
- onClipVerb(refFor(beat, d.index), 'to-overlay', {});
656
- return null;
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
- if (d && d.targetSlot !== d.slot) {
661
- const moved = story[d.slot];
662
- // targetSlot counts positions among the OTHER beats — map to a ref
663
- // beat + before/after in the original order.
664
- const others = story.filter((_, k) => k !== d.slot);
665
- if (moved && others.length) {
666
- if (d.targetSlot <= 0) {
667
- onReorderMove?.(refFor(moved.s, moved.i), refFor(others[0].s, others[0].i), 'before');
668
- } else {
669
- const ref = others[Math.min(d.targetSlot, others.length) - 1];
670
- onReorderMove?.(refFor(moved.s, moved.i), refFor(ref.s, ref.i), 'after');
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
- return null;
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
- setLayerDrag((d) => {
705
- if (d && d.curTarget !== d.docIdx) {
706
- const seq = sequences[d.index];
707
- if (seq) onClipVerb?.(refFor(seq, d.index), 'layer-order', { toIndex: d.curTarget });
708
- }
709
- return null;
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
- function CommentThread({
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
- <div className="cm-thread__body">{renderBodyWithMentions(comment.text)}</div>
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