@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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closed shape (DDR-242) — rect / ellipse / diamond / triangle / triangle-down
|
|
3
|
+
* in ONE type with a `kind` field, so converting a shape is a field change that
|
|
4
|
+
* keeps its id, binds and label. The label is an embedded record (Excalidraw
|
|
5
|
+
* "bound text"): exactly one per shape, by construction.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_FONT_SIZE,
|
|
10
|
+
DEFAULT_INK,
|
|
11
|
+
SHAPE_KINDS,
|
|
12
|
+
SHAPE_MIN_SIZE,
|
|
13
|
+
type ShapeKind,
|
|
14
|
+
} from '../constants.ts';
|
|
15
|
+
import { bool, color, fill, num, oneOf, record, text } from '../fields.ts';
|
|
16
|
+
import type { AnnotationElement, Box, ElementDef } from '../types.ts';
|
|
17
|
+
import {
|
|
18
|
+
BOX_FIELDS,
|
|
19
|
+
boxOf,
|
|
20
|
+
get,
|
|
21
|
+
inBox,
|
|
22
|
+
pointSegmentDist,
|
|
23
|
+
ROT_FIELD,
|
|
24
|
+
resizeBox,
|
|
25
|
+
textStyleFields,
|
|
26
|
+
toLocal,
|
|
27
|
+
translateBox,
|
|
28
|
+
} from './_shared.ts';
|
|
29
|
+
|
|
30
|
+
export const LABEL_FIELDS = {
|
|
31
|
+
text: text(),
|
|
32
|
+
fontSize: num({ min: 4, max: 400, dp: 1, def: DEFAULT_FONT_SIZE }),
|
|
33
|
+
color: color(DEFAULT_INK),
|
|
34
|
+
...textStyleFields('center'),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const FIELDS = {
|
|
38
|
+
kind: oneOf(SHAPE_KINDS, 'rect'),
|
|
39
|
+
...BOX_FIELDS,
|
|
40
|
+
...ROT_FIELD,
|
|
41
|
+
color: color(DEFAULT_INK),
|
|
42
|
+
width: num({ min: 0.5, max: 64, dp: 1, def: 2 }),
|
|
43
|
+
fill: fill(),
|
|
44
|
+
radius: num({ min: 0, max: 1000, dp: 1, def: 0 }),
|
|
45
|
+
dashed: bool(),
|
|
46
|
+
label: record(LABEL_FIELDS, { def: {} }),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Outline vertices of the polygon kinds, spanning the full box. */
|
|
50
|
+
export function polygonVertices(kind: ShapeKind, b: Box): Array<[number, number]> {
|
|
51
|
+
const { x, y, w, h } = b;
|
|
52
|
+
if (kind === 'diamond') {
|
|
53
|
+
return [
|
|
54
|
+
[x + w / 2, y],
|
|
55
|
+
[x + w, y + h / 2],
|
|
56
|
+
[x + w / 2, y + h],
|
|
57
|
+
[x, y + h / 2],
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
if (kind === 'triangle') {
|
|
61
|
+
return [
|
|
62
|
+
[x + w / 2, y],
|
|
63
|
+
[x + w, y + h],
|
|
64
|
+
[x, y + h],
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
return [
|
|
68
|
+
[x, y],
|
|
69
|
+
[x + w, y],
|
|
70
|
+
[x + w / 2, y + h],
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pointInPolygon(px: number, py: number, pts: ReadonlyArray<[number, number]>): boolean {
|
|
75
|
+
let inside = false;
|
|
76
|
+
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
77
|
+
const a = pts[i] as [number, number];
|
|
78
|
+
const b = pts[j] as [number, number];
|
|
79
|
+
if (a[1] > py !== b[1] > py && px < ((b[0] - a[0]) * (py - a[1])) / (b[1] - a[1]) + a[0]) {
|
|
80
|
+
inside = !inside;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return inside;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function shapeHit(el: AnnotationElement, px: number, py: number, tol: number): boolean {
|
|
87
|
+
const b = boxOf(el);
|
|
88
|
+
const [lx, ly] = toLocal(el, b, px, py);
|
|
89
|
+
const kind = get<ShapeKind>(FIELDS, el, 'kind');
|
|
90
|
+
const filled = get<string | null>(FIELDS, el, 'fill') != null;
|
|
91
|
+
const t = Math.max(tol, get<number>(FIELDS, el, 'width'));
|
|
92
|
+
if (kind === 'ellipse') {
|
|
93
|
+
const rx = b.w / 2;
|
|
94
|
+
const ry = b.h / 2;
|
|
95
|
+
if (rx <= 0 || ry <= 0) return false;
|
|
96
|
+
const nx = (lx - (b.x + rx)) / rx;
|
|
97
|
+
const ny = (ly - (b.y + ry)) / ry;
|
|
98
|
+
const d = nx * nx + ny * ny;
|
|
99
|
+
if (filled) return d <= 1 + t / Math.max(rx, ry);
|
|
100
|
+
return Math.abs(Math.sqrt(d) - 1) <= t / Math.max(rx, ry);
|
|
101
|
+
}
|
|
102
|
+
if (kind === 'rect') {
|
|
103
|
+
if (filled) return inBox(b, lx, ly, t);
|
|
104
|
+
if (!inBox(b, lx, ly, t)) return false;
|
|
105
|
+
return (
|
|
106
|
+
Math.abs(lx - b.x) <= t ||
|
|
107
|
+
Math.abs(lx - (b.x + b.w)) <= t ||
|
|
108
|
+
Math.abs(ly - b.y) <= t ||
|
|
109
|
+
Math.abs(ly - (b.y + b.h)) <= t
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const pts = polygonVertices(kind, b);
|
|
113
|
+
if (filled && pointInPolygon(lx, ly, pts)) return true;
|
|
114
|
+
for (let i = 0; i < pts.length; i++) {
|
|
115
|
+
const a = pts[i] as [number, number];
|
|
116
|
+
const c = pts[(i + 1) % pts.length] as [number, number];
|
|
117
|
+
if (pointSegmentDist(lx, ly, a[0], a[1], c[0], c[1]) <= t) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const shape: ElementDef = {
|
|
123
|
+
type: 'shape',
|
|
124
|
+
fields: FIELDS,
|
|
125
|
+
caps: {
|
|
126
|
+
box: true,
|
|
127
|
+
rotatable: true,
|
|
128
|
+
resizable: true,
|
|
129
|
+
bindable: true,
|
|
130
|
+
container: false,
|
|
131
|
+
textSlot: 'label',
|
|
132
|
+
},
|
|
133
|
+
bounds: (el) => boxOf(el),
|
|
134
|
+
hitTest: (el, px, py, tol) => shapeHit(el, px, py, tol),
|
|
135
|
+
translate: translateBox,
|
|
136
|
+
resize: resizeBox,
|
|
137
|
+
meaningful: (el) => {
|
|
138
|
+
const b = boxOf(el);
|
|
139
|
+
return b.w >= SHAPE_MIN_SIZE && b.h >= SHAPE_MIN_SIZE;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Sticky note — a paper card with a wrapped text body (DDR-242). */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_FONT_SIZE,
|
|
5
|
+
DEFAULT_STICKY_FILL,
|
|
6
|
+
STICKY_MIN_SIZE,
|
|
7
|
+
STICKY_RADIUS,
|
|
8
|
+
} from '../constants.ts';
|
|
9
|
+
import { color, num, text } from '../fields.ts';
|
|
10
|
+
import type { ElementDef } from '../types.ts';
|
|
11
|
+
import {
|
|
12
|
+
BOX_FIELDS,
|
|
13
|
+
boxOf,
|
|
14
|
+
ROT_FIELD,
|
|
15
|
+
resizeBox,
|
|
16
|
+
solidBoxHit,
|
|
17
|
+
textStyleFields,
|
|
18
|
+
translateBox,
|
|
19
|
+
} from './_shared.ts';
|
|
20
|
+
|
|
21
|
+
export const sticky: ElementDef = {
|
|
22
|
+
type: 'sticky',
|
|
23
|
+
fields: {
|
|
24
|
+
...BOX_FIELDS,
|
|
25
|
+
...ROT_FIELD,
|
|
26
|
+
fill: color(DEFAULT_STICKY_FILL),
|
|
27
|
+
text: text(),
|
|
28
|
+
fontSize: num({ min: 4, max: 400, dp: 1, def: DEFAULT_FONT_SIZE }),
|
|
29
|
+
radius: num({ min: 0, max: 200, dp: 1, def: STICKY_RADIUS }),
|
|
30
|
+
...textStyleFields('left'),
|
|
31
|
+
},
|
|
32
|
+
caps: {
|
|
33
|
+
box: true,
|
|
34
|
+
rotatable: true,
|
|
35
|
+
resizable: true,
|
|
36
|
+
bindable: true,
|
|
37
|
+
container: false,
|
|
38
|
+
textSlot: 'text',
|
|
39
|
+
},
|
|
40
|
+
bounds: (el) => boxOf(el),
|
|
41
|
+
hitTest: (el, px, py, tol) => solidBoxHit(el, px, py, tol),
|
|
42
|
+
translate: translateBox,
|
|
43
|
+
resize: resizeBox,
|
|
44
|
+
meaningful: (el) => {
|
|
45
|
+
const b = boxOf(el);
|
|
46
|
+
return b.w >= STICKY_MIN_SIZE && b.h >= STICKY_MIN_SIZE;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone text (DDR-242). Unlike v1, its box is STORED (measured by the
|
|
3
|
+
* editor at commit) instead of estimated from a character count, so hit-test,
|
|
4
|
+
* marquee, snapping and headless readers agree with what is drawn. A shape's
|
|
5
|
+
* label is NOT a text element — it lives on the shape (`shape.label`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { DEFAULT_FONT_SIZE, DEFAULT_INK } from '../constants.ts';
|
|
9
|
+
import { color, num, text } from '../fields.ts';
|
|
10
|
+
import type { ElementDef } from '../types.ts';
|
|
11
|
+
import {
|
|
12
|
+
BOX_FIELDS,
|
|
13
|
+
boxOf,
|
|
14
|
+
ROT_FIELD,
|
|
15
|
+
resizeBox,
|
|
16
|
+
solidBoxHit,
|
|
17
|
+
textStyleFields,
|
|
18
|
+
translateBox,
|
|
19
|
+
} from './_shared.ts';
|
|
20
|
+
|
|
21
|
+
/** Text blocks larger than this don't take arrow binds (a paragraph is not a node). */
|
|
22
|
+
export const MAX_TEXT_BIND_W = 1200;
|
|
23
|
+
export const MAX_TEXT_BIND_H = 480;
|
|
24
|
+
|
|
25
|
+
export const textEl: ElementDef = {
|
|
26
|
+
type: 'text',
|
|
27
|
+
fields: {
|
|
28
|
+
...BOX_FIELDS,
|
|
29
|
+
...ROT_FIELD,
|
|
30
|
+
text: text(),
|
|
31
|
+
fontSize: num({ min: 4, max: 400, dp: 1, def: DEFAULT_FONT_SIZE }),
|
|
32
|
+
color: color(DEFAULT_INK),
|
|
33
|
+
...textStyleFields('left'),
|
|
34
|
+
},
|
|
35
|
+
caps: {
|
|
36
|
+
box: true,
|
|
37
|
+
rotatable: true,
|
|
38
|
+
resizable: true,
|
|
39
|
+
bindable: true,
|
|
40
|
+
container: false,
|
|
41
|
+
textSlot: 'text',
|
|
42
|
+
},
|
|
43
|
+
bounds: (el) => boxOf(el),
|
|
44
|
+
hitTest: (el, px, py, tol) => solidBoxHit(el, px, py, tol, 0),
|
|
45
|
+
translate: translateBox,
|
|
46
|
+
resize: resizeBox,
|
|
47
|
+
meaningful: (el) => typeof el.text === 'string' && el.text.trim().length > 0,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function textBindable(w: number, h: number): boolean {
|
|
51
|
+
return w > 0 && h > 0 && w <= MAX_TEXT_BIND_W && h <= MAX_TEXT_BIND_H;
|
|
52
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/fields.ts — field-spec vocabulary + the validator engine
|
|
3
|
+
* @scope apps/studio/annotations/fields.ts
|
|
4
|
+
* @purpose The annotations-v2 element model (DDR-242) validates every record
|
|
5
|
+
* against a per-type field spec. This module is that vocabulary —
|
|
6
|
+
* number / string / enum / point / bind / text-style … — plus the
|
|
7
|
+
* one engine that walks a spec, clamps, rounds, and drops defaults
|
|
8
|
+
* (the canonical form). It is shared by the studio, the hub kernel
|
|
9
|
+
* (Node 24 type-stripping — keep this file free of `enum`,
|
|
10
|
+
* `namespace` and parameter properties) and the headless bins, so
|
|
11
|
+
* every writer produces byte-identical canonical records.
|
|
12
|
+
*
|
|
13
|
+
* Peer data is UNTRUSTED (DDR-054). The engine never throws on
|
|
14
|
+
* bad input: a field that fails its spec falls back to its default
|
|
15
|
+
* (or makes the element invalid when it is `required`), unknown
|
|
16
|
+
* keys are dropped, and prototype keys never survive.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Keys that must never survive a parse of untrusted JSON (DDR-054 §2g). */
|
|
20
|
+
export const DANGEROUS_KEYS: ReadonlySet<string> = new Set([
|
|
21
|
+
'__proto__',
|
|
22
|
+
'constructor',
|
|
23
|
+
'prototype',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Coordinates and sizes round to 2 decimals; rotation to 0.1°. */
|
|
27
|
+
export const COORD_DP = 2;
|
|
28
|
+
/** Every coordinate is clamped into ±COORD_MAX (a peer can't park a node at 1e300). */
|
|
29
|
+
export const COORD_MAX = 1_000_000;
|
|
30
|
+
|
|
31
|
+
export type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A field spec. `parse` returns the clean value, or `undefined` when the raw
|
|
35
|
+
* value is unusable (the engine then applies the default, or rejects the
|
|
36
|
+
* element when the field is required). `def` is the implicit value: a field
|
|
37
|
+
* equal to its default is OMITTED from the canonical record.
|
|
38
|
+
*/
|
|
39
|
+
export interface FieldSpec<T = unknown> {
|
|
40
|
+
parse(raw: unknown): T | undefined;
|
|
41
|
+
def?: T;
|
|
42
|
+
required?: boolean;
|
|
43
|
+
/** Structural equality for default-omission and change detection. */
|
|
44
|
+
eq?(a: T, b: T): boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function round(v: number, dp: number): number {
|
|
48
|
+
const f = 10 ** dp;
|
|
49
|
+
const r = Math.round(v * f) / f;
|
|
50
|
+
return Object.is(r, -0) ? 0 : r;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface NumOpts {
|
|
54
|
+
min?: number;
|
|
55
|
+
max?: number;
|
|
56
|
+
dp?: number;
|
|
57
|
+
def?: number;
|
|
58
|
+
required?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function num(opts: NumOpts = {}): FieldSpec<number> {
|
|
62
|
+
const min = opts.min ?? -COORD_MAX;
|
|
63
|
+
const max = opts.max ?? COORD_MAX;
|
|
64
|
+
const dp = opts.dp ?? COORD_DP;
|
|
65
|
+
return {
|
|
66
|
+
def: opts.def,
|
|
67
|
+
required: opts.required,
|
|
68
|
+
parse(raw) {
|
|
69
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) return undefined;
|
|
70
|
+
return round(Math.min(max, Math.max(min, raw)), dp);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface StrOpts {
|
|
76
|
+
max: number;
|
|
77
|
+
re?: RegExp;
|
|
78
|
+
def?: string;
|
|
79
|
+
required?: boolean;
|
|
80
|
+
/** Strip control / bidi / zero-width characters (display names). */
|
|
81
|
+
plain?: boolean;
|
|
82
|
+
/** Allow an empty string (else '' counts as absent). */
|
|
83
|
+
allowEmpty?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Control chars, C1, zero-width, bidi overrides/isolates, BOM. Newline/tab kept for text bodies. */
|
|
87
|
+
export function stripUnsafe(s: string, keepNewlines: boolean): string {
|
|
88
|
+
let out = '';
|
|
89
|
+
for (const ch of s) {
|
|
90
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
91
|
+
if (keepNewlines && (cp === 0x0a || cp === 0x09)) {
|
|
92
|
+
out += ch;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const unsafe =
|
|
96
|
+
cp <= 0x1f ||
|
|
97
|
+
(cp >= 0x7f && cp <= 0x9f) ||
|
|
98
|
+
(cp >= 0x200b && cp <= 0x200f) ||
|
|
99
|
+
(cp >= 0x202a && cp <= 0x202e) ||
|
|
100
|
+
(cp >= 0x2066 && cp <= 0x2069) ||
|
|
101
|
+
cp === 0xfeff;
|
|
102
|
+
if (!unsafe) out += ch;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function str(opts: StrOpts): FieldSpec<string> {
|
|
108
|
+
return {
|
|
109
|
+
def: opts.def,
|
|
110
|
+
required: opts.required,
|
|
111
|
+
parse(raw) {
|
|
112
|
+
if (typeof raw !== 'string') return undefined;
|
|
113
|
+
let v = opts.plain ? stripUnsafe(raw, false).trim() : raw.replace(/\r\n?/g, '\n');
|
|
114
|
+
if (!opts.plain) v = stripUnsafe(v, true);
|
|
115
|
+
if (v.length > opts.max) {
|
|
116
|
+
// A patterned value (id, path, url, colour) is an identity — reject, never truncate.
|
|
117
|
+
if (opts.re) return undefined;
|
|
118
|
+
v = v.slice(0, opts.max);
|
|
119
|
+
}
|
|
120
|
+
if (!opts.allowEmpty && v === '') return undefined;
|
|
121
|
+
if (opts.re && !opts.re.test(v)) return undefined;
|
|
122
|
+
return v;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Multi-line body text. Empty is a valid value (a fresh sticky). */
|
|
128
|
+
export function text(max = 20_000): FieldSpec<string> {
|
|
129
|
+
return { ...str({ max, allowEmpty: true }), def: '' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function bool(def = false): FieldSpec<boolean> {
|
|
133
|
+
return {
|
|
134
|
+
def,
|
|
135
|
+
parse(raw) {
|
|
136
|
+
return typeof raw === 'boolean' ? raw : undefined;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function oneOf<T extends string>(
|
|
142
|
+
values: readonly T[],
|
|
143
|
+
def?: T,
|
|
144
|
+
required?: boolean
|
|
145
|
+
): FieldSpec<T> {
|
|
146
|
+
const set = new Set<string>(values);
|
|
147
|
+
return {
|
|
148
|
+
def,
|
|
149
|
+
required,
|
|
150
|
+
parse(raw) {
|
|
151
|
+
return typeof raw === 'string' && set.has(raw) ? (raw as T) : undefined;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `#rgb`, `#rrggbb`, `#rrggbbaa` — the only colour forms the palettes produce. */
|
|
157
|
+
export const COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
158
|
+
|
|
159
|
+
export function color(def?: string, required?: boolean): FieldSpec<string> {
|
|
160
|
+
return { ...str({ max: 9, re: COLOR_RE }), def, required };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A fill: a colour, or `null` for "no fill" (null is the default). */
|
|
164
|
+
export function fill(): FieldSpec<string | null> {
|
|
165
|
+
return {
|
|
166
|
+
def: null,
|
|
167
|
+
parse(raw) {
|
|
168
|
+
if (raw === null) return null;
|
|
169
|
+
return typeof raw === 'string' && COLOR_RE.test(raw) ? raw : undefined;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Element id — the stable identity external references (comments) hold. */
|
|
175
|
+
export const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
176
|
+
|
|
177
|
+
export function id(required = false): FieldSpec<string> {
|
|
178
|
+
return { ...str({ max: 64, re: ID_RE }), required };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Fractional z-order key (base-62 digits, see fractional-index.ts). */
|
|
182
|
+
export const INDEX_RE = /^[0-9A-Za-z]{1,64}$/;
|
|
183
|
+
|
|
184
|
+
export function stringList(maxItems: number, item: FieldSpec<string>): FieldSpec<string[]> {
|
|
185
|
+
return {
|
|
186
|
+
def: [],
|
|
187
|
+
eq: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]),
|
|
188
|
+
parse(raw) {
|
|
189
|
+
if (!Array.isArray(raw)) return undefined;
|
|
190
|
+
const out: string[] = [];
|
|
191
|
+
for (const r of raw.slice(0, maxItems)) {
|
|
192
|
+
const v = item.parse(r);
|
|
193
|
+
if (v !== undefined && !out.includes(v)) out.push(v);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Flat `[x0, y0, x1, y1, …]` point list (pen ink). Odd trailing values are dropped. */
|
|
201
|
+
export function points(maxPoints: number): FieldSpec<number[]> {
|
|
202
|
+
return {
|
|
203
|
+
required: true,
|
|
204
|
+
eq: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]),
|
|
205
|
+
parse(raw) {
|
|
206
|
+
if (!Array.isArray(raw)) return undefined;
|
|
207
|
+
const n = Math.min(raw.length - (raw.length % 2), maxPoints * 2);
|
|
208
|
+
const out: number[] = [];
|
|
209
|
+
for (let i = 0; i < n; i++) {
|
|
210
|
+
const v = raw[i];
|
|
211
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return undefined;
|
|
212
|
+
out.push(round(Math.min(COORD_MAX, Math.max(-COORD_MAX, v)), COORD_DP));
|
|
213
|
+
}
|
|
214
|
+
return out.length >= 2 ? out : undefined;
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Deep structural equality over JSON values (records, arrays, primitives). */
|
|
220
|
+
export function jsonEq(a: unknown, b: unknown): boolean {
|
|
221
|
+
if (a === b) return true;
|
|
222
|
+
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
223
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
224
|
+
if (Array.isArray(a)) {
|
|
225
|
+
const bb = b as unknown[];
|
|
226
|
+
return a.length === bb.length && a.every((v, i) => jsonEq(v, bb[i]));
|
|
227
|
+
}
|
|
228
|
+
const ak = Object.keys(a as object);
|
|
229
|
+
const bk = Object.keys(b as object);
|
|
230
|
+
if (ak.length !== bk.length) return false;
|
|
231
|
+
for (const k of ak) {
|
|
232
|
+
if (!Object.hasOwn(b as object, k)) return false;
|
|
233
|
+
if (!jsonEq((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false;
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A nested record with its own field spec (e.g. an arrow end, a shape label). */
|
|
239
|
+
export function record(
|
|
240
|
+
spec: FieldSpecMap,
|
|
241
|
+
opts: { required?: boolean; def?: Record<string, unknown> } = {}
|
|
242
|
+
): FieldSpec<Record<string, unknown>> {
|
|
243
|
+
return {
|
|
244
|
+
required: opts.required,
|
|
245
|
+
def: opts.def,
|
|
246
|
+
eq: jsonEq,
|
|
247
|
+
parse(raw) {
|
|
248
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
249
|
+
const r = parseFields(spec, raw as Record<string, unknown>);
|
|
250
|
+
return r.ok ? r.value : undefined;
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Try each alternative in order; the first that parses wins. */
|
|
256
|
+
export function either<T>(...alts: FieldSpec<T>[]): FieldSpec<T> {
|
|
257
|
+
return {
|
|
258
|
+
eq: jsonEq as (a: T, b: T) => boolean,
|
|
259
|
+
parse(raw) {
|
|
260
|
+
for (const a of alts) {
|
|
261
|
+
const v = a.parse(raw);
|
|
262
|
+
if (v !== undefined) return v;
|
|
263
|
+
}
|
|
264
|
+
return undefined;
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export type FieldSpecMap = Readonly<Record<string, FieldSpec<unknown>>>;
|
|
270
|
+
|
|
271
|
+
export type ParseResult =
|
|
272
|
+
| { ok: true; value: Record<string, unknown> }
|
|
273
|
+
| { ok: false; reason: string };
|
|
274
|
+
|
|
275
|
+
function isDefault(spec: FieldSpec<unknown>, v: unknown): boolean {
|
|
276
|
+
if (spec.def === undefined) return false;
|
|
277
|
+
return spec.eq ? spec.eq(v, spec.def) : jsonEq(v, spec.def);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Walk `spec` over `raw` in SPEC ORDER (that order is the canonical key order),
|
|
282
|
+
* dropping unknown keys and default-valued fields. Required fields that fail
|
|
283
|
+
* make the record invalid; optional ones fall back to (omitted) defaults.
|
|
284
|
+
*/
|
|
285
|
+
export function parseFields(spec: FieldSpecMap, raw: Record<string, unknown>): ParseResult {
|
|
286
|
+
const out: Record<string, unknown> = {};
|
|
287
|
+
for (const key of Object.keys(spec)) {
|
|
288
|
+
if (DANGEROUS_KEYS.has(key)) continue;
|
|
289
|
+
const f = spec[key] as FieldSpec<unknown>;
|
|
290
|
+
const has = Object.hasOwn(raw, key);
|
|
291
|
+
const v = has ? f.parse(raw[key]) : undefined;
|
|
292
|
+
if (v === undefined) {
|
|
293
|
+
if (f.required) return { ok: false, reason: `field "${key}" missing or invalid` };
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (isDefault(f, v)) continue;
|
|
297
|
+
out[key] = v;
|
|
298
|
+
}
|
|
299
|
+
return { ok: true, value: out };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The value of `key` on a canonical record, falling back to the spec default. */
|
|
303
|
+
export function fieldValue(spec: FieldSpecMap, rec: Record<string, unknown>, key: string): unknown {
|
|
304
|
+
if (Object.hasOwn(rec, key)) return rec[key];
|
|
305
|
+
return spec[key]?.def;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Safe JSON parse for untrusted text: a reviver drops prototype keys at every
|
|
310
|
+
* depth (DDR-054 §2g). Returns `undefined` on malformed input — never throws.
|
|
311
|
+
*/
|
|
312
|
+
export function parseJsonSafe(text: string): unknown {
|
|
313
|
+
try {
|
|
314
|
+
return JSON.parse(text, (k, v) => (DANGEROUS_KEYS.has(k) ? undefined : v));
|
|
315
|
+
} catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Bounded generic JSON sanitizer for UNKNOWN element types (forward-compat
|
|
322
|
+
* passthrough): primitives, arrays and plain objects only, depth ≤ 4, ≤ 256
|
|
323
|
+
* keys/items per level, strings capped, prototype keys dropped. Returns
|
|
324
|
+
* `undefined` when the value is not representable.
|
|
325
|
+
*/
|
|
326
|
+
export function sanitizeJson(raw: unknown, depth = 0): Json | undefined {
|
|
327
|
+
if (raw === null) return null;
|
|
328
|
+
if (typeof raw === 'boolean') return raw;
|
|
329
|
+
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined;
|
|
330
|
+
if (typeof raw === 'string') return stripUnsafe(raw, true).slice(0, 20_000);
|
|
331
|
+
if (depth >= 4 || typeof raw !== 'object') return undefined;
|
|
332
|
+
if (Array.isArray(raw)) {
|
|
333
|
+
const out: Json[] = [];
|
|
334
|
+
for (const v of raw.slice(0, 256)) {
|
|
335
|
+
const s = sanitizeJson(v, depth + 1);
|
|
336
|
+
if (s !== undefined) out.push(s);
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
const out: { [k: string]: Json } = {};
|
|
341
|
+
for (const k of Object.keys(raw as object).slice(0, 256)) {
|
|
342
|
+
if (DANGEROUS_KEYS.has(k) || k.length > 64) continue;
|
|
343
|
+
const s = sanitizeJson((raw as Record<string, unknown>)[k], depth + 1);
|
|
344
|
+
if (s !== undefined) out[k] = s;
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|