@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/api.ts
CHANGED
|
@@ -14,14 +14,25 @@ import {
|
|
|
14
14
|
stat as statp,
|
|
15
15
|
} from 'node:fs/promises';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
+
import { canonicalAnnotations } from './annotations/board-text.ts';
|
|
18
|
+
import {
|
|
19
|
+
type Op as AnnotationOp,
|
|
20
|
+
type ApplyResult,
|
|
21
|
+
applyOps as applyAnnotationOpsPure,
|
|
22
|
+
diffToOps,
|
|
23
|
+
} from './annotations/ops.ts';
|
|
24
|
+
import { parseBoard, serializeBoard } from './annotations/schema.ts';
|
|
25
|
+
import type { AnnotationElement } from './annotations/types.ts';
|
|
17
26
|
import { createAssetMirror, s3ConfigFromEnv } from './assets-s3.ts';
|
|
18
27
|
import { canvasArtifacts, locatorKeyFor, relocatedName } from './canvas-artifacts.ts';
|
|
19
28
|
import { renderBriefBoard, validateCanvasName, validateFolderName } from './canvas-create.ts';
|
|
20
29
|
import { rewriteRelativeImports } from './canvas-imports.ts';
|
|
21
30
|
import { canvasSlugFromRel } from './canvas-slug.ts';
|
|
31
|
+
import { isAnnotationId, isWorldPoint } from './comment-anchor.ts';
|
|
22
32
|
import { atomicWrite } from './sync/atomic-write.ts';
|
|
23
33
|
import { dedupeCommentsById } from './sync/comment-identity.ts';
|
|
24
34
|
import { isRuntimeStateRel } from './sync/file-membership.ts';
|
|
35
|
+
import { MAX_ANNOTATIONS_BYTES } from './sync/limits.ts';
|
|
25
36
|
|
|
26
37
|
// Re-exported so existing external callers (canvas-list-watch.ts, tests) keep
|
|
27
38
|
// importing it from api.ts — the actual implementation now lives in
|
|
@@ -33,7 +44,7 @@ export { canvasSlugFromRel } from './canvas-slug.ts';
|
|
|
33
44
|
* delete: what it previews (notes, styles, data, images, media, fonts), minus
|
|
34
45
|
* the canvas's own sidecars, which only ever travel with their canvas. */
|
|
35
46
|
export function isSupportingFileRel(rel: string): boolean {
|
|
36
|
-
if (/\.(meta\.json|annotations\.svg|registry\.json)$/i.test(rel)) return false;
|
|
47
|
+
if (/\.(meta\.json|annotations\.(?:svg|json)|registry\.json)$/i.test(rel)) return false;
|
|
37
48
|
return /\.(md|css|json|txt|ya?ml|svg|png|jpe?g|gif|webp|avif|mp4|webm|mov|mp3|wav|ogg|m4a|woff2?|ttf|otf)$/i.test(
|
|
38
49
|
rel
|
|
39
50
|
);
|
|
@@ -116,6 +127,7 @@ import { STICKERS_DIR } from './paths.ts';
|
|
|
116
127
|
import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
|
|
117
128
|
import { sessionDir } from './session-scope.ts';
|
|
118
129
|
import { describeSourceOp } from './sync/source-ops.ts';
|
|
130
|
+
import { normalizeTreeState, type TreeState } from './tree-state.ts';
|
|
119
131
|
import { isWorkspaceMode } from './workspace-mode.ts';
|
|
120
132
|
|
|
121
133
|
// Directories that never hold user-facing canvases. Exported so the
|
|
@@ -323,6 +335,12 @@ export interface Comment {
|
|
|
323
335
|
* text is untrusted user/peer text (DDR-054) — rendered as text, never
|
|
324
336
|
* into TSX. */
|
|
325
337
|
timeline?: { clipStableId?: string; frameOffset?: number; frame?: number; lane?: string };
|
|
338
|
+
/** #134/#136 — anchor on an annotation (an element `id` in `*.annotations.json`).
|
|
339
|
+
* Absent on element and floating comments. See comment-anchor.ts. */
|
|
340
|
+
annotationId?: string;
|
|
341
|
+
/** World point of the comment: the anchor of a floating comment, the last
|
|
342
|
+
* known place of an anchored one. Absent on legacy comments. */
|
|
343
|
+
world?: { x: number; y: number };
|
|
326
344
|
}
|
|
327
345
|
|
|
328
346
|
export interface GitCommitter {
|
|
@@ -417,6 +435,8 @@ export interface Api {
|
|
|
417
435
|
// Canvas state
|
|
418
436
|
loadCanvasState(file: string): Promise<Record<string, unknown> | null>;
|
|
419
437
|
saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
|
|
438
|
+
loadTreeState(): Promise<TreeState>;
|
|
439
|
+
saveTreeState(state: TreeState): Promise<TreeState>;
|
|
420
440
|
timelineMediaLoad(key: string): Promise<Record<string, unknown> | null>;
|
|
421
441
|
timelineMediaSave(key: string, data: Record<string, unknown>): Promise<boolean>;
|
|
422
442
|
// Canvas meta sidecar (Phase 4 T5 — .design/ui/<slug>.meta.json)
|
|
@@ -429,11 +449,23 @@ export interface Api {
|
|
|
429
449
|
file: string,
|
|
430
450
|
patch: Record<string, unknown>
|
|
431
451
|
): Promise<Record<string, unknown> | null>;
|
|
432
|
-
// Annotations
|
|
452
|
+
// Annotations board (DDR-242 — .design/<slug>.annotations.json)
|
|
453
|
+
/** Canonical board text; a not-yet-migrated legacy SVG is read through the migration. */
|
|
433
454
|
loadAnnotations(file: string): Promise<string | null>;
|
|
434
|
-
|
|
455
|
+
/** Strict read: an existing board that can't be read is `ok: false`, never an empty board. */
|
|
456
|
+
readBoard(
|
|
457
|
+
file: string
|
|
458
|
+
): Promise<{ ok: true; text: string | null } | { ok: false; error: string }>;
|
|
459
|
+
/** Whole-board write (board text or legacy SVG, canonicalized). */
|
|
460
|
+
saveAnnotations(file: string, text: string, writeId?: string, base?: string): Promise<boolean>;
|
|
461
|
+
/** The canvas write path: an op batch under the DDR-242 merge rule. */
|
|
462
|
+
applyAnnotationOps(
|
|
463
|
+
file: string,
|
|
464
|
+
ops: readonly AnnotationOp[],
|
|
465
|
+
actionId?: string
|
|
466
|
+
): Promise<AnnotationOpsResult>;
|
|
435
467
|
/** Materialize a document snapshot without publishing it as another user edit. */
|
|
436
|
-
projectAnnotations(file: string,
|
|
468
|
+
projectAnnotations(file: string, text: string, isCurrent: () => boolean): Promise<boolean>;
|
|
437
469
|
// Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
|
|
438
470
|
saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
439
471
|
/** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
|
|
@@ -811,8 +843,22 @@ export interface ApiHooks {
|
|
|
811
843
|
*/
|
|
812
844
|
/** `base` — the list this mutation started from (a merge hint for the project). */
|
|
813
845
|
onCommentsChanged: (file: string, comments: Comment[], base?: Comment[]) => void | Promise<void>;
|
|
814
|
-
/**
|
|
815
|
-
|
|
846
|
+
/**
|
|
847
|
+
* Fires after every successful annotations write with the new canonical
|
|
848
|
+
* board text, the action/write id and the board text it replaced (a merge
|
|
849
|
+
* base for accepted revisions).
|
|
850
|
+
*/
|
|
851
|
+
onAnnotationsChanged?: (file: string, text: string, writeId?: string, base?: string) => void;
|
|
852
|
+
/**
|
|
853
|
+
* Apply an op batch to the canvas's LIVE collab room, when one holds the
|
|
854
|
+
* board (code review H1). `null` = no live room / accepted mode — the batch
|
|
855
|
+
* goes to disk. The room's persistence projects the result to the file.
|
|
856
|
+
*/
|
|
857
|
+
applyAnnotationOpsLive?: (
|
|
858
|
+
file: string,
|
|
859
|
+
ops: readonly AnnotationOp[],
|
|
860
|
+
actionId?: string
|
|
861
|
+
) => ApplyResult | 'too-large' | null;
|
|
816
862
|
/**
|
|
817
863
|
* Accepted-revisions mode (DDR-241): propose a folder operation as ONE
|
|
818
864
|
* project action before touching disk. Absent, or answering `null`, means
|
|
@@ -858,8 +904,18 @@ export interface ApiHooks {
|
|
|
858
904
|
// server modules). Re-exported here so every existing `from './api.ts'`
|
|
859
905
|
// import keeps working unchanged.
|
|
860
906
|
export { ASSET_IMAGE_HREF_RE, sanitizeAnnotationSvg } from './annotations-model.ts';
|
|
861
|
-
|
|
862
|
-
|
|
907
|
+
export type { AnnotationOp };
|
|
908
|
+
/** Outcome of an annotations op batch (DDR-242 §4). */
|
|
909
|
+
export interface AnnotationOpsResult {
|
|
910
|
+
ok: boolean;
|
|
911
|
+
/** Whether the board changed (false for a no-op batch). */
|
|
912
|
+
changed: boolean;
|
|
913
|
+
/** Ops the board could not take: `gone` (element deleted), `invalid`, `stale` (strict undo). */
|
|
914
|
+
rejected: Array<{ id?: string; reason: string; fields?: string[] }>;
|
|
915
|
+
error?: string;
|
|
916
|
+
/** The board on disk exists but can't be read — nothing was written (HTTP 409). */
|
|
917
|
+
unreadable?: boolean;
|
|
918
|
+
}
|
|
863
919
|
|
|
864
920
|
/**
|
|
865
921
|
* Phase 23 — per-file ceiling for a still image. Raised 10 MB → 50 MB (still
|
|
@@ -1247,6 +1303,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1247
1303
|
thread: Array.isArray(c.thread) ? c.thread : [],
|
|
1248
1304
|
mentions: Array.isArray(c.mentions) ? c.mentions : [],
|
|
1249
1305
|
...(timeline ? { timeline } : { timeline: undefined }),
|
|
1306
|
+
// Same trust boundary for the #134/#136 anchors: a peer-synced comment
|
|
1307
|
+
// never passed commentsAdd, so an anchor that fails its shape is dropped
|
|
1308
|
+
// (the comment then renders from `bounds`, detached — never deleted).
|
|
1309
|
+
annotationId: isAnnotationId(c.annotationId) ? c.annotationId : undefined,
|
|
1310
|
+
world: isWorldPoint(c.world) ? { x: c.world.x, y: c.world.y } : undefined,
|
|
1250
1311
|
};
|
|
1251
1312
|
}
|
|
1252
1313
|
|
|
@@ -1462,6 +1523,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1462
1523
|
}
|
|
1463
1524
|
if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor;
|
|
1464
1525
|
}
|
|
1526
|
+
// #134/#136 — annotation / world anchors (peer-supplied: shape-checked).
|
|
1527
|
+
if (isAnnotationId(payload.annotationId)) c.annotationId = payload.annotationId;
|
|
1528
|
+
if (isWorldPoint(payload.world)) c.world = { x: payload.world.x, y: payload.world.y };
|
|
1465
1529
|
list.push(c);
|
|
1466
1530
|
await publishComments(payload.file, list, base);
|
|
1467
1531
|
return c;
|
|
@@ -1559,6 +1623,37 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1559
1623
|
}
|
|
1560
1624
|
}
|
|
1561
1625
|
|
|
1626
|
+
// ---------- File-tree expansion (issue #124) ----------
|
|
1627
|
+
//
|
|
1628
|
+
// Which folders + sections of the Files panel the user left open. Per-user
|
|
1629
|
+
// runtime state (DDR-115): it lives under `_canvas-state/` — already on every
|
|
1630
|
+
// ignore list, never versioned or synced — and goes through `sessionDir`, so
|
|
1631
|
+
// in a cell each member keeps their own tree. A SUBDIRECTORY, not a sibling
|
|
1632
|
+
// file: `/_canvas-state` builds `<fileSlug(file)>.json` from user input with
|
|
1633
|
+
// no host/origin guard, so any flat name here is reachable through it
|
|
1634
|
+
// (`?file=_file-tree` hit `_file-tree.json`). A slug never contains `/`.
|
|
1635
|
+
function treeStateDir(): string {
|
|
1636
|
+
return path.join(sessionDir(paths.canvasStateDir), '_tree');
|
|
1637
|
+
}
|
|
1638
|
+
function treeStatePath(): string {
|
|
1639
|
+
return path.join(treeStateDir(), 'state.json');
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
async function loadTreeState(): Promise<TreeState> {
|
|
1643
|
+
try {
|
|
1644
|
+
return normalizeTreeState(JSON.parse(await Bun.file(treeStatePath()).text()));
|
|
1645
|
+
} catch {
|
|
1646
|
+
return { dirs: [], sections: {} };
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
async function saveTreeState(state: TreeState): Promise<TreeState> {
|
|
1651
|
+
const safe = normalizeTreeState(state);
|
|
1652
|
+
await mkdir(treeStateDir(), { recursive: true });
|
|
1653
|
+
await Bun.write(treeStatePath(), JSON.stringify(safe, null, 2));
|
|
1654
|
+
return safe;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1562
1657
|
// ---------- Timeline media visuals cache (enhanced-video-editing Task 7) ----
|
|
1563
1658
|
//
|
|
1564
1659
|
// Filmstrip dataURL strips + waveform peak arrays, keyed `<sha8>:<bucket>`,
|
|
@@ -2064,81 +2159,179 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2064
2159
|
return await loadCanvasMeta(file);
|
|
2065
2160
|
}
|
|
2066
2161
|
|
|
2067
|
-
// ---------- Annotations sidecar (
|
|
2162
|
+
// ---------- Annotations sidecar (DDR-242 — annotations v2) ----------
|
|
2068
2163
|
//
|
|
2069
|
-
// Each canvas keeps
|
|
2070
|
-
//
|
|
2071
|
-
//
|
|
2072
|
-
//
|
|
2073
|
-
//
|
|
2074
|
-
//
|
|
2164
|
+
// Each canvas keeps ONE `<slug>.annotations.json` board under `<designRoot>/`
|
|
2165
|
+
// (canonical, one element per line — annotations/schema.ts). The canvas sends
|
|
2166
|
+
// OPS (`applyAnnotationOps`), never the whole board; whole-board writes
|
|
2167
|
+
// (`saveAnnotations`) remain for imports and headless writers. Every write is
|
|
2168
|
+
// validated element-by-element (DDR-054 — the canvas origin and peers are
|
|
2169
|
+
// untrusted) and capped at MAX_ANNOTATIONS_BYTES.
|
|
2170
|
+
//
|
|
2171
|
+
// A board still stored as a legacy `.annotations.svg` (not yet migrated) is
|
|
2172
|
+
// READ through the v1→v2 migration; the boot migration (annotations/
|
|
2173
|
+
// migrate-boot.ts) rewrites it on disk.
|
|
2075
2174
|
|
|
2076
2175
|
function annotationsPath(file: string): string {
|
|
2176
|
+
return path.join(paths.designRoot, `${fileSlug(file)}.annotations.json`);
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
function legacyAnnotationsPath(file: string): string {
|
|
2077
2180
|
return path.join(paths.designRoot, `${fileSlug(file)}.annotations.svg`);
|
|
2078
2181
|
}
|
|
2079
2182
|
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2183
|
+
type BoardRead = { ok: true; text: string | null } | { ok: false; error: string };
|
|
2184
|
+
|
|
2185
|
+
/**
|
|
2186
|
+
* Strict board read. `text: null` = the canvas has no annotations. A file
|
|
2187
|
+
* that EXISTS but is oversized or not a board is `ok: false` — never an
|
|
2188
|
+
* empty board, or the next write would silently erase it (code review H2).
|
|
2189
|
+
*/
|
|
2190
|
+
async function readBoard(file: string): Promise<BoardRead> {
|
|
2191
|
+
for (const p of [annotationsPath(file), legacyAnnotationsPath(file)]) {
|
|
2192
|
+
const f = Bun.file(p);
|
|
2193
|
+
if (!(await f.exists())) continue;
|
|
2194
|
+
// Untrusted (peer / git) content: size gate BEFORE reading (DDR-054).
|
|
2195
|
+
if (f.size > MAX_ANNOTATIONS_BYTES) {
|
|
2196
|
+
return { ok: false, error: 'board file exceeds the size cap' };
|
|
2197
|
+
}
|
|
2198
|
+
let raw: string;
|
|
2199
|
+
try {
|
|
2200
|
+
raw = await f.text();
|
|
2201
|
+
} catch {
|
|
2202
|
+
return { ok: false, error: 'board file is unreadable' };
|
|
2203
|
+
}
|
|
2204
|
+
const text = canonicalAnnotations(raw);
|
|
2205
|
+
return text === null
|
|
2206
|
+
? { ok: false, error: 'board file is not a valid board' }
|
|
2207
|
+
: { ok: true, text };
|
|
2085
2208
|
}
|
|
2209
|
+
return { ok: true, text: null };
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
/** Canonical board text, or null when the canvas has no annotations (or they can't be read). */
|
|
2213
|
+
async function loadAnnotations(file: string): Promise<string | null> {
|
|
2214
|
+
const r = await readBoard(file);
|
|
2215
|
+
return r.ok ? r.text : null;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// Read-modify-write of one board is serialized per file: two op batches
|
|
2219
|
+
// landing together must not both read the same state and drop one another.
|
|
2220
|
+
const annotationChains = new Map<string, Promise<unknown>>();
|
|
2221
|
+
function onAnnotationChain<T>(file: string, fn: () => Promise<T>): Promise<T> {
|
|
2222
|
+
const key = fileSlug(file);
|
|
2223
|
+
const prev = annotationChains.get(key) ?? Promise.resolve();
|
|
2224
|
+
const next = prev.then(fn, fn);
|
|
2225
|
+
annotationChains.set(
|
|
2226
|
+
key,
|
|
2227
|
+
next.catch(() => {})
|
|
2228
|
+
);
|
|
2229
|
+
return next;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
async function writeBoardFile(file: string, text: string): Promise<void> {
|
|
2233
|
+
await Bun.write(annotationsPath(file), text);
|
|
2234
|
+
// Annotations reach OTHER VIEWERS over the collab room, but the file is also
|
|
2235
|
+
// a versioned, file-plane sidecar (DDR-115), and the file plane learns about
|
|
2236
|
+
// a cell's own writes through exactly this event.
|
|
2237
|
+
announceWritten(`${fileSlug(file)}.annotations.json`);
|
|
2086
2238
|
}
|
|
2087
2239
|
|
|
2240
|
+
/**
|
|
2241
|
+
* Whole-board write (imports, headless writers, legacy PUT). Accepts board
|
|
2242
|
+
* text or a legacy SVG (converted). `base` travels only as a merge hint for
|
|
2243
|
+
* the project — bounded and canonicalized like the value, never written.
|
|
2244
|
+
*/
|
|
2088
2245
|
async function saveAnnotations(
|
|
2089
2246
|
file: string,
|
|
2090
|
-
|
|
2247
|
+
text: string,
|
|
2091
2248
|
writeId?: string,
|
|
2092
2249
|
base?: string
|
|
2093
2250
|
): Promise<boolean> {
|
|
2094
|
-
if (typeof
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
// writes of arbitrary blobs through this endpoint.
|
|
2098
|
-
if (!/^\s*<svg[\s>]/i.test(svg)) return false;
|
|
2099
|
-
// A3 (DDR-060 F1 re-audit) — sanitize active content before persisting.
|
|
2100
|
-
// This endpoint is on the canvas-origin allowlist (DDR-054 "inert collab
|
|
2101
|
-
// write") and accepts ANY `file`, so a hub-pushed canvas can write a
|
|
2102
|
-
// sibling's `.annotations.svg`. The persisted SVG is currently consumed only
|
|
2103
|
-
// via `svgToStrokes` (DOMParser image/svg+xml → structured strokes → React
|
|
2104
|
-
// re-render), so a `<script>`/`on*` payload is parsed inertly and discarded
|
|
2105
|
-
// — the stored-XSS chain is LATENT today, not live. We sanitize anyway so
|
|
2106
|
-
// "inert" stays true for any future raw-render consumer and for the synced
|
|
2107
|
-
// file a peer/Claude-context ingests. The legit annotation vocabulary
|
|
2108
|
-
// (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
|
|
2109
|
-
// polyline/text — so stripping executable constructs is zero-regression.
|
|
2110
|
-
const clean = sanitizeAnnotationSvg(svg);
|
|
2111
|
-
// The edit's base travels only as a merge hint for the project — bounded
|
|
2112
|
-
// and sanitized like the value itself, never written anywhere.
|
|
2251
|
+
if (typeof text !== 'string' || text.length > MAX_ANNOTATIONS_BYTES) return false;
|
|
2252
|
+
const clean = canonicalAnnotations(text);
|
|
2253
|
+
if (clean === null) return false;
|
|
2113
2254
|
const cleanBase =
|
|
2114
|
-
typeof base === 'string' &&
|
|
2115
|
-
|
|
2116
|
-
(base === '' || /^\s*<svg[\s>]/i.test(base))
|
|
2117
|
-
? base === ''
|
|
2118
|
-
? ''
|
|
2119
|
-
: sanitizeAnnotationSvg(base)
|
|
2255
|
+
typeof base === 'string' && base.length <= MAX_ANNOTATIONS_BYTES
|
|
2256
|
+
? (canonicalAnnotations(base) ?? undefined)
|
|
2120
2257
|
: undefined;
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2258
|
+
return onAnnotationChain(file, async () => {
|
|
2259
|
+
// Never overwrite a board we can't read — it may hold work (review H2).
|
|
2260
|
+
const read = await readBoard(file);
|
|
2261
|
+
if (!read.ok) return false;
|
|
2262
|
+
let next = clean;
|
|
2263
|
+
if (cleanBase !== undefined) {
|
|
2264
|
+
// With a base, the write is "what changed since base", merged onto the
|
|
2265
|
+
// current board — a whole-board PUT no longer erases concurrent edits
|
|
2266
|
+
// (code review M5). Same one merge rule as the op path.
|
|
2267
|
+
const byId = (t: string) => new Map(parseBoard(t).elements.map((e) => [e.id, e]));
|
|
2268
|
+
const ops = diffToOps(byId(cleanBase), byId(clean));
|
|
2269
|
+
const live = hooks.applyAnnotationOpsLive?.(file, ops, writeId) ?? null;
|
|
2270
|
+
if (live === 'too-large') return false;
|
|
2271
|
+
if (live) return true;
|
|
2272
|
+
const current = read.text ?? serializeBoard([]);
|
|
2273
|
+
next = serializeBoard([...applyAnnotationOpsPure(byId(current), ops).state.values()]);
|
|
2274
|
+
if (next.length > MAX_ANNOTATIONS_BYTES) return false;
|
|
2275
|
+
if (next === current) return true;
|
|
2276
|
+
}
|
|
2277
|
+
await writeBoardFile(file, next);
|
|
2278
|
+
onAnnotationsChanged?.(file, next, writeId, cleanBase);
|
|
2279
|
+
return true;
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
/**
|
|
2284
|
+
* The canvas write path (DDR-242 §4): apply an op batch under the one merge
|
|
2285
|
+
* rule and persist the result. Ops the board can't take (gone / invalid /
|
|
2286
|
+
* stale) are reported, never silently dropped.
|
|
2287
|
+
*/
|
|
2288
|
+
async function applyAnnotationOps(
|
|
2289
|
+
file: string,
|
|
2290
|
+
ops: readonly AnnotationOp[],
|
|
2291
|
+
actionId?: string
|
|
2292
|
+
): Promise<AnnotationOpsResult> {
|
|
2293
|
+
return onAnnotationChain(file, async () => {
|
|
2294
|
+
const rejectedOf = (r: ApplyResult) =>
|
|
2295
|
+
r.rejected.map((x) => ({
|
|
2296
|
+
id: 'id' in x.op ? x.op.id : x.op.el?.id,
|
|
2297
|
+
reason: x.reason,
|
|
2298
|
+
...(x.fields ? { fields: x.fields } : {}),
|
|
2299
|
+
}));
|
|
2300
|
+
// A live room is ahead of the file (its flush is debounced): the batch
|
|
2301
|
+
// goes to its replica, or it would revert a peer's unflushed edit (H1).
|
|
2302
|
+
const live = hooks.applyAnnotationOpsLive?.(file, ops, actionId) ?? null;
|
|
2303
|
+
if (live === 'too-large') {
|
|
2304
|
+
return { ok: false, changed: false, rejected: [], error: 'board exceeds the size cap' };
|
|
2305
|
+
}
|
|
2306
|
+
if (live) return { ok: true, changed: live.touched.size > 0, rejected: rejectedOf(live) };
|
|
2307
|
+
const read = await readBoard(file);
|
|
2308
|
+
if (!read.ok) {
|
|
2309
|
+
return { ok: false, changed: false, rejected: [], error: read.error, unreadable: true };
|
|
2310
|
+
}
|
|
2311
|
+
const before = read.text ? parseBoard(read.text).elements : [];
|
|
2312
|
+
const beforeText = serializeBoard(before);
|
|
2313
|
+
const r = applyAnnotationOpsPure(new Map(before.map((e) => [e.id, e])), ops);
|
|
2314
|
+
const rejected = rejectedOf(r);
|
|
2315
|
+
if (!r.touched.size) return { ok: true, changed: false, rejected };
|
|
2316
|
+
const text = serializeBoard([...r.state.values()]);
|
|
2317
|
+
if (text.length > MAX_ANNOTATIONS_BYTES) {
|
|
2318
|
+
return { ok: false, changed: false, rejected, error: 'board exceeds the size cap' };
|
|
2319
|
+
}
|
|
2320
|
+
await writeBoardFile(file, text);
|
|
2321
|
+
onAnnotationsChanged?.(file, text, actionId, beforeText);
|
|
2322
|
+
return { ok: true, changed: true, rejected };
|
|
2323
|
+
});
|
|
2131
2324
|
}
|
|
2132
2325
|
|
|
2133
2326
|
async function projectAnnotations(
|
|
2134
2327
|
file: string,
|
|
2135
|
-
|
|
2328
|
+
text: string,
|
|
2136
2329
|
isCurrent: () => boolean
|
|
2137
2330
|
): Promise<boolean> {
|
|
2138
|
-
if (typeof
|
|
2139
|
-
return false;
|
|
2331
|
+
if (typeof text !== 'string' || text.length > MAX_ANNOTATIONS_BYTES) return false;
|
|
2140
2332
|
if (!isCurrent()) return false;
|
|
2141
|
-
const clean =
|
|
2333
|
+
const clean = canonicalAnnotations(text);
|
|
2334
|
+
if (clean === null) return false;
|
|
2142
2335
|
// Runtime scratch stays out of the file plane. The async IO must not touch
|
|
2143
2336
|
// the serving file until we recheck the document; another edit may have
|
|
2144
2337
|
// arrived while Bun.write was pending. Check + rename have no await gap.
|
|
@@ -2149,7 +2342,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2149
2342
|
await Bun.write(temp, clean);
|
|
2150
2343
|
if (!isCurrent()) return false;
|
|
2151
2344
|
renameSync(temp, annotationsPath(file));
|
|
2152
|
-
announceWritten(`${fileSlug(file)}.annotations.
|
|
2345
|
+
announceWritten(`${fileSlug(file)}.annotations.json`);
|
|
2153
2346
|
return true;
|
|
2154
2347
|
} finally {
|
|
2155
2348
|
await rm(temp, { force: true });
|
|
@@ -2967,12 +3160,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2967
3160
|
}
|
|
2968
3161
|
const slug = fileSlug(toRel);
|
|
2969
3162
|
// The whiteboard layer is slug-keyed at the design root (canvas-artifacts).
|
|
2970
|
-
const
|
|
2971
|
-
if (
|
|
2972
|
-
await Bun.write(
|
|
2973
|
-
path.join(paths.designRoot, `${slug}.annotations.svg`),
|
|
2974
|
-
await Bun.file(annotationsAbs).arrayBuffer()
|
|
2975
|
-
);
|
|
3163
|
+
const board = await loadAnnotations(rel);
|
|
3164
|
+
if (board !== null) {
|
|
3165
|
+
await Bun.write(path.join(paths.designRoot, `${slug}.annotations.json`), board);
|
|
2976
3166
|
}
|
|
2977
3167
|
ctx.bus.emit('canvas-list-update', { action: 'added', rel: toRel, slug });
|
|
2978
3168
|
ctx.bus.emit('canvas-created', { slug });
|
|
@@ -3177,7 +3367,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3177
3367
|
if (e.isDirectory()) {
|
|
3178
3368
|
const hit = await walk(abs, depth + 1);
|
|
3179
3369
|
if (hit) return hit;
|
|
3180
|
-
} else if (/\.(tsx|jsx|css|meta\.json|annotations\.svg)$/i.test(e.name)) {
|
|
3370
|
+
} else if (/\.(tsx|jsx|css|meta\.json|annotations\.(?:svg|json))$/i.test(e.name)) {
|
|
3181
3371
|
const other = path.relative(paths.designRoot, abs).split(path.sep).join('/');
|
|
3182
3372
|
if (other === rel) continue;
|
|
3183
3373
|
const text = await readFile(abs, 'utf8').catch(() => '');
|
|
@@ -6606,13 +6796,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
6606
6796
|
parseMentions,
|
|
6607
6797
|
loadCanvasState,
|
|
6608
6798
|
saveCanvasState,
|
|
6799
|
+
loadTreeState,
|
|
6800
|
+
saveTreeState,
|
|
6609
6801
|
timelineMediaLoad,
|
|
6610
6802
|
timelineMediaSave,
|
|
6611
6803
|
loadCanvasMeta,
|
|
6612
6804
|
loadCanvasSource,
|
|
6613
6805
|
patchCanvasMeta,
|
|
6614
6806
|
loadAnnotations,
|
|
6807
|
+
readBoard,
|
|
6615
6808
|
saveAnnotations,
|
|
6809
|
+
applyAnnotationOps,
|
|
6616
6810
|
projectAnnotations,
|
|
6617
6811
|
saveAsset,
|
|
6618
6812
|
listAssets,
|
|
@@ -42,8 +42,16 @@ import {
|
|
|
42
42
|
import { homedir, tmpdir } from 'node:os';
|
|
43
43
|
import { join, resolve, sep } from 'node:path';
|
|
44
44
|
import { pathToFileURL } from 'node:url';
|
|
45
|
+
import { serializeBoard } from '../annotations/schema.ts';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Translated strokes → canonical v2 board text (DDR-242): straight to
|
|
49
|
+
* registry-validated elements (`toBoardElements`), never through SVG.
|
|
50
|
+
*/
|
|
51
|
+
function boardText(strokes) {
|
|
52
|
+
return serializeBoard(toBoardElements(strokes).elements);
|
|
53
|
+
}
|
|
45
54
|
|
|
46
|
-
import { sanitizeAnnotationSvg, strokesToSvg } from '../annotations-model.ts';
|
|
47
55
|
import {
|
|
48
56
|
applyRewrites,
|
|
49
57
|
FIGMA_ASSET_HOSTS,
|
|
@@ -68,7 +76,7 @@ import { readFigZip } from '../figma/fig-zip.ts';
|
|
|
68
76
|
import { attrValue, ImportReport } from '../figma/sanitize.ts';
|
|
69
77
|
import { JsxTooLargeError, toArtboard, toCanvas } from '../figma/to-artboard.ts';
|
|
70
78
|
import { toRenderCanvas } from '../figma/to-render.ts';
|
|
71
|
-
import { BoardTooLargeError, toStrokes } from '../figma/to-strokes.ts';
|
|
79
|
+
import { BoardTooLargeError, toBoardElements, toStrokes } from '../figma/to-strokes.ts';
|
|
72
80
|
import { stylesToTokens, variablesToTokens } from '../figma/to-tokens.ts';
|
|
73
81
|
import { FigmaCapError, normalizeDocument, walkNodes } from '../figma/types.ts';
|
|
74
82
|
import { FigmaUrlError, parseFigmaTarget } from '../figma/url.ts';
|
|
@@ -258,9 +266,9 @@ export function formatSummary(report, extra = {}) {
|
|
|
258
266
|
/**
|
|
259
267
|
* Phase 2 — import a FigJam board into the whiteboard annotation layer.
|
|
260
268
|
*
|
|
261
|
-
* Writes `<designRoot>/<slug>.annotations.
|
|
262
|
-
*
|
|
263
|
-
*
|
|
269
|
+
* Writes `<designRoot>/<slug>.annotations.json` as registry-validated v2
|
|
270
|
+
* elements (DDR-242), so this verb can never persist an element the canvas
|
|
271
|
+
* would reject (D6's annotation row).
|
|
264
272
|
*/
|
|
265
273
|
/**
|
|
266
274
|
* Read and decode a local `.fig` / `.jam` (DDR-221). Offline end to end: no
|
|
@@ -553,7 +561,9 @@ export async function importBoard({
|
|
|
553
561
|
const usable = strokes.filter((s) => s.tool !== 'image' || Boolean(s.href));
|
|
554
562
|
// Paper, then region, then content — in paint order. Either one emitted
|
|
555
563
|
// after the board would veil it.
|
|
556
|
-
|
|
564
|
+
// DDR-242 — the board is written as the v2 element model (`.annotations.json`):
|
|
565
|
+
// the translator's strokes become registry-validated elements directly.
|
|
566
|
+
const boardFinal = boardText([paper, backing, ...usable]);
|
|
557
567
|
|
|
558
568
|
// The board needs a canvas to live on — see `boardHostCanvas`. The
|
|
559
569
|
// annotation layer is named after THAT canvas's slug, not after a slug of
|
|
@@ -562,10 +572,10 @@ export async function importBoard({
|
|
|
562
572
|
const canvasRel = `ui/${title}.tsx`;
|
|
563
573
|
const annSlug = canvasSlug(canvasRel);
|
|
564
574
|
|
|
565
|
-
const stagedSvg = join(staging, 'board.annotations.
|
|
575
|
+
const stagedSvg = join(staging, 'board.annotations.json');
|
|
566
576
|
const stagedTsx = join(staging, 'board.tsx');
|
|
567
577
|
const stagedMeta = join(staging, 'board.meta.json');
|
|
568
|
-
writeFileSync(stagedSvg,
|
|
578
|
+
writeFileSync(stagedSvg, boardFinal, 'utf8');
|
|
569
579
|
writeFileSync(stagedTsx, boardHostCanvas(title), 'utf8');
|
|
570
580
|
writeFileSync(
|
|
571
581
|
stagedMeta,
|
|
@@ -583,7 +593,7 @@ export async function importBoard({
|
|
|
583
593
|
const finalPath = assertContained(
|
|
584
594
|
root,
|
|
585
595
|
designRootRel,
|
|
586
|
-
join(root, designRootRel, `${annSlug}.annotations.
|
|
596
|
+
join(root, designRootRel, `${annSlug}.annotations.json`)
|
|
587
597
|
);
|
|
588
598
|
const finalTsx = assertContained(root, designRootRel, join(root, designRootRel, canvasRel));
|
|
589
599
|
const finalMeta = assertContained(
|
|
@@ -1099,12 +1109,12 @@ export async function importPages({
|
|
|
1099
1109
|
|
|
1100
1110
|
if (annStrokes.length > 0) {
|
|
1101
1111
|
const annSlug = canvasSlug(`${relDir}/${title}.tsx`);
|
|
1102
|
-
const stagedAnn = join(staging, 'page.annotations.
|
|
1103
|
-
writeFileSync(stagedAnn,
|
|
1112
|
+
const stagedAnn = join(staging, 'page.annotations.json');
|
|
1113
|
+
writeFileSync(stagedAnn, boardText(annStrokes), 'utf8');
|
|
1104
1114
|
const finalAnn = assertContained(
|
|
1105
1115
|
root,
|
|
1106
1116
|
designRootRel,
|
|
1107
|
-
join(root, designRootRel, `${annSlug}.annotations.
|
|
1117
|
+
join(root, designRootRel, `${annSlug}.annotations.json`)
|
|
1108
1118
|
);
|
|
1109
1119
|
promoteFile(stagedAnn, finalAnn);
|
|
1110
1120
|
}
|