@1agh/maude 0.58.1 → 0.58.3
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/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +129 -23
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/client/styles/6-acp-chat.css +12 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1491 -1491
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +306 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +755 -32
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-canvas-path.test.ts +200 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-path-pull.test.ts +465 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +434 -1
- package/apps/studio/test/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file figma/to-strokes.ts — FigJam board → the whiteboard Stroke model.
|
|
3
|
+
* @scope apps/studio/figma/to-strokes.ts
|
|
4
|
+
* @purpose The flagship mapping: a FigJam document (normalized by
|
|
5
|
+
* `types.ts`) becomes `Stroke[]` that `strokesToSvg` serializes into
|
|
6
|
+
* `<slug>.annotations.svg`. Maude's whiteboard vocabulary is a close
|
|
7
|
+
* match for FigJam's primitives, and this is the piece no competitor
|
|
8
|
+
* ships.
|
|
9
|
+
*
|
|
10
|
+
* @invariant IMPORT THE CANONICAL MODEL, NEVER HAND-WRITE SVG. Same discipline
|
|
11
|
+
* as `annotate.mjs`: every stroke goes through the real `Stroke`
|
|
12
|
+
* types and the real serializer, so this translator can never emit
|
|
13
|
+
* a shape the canvas wouldn't accept.
|
|
14
|
+
*
|
|
15
|
+
* @invariant THE OUTPUT IS A VERSIONED, PEER-SYNCED, AGENT-READ ARTIFACT.
|
|
16
|
+
* `*.annotations.svg` is VERSIONED (DDR-115), commits and syncs
|
|
17
|
+
* (DDR-054), and `maude design read-annotations` parses it into JSON
|
|
18
|
+
* EXPRESSLY to put in a model's context. DDR-216 D1 calls this the
|
|
19
|
+
* sharpest consumption sink in the feature. So every string here
|
|
20
|
+
* goes through `sanitize.ts` (D6a character classes + D6b
|
|
21
|
+
* normalization) — and residual 1 still applies: none of that stops
|
|
22
|
+
* an instruction from reading like an instruction.
|
|
23
|
+
*
|
|
24
|
+
* @invariant DEPENDENCY-FREE beyond the model. No fs, no network — the caller
|
|
25
|
+
* (`_import-figma.mjs`) owns writes and asset downloads.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
type ArrowBind,
|
|
30
|
+
type ArrowStroke,
|
|
31
|
+
DEFAULT_SECTION_COLOR,
|
|
32
|
+
type EllipseStroke,
|
|
33
|
+
type ImageStroke,
|
|
34
|
+
type PolygonShape,
|
|
35
|
+
type PolygonStroke,
|
|
36
|
+
type RectStroke,
|
|
37
|
+
type SectionStroke,
|
|
38
|
+
STICKY_PALETTE,
|
|
39
|
+
type StickyStroke,
|
|
40
|
+
type Stroke,
|
|
41
|
+
type TextStroke,
|
|
42
|
+
} from '../annotations-model.ts';
|
|
43
|
+
import {
|
|
44
|
+
attrValue,
|
|
45
|
+
type Bounds,
|
|
46
|
+
clampIntoBounds,
|
|
47
|
+
cleanText,
|
|
48
|
+
ensureContrast,
|
|
49
|
+
ensureFontSize,
|
|
50
|
+
hexToRgb01,
|
|
51
|
+
ImportReport,
|
|
52
|
+
rgb01ToHex,
|
|
53
|
+
} from './sanitize.ts';
|
|
54
|
+
import type { FigmaColor, FigmaNode, NormalizedDocument } from './types.ts';
|
|
55
|
+
|
|
56
|
+
/** Per-sink text capacities. Overflow is truncated AND reported, never silent. */
|
|
57
|
+
const STICKY_TEXT_CAP = 1200;
|
|
58
|
+
const SHAPE_LABEL_CAP = 400;
|
|
59
|
+
const TEXT_CAP = 4000;
|
|
60
|
+
const SECTION_LABEL_CAP = 120;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Above this, a board import needs explicit confirmation.
|
|
64
|
+
*
|
|
65
|
+
* D6b's board-side control: on a board the payload does NOT have to hide. A
|
|
66
|
+
* 300-sticky workshop board is imported wholesale, no human reads all 300, and
|
|
67
|
+
* a fully visible sticky is completely effective — so no content rule helps.
|
|
68
|
+
* What helps is bounding how much arrives unreviewed in one gesture
|
|
69
|
+
* (post-implementation review F5: the DDR asserted this control and the code
|
|
70
|
+
* did not have it).
|
|
71
|
+
*/
|
|
72
|
+
export const BOARD_STROKE_CEILING = 250;
|
|
73
|
+
|
|
74
|
+
/** Stamped on every imported stroke so a reader can see where it came from. */
|
|
75
|
+
export const FIGMA_AUTHOR_NAME = 'imported-figma';
|
|
76
|
+
|
|
77
|
+
/** Ink default when a node carries no usable stroke colour. */
|
|
78
|
+
const DEFAULT_INK = '#1a1a1a';
|
|
79
|
+
const DEFAULT_STROKE_WIDTH = 2;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* FigJam `shapeType` → Maude primitive. The five that land natively plus the
|
|
83
|
+
* two triangles; everything else (parallelogram, the `ENG_*` engineering set,
|
|
84
|
+
* etc.) has NO Maude equivalent and is skipped AND REPORTED — never silently
|
|
85
|
+
* dropped, and never approximated into a shape that means something different.
|
|
86
|
+
*/
|
|
87
|
+
const SHAPE_MAP: Readonly<Record<string, 'rect' | 'ellipse' | PolygonShape>> = Object.assign(
|
|
88
|
+
Object.create(null),
|
|
89
|
+
{
|
|
90
|
+
SQUARE: 'rect',
|
|
91
|
+
ROUNDED_RECTANGLE: 'rect',
|
|
92
|
+
ELLIPSE: 'ellipse',
|
|
93
|
+
DIAMOND: 'diamond',
|
|
94
|
+
TRIANGLE_UP: 'triangle',
|
|
95
|
+
TRIANGLE_DOWN: 'triangle-down',
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/** FigJam connector caps → the `canvas-arrowheads` vocabulary. */
|
|
100
|
+
const CAP_MAP: Readonly<Record<string, 'none' | 'triangle' | 'line'>> = Object.assign(
|
|
101
|
+
Object.create(null),
|
|
102
|
+
{
|
|
103
|
+
NONE: 'none',
|
|
104
|
+
ARROW_LINES: 'line',
|
|
105
|
+
ARROW_EQUILATERAL: 'triangle',
|
|
106
|
+
TRIANGLE_FILLED: 'triangle',
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const LINE_TYPE_MAP: Readonly<Record<string, 'straight' | 'elbow' | 'curve'>> = Object.assign(
|
|
111
|
+
Object.create(null),
|
|
112
|
+
{
|
|
113
|
+
STRAIGHT: 'straight',
|
|
114
|
+
ELBOWED: 'elbow',
|
|
115
|
+
CURVED: 'curve',
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
export interface PendingImage {
|
|
120
|
+
/** The stroke whose `href` must be rewritten once the asset lands. */
|
|
121
|
+
strokeId: string;
|
|
122
|
+
nodeId: string;
|
|
123
|
+
/**
|
|
124
|
+
* Figma's image handle for a raster fill — resolved via `/v1/images`,
|
|
125
|
+
* downloaded by T8. `null` for loose vector artwork, which has no handle:
|
|
126
|
+
* there the NODE itself is what gets rendered.
|
|
127
|
+
*/
|
|
128
|
+
imageRef: string | null;
|
|
129
|
+
/**
|
|
130
|
+
* What to ask Figma for. Absent ⇒ `png` (the raster-fill case, unchanged).
|
|
131
|
+
* Vector artwork ALSO asks for png — `ASSET_IMAGE_HREF_RE` admits only raster
|
|
132
|
+
* on an `<image>`, and an svg href is silently stripped by the sanitizer
|
|
133
|
+
* rather than rejected loudly.
|
|
134
|
+
*/
|
|
135
|
+
format?: 'png' | 'svg';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ToStrokesResult {
|
|
139
|
+
strokes: Stroke[];
|
|
140
|
+
report: ImportReport;
|
|
141
|
+
/** Images the caller must resolve + download through `fetch-asset` (T8). */
|
|
142
|
+
pendingImages: PendingImage[];
|
|
143
|
+
/** The translation origin, so a caller can report what it shifted by. */
|
|
144
|
+
origin: { x: number; y: number };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function figmaColorToHex(c: FigmaColor | undefined): string | null {
|
|
148
|
+
if (!c) return null;
|
|
149
|
+
return rgb01ToHex({ r: c.r, g: c.g, b: c.b });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** First visible SOLID fill, as hex. */
|
|
153
|
+
function solidFillHex(node: FigmaNode): string | null {
|
|
154
|
+
for (const p of node.fills ?? []) {
|
|
155
|
+
if (!p.visible) continue;
|
|
156
|
+
if (p.type === 'SOLID') return figmaColorToHex(p.color);
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function solidStrokeHex(node: FigmaNode): string | null {
|
|
162
|
+
for (const p of node.strokes ?? []) {
|
|
163
|
+
if (!p.visible) continue;
|
|
164
|
+
if (p.type === 'SOLID') return figmaColorToHex(p.color);
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** First visible IMAGE fill's handle. */
|
|
170
|
+
function imageRef(node: FigmaNode): string | null {
|
|
171
|
+
for (const p of node.fills ?? []) {
|
|
172
|
+
if (!p.visible) continue;
|
|
173
|
+
if (p.type === 'IMAGE' && p.imageRef) return p.imageRef;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Snap an arbitrary sticky colour onto the nearest `STICKY_PALETTE` tint.
|
|
180
|
+
* `StickyStroke.color` is a free-form string so a raw hex WOULD round-trip —
|
|
181
|
+
* but a FigJam board's named tints (`STICKY_GRAY`, `…_UI3`, `CUSTOM`) should
|
|
182
|
+
* read as Maude paper, not as arbitrary ink.
|
|
183
|
+
*/
|
|
184
|
+
export function nearestStickyColor(hex: string | null): string {
|
|
185
|
+
const target = hex ? hexToRgb01(hex) : null;
|
|
186
|
+
if (!target) return STICKY_PALETTE[0];
|
|
187
|
+
let best = STICKY_PALETTE[0];
|
|
188
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
189
|
+
for (const candidate of STICKY_PALETTE) {
|
|
190
|
+
const c = hexToRgb01(candidate);
|
|
191
|
+
if (!c) continue;
|
|
192
|
+
const d = (c.r - target.r) ** 2 + (c.g - target.g) ** 2 + (c.b - target.b) ** 2;
|
|
193
|
+
if (d < bestDist) {
|
|
194
|
+
bestDist = d;
|
|
195
|
+
best = candidate;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return best;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** World bounds of every node carrying geometry — the translation origin. */
|
|
202
|
+
function documentBounds(root: FigmaNode): Bounds {
|
|
203
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
204
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
205
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
206
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
207
|
+
const visit = (n: FigmaNode) => {
|
|
208
|
+
const b = n.absoluteBoundingBox;
|
|
209
|
+
if (b) {
|
|
210
|
+
minX = Math.min(minX, b.x);
|
|
211
|
+
minY = Math.min(minY, b.y);
|
|
212
|
+
maxX = Math.max(maxX, b.x + b.width);
|
|
213
|
+
maxY = Math.max(maxY, b.y + b.height);
|
|
214
|
+
}
|
|
215
|
+
for (const c of n.children ?? []) visit(c);
|
|
216
|
+
};
|
|
217
|
+
visit(root);
|
|
218
|
+
if (!Number.isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
219
|
+
return { minX, minY, maxX, maxY };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let idCounter = 0;
|
|
223
|
+
/** Deterministic per-run stroke ids — derived from the NODE ID, never text. */
|
|
224
|
+
function strokeId(nodeId: string, suffix = ''): string {
|
|
225
|
+
idCounter += 1;
|
|
226
|
+
return `fig_${nodeId.replace(/[^0-9]+/g, '_')}${suffix ? `_${suffix}` : ''}_${idCounter}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface ToStrokesOptions {
|
|
230
|
+
/** Reset the id counter — tests want deterministic ids. */
|
|
231
|
+
resetIds?: boolean;
|
|
232
|
+
/**
|
|
233
|
+
* Shift by THIS origin instead of the document's own bounding box.
|
|
234
|
+
*
|
|
235
|
+
* A design page's annotation layer has to line up with the artboards the
|
|
236
|
+
* canvas already positioned, and those were placed against the PAGE origin —
|
|
237
|
+
* which includes the frames this call does not see. Without the override the
|
|
238
|
+
* strokes get their own origin and every note lands offset by the distance
|
|
239
|
+
* between the two.
|
|
240
|
+
*/
|
|
241
|
+
originOverride?: { x: number; y: number };
|
|
242
|
+
/** Caller confirmed a board above `BOARD_STROKE_CEILING`. */
|
|
243
|
+
confirmLarge?: boolean;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Thrown when a board exceeds the ceiling and the caller has not confirmed. */
|
|
247
|
+
export class BoardTooLargeError extends Error {
|
|
248
|
+
readonly strokeCount: number;
|
|
249
|
+
constructor(strokeCount: number) {
|
|
250
|
+
super(
|
|
251
|
+
`board translates to ${strokeCount} strokes (ceiling ${BOARD_STROKE_CEILING}) — re-run with --confirm-large to import it all`
|
|
252
|
+
);
|
|
253
|
+
this.name = 'BoardTooLargeError';
|
|
254
|
+
this.strokeCount = strokeCount;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Translate a normalized FigJam document into strokes.
|
|
260
|
+
*
|
|
261
|
+
* Coordinates: FigJam is absolute-canvas and a real board spans roughly
|
|
262
|
+
* 14 000 × 30 000 units starting deep in negative space (measured: x ≈ −3 244…
|
|
263
|
+
* +11 037, y ≈ −6 272…+23 488). An untranslated import lands tens of thousands
|
|
264
|
+
* of px off-screen, so everything is shifted by the document's own bounding-box
|
|
265
|
+
* origin. **Absolute SIZES are preserved** — FigJam's sticky default is 240×240
|
|
266
|
+
* against Maude's `STICKY_DEFAULT_W` 200, and normalising to Maude's default
|
|
267
|
+
* collapses every layout.
|
|
268
|
+
*/
|
|
269
|
+
export function toStrokes(doc: NormalizedDocument, opts: ToStrokesOptions = {}): ToStrokesResult {
|
|
270
|
+
if (opts.resetIds) idCounter = 0;
|
|
271
|
+
const report = new ImportReport();
|
|
272
|
+
const pendingImages: PendingImage[] = [];
|
|
273
|
+
const bounds = documentBounds(doc.root);
|
|
274
|
+
const origin = opts.originOverride ?? { x: bounds.minX, y: bounds.minY };
|
|
275
|
+
// Post-shift bounds, for D6b's geometry clamp.
|
|
276
|
+
const shifted: Bounds = {
|
|
277
|
+
minX: 0,
|
|
278
|
+
minY: 0,
|
|
279
|
+
maxX: bounds.maxX - bounds.minX,
|
|
280
|
+
maxY: bounds.maxY - bounds.minY,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const strokes: Stroke[] = [];
|
|
284
|
+
/** Figma node id → the stroke id it produced, for connector binding. */
|
|
285
|
+
const nodeToStroke = new Map<string, string>();
|
|
286
|
+
/** Figma node id → its shifted bbox, for the group-endpoint fallback. */
|
|
287
|
+
const nodeBBox = new Map<string, { x: number; y: number; w: number; h: number }>();
|
|
288
|
+
/** Connectors are resolved in a second pass — their hosts must exist first. */
|
|
289
|
+
const connectors: Array<{ node: FigmaNode; groupIds: string[] }> = [];
|
|
290
|
+
|
|
291
|
+
const shift = (x: number, y: number) => ({ x: x - origin.x, y: y - origin.y });
|
|
292
|
+
|
|
293
|
+
const emitText = (
|
|
294
|
+
node: FigmaNode,
|
|
295
|
+
groupIds: string[],
|
|
296
|
+
box: { x: number; y: number; w: number; h: number }
|
|
297
|
+
): void => {
|
|
298
|
+
const raw = node.characters ?? node.name;
|
|
299
|
+
const cleaned = cleanText(raw, TEXT_CAP);
|
|
300
|
+
if (cleaned.strippedHidden) report.add(node.id, node.type, 'hidden-chars-dropped');
|
|
301
|
+
if (cleaned.truncated) report.add(node.id, node.type, 'truncated-text');
|
|
302
|
+
if (!cleaned.text.trim()) {
|
|
303
|
+
report.add(node.id, node.type, 'hidden-node-skipped', 'empty after sanitize');
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const size = ensureFontSize(node.style?.fontSize ?? 16);
|
|
307
|
+
if (size.changed) report.add(node.id, node.type, 'text-normalized', 'font-size floor');
|
|
308
|
+
// Contrast is against the board paper, which is what a standalone FigJam
|
|
309
|
+
// text sits on — there is no parent fill to resolve.
|
|
310
|
+
const ink = ensureContrast(solidFillHex(node) ?? DEFAULT_INK, '#ffffff');
|
|
311
|
+
if (ink.changed) report.add(node.id, node.type, 'text-normalized', 'contrast floor');
|
|
312
|
+
const id = strokeId(node.id);
|
|
313
|
+
const stroke: TextStroke = {
|
|
314
|
+
id,
|
|
315
|
+
tool: 'text',
|
|
316
|
+
color: ink.hex,
|
|
317
|
+
fontSize: size.size,
|
|
318
|
+
text: cleaned.text,
|
|
319
|
+
x: box.x,
|
|
320
|
+
y: box.y,
|
|
321
|
+
...(groupIds.length ? { groupIds } : {}),
|
|
322
|
+
...(node.rotation ? { rotation: -node.rotation } : {}),
|
|
323
|
+
};
|
|
324
|
+
strokes.push(stroke);
|
|
325
|
+
nodeToStroke.set(node.id, id);
|
|
326
|
+
report.add(node.id, node.type, 'imported');
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const visit = (node: FigmaNode, groupIds: string[], silent = false): void => {
|
|
330
|
+
// D6b: an explicitly hidden node is not emitted at all.
|
|
331
|
+
if (!node.visible) {
|
|
332
|
+
if (!silent) report.add(node.id, node.type, 'hidden-node-skipped', 'visible:false');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const bb = node.absoluteBoundingBox;
|
|
337
|
+
let box = { x: 0, y: 0, w: 0, h: 0 };
|
|
338
|
+
if (bb) {
|
|
339
|
+
const p = shift(bb.x, bb.y);
|
|
340
|
+
const clamped = clampIntoBounds(p.x, p.y, shifted);
|
|
341
|
+
if (clamped.changed) report.add(node.id, node.type, 'geometry-clamped');
|
|
342
|
+
box = { x: clamped.x, y: clamped.y, w: bb.width, h: bb.height };
|
|
343
|
+
nodeBBox.set(node.id, box);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
switch (node.type) {
|
|
347
|
+
case 'DOCUMENT':
|
|
348
|
+
case 'CANVAS':
|
|
349
|
+
for (const c of node.children ?? []) visit(c, groupIds, silent);
|
|
350
|
+
return;
|
|
351
|
+
|
|
352
|
+
case 'GROUP':
|
|
353
|
+
case 'FRAME': {
|
|
354
|
+
// A FigJam group becomes a flat `groupIds[]` TAG on its members — the
|
|
355
|
+
// Excalidraw tag model. Deepest-first, so nested groups nest correctly.
|
|
356
|
+
const gid = strokeId(node.id, 'g');
|
|
357
|
+
const nextGroups = [gid, ...groupIds];
|
|
358
|
+
for (const c of node.children ?? []) visit(c, nextGroups, silent);
|
|
359
|
+
report.add(node.id, node.type, 'imported', 'group → groupIds tag');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
case 'SECTION': {
|
|
364
|
+
// SectionStroke is FLAT — no parent field. Nesting survives as
|
|
365
|
+
// GEOMETRIC CONTAINMENT (dragging carries every stroke whose centre is
|
|
366
|
+
// inside), which is how Maude sections already work. The sample board
|
|
367
|
+
// has a section with 12 child sections; that reads correctly here.
|
|
368
|
+
const label = cleanText(node.name, SECTION_LABEL_CAP);
|
|
369
|
+
if (label.strippedHidden) report.add(node.id, node.type, 'hidden-chars-dropped');
|
|
370
|
+
const id = strokeId(node.id);
|
|
371
|
+
const stroke: SectionStroke = {
|
|
372
|
+
id,
|
|
373
|
+
tool: 'section',
|
|
374
|
+
x: box.x,
|
|
375
|
+
y: box.y,
|
|
376
|
+
w: box.w,
|
|
377
|
+
h: box.h,
|
|
378
|
+
label: label.text,
|
|
379
|
+
color: solidFillHex(node) ?? DEFAULT_SECTION_COLOR,
|
|
380
|
+
...(groupIds.length ? { groupIds } : {}),
|
|
381
|
+
};
|
|
382
|
+
strokes.push(stroke);
|
|
383
|
+
nodeToStroke.set(node.id, id);
|
|
384
|
+
report.add(node.id, node.type, 'imported');
|
|
385
|
+
for (const c of node.children ?? []) visit(c, groupIds, silent);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
case 'STICKY': {
|
|
390
|
+
const body = cleanText(node.characters ?? '', STICKY_TEXT_CAP);
|
|
391
|
+
if (body.strippedHidden) report.add(node.id, node.type, 'hidden-chars-dropped');
|
|
392
|
+
if (body.truncated) report.add(node.id, node.type, 'truncated-text');
|
|
393
|
+
const paper = nearestStickyColor(solidFillHex(node));
|
|
394
|
+
const size = ensureFontSize(node.style?.fontSize ?? 14);
|
|
395
|
+
if (size.changed) report.add(node.id, node.type, 'text-normalized', 'font-size floor');
|
|
396
|
+
const id = strokeId(node.id);
|
|
397
|
+
const stroke: StickyStroke = {
|
|
398
|
+
id,
|
|
399
|
+
tool: 'sticky',
|
|
400
|
+
color: paper,
|
|
401
|
+
// Absolute geometry PRESERVED — FigJam's 240×240 default (and the
|
|
402
|
+
// 416×240 wide variant) must not collapse to Maude's 200.
|
|
403
|
+
x: box.x,
|
|
404
|
+
y: box.y,
|
|
405
|
+
w: box.w || 240,
|
|
406
|
+
h: box.h || 240,
|
|
407
|
+
text: body.text,
|
|
408
|
+
fontSize: size.size,
|
|
409
|
+
...(groupIds.length ? { groupIds } : {}),
|
|
410
|
+
...(node.rotation ? { rotation: -node.rotation } : {}),
|
|
411
|
+
};
|
|
412
|
+
strokes.push(stroke);
|
|
413
|
+
nodeToStroke.set(node.id, id);
|
|
414
|
+
report.add(node.id, node.type, 'imported');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
case 'SHAPE_WITH_TEXT': {
|
|
419
|
+
const kind = node.shapeType ? SHAPE_MAP[node.shapeType] : undefined;
|
|
420
|
+
if (!kind) {
|
|
421
|
+
report.add(node.id, node.type, 'unmappable-shape', node.shapeType ?? 'unknown');
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const ink = solidStrokeHex(node) ?? DEFAULT_INK;
|
|
425
|
+
const fill = solidFillHex(node);
|
|
426
|
+
const id = strokeId(node.id);
|
|
427
|
+
const base = {
|
|
428
|
+
id,
|
|
429
|
+
color: ink,
|
|
430
|
+
width: node.strokeWeight ?? DEFAULT_STROKE_WIDTH,
|
|
431
|
+
...(fill ? { fill } : {}),
|
|
432
|
+
...(groupIds.length ? { groupIds } : {}),
|
|
433
|
+
...(node.rotation ? { rotation: -node.rotation } : {}),
|
|
434
|
+
};
|
|
435
|
+
if (kind === 'ellipse') {
|
|
436
|
+
const stroke: EllipseStroke = {
|
|
437
|
+
...base,
|
|
438
|
+
tool: 'ellipse',
|
|
439
|
+
cx: box.x + box.w / 2,
|
|
440
|
+
cy: box.y + box.h / 2,
|
|
441
|
+
rx: box.w / 2,
|
|
442
|
+
ry: box.h / 2,
|
|
443
|
+
};
|
|
444
|
+
strokes.push(stroke);
|
|
445
|
+
} else if (kind === 'rect') {
|
|
446
|
+
const stroke: RectStroke = {
|
|
447
|
+
...base,
|
|
448
|
+
tool: 'rect',
|
|
449
|
+
x: box.x,
|
|
450
|
+
y: box.y,
|
|
451
|
+
w: box.w,
|
|
452
|
+
h: box.h,
|
|
453
|
+
...(node.cornerRadius ? { cornerRadius: node.cornerRadius } : {}),
|
|
454
|
+
};
|
|
455
|
+
strokes.push(stroke);
|
|
456
|
+
} else {
|
|
457
|
+
const stroke: PolygonStroke = {
|
|
458
|
+
...base,
|
|
459
|
+
tool: 'polygon',
|
|
460
|
+
shape: kind,
|
|
461
|
+
x: box.x,
|
|
462
|
+
y: box.y,
|
|
463
|
+
w: box.w,
|
|
464
|
+
h: box.h,
|
|
465
|
+
};
|
|
466
|
+
strokes.push(stroke);
|
|
467
|
+
}
|
|
468
|
+
nodeToStroke.set(node.id, id);
|
|
469
|
+
report.add(node.id, node.type, 'imported');
|
|
470
|
+
|
|
471
|
+
// The shape's label becomes ANCHORED text on it — the same shape a
|
|
472
|
+
// double-click produces natively.
|
|
473
|
+
const label = cleanText(node.characters ?? '', SHAPE_LABEL_CAP);
|
|
474
|
+
if (label.text.trim()) {
|
|
475
|
+
const size = ensureFontSize(node.style?.fontSize ?? 14);
|
|
476
|
+
const ink2 = ensureContrast(DEFAULT_INK, fill ?? '#ffffff');
|
|
477
|
+
if (ink2.changed) report.add(node.id, node.type, 'text-normalized', 'contrast floor');
|
|
478
|
+
strokes.push({
|
|
479
|
+
id: strokeId(node.id, 'label'),
|
|
480
|
+
tool: 'text',
|
|
481
|
+
color: ink2.hex,
|
|
482
|
+
fontSize: size.size,
|
|
483
|
+
text: label.text,
|
|
484
|
+
anchorId: id,
|
|
485
|
+
...(groupIds.length ? { groupIds } : {}),
|
|
486
|
+
} as TextStroke);
|
|
487
|
+
}
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
case 'TEXT':
|
|
492
|
+
emitText(node, groupIds, box);
|
|
493
|
+
return;
|
|
494
|
+
|
|
495
|
+
case 'CONNECTOR':
|
|
496
|
+
// Deferred — hosts must exist before endpoints can bind.
|
|
497
|
+
connectors.push({ node, groupIds });
|
|
498
|
+
return;
|
|
499
|
+
|
|
500
|
+
case 'VECTOR':
|
|
501
|
+
case 'LINE':
|
|
502
|
+
case 'STAR':
|
|
503
|
+
case 'REGULAR_POLYGON':
|
|
504
|
+
case 'BOOLEAN_OPERATION': {
|
|
505
|
+
// LOOSE VECTOR ARTWORK IS CONTENT, NOT NOISE.
|
|
506
|
+
//
|
|
507
|
+
// These used to fall through to `unmappable-type` and vanish. On the
|
|
508
|
+
// live StudyFi file that dropped the NINE red flow arrows drawn between
|
|
509
|
+
// the onboarding screens on Phase 0 — hand-drawn `VECTOR` nodes named
|
|
510
|
+
// "Arrow 35/37/38/…", not CONNECTORs, so the connector path never saw
|
|
511
|
+
// them. Side by side against Figma, the screens were right and the flow
|
|
512
|
+
// between them was simply gone.
|
|
513
|
+
//
|
|
514
|
+
// There is no stroke tool that reproduces an arbitrary path, and there
|
|
515
|
+
// does not need to be: the same renderer that draws the artboards draws
|
|
516
|
+
// these. Ask Figma for the node and place it as an image.
|
|
517
|
+
//
|
|
518
|
+
// RASTER, NOT VECTOR — and that is a security boundary, not a taste
|
|
519
|
+
// call. `ASSET_IMAGE_HREF_RE` admits only png/jpeg/webp/gif on an
|
|
520
|
+
// `<image>`, because an annotation SVG is PERSISTED AND SYNCED TO PEERS
|
|
521
|
+
// (DDR-054/060) and an `<image href="…svg">` pulls in a nested SVG
|
|
522
|
+
// document — a script-execution vector. Asking for svg here does not
|
|
523
|
+
// fail loudly: the sanitizer keeps the element and strips the href, so
|
|
524
|
+
// the arrow renders as nothing at all. Widening that allowlist to suit
|
|
525
|
+
// an importer would weaken every peer-synced board, so the import bends
|
|
526
|
+
// instead. The artboard renders stay svg — those are `<img src>` in a
|
|
527
|
+
// TSX canvas, a different surface with its own containment (D12).
|
|
528
|
+
// GEOMETRY COMES FROM THE RENDER BOUNDS, NOT THE GEOMETRIC BOX.
|
|
529
|
+
//
|
|
530
|
+
// A stroked path is drawn wider than its geometry. The nine Phase-0
|
|
531
|
+
// arrows are horizontal, so `absoluteBoundingBox.height` is 0.0001
|
|
532
|
+
// while `absoluteRenderBounds.height` is 22.09 (3px stroke plus the
|
|
533
|
+
// arrowhead). Placed at the geometric box the image is 121 × 0.00005
|
|
534
|
+
// px — present in the file, referenced correctly, and invisible.
|
|
535
|
+
const rb = node.absoluteRenderBounds;
|
|
536
|
+
const geo = rb ? { ...shift(rb.x, rb.y), w: rb.width, h: rb.height } : box;
|
|
537
|
+
const id = strokeId(node.id);
|
|
538
|
+
const stroke: ImageStroke = {
|
|
539
|
+
id,
|
|
540
|
+
tool: 'image',
|
|
541
|
+
x: geo.x,
|
|
542
|
+
y: geo.y,
|
|
543
|
+
w: Math.max(1, geo.w),
|
|
544
|
+
h: Math.max(1, geo.h),
|
|
545
|
+
href: '',
|
|
546
|
+
alt: attrValue(node.name) || 'vector',
|
|
547
|
+
...(groupIds.length ? { groupIds } : {}),
|
|
548
|
+
...(node.rotation ? { rotation: -node.rotation } : {}),
|
|
549
|
+
};
|
|
550
|
+
strokes.push(stroke);
|
|
551
|
+
nodeToStroke.set(node.id, id);
|
|
552
|
+
pendingImages.push({ strokeId: id, nodeId: node.id, imageRef: null, format: 'png' });
|
|
553
|
+
report.add(node.id, node.type, 'asset-pending');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
case 'RECTANGLE':
|
|
558
|
+
case 'ROUNDED_RECTANGLE':
|
|
559
|
+
case 'ELLIPSE': {
|
|
560
|
+
// A plain shape with an image fill is the board's picture case.
|
|
561
|
+
const ref = imageRef(node);
|
|
562
|
+
if (ref) {
|
|
563
|
+
const id = strokeId(node.id);
|
|
564
|
+
const stroke: ImageStroke = {
|
|
565
|
+
id,
|
|
566
|
+
tool: 'image',
|
|
567
|
+
x: box.x,
|
|
568
|
+
y: box.y,
|
|
569
|
+
w: box.w,
|
|
570
|
+
h: box.h,
|
|
571
|
+
// Rewritten by the caller once `fetch-asset` lands the bytes. Until
|
|
572
|
+
// then it is a placeholder, never an external URL — a hotlink is
|
|
573
|
+
// CSP-blocked in the canvas anyway (DDR-216 D4).
|
|
574
|
+
href: '',
|
|
575
|
+
...(groupIds.length ? { groupIds } : {}),
|
|
576
|
+
...(node.rotation ? { rotation: -node.rotation } : {}),
|
|
577
|
+
};
|
|
578
|
+
strokes.push(stroke);
|
|
579
|
+
nodeToStroke.set(node.id, id);
|
|
580
|
+
pendingImages.push({ strokeId: id, nodeId: node.id, imageRef: ref });
|
|
581
|
+
report.add(node.id, node.type, 'asset-pending');
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
// Otherwise it is an ordinary geometric shape.
|
|
585
|
+
const ink = solidStrokeHex(node) ?? DEFAULT_INK;
|
|
586
|
+
const fill = solidFillHex(node);
|
|
587
|
+
const id = strokeId(node.id);
|
|
588
|
+
if (node.type === 'ELLIPSE') {
|
|
589
|
+
strokes.push({
|
|
590
|
+
id,
|
|
591
|
+
tool: 'ellipse',
|
|
592
|
+
color: ink,
|
|
593
|
+
width: node.strokeWeight ?? DEFAULT_STROKE_WIDTH,
|
|
594
|
+
cx: box.x + box.w / 2,
|
|
595
|
+
cy: box.y + box.h / 2,
|
|
596
|
+
rx: box.w / 2,
|
|
597
|
+
ry: box.h / 2,
|
|
598
|
+
...(fill ? { fill } : {}),
|
|
599
|
+
...(groupIds.length ? { groupIds } : {}),
|
|
600
|
+
} as EllipseStroke);
|
|
601
|
+
} else {
|
|
602
|
+
strokes.push({
|
|
603
|
+
id,
|
|
604
|
+
tool: 'rect',
|
|
605
|
+
color: ink,
|
|
606
|
+
width: node.strokeWeight ?? DEFAULT_STROKE_WIDTH,
|
|
607
|
+
x: box.x,
|
|
608
|
+
y: box.y,
|
|
609
|
+
w: box.w,
|
|
610
|
+
h: box.h,
|
|
611
|
+
...(fill ? { fill } : {}),
|
|
612
|
+
...(node.cornerRadius ? { cornerRadius: node.cornerRadius } : {}),
|
|
613
|
+
...(groupIds.length ? { groupIds } : {}),
|
|
614
|
+
} as RectStroke);
|
|
615
|
+
}
|
|
616
|
+
nodeToStroke.set(node.id, id);
|
|
617
|
+
report.add(node.id, node.type, 'imported');
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
default: {
|
|
622
|
+
// WIDGET, STAMP, TABLE, CODE_BLOCK, EMBED, LINK_UNFURL, MEDIA, and a
|
|
623
|
+
// FigJam sticker (an INSTANCE) … have no Maude equivalent. Skipped AND
|
|
624
|
+
// REPORTED — the summary is what makes "never silently dropped" true.
|
|
625
|
+
//
|
|
626
|
+
// But report the SUBTREE ONCE, not every leaf. Measured on a real retro
|
|
627
|
+
// board: 16 stickers carried 136 VECTOR children, so per-leaf reporting
|
|
628
|
+
// produced 152 lines of noise around 102 stickies that actually
|
|
629
|
+
// mattered. A summary nobody reads is not an honesty mechanism, and
|
|
630
|
+
// "this sticker didn't come through" is the fact — its 9 internal paths
|
|
631
|
+
// are not.
|
|
632
|
+
let descendants = 0;
|
|
633
|
+
const count = (n: FigmaNode) => {
|
|
634
|
+
for (const c of n.children ?? []) {
|
|
635
|
+
descendants += 1;
|
|
636
|
+
count(c);
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
count(node);
|
|
640
|
+
if (!silent) {
|
|
641
|
+
report.add(
|
|
642
|
+
node.id,
|
|
643
|
+
node.type,
|
|
644
|
+
'unmappable-type',
|
|
645
|
+
descendants > 0 ? `+${descendants} nested` : undefined
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
// STILL RECURSE. Quieting the report must not quiet the IMPORT: a
|
|
649
|
+
// sticker's subtree can hold a real photo, and on the first live board
|
|
650
|
+
// an earlier version of this collapse silently dropped 2 images and 6
|
|
651
|
+
// ellipses along with the 136 vector leaves it was meant to stop
|
|
652
|
+
// listing. Losing content is a worse failure than a noisy summary, and
|
|
653
|
+
// it is the harder one to notice. `silent` suppresses the per-descendant
|
|
654
|
+
// REPORT only.
|
|
655
|
+
for (const c of node.children ?? []) visit(c, groupIds, true);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
visit(doc.root, []);
|
|
662
|
+
|
|
663
|
+
// PROVENANCE (review F5). Every imported stroke carries an author marker, so
|
|
664
|
+
// `read-annotations` — which parses this file expressly to put it in a
|
|
665
|
+
// model's context — can tell "the user drew this" from "a third party's Figma
|
|
666
|
+
// file did". Deliberately NOT `author: 'ai'`: the whiteboard trust model says
|
|
667
|
+
// in as many words that `author:'ai'` is not a trust signal, and the blank
|
|
668
|
+
// human default is exactly what this needs to stop looking like.
|
|
669
|
+
for (const stroke of strokes) {
|
|
670
|
+
stroke.authorName = FIGMA_AUTHOR_NAME;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ── Connector pass ────────────────────────────────────────────────────────
|
|
674
|
+
const strokeById = new Map(strokes.map((s) => [s.id, s]));
|
|
675
|
+
for (const { node, groupIds } of connectors) {
|
|
676
|
+
const startNode = node.connectorStart;
|
|
677
|
+
const endNode = node.connectorEnd;
|
|
678
|
+
|
|
679
|
+
// A degenerate self-connector (start id == end id) was observed on the real
|
|
680
|
+
// board. Emitting it as a bound arrow yields a zero-length shape that reads
|
|
681
|
+
// as a rendering bug; report and skip.
|
|
682
|
+
if (startNode && endNode && startNode === endNode) {
|
|
683
|
+
report.add(node.id, node.type, 'bind-dropped-self-connector');
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const resolve = (
|
|
688
|
+
figmaId: string | undefined
|
|
689
|
+
): { bind?: ArrowBind; point: [number, number] } | null => {
|
|
690
|
+
if (!figmaId) return null;
|
|
691
|
+
const sid = nodeToStroke.get(figmaId);
|
|
692
|
+
const bbox = nodeBBox.get(figmaId);
|
|
693
|
+
if (sid) {
|
|
694
|
+
const host = strokeById.get(sid);
|
|
695
|
+
if (host) {
|
|
696
|
+
// `isBindable` decides — widened to text + section by DDR-216 D9, and
|
|
697
|
+
// deliberately still excluding groups (Maude groups are tags, not
|
|
698
|
+
// addressable objects) and anchored text (no resolvable bbox here).
|
|
699
|
+
const b = bbox ?? { x: 0, y: 0, w: 0, h: 0 };
|
|
700
|
+
return {
|
|
701
|
+
bind: { hostId: sid, nx: 0.5, ny: 0.5 },
|
|
702
|
+
point: [b.x + b.w / 2, b.y + b.h / 2],
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (bbox) {
|
|
707
|
+
// A GROUP-targeted endpoint: fall back to the group's geometric bbox,
|
|
708
|
+
// unbound, and report the degradation. Deliberately does NOT invent a
|
|
709
|
+
// group stroke — that would add an addressable object to a model which
|
|
710
|
+
// by design has none (DDR-216 D9).
|
|
711
|
+
report.add(node.id, node.type, 'bind-degraded-to-bbox', 'group endpoint');
|
|
712
|
+
return { point: [bbox.x + bbox.w / 2, bbox.y + bbox.h / 2] };
|
|
713
|
+
}
|
|
714
|
+
return null;
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const from = resolve(startNode);
|
|
718
|
+
const to = resolve(endNode);
|
|
719
|
+
const bb = node.absoluteBoundingBox;
|
|
720
|
+
const fallback = bb ? shift(bb.x, bb.y) : { x: 0, y: 0 };
|
|
721
|
+
const p1 = from?.point ?? [fallback.x, fallback.y];
|
|
722
|
+
const p2 = to?.point ?? [fallback.x + (bb?.width ?? 100), fallback.y + (bb?.height ?? 0)];
|
|
723
|
+
|
|
724
|
+
const arrow: ArrowStroke = {
|
|
725
|
+
id: strokeId(node.id),
|
|
726
|
+
tool: 'arrow',
|
|
727
|
+
color: solidStrokeHex(node) ?? DEFAULT_INK,
|
|
728
|
+
width: node.strokeWeight ?? DEFAULT_STROKE_WIDTH,
|
|
729
|
+
x1: p1[0],
|
|
730
|
+
y1: p1[1],
|
|
731
|
+
x2: p2[0],
|
|
732
|
+
y2: p2[1],
|
|
733
|
+
startHead: CAP_MAP[node.connectorStartCap ?? 'NONE'] ?? 'none',
|
|
734
|
+
endHead: CAP_MAP[node.connectorEndCap ?? 'NONE'] ?? 'none',
|
|
735
|
+
lineType: LINE_TYPE_MAP[node.connectorLineType ?? 'STRAIGHT'] ?? 'straight',
|
|
736
|
+
...(from?.bind ? { startBind: from.bind } : {}),
|
|
737
|
+
...(to?.bind ? { endBind: to.bind } : {}),
|
|
738
|
+
...(groupIds.length ? { groupIds } : {}),
|
|
739
|
+
};
|
|
740
|
+
strokes.push(arrow);
|
|
741
|
+
report.add(node.id, node.type, 'imported');
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (strokes.length > BOARD_STROKE_CEILING && !opts.confirmLarge) {
|
|
745
|
+
throw new BoardTooLargeError(strokes.length);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return { strokes, report, pendingImages, origin };
|
|
749
|
+
}
|