@1agh/maude 1.4.5 → 1.5.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.
Files changed (106) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +533 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops.ts +474 -0
  21. package/apps/studio/annotations/registry.ts +159 -0
  22. package/apps/studio/annotations/replica.ts +264 -0
  23. package/apps/studio/annotations/scene.ts +235 -0
  24. package/apps/studio/annotations/schema.ts +188 -0
  25. package/apps/studio/annotations/types.ts +77 -0
  26. package/apps/studio/annotations/ui/board.ts +126 -0
  27. package/apps/studio/annotations/ui/containment.ts +118 -0
  28. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  29. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  30. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  31. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  32. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  33. package/apps/studio/annotations/ui/render-model.ts +264 -0
  34. package/apps/studio/annotations/ui/scene.tsx +63 -0
  35. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  36. package/apps/studio/annotations/ui/text-session.ts +140 -0
  37. package/apps/studio/annotations/ui/text-style.ts +111 -0
  38. package/apps/studio/annotations/ui/world.ts +47 -0
  39. package/apps/studio/annotations/v1-adapter.ts +428 -0
  40. package/apps/studio/annotations-align.ts +21 -6
  41. package/apps/studio/annotations-bindings.ts +2 -0
  42. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  43. package/apps/studio/annotations-groups.ts +3 -0
  44. package/apps/studio/annotations-layer.tsx +923 -1925
  45. package/apps/studio/annotations-model.ts +97 -4
  46. package/apps/studio/annotations-sync.ts +4 -47
  47. package/apps/studio/api.ts +262 -68
  48. package/apps/studio/bin/_import-figma.mjs +22 -12
  49. package/apps/studio/bin/annotate.mjs +331 -838
  50. package/apps/studio/bin/annotate.sh +4 -4
  51. package/apps/studio/bin/perf.sh +21 -7
  52. package/apps/studio/bin/read-annotations.mjs +184 -666
  53. package/apps/studio/bin/read-annotations.sh +9 -5
  54. package/apps/studio/canvas-artifacts.ts +9 -0
  55. package/apps/studio/canvas-comment-mount.tsx +35 -2
  56. package/apps/studio/canvas-lib.tsx +18 -1
  57. package/apps/studio/canvas-shell.tsx +54 -1
  58. package/apps/studio/client/app.jsx +101 -35
  59. package/apps/studio/client/comments-overlay.css +10 -0
  60. package/apps/studio/client/hmr.mjs +1 -1
  61. package/apps/studio/client/panels/git-grouping.js +2 -2
  62. package/apps/studio/client/tree-expansion.js +217 -0
  63. package/apps/studio/collab/index.ts +58 -5
  64. package/apps/studio/collab/persistence.ts +80 -11
  65. package/apps/studio/collab/registry.ts +63 -26
  66. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  67. package/apps/studio/comment-anchor.ts +116 -0
  68. package/apps/studio/comments-overlay.tsx +78 -35
  69. package/apps/studio/context.ts +1 -0
  70. package/apps/studio/cursors-overlay.tsx +337 -109
  71. package/apps/studio/dist/client.bundle.js +850 -850
  72. package/apps/studio/dist/comment-mount.js +2 -2
  73. package/apps/studio/figma/to-strokes.ts +31 -10
  74. package/apps/studio/git/endpoints.ts +1 -1
  75. package/apps/studio/git/service.ts +1 -1
  76. package/apps/studio/git/watch.ts +1 -1
  77. package/apps/studio/http.ts +90 -15
  78. package/apps/studio/server.ts +16 -0
  79. package/apps/studio/sync/accepted-cold-start.ts +72 -8
  80. package/apps/studio/sync/agent.ts +36 -6
  81. package/apps/studio/sync/codec.ts +95 -40
  82. package/apps/studio/sync/comment-ledger.ts +229 -0
  83. package/apps/studio/sync/file-membership.ts +12 -2
  84. package/apps/studio/sync/file-plane.ts +78 -2
  85. package/apps/studio/sync/index.ts +75 -16
  86. package/apps/studio/sync/journal-client.ts +5 -0
  87. package/apps/studio/sync/limits.ts +7 -2
  88. package/apps/studio/sync/migrate-seed.ts +17 -7
  89. package/apps/studio/sync/projection.ts +7 -2
  90. package/apps/studio/sync/remote-docs.ts +34 -0
  91. package/apps/studio/sync/writer-registry.ts +10 -0
  92. package/apps/studio/text-caret.ts +11 -2
  93. package/apps/studio/tree-state.ts +45 -0
  94. package/apps/studio/undo-stack.ts +2 -2
  95. package/apps/studio/use-annotation-resize.tsx +48 -23
  96. package/apps/studio/use-annotation-selection.tsx +9 -2
  97. package/apps/studio/use-collab.tsx +104 -1
  98. package/apps/studio/use-selection-set.tsx +4 -0
  99. package/apps/studio/whats-new.json +63 -0
  100. package/cli/lib/design-link.mjs +5 -1
  101. package/cli/lib/gitignore-block.mjs +1 -1
  102. package/cli/lib/gitignore-drift.mjs +2 -1
  103. package/package.json +9 -8
  104. package/plugins/design/templates/brief-board.tsx.template +1 -1
  105. package/apps/studio/annotation-edit-base.ts +0 -36
  106. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -34,10 +34,12 @@ import path from 'node:path';
34
34
 
35
35
  import type * as Y from 'yjs';
36
36
 
37
+ import { readReplica } from '../annotations/replica.ts';
37
38
  import { Y_TYPES } from '../collab/persistence.ts';
38
39
  import { atomicWrite } from './atomic-write.ts';
39
40
  import {
40
41
  annotationsEditAtFromDoc,
42
+ annotationsFromDoc,
41
43
  applyAnnotationsToDoc,
42
44
  applyCommentsToDoc,
43
45
  applyCssToDoc,
@@ -45,6 +47,7 @@ import {
45
47
  applyMetaToDoc,
46
48
  bodyEditAtFromDoc,
47
49
  isEmptyAnnotationsSvg,
50
+ readLocalAnnotations,
48
51
  stampAnnotationsEdit,
49
52
  stampBodyEdit,
50
53
  Y_SYNC_TYPES,
@@ -58,6 +61,7 @@ import {
58
61
  unionCommentsById,
59
62
  } from './cold-start.ts';
60
63
  import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
64
+ import { type CommentLedger, withoutRemotelyDeleted } from './comment-ledger.ts';
61
65
  import { hashBytes } from './echo-guard.ts';
62
66
  import type { SyncJournal } from './journal.ts';
63
67
  import { ORIGINS } from './origins.ts';
@@ -85,6 +89,8 @@ export interface MigrateSeedOptions {
85
89
  historyDir?: string;
86
90
  /** DDR-102 — per-machine journal; gates fast-forward vs conflict. */
87
91
  journal?: SyncJournal;
92
+ /** Issue #133 — comment ids synced from here before (sync/comment-ledger.ts). */
93
+ commentLedger?: CommentLedger;
88
94
  /** DDR-102 — body snapshot writer (history.ts), same contract as the agent's. */
89
95
  snapshot?: (content: string, reason: ColdStartSnapshotReason) => Promise<string | null>;
90
96
  /**
@@ -152,8 +158,9 @@ export function docIsEmpty(doc: Y.Doc): boolean {
152
158
  if (doc.getText(Y_SYNC_TYPES.css).length > 0) return false;
153
159
  if (doc.getText(Y_SYNC_TYPES.meta).length > 0) return false;
154
160
  if (doc.getArray(Y_TYPES.comments).length > 0) return false;
155
- const svg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
156
- if (typeof svg === 'string' && svg.length > 0) return false;
161
+ // DDR-242 — the annotations replica (or a legacy v1 value read through the
162
+ // migration): content means at least one element.
163
+ if ((readReplica(doc)?.elements.length ?? 0) > 0) return false;
157
164
  return true;
158
165
  }
159
166
 
@@ -171,7 +178,7 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
171
178
  const localHtml =
172
179
  diskHtml !== null && sourceError(paths.html, diskHtml) === null ? diskHtml : null;
173
180
  const localComments = readLocal(paths.comments);
174
- const localAnnotations = readLocal(paths.annotations);
181
+ const localAnnotations = readLocalAnnotations(paths.annotations, readLocal);
175
182
  const localMeta = paths.meta ? readLocal(paths.meta) : null;
176
183
  const readCss = paths.css ? readLocal(paths.css) : null;
177
184
  const localCss = readCss === null ? null : (collapseRepeatedText(readCss)?.unit ?? readCss);
@@ -352,8 +359,7 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
352
359
  // right after this seed ran. Resolve the lane here, before the room
353
360
  // materializes: unstamped emptiness never beats content.
354
361
  {
355
- const docSvg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
356
- const docAnnotations = typeof docSvg === 'string' ? docSvg : '';
362
+ const docAnnotations = annotationsFromDoc(doc) ?? '';
357
363
  const annDecision = decideAnnotationsColdStart({
358
364
  local: localAnnotations,
359
365
  doc: docAnnotations,
@@ -414,9 +420,13 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
414
420
  // delete-then-insert codec — same-id entries keep the doc's version, so the
415
421
  // duplication trap stays closed; local-only comments survive.
416
422
  if (localComments) {
417
- const parsed = tryParseJsonArray(localComments);
423
+ const docList = doc.getArray(Y_TYPES.comments).toArray();
424
+ const read = tryParseJsonArray(localComments);
425
+ // Issue #133 — never union back a comment a peer deleted while we were away.
426
+ const parsed = read
427
+ ? withoutRemotelyDeleted(read, docList, opts.commentLedger?.get(slug))
428
+ : null;
418
429
  if (parsed && parsed.length > 0) {
419
- const docList = doc.getArray(Y_TYPES.comments).toArray();
420
430
  const merged = unionCommentsById(docList, parsed);
421
431
  if (merged.length !== docList.length) {
422
432
  doc.transact(() => {
@@ -37,10 +37,12 @@ import {
37
37
  applyMetaToDoc,
38
38
  cssFromDoc,
39
39
  htmlFromDoc,
40
+ importAnnotationsFromDisk,
40
41
  laneValueFromFile,
41
42
  mergeSharedMetaIntoLocal,
42
43
  metaFromDoc,
43
44
  movedToFromDoc,
45
+ noteAnnotationsOnDisk,
44
46
  readLaneFromDoc,
45
47
  stampAnnotationsEdit,
46
48
  stampBodyEdit,
@@ -84,7 +86,7 @@ export interface ProjectionPaths {
84
86
  html: string;
85
87
  /** Absolute path to `_comments/<slug>.json`. */
86
88
  comments: string;
87
- /** Absolute path to `<slug>.annotations.svg`. */
89
+ /** Absolute path to `<slug>.annotations.json` (DDR-242). */
88
90
  annotations: string;
89
91
  /** Absolute path to the canvas `.meta.json` sibling (optional). */
90
92
  meta?: string;
@@ -449,6 +451,9 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
449
451
  * fire must never cost the write that already succeeded.
450
452
  */
451
453
  function writeAndAnnounce(path: string, value: string): void {
454
+ // The board disk holds from here on — the base a later file event is
455
+ // imported against (codec importAnnotationsFromDisk).
456
+ if (path === paths.annotations) noteAnnotationsOnDisk(doc, value);
452
457
  if (readLocal(path) === value) return;
453
458
  writer(path, value);
454
459
  try {
@@ -1159,7 +1164,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
1159
1164
  // survives cold start on other peers (the 2026-08-14 eraser fix).
1160
1165
  let changed = false;
1161
1166
  doc.transact(() => {
1162
- changed = applyAnnotationsToDoc(doc, str, importOrigin);
1167
+ changed = importAnnotationsFromDisk(doc, str, importOrigin);
1163
1168
  if (changed) stampAnnotationsEdit(doc, importOrigin);
1164
1169
  }, importOrigin);
1165
1170
  return changed;
@@ -391,3 +391,37 @@ export function resolvePulledTarget(args: {
391
391
  }
392
392
  return { bodyAbs, fromPath };
393
393
  }
394
+
395
+ /**
396
+ * The listing as an accepted-revisions project states it.
397
+ *
398
+ * In accepted mode the MANIFEST names the live canvases: an open Yjs room is
399
+ * not necessarily an accepted canvas (its author can connect before
400
+ * `doc.create` commits), so the documents come from the manifest, with the
401
+ * transport listing's byte counts where it has them.
402
+ *
403
+ * The same holds for DELETIONS. The transport's tombstones outlive the legacy
404
+ * era: a canvas deleted then and later re-created under the same name through
405
+ * `doc.create` stays tombstoned in the document store while the manifest lists
406
+ * it live — and every peer that honoured the tombstone skipped a live canvas
407
+ * (the web showed it, the desktop never pulled it). A tombstone for a name the
408
+ * manifest lists live is stale and dropped; one for a name the manifest
409
+ * retired, or does not carry, still stands. A peer's OWN just-made deletion is
410
+ * not affected: it is remembered locally and never passes through here.
411
+ */
412
+ export function acceptedListing(
413
+ listing: RemoteListing | null,
414
+ manifestDocs: readonly { doc: string; retired: boolean }[]
415
+ ): RemoteListing {
416
+ const bytesByName = new Map((listing?.documents ?? []).map((d) => [d.name, d.bytes]));
417
+ const documents = manifestDocs
418
+ .filter((d) => !d.retired)
419
+ .map((d) => ({ name: d.doc, bytes: bytesByName.get(d.doc) ?? 1 }));
420
+ const live = new Set(
421
+ manifestDocs.filter((d) => !d.retired).map((d) => slugFromDocName(d.doc) ?? d.doc)
422
+ );
423
+ const tombstones = (listing?.tombstones ?? []).filter(
424
+ (t) => !live.has(slugFromDocName(t.name) ?? t.name)
425
+ );
426
+ return { documents, tombstones };
427
+ }
@@ -79,6 +79,15 @@ export const WRITER_REGISTRY: Record<string, WriterEntry> = {
79
79
  via: 'onAnnotationsChanged hook → annotations lane.replace with the base the layer sent',
80
80
  test: 'test/sync-accepted-projection.test.ts',
81
81
  },
82
+ // DDR-242 §4 — the canvas's op batches. Same lane, same hook: the api applies
83
+ // the ops to the board, then fires onAnnotationsChanged with the pre-batch
84
+ // board as base (the kernel's `annotations.apply` replaces this in Task 12).
85
+ '/_api/annotations/ops': {
86
+ class: 'lane',
87
+ rows: 'S23',
88
+ via: 'onAnnotationsChanged hook → annotations lane.replace with the pre-batch board as base',
89
+ test: 'test/sync-accepted-projection.test.ts',
90
+ },
82
91
  // --- accepted history (T27/T28)
83
92
  '/_api/project/history': { class: 'read' },
84
93
  '/_api/project/restore': {
@@ -230,6 +239,7 @@ export const WRITER_REGISTRY: Record<string, WriterEntry> = {
230
239
  '/_api/sync/settings': { class: 'local' },
231
240
  '/_api/sync/trash': { class: 'local', rows: 'X15 (restore re-enters through cold start)' },
232
241
  '/_api/timeline-media': { class: 'local' },
242
+ '/_api/tree-state': { class: 'local' },
233
243
  '/_api/ui-prefs': { class: 'local' },
234
244
  '/_api/workspace/disclosure': { class: 'local' },
235
245
  '/_api/workspace/sign-in': { class: 'local' },
@@ -146,7 +146,7 @@ interface CaretRect {
146
146
  * should hide (no collapsed selection inside the editable / editable not
147
147
  * focused / a range selection is active, where the native highlight is the
148
148
  * affordance). */
149
- function caretRectFor(editable: HTMLElement, win: Window): CaretRect | null {
149
+ function caretRectFor(editable: HTMLElement, win: Window): CaretRect | 'native' | null {
150
150
  const doc = editable.ownerDocument;
151
151
  const active = doc.activeElement;
152
152
  if (active !== editable && !editable.contains(active)) return null;
@@ -165,6 +165,13 @@ function caretRectFor(editable: HTMLElement, win: Window): CaretRect | null {
165
165
  if (br && br.height > 0) r = br;
166
166
  }
167
167
  if (r && r.height > 0) return { x: r.left, y: r.top, h: r.height };
168
+ // A caret on a line with no text box yet — Shift+Enter's "\n" puts it at
169
+ // the start of an empty last line — has no client rect in Chromium or
170
+ // WebKit. Guessing a position from the editable's box painted it back at
171
+ // the top-left of line ONE (the "Shift+Enter throws the cursor somewhere
172
+ // weird" report). Only the engine knows where that caret is, so hand the
173
+ // caret back to it: the native caret shows until a rect exists again.
174
+ if ((editable.textContent ?? '').replace(/\u200b/g, '') !== '') return 'native';
168
175
  // Empty editable — no rect exists anywhere. Derive a caret box from the
169
176
  // editable's padding box + line metrics. (The annotation editors avoid this
170
177
  // path by mounting with a zero-width-space JUMP_SENTINEL, but the artboard
@@ -221,7 +228,9 @@ export function mountCaret(editable: HTMLElement, win: Window): () => void {
221
228
 
222
229
  const position = (): void => {
223
230
  const rc = caretRectFor(editable, win);
224
- if (!rc) {
231
+ // The engine's own caret, while ours can't be placed (see caretRectFor).
232
+ editable.style.caretColor = rc === 'native' ? prevCaretColor || 'auto' : 'transparent';
233
+ if (!rc || rc === 'native') {
225
234
  if (shown) {
226
235
  caret.style.display = 'none';
227
236
  shown = false;
@@ -0,0 +1,45 @@
1
+ // tree-state.ts — the Files panel's remembered disclosure (issue #124).
2
+ //
3
+ // `dirs` are the repo-relative folder paths the user left expanded (the same
4
+ // `dirPath` the tree rows carry); `sections` maps a section label (PROJECT /
5
+ // UI CANVASES / …) to open/closed. Anything absent is CLOSED — that default is
6
+ // the point of the issue. Stored per project + per member by api.ts under
7
+ // `_canvas-state/`; this module only owns the shape and its bounds, so a
8
+ // hostile or corrupt body can neither grow the file without limit nor smuggle
9
+ // a non-string into a path the client later splits.
10
+
11
+ export interface TreeState {
12
+ dirs: string[];
13
+ sections: Record<string, boolean>;
14
+ }
15
+
16
+ export const TREE_STATE_MAX_DIRS = 2000;
17
+ export const TREE_STATE_MAX_PATH = 1024;
18
+ export const TREE_STATE_MAX_SECTIONS = 64;
19
+ const MAX_LABEL = 128;
20
+
21
+ /** Coerce anything into a bounded, de-duplicated TreeState. Never throws. */
22
+ export function normalizeTreeState(raw: unknown): TreeState {
23
+ const o =
24
+ raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
25
+ const dirs = new Set<string>();
26
+ if (Array.isArray(o.dirs)) {
27
+ for (const d of o.dirs) {
28
+ if (dirs.size >= TREE_STATE_MAX_DIRS) break;
29
+ if (typeof d !== 'string' || !d || d.length > TREE_STATE_MAX_PATH || d.includes('\0'))
30
+ continue;
31
+ dirs.add(d);
32
+ }
33
+ }
34
+ const sections: Record<string, boolean> = {};
35
+ if (o.sections && typeof o.sections === 'object' && !Array.isArray(o.sections)) {
36
+ let n = 0;
37
+ for (const [k, v] of Object.entries(o.sections as Record<string, unknown>)) {
38
+ if (n >= TREE_STATE_MAX_SECTIONS) break;
39
+ if (typeof v !== 'boolean' || !k || k.length > MAX_LABEL || k === '__proto__') continue;
40
+ sections[k] = v;
41
+ n++;
42
+ }
43
+ }
44
+ return { dirs: [...dirs].sort(), sections };
45
+ }
@@ -69,8 +69,8 @@ export interface CommandRecord<P = unknown> {
69
69
  export interface CommandSinks {
70
70
  /** Wired by canvas-lib `DesignCanvasInner`. */
71
71
  layoutPatchFn?: unknown;
72
- /** Wired by `AnnotationsLayer`. */
73
- strokesPutFn?: unknown;
72
+ /** Wired by `AnnotationsLayer` (DDR-242): apply + send an annotation op batch. */
73
+ annotationOpsFn?: unknown;
74
74
  /**
75
75
  * Wired by `CanvasShell` (canvas-shell.tsx). Applies a single inline source
76
76
  * edit (CSS / text / attr) by posting `dgn:'apply-edit'` to the parent shell,
@@ -18,10 +18,13 @@
18
18
  */
19
19
 
20
20
  import { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
21
+ import { defOf } from './annotations/registry.ts';
22
+ import { useAnnotationPipeline } from './annotations/ui/pipeline-context.ts';
21
23
  import { anchorPoint, BIND_THRESHOLD_PX, bindCandidate } from './annotations-bindings.ts';
22
24
  import {
23
25
  type ArrowStroke,
24
26
  canRotate,
27
+ type ElementStroke,
25
28
  type EllipseStroke,
26
29
  HALO_PAD_PX,
27
30
  type ImageStroke,
@@ -141,6 +144,7 @@ export const padDY = (c: Corner): number =>
141
144
  function isResizable(
142
145
  s: Stroke
143
146
  ): s is
147
+ | ElementStroke
144
148
  | RectStroke
145
149
  | EllipseStroke
146
150
  | PolygonStroke
@@ -150,6 +154,10 @@ function isResizable(
150
154
  | ImageStroke
151
155
  | LinkStroke
152
156
  | SectionStroke {
157
+ if (s.tool === 'element') {
158
+ const caps = defOf(s.el.type)?.caps;
159
+ return !!caps?.box && caps.resizable;
160
+ }
153
161
  return (
154
162
  s.tool === 'rect' ||
155
163
  s.tool === 'ellipse' ||
@@ -386,6 +394,15 @@ export function resizeStroke(
386
394
  const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, start.tool === 'sticky');
387
395
  return box as Partial<RectStroke | StickyStroke | PolygonStroke | LinkStroke>;
388
396
  }
397
+ if (start.tool === 'element') {
398
+ // A registry type: fit its box, the fields come from its definition.
399
+ const def = defOf(start.el.type);
400
+ const b0 = strokeBBox(start);
401
+ if (!def || !b0) return null;
402
+ const box = bboxResize(b0, corner, wx, wy, mods, !!def.caps.aspectLock);
403
+ const ctx = { origin: { x: 0, y: 0 }, resolve: () => null };
404
+ return { el: { ...start.el, ...def.resize(start.el, box, ctx) } } as Partial<Stroke>;
405
+ }
389
406
  if (start.tool === 'image') {
390
407
  // Phase 23 — images aspect-LOCK by default and free-resize with Shift held
391
408
  // (the inverse of the shape tools — Figma/FigJam image behaviour). Invert
@@ -560,6 +577,7 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
560
577
  const annotSel = useAnnotationSelection();
561
578
  const controller = useViewportControllerContext();
562
579
  const vp = controller?.viewport ?? null;
580
+ const pipeline = useAnnotationPipeline();
563
581
  const containerRef = useRef<HTMLDivElement | null>(null);
564
582
  const rafRef = useRef<number | null>(null);
565
583
  const dragRef = useRef<{
@@ -725,6 +743,17 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
725
743
  } catch {
726
744
  /* some browsers reject capture on synthetic events */
727
745
  }
746
+ // The layer's pointer pipeline owns the moves and the release (Task 21);
747
+ // mounted without a layer (tests), the overlay listens itself.
748
+ if (pipeline) {
749
+ const pointerId = e.pointerId;
750
+ pipeline.begin(pointerId, {
751
+ kind: isRotCorner(corner) ? 'rotating' : 'resizing',
752
+ move: onMove,
753
+ up: onUp,
754
+ cancel: (ev) => onUp(ev ?? ({ pointerId } as PointerEvent)),
755
+ });
756
+ }
728
757
  };
729
758
  const applyResize = (
730
759
  clientX: number,
@@ -810,19 +839,15 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
810
839
  matchIds.push(bestH.id);
811
840
  }
812
841
  }
813
- document.dispatchEvent(
814
- new CustomEvent('maude:resize-info', {
815
- detail: {
816
- box: { x: patch.x, y: patch.y, w: patch.w, h: patch.h },
817
- matchIds,
818
- },
819
- })
820
- );
842
+ store.setResizeInfo?.({
843
+ box: { x: patch.x, y: patch.y, w: patch.w, h: patch.h },
844
+ matchIds,
845
+ });
821
846
  }
822
847
  // FigJam v3 — dragging an arrow ENDPOINT re-anchors its bind: within the
823
848
  // magnet threshold of a bindable host the endpoint snaps + binds; free
824
849
  // space (or ⌘ held — Figma "suppress snap") clears the bind. The layer
825
- // paints the candidate halo via the maude:bind-hint broadcast.
850
+ // paints the candidate halo (store.setBindHint).
826
851
  if (d.startStroke.tool === 'arrow' && (d.corner === 'ep1' || d.corner === 'ep2')) {
827
852
  const zoom = vp?.zoom || 1;
828
853
  const cand = suppressBind
@@ -852,9 +877,7 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
852
877
  [d.corner === 'ep1' ? 'startBind' : 'endBind']: undefined,
853
878
  } as Partial<Stroke>;
854
879
  }
855
- document.dispatchEvent(
856
- new CustomEvent('maude:bind-hint', { detail: { hostId: cand?.hostId ?? null } })
857
- );
880
+ store.setBindHint?.(cand?.hostId ?? null);
858
881
  }
859
882
  // Wave H — transient preview tick: no undo record, no PUT. The gesture
860
883
  // commits ONCE on pointerup (undo used to walk every resize pixel).
@@ -898,26 +921,28 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
898
921
  );
899
922
  // Clear the bind-hint halo + size label regardless of what was dragged
900
923
  // (no-ops when nothing was hinted).
901
- document.dispatchEvent(new CustomEvent('maude:bind-hint', { detail: { hostId: null } }));
902
- document.dispatchEvent(
903
- new CustomEvent('maude:resize-info', { detail: { box: null, matchIds: [] } })
904
- );
924
+ store?.setBindHint?.(null);
925
+ store?.setResizeInfo?.(null);
905
926
  };
906
927
  c.addEventListener('pointerdown', onDown);
907
- document.addEventListener('pointermove', onMove);
908
- document.addEventListener('pointerup', onUp);
909
- document.addEventListener('pointercancel', onUp);
928
+ if (!pipeline) {
929
+ document.addEventListener('pointermove', onMove);
930
+ document.addEventListener('pointerup', onUp);
931
+ document.addEventListener('pointercancel', onUp);
932
+ }
910
933
  document.addEventListener('keydown', onKey, true);
911
934
  document.addEventListener('keyup', onKey, true);
912
935
  return () => {
913
936
  c.removeEventListener('pointerdown', onDown);
914
- document.removeEventListener('pointermove', onMove);
915
- document.removeEventListener('pointerup', onUp);
916
- document.removeEventListener('pointercancel', onUp);
937
+ if (!pipeline) {
938
+ document.removeEventListener('pointermove', onMove);
939
+ document.removeEventListener('pointerup', onUp);
940
+ document.removeEventListener('pointercancel', onUp);
941
+ }
917
942
  document.removeEventListener('keydown', onKey, true);
918
943
  document.removeEventListener('keyup', onKey, true);
919
944
  };
920
- }, [selectedStroke, store, screenToWorld, vp]);
945
+ }, [selectedStroke, store, screenToWorld, vp, pipeline]);
921
946
 
922
947
  // Only render when there's exactly one resizable stroke selected. Multi
923
948
  // resize is undefined for v1 (no canonical UX); text inherits anchor bbox.
@@ -24,6 +24,8 @@ export interface AnnotationSelectionValue {
24
24
  selectedIds: string[];
25
25
  replace: (id: string | string[]) => void;
26
26
  add: (id: string | string[]) => void;
27
+ /** Drop ids from the selection (shift-click on a selected element). */
28
+ remove: (id: string | string[]) => void;
27
29
  toggle: (id: string) => void;
28
30
  clear: () => void;
29
31
  contains: (id: string) => boolean;
@@ -55,6 +57,11 @@ export function AnnotationSelectionProvider({ children }: { children: ReactNode
55
57
  setSelectedIds((prev) => dedupe([...prev, ...incoming]));
56
58
  }, []);
57
59
 
60
+ const remove = useCallback((id: string | string[]) => {
61
+ const drop = new Set(Array.isArray(id) ? id : [id]);
62
+ setSelectedIds((prev) => prev.filter((x) => !drop.has(x)));
63
+ }, []);
64
+
58
65
  const toggle = useCallback((id: string) => {
59
66
  setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
60
67
  }, []);
@@ -76,8 +83,8 @@ export function AnnotationSelectionProvider({ children }: { children: ReactNode
76
83
  const contains = useCallback((id: string) => containsRef.current.includes(id), []);
77
84
 
78
85
  const value = useMemo<AnnotationSelectionValue>(
79
- () => ({ selectedIds, replace, add, toggle, clear, contains }),
80
- [selectedIds, replace, add, toggle, clear, contains]
86
+ () => ({ selectedIds, replace, add, remove, toggle, clear, contains }),
87
+ [selectedIds, replace, add, remove, toggle, clear, contains]
81
88
  );
82
89
 
83
90
  return (
@@ -106,6 +106,12 @@ export interface CollabAwarenessState {
106
106
  * annotation-shaped is selected.
107
107
  */
108
108
  annotationSelection: string[];
109
+ /**
110
+ * DDR-242 AD5 / Task 22 — an annotation gesture in flight (drag, resize,
111
+ * draw), so peers see it move live. It never touches storage: the op
112
+ * commits once at gesture end, and this goes back to null.
113
+ */
114
+ annotationGesture?: AnnotationGesture | null;
109
115
  viewport: { x: number; y: number; zoom: number };
110
116
  /**
111
117
  * Soft editing-presence (Phase 30). Set while THIS peer (a human editing via
@@ -130,6 +136,20 @@ export interface CollabAwarenessState {
130
136
 
131
137
  export type ForeignAwareness = Omit<CollabAwarenessState, '__connId'> & { clientID: number };
132
138
 
139
+ /** A live annotation gesture, in WORLD coordinates. */
140
+ export interface AnnotationGesture {
141
+ kind: 'move' | 'resize' | 'draw';
142
+ /** Elements the gesture moves / resizes (empty while drawing a new one). */
143
+ ids: string[];
144
+ /** move: the drag offset. */
145
+ dx?: number;
146
+ dy?: number;
147
+ /** resize: the box being dragged out; draw: a shape / sticky / section being drawn. */
148
+ box?: { x: number; y: number; w: number; h: number };
149
+ /** draw: pen ink so far, flat [x0,y0,x1,y1,…] (the latest part, capped). */
150
+ points?: number[];
151
+ }
152
+
133
153
  // ─────────────────────────────────────────────────────────────────────────────
134
154
  // Untrusted-input sanitization at the awareness trust boundary.
135
155
  //
@@ -214,6 +234,58 @@ function sanitizeCursor(raw: unknown): { x: number; y: number } | null {
214
234
  return isFiniteNum(c.x) && isFiniteNum(c.y) ? { x: c.x, y: c.y } : null;
215
235
  }
216
236
 
237
+ const GESTURE_KINDS = new Set(['move', 'resize', 'draw']);
238
+ const MAX_GESTURE_POINTS = 512; // numbers, i.e. 256 points
239
+ const GESTURE_COORD_LIMIT = 1e6;
240
+
241
+ function gestureNum(v: unknown): number | undefined {
242
+ if (typeof v !== 'number' || !Number.isFinite(v)) return undefined;
243
+ return Math.max(-GESTURE_COORD_LIMIT, Math.min(GESTURE_COORD_LIMIT, v));
244
+ }
245
+
246
+ /**
247
+ * Task 22 — a peer's gesture preview is untrusted like every other awareness
248
+ * field: kind allowlisted, ids through the same token + count gate as the
249
+ * selection, every number finite and clamped, pen points capped.
250
+ */
251
+ export function sanitizeAnnotationGesture(raw: unknown): AnnotationGesture | null {
252
+ if (!raw || typeof raw !== 'object') return null;
253
+ const g = raw as Record<string, unknown>;
254
+ if (typeof g.kind !== 'string' || !GESTURE_KINDS.has(g.kind)) return null;
255
+ const out: AnnotationGesture = {
256
+ kind: g.kind as AnnotationGesture['kind'],
257
+ ids: sanitizeAnnotationSelection(g.ids),
258
+ };
259
+ const dx = gestureNum(g.dx);
260
+ const dy = gestureNum(g.dy);
261
+ if (dx !== undefined && dy !== undefined) {
262
+ out.dx = dx;
263
+ out.dy = dy;
264
+ }
265
+ const b = g.box as Record<string, unknown> | undefined;
266
+ if (b && typeof b === 'object') {
267
+ const x = gestureNum(b.x);
268
+ const y = gestureNum(b.y);
269
+ const w = gestureNum(b.w);
270
+ const h = gestureNum(b.h);
271
+ if (x !== undefined && y !== undefined && w !== undefined && h !== undefined) {
272
+ out.box = { x, y, w: Math.abs(w), h: Math.abs(h) };
273
+ }
274
+ }
275
+ if (Array.isArray(g.points)) {
276
+ const pts: number[] = [];
277
+ const src = g.points.slice(-MAX_GESTURE_POINTS);
278
+ for (const v of src) {
279
+ const n = gestureNum(v);
280
+ if (n === undefined) break;
281
+ pts.push(n);
282
+ }
283
+ if (pts.length % 2 === 1) pts.pop();
284
+ if (pts.length) out.points = pts;
285
+ }
286
+ return out;
287
+ }
288
+
217
289
  function sanitizeViewport(raw: unknown): { x: number; y: number; zoom: number } {
218
290
  const fallback = { x: 0, y: 0, zoom: 1 };
219
291
  if (!raw || typeof raw !== 'object') return fallback;
@@ -284,6 +356,7 @@ export function sanitizeForeignState(clientID: number, state: unknown): ForeignA
284
356
  cursor: sanitizeCursor(s.cursor),
285
357
  selection: sanitizeSelection(s.selection),
286
358
  annotationSelection: sanitizeAnnotationSelection(s.annotationSelection),
359
+ annotationGesture: sanitizeAnnotationGesture(s.annotationGesture),
287
360
  viewport: sanitizeViewport(s.viewport),
288
361
  editing: sanitizeEditingState(s.editing),
289
362
  };
@@ -323,6 +396,27 @@ export function useCollab(): CollabValue | null {
323
396
  // ─────────────────────────────────────────────────────────────────────────────
324
397
  // Hook: foreign awareness peers (the cursor overlay subscribes to this).
325
398
 
399
+ export interface AwarenessChanges {
400
+ added: number[];
401
+ updated: number[];
402
+ removed: number[];
403
+ }
404
+
405
+ /**
406
+ * Could this awareness `change` alter the foreign peers we show? False only
407
+ * when every touched client is the local one (our own cursor publish — #131).
408
+ * Unknown shape → true, the safe direction.
409
+ */
410
+ export function touchesForeignClient(changes: AwarenessChanges | undefined, myId: number): boolean {
411
+ if (!changes) return true;
412
+ const touched = [
413
+ ...(changes.added ?? []),
414
+ ...(changes.updated ?? []),
415
+ ...(changes.removed ?? []),
416
+ ];
417
+ return touched.length === 0 || touched.some((id) => id !== myId);
418
+ }
419
+
326
420
  /**
327
421
  * Returns the current set of foreign peers (excludes the local client). The
328
422
  * returned array is stable-reference between awareness updates — useful for
@@ -351,7 +445,13 @@ export function useForeignAwareness(): ForeignAwareness[] {
351
445
  return out;
352
446
  }
353
447
  setPeers(compute());
354
- const onChange = () => setPeers(compute());
448
+ // Issue #131 — `change` fires for OUR OWN publishes too (the 30 Hz cursor),
449
+ // and each one rebuilt every peer object and re-rendered every consumer.
450
+ // Only a change that touches another client can change what we show.
451
+ const onChange = (changes?: AwarenessChanges) => {
452
+ if (!touchesForeignClient(changes, awareness.clientID)) return;
453
+ setPeers(compute());
454
+ };
355
455
  awareness.on('change', onChange);
356
456
  return () => {
357
457
  awareness.off('change', onChange);
@@ -559,6 +659,7 @@ function createSession(slug: string): CollabSession {
559
659
  cursor: cur.cursor ?? null,
560
660
  selection: cur.selection ?? null,
561
661
  annotationSelection: cur.annotationSelection ?? [],
662
+ annotationGesture: null,
562
663
  viewport: cur.viewport ?? { x: 0, y: 0, zoom: 1 },
563
664
  editing: cur.editing ?? null,
564
665
  __connId: connId,
@@ -867,6 +968,8 @@ export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Ele
867
968
  cursor: current.cursor ?? null,
868
969
  selection: current.selection ?? null,
869
970
  annotationSelection: current.annotationSelection ?? [],
971
+ // Kept across other fields' publishes (a cursor move mid-drag).
972
+ annotationGesture: current.annotationGesture ?? null,
870
973
  viewport: current.viewport ?? { x: 0, y: 0, zoom: 1 },
871
974
  editing: current.editing ?? null,
872
975
  __connId: myConnId,
@@ -67,6 +67,10 @@ export interface Selection {
67
67
  worldW?: number;
68
68
  worldH?: number;
69
69
  html?: string;
70
+ /** Comment drops only — the annotation the click landed on, and the click's
71
+ * world point (comment-anchor.ts). */
72
+ annotationId?: string;
73
+ world?: { x: number; y: number };
70
74
  /** Phase 12.2 — authored inline-style values (knob pre-fill) + resolved computed (placeholder hint). */
71
75
  authored?: Record<string, string>;
72
76
  computed?: Record<string, string>;