@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.
- package/apps/studio/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +533 -0
- package/apps/studio/annotations/board-io.ts +94 -0
- package/apps/studio/annotations/board-text.ts +45 -0
- package/apps/studio/annotations/constants.ts +58 -0
- package/apps/studio/annotations/elements/_shared.ts +125 -0
- package/apps/studio/annotations/elements/arrow.model.ts +196 -0
- package/apps/studio/annotations/elements/media.model.ts +79 -0
- package/apps/studio/annotations/elements/pen.model.ts +82 -0
- package/apps/studio/annotations/elements/section.model.ts +70 -0
- package/apps/studio/annotations/elements/shape.model.ts +141 -0
- package/apps/studio/annotations/elements/sticky.model.ts +48 -0
- package/apps/studio/annotations/elements/text.model.ts +52 -0
- package/apps/studio/annotations/fields.ts +347 -0
- package/apps/studio/annotations/fractional-index.ts +234 -0
- package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
- package/apps/studio/annotations/migrate-boot.ts +172 -0
- package/apps/studio/annotations/migrate-cli.ts +37 -0
- package/apps/studio/annotations/migrate-v1.ts +383 -0
- package/apps/studio/annotations/ops.ts +474 -0
- package/apps/studio/annotations/registry.ts +159 -0
- package/apps/studio/annotations/replica.ts +264 -0
- package/apps/studio/annotations/scene.ts +235 -0
- package/apps/studio/annotations/schema.ts +188 -0
- package/apps/studio/annotations/types.ts +77 -0
- package/apps/studio/annotations/ui/board.ts +126 -0
- package/apps/studio/annotations/ui/containment.ts +118 -0
- package/apps/studio/annotations/ui/edit-actions.ts +551 -0
- package/apps/studio/annotations/ui/editor-channel.ts +62 -0
- package/apps/studio/annotations/ui/element-node.tsx +993 -0
- package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
- package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
- package/apps/studio/annotations/ui/render-model.ts +264 -0
- package/apps/studio/annotations/ui/scene.tsx +63 -0
- package/apps/studio/annotations/ui/text-editor.tsx +420 -0
- package/apps/studio/annotations/ui/text-session.ts +140 -0
- package/apps/studio/annotations/ui/text-style.ts +111 -0
- package/apps/studio/annotations/ui/world.ts +47 -0
- package/apps/studio/annotations/v1-adapter.ts +428 -0
- package/apps/studio/annotations-align.ts +21 -6
- package/apps/studio/annotations-bindings.ts +2 -0
- package/apps/studio/annotations-context-toolbar.tsx +9 -13
- package/apps/studio/annotations-groups.ts +3 -0
- package/apps/studio/annotations-layer.tsx +923 -1925
- package/apps/studio/annotations-model.ts +97 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +262 -68
- package/apps/studio/bin/_import-figma.mjs +22 -12
- package/apps/studio/bin/annotate.mjs +331 -838
- package/apps/studio/bin/annotate.sh +4 -4
- package/apps/studio/bin/perf.sh +21 -7
- package/apps/studio/bin/read-annotations.mjs +184 -666
- package/apps/studio/bin/read-annotations.sh +9 -5
- package/apps/studio/canvas-artifacts.ts +9 -0
- package/apps/studio/canvas-comment-mount.tsx +35 -2
- package/apps/studio/canvas-lib.tsx +18 -1
- package/apps/studio/canvas-shell.tsx +54 -1
- package/apps/studio/client/app.jsx +101 -35
- package/apps/studio/client/comments-overlay.css +10 -0
- package/apps/studio/client/hmr.mjs +1 -1
- package/apps/studio/client/panels/git-grouping.js +2 -2
- package/apps/studio/client/tree-expansion.js +217 -0
- package/apps/studio/collab/index.ts +58 -5
- package/apps/studio/collab/persistence.ts +80 -11
- package/apps/studio/collab/registry.ts +63 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/comment-anchor.ts +116 -0
- package/apps/studio/comments-overlay.tsx +78 -35
- package/apps/studio/context.ts +1 -0
- package/apps/studio/cursors-overlay.tsx +337 -109
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/figma/to-strokes.ts +31 -10
- package/apps/studio/git/endpoints.ts +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/git/watch.ts +1 -1
- package/apps/studio/http.ts +90 -15
- package/apps/studio/server.ts +16 -0
- package/apps/studio/sync/accepted-cold-start.ts +72 -8
- package/apps/studio/sync/agent.ts +36 -6
- package/apps/studio/sync/codec.ts +95 -40
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +78 -2
- package/apps/studio/sync/index.ts +75 -16
- package/apps/studio/sync/journal-client.ts +5 -0
- package/apps/studio/sync/limits.ts +7 -2
- package/apps/studio/sync/migrate-seed.ts +17 -7
- package/apps/studio/sync/projection.ts +7 -2
- package/apps/studio/sync/remote-docs.ts +34 -0
- package/apps/studio/sync/writer-registry.ts +10 -0
- package/apps/studio/text-caret.ts +11 -2
- package/apps/studio/tree-state.ts +45 -0
- package/apps/studio/undo-stack.ts +2 -2
- package/apps/studio/use-annotation-resize.tsx +48 -23
- package/apps/studio/use-annotation-selection.tsx +9 -2
- package/apps/studio/use-collab.tsx +104 -1
- package/apps/studio/use-selection-set.tsx +4 -0
- package/apps/studio/whats-new.json +63 -0
- package/cli/lib/design-link.mjs +5 -1
- package/cli/lib/gitignore-block.mjs +1 -1
- package/cli/lib/gitignore-drift.mjs +2 -1
- package/package.json +9 -8
- package/plugins/design/templates/brief-board.tsx.template +1 -1
- package/apps/studio/annotation-edit-base.ts +0 -36
- 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
|
-
|
|
156
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
902
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
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>;
|