@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
package/apps/studio/server.ts
CHANGED
|
@@ -18,6 +18,7 @@ import path from 'node:path';
|
|
|
18
18
|
import { createAcp } from './acp/index.ts';
|
|
19
19
|
import { cancelInstall, cancelSignin } from './acp/login-state.ts';
|
|
20
20
|
import { createActivity } from './activity.ts';
|
|
21
|
+
import { migrateAnnotationsV2 } from './annotations/migrate-boot.ts';
|
|
21
22
|
import { ASSET_MAX_VIDEO_BYTES, createApi } from './api.ts';
|
|
22
23
|
import { bootSelfHeal } from './boot-self-heal.ts';
|
|
23
24
|
import { isSandboxArmed } from './canvas-build-sandbox.ts';
|
|
@@ -129,6 +130,13 @@ const api = createApi(ctx, {
|
|
|
129
130
|
collab.registry.syncRoomFromAnnotations(api.fileSlug(file), svg, writeId);
|
|
130
131
|
}
|
|
131
132
|
},
|
|
133
|
+
// Code review H1 — the canvas op path goes to a live room's replica, not to a
|
|
134
|
+
// file its debounced flush hasn't caught up with. Accepted mode keeps the
|
|
135
|
+
// disk + proposeLane path: there the hub kernel merges against the base.
|
|
136
|
+
applyAnnotationOpsLive: (file, ops, actionId) => {
|
|
137
|
+
if (ctx.syncControl?.current?.()?.acceptedMode?.()) return null;
|
|
138
|
+
return collab?.registry.applyOpsToRoom(api.fileSlug(file), ops, actionId) ?? null;
|
|
139
|
+
},
|
|
132
140
|
// feature-file-tree-drag-drop-folders (Task 3) — moveCanvas's collab guard
|
|
133
141
|
// + `_active.json` retarget, bridged the same forward-declared way as the
|
|
134
142
|
// comments/annotations hooks above.
|
|
@@ -182,6 +190,13 @@ ctx.bus.on('canvas-list-update', (change) => {
|
|
|
182
190
|
}
|
|
183
191
|
});
|
|
184
192
|
|
|
193
|
+
// DDR-242 — convert legacy `<slug>.annotations.svg` boards to
|
|
194
|
+
// `.annotations.json` BEFORE any room seeds or sync scan reads them. Skipped in
|
|
195
|
+
// a cloud workspace: the hub owns that checkout (its workspace agent migrates).
|
|
196
|
+
if (process.env.MAUDE_WORKSPACE_MODE !== '1') {
|
|
197
|
+
migrateAnnotationsV2({ designRoot: ctx.paths.designRoot });
|
|
198
|
+
}
|
|
199
|
+
|
|
185
200
|
collab = createCollab(ctx, api);
|
|
186
201
|
const aiActivity = createAiActivity(ctx);
|
|
187
202
|
|
|
@@ -518,6 +533,7 @@ function startCanvasServer(port: number): BunServer {
|
|
|
518
533
|
'/_api/git-user': http.routes['/_api/git-user'],
|
|
519
534
|
'/_api/canvas-meta': http.routes['/_api/canvas-meta'],
|
|
520
535
|
'/_api/annotations': http.routes['/_api/annotations'],
|
|
536
|
+
'/_api/annotations/ops': http.routes['/_api/annotations/ops'],
|
|
521
537
|
// Phase 23 — capped binary image upload (magic-byte sniff + category cap +
|
|
522
538
|
// content-addressed name + traversal guard + no-SVG, in api.saveAsset).
|
|
523
539
|
// Bun matches `routes` BEFORE `fetch`, so the route must be listed here
|
|
@@ -18,16 +18,38 @@
|
|
|
18
18
|
// A canvas the project does not know yet is proposed as `doc.create` with all
|
|
19
19
|
// of its lanes, so it arrives on every peer as ONE action.
|
|
20
20
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
21
|
+
// Annotations are never re-proposed from disk here: every change the studio
|
|
22
|
+
// made to them went through the durable outbox (drained before any cold start),
|
|
23
|
+
// so a difference on disk is an older accepted state the room had not
|
|
24
|
+
// re-projected — the accepted value wins, and the local file is kept in a
|
|
25
25
|
// recovery slot in case a raw edit made while the studio was down lived there.
|
|
26
|
+
//
|
|
27
|
+
// Comments had the same rule, and the premise did not hold for them (issue
|
|
28
|
+
// #133, reproduced end-to-end): a comment added while the sync runtime was not
|
|
29
|
+
// up yet (`proposeLane` unavailable, server.ts) went to the local room and disk
|
|
30
|
+
// only, never into the outbox — so the web never saw it, and the file then
|
|
31
|
+
// froze behind it. Comments carry stable ids, so the difference is decidable:
|
|
32
|
+
// an id on disk that the accepted state lacks and that the comment ledger says
|
|
33
|
+
// was never synced from here is a local comment still owed to the project; it
|
|
34
|
+
// is proposed, as the accepted list plus those comments (a three-way id merge
|
|
35
|
+
// on the hub). Ids the ledger knows were synced are deletions made while this
|
|
36
|
+
// machine was away and are not proposed back. With no ledger record for the
|
|
37
|
+
// canvas (first launch after upgrading) nothing is decidable: the old rule —
|
|
38
|
+
// accepted wins, local kept in recovery — stands.
|
|
26
39
|
|
|
27
40
|
import { existsSync, readFileSync } from 'node:fs';
|
|
28
41
|
import type * as Y from 'yjs';
|
|
29
42
|
|
|
30
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
cssFromDoc,
|
|
45
|
+
htmlFromDoc,
|
|
46
|
+
isEmptyAnnotationsSvg,
|
|
47
|
+
laneValueFromFile,
|
|
48
|
+
readLaneFromDoc,
|
|
49
|
+
readLocalAnnotations,
|
|
50
|
+
} from './codec.ts';
|
|
51
|
+
import { commentKey } from './comment-identity.ts';
|
|
52
|
+
import type { CommentLedger } from './comment-ledger.ts';
|
|
31
53
|
import { hashBytes } from './echo-guard.ts';
|
|
32
54
|
import type { SyncJournal } from './journal.ts';
|
|
33
55
|
import type { DocProjection, ProjectionPaths, ProposalLane } from './projection.ts';
|
|
@@ -53,6 +75,38 @@ function readText(p: string | undefined): string | null {
|
|
|
53
75
|
}
|
|
54
76
|
}
|
|
55
77
|
|
|
78
|
+
function parseList(v: string): unknown[] {
|
|
79
|
+
if (!v) return [];
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(v) as unknown;
|
|
82
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The comments list to propose at cold start — the accepted list plus the local
|
|
90
|
+
* comments never synced from here — or null when there is nothing owed (or no
|
|
91
|
+
* ledger record to decide by). See the header.
|
|
92
|
+
*/
|
|
93
|
+
export function commentsOwedToProject(
|
|
94
|
+
local: string,
|
|
95
|
+
accepted: string,
|
|
96
|
+
slug: string,
|
|
97
|
+
ledger: CommentLedger | undefined
|
|
98
|
+
): string | null {
|
|
99
|
+
if (!ledger?.known(slug)) return null;
|
|
100
|
+
const acceptedList = parseList(accepted);
|
|
101
|
+
const inProject = new Set(acceptedList.map(commentKey));
|
|
102
|
+
const synced = ledger.get(slug);
|
|
103
|
+
const owed = parseList(local).filter((c) => {
|
|
104
|
+
const k = commentKey(c);
|
|
105
|
+
return !inProject.has(k) && !synced.has(k);
|
|
106
|
+
});
|
|
107
|
+
return owed.length > 0 ? JSON.stringify([...acceptedList, ...owed]) : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
56
110
|
/**
|
|
57
111
|
* Pure decision for the source lanes. `knownBase` is the last value disk and
|
|
58
112
|
* the accepted replica agreed on (recovery slot), `baseHash` the journal's
|
|
@@ -92,6 +146,8 @@ export interface AcceptedColdStartInput {
|
|
|
92
146
|
projection: DocProjection;
|
|
93
147
|
historyDir?: string;
|
|
94
148
|
journal?: SyncJournal;
|
|
149
|
+
/** Issue #133 — comment ids synced from here before (see the header). */
|
|
150
|
+
commentLedger?: CommentLedger;
|
|
95
151
|
/**
|
|
96
152
|
* This disk's own html proposal a previous run left unanswered, which the
|
|
97
153
|
* outbox drain has just had accepted. The disk was saved on top of it, so it
|
|
@@ -131,8 +187,9 @@ export async function acceptedColdStart(
|
|
|
131
187
|
const metaText = readText(i.paths.meta);
|
|
132
188
|
const meta = metaText === null ? null : laneValueFromFile('meta', metaText);
|
|
133
189
|
if (meta && meta !== '{}') lanes.meta = meta;
|
|
134
|
-
|
|
135
|
-
|
|
190
|
+
// DDR-242 — canonical board text; a legacy sidecar arrives migrated.
|
|
191
|
+
const ann = readLocalAnnotations(i.paths.annotations, readText);
|
|
192
|
+
if (ann && !isEmptyAnnotationsSvg(ann)) lanes.annotations = ann;
|
|
136
193
|
const commentsText = readText(i.paths.comments);
|
|
137
194
|
const comments = commentsText === null ? null : laneValueFromFile('comments', commentsText);
|
|
138
195
|
if (comments) lanes.comments = comments;
|
|
@@ -218,13 +275,20 @@ export async function acceptedColdStart(
|
|
|
218
275
|
// ---- comments / annotations — the accepted value wins (see header)
|
|
219
276
|
for (const lane of ['comments', 'annotations'] as const) {
|
|
220
277
|
const p = lane === 'comments' ? i.paths.comments : i.paths.annotations;
|
|
221
|
-
const text = readText(p);
|
|
278
|
+
const text = lane === 'annotations' ? readLocalAnnotations(p, readText) : readText(p);
|
|
222
279
|
const local = text === null ? null : laneValueFromFile(lane, text);
|
|
223
280
|
const accepted = readLaneFromDoc(i.doc, lane);
|
|
224
281
|
if (local === null || local === accepted) {
|
|
225
282
|
verdicts.push({ lane, decision: local === null ? 'materialize' : 'agreed' });
|
|
226
283
|
continue;
|
|
227
284
|
}
|
|
285
|
+
if (lane === 'comments') {
|
|
286
|
+
const owed = commentsOwedToProject(local, accepted, i.slug, i.commentLedger);
|
|
287
|
+
if (owed) {
|
|
288
|
+
verdicts.push({ lane, decision: 'propose', base: accepted, local: owed });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
228
292
|
if (i.historyDir && text) {
|
|
229
293
|
try {
|
|
230
294
|
saveRecoveryBody(i.historyDir, p, 'local', text);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// `.design/<slug>.html` ←→ Y.Text (Y_SYNC_TYPES.html)
|
|
7
7
|
// `.design/_comments/<slug>.json` ←→ Y.Array (Y_TYPES.comments)
|
|
8
|
-
// `.design/<slug>.annotations.
|
|
8
|
+
// `.design/<slug>.annotations.json`←→ 'annotations2' replica (DDR-242)
|
|
9
9
|
//
|
|
10
10
|
// Provider is INJECTED — the agent doesn't import @hocuspocus/provider. This
|
|
11
11
|
// keeps the orchestration testable with an in-memory pair of Y.Docs (no hub
|
|
@@ -51,10 +51,13 @@ import {
|
|
|
51
51
|
commentsFromDoc,
|
|
52
52
|
cssFromDoc,
|
|
53
53
|
htmlFromDoc,
|
|
54
|
+
importAnnotationsFromDisk,
|
|
54
55
|
isEmptyAnnotationsSvg,
|
|
55
56
|
mergeSharedMetaIntoLocal,
|
|
56
57
|
metaFromDoc,
|
|
57
58
|
movedToFromDoc,
|
|
59
|
+
noteAnnotationsOnDisk,
|
|
60
|
+
readLocalAnnotations,
|
|
58
61
|
repairSharedMeta,
|
|
59
62
|
stampAnnotationsEdit,
|
|
60
63
|
stampBodyEdit,
|
|
@@ -67,7 +70,8 @@ import {
|
|
|
67
70
|
unionCommentsById,
|
|
68
71
|
} from './cold-start.ts';
|
|
69
72
|
import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
|
|
70
|
-
import { dedupeCommentsById, hasDuplicateComments } from './comment-identity.ts';
|
|
73
|
+
import { commentKey, dedupeCommentsById, hasDuplicateComments } from './comment-identity.ts';
|
|
74
|
+
import { type CommentLedger, withoutRemotelyDeleted } from './comment-ledger.ts';
|
|
71
75
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
72
76
|
import type { SyncJournal } from './journal.ts';
|
|
73
77
|
import { rememberSeed, repairSeedDuplication } from './seed-repair.ts';
|
|
@@ -87,7 +91,7 @@ export interface CanvasSyncPaths {
|
|
|
87
91
|
html: string;
|
|
88
92
|
/** Absolute path to <designRoot>/_comments/<slug>.json. */
|
|
89
93
|
comments: string;
|
|
90
|
-
/** Absolute path to <designRoot>/<slug>.annotations.
|
|
94
|
+
/** Absolute path to <designRoot>/<slug>.annotations.json (DDR-242). */
|
|
91
95
|
annotations: string;
|
|
92
96
|
/** Absolute path to the canvas `.meta.json` (sibling of the body). Optional:
|
|
93
97
|
* when set (always, in production wiring), shared meta keys (layout/artboards)
|
|
@@ -119,6 +123,20 @@ export interface CanvasSyncAgentOptions {
|
|
|
119
123
|
* without it simply degrade to the conservative conflict path.
|
|
120
124
|
*/
|
|
121
125
|
journal?: SyncJournal;
|
|
126
|
+
/**
|
|
127
|
+
* Issue #133 — comment ids synced from this machine before
|
|
128
|
+
* (sync/comment-ledger.ts). The cold-start union drops a local comment the
|
|
129
|
+
* ledger knows and the hub's doc no longer holds: that is a delete made
|
|
130
|
+
* while this machine was away, not a local-only comment. Optional — absent,
|
|
131
|
+
* the union keeps everything, as before.
|
|
132
|
+
*/
|
|
133
|
+
commentLedger?: CommentLedger;
|
|
134
|
+
/**
|
|
135
|
+
* Does the hub hold this doc's current state (nothing unacknowledged)? The
|
|
136
|
+
* ledger records comment ids as synced only then — see
|
|
137
|
+
* `commentsConfirmedOnHub` in sync/index.ts. Absent → never records.
|
|
138
|
+
*/
|
|
139
|
+
commentsConfirmed?: () => boolean;
|
|
122
140
|
/**
|
|
123
141
|
* Snapshot writer (DDR-102 conflict protocol) — persists a body version to
|
|
124
142
|
* `_history/<slug>/` and resolves with the snapshot's ISO ts (null on
|
|
@@ -339,6 +357,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
339
357
|
echoGuard.record(paths.comments, hash);
|
|
340
358
|
writer(paths.comments, serialized);
|
|
341
359
|
lastComments = serialized;
|
|
360
|
+
if (opts.commentsConfirmed?.()) opts.commentLedger?.record(slug, next.map(commentKey));
|
|
342
361
|
}
|
|
343
362
|
|
|
344
363
|
function writeAnnotationsIfChanged(): void {
|
|
@@ -352,6 +371,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
352
371
|
}
|
|
353
372
|
const hash = hashBytes(value);
|
|
354
373
|
echoGuard.record(paths.annotations, hash);
|
|
374
|
+
noteAnnotationsOnDisk(doc, value);
|
|
355
375
|
writer(paths.annotations, value);
|
|
356
376
|
lastAnnotations = value;
|
|
357
377
|
}
|
|
@@ -411,6 +431,8 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
411
431
|
if (parsed === null) return false;
|
|
412
432
|
const changed = applyCommentsToDoc(doc, parsed, origin);
|
|
413
433
|
if (changed) lastComments = str;
|
|
434
|
+
if (opts.commentsConfirmed?.())
|
|
435
|
+
opts.commentLedger?.record(slug, commentsFromDoc(doc).map(commentKey));
|
|
414
436
|
return changed;
|
|
415
437
|
}
|
|
416
438
|
if (evt.path === paths.annotations) {
|
|
@@ -420,7 +442,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
420
442
|
// wrapper written by saveAnnotations) cold-start-safe on other peers.
|
|
421
443
|
let changed = false;
|
|
422
444
|
doc.transact(() => {
|
|
423
|
-
changed =
|
|
445
|
+
changed = importAnnotationsFromDisk(doc, str, origin, lastAnnotations);
|
|
424
446
|
if (changed) stampAnnotationsEdit(doc, origin);
|
|
425
447
|
}, origin);
|
|
426
448
|
if (changed) lastAnnotations = str;
|
|
@@ -452,7 +474,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
452
474
|
if (movedToFromDoc(doc) !== null) return;
|
|
453
475
|
const localHtml = readLocal(paths.html);
|
|
454
476
|
const localComments = readLocal(paths.comments);
|
|
455
|
-
const localAnnotations =
|
|
477
|
+
const localAnnotations = readLocalAnnotations(paths.annotations, readLocal);
|
|
456
478
|
const localMeta = paths.meta ? readLocal(paths.meta) : null;
|
|
457
479
|
const localCss = paths.css ? readLocal(paths.css) : null;
|
|
458
480
|
|
|
@@ -562,7 +584,13 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
562
584
|
const bodyWinner = applied.bodyWinner;
|
|
563
585
|
|
|
564
586
|
// ---- comments: id-union merge (DDR-102 — union loses nothing) ----------
|
|
565
|
-
const
|
|
587
|
+
const localParsed = localComments !== null ? tryParseJsonArray(localComments) : null;
|
|
588
|
+
// Issue #133 — a local comment that was synced from here before and that
|
|
589
|
+
// the hub's doc no longer holds was deleted while this machine was away.
|
|
590
|
+
// Unioning it back resurrected it for everyone.
|
|
591
|
+
const localParsedComments = localParsed
|
|
592
|
+
? withoutRemotelyDeleted(localParsed, docComments, opts.commentLedger?.get(slug))
|
|
593
|
+
: null;
|
|
566
594
|
if (localParsedComments !== null && localParsedComments.length > 0) {
|
|
567
595
|
const merged = unionCommentsById(docComments, localParsedComments);
|
|
568
596
|
const mergedStr = merged.length > 0 ? `${JSON.stringify(merged, null, 2)}\n` : '';
|
|
@@ -573,6 +601,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
573
601
|
writer(paths.comments, mergedStr);
|
|
574
602
|
}
|
|
575
603
|
lastComments = mergedStr;
|
|
604
|
+
if (opts.commentsConfirmed?.()) opts.commentLedger?.record(slug, merged.map(commentKey));
|
|
576
605
|
} else {
|
|
577
606
|
// No (parseable) local comments — hub state materializes as before.
|
|
578
607
|
lastComments = docCommentsStr;
|
|
@@ -581,6 +610,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
581
610
|
echoGuard.record(paths.comments, hash);
|
|
582
611
|
writer(paths.comments, docCommentsStr);
|
|
583
612
|
}
|
|
613
|
+
if (opts.commentsConfirmed?.()) opts.commentLedger?.record(slug, docComments.map(commentKey));
|
|
584
614
|
}
|
|
585
615
|
|
|
586
616
|
// ---- annotations: PER-LANE newest-wins (the 2026-08-14 eraser fix) -----
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { ANNOTATION_WRITE_ID } from '../annotations-sync.ts';
|
|
2
1
|
// Y.Doc ↔ disk codecs for the bidirectional file sync agent (Phase 9 Task 4).
|
|
3
2
|
//
|
|
4
3
|
// The agent shuttles three classes of files between disk and the Y.Doc the
|
|
@@ -6,7 +5,8 @@ import { ANNOTATION_WRITE_ID } from '../annotations-sync.ts';
|
|
|
6
5
|
//
|
|
7
6
|
// `.design/<canvas>.html` -> Y.Text (Y_SYNC_TYPES.html)
|
|
8
7
|
// `.design/_comments/<slug>.json` -> Y.Array (Y_TYPES.comments — Phase 6)
|
|
9
|
-
// `.design/<slug>.annotations.
|
|
8
|
+
// `.design/<slug>.annotations.json` -> Y.Map ('annotations2' — DDR-242; per-element replica,
|
|
9
|
+
// see annotations/replica.ts)
|
|
10
10
|
//
|
|
11
11
|
// v1.1 design decision (plan §"Key insight"): HTML body is treated as opaque
|
|
12
12
|
// Y.Text rather than structured Y.XmlFragment. Round-trip drift would
|
|
@@ -26,6 +26,17 @@ import { hostname } from 'node:os';
|
|
|
26
26
|
import { diffChars } from 'diff';
|
|
27
27
|
import type * as Y from 'yjs';
|
|
28
28
|
|
|
29
|
+
import { annotationsLaneValue, canonicalAnnotations } from '../annotations/board-text.ts';
|
|
30
|
+
import { applyOps, diffToOps } from '../annotations/ops.ts';
|
|
31
|
+
import {
|
|
32
|
+
annotationsOnDiskOf,
|
|
33
|
+
isEmptyBoardText,
|
|
34
|
+
noteAnnotationsOnDisk,
|
|
35
|
+
readReplica,
|
|
36
|
+
replicaBoardText,
|
|
37
|
+
writeReplica,
|
|
38
|
+
} from '../annotations/replica.ts';
|
|
39
|
+
import { parseBoard, serializeBoard } from '../annotations/schema.ts';
|
|
29
40
|
import { Y_TYPES } from '../collab/persistence.ts';
|
|
30
41
|
import { commentKey, dedupeCommentsById } from './comment-identity.ts';
|
|
31
42
|
import {
|
|
@@ -293,30 +304,84 @@ export function applyCommentsToDoc(doc: Y.Doc, next: CommentsSnapshot, origin?:
|
|
|
293
304
|
|
|
294
305
|
/* ---------------------------------------------------------------- annotations */
|
|
295
306
|
|
|
296
|
-
|
|
307
|
+
export { canonicalAnnotations };
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The local board (canonical text) at `jsonPath`, falling back to a not-yet-
|
|
311
|
+
* migrated legacy `.annotations.svg` sibling — so a cold start that races the
|
|
312
|
+
* boot migration still sees the local strokes instead of "no local" (which lets
|
|
313
|
+
* the hub win by default: the DDR-223 eraser shape). null = neither exists.
|
|
314
|
+
*/
|
|
315
|
+
export function readLocalAnnotations(
|
|
316
|
+
jsonPath: string,
|
|
317
|
+
read: (abs: string) => string | null
|
|
318
|
+
): string | null {
|
|
319
|
+
const local = read(jsonPath);
|
|
320
|
+
if (local !== null) return canonicalAnnotations(local) ?? local;
|
|
321
|
+
const legacy = read(jsonPath.replace(/\.json$/, '.svg'));
|
|
322
|
+
return legacy === null ? null : canonicalAnnotations(legacy);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The board held by the doc as canonical text, or null if never populated. */
|
|
297
326
|
export function annotationsFromDoc(doc: Y.Doc): string | null {
|
|
298
|
-
|
|
299
|
-
const svg = map.get('svg');
|
|
300
|
-
return typeof svg === 'string' ? svg : null;
|
|
327
|
+
return replicaBoardText(doc);
|
|
301
328
|
}
|
|
302
329
|
|
|
303
330
|
/**
|
|
304
|
-
* True when an annotations value carries ZERO
|
|
305
|
-
*
|
|
306
|
-
* `strokesToSvg([])` emits — 72 bytes, constant across peers).
|
|
331
|
+
* True when an annotations value carries ZERO elements: null, `''`, an empty
|
|
332
|
+
* board, or the bare legacy wrapper `<svg …></svg>`.
|
|
307
333
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
|
|
314
|
-
|
|
334
|
+
* Load-bearing for cold start (the 2026-08-14 annotations eraser, DDR-223): the
|
|
335
|
+
* legacy wrapper was a non-empty STRING, so every `!== ''` guard let a stale hub
|
|
336
|
+
* wrapper overwrite a peer's real strokes. Emptiness is "zero elements", never a
|
|
337
|
+
* byte heuristic. Live delete-all still materializes; only COLD-START decisions
|
|
338
|
+
* treat this as emptiness.
|
|
339
|
+
*/
|
|
340
|
+
export function isEmptyAnnotationsSvg(text: string | null): boolean {
|
|
341
|
+
return isEmptyBoardText(text);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Make the doc's replica hold `next` (board text or legacy SVG), writing only
|
|
346
|
+
* the elements / fields that differ. `null` / `''` → an empty board. A
|
|
347
|
+
* filesystem import is a new operation, never the previous UI author's echo:
|
|
348
|
+
* `writeReplica` clears '~action' on any change made without an action id.
|
|
349
|
+
*/
|
|
350
|
+
/**
|
|
351
|
+
* A disk change to the board, imported into the doc as the CHANGE it made —
|
|
352
|
+
* the ops from what disk last held to what it holds now — never as a
|
|
353
|
+
* replacement. A projection written a moment before a newer edit, whose file
|
|
354
|
+
* event arrives after it, would otherwise put the older values back (the
|
|
355
|
+
* multiplayer rig's lost toolbar edits and deletes). Without a known base
|
|
356
|
+
* (first sight of the file) it is a full apply. Returns whether the doc changed.
|
|
315
357
|
*/
|
|
316
|
-
export
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
358
|
+
export { noteAnnotationsOnDisk };
|
|
359
|
+
|
|
360
|
+
export function importAnnotationsFromDisk(
|
|
361
|
+
doc: Y.Doc,
|
|
362
|
+
next: string | null,
|
|
363
|
+
origin?: unknown,
|
|
364
|
+
fallbackBase?: string | null
|
|
365
|
+
): boolean {
|
|
366
|
+
const text = next === null ? serializeBoard([]) : canonicalAnnotations(next);
|
|
367
|
+
if (text === null || byteLengthUtf8(text) > MAX_ANNOTATIONS_BYTES) {
|
|
368
|
+
return applyAnnotationsToDoc(doc, next, origin);
|
|
369
|
+
}
|
|
370
|
+
const known = annotationsOnDiskOf(doc);
|
|
371
|
+
const base =
|
|
372
|
+
(known !== undefined ? canonicalAnnotations(known) : null) ??
|
|
373
|
+
(fallbackBase ? canonicalAnnotations(fallbackBase) : null);
|
|
374
|
+
const cur = readReplica(doc);
|
|
375
|
+
noteAnnotationsOnDisk(doc, text);
|
|
376
|
+
if (base === null || cur === null) {
|
|
377
|
+
return applyAnnotationsToDoc(doc, text, origin);
|
|
378
|
+
}
|
|
379
|
+
const toMap = (t: string) => new Map(parseBoard(t).elements.map((e) => [e.id, e]));
|
|
380
|
+
const ops = diffToOps(toMap(base), toMap(text));
|
|
381
|
+
if (!ops.length) return false;
|
|
382
|
+
const r = applyOps(new Map(cur.elements.map((e) => [e.id, e])), ops);
|
|
383
|
+
if (!r.touched.size) return false;
|
|
384
|
+
return writeReplica(doc, [...r.state.values()], origin);
|
|
320
385
|
}
|
|
321
386
|
|
|
322
387
|
export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?: unknown): boolean {
|
|
@@ -326,21 +391,13 @@ export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?:
|
|
|
326
391
|
);
|
|
327
392
|
return false;
|
|
328
393
|
}
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
map.delete(ANNOTATION_WRITE_ID);
|
|
337
|
-
if (next === null || next === '') {
|
|
338
|
-
map.delete('svg');
|
|
339
|
-
} else {
|
|
340
|
-
map.set('svg', next);
|
|
341
|
-
}
|
|
342
|
-
}, origin);
|
|
343
|
-
return true;
|
|
394
|
+
const text = next === null ? serializeBoard([]) : canonicalAnnotations(next);
|
|
395
|
+
if (text === null) {
|
|
396
|
+
console.warn('[sync/codec] refusing annotations apply: not a board document');
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
if (readReplica(doc) !== null && replicaBoardText(doc) === text) return false;
|
|
400
|
+
return writeReplica(doc, parseBoard(text).elements, origin);
|
|
344
401
|
}
|
|
345
402
|
|
|
346
403
|
/* ---------------------------------------------------------------- meta */
|
|
@@ -426,7 +483,8 @@ export function laneValueFromFile(
|
|
|
426
483
|
lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments',
|
|
427
484
|
text: string
|
|
428
485
|
): string | null {
|
|
429
|
-
if (lane === 'html' || lane === 'css'
|
|
486
|
+
if (lane === 'html' || lane === 'css') return text;
|
|
487
|
+
if (lane === 'annotations') return annotationsLaneValue(text);
|
|
430
488
|
let parsed: unknown;
|
|
431
489
|
try {
|
|
432
490
|
parsed = parseJsonSafe(text);
|
|
@@ -448,10 +506,7 @@ export function readLaneFromDoc(
|
|
|
448
506
|
lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments'
|
|
449
507
|
): string {
|
|
450
508
|
if (lane === 'html' || lane === 'css' || lane === 'meta') return doc.getText(lane).toString();
|
|
451
|
-
if (lane === 'annotations')
|
|
452
|
-
const svg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
|
|
453
|
-
return typeof svg === 'string' ? svg : '';
|
|
454
|
-
}
|
|
509
|
+
if (lane === 'annotations') return annotationsLaneValue(replicaBoardText(doc) ?? '') ?? '';
|
|
455
510
|
const list = doc.getArray<unknown>(Y_TYPES.comments).toArray();
|
|
456
511
|
return list.length ? JSON.stringify(list) : '';
|
|
457
512
|
}
|