@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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/constants.ts — defaults of the annotations-v2 model
|
|
3
|
+
* @scope apps/studio/annotations/constants.ts
|
|
4
|
+
* @purpose Values baked into the canonical form (a field equal to its default
|
|
5
|
+
* is omitted on disk, so these MUST NOT change once shipped — changing
|
|
6
|
+
* one silently restyles every element that relied on it). They match
|
|
7
|
+
* the v1 model (`annotations-model.ts`) value for value so migrated
|
|
8
|
+
* boards look identical.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_INK = '#1f1f1f';
|
|
12
|
+
export const DEFAULT_STICKY_FILL = '#fce8a6';
|
|
13
|
+
export const DEFAULT_SECTION_COLOR = '#8b8b94';
|
|
14
|
+
export const DEFAULT_FONT_SIZE = 14;
|
|
15
|
+
export const DEFAULT_STROKE_WIDTH = 2;
|
|
16
|
+
export const STICKY_RADIUS = 8;
|
|
17
|
+
export const STICKY_MIN_SIZE = 40;
|
|
18
|
+
export const STICKY_MAX_GROWN_H = 8000;
|
|
19
|
+
export const SHAPE_MIN_SIZE = 4;
|
|
20
|
+
export const SECTION_MIN_SIZE = 64;
|
|
21
|
+
export const SECTION_LABEL_FONT = 12;
|
|
22
|
+
export const SECTION_LABEL_H = 20;
|
|
23
|
+
export const IMAGE_MIN_SIZE = 16;
|
|
24
|
+
export const TEXT_LINE_HEIGHT = 1.25;
|
|
25
|
+
|
|
26
|
+
/** Hard caps (untrusted input, DDR-054 §2d). */
|
|
27
|
+
export const MAX_PEN_POINTS = 20_000;
|
|
28
|
+
export const MAX_TEXT_CHARS = 20_000;
|
|
29
|
+
export const MAX_GROUPS_PER_ELEMENT = 16;
|
|
30
|
+
export const MAX_NESTING_DEPTH = 8;
|
|
31
|
+
export const MAX_ELEMENTS = 20_000;
|
|
32
|
+
/** Serialized bytes of one element record. */
|
|
33
|
+
export const MAX_ELEMENT_BYTES = 256 * 1024;
|
|
34
|
+
/** Serialized bytes of a whole board file. */
|
|
35
|
+
export const MAX_BOARD_BYTES = 4 * 1024 * 1024;
|
|
36
|
+
/** A legacy v1 `.annotations.svg` (v1 capped boards at 1 MB) — larger input is refused unparsed. */
|
|
37
|
+
export const MAX_LEGACY_SVG_BYTES = 2 * 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
/** Arrow head vocabulary — mirrors `canvas-arrowheads.ts` ARROW_HEADS. */
|
|
40
|
+
export const ARROW_HEADS = [
|
|
41
|
+
'none',
|
|
42
|
+
'line',
|
|
43
|
+
'triangle',
|
|
44
|
+
'triangle-outline',
|
|
45
|
+
'circle',
|
|
46
|
+
'diamond',
|
|
47
|
+
] as const;
|
|
48
|
+
export const ARROW_LINES = ['straight', 'curved', 'elbow'] as const;
|
|
49
|
+
export const SHAPE_KINDS = ['rect', 'ellipse', 'diamond', 'triangle', 'triangle-down'] as const;
|
|
50
|
+
export type ShapeKind = (typeof SHAPE_KINDS)[number];
|
|
51
|
+
|
|
52
|
+
/** Relative `assets/<name>.<ext>` raster path — the only href an image keeps (DDR-054, Phase 23). */
|
|
53
|
+
export const ASSET_IMAGE_HREF_RE = /^assets\/[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif)$/;
|
|
54
|
+
/** Relative `assets/<name>.<ext>` video/audio path — the only src a media chip keeps (DDR-150 P4). */
|
|
55
|
+
export const ASSET_MEDIA_SRC_RE =
|
|
56
|
+
/^assets\/[A-Za-z0-9._-]+\.(?:mp4|webm|mov|m4v|mp3|wav|m4a|aac|ogg)$/;
|
|
57
|
+
/** Link cards open http(s) only. */
|
|
58
|
+
export const LINK_URL_RE = /^https?:\/\/[^\s]+$/i;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/elements/_shared.ts — helpers shared by element definitions
|
|
3
|
+
* @scope apps/studio/annotations/elements/_shared.ts
|
|
4
|
+
* @purpose Box fields, text-style fields and the rotation-aware box
|
|
5
|
+
* hit-test most element types reuse. A new element type composes
|
|
6
|
+
* these; it never re-implements them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { bool, type FieldSpecMap, fieldValue, num, oneOf } from '../fields.ts';
|
|
10
|
+
import type { AnnotationElement, Box } from '../types.ts';
|
|
11
|
+
|
|
12
|
+
export const ALIGNS = ['left', 'center', 'right'] as const;
|
|
13
|
+
export const LISTS = ['bullet', 'number'] as const;
|
|
14
|
+
|
|
15
|
+
/** Geometry of every box-shaped element. `w`/`h` are always normalized (≥ 0). */
|
|
16
|
+
export const BOX_FIELDS: FieldSpecMap = {
|
|
17
|
+
x: num({ required: true }),
|
|
18
|
+
y: num({ required: true }),
|
|
19
|
+
w: num({ min: 0, required: true }),
|
|
20
|
+
h: num({ min: 0, required: true }),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const ROT_FIELD: FieldSpecMap = { rot: num({ min: -180, max: 180, dp: 1, def: 0 }) };
|
|
24
|
+
|
|
25
|
+
/** Whole-element text formatting (DDR-091: list markers are render-only). */
|
|
26
|
+
export function textStyleFields(defAlign: (typeof ALIGNS)[number]): FieldSpecMap {
|
|
27
|
+
return {
|
|
28
|
+
bold: bool(),
|
|
29
|
+
italic: bool(),
|
|
30
|
+
strike: bool(),
|
|
31
|
+
underline: bool(),
|
|
32
|
+
list: oneOf(LISTS),
|
|
33
|
+
align: oneOf(ALIGNS, defAlign),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function n(el: AnnotationElement, key: string): number {
|
|
38
|
+
const v = el[key];
|
|
39
|
+
return typeof v === 'number' ? v : 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function boxOf(el: AnnotationElement): Box {
|
|
43
|
+
return { x: n(el, 'x'), y: n(el, 'y'), w: n(el, 'w'), h: n(el, 'h') };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function rotOf(el: AnnotationElement): number {
|
|
47
|
+
return n(el, 'rot');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Rotate (px, py) around (cx, cy) by `deg` clockwise. */
|
|
51
|
+
export function rotatePoint(
|
|
52
|
+
px: number,
|
|
53
|
+
py: number,
|
|
54
|
+
cx: number,
|
|
55
|
+
cy: number,
|
|
56
|
+
deg: number
|
|
57
|
+
): [number, number] {
|
|
58
|
+
if (deg === 0) return [px, py];
|
|
59
|
+
const rad = (deg * Math.PI) / 180;
|
|
60
|
+
const cos = Math.cos(rad);
|
|
61
|
+
const sin = Math.sin(rad);
|
|
62
|
+
const dx = px - cx;
|
|
63
|
+
const dy = py - cy;
|
|
64
|
+
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Inverse-rotate a probe into the element's local (unrotated) frame. */
|
|
68
|
+
export function toLocal(el: AnnotationElement, box: Box, px: number, py: number): [number, number] {
|
|
69
|
+
const rot = rotOf(el);
|
|
70
|
+
if (rot === 0) return [px, py];
|
|
71
|
+
return rotatePoint(px, py, box.x + box.w / 2, box.y + box.h / 2, -rot);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function inBox(b: Box, px: number, py: number, tol: number): boolean {
|
|
75
|
+
return px >= b.x - tol && px <= b.x + b.w + tol && py >= b.y - tol && py <= b.y + b.h + tol;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function pointSegmentDist(
|
|
79
|
+
px: number,
|
|
80
|
+
py: number,
|
|
81
|
+
ax: number,
|
|
82
|
+
ay: number,
|
|
83
|
+
bx: number,
|
|
84
|
+
by: number
|
|
85
|
+
): number {
|
|
86
|
+
const dx = bx - ax;
|
|
87
|
+
const dy = by - ay;
|
|
88
|
+
const len2 = dx * dx + dy * dy;
|
|
89
|
+
if (len2 === 0) return Math.hypot(px - ax, py - ay);
|
|
90
|
+
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
|
|
91
|
+
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Minimum grab tolerance of solid cards (v1 parity: cards have no stroke width, so 2). */
|
|
95
|
+
export const CARD_MIN_TOL = 2;
|
|
96
|
+
|
|
97
|
+
/** Solid card: the whole (rotated) box is hittable, with at least `minTol` slack. */
|
|
98
|
+
export function solidBoxHit(
|
|
99
|
+
el: AnnotationElement,
|
|
100
|
+
px: number,
|
|
101
|
+
py: number,
|
|
102
|
+
tol: number,
|
|
103
|
+
minTol = CARD_MIN_TOL
|
|
104
|
+
): boolean {
|
|
105
|
+
const b = boxOf(el);
|
|
106
|
+
const [lx, ly] = toLocal(el, b, px, py);
|
|
107
|
+
return inBox(b, lx, ly, Math.max(tol, minTol));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function translateBox(
|
|
111
|
+
el: AnnotationElement,
|
|
112
|
+
dx: number,
|
|
113
|
+
dy: number
|
|
114
|
+
): Record<string, unknown> {
|
|
115
|
+
return { x: n(el, 'x') + dx, y: n(el, 'y') + dy };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function resizeBox(_el: AnnotationElement, box: Box): Record<string, unknown> {
|
|
119
|
+
return { x: box.x, y: box.y, w: box.w, h: box.h };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Read a field with its spec default applied. */
|
|
123
|
+
export function get<T>(spec: FieldSpecMap, el: AnnotationElement, key: string): T {
|
|
124
|
+
return fieldValue(spec, el, key) as T;
|
|
125
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arrow / connector (DDR-242). Stores its two ENDS only — never its computed
|
|
3
|
+
* endpoints. An end is either free (`{x, y}`, parent space) or bound to an
|
|
4
|
+
* element: `{el}` = auto (re-faces the other end as things move, FigJam
|
|
5
|
+
* connector behaviour), `{el, nx, ny}` = pinned to a magnet on the host's box.
|
|
6
|
+
* Moving a host therefore writes only the host; every reader derives the
|
|
7
|
+
* endpoints through `arrowEnds`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ARROW_HEADS, ARROW_LINES, DEFAULT_INK } from '../constants.ts';
|
|
11
|
+
import { bool, color, type FieldSpec, id, num, oneOf, round } from '../fields.ts';
|
|
12
|
+
import type { AnnotationElement, ArrowEnd, Box, ElementDef, GeomCtx } from '../types.ts';
|
|
13
|
+
import { get, pointSegmentDist, rotatePoint } from './_shared.ts';
|
|
14
|
+
|
|
15
|
+
const MAGNETS = [0, 0.5, 1] as const;
|
|
16
|
+
|
|
17
|
+
/** Snap a normalized coordinate to the nearest {0, ½, 1} magnet. */
|
|
18
|
+
export function snapMagnet(v: number): number {
|
|
19
|
+
let best: number = MAGNETS[0];
|
|
20
|
+
for (const m of MAGNETS) if (Math.abs(v - m) < Math.abs(v - best)) best = m;
|
|
21
|
+
return best;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const elId = id(true);
|
|
25
|
+
const coord = num({ required: true });
|
|
26
|
+
const unit = num({ min: 0, max: 1, dp: 3 });
|
|
27
|
+
|
|
28
|
+
/** An arrow end: `{el}` | `{el, nx, ny}` | `{x, y}`. Half-specified magnets collapse to auto. */
|
|
29
|
+
export function arrowEnd(): FieldSpec<ArrowEnd> {
|
|
30
|
+
return {
|
|
31
|
+
required: true,
|
|
32
|
+
eq: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
|
33
|
+
parse(raw) {
|
|
34
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
35
|
+
const r = raw as Record<string, unknown>;
|
|
36
|
+
if (r.el !== undefined) {
|
|
37
|
+
const el = elId.parse(r.el);
|
|
38
|
+
if (el === undefined) return undefined;
|
|
39
|
+
const nx = unit.parse(r.nx);
|
|
40
|
+
const ny = unit.parse(r.ny);
|
|
41
|
+
return nx !== undefined && ny !== undefined ? { el, nx, ny } : { el };
|
|
42
|
+
}
|
|
43
|
+
const x = coord.parse(r.x);
|
|
44
|
+
const y = coord.parse(r.y);
|
|
45
|
+
return x !== undefined && y !== undefined ? { x, y } : undefined;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const FIELDS = {
|
|
51
|
+
start: arrowEnd(),
|
|
52
|
+
end: arrowEnd(),
|
|
53
|
+
color: color(DEFAULT_INK),
|
|
54
|
+
width: num({ min: 0.5, max: 64, dp: 1, def: 2 }),
|
|
55
|
+
startHead: oneOf(ARROW_HEADS, 'none'),
|
|
56
|
+
endHead: oneOf(ARROW_HEADS, 'triangle'),
|
|
57
|
+
dashed: bool(),
|
|
58
|
+
line: oneOf(ARROW_LINES, 'straight'),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export interface ResolvedArrow {
|
|
62
|
+
x1: number;
|
|
63
|
+
y1: number;
|
|
64
|
+
x2: number;
|
|
65
|
+
y2: number;
|
|
66
|
+
/** The magnet each bound end actually attaches to (auto ends: the facing side). */
|
|
67
|
+
startAnchor?: { el: string; nx: number; ny: number };
|
|
68
|
+
endAnchor?: { el: string; nx: number; ny: number };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Side magnet of `box` facing the world point (tx, ty) — FigJam auto-routing. */
|
|
72
|
+
export function facingAnchor(box: Box, tx: number, ty: number): { nx: number; ny: number } {
|
|
73
|
+
const dx = tx - (box.x + box.w / 2);
|
|
74
|
+
const dy = ty - (box.y + box.h / 2);
|
|
75
|
+
const rx = Math.abs(dx) / Math.max(1, box.w / 2);
|
|
76
|
+
const ry = Math.abs(dy) / Math.max(1, box.h / 2);
|
|
77
|
+
return rx >= ry ? { nx: dx >= 0 ? 1 : 0, ny: 0.5 } : { nx: 0.5, ny: dy >= 0 ? 1 : 0 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** World point of a normalized anchor on a (possibly rotated) box. */
|
|
81
|
+
export function anchorPoint(box: Box, rot: number, nx: number, ny: number): [number, number] {
|
|
82
|
+
return rotatePoint(
|
|
83
|
+
box.x + nx * box.w,
|
|
84
|
+
box.y + ny * box.h,
|
|
85
|
+
box.x + box.w / 2,
|
|
86
|
+
box.y + box.h / 2,
|
|
87
|
+
rot
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve both endpoints in PARENT space. A bound end whose host is missing or
|
|
93
|
+
* no longer bindable resolves to null (the op layer freezes such ends into free
|
|
94
|
+
* points at delete time — see ops.ts `unbindFrom`).
|
|
95
|
+
*/
|
|
96
|
+
export function arrowEnds(el: AnnotationElement, ctx: GeomCtx): ResolvedArrow | null {
|
|
97
|
+
const start = el.start as ArrowEnd | undefined;
|
|
98
|
+
const end = el.end as ArrowEnd | undefined;
|
|
99
|
+
if (!start || !end) return null;
|
|
100
|
+
const host = (e: ArrowEnd) => ('el' in e ? ctx.resolve(e.el) : null);
|
|
101
|
+
const sHost = host(start);
|
|
102
|
+
const eHost = host(end);
|
|
103
|
+
if (('el' in start && !sHost?.bindable) || ('el' in end && !eHost?.bindable)) return null;
|
|
104
|
+
const world = (e: ArrowEnd): [number, number] | null =>
|
|
105
|
+
'el' in e ? null : [e.x + ctx.origin.x, e.y + ctx.origin.y];
|
|
106
|
+
const center = (h: { box: Box } | null): [number, number] | null =>
|
|
107
|
+
h ? [h.box.x + h.box.w / 2, h.box.y + h.box.h / 2] : null;
|
|
108
|
+
// Auto ends face the OTHER end: its host's center, else its free point.
|
|
109
|
+
const startRef = center(eHost) ?? world(end) ?? [0, 0];
|
|
110
|
+
const endRef = center(sHost) ?? world(start) ?? [0, 0];
|
|
111
|
+
const out: Partial<ResolvedArrow> = {};
|
|
112
|
+
const place = (e: ArrowEnd, h: typeof sHost, ref: [number, number], which: 'start' | 'end') => {
|
|
113
|
+
let p: [number, number];
|
|
114
|
+
if ('el' in e && h) {
|
|
115
|
+
const mag =
|
|
116
|
+
e.nx !== undefined && e.ny !== undefined
|
|
117
|
+
? { nx: e.nx, ny: e.ny }
|
|
118
|
+
: facingAnchor(h.box, ref[0], ref[1]);
|
|
119
|
+
p = anchorPoint(h.box, h.rot, mag.nx, mag.ny);
|
|
120
|
+
out[which === 'start' ? 'startAnchor' : 'endAnchor'] = { el: e.el, ...mag };
|
|
121
|
+
} else {
|
|
122
|
+
p = world(e) as [number, number];
|
|
123
|
+
}
|
|
124
|
+
const x = round(p[0] - ctx.origin.x, 2);
|
|
125
|
+
const y = round(p[1] - ctx.origin.y, 2);
|
|
126
|
+
if (which === 'start') {
|
|
127
|
+
out.x1 = x;
|
|
128
|
+
out.y1 = y;
|
|
129
|
+
} else {
|
|
130
|
+
out.x2 = x;
|
|
131
|
+
out.y2 = y;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
place(start, sHost, startRef, 'start');
|
|
135
|
+
place(end, eHost, endRef, 'end');
|
|
136
|
+
return out as ResolvedArrow;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const arrow: ElementDef = {
|
|
140
|
+
type: 'arrow',
|
|
141
|
+
fields: FIELDS,
|
|
142
|
+
caps: {
|
|
143
|
+
box: false,
|
|
144
|
+
rotatable: false,
|
|
145
|
+
resizable: true,
|
|
146
|
+
bindable: false,
|
|
147
|
+
container: false,
|
|
148
|
+
textSlot: null,
|
|
149
|
+
},
|
|
150
|
+
bounds(el, ctx) {
|
|
151
|
+
const r = arrowEnds(el, ctx);
|
|
152
|
+
if (!r) return null;
|
|
153
|
+
return {
|
|
154
|
+
x: Math.min(r.x1, r.x2),
|
|
155
|
+
y: Math.min(r.y1, r.y2),
|
|
156
|
+
w: Math.abs(r.x2 - r.x1),
|
|
157
|
+
h: Math.abs(r.y2 - r.y1),
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
hitTest(el, px, py, tol, ctx) {
|
|
161
|
+
const r = arrowEnds(el, ctx);
|
|
162
|
+
if (!r) return false;
|
|
163
|
+
const t = Math.max(tol, get<number>(FIELDS, el, 'width'));
|
|
164
|
+
return pointSegmentDist(px, py, r.x1, r.y1, r.x2, r.y2) <= t;
|
|
165
|
+
},
|
|
166
|
+
// Only FREE ends move; bound ends follow their hosts.
|
|
167
|
+
translate(el, dx, dy) {
|
|
168
|
+
const patch: Record<string, unknown> = {};
|
|
169
|
+
for (const k of ['start', 'end'] as const) {
|
|
170
|
+
const e = el[k] as ArrowEnd | undefined;
|
|
171
|
+
if (e && !('el' in e)) patch[k] = { x: e.x + dx, y: e.y + dy };
|
|
172
|
+
}
|
|
173
|
+
return patch;
|
|
174
|
+
},
|
|
175
|
+
resize(el, box, ctx) {
|
|
176
|
+
const r = arrowEnds(el, ctx);
|
|
177
|
+
const cur = arrow.bounds(el, ctx);
|
|
178
|
+
if (!r || !cur) return {};
|
|
179
|
+
const sx = cur.w > 0 ? box.w / cur.w : 1;
|
|
180
|
+
const sy = cur.h > 0 ? box.h / cur.h : 1;
|
|
181
|
+
const patch: Record<string, unknown> = {};
|
|
182
|
+
for (const k of ['start', 'end'] as const) {
|
|
183
|
+
const e = el[k] as ArrowEnd | undefined;
|
|
184
|
+
if (!e || 'el' in e) continue;
|
|
185
|
+
patch[k] = { x: box.x + (e.x - cur.x) * sx, y: box.y + (e.y - cur.y) * sy };
|
|
186
|
+
}
|
|
187
|
+
return patch;
|
|
188
|
+
},
|
|
189
|
+
meaningful(el) {
|
|
190
|
+
const s = el.start as ArrowEnd | undefined;
|
|
191
|
+
const e = el.end as ArrowEnd | undefined;
|
|
192
|
+
if (!s || !e) return false;
|
|
193
|
+
if ('el' in s || 'el' in e) return !('el' in s && 'el' in e && s.el === e.el);
|
|
194
|
+
return Math.hypot(e.x - s.x, e.y - s.y) >= 4;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three media cards (DDR-242): `image` (a dropped raster), `link` (a URL
|
|
3
|
+
* preview chip — no server fetch, DDR-054/060) and `mediaref` (a pointer to an
|
|
4
|
+
* `assets/` video/audio clip, DDR-150 P4). All are solid rect cards whose only
|
|
5
|
+
* external references are validated relative `assets/` paths or http(s) URLs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ASSET_IMAGE_HREF_RE,
|
|
10
|
+
ASSET_MEDIA_SRC_RE,
|
|
11
|
+
IMAGE_MIN_SIZE,
|
|
12
|
+
LINK_URL_RE,
|
|
13
|
+
} from '../constants.ts';
|
|
14
|
+
import { oneOf, str } from '../fields.ts';
|
|
15
|
+
import type { ElementDef } from '../types.ts';
|
|
16
|
+
import { BOX_FIELDS, boxOf, ROT_FIELD, resizeBox, solidBoxHit, translateBox } from './_shared.ts';
|
|
17
|
+
|
|
18
|
+
const CARD_CAPS = {
|
|
19
|
+
box: true,
|
|
20
|
+
rotatable: true,
|
|
21
|
+
resizable: true,
|
|
22
|
+
bindable: false,
|
|
23
|
+
container: false,
|
|
24
|
+
textSlot: null,
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
function card(
|
|
28
|
+
type: string,
|
|
29
|
+
fields: ElementDef['fields'],
|
|
30
|
+
meaningful: ElementDef['meaningful']
|
|
31
|
+
): ElementDef {
|
|
32
|
+
return {
|
|
33
|
+
type,
|
|
34
|
+
fields: { ...BOX_FIELDS, ...ROT_FIELD, ...fields },
|
|
35
|
+
caps: CARD_CAPS,
|
|
36
|
+
bounds: (el) => boxOf(el),
|
|
37
|
+
hitTest: (el, px, py, tol) => solidBoxHit(el, px, py, tol),
|
|
38
|
+
translate: translateBox,
|
|
39
|
+
resize: resizeBox,
|
|
40
|
+
meaningful,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const image: ElementDef = {
|
|
45
|
+
...card(
|
|
46
|
+
'image',
|
|
47
|
+
{
|
|
48
|
+
// A stripped/invalid href degrades to '' — an inert frame that fetches nothing.
|
|
49
|
+
href: str({ max: 256, re: ASSET_IMAGE_HREF_RE, def: '', allowEmpty: true }),
|
|
50
|
+
alt: str({ max: 500, plain: true, def: '', allowEmpty: true }),
|
|
51
|
+
},
|
|
52
|
+
(el) => {
|
|
53
|
+
const b = boxOf(el);
|
|
54
|
+
return b.w >= IMAGE_MIN_SIZE && b.h >= IMAGE_MIN_SIZE;
|
|
55
|
+
}
|
|
56
|
+
),
|
|
57
|
+
// Images take arrow binds (v1 parity: isBindable included image).
|
|
58
|
+
caps: { ...CARD_CAPS, bindable: true },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const link: ElementDef = card(
|
|
62
|
+
'link',
|
|
63
|
+
{
|
|
64
|
+
url: str({ max: 2048, re: LINK_URL_RE, required: true }),
|
|
65
|
+
title: str({ max: 300, plain: true, def: '', allowEmpty: true }),
|
|
66
|
+
domain: str({ max: 253, plain: true, def: '', allowEmpty: true }),
|
|
67
|
+
},
|
|
68
|
+
(el) => typeof el.url === 'string' && el.url.length > 0
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
export const mediaref: ElementDef = card(
|
|
72
|
+
'mediaref',
|
|
73
|
+
{
|
|
74
|
+
src: str({ max: 256, re: ASSET_MEDIA_SRC_RE, def: '', allowEmpty: true }),
|
|
75
|
+
media: oneOf(['video', 'audio'] as const, 'video'),
|
|
76
|
+
title: str({ max: 300, plain: true, def: '', allowEmpty: true }),
|
|
77
|
+
},
|
|
78
|
+
(el) => typeof el.src === 'string' && el.src.length > 0
|
|
79
|
+
);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freehand pen / highlighter (DDR-242, DDR-090: the highlighter is a pen flag).
|
|
3
|
+
* Points are a flat `[x0, y0, x1, y1, …]` list in parent space.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_INK, MAX_PEN_POINTS } from '../constants.ts';
|
|
7
|
+
import { bool, color, num, points } from '../fields.ts';
|
|
8
|
+
import type { AnnotationElement, Box, ElementDef } from '../types.ts';
|
|
9
|
+
import { get, pointSegmentDist } from './_shared.ts';
|
|
10
|
+
|
|
11
|
+
const FIELDS = {
|
|
12
|
+
points: points(MAX_PEN_POINTS),
|
|
13
|
+
color: color(DEFAULT_INK),
|
|
14
|
+
width: num({ min: 0.5, max: 128, dp: 1, def: 2 }),
|
|
15
|
+
highlighter: bool(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function pts(el: AnnotationElement): number[] {
|
|
19
|
+
return Array.isArray(el.points) ? (el.points as number[]) : [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function penBounds(p: readonly number[]): Box | null {
|
|
23
|
+
if (p.length < 2) return null;
|
|
24
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
25
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
26
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
27
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
28
|
+
for (let i = 0; i + 1 < p.length; i += 2) {
|
|
29
|
+
const x = p[i] as number;
|
|
30
|
+
const y = p[i + 1] as number;
|
|
31
|
+
if (x < xMin) xMin = x;
|
|
32
|
+
if (x > xMax) xMax = x;
|
|
33
|
+
if (y < yMin) yMin = y;
|
|
34
|
+
if (y > yMax) yMax = y;
|
|
35
|
+
}
|
|
36
|
+
return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const pen: ElementDef = {
|
|
40
|
+
type: 'pen',
|
|
41
|
+
fields: FIELDS,
|
|
42
|
+
caps: {
|
|
43
|
+
box: false,
|
|
44
|
+
rotatable: false,
|
|
45
|
+
resizable: true,
|
|
46
|
+
bindable: false,
|
|
47
|
+
container: false,
|
|
48
|
+
textSlot: null,
|
|
49
|
+
},
|
|
50
|
+
bounds: (el) => penBounds(pts(el)),
|
|
51
|
+
hitTest(el, px, py, tol) {
|
|
52
|
+
const p = pts(el);
|
|
53
|
+
const t = Math.max(tol, get<number>(FIELDS, el, 'width'));
|
|
54
|
+
if (p.length === 2) return Math.hypot(px - (p[0] as number), py - (p[1] as number)) <= t;
|
|
55
|
+
for (let i = 2; i + 1 < p.length; i += 2) {
|
|
56
|
+
const d = pointSegmentDist(
|
|
57
|
+
px,
|
|
58
|
+
py,
|
|
59
|
+
p[i - 2] as number,
|
|
60
|
+
p[i - 1] as number,
|
|
61
|
+
p[i] as number,
|
|
62
|
+
p[i + 1] as number
|
|
63
|
+
);
|
|
64
|
+
if (d <= t) return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
},
|
|
68
|
+
translate(el, dx, dy) {
|
|
69
|
+
return { points: pts(el).map((v, i) => (i % 2 === 0 ? v + dx : v + dy)) };
|
|
70
|
+
},
|
|
71
|
+
resize(el, box) {
|
|
72
|
+
const p = pts(el);
|
|
73
|
+
const cur = penBounds(p);
|
|
74
|
+
if (!cur) return {};
|
|
75
|
+
const sx = cur.w > 0 ? box.w / cur.w : 1;
|
|
76
|
+
const sy = cur.h > 0 ? box.h / cur.h : 1;
|
|
77
|
+
return {
|
|
78
|
+
points: p.map((v, i) => (i % 2 === 0 ? box.x + (v - cur.x) * sx : box.y + (v - cur.y) * sy)),
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
meaningful: (el) => pts(el).length >= 4,
|
|
82
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section (DDR-242) — a labelled container. Children name it as `parent` and
|
|
3
|
+
* are positioned RELATIVE to its origin, so moving a section is one write and
|
|
4
|
+
* nudge / align / duplicate / delete act on it as a subtree. Its interior is
|
|
5
|
+
* click-through: only the border band and the title chip are hittable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_SECTION_COLOR,
|
|
10
|
+
SECTION_LABEL_FONT,
|
|
11
|
+
SECTION_LABEL_H,
|
|
12
|
+
SECTION_MIN_SIZE,
|
|
13
|
+
} from '../constants.ts';
|
|
14
|
+
import { color, str } from '../fields.ts';
|
|
15
|
+
import type { AnnotationElement, Box, ElementDef } from '../types.ts';
|
|
16
|
+
import { BOX_FIELDS, boxOf, resizeBox, translateBox } from './_shared.ts';
|
|
17
|
+
|
|
18
|
+
const FIELDS = {
|
|
19
|
+
...BOX_FIELDS,
|
|
20
|
+
label: str({ max: 200, plain: true, def: 'Section', allowEmpty: true }),
|
|
21
|
+
color: color(DEFAULT_SECTION_COLOR),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The title chip's box in WORLD units, given the current zoom. The chip is
|
|
26
|
+
* drawn at a constant SCREEN size, so its world size is screen size / zoom —
|
|
27
|
+
* the ONE definition every hit-test, eraser and connector uses (v1 had three
|
|
28
|
+
* drifting copies, see the plan's Problem §5).
|
|
29
|
+
*/
|
|
30
|
+
export function sectionChipBox(el: AnnotationElement, zoom = 1): Box {
|
|
31
|
+
const b = boxOf(el);
|
|
32
|
+
const label = typeof el.label === 'string' ? el.label : 'Section';
|
|
33
|
+
const z = zoom > 0 ? zoom : 1;
|
|
34
|
+
const w = Math.max(56, label.length * SECTION_LABEL_FONT * 0.62 + 18) / z;
|
|
35
|
+
const h = (SECTION_LABEL_H + 6) / z;
|
|
36
|
+
return { x: b.x, y: b.y - h, w, h };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const section: ElementDef = {
|
|
40
|
+
type: 'section',
|
|
41
|
+
fields: FIELDS,
|
|
42
|
+
caps: {
|
|
43
|
+
box: true,
|
|
44
|
+
rotatable: false,
|
|
45
|
+
resizable: true,
|
|
46
|
+
bindable: true,
|
|
47
|
+
container: true,
|
|
48
|
+
textSlot: 'title',
|
|
49
|
+
},
|
|
50
|
+
bounds: (el) => boxOf(el),
|
|
51
|
+
hitTest(el, px, py, tol) {
|
|
52
|
+
const b = boxOf(el);
|
|
53
|
+
const chip = sectionChipBox(el);
|
|
54
|
+
if (px >= chip.x && px <= chip.x + chip.w && py >= chip.y && py <= b.y) return true;
|
|
55
|
+
const t = Math.max(tol, 8);
|
|
56
|
+
if (px < b.x - t || px > b.x + b.w + t || py < b.y - t || py > b.y + b.h + t) return false;
|
|
57
|
+
return (
|
|
58
|
+
Math.abs(px - b.x) <= t ||
|
|
59
|
+
Math.abs(px - (b.x + b.w)) <= t ||
|
|
60
|
+
Math.abs(py - b.y) <= t ||
|
|
61
|
+
Math.abs(py - (b.y + b.h)) <= t
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
translate: translateBox,
|
|
65
|
+
resize: resizeBox,
|
|
66
|
+
meaningful: (el) => {
|
|
67
|
+
const b = boxOf(el);
|
|
68
|
+
return b.w >= SECTION_MIN_SIZE && b.h >= SECTION_MIN_SIZE;
|
|
69
|
+
},
|
|
70
|
+
};
|