@1agh/maude 1.4.6 → 1.5.1
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 +534 -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-merge.ts +15 -0
- package/apps/studio/annotations/ops.ts +499 -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 +65 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +249 -69
- 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-lib.tsx +13 -1
- package/apps/studio/canvas-shell.tsx +6 -0
- package/apps/studio/client/app.jsx +98 -35
- 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 +49 -5
- package/apps/studio/collab/persistence.ts +31 -10
- package/apps/studio/collab/registry.ts +64 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/cursors-overlay.tsx +158 -4
- 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 +12 -4
- package/apps/studio/sync/agent.ts +8 -4
- package/apps/studio/sync/codec.ts +96 -40
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +1 -1
- package/apps/studio/sync/index.ts +38 -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 +8 -5
- 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 +76 -0
- package/apps/studio/whats-new.json +27 -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
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
* - sticky / polygon / image / link — see the per-tool serializers below.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { defOf } from './annotations/registry.ts';
|
|
29
|
+
import type { AnnotationElement, GeomCtx } from './annotations/types.ts';
|
|
28
30
|
import {
|
|
29
31
|
ARROW_HEADS,
|
|
30
32
|
type ArrowHead,
|
|
@@ -318,7 +320,21 @@ export interface SectionStroke extends StrokeBase {
|
|
|
318
320
|
color: string;
|
|
319
321
|
}
|
|
320
322
|
|
|
323
|
+
/**
|
|
324
|
+
* DDR-242 (Task 25) — an element of a registered type with no stroke form of
|
|
325
|
+
* its own. Its world-space record rides along and every geometry question is
|
|
326
|
+
* answered by its registry definition, so a new element type is one model file:
|
|
327
|
+
* the whiteboard draws, selects, moves, syncs and hands it to the AI verbs
|
|
328
|
+
* without another `tool ===` branch.
|
|
329
|
+
*/
|
|
330
|
+
export interface ElementStroke extends StrokeBase {
|
|
331
|
+
tool: 'element';
|
|
332
|
+
/** The element in WORLD coordinates, without `parent`. */
|
|
333
|
+
el: AnnotationElement;
|
|
334
|
+
}
|
|
335
|
+
|
|
321
336
|
export type Stroke =
|
|
337
|
+
| ElementStroke
|
|
322
338
|
| PenStroke
|
|
323
339
|
| RectStroke
|
|
324
340
|
| EllipseStroke
|
|
@@ -671,7 +687,7 @@ export function rid(): string {
|
|
|
671
687
|
* and reload — and is what external references (comments' `annotationId`)
|
|
672
688
|
* hold. A migration must carry ids over unchanged.
|
|
673
689
|
*/
|
|
674
|
-
export function stableAnnotationId(el:
|
|
690
|
+
export function stableAnnotationId(el: SvgElLike, seen: Map<string, number>): string {
|
|
675
691
|
const content = el.outerHTML;
|
|
676
692
|
const n = seen.get(content) ?? 0;
|
|
677
693
|
seen.set(content, n + 1);
|
|
@@ -1047,6 +1063,8 @@ export function strokeToSvgEl(s: Stroke): string {
|
|
|
1047
1063
|
}
|
|
1048
1064
|
|
|
1049
1065
|
function strokeToSvgElBase(s: Stroke): string {
|
|
1066
|
+
// No legacy SVG form: such an element only ever lives in a v2 board.
|
|
1067
|
+
if (s.tool === 'element') return '';
|
|
1050
1068
|
if (s.tool === 'text') {
|
|
1051
1069
|
// Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
|
|
1052
1070
|
// standalone text (no anchorId) writes its own world x/y and omits
|
|
@@ -1348,7 +1366,7 @@ function parseFill(raw: string | null): string | null {
|
|
|
1348
1366
|
* between them, so first-pair = start, last-pair = end recovers the ends
|
|
1349
1367
|
* exactly → idempotent re-serialize).
|
|
1350
1368
|
*/
|
|
1351
|
-
function arrowEndpoints(el:
|
|
1369
|
+
function arrowEndpoints(el: SvgElLike): { x1: number; y1: number; x2: number; y2: number } | null {
|
|
1352
1370
|
const line = el.querySelector('line');
|
|
1353
1371
|
if (line) {
|
|
1354
1372
|
return {
|
|
@@ -1421,7 +1439,7 @@ function sanitizeAuthorName(raw: string): string {
|
|
|
1421
1439
|
}
|
|
1422
1440
|
|
|
1423
1441
|
/** FigJam v3 — read the cross-tool root attrs back onto a parsed stroke. */
|
|
1424
|
-
function readSharedAttrs(el:
|
|
1442
|
+
function readSharedAttrs(el: SvgElLike, s: Stroke): void {
|
|
1425
1443
|
const g = el.getAttribute('data-group-ids');
|
|
1426
1444
|
if (g) {
|
|
1427
1445
|
const ids = g.split(/\s+/).filter(Boolean);
|
|
@@ -1447,12 +1465,39 @@ function readSharedAttrs(el: Element, s: Stroke): void {
|
|
|
1447
1465
|
}
|
|
1448
1466
|
}
|
|
1449
1467
|
|
|
1468
|
+
/**
|
|
1469
|
+
* The slice of the DOM `Element` API the parser reads. A browser `Element`
|
|
1470
|
+
* satisfies it; so does the DOM-free tree in `annotations/legacy/mini-dom.ts`,
|
|
1471
|
+
* which lets the hub (Node, no DOMParser) upconvert legacy SVG history for the
|
|
1472
|
+
* annotations-v2 migration (DDR-242 §6).
|
|
1473
|
+
*/
|
|
1474
|
+
export interface SvgElLike {
|
|
1475
|
+
getAttribute(name: string): string | null;
|
|
1476
|
+
querySelector(selector: string): SvgElLike | null;
|
|
1477
|
+
querySelectorAll(selector: string): ArrayLike<SvgElLike>;
|
|
1478
|
+
readonly textContent: string | null;
|
|
1479
|
+
readonly outerHTML: string;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
export interface SvgDocLike {
|
|
1483
|
+
querySelector(selector: string): SvgElLike | null;
|
|
1484
|
+
querySelectorAll(selector: string): ArrayLike<SvgElLike>;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1450
1487
|
export function svgToStrokes(svgText: string): Stroke[] {
|
|
1451
1488
|
const text = (svgText ?? '').trim();
|
|
1452
1489
|
if (!text) return [];
|
|
1453
1490
|
if (typeof DOMParser === 'undefined') return [];
|
|
1454
1491
|
try {
|
|
1455
|
-
|
|
1492
|
+
return strokesFromDocument(new DOMParser().parseFromString(text, 'image/svg+xml'));
|
|
1493
|
+
} catch {
|
|
1494
|
+
return [];
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/** The parser proper, over any document exposing `SvgDocLike`. Never throws. */
|
|
1499
|
+
export function strokesFromDocument(doc: SvgDocLike): Stroke[] {
|
|
1500
|
+
try {
|
|
1456
1501
|
if (doc.querySelector('parsererror')) return [];
|
|
1457
1502
|
const out: Stroke[] = [];
|
|
1458
1503
|
const seenContent = new Map<string, number>();
|
|
@@ -1773,6 +1818,7 @@ function pointSegmentDist(
|
|
|
1773
1818
|
|
|
1774
1819
|
/** FigJam v3 — strokes whose `rotation` is honoured (see StrokeBase doc). */
|
|
1775
1820
|
export function canRotate(s: Stroke): boolean {
|
|
1821
|
+
if (s.tool === 'element') return false;
|
|
1776
1822
|
if (s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'section') return false;
|
|
1777
1823
|
if (s.tool === 'text') return s.anchorId == null || s.anchorId === '';
|
|
1778
1824
|
return true;
|
|
@@ -1840,6 +1886,9 @@ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): b
|
|
|
1840
1886
|
wx >= bb.x - tol && wx <= bb.x + bb.w + tol && wy >= bb.y - tol && wy <= bb.y + bb.h + tol
|
|
1841
1887
|
);
|
|
1842
1888
|
}
|
|
1889
|
+
if (s.tool === 'element') {
|
|
1890
|
+
return defOf(s.el.type)?.hitTest(s.el, wx, wy, tol, WORLD_CTX) ?? false;
|
|
1891
|
+
}
|
|
1843
1892
|
if (s.tool === 'section') {
|
|
1844
1893
|
// FigJam — a section is grabbed by its BORDER or its label chip; the
|
|
1845
1894
|
// interior stays click-through so content on the section selects normally.
|
|
@@ -2045,6 +2094,7 @@ export function normalizeSticky(s: StickyStroke): StickyStroke {
|
|
|
2045
2094
|
}
|
|
2046
2095
|
|
|
2047
2096
|
export function isStrokeMeaningful(s: Stroke): boolean {
|
|
2097
|
+
if (s.tool === 'element') return defOf(s.el.type)?.meaningful(s.el) ?? false;
|
|
2048
2098
|
if (s.tool === 'pen') return s.points.length >= 2;
|
|
2049
2099
|
if (s.tool === 'rect') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
|
|
2050
2100
|
if (s.tool === 'polygon') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
|
|
@@ -2063,10 +2113,17 @@ export function isStrokeMeaningful(s: Stroke): boolean {
|
|
|
2063
2113
|
return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
|
|
2064
2114
|
}
|
|
2065
2115
|
|
|
2116
|
+
const WORLD_CTX: GeomCtx = { origin: { x: 0, y: 0 }, resolve: () => null };
|
|
2117
|
+
|
|
2118
|
+
function elementBox(s: ElementStroke): { x: number; y: number; w: number; h: number } | null {
|
|
2119
|
+
return defOf(s.el.type)?.bounds(s.el, WORLD_CTX) ?? null;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2066
2122
|
export function strokeBBox(
|
|
2067
2123
|
s: Stroke,
|
|
2068
2124
|
anchors?: Map<string, AnchorHost>
|
|
2069
2125
|
): { x: number; y: number; w: number; h: number } | null {
|
|
2126
|
+
if (s.tool === 'element') return elementBox(s);
|
|
2070
2127
|
if (s.tool === 'pen') {
|
|
2071
2128
|
if (!s.points.length) return null;
|
|
2072
2129
|
let xMin = Number.POSITIVE_INFINITY;
|
|
@@ -2141,6 +2198,10 @@ export function strokeBBox(
|
|
|
2141
2198
|
}
|
|
2142
2199
|
|
|
2143
2200
|
export function translateOne(s: Stroke, dx: number, dy: number): Stroke {
|
|
2201
|
+
if (s.tool === 'element') {
|
|
2202
|
+
const def = defOf(s.el.type);
|
|
2203
|
+
return def ? { ...s, el: { ...s.el, ...def.translate(s.el, dx, dy) } } : s;
|
|
2204
|
+
}
|
|
2144
2205
|
if (s.tool === 'pen') {
|
|
2145
2206
|
return { ...s, points: s.points.map(([x, y]) => [x + dx, y + dy] as WorldPoint) };
|
|
2146
2207
|
}
|
|
@@ -1,50 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Write/action ids travel with annotation writes as echo metadata (DDR-242:
|
|
3
|
+
* the replica's `~action`), never as authorization or a durable ACK.
|
|
4
|
+
*/
|
|
6
5
|
export function validAnnotationWriteId(value: unknown): value is string {
|
|
7
6
|
return typeof value === 'string' && /^[a-zA-Z0-9_-]{1,96}$/.test(value);
|
|
8
7
|
}
|
|
9
|
-
|
|
10
|
-
/** Remember authored operations, not previously rendered content (A → B → A). */
|
|
11
|
-
export function createAnnotationEchoGuard() {
|
|
12
|
-
const authored = new Map<string, string>();
|
|
13
|
-
return {
|
|
14
|
-
remember(id: string, svg: string) {
|
|
15
|
-
authored.set(id, svg);
|
|
16
|
-
if (authored.size > 64) {
|
|
17
|
-
const oldest = authored.keys().next().value;
|
|
18
|
-
if (oldest !== undefined) authored.delete(oldest);
|
|
19
|
-
}
|
|
20
|
-
},
|
|
21
|
-
forget(id: string) {
|
|
22
|
-
authored.delete(id);
|
|
23
|
-
},
|
|
24
|
-
isOwn(svg: string, id: unknown) {
|
|
25
|
-
return validAnnotationWriteId(id) && authored.get(id) === svg;
|
|
26
|
-
},
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Old clients/importers may change svg without changing its old writeId. */
|
|
31
|
-
export function observeAnnotationSnapshots(
|
|
32
|
-
doc: Y.Doc,
|
|
33
|
-
receive: (svg: string, writeId: unknown) => void
|
|
34
|
-
): () => void {
|
|
35
|
-
const map = doc.getMap<unknown>('annotations');
|
|
36
|
-
const apply = (event?: Y.YMapEvent<unknown>) => {
|
|
37
|
-
if (event && !event.keysChanged.has('svg') && !event.keysChanged.has(ANNOTATION_WRITE_ID))
|
|
38
|
-
return;
|
|
39
|
-
const svg = map.get('svg');
|
|
40
|
-
if (typeof svg !== 'string' && !event?.keysChanged.has('svg')) return;
|
|
41
|
-
const id =
|
|
42
|
-
!event || event.keysChanged.has(ANNOTATION_WRITE_ID)
|
|
43
|
-
? map.get(ANNOTATION_WRITE_ID)
|
|
44
|
-
: undefined;
|
|
45
|
-
receive(typeof svg === 'string' ? svg : '', id);
|
|
46
|
-
};
|
|
47
|
-
map.observe(apply);
|
|
48
|
-
apply();
|
|
49
|
-
return () => map.unobserve(apply);
|
|
50
|
-
}
|
package/apps/studio/api.ts
CHANGED
|
@@ -14,6 +14,16 @@ 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 './annotations/ops-merge.ts';
|
|
25
|
+
import { parseBoard, serializeBoard } from './annotations/schema.ts';
|
|
26
|
+
import type { AnnotationElement } from './annotations/types.ts';
|
|
17
27
|
import { createAssetMirror, s3ConfigFromEnv } from './assets-s3.ts';
|
|
18
28
|
import { canvasArtifacts, locatorKeyFor, relocatedName } from './canvas-artifacts.ts';
|
|
19
29
|
import { renderBriefBoard, validateCanvasName, validateFolderName } from './canvas-create.ts';
|
|
@@ -23,6 +33,7 @@ import { isAnnotationId, isWorldPoint } from './comment-anchor.ts';
|
|
|
23
33
|
import { atomicWrite } from './sync/atomic-write.ts';
|
|
24
34
|
import { dedupeCommentsById } from './sync/comment-identity.ts';
|
|
25
35
|
import { isRuntimeStateRel } from './sync/file-membership.ts';
|
|
36
|
+
import { MAX_ANNOTATIONS_BYTES } from './sync/limits.ts';
|
|
26
37
|
|
|
27
38
|
// Re-exported so existing external callers (canvas-list-watch.ts, tests) keep
|
|
28
39
|
// importing it from api.ts — the actual implementation now lives in
|
|
@@ -34,7 +45,7 @@ export { canvasSlugFromRel } from './canvas-slug.ts';
|
|
|
34
45
|
* delete: what it previews (notes, styles, data, images, media, fonts), minus
|
|
35
46
|
* the canvas's own sidecars, which only ever travel with their canvas. */
|
|
36
47
|
export function isSupportingFileRel(rel: string): boolean {
|
|
37
|
-
if (/\.(meta\.json|annotations\.svg|registry\.json)$/i.test(rel)) return false;
|
|
48
|
+
if (/\.(meta\.json|annotations\.(?:svg|json)|registry\.json)$/i.test(rel)) return false;
|
|
38
49
|
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(
|
|
39
50
|
rel
|
|
40
51
|
);
|
|
@@ -117,6 +128,7 @@ import { STICKERS_DIR } from './paths.ts';
|
|
|
117
128
|
import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
|
|
118
129
|
import { sessionDir } from './session-scope.ts';
|
|
119
130
|
import { describeSourceOp } from './sync/source-ops.ts';
|
|
131
|
+
import { normalizeTreeState, type TreeState } from './tree-state.ts';
|
|
120
132
|
import { isWorkspaceMode } from './workspace-mode.ts';
|
|
121
133
|
|
|
122
134
|
// Directories that never hold user-facing canvases. Exported so the
|
|
@@ -324,7 +336,7 @@ export interface Comment {
|
|
|
324
336
|
* text is untrusted user/peer text (DDR-054) — rendered as text, never
|
|
325
337
|
* into TSX. */
|
|
326
338
|
timeline?: { clipStableId?: string; frameOffset?: number; frame?: number; lane?: string };
|
|
327
|
-
/** #134/#136 — anchor on an annotation (`
|
|
339
|
+
/** #134/#136 — anchor on an annotation (an element `id` in `*.annotations.json`).
|
|
328
340
|
* Absent on element and floating comments. See comment-anchor.ts. */
|
|
329
341
|
annotationId?: string;
|
|
330
342
|
/** World point of the comment: the anchor of a floating comment, the last
|
|
@@ -424,6 +436,8 @@ export interface Api {
|
|
|
424
436
|
// Canvas state
|
|
425
437
|
loadCanvasState(file: string): Promise<Record<string, unknown> | null>;
|
|
426
438
|
saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
|
|
439
|
+
loadTreeState(): Promise<TreeState>;
|
|
440
|
+
saveTreeState(state: TreeState): Promise<TreeState>;
|
|
427
441
|
timelineMediaLoad(key: string): Promise<Record<string, unknown> | null>;
|
|
428
442
|
timelineMediaSave(key: string, data: Record<string, unknown>): Promise<boolean>;
|
|
429
443
|
// Canvas meta sidecar (Phase 4 T5 — .design/ui/<slug>.meta.json)
|
|
@@ -436,11 +450,23 @@ export interface Api {
|
|
|
436
450
|
file: string,
|
|
437
451
|
patch: Record<string, unknown>
|
|
438
452
|
): Promise<Record<string, unknown> | null>;
|
|
439
|
-
// Annotations
|
|
453
|
+
// Annotations board (DDR-242 — .design/<slug>.annotations.json)
|
|
454
|
+
/** Canonical board text; a not-yet-migrated legacy SVG is read through the migration. */
|
|
440
455
|
loadAnnotations(file: string): Promise<string | null>;
|
|
441
|
-
|
|
456
|
+
/** Strict read: an existing board that can't be read is `ok: false`, never an empty board. */
|
|
457
|
+
readBoard(
|
|
458
|
+
file: string
|
|
459
|
+
): Promise<{ ok: true; text: string | null } | { ok: false; error: string }>;
|
|
460
|
+
/** Whole-board write (board text or legacy SVG, canonicalized). */
|
|
461
|
+
saveAnnotations(file: string, text: string, writeId?: string, base?: string): Promise<boolean>;
|
|
462
|
+
/** The canvas write path: an op batch under the DDR-242 merge rule. */
|
|
463
|
+
applyAnnotationOps(
|
|
464
|
+
file: string,
|
|
465
|
+
ops: readonly AnnotationOp[],
|
|
466
|
+
actionId?: string
|
|
467
|
+
): Promise<AnnotationOpsResult>;
|
|
442
468
|
/** Materialize a document snapshot without publishing it as another user edit. */
|
|
443
|
-
projectAnnotations(file: string,
|
|
469
|
+
projectAnnotations(file: string, text: string, isCurrent: () => boolean): Promise<boolean>;
|
|
444
470
|
// Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
|
|
445
471
|
saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
446
472
|
/** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
|
|
@@ -818,8 +844,22 @@ export interface ApiHooks {
|
|
|
818
844
|
*/
|
|
819
845
|
/** `base` — the list this mutation started from (a merge hint for the project). */
|
|
820
846
|
onCommentsChanged: (file: string, comments: Comment[], base?: Comment[]) => void | Promise<void>;
|
|
821
|
-
/**
|
|
822
|
-
|
|
847
|
+
/**
|
|
848
|
+
* Fires after every successful annotations write with the new canonical
|
|
849
|
+
* board text, the action/write id and the board text it replaced (a merge
|
|
850
|
+
* base for accepted revisions).
|
|
851
|
+
*/
|
|
852
|
+
onAnnotationsChanged?: (file: string, text: string, writeId?: string, base?: string) => void;
|
|
853
|
+
/**
|
|
854
|
+
* Apply an op batch to the canvas's LIVE collab room, when one holds the
|
|
855
|
+
* board (code review H1). `null` = no live room / accepted mode — the batch
|
|
856
|
+
* goes to disk. The room's persistence projects the result to the file.
|
|
857
|
+
*/
|
|
858
|
+
applyAnnotationOpsLive?: (
|
|
859
|
+
file: string,
|
|
860
|
+
ops: readonly AnnotationOp[],
|
|
861
|
+
actionId?: string
|
|
862
|
+
) => ApplyResult | 'too-large' | null;
|
|
823
863
|
/**
|
|
824
864
|
* Accepted-revisions mode (DDR-241): propose a folder operation as ONE
|
|
825
865
|
* project action before touching disk. Absent, or answering `null`, means
|
|
@@ -865,8 +905,18 @@ export interface ApiHooks {
|
|
|
865
905
|
// server modules). Re-exported here so every existing `from './api.ts'`
|
|
866
906
|
// import keeps working unchanged.
|
|
867
907
|
export { ASSET_IMAGE_HREF_RE, sanitizeAnnotationSvg } from './annotations-model.ts';
|
|
868
|
-
|
|
869
|
-
|
|
908
|
+
export type { AnnotationOp };
|
|
909
|
+
/** Outcome of an annotations op batch (DDR-242 §4). */
|
|
910
|
+
export interface AnnotationOpsResult {
|
|
911
|
+
ok: boolean;
|
|
912
|
+
/** Whether the board changed (false for a no-op batch). */
|
|
913
|
+
changed: boolean;
|
|
914
|
+
/** Ops the board could not take: `gone` (element deleted), `invalid`, `stale` (strict undo). */
|
|
915
|
+
rejected: Array<{ id?: string; reason: string; fields?: string[] }>;
|
|
916
|
+
error?: string;
|
|
917
|
+
/** The board on disk exists but can't be read — nothing was written (HTTP 409). */
|
|
918
|
+
unreadable?: boolean;
|
|
919
|
+
}
|
|
870
920
|
|
|
871
921
|
/**
|
|
872
922
|
* Phase 23 — per-file ceiling for a still image. Raised 10 MB → 50 MB (still
|
|
@@ -1574,6 +1624,37 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1574
1624
|
}
|
|
1575
1625
|
}
|
|
1576
1626
|
|
|
1627
|
+
// ---------- File-tree expansion (issue #124) ----------
|
|
1628
|
+
//
|
|
1629
|
+
// Which folders + sections of the Files panel the user left open. Per-user
|
|
1630
|
+
// runtime state (DDR-115): it lives under `_canvas-state/` — already on every
|
|
1631
|
+
// ignore list, never versioned or synced — and goes through `sessionDir`, so
|
|
1632
|
+
// in a cell each member keeps their own tree. A SUBDIRECTORY, not a sibling
|
|
1633
|
+
// file: `/_canvas-state` builds `<fileSlug(file)>.json` from user input with
|
|
1634
|
+
// no host/origin guard, so any flat name here is reachable through it
|
|
1635
|
+
// (`?file=_file-tree` hit `_file-tree.json`). A slug never contains `/`.
|
|
1636
|
+
function treeStateDir(): string {
|
|
1637
|
+
return path.join(sessionDir(paths.canvasStateDir), '_tree');
|
|
1638
|
+
}
|
|
1639
|
+
function treeStatePath(): string {
|
|
1640
|
+
return path.join(treeStateDir(), 'state.json');
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
async function loadTreeState(): Promise<TreeState> {
|
|
1644
|
+
try {
|
|
1645
|
+
return normalizeTreeState(JSON.parse(await Bun.file(treeStatePath()).text()));
|
|
1646
|
+
} catch {
|
|
1647
|
+
return { dirs: [], sections: {} };
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
async function saveTreeState(state: TreeState): Promise<TreeState> {
|
|
1652
|
+
const safe = normalizeTreeState(state);
|
|
1653
|
+
await mkdir(treeStateDir(), { recursive: true });
|
|
1654
|
+
await Bun.write(treeStatePath(), JSON.stringify(safe, null, 2));
|
|
1655
|
+
return safe;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1577
1658
|
// ---------- Timeline media visuals cache (enhanced-video-editing Task 7) ----
|
|
1578
1659
|
//
|
|
1579
1660
|
// Filmstrip dataURL strips + waveform peak arrays, keyed `<sha8>:<bucket>`,
|
|
@@ -2079,81 +2160,179 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2079
2160
|
return await loadCanvasMeta(file);
|
|
2080
2161
|
}
|
|
2081
2162
|
|
|
2082
|
-
// ---------- Annotations sidecar (
|
|
2163
|
+
// ---------- Annotations sidecar (DDR-242 — annotations v2) ----------
|
|
2083
2164
|
//
|
|
2084
|
-
// Each canvas keeps
|
|
2085
|
-
//
|
|
2086
|
-
//
|
|
2087
|
-
//
|
|
2088
|
-
//
|
|
2089
|
-
//
|
|
2165
|
+
// Each canvas keeps ONE `<slug>.annotations.json` board under `<designRoot>/`
|
|
2166
|
+
// (canonical, one element per line — annotations/schema.ts). The canvas sends
|
|
2167
|
+
// OPS (`applyAnnotationOps`), never the whole board; whole-board writes
|
|
2168
|
+
// (`saveAnnotations`) remain for imports and headless writers. Every write is
|
|
2169
|
+
// validated element-by-element (DDR-054 — the canvas origin and peers are
|
|
2170
|
+
// untrusted) and capped at MAX_ANNOTATIONS_BYTES.
|
|
2171
|
+
//
|
|
2172
|
+
// A board still stored as a legacy `.annotations.svg` (not yet migrated) is
|
|
2173
|
+
// READ through the v1→v2 migration; the boot migration (annotations/
|
|
2174
|
+
// migrate-boot.ts) rewrites it on disk.
|
|
2090
2175
|
|
|
2091
2176
|
function annotationsPath(file: string): string {
|
|
2177
|
+
return path.join(paths.designRoot, `${fileSlug(file)}.annotations.json`);
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function legacyAnnotationsPath(file: string): string {
|
|
2092
2181
|
return path.join(paths.designRoot, `${fileSlug(file)}.annotations.svg`);
|
|
2093
2182
|
}
|
|
2094
2183
|
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2184
|
+
type BoardRead = { ok: true; text: string | null } | { ok: false; error: string };
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Strict board read. `text: null` = the canvas has no annotations. A file
|
|
2188
|
+
* that EXISTS but is oversized or not a board is `ok: false` — never an
|
|
2189
|
+
* empty board, or the next write would silently erase it (code review H2).
|
|
2190
|
+
*/
|
|
2191
|
+
async function readBoard(file: string): Promise<BoardRead> {
|
|
2192
|
+
for (const p of [annotationsPath(file), legacyAnnotationsPath(file)]) {
|
|
2193
|
+
const f = Bun.file(p);
|
|
2194
|
+
if (!(await f.exists())) continue;
|
|
2195
|
+
// Untrusted (peer / git) content: size gate BEFORE reading (DDR-054).
|
|
2196
|
+
if (f.size > MAX_ANNOTATIONS_BYTES) {
|
|
2197
|
+
return { ok: false, error: 'board file exceeds the size cap' };
|
|
2198
|
+
}
|
|
2199
|
+
let raw: string;
|
|
2200
|
+
try {
|
|
2201
|
+
raw = await f.text();
|
|
2202
|
+
} catch {
|
|
2203
|
+
return { ok: false, error: 'board file is unreadable' };
|
|
2204
|
+
}
|
|
2205
|
+
const text = canonicalAnnotations(raw);
|
|
2206
|
+
return text === null
|
|
2207
|
+
? { ok: false, error: 'board file is not a valid board' }
|
|
2208
|
+
: { ok: true, text };
|
|
2100
2209
|
}
|
|
2210
|
+
return { ok: true, text: null };
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
/** Canonical board text, or null when the canvas has no annotations (or they can't be read). */
|
|
2214
|
+
async function loadAnnotations(file: string): Promise<string | null> {
|
|
2215
|
+
const r = await readBoard(file);
|
|
2216
|
+
return r.ok ? r.text : null;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// Read-modify-write of one board is serialized per file: two op batches
|
|
2220
|
+
// landing together must not both read the same state and drop one another.
|
|
2221
|
+
const annotationChains = new Map<string, Promise<unknown>>();
|
|
2222
|
+
function onAnnotationChain<T>(file: string, fn: () => Promise<T>): Promise<T> {
|
|
2223
|
+
const key = fileSlug(file);
|
|
2224
|
+
const prev = annotationChains.get(key) ?? Promise.resolve();
|
|
2225
|
+
const next = prev.then(fn, fn);
|
|
2226
|
+
annotationChains.set(
|
|
2227
|
+
key,
|
|
2228
|
+
next.catch(() => {})
|
|
2229
|
+
);
|
|
2230
|
+
return next;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
async function writeBoardFile(file: string, text: string): Promise<void> {
|
|
2234
|
+
await Bun.write(annotationsPath(file), text);
|
|
2235
|
+
// Annotations reach OTHER VIEWERS over the collab room, but the file is also
|
|
2236
|
+
// a versioned, file-plane sidecar (DDR-115), and the file plane learns about
|
|
2237
|
+
// a cell's own writes through exactly this event.
|
|
2238
|
+
announceWritten(`${fileSlug(file)}.annotations.json`);
|
|
2101
2239
|
}
|
|
2102
2240
|
|
|
2241
|
+
/**
|
|
2242
|
+
* Whole-board write (imports, headless writers, legacy PUT). Accepts board
|
|
2243
|
+
* text or a legacy SVG (converted). `base` travels only as a merge hint for
|
|
2244
|
+
* the project — bounded and canonicalized like the value, never written.
|
|
2245
|
+
*/
|
|
2103
2246
|
async function saveAnnotations(
|
|
2104
2247
|
file: string,
|
|
2105
|
-
|
|
2248
|
+
text: string,
|
|
2106
2249
|
writeId?: string,
|
|
2107
2250
|
base?: string
|
|
2108
2251
|
): Promise<boolean> {
|
|
2109
|
-
if (typeof
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
// writes of arbitrary blobs through this endpoint.
|
|
2113
|
-
if (!/^\s*<svg[\s>]/i.test(svg)) return false;
|
|
2114
|
-
// A3 (DDR-060 F1 re-audit) — sanitize active content before persisting.
|
|
2115
|
-
// This endpoint is on the canvas-origin allowlist (DDR-054 "inert collab
|
|
2116
|
-
// write") and accepts ANY `file`, so a hub-pushed canvas can write a
|
|
2117
|
-
// sibling's `.annotations.svg`. The persisted SVG is currently consumed only
|
|
2118
|
-
// via `svgToStrokes` (DOMParser image/svg+xml → structured strokes → React
|
|
2119
|
-
// re-render), so a `<script>`/`on*` payload is parsed inertly and discarded
|
|
2120
|
-
// — the stored-XSS chain is LATENT today, not live. We sanitize anyway so
|
|
2121
|
-
// "inert" stays true for any future raw-render consumer and for the synced
|
|
2122
|
-
// file a peer/Claude-context ingests. The legit annotation vocabulary
|
|
2123
|
-
// (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
|
|
2124
|
-
// polyline/text — so stripping executable constructs is zero-regression.
|
|
2125
|
-
const clean = sanitizeAnnotationSvg(svg);
|
|
2126
|
-
// The edit's base travels only as a merge hint for the project — bounded
|
|
2127
|
-
// and sanitized like the value itself, never written anywhere.
|
|
2252
|
+
if (typeof text !== 'string' || text.length > MAX_ANNOTATIONS_BYTES) return false;
|
|
2253
|
+
const clean = canonicalAnnotations(text);
|
|
2254
|
+
if (clean === null) return false;
|
|
2128
2255
|
const cleanBase =
|
|
2129
|
-
typeof base === 'string' &&
|
|
2130
|
-
|
|
2131
|
-
(base === '' || /^\s*<svg[\s>]/i.test(base))
|
|
2132
|
-
? base === ''
|
|
2133
|
-
? ''
|
|
2134
|
-
: sanitizeAnnotationSvg(base)
|
|
2256
|
+
typeof base === 'string' && base.length <= MAX_ANNOTATIONS_BYTES
|
|
2257
|
+
? (canonicalAnnotations(base) ?? undefined)
|
|
2135
2258
|
: undefined;
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2259
|
+
return onAnnotationChain(file, async () => {
|
|
2260
|
+
// Never overwrite a board we can't read — it may hold work (review H2).
|
|
2261
|
+
const read = await readBoard(file);
|
|
2262
|
+
if (!read.ok) return false;
|
|
2263
|
+
let next = clean;
|
|
2264
|
+
if (cleanBase !== undefined) {
|
|
2265
|
+
// With a base, the write is "what changed since base", merged onto the
|
|
2266
|
+
// current board — a whole-board PUT no longer erases concurrent edits
|
|
2267
|
+
// (code review M5). Same one merge rule as the op path.
|
|
2268
|
+
const byId = (t: string) => new Map(parseBoard(t).elements.map((e) => [e.id, e]));
|
|
2269
|
+
const ops = diffToOps(byId(cleanBase), byId(clean));
|
|
2270
|
+
const live = hooks.applyAnnotationOpsLive?.(file, ops, writeId) ?? null;
|
|
2271
|
+
if (live === 'too-large') return false;
|
|
2272
|
+
if (live) return true;
|
|
2273
|
+
const current = read.text ?? serializeBoard([]);
|
|
2274
|
+
next = serializeBoard([...applyAnnotationOpsPure(byId(current), ops).state.values()]);
|
|
2275
|
+
if (next.length > MAX_ANNOTATIONS_BYTES) return false;
|
|
2276
|
+
if (next === current) return true;
|
|
2277
|
+
}
|
|
2278
|
+
await writeBoardFile(file, next);
|
|
2279
|
+
onAnnotationsChanged?.(file, next, writeId, cleanBase);
|
|
2280
|
+
return true;
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
/**
|
|
2285
|
+
* The canvas write path (DDR-242 §4): apply an op batch under the one merge
|
|
2286
|
+
* rule and persist the result. Ops the board can't take (gone / invalid /
|
|
2287
|
+
* stale) are reported, never silently dropped.
|
|
2288
|
+
*/
|
|
2289
|
+
async function applyAnnotationOps(
|
|
2290
|
+
file: string,
|
|
2291
|
+
ops: readonly AnnotationOp[],
|
|
2292
|
+
actionId?: string
|
|
2293
|
+
): Promise<AnnotationOpsResult> {
|
|
2294
|
+
return onAnnotationChain(file, async () => {
|
|
2295
|
+
const rejectedOf = (r: ApplyResult) =>
|
|
2296
|
+
r.rejected.map((x) => ({
|
|
2297
|
+
id: 'id' in x.op ? x.op.id : x.op.el?.id,
|
|
2298
|
+
reason: x.reason,
|
|
2299
|
+
...(x.fields ? { fields: x.fields } : {}),
|
|
2300
|
+
}));
|
|
2301
|
+
// A live room is ahead of the file (its flush is debounced): the batch
|
|
2302
|
+
// goes to its replica, or it would revert a peer's unflushed edit (H1).
|
|
2303
|
+
const live = hooks.applyAnnotationOpsLive?.(file, ops, actionId) ?? null;
|
|
2304
|
+
if (live === 'too-large') {
|
|
2305
|
+
return { ok: false, changed: false, rejected: [], error: 'board exceeds the size cap' };
|
|
2306
|
+
}
|
|
2307
|
+
if (live) return { ok: true, changed: live.touched.size > 0, rejected: rejectedOf(live) };
|
|
2308
|
+
const read = await readBoard(file);
|
|
2309
|
+
if (!read.ok) {
|
|
2310
|
+
return { ok: false, changed: false, rejected: [], error: read.error, unreadable: true };
|
|
2311
|
+
}
|
|
2312
|
+
const before = read.text ? parseBoard(read.text).elements : [];
|
|
2313
|
+
const beforeText = serializeBoard(before);
|
|
2314
|
+
const r = applyAnnotationOpsPure(new Map(before.map((e) => [e.id, e])), ops);
|
|
2315
|
+
const rejected = rejectedOf(r);
|
|
2316
|
+
if (!r.touched.size) return { ok: true, changed: false, rejected };
|
|
2317
|
+
const text = serializeBoard([...r.state.values()]);
|
|
2318
|
+
if (text.length > MAX_ANNOTATIONS_BYTES) {
|
|
2319
|
+
return { ok: false, changed: false, rejected, error: 'board exceeds the size cap' };
|
|
2320
|
+
}
|
|
2321
|
+
await writeBoardFile(file, text);
|
|
2322
|
+
onAnnotationsChanged?.(file, text, actionId, beforeText);
|
|
2323
|
+
return { ok: true, changed: true, rejected };
|
|
2324
|
+
});
|
|
2146
2325
|
}
|
|
2147
2326
|
|
|
2148
2327
|
async function projectAnnotations(
|
|
2149
2328
|
file: string,
|
|
2150
|
-
|
|
2329
|
+
text: string,
|
|
2151
2330
|
isCurrent: () => boolean
|
|
2152
2331
|
): Promise<boolean> {
|
|
2153
|
-
if (typeof
|
|
2154
|
-
return false;
|
|
2332
|
+
if (typeof text !== 'string' || text.length > MAX_ANNOTATIONS_BYTES) return false;
|
|
2155
2333
|
if (!isCurrent()) return false;
|
|
2156
|
-
const clean =
|
|
2334
|
+
const clean = canonicalAnnotations(text);
|
|
2335
|
+
if (clean === null) return false;
|
|
2157
2336
|
// Runtime scratch stays out of the file plane. The async IO must not touch
|
|
2158
2337
|
// the serving file until we recheck the document; another edit may have
|
|
2159
2338
|
// arrived while Bun.write was pending. Check + rename have no await gap.
|
|
@@ -2164,7 +2343,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2164
2343
|
await Bun.write(temp, clean);
|
|
2165
2344
|
if (!isCurrent()) return false;
|
|
2166
2345
|
renameSync(temp, annotationsPath(file));
|
|
2167
|
-
announceWritten(`${fileSlug(file)}.annotations.
|
|
2346
|
+
announceWritten(`${fileSlug(file)}.annotations.json`);
|
|
2168
2347
|
return true;
|
|
2169
2348
|
} finally {
|
|
2170
2349
|
await rm(temp, { force: true });
|
|
@@ -2982,12 +3161,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2982
3161
|
}
|
|
2983
3162
|
const slug = fileSlug(toRel);
|
|
2984
3163
|
// The whiteboard layer is slug-keyed at the design root (canvas-artifacts).
|
|
2985
|
-
const
|
|
2986
|
-
if (
|
|
2987
|
-
await Bun.write(
|
|
2988
|
-
path.join(paths.designRoot, `${slug}.annotations.svg`),
|
|
2989
|
-
await Bun.file(annotationsAbs).arrayBuffer()
|
|
2990
|
-
);
|
|
3164
|
+
const board = await loadAnnotations(rel);
|
|
3165
|
+
if (board !== null) {
|
|
3166
|
+
await Bun.write(path.join(paths.designRoot, `${slug}.annotations.json`), board);
|
|
2991
3167
|
}
|
|
2992
3168
|
ctx.bus.emit('canvas-list-update', { action: 'added', rel: toRel, slug });
|
|
2993
3169
|
ctx.bus.emit('canvas-created', { slug });
|
|
@@ -3192,7 +3368,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3192
3368
|
if (e.isDirectory()) {
|
|
3193
3369
|
const hit = await walk(abs, depth + 1);
|
|
3194
3370
|
if (hit) return hit;
|
|
3195
|
-
} else if (/\.(tsx|jsx|css|meta\.json|annotations\.svg)$/i.test(e.name)) {
|
|
3371
|
+
} else if (/\.(tsx|jsx|css|meta\.json|annotations\.(?:svg|json))$/i.test(e.name)) {
|
|
3196
3372
|
const other = path.relative(paths.designRoot, abs).split(path.sep).join('/');
|
|
3197
3373
|
if (other === rel) continue;
|
|
3198
3374
|
const text = await readFile(abs, 'utf8').catch(() => '');
|
|
@@ -6621,13 +6797,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
6621
6797
|
parseMentions,
|
|
6622
6798
|
loadCanvasState,
|
|
6623
6799
|
saveCanvasState,
|
|
6800
|
+
loadTreeState,
|
|
6801
|
+
saveTreeState,
|
|
6624
6802
|
timelineMediaLoad,
|
|
6625
6803
|
timelineMediaSave,
|
|
6626
6804
|
loadCanvasMeta,
|
|
6627
6805
|
loadCanvasSource,
|
|
6628
6806
|
patchCanvasMeta,
|
|
6629
6807
|
loadAnnotations,
|
|
6808
|
+
readBoard,
|
|
6630
6809
|
saveAnnotations,
|
|
6810
|
+
applyAnnotationOps,
|
|
6631
6811
|
projectAnnotations,
|
|
6632
6812
|
saveAsset,
|
|
6633
6813
|
listAssets,
|