@mocanvas/mocanvas 1.0.0 → 4.0.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.
@@ -0,0 +1,3370 @@
1
+ import { tipTapDefaultExtensions, richTextToHtml, isRichText, richTextToText, getRichTextEditorFactory, applyPlainTextToRichText, propsOf, readNumber, readText, readRichText, readStyle, readEnum, readPoint, arrowShapeMigrations, arrowShapeProps, toRichText, svgPath, frameShapeProps, frameShapeMigrations, readString, rectPath, readBoolean, geoShapeProps, noteShapeProps, noteShapeMigrations, richTextToBlocks, textShapeProps, textShapeMigrations, videoShapeProps, videoShapeMigrations, safeHref, getBookmarkCard, getBookmarkLayout, BOOKMARK_STROKE_WIDTH, BOOKMARK_STROKE, BOOKMARK_FILL, BOOKMARK_RADIUS, BOOKMARK_BANNER_FILL, BOOKMARK_TITLE_FONT_SIZE, BOOKMARK_TITLE_COLOR, BOOKMARK_META_COLOR, BOOKMARK_TEXT_FONT_SIZE, BOOKMARK_TEXT_COLOR, BOOKMARK_META_FONT_SIZE, getEmbedDefinition, EMBED_PLACEHOLDER_STROKE, EMBED_PLACEHOLDER_FILL, EMBED_RADIUS, EMBED_PLACEHOLDER_PADDING, EMBED_PLACEHOLDER_FONT_SIZE, EMBED_PLACEHOLDER_TEXT } from './chunk-OCYJAMXT.js';
2
+ import { getGeoGeometry, arcToCubicSegments, pathWordsToSvgD } from './chunk-OMUOD53T.js';
3
+ import { DEFAULT_THEME, GEO_SHAPE_KINDS, createBuiltInShapePropsMigrationIds, createShapePropsMigrationSequence, ARROW_SHAPE_KINDS, Vec, Polyline2d, CubicSpline2d, Polygon2d, Box, Group2d, STROKE_SIZES, hexToRgba, getColorValue, DEFAULT_FILL_TOKENS, DefaultFontFaces, useEditor, DefaultFontStyle, ARROWHEAD_KINDS, DefaultSizeStyle, DefaultDashStyle, DefaultFillStyle, DefaultLabelColorStyle, DefaultColorStyle, getDefaultDisplayValues, ShapeUtil, FONT_SIZES, Rectangle2d, getDisplayValues, BaseFrameLikeShapeUtil, DefaultVerticalAlignStyle, DefaultHorizontalAlignStyle, GeoShapeGeoStyle, BaseBoxShapeUtil, GEO_KIND, LIGHT_THEME, PATH_OP } from '@mocanvas/editor';
4
+ import { renderToStaticMarkup } from 'react-dom/server';
5
+ import { useMemo, useRef, useState, useCallback, useEffect, useLayoutEffect } from 'react';
6
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
7
+
8
+ var EPS = 1e-6;
9
+ function normalizeAngle(a) {
10
+ let r = a % (Math.PI * 2);
11
+ if (r > Math.PI) r -= Math.PI * 2;
12
+ if (r <= -Math.PI) r += Math.PI * 2;
13
+ return r;
14
+ }
15
+ function getArrowBody(start, end, bend) {
16
+ const s = Vec.From(start);
17
+ const e = Vec.From(end);
18
+ const chord = Vec.Sub(e, s);
19
+ const halfChord = Vec.Len(chord) / 2;
20
+ if (Math.abs(bend) < EPS || halfChord < EPS) return { kind: "straight", start: s, end: e };
21
+ const u = Vec.Uni(chord);
22
+ const n = Vec.Per(u);
23
+ const mid = Vec.Lrp(s, e, 0.5);
24
+ const b = Math.abs(bend);
25
+ const sign = Math.sign(bend);
26
+ const radius = (halfChord * halfChord + b * b) / (2 * b);
27
+ const center = Vec.Add(mid, Vec.Mul(n, bend - sign * radius));
28
+ const arcMid = Vec.Add(mid, Vec.Mul(n, bend));
29
+ const startAngle = Vec.Angle(center, s);
30
+ const endAngle = Vec.Angle(center, e);
31
+ let sweep = normalizeAngle(endAngle - startAngle);
32
+ const midAngle = startAngle + sweep / 2;
33
+ const candidate = new Vec(center.x + radius * Math.cos(midAngle), center.y + radius * Math.sin(midAngle));
34
+ if (Vec.Dist(candidate, arcMid) > 1e-3 * Math.max(1, radius)) {
35
+ sweep = sweep - Math.sign(sweep || 1) * Math.PI * 2;
36
+ }
37
+ return { kind: "arc", center, radius, startAngle, sweep };
38
+ }
39
+ function cumulativeLengths(points) {
40
+ const out = [0];
41
+ for (let i = 1; i < points.length; i++) out.push(out[i - 1] + Vec.Dist(points[i - 1], points[i]));
42
+ return out;
43
+ }
44
+ function segmentAt(lengths, distance) {
45
+ for (let i = 1; i < lengths.length; i++) {
46
+ if (distance <= lengths[i] || i === lengths.length - 1) return i - 1;
47
+ }
48
+ return 0;
49
+ }
50
+ function pointAtDistance(points, lengths, distance) {
51
+ if (points.length === 0) return new Vec();
52
+ if (points.length === 1) return points[0].clone();
53
+ const i = segmentAt(lengths, distance);
54
+ const segment = lengths[i + 1] - lengths[i];
55
+ const t = segment < EPS ? 0 : (distance - lengths[i]) / segment;
56
+ return Vec.Lrp(points[i], points[i + 1], Math.max(0, Math.min(1, t)));
57
+ }
58
+ function getBodyLength(body) {
59
+ if (body.kind === "straight") return Vec.Dist(body.start, body.end);
60
+ if (body.kind === "elbow") return cumulativeLengths(body.points).at(-1) ?? 0;
61
+ return Math.abs(body.sweep) * body.radius;
62
+ }
63
+ function getPointOnBody(body, t) {
64
+ if (body.kind === "straight") return Vec.Lrp(body.start, body.end, t);
65
+ if (body.kind === "elbow") {
66
+ const lengths = cumulativeLengths(body.points);
67
+ return pointAtDistance(body.points, lengths, (lengths.at(-1) ?? 0) * t);
68
+ }
69
+ const a = body.startAngle + body.sweep * t;
70
+ return new Vec(body.center.x + body.radius * Math.cos(a), body.center.y + body.radius * Math.sin(a));
71
+ }
72
+ function getTangentOnBody(body, t) {
73
+ if (body.kind === "straight") return Vec.Uni(Vec.Sub(body.end, body.start));
74
+ if (body.kind === "elbow") {
75
+ const points = body.points;
76
+ if (points.length < 2) return new Vec(1, 0);
77
+ const lengths = cumulativeLengths(points);
78
+ const i = segmentAt(lengths, (lengths.at(-1) ?? 0) * Math.max(0, Math.min(1, t)));
79
+ for (let step = 0; step < points.length; step++) {
80
+ for (const j of [i + step, i - step]) {
81
+ if (j < 0 || j >= points.length - 1) continue;
82
+ const d = Vec.Sub(points[j + 1], points[j]);
83
+ if (Vec.Len(d) > EPS) return Vec.Uni(d);
84
+ }
85
+ }
86
+ return new Vec(1, 0);
87
+ }
88
+ const a = body.startAngle + body.sweep * t;
89
+ const dir = Math.sign(body.sweep) || 1;
90
+ return new Vec(-Math.sin(a) * dir, Math.cos(a) * dir);
91
+ }
92
+ function shortenBody(body, startBy, endBy) {
93
+ const length = getBodyLength(body);
94
+ if (length < EPS) return body;
95
+ const total = startBy + endBy;
96
+ const budget = length * 0.9;
97
+ const scale = total > budget ? budget / total : 1;
98
+ const s = startBy * scale;
99
+ const e = endBy * scale;
100
+ if (body.kind === "straight") {
101
+ return { kind: "straight", start: getPointOnBody(body, s / length), end: getPointOnBody(body, 1 - e / length) };
102
+ }
103
+ if (body.kind === "elbow") {
104
+ const lengths = cumulativeLengths(body.points);
105
+ const from = s;
106
+ const to = length - e;
107
+ const points = [pointAtDistance(body.points, lengths, from)];
108
+ for (let i = 1; i < body.points.length - 1; i++) {
109
+ const at = lengths[i];
110
+ if (at > from + EPS && at < to - EPS) points.push(body.points[i].clone());
111
+ }
112
+ points.push(pointAtDistance(body.points, lengths, to));
113
+ return { kind: "elbow", points };
114
+ }
115
+ const dir = Math.sign(body.sweep) || 1;
116
+ const dStart = s / body.radius * dir;
117
+ const dEnd = e / body.radius * dir;
118
+ return {
119
+ kind: "arc",
120
+ center: body.center,
121
+ radius: body.radius,
122
+ startAngle: body.startAngle + dStart,
123
+ sweep: body.sweep - dStart - dEnd
124
+ };
125
+ }
126
+ function bodyToGeometry(body) {
127
+ if (body.kind === "straight") return new Polyline2d({ points: [body.start, body.end] });
128
+ if (body.kind === "elbow") return new Polyline2d({ points: body.points });
129
+ const count = Math.max(2, Math.ceil(Math.abs(body.sweep) / (Math.PI / 2)));
130
+ return new CubicSpline2d({
131
+ segments: arcToCubicSegments(body.center, body.radius, body.startAngle, body.sweep, count),
132
+ isClosed: false,
133
+ isFilled: false
134
+ });
135
+ }
136
+ function getArrowheadLength(strokeWidth, bodyLength) {
137
+ return Math.max(0, Math.min(Math.max(12, strokeWidth * 4), bodyLength / 2.5));
138
+ }
139
+ function getArrowheadInset(kind, length) {
140
+ switch (kind) {
141
+ case "triangle":
142
+ case "inverted":
143
+ case "diamond":
144
+ case "square":
145
+ case "dot":
146
+ return length;
147
+ case "arrow":
148
+ case "bar":
149
+ case "pipe":
150
+ case "none":
151
+ return 0;
152
+ }
153
+ }
154
+ var CHEVRON_HALF_WIDTH = Math.tan(Math.PI / 6);
155
+ function getArrowheadGeometry(kind, tip, dir, length) {
156
+ if (kind === "none" || length <= 0) return null;
157
+ const t = Vec.From(tip);
158
+ const d = Vec.From(dir);
159
+ const p = Vec.Per(d);
160
+ const back = Vec.Sub(t, Vec.Mul(d, length));
161
+ const wing = Vec.Mul(p, length * CHEVRON_HALF_WIDTH);
162
+ switch (kind) {
163
+ case "arrow":
164
+ return new Polyline2d({ points: [Vec.Add(back, wing), t, Vec.Sub(back, wing)] });
165
+ case "triangle":
166
+ return new Polygon2d({ points: [t, Vec.Add(back, wing), Vec.Sub(back, wing)], isFilled: true });
167
+ case "inverted":
168
+ return new Polygon2d({ points: [back, Vec.Add(t, wing), Vec.Sub(t, wing)], isFilled: true });
169
+ case "square": {
170
+ const half = Vec.Mul(p, length / 2);
171
+ return new Polygon2d({
172
+ points: [Vec.Add(t, half), Vec.Sub(t, half), Vec.Sub(back, half), Vec.Add(back, half)],
173
+ isFilled: true
174
+ });
175
+ }
176
+ case "diamond": {
177
+ const mid = Vec.Lrp(t, back, 0.5);
178
+ const half = Vec.Mul(p, length / 2);
179
+ return new Polygon2d({ points: [t, Vec.Add(mid, half), back, Vec.Sub(mid, half)], isFilled: true });
180
+ }
181
+ case "dot": {
182
+ const center = Vec.Lrp(t, back, 0.5);
183
+ return new CubicSpline2d({
184
+ segments: arcToCubicSegments(center, length / 2, 0, Math.PI * 2, 4),
185
+ isClosed: true,
186
+ isFilled: true
187
+ });
188
+ }
189
+ case "bar": {
190
+ const half = Vec.Mul(p, length / 2);
191
+ return new Polyline2d({ points: [Vec.Add(t, half), Vec.Sub(t, half)] });
192
+ }
193
+ case "pipe": {
194
+ const half = Vec.Mul(p, length * 0.35);
195
+ return new Polyline2d({ points: [Vec.Add(t, half), Vec.Sub(t, half)] });
196
+ }
197
+ }
198
+ }
199
+ function labelGapOnBody(body, box) {
200
+ const SAMPLES = 192;
201
+ const minX = box.x;
202
+ const maxX = box.x + box.w;
203
+ const minY = box.y;
204
+ const maxY = box.y + box.h;
205
+ let from = -1;
206
+ let to = -1;
207
+ for (let i = 0; i <= SAMPLES; i++) {
208
+ const t = i / SAMPLES;
209
+ const p = getPointOnBody(body, t);
210
+ if (p.x < minX || p.x > maxX || p.y < minY || p.y > maxY) continue;
211
+ if (from < 0) from = t;
212
+ to = t;
213
+ }
214
+ if (from < 0) return null;
215
+ const half = 0.5 / SAMPLES;
216
+ const gap = { from: Math.max(0, from - half), to: Math.min(1, to + half) };
217
+ if (gap.to - gap.from > 0.9) return null;
218
+ return gap;
219
+ }
220
+ function getBendFromPoint(start, end, point) {
221
+ const chord = Vec.Sub(end, start);
222
+ if (Vec.Len(chord) < EPS) return 0;
223
+ const n = Vec.Per(Vec.Uni(chord));
224
+ const mid = Vec.Lrp(start, end, 0.5);
225
+ const bend = Vec.Dot(Vec.Sub(point, mid), n);
226
+ return Math.abs(bend) < 1 ? 0 : bend;
227
+ }
228
+
229
+ // src/bindings/arrow-terminals.ts
230
+ function applyTransform(m, p) {
231
+ return new Vec(m.a * p.x + m.c * p.y + m.e, m.b * p.x + m.d * p.y + m.f);
232
+ }
233
+ function getArrowBindings(editor, arrow) {
234
+ const out = {};
235
+ for (const binding of editor.getBindingsFromShape(arrow, "arrow")) {
236
+ out[binding.props.terminal] = binding;
237
+ }
238
+ return out;
239
+ }
240
+ function getAnchorInShapeSpace(editor, shape, normalizedAnchor) {
241
+ const b = editor.getShapeGeometry(shape).bounds;
242
+ return new Vec(b.x + b.w * normalizedAnchor.x, b.y + b.h * normalizedAnchor.y);
243
+ }
244
+ function getArrowBindingTargetAtPoint(editor, arrow, pagePoint) {
245
+ const margin = editor.options.hitTestMargin / editor.getZoomLevel();
246
+ const ancestors = /* @__PURE__ */ new Set();
247
+ for (let p = editor.getShapeParent(arrow); p; p = editor.getShapeParent(p)) ancestors.add(p.id);
248
+ const shapes = editor.getCurrentPageShapesSorted();
249
+ for (let i = shapes.length - 1; i >= 0; i--) {
250
+ const shape = shapes[i];
251
+ if (shape.id === arrow.id || shape.type === "arrow" || shape.isLocked || ancestors.has(shape.id)) continue;
252
+ if (!editor.getShapeUtil(shape).canBind({ fromShape: arrow, toShape: shape, bindingType: "arrow" })) continue;
253
+ const bounds = editor.getShapePageBounds(shape);
254
+ if (!bounds || !Box.ContainsPoint(bounds, pagePoint, margin)) continue;
255
+ const local = editor.getPointInShapeSpace(shape, pagePoint);
256
+ if (editor.getShapeGeometry(shape).hitTestPoint(local, margin, true)) return shape;
257
+ }
258
+ return void 0;
259
+ }
260
+ function getNormalizedAnchor(editor, shape, pagePoint) {
261
+ const local = editor.getPointInShapeSpace(shape, pagePoint);
262
+ const b = editor.getShapeGeometry(shape).bounds;
263
+ const clamp = (v) => Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0.5;
264
+ return new Vec(clamp(b.w === 0 ? 0.5 : (local.x - b.x) / b.w), clamp(b.h === 0 ? 0.5 : (local.y - b.y) / b.h));
265
+ }
266
+ function getOutlineSegments(geometry) {
267
+ if (geometry.isLabel) return [];
268
+ if (geometry instanceof Group2d) return geometry.children.flatMap(getOutlineSegments);
269
+ const v = geometry.vertices;
270
+ const out = [];
271
+ if (v.length < 2) return out;
272
+ const n = geometry.isClosed ? v.length : v.length - 1;
273
+ for (let i = 0; i < n; i++) out.push([v[i], v[(i + 1) % v.length]]);
274
+ return out;
275
+ }
276
+ function intersectSegments(a1, a2, b1, b2) {
277
+ const rx = a2.x - a1.x;
278
+ const ry = a2.y - a1.y;
279
+ const sx = b2.x - b1.x;
280
+ const sy = b2.y - b1.y;
281
+ const denom = rx * sy - ry * sx;
282
+ if (Math.abs(denom) < 1e-12) return null;
283
+ const qx = b1.x - a1.x;
284
+ const qy = b1.y - a1.y;
285
+ const t = (qx * sy - qy * sx) / denom;
286
+ const u = (qx * ry - qy * rx) / denom;
287
+ if (t < 0 || t > 1 || u < 0 || u > 1) return null;
288
+ return new Vec(a1.x + rx * t, a1.y + ry * t);
289
+ }
290
+ function firstCrossing(path, segments) {
291
+ for (let i = 0; i < path.length - 1; i++) {
292
+ const p0 = path[i];
293
+ const p1 = path[i + 1];
294
+ let best = null;
295
+ let bestD = Infinity;
296
+ for (const [s0, s1] of segments) {
297
+ const hit = intersectSegments(p0, p1, s0, s1);
298
+ if (!hit) continue;
299
+ const d = Vec.Dist2(p0, hit);
300
+ if (d < bestD) {
301
+ bestD = d;
302
+ best = hit;
303
+ }
304
+ }
305
+ if (best) return best;
306
+ }
307
+ return null;
308
+ }
309
+ function sampleBody(from, to, bend, reverse) {
310
+ const body = reverse ? getArrowBody(to, from, bend) : getArrowBody(from, to, bend);
311
+ if (body.kind !== "arc") return [from, to];
312
+ const count = Math.max(8, Math.min(128, Math.ceil(Math.abs(body.sweep) * body.radius / 4)));
313
+ const pts = [];
314
+ for (let i = 0; i <= count; i++) {
315
+ const t = reverse ? 1 - i / count : i / count;
316
+ pts.push(getPointOnBody(body, t));
317
+ }
318
+ return pts;
319
+ }
320
+ function getBoundElbowAxes(editor, arrow, terminals) {
321
+ const bindings = getArrowBindings(editor, arrow);
322
+ const out = {};
323
+ for (const terminal of ["start", "end"]) {
324
+ const binding = bindings[terminal];
325
+ const shape = binding ? editor.getShape(binding.toId) : void 0;
326
+ if (!shape) continue;
327
+ const m = editor.getShapePageTransform(shape);
328
+ const corners = editor.getShapeGeometry(shape).bounds.corners.map((p2) => editor.getPointInShapeSpace(arrow, applyTransform(m, p2)));
329
+ const box = Box.FromPoints(corners);
330
+ if (box.w < 1e-6 && box.h < 1e-6) continue;
331
+ const p = terminals[terminal];
332
+ const dx = Math.min(Math.abs(p.x - box.minX), Math.abs(p.x - box.maxX));
333
+ const dy = Math.min(Math.abs(p.y - box.minY), Math.abs(p.y - box.maxY));
334
+ out[terminal] = dx <= dy ? "x" : "y";
335
+ }
336
+ return out;
337
+ }
338
+ var ARROW_TERMINAL_GAP_STROKES = 2.7;
339
+ function getArrowTerminalGap(size, scale = 1) {
340
+ return STROKE_SIZES[size] * scale * ARROW_TERMINAL_GAP_STROKES;
341
+ }
342
+ function pullBack(point, from, distance) {
343
+ const d = Vec.Dist(point, from);
344
+ if (d <= 1e-6) return point;
345
+ return Vec.Lrp(point, from, Math.min(distance, d) / d);
346
+ }
347
+ function getArrowTerminalsInArrowSpace(editor, arrow) {
348
+ const bindings = getArrowBindings(editor, arrow);
349
+ const startShape = bindings.start ? editor.getShape(bindings.start.toId) : void 0;
350
+ const endShape = bindings.end ? editor.getShape(bindings.end.toId) : void 0;
351
+ const anchorFor2 = (terminal, binding, shape) => {
352
+ if (!binding || !shape) return Vec.From(arrow.props[terminal]);
353
+ const local = getAnchorInShapeSpace(editor, shape, binding.props.normalizedAnchor);
354
+ const page = applyTransform(editor.getShapePageTransform(shape), local);
355
+ return editor.getPointInShapeSpace(arrow, page);
356
+ };
357
+ const startAnchor = anchorFor2("start", bindings.start, startShape);
358
+ const endAnchor = anchorFor2("end", bindings.end, endShape);
359
+ const outlineInArrowSpace = (shape) => {
360
+ const m = editor.getShapePageTransform(shape);
361
+ const toArrow = (p) => editor.getPointInShapeSpace(arrow, applyTransform(m, p));
362
+ return getOutlineSegments(editor.getShapeGeometry(shape)).map(([a, b]) => [toArrow(a), toArrow(b)]);
363
+ };
364
+ const snap = (terminal, binding, shape, anchor, other) => {
365
+ if (!binding || !shape || binding.props.isExact) return anchor;
366
+ if (Vec.Dist2(anchor, other) < 1e-12) return anchor;
367
+ const path = sampleBody(other, anchor, arrow.props.bend, terminal === "start");
368
+ const crossing = firstCrossing(path, outlineInArrowSpace(shape));
369
+ if (!crossing) return anchor;
370
+ return pullBack(crossing, other, getArrowTerminalGap(arrow.props.size, arrow.props.scale));
371
+ };
372
+ return {
373
+ start: snap("start", bindings.start, startShape, startAnchor, endAnchor),
374
+ end: snap("end", bindings.end, endShape, endAnchor, startAnchor)
375
+ };
376
+ }
377
+ var FALLBACK_THEME_COLORS = DEFAULT_THEME.colors.light;
378
+ function getTheme(source) {
379
+ return source?.getCurrentTheme?.() ?? DEFAULT_THEME;
380
+ }
381
+ function getThemeColors(source) {
382
+ const theme = getTheme(source);
383
+ const mode = source?.getColorMode?.() ?? "light";
384
+ return theme.colors[mode] ?? theme.colors.light;
385
+ }
386
+ function getStrokeRgba(color, colors = FALLBACK_THEME_COLORS) {
387
+ return hexToRgba(getColorValue(colors, color, "solid"));
388
+ }
389
+ function getFillRgba(color, fill, colors = FALLBACK_THEME_COLORS) {
390
+ const token = DEFAULT_FILL_TOKENS[fill] ?? "none";
391
+ if (token === "none") return 0;
392
+ if (token === "paper") return hexToRgba(colors.solid);
393
+ return hexToRgba(getColorValue(colors, color, token));
394
+ }
395
+ function getNoteFillRgba(color, colors = FALLBACK_THEME_COLORS) {
396
+ return hexToRgba(getColorValue(colors, color, "noteFill"));
397
+ }
398
+ function getTextCssColor(color, colors = FALLBACK_THEME_COLORS) {
399
+ return getColorValue(colors, color, "solid");
400
+ }
401
+ function getNoteTextCssColor(color, colors = FALLBACK_THEME_COLORS) {
402
+ return getColorValue(colors, color, "noteText");
403
+ }
404
+ function getFontFamily(font, theme = DEFAULT_THEME) {
405
+ return theme.fonts[font] ?? theme.fonts.draw;
406
+ }
407
+ function getLabelFontFaces(font) {
408
+ const set = DefaultFontFaces[`tldraw_${font}`];
409
+ if (set === void 0) return [];
410
+ const faces = [];
411
+ for (const byWeight of Object.values(set)) {
412
+ for (const face of Object.values(byWeight)) faces.push(face);
413
+ }
414
+ return faces;
415
+ }
416
+ function getDashId(dash) {
417
+ switch (dash) {
418
+ case "dashed":
419
+ return 1;
420
+ case "dotted":
421
+ return 2;
422
+ case "draw":
423
+ return 3;
424
+ default:
425
+ return 0;
426
+ }
427
+ }
428
+ var FRAME_FILL = "#ffffff";
429
+ var FRAME_STROKE = "#717171";
430
+ var FRAME_STROKE_WIDTH = 1;
431
+ var FRAME_NAME_COLOR = "#5f5f5f";
432
+ var FRAME_NAME_FONT_SIZE = 12;
433
+ var FRAME_NAME_OFFSET = 24;
434
+ var FRAME_NAME_GAP = 6;
435
+ var FRAME_NAME_HEIGHT = FRAME_NAME_OFFSET - FRAME_NAME_GAP;
436
+ var NOTE_GRADIENT_TOP_SCALE = 0.9785;
437
+ var NOTE_SHADOW_COLOR = "#152223";
438
+ var NOTE_SHADOW_OPACITY = 0.36;
439
+ var NOTE_SHADOW_OFFSET_Y = 12;
440
+ var NOTE_SHADOW_BLUR = 13;
441
+ var NOTE_SHADOW_SPREAD = -9;
442
+ function scaleHexColor(hex, factor) {
443
+ const n = Number.parseInt(hex.slice(1), 16);
444
+ const ch = (shift) => Math.max(0, Math.min(255, Math.round((n >> shift & 255) * factor)));
445
+ return `#${((ch(16) << 16 | ch(8) << 8 | ch(0)) >>> 0).toString(16).padStart(6, "0")}`;
446
+ }
447
+ function hexToCssRgba(hex, alpha) {
448
+ const n = Number.parseInt(hex.slice(1), 16);
449
+ return `rgba(${n >> 16 & 255}, ${n >> 8 & 255}, ${n & 255}, ${alpha})`;
450
+ }
451
+ function getNoteFillCssColor(color, colors = FALLBACK_THEME_COLORS) {
452
+ return getColorValue(colors, color, "noteFill");
453
+ }
454
+ function getNoteGradientTopFrom(fill) {
455
+ if (!/^#[0-9a-fA-F]{6}$/.test(fill)) return fill;
456
+ return scaleHexColor(fill, NOTE_GRADIENT_TOP_SCALE);
457
+ }
458
+ function getNoteGradientTopCssColor(color, colors = FALLBACK_THEME_COLORS) {
459
+ return getNoteGradientTopFrom(getNoteFillCssColor(color, colors));
460
+ }
461
+ function getNoteBodyGradientCss(color, colors = FALLBACK_THEME_COLORS) {
462
+ return `linear-gradient(to bottom, ${getNoteGradientTopCssColor(color, colors)} 0%, ${getNoteFillCssColor(color, colors)} 100%)`;
463
+ }
464
+ function getNoteShadowCss(scale = 1) {
465
+ const dy = NOTE_SHADOW_OFFSET_Y * scale;
466
+ const blur = NOTE_SHADOW_BLUR * scale;
467
+ const spread = NOTE_SHADOW_SPREAD * scale;
468
+ return `0 ${dy}px ${blur}px ${spread}px ${hexToCssRgba(NOTE_SHADOW_COLOR, NOTE_SHADOW_OPACITY)}`;
469
+ }
470
+ function getNoteShadowSvgRect(w, h, scale = 1) {
471
+ const inset = -NOTE_SHADOW_SPREAD * scale;
472
+ return {
473
+ x: inset,
474
+ y: NOTE_SHADOW_OFFSET_Y * scale + inset,
475
+ w: Math.max(0, w - inset * 2),
476
+ h: Math.max(0, h - inset * 2),
477
+ // CSS blur radius is twice the Gaussian standard deviation.
478
+ stdDeviation: NOTE_SHADOW_BLUR * scale / 2
479
+ };
480
+ }
481
+
482
+ // src/shapes/text-helpers.ts
483
+ var LINE_HEIGHT = 1.3;
484
+ var AVG_CHAR_WIDTH = 0.6;
485
+ function estimateTextSize(text, fontSize, maxWidth = Infinity) {
486
+ const charW = fontSize * AVG_CHAR_WIDTH;
487
+ const charsPerLine = Number.isFinite(maxWidth) && charW > 0 ? Math.max(1, Math.floor(maxWidth / charW)) : Infinity;
488
+ let lines = 0;
489
+ let widest = 0;
490
+ for (const paragraph of text.split("\n")) {
491
+ const n = paragraph.length;
492
+ lines += Math.max(1, Math.ceil(n / charsPerLine));
493
+ widest = Math.max(widest, Math.min(n, charsPerLine) * charW);
494
+ }
495
+ return { w: widest, h: lines * fontSize * LINE_HEIGHT, lines };
496
+ }
497
+
498
+ // src/text/TextMeasure.ts
499
+ var MAX_CACHE_ENTRIES = 2e3;
500
+ var ZERO_WIDTH_SPACE = "\u200B";
501
+ function toDisplayText(text) {
502
+ if (text.length === 0) return ZERO_WIDTH_SPACE;
503
+ return text.endsWith("\n") ? text + ZERO_WIDTH_SPACE : text;
504
+ }
505
+ var TextMeasure = class {
506
+ element = null;
507
+ htmlElement = null;
508
+ cache = /* @__PURE__ */ new Map();
509
+ htmlCache = /* @__PURE__ */ new Map();
510
+ measureText(text, opts) {
511
+ const key = cacheKey(text, opts);
512
+ const hit = this.cache.get(key);
513
+ if (hit) return hit;
514
+ const result = typeof document === "undefined" ? this.estimate(text, opts) : this.measureDom(text, opts);
515
+ if (this.cache.size >= MAX_CACHE_ENTRIES) {
516
+ const oldest = this.cache.keys().next().value;
517
+ if (oldest !== void 0) this.cache.delete(oldest);
518
+ }
519
+ this.cache.set(key, result);
520
+ return result;
521
+ }
522
+ /**
523
+ * Measure a run of HTML — a rich-text label — laid out the way the DOM
524
+ * overlay renders it.
525
+ *
526
+ * Only pass HTML this package generated (`richTextToHtml` escapes text and
527
+ * scheme-checks link hrefs); the probe is inert but it is still a live DOM
528
+ * subtree.
529
+ */
530
+ measureHtml(html, opts) {
531
+ const key = htmlCacheKey(html, opts);
532
+ const hit = this.htmlCache.get(key);
533
+ if (hit) return hit;
534
+ const result = typeof document === "undefined" ? this.estimateHtml(html, opts) : this.measureHtmlDom(html, opts);
535
+ if (this.htmlCache.size >= MAX_CACHE_ENTRIES) {
536
+ const oldest = this.htmlCache.keys().next().value;
537
+ if (oldest !== void 0) this.htmlCache.delete(oldest);
538
+ }
539
+ this.htmlCache.set(key, result);
540
+ return result;
541
+ }
542
+ /**
543
+ * Measure several HTML runs against one probe. Nothing is shared between the
544
+ * items beyond the element itself; the win is one style write and one layout
545
+ * flush per item instead of one per call site.
546
+ */
547
+ measureHtmlBatch(items) {
548
+ return items.map((item) => this.measureHtml(item.html, item.opts));
549
+ }
550
+ /** Number of cached measurements (for tests and debugging). */
551
+ get cacheSize() {
552
+ return this.cache.size;
553
+ }
554
+ clearCache() {
555
+ this.cache.clear();
556
+ this.htmlCache.clear();
557
+ }
558
+ dispose() {
559
+ this.cache.clear();
560
+ this.htmlCache.clear();
561
+ this.element?.remove();
562
+ this.element = null;
563
+ this.htmlElement?.remove();
564
+ this.htmlElement = null;
565
+ }
566
+ estimate(text, opts) {
567
+ const padding = opts.padding ?? 0;
568
+ const inner = opts.maxWidth === void 0 ? Infinity : Math.max(1, opts.maxWidth - padding * 2);
569
+ const est = estimateTextSize(text, opts.fontSize, inner);
570
+ return {
571
+ w: est.w + padding * 2,
572
+ h: est.lines * opts.fontSize * opts.lineHeight + padding * 2,
573
+ lineCount: est.lines
574
+ };
575
+ }
576
+ measureDom(text, opts) {
577
+ const el = this.getElement();
578
+ const padding = opts.padding ?? 0;
579
+ const s = el.style;
580
+ s.fontFamily = opts.fontFamily;
581
+ s.fontSize = `${opts.fontSize}px`;
582
+ s.fontWeight = opts.fontWeight === void 0 ? "normal" : String(opts.fontWeight);
583
+ s.lineHeight = String(opts.lineHeight);
584
+ s.padding = `${padding}px`;
585
+ s.maxWidth = opts.maxWidth === void 0 ? "none" : `${Math.max(1, opts.maxWidth)}px`;
586
+ el.textContent = toDisplayText(text);
587
+ const rect = el.getBoundingClientRect();
588
+ const lineHeightPx = opts.fontSize * opts.lineHeight;
589
+ const contentH = Math.max(0, rect.height - padding * 2);
590
+ const lineCount = lineHeightPx > 0 ? Math.max(1, Math.round(contentH / lineHeightPx)) : 1;
591
+ return { w: Math.ceil(rect.width * 100) / 100, h: Math.ceil(rect.height * 100) / 100, lineCount };
592
+ }
593
+ estimateHtml(html, opts) {
594
+ const text = htmlToProbeText(html);
595
+ const padding = paddingPx(opts.padding);
596
+ const base = this.estimate(text, {
597
+ fontFamily: opts.fontFamily,
598
+ fontSize: opts.fontSize,
599
+ lineHeight: opts.lineHeight,
600
+ ...opts.fontWeight === void 0 ? {} : { fontWeight: opts.fontWeight },
601
+ ...opts.maxWidth === void 0 || opts.maxWidth === null ? {} : { maxWidth: opts.maxWidth },
602
+ padding
603
+ });
604
+ const unwrapped = this.estimate(text, { fontFamily: opts.fontFamily, fontSize: opts.fontSize, lineHeight: opts.lineHeight, padding });
605
+ return { ...base, scrollWidth: opts.measureScrollWidth ? unwrapped.w : base.w };
606
+ }
607
+ measureHtmlDom(html, opts) {
608
+ const el = this.getHtmlElement();
609
+ const s = el.style;
610
+ s.fontFamily = opts.fontFamily;
611
+ s.fontSize = `${opts.fontSize}px`;
612
+ s.fontWeight = opts.fontWeight === void 0 ? "normal" : String(opts.fontWeight);
613
+ s.fontStyle = opts.fontStyle ?? "normal";
614
+ s.lineHeight = String(opts.lineHeight);
615
+ s.padding = typeof opts.padding === "number" ? `${opts.padding}px` : opts.padding ?? "0px";
616
+ s.maxWidth = opts.maxWidth === void 0 || opts.maxWidth === null ? "none" : `${Math.max(1, opts.maxWidth)}px`;
617
+ for (const name of readCustomStyleNames(el)) s.removeProperty(name);
618
+ for (const [name, value] of Object.entries(opts.otherStyles ?? {})) s.setProperty(name, value);
619
+ writeCustomStyleNames(el, Object.keys(opts.otherStyles ?? {}));
620
+ el.innerHTML = html;
621
+ const rect = el.getBoundingClientRect();
622
+ const padding = paddingPx(opts.padding);
623
+ const lineHeightPx = opts.fontSize * opts.lineHeight;
624
+ const contentH = Math.max(0, rect.height - padding * 2);
625
+ const lineCount = lineHeightPx > 0 ? Math.max(1, Math.round(contentH / lineHeightPx)) : 1;
626
+ let scrollWidth = rect.width;
627
+ if (opts.measureScrollWidth) {
628
+ const previousMax = s.maxWidth;
629
+ s.maxWidth = "none";
630
+ scrollWidth = el.getBoundingClientRect().width;
631
+ s.maxWidth = previousMax;
632
+ }
633
+ return {
634
+ w: Math.ceil(rect.width * 100) / 100,
635
+ h: Math.ceil(rect.height * 100) / 100,
636
+ lineCount,
637
+ scrollWidth: Math.ceil(scrollWidth * 100) / 100
638
+ };
639
+ }
640
+ getHtmlElement() {
641
+ if (this.htmlElement && this.htmlElement.isConnected) return this.htmlElement;
642
+ installHtmlProbeStyles();
643
+ const el = document.createElement("div");
644
+ el.setAttribute("aria-hidden", "true");
645
+ el.className = "mocanvas-text-measure-html";
646
+ Object.assign(el.style, {
647
+ position: "fixed",
648
+ top: "-10000px",
649
+ left: "-10000px",
650
+ visibility: "hidden",
651
+ pointerEvents: "none",
652
+ whiteSpace: "pre-wrap",
653
+ overflowWrap: "break-word",
654
+ wordBreak: "normal",
655
+ width: "max-content",
656
+ boxSizing: "border-box",
657
+ margin: "0",
658
+ border: "0",
659
+ zIndex: "-1"
660
+ });
661
+ document.body.appendChild(el);
662
+ this.htmlElement = el;
663
+ return el;
664
+ }
665
+ getElement() {
666
+ if (this.element && this.element.isConnected) return this.element;
667
+ const el = document.createElement("div");
668
+ el.setAttribute("aria-hidden", "true");
669
+ el.className = "mocanvas-text-measure";
670
+ Object.assign(el.style, {
671
+ position: "fixed",
672
+ top: "-10000px",
673
+ left: "-10000px",
674
+ visibility: "hidden",
675
+ pointerEvents: "none",
676
+ whiteSpace: "pre-wrap",
677
+ overflowWrap: "break-word",
678
+ wordBreak: "normal",
679
+ width: "max-content",
680
+ boxSizing: "border-box",
681
+ margin: "0",
682
+ border: "0",
683
+ zIndex: "-1"
684
+ });
685
+ document.body.appendChild(el);
686
+ this.element = el;
687
+ return el;
688
+ }
689
+ };
690
+ function cacheKey(text, o) {
691
+ return `${o.fontFamily}|${o.fontSize}|${o.fontWeight ?? ""}|${o.lineHeight}|${o.maxWidth ?? ""}|${o.padding ?? 0}|${text}`;
692
+ }
693
+ function htmlCacheKey(html, o) {
694
+ const other = Object.entries(o.otherStyles ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${k}:${v}`).join(";");
695
+ return `${o.fontFamily}|${o.fontSize}|${o.fontWeight ?? ""}|${o.fontStyle ?? ""}|${o.lineHeight}|${o.maxWidth ?? ""}|${o.padding ?? 0}|${o.measureScrollWidth ? 1 : 0}|${other}|${html}`;
696
+ }
697
+ function paddingPx(padding) {
698
+ if (typeof padding === "number") return Number.isFinite(padding) ? padding : 0;
699
+ if (typeof padding !== "string") return 0;
700
+ const first = padding.trim().split(/\s+/)[0] ?? "";
701
+ const value = Number.parseFloat(first);
702
+ return Number.isFinite(value) ? value : 0;
703
+ }
704
+ var BLOCK_TAG = /<\/?(?:p|div|li|ul|ol|h[1-6]|blockquote|pre|tr|br|hr)\b[^>]*>/gi;
705
+ function htmlToProbeText(html) {
706
+ return html.replace(BLOCK_TAG, "\n").replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&").replace(/\n{2,}/g, "\n").replace(/^\n|\n$/g, "");
707
+ }
708
+ function readCustomStyleNames(el) {
709
+ const raw = el.dataset["mocanvasOtherStyles"];
710
+ return raw === void 0 || raw.length === 0 ? [] : raw.split(",");
711
+ }
712
+ function writeCustomStyleNames(el, names) {
713
+ if (names.length === 0) delete el.dataset["mocanvasOtherStyles"];
714
+ else el.dataset["mocanvasOtherStyles"] = names.join(",");
715
+ }
716
+ var htmlProbeStylesInstalled = false;
717
+ function installHtmlProbeStyles() {
718
+ if (htmlProbeStylesInstalled || typeof document === "undefined") return;
719
+ htmlProbeStylesInstalled = true;
720
+ const style = document.createElement("style");
721
+ style.setAttribute("data-mocanvas", "text-measure");
722
+ style.textContent = RICH_TEXT_BLOCK_CSS.replace(/__SCOPE__/g, ".mocanvas-text-measure-html");
723
+ document.head.appendChild(style);
724
+ }
725
+ var RICH_TEXT_BLOCK_CSS = [
726
+ "__SCOPE__ p,__SCOPE__ h1,__SCOPE__ h2,__SCOPE__ h3,__SCOPE__ h4,__SCOPE__ h5,__SCOPE__ h6,__SCOPE__ blockquote,__SCOPE__ pre,__SCOPE__ ul,__SCOPE__ ol{margin:0;padding:0;font-size:inherit;font-weight:inherit;line-height:inherit;}",
727
+ "__SCOPE__ ul,__SCOPE__ ol{padding-inline-start:1.4em;}",
728
+ "__SCOPE__ li{margin:0;}",
729
+ "__SCOPE__ pre,__SCOPE__ code{font-family:inherit;white-space:pre-wrap;}",
730
+ "__SCOPE__ hr{margin:0;border:0;border-top:1px solid currentColor;}",
731
+ "__SCOPE__ a{color:inherit;}"
732
+ ].join("");
733
+ var singleton = null;
734
+ function getTextMeasure() {
735
+ if (!singleton) singleton = new TextMeasure();
736
+ return singleton;
737
+ }
738
+
739
+ // src/text/measure-html.ts
740
+ function getRichTextExtensions(editor) {
741
+ const host = editor;
742
+ const configured = host?.options?.text?.tipTapConfig?.extensions ?? host?.textOptions?.tipTapConfig?.extensions;
743
+ return configured && configured.length > 0 ? configured : tipTapDefaultExtensions;
744
+ }
745
+ function renderHtmlFromRichTextForMeasurement(editor, richText) {
746
+ return richTextToHtml(richText, { extensions: getRichTextExtensions(editor) });
747
+ }
748
+ function renderHtmlFromRichTextWithExtensions(richText, extensions) {
749
+ return richTextToHtml(richText, { extensions });
750
+ }
751
+ function measureRichText(editor, richText, opts) {
752
+ return getTextMeasure().measureHtml(renderHtmlFromRichTextForMeasurement(editor, richText), opts);
753
+ }
754
+ function labelFontFamily(props) {
755
+ return props.fontFamily ?? props.font ?? "draw";
756
+ }
757
+ function labelTextAlign(props) {
758
+ return props.textAlign ?? props.align ?? "middle";
759
+ }
760
+ function alignToJustify(align) {
761
+ switch (align) {
762
+ case "start":
763
+ case "start-legacy":
764
+ return "flex-start";
765
+ case "end":
766
+ case "end-legacy":
767
+ return "flex-end";
768
+ default:
769
+ return "center";
770
+ }
771
+ }
772
+ function alignToTextAlign(align) {
773
+ switch (align) {
774
+ case "start":
775
+ case "start-legacy":
776
+ return "left";
777
+ case "end":
778
+ case "end-legacy":
779
+ return "right";
780
+ default:
781
+ return "center";
782
+ }
783
+ }
784
+ function verticalAlignToAlignItems(align) {
785
+ switch (align) {
786
+ case "start":
787
+ return "flex-start";
788
+ case "end":
789
+ return "flex-end";
790
+ default:
791
+ return "center";
792
+ }
793
+ }
794
+ var FLUSH_FALLBACK_MS = 32;
795
+ var stop = (e) => {
796
+ e.stopPropagation();
797
+ };
798
+ function TextLabel(props) {
799
+ const { text, richText, isEditing, fontSize, color, verticalAlign, wrap, width, height, padding = 0, placeholder, singleLine } = props;
800
+ const align = labelTextAlign(props);
801
+ const font = labelFontFamily(props);
802
+ const editor = useEditor();
803
+ const rich = isRichText(richText) ? richText : null;
804
+ const plain = rich ? richTextToText(rich) : text;
805
+ const html = useMemo(() => rich ? renderHtmlFromRichTextForMeasurement(editor, rich) : null, [editor, rich]);
806
+ const textStyle = {
807
+ position: "relative",
808
+ fontFamily: getFontFamily(font),
809
+ fontSize,
810
+ fontWeight: "normal",
811
+ lineHeight: LINE_HEIGHT,
812
+ color,
813
+ textAlign: alignToTextAlign(align),
814
+ whiteSpace: wrap ? "pre-wrap" : "pre",
815
+ overflowWrap: wrap ? "break-word" : "normal",
816
+ wordBreak: "normal",
817
+ padding,
818
+ boxSizing: "border-box",
819
+ maxWidth: "100%",
820
+ minWidth: isEditing ? fontSize + padding * 2 : void 0,
821
+ width: wrap ? "100%" : void 0,
822
+ margin: 0
823
+ };
824
+ const outerStyle = {
825
+ position: "absolute",
826
+ left: 0,
827
+ top: 0,
828
+ width: width ?? "max-content",
829
+ height: height ?? "auto",
830
+ display: "flex",
831
+ justifyContent: alignToJustify(align),
832
+ alignItems: verticalAlignToAlignItems(verticalAlign),
833
+ pointerEvents: isEditing ? "auto" : "none",
834
+ userSelect: isEditing ? "text" : "none",
835
+ WebkitUserSelect: isEditing ? "text" : "none"
836
+ };
837
+ if (!isEditing) {
838
+ const empty = plain.length === 0;
839
+ if (rich && !(empty && placeholder)) {
840
+ return /* @__PURE__ */ jsxs("div", { className: "mocanvas-text-label", style: outerStyle, children: [
841
+ /* @__PURE__ */ jsx(RichTextBlockStyles, {}),
842
+ /* @__PURE__ */ jsx("div", { className: "mocanvas-rich-text", style: textStyle, dangerouslySetInnerHTML: { __html: html ?? "" } })
843
+ ] });
844
+ }
845
+ const shown = empty && placeholder ? placeholder : toDisplayText(plain);
846
+ return /* @__PURE__ */ jsx("div", { className: "mocanvas-text-label", style: outerStyle, children: /* @__PURE__ */ jsx("div", { style: { ...textStyle, opacity: empty && placeholder ? 0.5 : 1 }, children: shown }) });
847
+ }
848
+ const factory = getRichTextEditorFactory();
849
+ const useRichSurface = factory !== null && props.onChangeRichText !== void 0 && rich !== null;
850
+ return /* @__PURE__ */ jsx("div", { className: "mocanvas-text-label mocanvas-text-label-editing", style: outerStyle, children: useRichSurface ? /* @__PURE__ */ jsx(RichTextEditorSurface, { ...props, richText: rich, onChangeRichText: props.onChangeRichText, factory, textStyle, singleLine: singleLine ?? false }) : /* @__PURE__ */ jsx(TextEditor, { ...props, text: plain, textStyle, singleLine: singleLine ?? false }) });
851
+ }
852
+ function RichTextBlockStyles() {
853
+ return /* @__PURE__ */ jsx("style", { children: RICH_TEXT_BLOCK_CSS.replace(/__SCOPE__/g, ".mocanvas-rich-text") });
854
+ }
855
+ function TextEditor(props) {
856
+ const { text, onChange, onChangeRichText, richText, textStyle, singleLine, wrap, color } = props;
857
+ const editor = useEditor();
858
+ const ref = useRef(null);
859
+ const [value, setValue] = useState(text);
860
+ const valueRef = useRef(text);
861
+ const pendingRef = useRef(null);
862
+ const rafRef = useRef(0);
863
+ const timerRef = useRef(0);
864
+ const richRef = useRef(richText);
865
+ richRef.current = richText;
866
+ const onChangeRef = useRef(() => {
867
+ });
868
+ onChangeRef.current = (next) => {
869
+ onChange(next);
870
+ onChangeRichText?.(applyPlainTextToRichText(richRef.current, next));
871
+ };
872
+ const flush = useCallback(() => {
873
+ if (rafRef.current) {
874
+ cancelAnimationFrame(rafRef.current);
875
+ rafRef.current = 0;
876
+ }
877
+ if (timerRef.current) {
878
+ clearTimeout(timerRef.current);
879
+ timerRef.current = 0;
880
+ }
881
+ if (pendingRef.current !== null) {
882
+ const next = pendingRef.current;
883
+ pendingRef.current = null;
884
+ onChangeRef.current(next);
885
+ }
886
+ }, []);
887
+ const commit = useCallback(
888
+ (next) => {
889
+ valueRef.current = next;
890
+ setValue(next);
891
+ pendingRef.current = next;
892
+ if (!rafRef.current) rafRef.current = requestAnimationFrame(flush);
893
+ if (!timerRef.current) timerRef.current = window.setTimeout(flush, FLUSH_FALLBACK_MS);
894
+ },
895
+ [flush]
896
+ );
897
+ useEffect(() => {
898
+ if (pendingRef.current === null && text !== valueRef.current) {
899
+ valueRef.current = text;
900
+ setValue(text);
901
+ }
902
+ }, [text]);
903
+ useLayoutEffect(() => {
904
+ const el = ref.current;
905
+ if (el) {
906
+ el.focus({ preventScroll: true });
907
+ const end = el.value.length;
908
+ el.setSelectionRange(end, end);
909
+ }
910
+ return () => flush();
911
+ }, [flush]);
912
+ const finish = useCallback(() => {
913
+ flush();
914
+ editor.setEditingShape(null);
915
+ editor.complete();
916
+ }, [editor, flush]);
917
+ const onKeyDown = (e) => {
918
+ e.stopPropagation();
919
+ if (e.key === "Escape") {
920
+ e.preventDefault();
921
+ finish();
922
+ return;
923
+ }
924
+ if (e.key === "Tab") {
925
+ e.preventDefault();
926
+ return;
927
+ }
928
+ if (e.key === "Enter" && singleLine && !e.shiftKey) {
929
+ e.preventDefault();
930
+ finish();
931
+ }
932
+ };
933
+ const displayed = toDisplayText(value);
934
+ return /* @__PURE__ */ jsxs("div", { style: textStyle, onPointerDown: stop, onPointerMove: stop, onPointerUp: stop, children: [
935
+ /* @__PURE__ */ jsx("div", { "aria-hidden": "true", style: { visibility: "hidden", pointerEvents: "none" }, children: displayed }),
936
+ /* @__PURE__ */ jsx(
937
+ "textarea",
938
+ {
939
+ ref,
940
+ className: "mocanvas-text-editor",
941
+ value,
942
+ rows: 1,
943
+ wrap: wrap ? "soft" : "off",
944
+ spellCheck: false,
945
+ autoCapitalize: "off",
946
+ autoCorrect: "off",
947
+ "data-gramm": "false",
948
+ onChange: (e) => {
949
+ const raw = e.currentTarget.value;
950
+ commit(singleLine ? raw.replace(/[\r\n]+/g, "") : raw);
951
+ },
952
+ onBlur: flush,
953
+ onKeyDown,
954
+ onKeyUp: stop,
955
+ onPointerDown: stop,
956
+ onPointerMove: stop,
957
+ onPointerUp: stop,
958
+ onContextMenu: stop,
959
+ style: {
960
+ position: "absolute",
961
+ inset: 0,
962
+ width: "100%",
963
+ height: "100%",
964
+ display: "block",
965
+ margin: 0,
966
+ border: "none",
967
+ outline: "none",
968
+ background: "transparent",
969
+ resize: "none",
970
+ overflow: "hidden",
971
+ boxSizing: "border-box",
972
+ padding: textStyle.padding,
973
+ fontFamily: textStyle.fontFamily,
974
+ fontSize: textStyle.fontSize,
975
+ fontWeight: textStyle.fontWeight,
976
+ lineHeight: textStyle.lineHeight,
977
+ color,
978
+ caretColor: color,
979
+ textAlign: textStyle.textAlign,
980
+ whiteSpace: textStyle.whiteSpace,
981
+ overflowWrap: textStyle.overflowWrap,
982
+ wordBreak: textStyle.wordBreak,
983
+ letterSpacing: "inherit",
984
+ userSelect: "text",
985
+ WebkitUserSelect: "text",
986
+ pointerEvents: "auto",
987
+ cursor: "text",
988
+ touchAction: "auto"
989
+ }
990
+ }
991
+ )
992
+ ] });
993
+ }
994
+ function RichTextEditorSurface({ richText, onChangeRichText, factory, textStyle, singleLine }) {
995
+ const editor = useEditor();
996
+ const ref = useRef(null);
997
+ const onChangeRef = useRef(onChangeRichText);
998
+ onChangeRef.current = onChangeRichText;
999
+ const initial = useRef(richText);
1000
+ useLayoutEffect(() => {
1001
+ const container = ref.current;
1002
+ if (!container) return;
1003
+ const handle = factory({
1004
+ container,
1005
+ initialValue: initial.current,
1006
+ extensions: getRichTextExtensions(editor),
1007
+ singleLine,
1008
+ onChange: (value) => onChangeRef.current(value),
1009
+ onFinish: () => {
1010
+ editor.setEditingShape(null);
1011
+ editor.complete();
1012
+ }
1013
+ });
1014
+ handle.focus();
1015
+ return () => handle.destroy();
1016
+ }, [editor, factory, singleLine]);
1017
+ return /* @__PURE__ */ jsx(
1018
+ "div",
1019
+ {
1020
+ ref,
1021
+ className: "mocanvas-rich-text mocanvas-rich-text-editor",
1022
+ style: { ...textStyle, userSelect: "text", WebkitUserSelect: "text", pointerEvents: "auto", cursor: "text" },
1023
+ onPointerDown: stop,
1024
+ onPointerMove: stop,
1025
+ onPointerUp: stop,
1026
+ onKeyDown: stop,
1027
+ onKeyUp: stop,
1028
+ onContextMenu: stop
1029
+ }
1030
+ );
1031
+ }
1032
+
1033
+ // src/text/text-layout.ts
1034
+ function computeGrowY(labelHeight, boxHeight) {
1035
+ if (!Number.isFinite(labelHeight) || !Number.isFinite(boxHeight)) return 0;
1036
+ return Math.max(0, labelHeight - boxHeight);
1037
+ }
1038
+ function trimTrailingWhitespace(text) {
1039
+ return text.replace(/\s+$/u, "");
1040
+ }
1041
+ function labelFontStyle(opts) {
1042
+ return opts.fontFamily ?? opts.font ?? "draw";
1043
+ }
1044
+ function measureLabel(source, opts) {
1045
+ const fontFamily = getFontFamily(labelFontStyle(opts));
1046
+ if (isRichText(source)) {
1047
+ const html = renderHtmlFromRichTextForMeasurement(opts.editor ?? null, source);
1048
+ return getTextMeasure().measureHtml(html, {
1049
+ fontFamily,
1050
+ fontSize: opts.fontSize,
1051
+ lineHeight: LINE_HEIGHT,
1052
+ ...opts.maxWidth === void 0 ? {} : { maxWidth: opts.maxWidth },
1053
+ padding: opts.padding ?? 0
1054
+ });
1055
+ }
1056
+ return getTextMeasure().measureText(richTextToText(source), {
1057
+ fontFamily,
1058
+ fontSize: opts.fontSize,
1059
+ lineHeight: LINE_HEIGHT,
1060
+ ...opts.maxWidth === void 0 ? {} : { maxWidth: opts.maxWidth },
1061
+ padding: opts.padding ?? 0
1062
+ });
1063
+ }
1064
+ var OPTICAL_LIFT_CACHE = /* @__PURE__ */ new Map();
1065
+ function getLabelOpticalLift(opts) {
1066
+ return getOpticalCentreLift(getFontFamily(labelFontStyle(opts))) * opts.fontSize;
1067
+ }
1068
+ function getOpticalCentreLift(fontFamily) {
1069
+ const cached = OPTICAL_LIFT_CACHE.get(fontFamily);
1070
+ if (cached !== void 0) return cached;
1071
+ let lift = 0;
1072
+ try {
1073
+ const ctx = document.createElement("canvas").getContext("2d");
1074
+ if (ctx) {
1075
+ const em = 100;
1076
+ ctx.font = `${em}px ${fontFamily}`;
1077
+ const caps = ctx.measureText("H");
1078
+ const ex = ctx.measureText("x");
1079
+ const capHeight = caps.actualBoundingBoxAscent;
1080
+ const xHeight = ex.actualBoundingBoxAscent;
1081
+ const ascent = caps.fontBoundingBoxAscent;
1082
+ const descent = caps.fontBoundingBoxDescent;
1083
+ if (capHeight > 0 && xHeight > 0 && ascent > 0) {
1084
+ const lineHeight = LINE_HEIGHT * em;
1085
+ const baseline = (lineHeight - (ascent + descent)) / 2 + ascent;
1086
+ const optical = baseline - (capHeight + xHeight) / 4;
1087
+ lift = (optical - lineHeight / 2) / em;
1088
+ }
1089
+ }
1090
+ } catch {
1091
+ }
1092
+ if (!Number.isFinite(lift) || lift < 0 || lift > 0.25) lift = 0;
1093
+ OPTICAL_LIFT_CACHE.set(fontFamily, lift);
1094
+ return lift;
1095
+ }
1096
+ var TEXT_SHAPE_MIN_WIDTH = 8;
1097
+ function getTextShapeSize(input) {
1098
+ const { text, fontSize, autoSize, w, editor } = input;
1099
+ const fontFamily = labelFontStyle(input);
1100
+ if (autoSize) {
1101
+ const m2 = measureLabel(text, { fontFamily, fontSize, editor: editor ?? null });
1102
+ return { w: Math.max(TEXT_SHAPE_MIN_WIDTH, m2.w), h: m2.h, lineCount: m2.lineCount };
1103
+ }
1104
+ const width = Math.max(1, w);
1105
+ const m = measureLabel(text, { fontFamily, fontSize, maxWidth: width, editor: editor ?? null });
1106
+ return { w: width, h: m.h, lineCount: m.lineCount };
1107
+ }
1108
+ var EPS2 = 1e-6;
1109
+ var ELBOW_CORNER_STROKES = 2.5;
1110
+ var CORNER_SEGMENTS = 4;
1111
+ function clamp01(value) {
1112
+ return value < 0 ? 0 : value > 1 ? 1 : value;
1113
+ }
1114
+ function getDominantAxis(start, end) {
1115
+ return Math.abs(end.x - start.x) >= Math.abs(end.y - start.y) ? "x" : "y";
1116
+ }
1117
+ function simplify(points) {
1118
+ const out = [];
1119
+ for (const p of points) {
1120
+ const last = out.at(-1);
1121
+ if (last && Vec.Dist2(last, p) < EPS2 * EPS2) continue;
1122
+ out.push(p);
1123
+ }
1124
+ for (let i = out.length - 2; i > 0; i--) {
1125
+ const a = out[i - 1];
1126
+ const b = out[i];
1127
+ const c = out[i + 1];
1128
+ const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
1129
+ if (Math.abs(cross) < EPS2) out.splice(i, 1);
1130
+ }
1131
+ return out;
1132
+ }
1133
+ function roundCorner(a, p, b, radius) {
1134
+ const lenA = Vec.Dist(a, p);
1135
+ const lenB = Vec.Dist(b, p);
1136
+ const r = Math.min(radius, lenA / 2, lenB / 2);
1137
+ if (r < EPS2) return [p];
1138
+ const da = Vec.Div(Vec.Sub(a, p), lenA);
1139
+ const db = Vec.Div(Vec.Sub(b, p), lenB);
1140
+ if (Math.abs(Vec.Dot(da, db)) > 1e-3) return [p];
1141
+ const from = Vec.Add(p, Vec.Mul(da, r));
1142
+ const to = Vec.Add(p, Vec.Mul(db, r));
1143
+ const center = Vec.Add(p, Vec.Mul(Vec.Add(da, db), r));
1144
+ const a0 = Math.atan2(from.y - center.y, from.x - center.x);
1145
+ const a1 = Math.atan2(to.y - center.y, to.x - center.x);
1146
+ let sweep = a1 - a0;
1147
+ while (sweep > Math.PI) sweep -= Math.PI * 2;
1148
+ while (sweep <= -Math.PI) sweep += Math.PI * 2;
1149
+ const out = [];
1150
+ for (let i = 0; i <= CORNER_SEGMENTS; i++) {
1151
+ const angle = a0 + sweep * i / CORNER_SEGMENTS;
1152
+ out.push(new Vec(center.x + r * Math.cos(angle), center.y + r * Math.sin(angle)));
1153
+ }
1154
+ return out;
1155
+ }
1156
+ function roundElbowCorners(corners, radius) {
1157
+ if (corners.length < 3 || radius < EPS2) return corners.map((p) => p.clone());
1158
+ const out = [corners[0].clone()];
1159
+ for (let i = 1; i < corners.length - 1; i++) {
1160
+ for (const p of roundCorner(corners[i - 1], corners[i], corners[i + 1], radius)) out.push(p);
1161
+ }
1162
+ out.push(corners.at(-1).clone());
1163
+ return simplify(out);
1164
+ }
1165
+ function getElbowRoute(start, end, options = {}) {
1166
+ const s = Vec.From(start);
1167
+ const e = Vec.From(end);
1168
+ const dominant = getDominantAxis(s, e);
1169
+ const startAxis = options.startAxis ?? dominant;
1170
+ const endAxis = options.endAxis ?? dominant;
1171
+ const t = clamp01(options.midPoint ?? 0.5);
1172
+ let corners;
1173
+ let midLeg = null;
1174
+ let slideAxis = null;
1175
+ if (startAxis === endAxis) {
1176
+ if (startAxis === "x") {
1177
+ const mx = s.x + (e.x - s.x) * t;
1178
+ const a = new Vec(mx, s.y);
1179
+ const b = new Vec(mx, e.y);
1180
+ corners = [s, a, b, e];
1181
+ if (Math.abs(e.y - s.y) > EPS2) {
1182
+ midLeg = [a, b];
1183
+ slideAxis = "x";
1184
+ }
1185
+ } else {
1186
+ const my = s.y + (e.y - s.y) * t;
1187
+ const a = new Vec(s.x, my);
1188
+ const b = new Vec(e.x, my);
1189
+ corners = [s, a, b, e];
1190
+ if (Math.abs(e.x - s.x) > EPS2) {
1191
+ midLeg = [a, b];
1192
+ slideAxis = "y";
1193
+ }
1194
+ }
1195
+ } else {
1196
+ corners = startAxis === "x" ? [s, new Vec(e.x, s.y), e] : [s, new Vec(s.x, e.y), e];
1197
+ }
1198
+ const simplified = simplify(corners);
1199
+ return {
1200
+ corners: simplified,
1201
+ points: roundElbowCorners(simplified, options.cornerRadius ?? 0),
1202
+ midLeg,
1203
+ slideAxis
1204
+ };
1205
+ }
1206
+ function getElbowBody(start, end, options = {}) {
1207
+ return { kind: "elbow", points: getElbowRoute(start, end, options).points };
1208
+ }
1209
+ function getElbowMidPointFromPoint(start, end, point, axis) {
1210
+ const span = end[axis] - start[axis];
1211
+ if (Math.abs(span) < EPS2) return 0.5;
1212
+ return clamp01((point[axis] - start[axis]) / span);
1213
+ }
1214
+ var ARROW_LABEL_PADDING = 8;
1215
+ var LABEL_PADDING = ARROW_LABEL_PADDING;
1216
+ var ARROW_KINDS = ARROW_SHAPE_KINDS;
1217
+ function readArrowShape(shape) {
1218
+ return { ...shape, props: readArrowProps(shape) };
1219
+ }
1220
+ function readArrowProps(shape) {
1221
+ const p = propsOf(shape);
1222
+ return {
1223
+ kind: readEnum(p, "kind", ARROW_KINDS, "arc"),
1224
+ start: readPoint(p, "start", { x: 0, y: 0 }),
1225
+ end: readPoint(p, "end", { x: 2, y: 0 }),
1226
+ bend: readNumber(p, "bend", 0),
1227
+ elbowMidPoint: readNumber(p, "elbowMidPoint", 0.5),
1228
+ color: readStyle(p, "color", DefaultColorStyle),
1229
+ labelColor: readStyle(p, "labelColor", DefaultLabelColorStyle),
1230
+ fill: readStyle(p, "fill", DefaultFillStyle),
1231
+ dash: readStyle(p, "dash", DefaultDashStyle),
1232
+ size: readStyle(p, "size", DefaultSizeStyle),
1233
+ arrowheadStart: readEnum(p, "arrowheadStart", ARROWHEAD_KINDS, "none"),
1234
+ arrowheadEnd: readEnum(p, "arrowheadEnd", ARROWHEAD_KINDS, "arrow"),
1235
+ font: readStyle(p, "font", DefaultFontStyle),
1236
+ richText: readRichText(p),
1237
+ text: readText(p),
1238
+ labelPosition: readNumber(p, "labelPosition", 0.5),
1239
+ scale: readNumber(p, "scale", 1)
1240
+ };
1241
+ }
1242
+ function getArrowDisplayValues(editor, shape, theme, colorMode) {
1243
+ const base = getDefaultDisplayValues(editor, shape, theme, colorMode);
1244
+ const scale = readNumber(propsOf(shape), "scale", 1);
1245
+ return {
1246
+ ...base,
1247
+ scaledStrokeWidth: base.strokeWidth * scale,
1248
+ labelFontSize: base.fontSize * scale,
1249
+ labelPadding: ARROW_LABEL_PADDING * scale,
1250
+ labelFontFamily: base.fontFamily
1251
+ };
1252
+ }
1253
+ var ArrowShapeUtil = class extends ShapeUtil {
1254
+ static type = "arrow";
1255
+ static migrations = arrowShapeMigrations;
1256
+ static options = { getDefaultDisplayValues: getArrowDisplayValues };
1257
+ /**
1258
+ * `kind` is deliberately not among these. A style is shared across shape
1259
+ * types, remembered for the next shape and applied to a whole selection at
1260
+ * once; arc-versus-elbow is routing that belongs to the one arrow, and
1261
+ * declaring it a style would put it in every mixed selection's shared styles
1262
+ * (and in the style panel) with nothing else to share it with.
1263
+ */
1264
+ static props = arrowShapeProps;
1265
+ getDefaultProps() {
1266
+ return {
1267
+ kind: "arc",
1268
+ start: { x: 0, y: 0 },
1269
+ end: { x: 2, y: 0 },
1270
+ bend: 0,
1271
+ elbowMidPoint: 0.5,
1272
+ color: "black",
1273
+ labelColor: "black",
1274
+ fill: "none",
1275
+ dash: "draw",
1276
+ size: "m",
1277
+ arrowheadStart: "none",
1278
+ arrowheadEnd: "arrow",
1279
+ font: "draw",
1280
+ richText: toRichText(""),
1281
+ text: "",
1282
+ labelPosition: 0.5,
1283
+ scale: 1
1284
+ };
1285
+ }
1286
+ /**
1287
+ * The arrow's terminals and the body running between them, in arrow-local
1288
+ * space. `"arc"` bows by `bend`; `"elbow"` routes axis-aligned legs, leaving
1289
+ * a bound shape along its nearest edge's normal, with corners rounded in
1290
+ * proportion to the stroke. The elbow's route is handed back as well, for the
1291
+ * midpoint handle.
1292
+ */
1293
+ resolveBody(shape) {
1294
+ const normalized = readArrowShape(shape);
1295
+ const props = normalized.props;
1296
+ const terminals = getArrowTerminalsInArrowSpace(this.editor, normalized);
1297
+ if (props.kind !== "elbow") {
1298
+ return { props, terminals, body: getArrowBody(terminals.start, terminals.end, props.bend), route: null };
1299
+ }
1300
+ const axes = getBoundElbowAxes(this.editor, normalized, terminals);
1301
+ const route = getElbowRoute(terminals.start, terminals.end, {
1302
+ midPoint: props.elbowMidPoint,
1303
+ startAxis: axes.start,
1304
+ endAxis: axes.end,
1305
+ cornerRadius: STROKE_SIZES[props.size] * props.scale * ELBOW_CORNER_STROKES
1306
+ });
1307
+ return { props, terminals, body: { kind: "elbow", points: route.points }, route };
1308
+ }
1309
+ /**
1310
+ * The label's box in shape space, or `null` when there is no label.
1311
+ *
1312
+ * Three things need it and they must agree: the geometry (which is what gets
1313
+ * hit-tested), the DOM element that paints the text, and the indicator that
1314
+ * outlines it on selection. They were computing it separately.
1315
+ */
1316
+ labelBox(shape) {
1317
+ const { text, labelPosition, font, size, scale } = readArrowProps(shape);
1318
+ if (!text) return null;
1319
+ const m = measureLabel(readRichText(shape.props), {
1320
+ fontFamily: font,
1321
+ fontSize: FONT_SIZES[size] * scale,
1322
+ padding: LABEL_PADDING * scale,
1323
+ editor: this.editor
1324
+ });
1325
+ const c = getPointOnBody(this.resolveBody(shape).body, Math.max(0, Math.min(1, labelPosition)));
1326
+ return { x: c.x - m.w / 2, y: c.y - m.h / 2, w: m.w, h: m.h };
1327
+ }
1328
+ /**
1329
+ * `gapAtLabel` splits the body around the label's box. Only the selection
1330
+ * outline asks for it; the geometry the editor hit-tests keeps the body whole,
1331
+ * so the stroke the label covers is still there to be clicked.
1332
+ */
1333
+ getGeometry(shape, opts) {
1334
+ const { props, terminals, body: full } = this.resolveBody(shape);
1335
+ const { arrowheadStart, arrowheadEnd, size, scale, text, labelPosition, font } = props;
1336
+ const { start, end } = terminals;
1337
+ const strokeWidth = STROKE_SIZES[size] * scale;
1338
+ const length = getBodyLength(full);
1339
+ const headLength = getArrowheadLength(strokeWidth, length);
1340
+ const body = shortenBody(full, getArrowheadInset(arrowheadStart, headLength), getArrowheadInset(arrowheadEnd, headLength));
1341
+ const children = [];
1342
+ const gap = opts?.gapAtLabel ? labelGapOnBody(body, opts.gapAtLabel) : null;
1343
+ if (gap) {
1344
+ const len = getBodyLength(body);
1345
+ if (gap.from > 0) children.push(bodyToGeometry(shortenBody(body, 0, len * (1 - gap.from))));
1346
+ if (gap.to < 1) children.push(bodyToGeometry(shortenBody(body, len * gap.to, 0)));
1347
+ } else {
1348
+ children.push(bodyToGeometry(body));
1349
+ }
1350
+ const startHead = getArrowheadGeometry(arrowheadStart, start, Vec.Mul(getTangentOnBody(full, 0), -1), headLength);
1351
+ if (startHead) children.push(startHead);
1352
+ const endHead = getArrowheadGeometry(arrowheadEnd, end, getTangentOnBody(full, 1), headLength);
1353
+ if (endHead) children.push(endHead);
1354
+ const label = this.labelBox(shape);
1355
+ if (label) {
1356
+ children.push(new Rectangle2d({ x: label.x, y: label.y, width: label.w, height: label.h, isFilled: false, isLabel: true }));
1357
+ }
1358
+ return new Group2d({ children });
1359
+ }
1360
+ /** An arrow needs its family's faces only while it carries a label. */
1361
+ getFontFaces(shape) {
1362
+ return readText(shape.props) ? getLabelFontFaces(readArrowProps(shape).font) : [];
1363
+ }
1364
+ getRenderStyle(shape) {
1365
+ const { color, dash, size, scale } = readArrowProps(shape);
1366
+ const stroke = getStrokeRgba(color, getThemeColors(this.editor));
1367
+ return { stroke, strokeWidth: STROKE_SIZES[size] * scale, fill: stroke, dash: getDashId(dash), opacity: 1 };
1368
+ }
1369
+ component(shape) {
1370
+ const { richText, text, font, size, scale, labelPosition } = readArrowProps(shape);
1371
+ const isEditing = this.editor.getEditingShapeId() === shape.id;
1372
+ if (!text && !isEditing) return null;
1373
+ const display = getDisplayValues(this, shape);
1374
+ const box = this.labelBox(shape) ?? { x: 0, y: 0, w: 0, h: 0 };
1375
+ const background = getThemeColors(this.editor).background;
1376
+ return /* @__PURE__ */ jsx(
1377
+ "div",
1378
+ {
1379
+ style: {
1380
+ position: "absolute",
1381
+ left: box.x,
1382
+ top: box.y,
1383
+ width: box.w,
1384
+ height: box.h,
1385
+ background,
1386
+ borderRadius: LABEL_PADDING * scale * 0.5,
1387
+ pointerEvents: "none"
1388
+ },
1389
+ children: /* @__PURE__ */ jsx(
1390
+ "div",
1391
+ {
1392
+ style: {
1393
+ position: "relative",
1394
+ width: "max-content",
1395
+ transform: `translateY(${-getLabelOpticalLift({ fontFamily: font, fontSize: FONT_SIZES[size] * scale })}px)`
1396
+ },
1397
+ children: /* @__PURE__ */ jsx(
1398
+ TextLabel,
1399
+ {
1400
+ shape,
1401
+ text,
1402
+ richText,
1403
+ isEditing,
1404
+ fontFamily: font,
1405
+ fontSize: FONT_SIZES[size] * scale,
1406
+ color: display.labelColor,
1407
+ textAlign: "middle",
1408
+ verticalAlign: "middle",
1409
+ wrap: false,
1410
+ padding: LABEL_PADDING * scale,
1411
+ onChange: (next) => this.editor.updateShape({
1412
+ id: shape.id,
1413
+ type: "arrow",
1414
+ props: { text: next, richText: applyPlainTextToRichText(richText, next) }
1415
+ }),
1416
+ onChangeRichText: (next) => this.editor.updateShape({
1417
+ id: shape.id,
1418
+ type: "arrow",
1419
+ props: { richText: next, text: richTextToText(next) }
1420
+ })
1421
+ }
1422
+ )
1423
+ }
1424
+ )
1425
+ }
1426
+ );
1427
+ }
1428
+ getIndicatorPath(shape) {
1429
+ const label = this.labelBox(shape);
1430
+ const labelRadius = () => Math.min(LABEL_PADDING * readArrowProps(shape).scale * 0.5, label.w / 2, label.h / 2);
1431
+ if (label && this.editor.getEditingShapeId() === shape.id) {
1432
+ const p2 = new Path2D();
1433
+ p2.roundRect(label.x, label.y, label.w, label.h, labelRadius());
1434
+ return p2;
1435
+ }
1436
+ const words = label ? this.getGeometry(shape, { gapAtLabel: label }).toPathWords() : this.getGeometry(shape).toPathWords();
1437
+ const p = svgPath(pathWordsToSvgD(words));
1438
+ if (label) p.roundRect(label.x, label.y, label.w, label.h, labelRadius());
1439
+ return p;
1440
+ }
1441
+ /** The GPU keeps drawing the arrow while its label is edited. */
1442
+ needsOverlay(_shape) {
1443
+ return false;
1444
+ }
1445
+ hasOverlayLabel(shape) {
1446
+ return readText(shape.props).trim().length > 0 || this.editor.getEditingShapeId() === shape.id;
1447
+ }
1448
+ canEdit(_shape) {
1449
+ return true;
1450
+ }
1451
+ /** Nothing binds to an arrow (no arrow-to-arrow bindings). */
1452
+ canBind(_opts) {
1453
+ return false;
1454
+ }
1455
+ /** A selected arrow shows its handles instead of a selection box. */
1456
+ hideSelectionBoundsBg(_shape) {
1457
+ return true;
1458
+ }
1459
+ /**
1460
+ * …but the arrow's *own* outline is drawn. This flag suppresses a lone selected
1461
+ * shape's indicator, and the arrow used to set it because the selection frame
1462
+ * was the cue instead. With the frame gone there was no cue at all — a selected
1463
+ * arrow showed nothing. Its indicator is the curve and the label's box, which
1464
+ * is the cue worth having.
1465
+ */
1466
+ hideSelectionBoundsFg(_shape) {
1467
+ return false;
1468
+ }
1469
+ hideResizeHandles(_shape) {
1470
+ return true;
1471
+ }
1472
+ /**
1473
+ * …and no rotate handle either, which is what actually suppresses the box.
1474
+ * `selectionHandles` only drops the frame when resize *and* rotate are both
1475
+ * hidden, so hiding one of the two left a selected arrow wrapped in a
1476
+ * rectangle with a lone rotate dot above it. An arrow has nothing to rotate
1477
+ * about: its ends are the two handles, and turning it means moving them.
1478
+ */
1479
+ hideRotateHandle(_shape) {
1480
+ return true;
1481
+ }
1482
+ getText(shape) {
1483
+ return readText(shape.props);
1484
+ }
1485
+ onEditEnd(shape) {
1486
+ const text = readText(shape.props);
1487
+ const trimmed = trimTrailingWhitespace(text);
1488
+ if (trimmed === text) return;
1489
+ this.editor.updateShape({
1490
+ id: shape.id,
1491
+ type: "arrow",
1492
+ props: { text: trimmed, richText: applyPlainTextToRichText(readRichText(shape.props), trimmed) }
1493
+ });
1494
+ }
1495
+ /**
1496
+ * Start and end handles move (and bind) the terminals. Between them sits one
1497
+ * virtual handle: an arc's `bend` handle rides the middle of the curve, while
1498
+ * an elbow's `midpoint` handle sits on its middle leg and slides that leg.
1499
+ * An elbow with no middle leg (an L route, or a straight run) has neither.
1500
+ */
1501
+ getHandles(shape) {
1502
+ const { terminals, body, route } = this.resolveBody(shape);
1503
+ const { start, end } = terminals;
1504
+ const handles = [{ id: "start", type: "vertex", index: "a1", x: start.x, y: start.y }];
1505
+ if (route) {
1506
+ if (route.midLeg) {
1507
+ const mid = Vec.Lrp(route.midLeg[0], route.midLeg[1], 0.5);
1508
+ handles.push({ id: "midpoint", type: "vertex", index: "a2", x: mid.x, y: mid.y });
1509
+ }
1510
+ } else {
1511
+ const mid = getPointOnBody(body, 0.5);
1512
+ handles.push({ id: "bend", type: "vertex", index: "a2", x: mid.x, y: mid.y });
1513
+ }
1514
+ handles.push({ id: "end", type: "vertex", index: "a3", x: end.x, y: end.y });
1515
+ return handles;
1516
+ }
1517
+ onHandleDrag(shape, info) {
1518
+ const { handle } = info;
1519
+ switch (handle.id) {
1520
+ case "start":
1521
+ case "end":
1522
+ return this.dragTerminal(shape, handle.id, handle, info.isPrecise);
1523
+ case "bend": {
1524
+ const { start, end } = getArrowTerminalsInArrowSpace(this.editor, readArrowShape(shape));
1525
+ return { props: { ...shape.props, bend: getBendFromPoint(start, end, handle) } };
1526
+ }
1527
+ case "midpoint": {
1528
+ const { terminals, route } = this.resolveBody(shape);
1529
+ if (!route?.slideAxis) return;
1530
+ const elbowMidPoint = getElbowMidPointFromPoint(terminals.start, terminals.end, handle, route.slideAxis);
1531
+ return { props: { ...shape.props, elbowMidPoint } };
1532
+ }
1533
+ default:
1534
+ return;
1535
+ }
1536
+ }
1537
+ /**
1538
+ * Move a terminal handle. When the handle lands on a bindable shape the
1539
+ * terminal is bound to it (centered, or at the precise point under the
1540
+ * pointer); otherwise any existing binding is dropped. The static point is
1541
+ * always written so the arrow renders sensibly if the binding goes away.
1542
+ * Holding Ctrl suppresses binding. Targets are found by hit-testing shape
1543
+ * geometry directly (not `editor.getShapeAtPoint`) because the engine treats
1544
+ * unfilled shapes as hollow, and arrows must bind into hollow shapes too.
1545
+ */
1546
+ dragTerminal(shape, terminal, point, isPrecise) {
1547
+ const editor = this.editor;
1548
+ const local = { x: point.x, y: point.y };
1549
+ const pagePoint = applyTransform(editor.getShapePageTransform(shape), local);
1550
+ const existing = getArrowBindings(editor, shape)[terminal];
1551
+ const target = editor.inputs.ctrlKey ? void 0 : getArrowBindingTargetAtPoint(editor, shape, pagePoint);
1552
+ if (target) {
1553
+ const bindingProps = {
1554
+ terminal,
1555
+ normalizedAnchor: isPrecise ? getNormalizedAnchor(editor, target, pagePoint).toJson() : { x: 0.5, y: 0.5 },
1556
+ isPrecise,
1557
+ isExact: false
1558
+ };
1559
+ if (existing && existing.toId === target.id) {
1560
+ editor.updateBinding({ id: existing.id, type: "arrow", props: bindingProps });
1561
+ } else {
1562
+ if (existing) editor.deleteBinding(existing.id);
1563
+ editor.createBinding({ type: "arrow", fromId: shape.id, toId: target.id, props: bindingProps });
1564
+ }
1565
+ } else if (existing) {
1566
+ editor.deleteBinding(existing.id);
1567
+ }
1568
+ return { props: { ...shape.props, [terminal]: local } };
1569
+ }
1570
+ /**
1571
+ * Dragging the arrow on its own detaches it: bindings to shapes that are not
1572
+ * part of the selection are dropped and their terminals frozen in place.
1573
+ * Bindings to shapes moving along with the arrow are kept.
1574
+ */
1575
+ onTranslateStart(shape) {
1576
+ const editor = this.editor;
1577
+ const selected = new Set(editor.getSelectedShapeIds());
1578
+ const stale = editor.getBindingsFromShape(shape, "arrow").filter((b) => !selected.has(b.toId));
1579
+ if (stale.length) editor.deleteBindings(stale, { isolateShapes: true });
1580
+ }
1581
+ };
1582
+ function getFrameDisplayValues(editor, shape, theme, colorMode) {
1583
+ return {
1584
+ ...getDefaultDisplayValues(editor, shape, theme, colorMode),
1585
+ frameFill: FRAME_FILL,
1586
+ frameStroke: FRAME_STROKE,
1587
+ frameStrokeWidth: FRAME_STROKE_WIDTH,
1588
+ nameFontSize: FRAME_NAME_FONT_SIZE,
1589
+ nameColor: FRAME_NAME_COLOR,
1590
+ nameHeight: FRAME_NAME_HEIGHT
1591
+ };
1592
+ }
1593
+ var FrameShapeUtil = class extends BaseFrameLikeShapeUtil {
1594
+ static type = "frame";
1595
+ static props = frameShapeProps;
1596
+ static migrations = frameShapeMigrations;
1597
+ static options = { getDefaultDisplayValues: getFrameDisplayValues };
1598
+ getDefaultProps() {
1599
+ return { w: 160, h: 90, name: "" };
1600
+ }
1601
+ getGeometry(shape) {
1602
+ const p = propsOf(shape);
1603
+ return new Rectangle2d({ width: readNumber(p, "w", 160), height: readNumber(p, "h", 90), isFilled: true });
1604
+ }
1605
+ getRenderStyle(_shape) {
1606
+ return { fill: hexToRgba(FRAME_FILL), stroke: hexToRgba(FRAME_STROKE), strokeWidth: FRAME_STROKE_WIDTH, dash: 0, opacity: 1 };
1607
+ }
1608
+ component(shape) {
1609
+ const p = propsOf(shape);
1610
+ const name = readString(p, "name", "");
1611
+ const w = readNumber(p, "w", 160);
1612
+ return /* @__PURE__ */ jsx(
1613
+ "div",
1614
+ {
1615
+ style: {
1616
+ position: "absolute",
1617
+ top: -FRAME_NAME_OFFSET,
1618
+ left: 0,
1619
+ width: w,
1620
+ height: FRAME_NAME_HEIGHT,
1621
+ overflow: "hidden",
1622
+ textOverflow: "ellipsis",
1623
+ pointerEvents: "none"
1624
+ },
1625
+ children: /* @__PURE__ */ jsx(
1626
+ TextLabel,
1627
+ {
1628
+ shape,
1629
+ text: name,
1630
+ isEditing: this.editor.getEditingShapeId() === shape.id,
1631
+ fontFamily: "sans",
1632
+ fontSize: FRAME_NAME_FONT_SIZE,
1633
+ color: FRAME_NAME_COLOR,
1634
+ textAlign: "start",
1635
+ verticalAlign: "end",
1636
+ wrap: false,
1637
+ width: w,
1638
+ height: FRAME_NAME_HEIGHT,
1639
+ placeholder: "Frame",
1640
+ singleLine: true,
1641
+ onChange: (next) => this.editor.updateShape({ id: shape.id, type: "frame", props: { name: next } })
1642
+ }
1643
+ )
1644
+ }
1645
+ );
1646
+ }
1647
+ getIndicatorPath(shape) {
1648
+ const p = propsOf(shape);
1649
+ return rectPath(readNumber(p, "w", 160), readNumber(p, "h", 90));
1650
+ }
1651
+ /** The GPU draws the frame body while its name is edited. */
1652
+ needsOverlay(_shape) {
1653
+ return false;
1654
+ }
1655
+ hasOverlayLabel(_shape) {
1656
+ return true;
1657
+ }
1658
+ /** A frame draws its name in the sans family, whatever its children use. */
1659
+ getFontFaces(_shape) {
1660
+ return getLabelFontFaces("sans");
1661
+ }
1662
+ canEdit(_shape) {
1663
+ return true;
1664
+ }
1665
+ getText(shape) {
1666
+ return readString(propsOf(shape), "name", "");
1667
+ }
1668
+ onEditEnd(shape) {
1669
+ const name = readString(propsOf(shape), "name", "");
1670
+ const trimmed = name.trim();
1671
+ if (trimmed !== name) this.editor.updateShape({ id: shape.id, type: "frame", props: { name: trimmed } });
1672
+ }
1673
+ };
1674
+ var BLOBBY_KINDS = /* @__PURE__ */ new Set(["ellipse", "oval", "cloud", "heart"]);
1675
+ var DEFAULT_GEO_TYPE_DEFINITIONS = Object.freeze(
1676
+ Object.fromEntries(
1677
+ GEO_SHAPE_KINDS.map((kind) => [
1678
+ kind,
1679
+ {
1680
+ id: kind,
1681
+ getPath: (w, h, opts) => getGeoGeometry(kind, w, h, opts?.isFilled ?? false, { flipX: opts?.flipX, flipY: opts?.flipY }, opts?.strokeWidth ?? 0),
1682
+ snapType: BLOBBY_KINDS.has(kind) ? "blobby" : "polygon",
1683
+ // The icon set names its geo icons after the kind itself.
1684
+ icon: `geo-${kind}`
1685
+ }
1686
+ ])
1687
+ )
1688
+ );
1689
+ function getGeoTypeDefinition(geo, customGeoTypes) {
1690
+ if (typeof geo !== "string" || geo.length === 0) return void 0;
1691
+ const custom = customGeoTypes?.[geo];
1692
+ if (custom !== void 0) return custom;
1693
+ return DEFAULT_GEO_TYPE_DEFINITIONS[geo];
1694
+ }
1695
+ var GEO_DEFAULT_SIZE = { w: 100, h: 100 };
1696
+ function getGeoDisplayValues(editor, shape, theme, colorMode) {
1697
+ const base = getDefaultDisplayValues(editor, shape, theme, colorMode);
1698
+ const scale = readNumber(propsOf(shape), "scale", 1);
1699
+ return {
1700
+ ...base,
1701
+ labelFontSize: base.fontSize * scale,
1702
+ labelPadding: GEO_LABEL_PADDING * scale,
1703
+ labelFontFamily: base.fontFamily,
1704
+ scaledStrokeWidth: base.strokeWidth * scale
1705
+ };
1706
+ }
1707
+ var GEO_LABEL_PADDING = 16;
1708
+ var LABEL_PADDING2 = GEO_LABEL_PADDING;
1709
+ function horizontalAlignToFlex(align) {
1710
+ return alignToJustify(align);
1711
+ }
1712
+ function horizontalAlignToTextAlign(align) {
1713
+ return alignToTextAlign(align);
1714
+ }
1715
+ function verticalAlignToFlex(align) {
1716
+ return verticalAlignToAlignItems(align);
1717
+ }
1718
+ function readGeoProps(shape) {
1719
+ const p = propsOf(shape);
1720
+ return {
1721
+ geo: readStyle(p, "geo", GeoShapeGeoStyle),
1722
+ w: readNumber(p, "w", 100),
1723
+ h: readNumber(p, "h", 100),
1724
+ color: readStyle(p, "color", DefaultColorStyle),
1725
+ labelColor: readStyle(p, "labelColor", DefaultLabelColorStyle),
1726
+ fill: readStyle(p, "fill", DefaultFillStyle),
1727
+ dash: readStyle(p, "dash", DefaultDashStyle),
1728
+ size: readStyle(p, "size", DefaultSizeStyle),
1729
+ font: readStyle(p, "font", DefaultFontStyle),
1730
+ align: readStyle(p, "align", DefaultHorizontalAlignStyle),
1731
+ verticalAlign: readStyle(p, "verticalAlign", DefaultVerticalAlignStyle),
1732
+ growY: readNumber(p, "growY", 0),
1733
+ url: readString(p, "url", ""),
1734
+ richText: readRichText(p),
1735
+ text: readText(p),
1736
+ scale: readNumber(p, "scale", 1),
1737
+ flipX: readBoolean(p, "flipX", false),
1738
+ flipY: readBoolean(p, "flipY", false)
1739
+ };
1740
+ }
1741
+ function measureGeoLabel(props, editor) {
1742
+ const richText = readRichText(props);
1743
+ const font = readStyle(props, "font", DefaultFontStyle);
1744
+ const size = readStyle(props, "size", DefaultSizeStyle);
1745
+ const scale = readNumber(props, "scale", 1);
1746
+ const w = readNumber(props, "w", 100);
1747
+ return measureLabel(richText, {
1748
+ fontFamily: font,
1749
+ fontSize: FONT_SIZES[size] * scale,
1750
+ maxWidth: Math.max(1, w),
1751
+ padding: LABEL_PADDING2 * scale,
1752
+ editor: editor ?? null
1753
+ });
1754
+ }
1755
+ function getGeoGrowY(props, editor) {
1756
+ if (!readText(props)) return 0;
1757
+ return computeGrowY(measureGeoLabel(props, editor).h, readNumber(props, "h", 100));
1758
+ }
1759
+ var LABEL_KEYS = ["richText", "text", "font", "size", "scale", "w", "h"];
1760
+ var geoShapeVersions = createBuiltInShapePropsMigrationIds("geo", {
1761
+ AddFlipProps: 1
1762
+ });
1763
+ var geoShapeMigrations = createShapePropsMigrationSequence({
1764
+ sequence: [
1765
+ {
1766
+ id: geoShapeVersions.AddFlipProps,
1767
+ up(props) {
1768
+ props["flipX"] ??= false;
1769
+ props["flipY"] ??= false;
1770
+ },
1771
+ down(props) {
1772
+ delete props["flipX"];
1773
+ delete props["flipY"];
1774
+ }
1775
+ }
1776
+ ]
1777
+ });
1778
+ var GeoShapeUtil = class extends BaseBoxShapeUtil {
1779
+ static type = "geo";
1780
+ static migrations = geoShapeMigrations;
1781
+ static options = { getDefaultDisplayValues: getGeoDisplayValues };
1782
+ static props = geoShapeProps;
1783
+ getDefaultProps() {
1784
+ return {
1785
+ geo: "rectangle",
1786
+ w: 100,
1787
+ h: 100,
1788
+ color: "black",
1789
+ labelColor: "black",
1790
+ fill: "none",
1791
+ dash: "draw",
1792
+ size: "m",
1793
+ font: "draw",
1794
+ align: "middle",
1795
+ verticalAlign: "middle",
1796
+ growY: 0,
1797
+ url: "",
1798
+ richText: toRichText(""),
1799
+ text: "",
1800
+ scale: 1,
1801
+ flipX: false,
1802
+ flipY: false
1803
+ };
1804
+ }
1805
+ /**
1806
+ * The silhouette for `geo`, taking this util's `customGeoTypes` into account.
1807
+ * Falls back to the rectangle so an unknown value still draws something.
1808
+ */
1809
+ getGeoTypeDefinition(geo) {
1810
+ return getGeoTypeDefinition(geo, this.options.customGeoTypes) ?? getGeoTypeDefinition("rectangle", this.options.customGeoTypes);
1811
+ }
1812
+ /**
1813
+ * The `geo` value this util will actually draw.
1814
+ *
1815
+ * `props.geo` is a *style* prop, and its validator only knows the built-in
1816
+ * silhouettes — so reading it through the style would turn every custom geo
1817
+ * type back into a rectangle before it ever reached the table. A stored value
1818
+ * this util has a definition for is therefore kept as it is; anything else
1819
+ * falls back to the validated style, which is what keeps a value from another
1820
+ * editor's vocabulary drawing something rather than nothing.
1821
+ */
1822
+ getGeoValue(shape) {
1823
+ const p = propsOf(shape);
1824
+ const raw = readString(p, "geo", "");
1825
+ if (raw !== "" && getGeoTypeDefinition(raw, this.options.customGeoTypes) !== void 0) return raw;
1826
+ return readStyle(p, "geo", GeoShapeGeoStyle);
1827
+ }
1828
+ /**
1829
+ * The engine has a generator for every built-in geo kind, so a built-in
1830
+ * travels as `(kind, w, h, flips)` rather than as its vertices. A custom geo
1831
+ * type returns `undefined` and keeps uploading whatever its `getPath` builds.
1832
+ */
1833
+ getEngineGeometry(shape) {
1834
+ const geo = this.getGeoValue(shape);
1835
+ if (this.options.customGeoTypes?.[geo]) return void 0;
1836
+ const kind = GEO_KIND[geo];
1837
+ if (kind === void 0) return void 0;
1838
+ const { w, h, growY, fill, flipX, flipY, size, scale } = readGeoProps(shape);
1839
+ return {
1840
+ type: "geo",
1841
+ kind,
1842
+ w,
1843
+ h: h + growY,
1844
+ isClosed: true,
1845
+ isFilled: fill !== "none",
1846
+ flipX,
1847
+ flipY,
1848
+ // Only the marks inside an outline use it — the X of an x-box, whose ends
1849
+ // sit on the corners. `getGeometry` has always shortened them by this; the
1850
+ // engine draws its own copy of the path and needs to be told the same.
1851
+ strokeWidth: STROKE_SIZES[size] * scale
1852
+ };
1853
+ }
1854
+ getGeometry(shape) {
1855
+ const props = readGeoProps(shape);
1856
+ const { w, h, growY, fill, text, flipX, flipY, size, scale } = props;
1857
+ const height = h + growY;
1858
+ const body = this.getGeoTypeDefinition(this.getGeoValue(shape)).getPath(w, height, {
1859
+ isFilled: fill !== "none",
1860
+ flipX,
1861
+ flipY,
1862
+ strokeWidth: STROKE_SIZES[size] * scale
1863
+ });
1864
+ if (!text) return body;
1865
+ return new Group2d({ children: [body, this.getLabelRect(props)] });
1866
+ }
1867
+ /** Where the text label sits inside the body, in shape-local space. */
1868
+ getLabelRect(props) {
1869
+ const { w, h, growY, align, verticalAlign } = props;
1870
+ const height = h + growY;
1871
+ const m = measureGeoLabel(props);
1872
+ const lw = Math.min(w, m.w);
1873
+ const lh = Math.min(height, m.h);
1874
+ const x = align === "start" || align === "start-legacy" ? 0 : align === "end" || align === "end-legacy" ? w - lw : (w - lw) / 2;
1875
+ const y = verticalAlign === "start" ? 0 : verticalAlign === "end" ? height - lh : (height - lh) / 2;
1876
+ return new Rectangle2d({ x, y, width: lw, height: lh, isFilled: false, isLabel: true });
1877
+ }
1878
+ getRenderStyle(shape) {
1879
+ const { color, fill, dash, size, scale } = readGeoProps(shape);
1880
+ const colors = getThemeColors(this.editor);
1881
+ return {
1882
+ stroke: getStrokeRgba(color, colors),
1883
+ strokeWidth: STROKE_SIZES[size] * scale,
1884
+ fill: getFillRgba(color, fill, colors),
1885
+ dash: getDashId(dash),
1886
+ opacity: 1
1887
+ };
1888
+ }
1889
+ /** A geo shape needs its family's faces only while it carries a label. */
1890
+ getFontFaces(shape) {
1891
+ return readText(shape.props) ? getLabelFontFaces(readGeoProps(shape).font) : [];
1892
+ }
1893
+ component(shape) {
1894
+ const { richText, text, font, size, scale, align, verticalAlign, w, h, growY } = readGeoProps(shape);
1895
+ const isEditing = this.editor.getEditingShapeId() === shape.id;
1896
+ if (!text && !isEditing) return null;
1897
+ const display = getDisplayValues(this, shape);
1898
+ return /* @__PURE__ */ jsx(
1899
+ TextLabel,
1900
+ {
1901
+ shape,
1902
+ text,
1903
+ richText,
1904
+ isEditing,
1905
+ fontFamily: font,
1906
+ fontSize: FONT_SIZES[size] * scale,
1907
+ color: display.labelColor,
1908
+ textAlign: align,
1909
+ verticalAlign,
1910
+ wrap: true,
1911
+ width: w,
1912
+ height: h + growY,
1913
+ padding: LABEL_PADDING2 * scale,
1914
+ onChange: (next) => this.editor.updateShape({
1915
+ id: shape.id,
1916
+ type: "geo",
1917
+ props: { text: next, richText: applyPlainTextToRichText(richText, next) }
1918
+ }),
1919
+ onChangeRichText: (next) => this.editor.updateShape({
1920
+ id: shape.id,
1921
+ type: "geo",
1922
+ props: { richText: next, text: richTextToText(next) }
1923
+ })
1924
+ }
1925
+ );
1926
+ }
1927
+ getIndicatorPath(shape) {
1928
+ return svgPath(pathWordsToSvgD(this.getGeometry(shape).toPathWords()));
1929
+ }
1930
+ /** The GPU keeps drawing the body while editing; only the label lives in the DOM. */
1931
+ needsOverlay(_shape) {
1932
+ return false;
1933
+ }
1934
+ hasOverlayLabel(shape) {
1935
+ return readText(shape.props).trim().length > 0 || this.editor.getEditingShapeId() === shape.id;
1936
+ }
1937
+ canEdit(_shape) {
1938
+ return true;
1939
+ }
1940
+ getText(shape) {
1941
+ return readText(shape.props);
1942
+ }
1943
+ onBeforeCreate(next) {
1944
+ const growY = getGeoGrowY(next.props, this.editor);
1945
+ if (growY !== next.props.growY) return { ...next, props: { ...next.props, growY } };
1946
+ }
1947
+ onBeforeUpdate(prev, next) {
1948
+ if (!LABEL_KEYS.some((k) => propsOf(prev)[k] !== propsOf(next)[k])) return;
1949
+ const growY = getGeoGrowY(next.props, this.editor);
1950
+ if (growY !== next.props.growY) return { ...next, props: { ...next.props, growY } };
1951
+ }
1952
+ onEditEnd(shape) {
1953
+ const text = readText(shape.props);
1954
+ const trimmed = trimTrailingWhitespace(text);
1955
+ if (trimmed === text) return;
1956
+ this.editor.updateShape({
1957
+ id: shape.id,
1958
+ type: "geo",
1959
+ props: { text: trimmed, richText: applyPlainTextToRichText(readRichText(shape.props), trimmed) }
1960
+ });
1961
+ }
1962
+ };
1963
+ var NOTE_SIZE = 200;
1964
+ var NOTE_PADDING = 16;
1965
+ function readNoteProps(shape) {
1966
+ const p = propsOf(shape);
1967
+ return {
1968
+ color: readStyle(p, "color", DefaultColorStyle),
1969
+ labelColor: readStyle(p, "labelColor", DefaultLabelColorStyle),
1970
+ size: readStyle(p, "size", DefaultSizeStyle),
1971
+ font: readStyle(p, "font", DefaultFontStyle),
1972
+ fontSizeAdjustment: readNumber(p, "fontSizeAdjustment", 0),
1973
+ align: readStyle(p, "align", DefaultHorizontalAlignStyle),
1974
+ verticalAlign: readStyle(p, "verticalAlign", DefaultVerticalAlignStyle),
1975
+ growY: readNumber(p, "growY", 0),
1976
+ url: readString(p, "url", ""),
1977
+ richText: readRichText(p),
1978
+ text: readText(p),
1979
+ textFirstEditedBy: readString(p, "textFirstEditedBy", "") || null,
1980
+ scale: readNumber(p, "scale", 1)
1981
+ };
1982
+ }
1983
+ var MIN_NOTE_FONT_SIZE = 4;
1984
+ function getNoteFontSize(shape, theme = DEFAULT_THEME) {
1985
+ const { size, scale, fontSizeAdjustment } = readNoteProps(shape);
1986
+ const styled = theme.fontSize[size] ?? DEFAULT_THEME.fontSize[size];
1987
+ return (fontSizeAdjustment >= MIN_NOTE_FONT_SIZE ? fontSizeAdjustment : styled) * scale;
1988
+ }
1989
+ function getNoteDisplayValues(editor, shape, theme, colorMode, options = {}) {
1990
+ const base = getDefaultDisplayValues(editor, shape, theme, colorMode);
1991
+ const props = readNoteProps(shape);
1992
+ const colors = theme.colors[colorMode] ?? theme.colors.light;
1993
+ const side = (options.noteSize ?? NOTE_SIZE) * props.scale;
1994
+ const fontSize = getNoteFontSize(shape, theme);
1995
+ const labelColor = props.labelColor === "black" ? getColorValue(colors, props.color, "noteText") : getColorValue(colors, props.labelColor, "solid");
1996
+ return {
1997
+ ...base,
1998
+ labelColor,
1999
+ fill: getColorValue(colors, props.color, "noteFill"),
2000
+ fontSize,
2001
+ lineHeight: fontSize * theme.lineHeight,
2002
+ noteWidth: side,
2003
+ noteHeight: side,
2004
+ labelPadding: (options.labelPadding ?? NOTE_PADDING) * props.scale,
2005
+ labelFontSize: fontSize,
2006
+ labelFontFamily: theme.fonts[props.font] ?? theme.fonts.draw,
2007
+ // SEMANTICS-ASSUMED: a note's label is upright, regular and unshaped. Rich
2008
+ // text carries its own bold/italic runs as marks, so the shape-level
2009
+ // typography is the base every run is measured relative to.
2010
+ labelFontStyle: "normal",
2011
+ labelFontWeight: "normal",
2012
+ labelFontVariant: "normal",
2013
+ labelLineHeight: theme.lineHeight
2014
+ };
2015
+ }
2016
+ function getNoteGrowY(shape, editor) {
2017
+ const { richText, text, font, scale } = readNoteProps(shape);
2018
+ if (!text) return 0;
2019
+ const side = NOTE_SIZE * scale;
2020
+ const m = measureLabel(richText, {
2021
+ fontFamily: font,
2022
+ fontSize: getNoteFontSize(shape),
2023
+ maxWidth: side,
2024
+ padding: NOTE_PADDING * scale,
2025
+ editor: editor ?? null
2026
+ });
2027
+ return computeGrowY(m.h, side);
2028
+ }
2029
+ var LABEL_KEYS2 = ["richText", "text", "font", "size", "scale", "fontSizeAdjustment"];
2030
+ var NoteShapeUtil = class extends ShapeUtil {
2031
+ static type = "note";
2032
+ // A method, not an arrow: `getDisplayValues` calls it on the options bag, so
2033
+ // `this` is the bag a `configure()` copy actually carries. Closing over
2034
+ // `NoteShapeUtil.options` instead would make every configured copy resolve
2035
+ // against the unconfigured defaults.
2036
+ static options = {
2037
+ getDefaultDisplayValues(editor, shape, theme, colorMode) {
2038
+ return getNoteDisplayValues(editor, shape, theme, colorMode, this);
2039
+ }
2040
+ };
2041
+ static props = noteShapeProps;
2042
+ static migrations = noteShapeMigrations;
2043
+ getDefaultProps() {
2044
+ return {
2045
+ color: "black",
2046
+ labelColor: "black",
2047
+ size: "m",
2048
+ font: "draw",
2049
+ fontSizeAdjustment: 0,
2050
+ align: "middle",
2051
+ verticalAlign: "middle",
2052
+ growY: 0,
2053
+ url: "",
2054
+ richText: toRichText(""),
2055
+ text: "",
2056
+ // Deliberately absent: `textFirstEditedBy` is read and round-tripped but
2057
+ // never written by mocanvas (only the host knows who "a person" is), and
2058
+ // defaulting it would make every file that predates attribution warn on
2059
+ // load about a prop nothing here ever sets.
2060
+ scale: 1
2061
+ };
2062
+ }
2063
+ getGeometry(shape) {
2064
+ const { scale, growY } = readNoteProps(shape);
2065
+ return new Rectangle2d({ width: NOTE_SIZE * scale, height: NOTE_SIZE * scale + growY, isFilled: true });
2066
+ }
2067
+ getRenderStyle(shape) {
2068
+ const colors = getThemeColors(this.editor);
2069
+ return { fill: getNoteFillRgba(readNoteProps(shape).color, colors), stroke: 0, strokeWidth: 0, dash: 0, opacity: 1 };
2070
+ }
2071
+ /** A note is nothing but a label, so it needs its family's faces. */
2072
+ getFontFaces(shape) {
2073
+ return getLabelFontFaces(readNoteProps(shape).font);
2074
+ }
2075
+ component(shape) {
2076
+ const { richText, text, font, color, align, verticalAlign, scale, growY } = readNoteProps(shape);
2077
+ const display = getDisplayValues(this, shape);
2078
+ const colors = getThemeColors(this.editor);
2079
+ const textColor = display.labelColor;
2080
+ const w = NOTE_SIZE * scale;
2081
+ const h = NOTE_SIZE * scale + growY;
2082
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2083
+ /* @__PURE__ */ jsx(
2084
+ "div",
2085
+ {
2086
+ "aria-hidden": "true",
2087
+ style: {
2088
+ position: "absolute",
2089
+ left: 0,
2090
+ top: 0,
2091
+ width: w,
2092
+ height: h,
2093
+ background: getNoteBodyGradientCss(color, colors),
2094
+ boxShadow: getNoteShadowCss(scale),
2095
+ pointerEvents: "none"
2096
+ }
2097
+ }
2098
+ ),
2099
+ /* @__PURE__ */ jsx(
2100
+ TextLabel,
2101
+ {
2102
+ shape,
2103
+ text,
2104
+ richText,
2105
+ isEditing: this.editor.getEditingShapeId() === shape.id,
2106
+ fontFamily: font,
2107
+ fontSize: display.labelFontSize,
2108
+ color: textColor,
2109
+ textAlign: align,
2110
+ verticalAlign,
2111
+ wrap: true,
2112
+ width: w,
2113
+ height: h,
2114
+ padding: display.labelPadding,
2115
+ onChange: (next) => this.editor.updateShape({
2116
+ id: shape.id,
2117
+ type: "note",
2118
+ props: { text: next, richText: applyPlainTextToRichText(richText, next) }
2119
+ }),
2120
+ onChangeRichText: (next) => this.editor.updateShape({
2121
+ id: shape.id,
2122
+ type: "note",
2123
+ props: { richText: next, text: richTextToText(next) }
2124
+ })
2125
+ }
2126
+ )
2127
+ ] });
2128
+ }
2129
+ getIndicatorPath(shape) {
2130
+ const { scale, growY } = readNoteProps(shape);
2131
+ return rectPath(NOTE_SIZE * scale, NOTE_SIZE * scale + growY);
2132
+ }
2133
+ /**
2134
+ * The GPU draws the sticky background even while editing; the overlay adds
2135
+ * the gradient and the shadow over it (see `component`).
2136
+ */
2137
+ needsOverlay(_shape) {
2138
+ return false;
2139
+ }
2140
+ hasOverlayLabel(_shape) {
2141
+ return true;
2142
+ }
2143
+ canEdit(_shape) {
2144
+ return true;
2145
+ }
2146
+ hideResizeHandles(_shape) {
2147
+ return true;
2148
+ }
2149
+ getText(shape) {
2150
+ return readText(shape.props);
2151
+ }
2152
+ onBeforeCreate(next) {
2153
+ const growY = getNoteGrowY(next, this.editor);
2154
+ if (growY !== next.props.growY) return { ...next, props: { ...next.props, growY } };
2155
+ }
2156
+ onBeforeUpdate(prev, next) {
2157
+ if (!LABEL_KEYS2.some((k) => propsOf(prev)[k] !== propsOf(next)[k])) return;
2158
+ const growY = getNoteGrowY(next, this.editor);
2159
+ if (growY !== next.props.growY) return { ...next, props: { ...next.props, growY } };
2160
+ }
2161
+ onEditEnd(shape) {
2162
+ const text = readText(shape.props);
2163
+ const trimmed = trimTrailingWhitespace(text);
2164
+ if (trimmed === text) return;
2165
+ this.editor.updateShape({
2166
+ id: shape.id,
2167
+ type: "note",
2168
+ props: { text: trimmed, richText: applyPlainTextToRichText(readRichText(shape.props), trimmed) }
2169
+ });
2170
+ }
2171
+ };
2172
+
2173
+ // src/text/TextTexture.ts
2174
+ var MAX_TEXT_TEXTURE_PX = 4096;
2175
+ function textTextureAlign(spec) {
2176
+ return spec.textAlign ?? spec.align ?? "start";
2177
+ }
2178
+ function textTextureText(spec) {
2179
+ return spec.richText === void 0 || spec.richText === null ? spec.text : richTextToText(spec.richText);
2180
+ }
2181
+ function getTextTextureKey(spec) {
2182
+ return [
2183
+ "text",
2184
+ spec.fontFamily,
2185
+ spec.fontSize,
2186
+ spec.lineHeight,
2187
+ spec.color,
2188
+ textTextureAlign(spec),
2189
+ spec.verticalAlign,
2190
+ round(spec.width),
2191
+ round(spec.height),
2192
+ spec.maxWidth === void 0 ? "" : round(spec.maxWidth),
2193
+ spec.padding ?? 0,
2194
+ spec.resolution,
2195
+ spec.richText === void 0 || spec.richText === null ? spec.text : JSON.stringify(spec.richText)
2196
+ ].join("|");
2197
+ }
2198
+ function runWidth(text, spec, style) {
2199
+ if (text.length === 0) return 0;
2200
+ return getTextMeasure().measureText(text, {
2201
+ fontFamily: spec.fontFamily,
2202
+ fontSize: spec.fontSize,
2203
+ lineHeight: spec.lineHeight,
2204
+ ...style?.bold ? { fontWeight: "bold" } : {}
2205
+ }).w;
2206
+ }
2207
+ function wrapTextTextureLines(spec) {
2208
+ const padding = spec.padding ?? 0;
2209
+ const max = spec.maxWidth === void 0 ? void 0 : Math.max(1, spec.maxWidth - padding * 2);
2210
+ const out = [];
2211
+ for (const paragraph of textTextureText(spec).split("\n")) {
2212
+ if (max === void 0 || paragraph.length === 0) {
2213
+ out.push(paragraph);
2214
+ continue;
2215
+ }
2216
+ let line = "";
2217
+ for (const word of paragraph.split(" ")) {
2218
+ const candidate = line.length === 0 ? word : `${line} ${word}`;
2219
+ if (runWidth(candidate, spec) <= max || line.length === 0) {
2220
+ line = candidate;
2221
+ if (line.length > 0 && runWidth(line, spec) > max) {
2222
+ const parts = breakLongWord(line, spec, max);
2223
+ out.push(...parts.slice(0, -1));
2224
+ line = parts[parts.length - 1] ?? "";
2225
+ }
2226
+ continue;
2227
+ }
2228
+ out.push(line);
2229
+ line = word;
2230
+ }
2231
+ out.push(line);
2232
+ }
2233
+ return out;
2234
+ }
2235
+ function breakLongWord(word, spec, max) {
2236
+ const parts = [];
2237
+ let current = "";
2238
+ for (const char of word) {
2239
+ const next = current + char;
2240
+ if (current.length > 0 && runWidth(next, spec) > max) {
2241
+ parts.push(current);
2242
+ current = char;
2243
+ } else {
2244
+ current = next;
2245
+ }
2246
+ }
2247
+ parts.push(current);
2248
+ return parts;
2249
+ }
2250
+ function sameStyle(a, b) {
2251
+ return a.bold === b.bold && a.italic === b.italic && a.underline === b.underline && a.strike === b.strike && a.code === b.code && a.href === b.href;
2252
+ }
2253
+ function wrapTextTextureRuns(spec) {
2254
+ const padding = spec.padding ?? 0;
2255
+ const max = spec.maxWidth === void 0 ? void 0 : Math.max(1, spec.maxWidth - padding * 2);
2256
+ const source = spec.richText === void 0 || spec.richText === null ? spec.text : spec.richText;
2257
+ const out = [];
2258
+ for (const block of richTextToBlocks(source)) {
2259
+ let line = [];
2260
+ let width = 0;
2261
+ const push = () => {
2262
+ while (line.length > 0 && /^\s+$/u.test(line[line.length - 1].text)) {
2263
+ const dropped = line.pop();
2264
+ width -= runWidth(dropped.text, spec, dropped);
2265
+ }
2266
+ out.push({ runs: mergeRuns(line), width: Math.max(0, width) });
2267
+ line = [];
2268
+ width = 0;
2269
+ };
2270
+ for (const run of block.runs) {
2271
+ for (const token of run.text.split(/(\s+)/u).filter((t) => t.length > 0)) {
2272
+ const isSpace = /^\s+$/u.test(token);
2273
+ const tokenWidth = runWidth(token, spec, run);
2274
+ if (max !== void 0 && !isSpace && line.length > 0 && width + tokenWidth > max) {
2275
+ push();
2276
+ }
2277
+ if (isSpace && line.length === 0) continue;
2278
+ if (max !== void 0 && !isSpace && tokenWidth > max) {
2279
+ for (const piece of breakLongToken(token, spec, run, max, width, line.length > 0)) {
2280
+ if (piece === null) push();
2281
+ else {
2282
+ line.push({ ...run, text: piece });
2283
+ width += runWidth(piece, spec, run);
2284
+ }
2285
+ }
2286
+ continue;
2287
+ }
2288
+ line.push({ ...run, text: token });
2289
+ width += tokenWidth;
2290
+ }
2291
+ }
2292
+ push();
2293
+ }
2294
+ return out;
2295
+ }
2296
+ function breakLongToken(token, spec, style, max, startWidth, canBreakFirst) {
2297
+ const out = [];
2298
+ if (canBreakFirst && startWidth > 0) out.push(null);
2299
+ let current = "";
2300
+ for (const char of token) {
2301
+ const next = current + char;
2302
+ if (current.length > 0 && runWidth(next, spec, style) > max) {
2303
+ out.push(current, null);
2304
+ current = char;
2305
+ } else {
2306
+ current = next;
2307
+ }
2308
+ }
2309
+ if (current.length > 0) out.push(current);
2310
+ return out;
2311
+ }
2312
+ function mergeRuns(runs) {
2313
+ const out = [];
2314
+ for (const run of runs) {
2315
+ const last = out[out.length - 1];
2316
+ if (last && sameStyle(last, run)) last.text += run.text;
2317
+ else out.push({ ...run });
2318
+ }
2319
+ return out;
2320
+ }
2321
+ function getTextTextureScale(spec) {
2322
+ const longest = Math.max(1, spec.width, spec.height);
2323
+ return Math.max(0.05, Math.min(spec.resolution, MAX_TEXT_TEXTURE_PX / longest));
2324
+ }
2325
+ function renderTextToCanvas(spec) {
2326
+ if (typeof document === "undefined") throw new Error("mocanvas: no document to rasterize text with");
2327
+ const scale = getTextTextureScale(spec);
2328
+ const canvas = document.createElement("canvas");
2329
+ canvas.width = Math.max(1, Math.ceil(spec.width * scale));
2330
+ canvas.height = Math.max(1, Math.ceil(spec.height * scale));
2331
+ const ctx = canvas.getContext("2d");
2332
+ if (!ctx) throw new Error("mocanvas: no 2d context to rasterize text with");
2333
+ const padding = spec.padding ?? 0;
2334
+ const lines = wrapTextTextureRuns(spec);
2335
+ const lineHeightPx = spec.fontSize * spec.lineHeight;
2336
+ const blockHeight = lines.length * lineHeightPx;
2337
+ const align = textTextureAlign(spec);
2338
+ ctx.scale(scale, scale);
2339
+ ctx.fillStyle = spec.color;
2340
+ ctx.textBaseline = "middle";
2341
+ ctx.textAlign = "left";
2342
+ const innerH = Math.max(0, spec.height - padding * 2);
2343
+ const innerW = Math.max(0, spec.width - padding * 2);
2344
+ const top = padding + (spec.verticalAlign === "start" ? 0 : spec.verticalAlign === "end" ? innerH - blockHeight : (innerH - blockHeight) / 2);
2345
+ for (let i = 0; i < lines.length; i++) {
2346
+ const line = lines[i];
2347
+ const baseline = top + i * lineHeightPx + lineHeightPx / 2;
2348
+ let x = padding + (align === "start" ? 0 : align === "end" ? innerW - line.width : (innerW - line.width) / 2);
2349
+ for (const run of line.runs) {
2350
+ ctx.font = runFont(run, spec);
2351
+ ctx.fillText(run.text, x, baseline);
2352
+ const width = runWidth(run.text, spec, run);
2353
+ if (run.underline || run.strike) {
2354
+ const thickness = Math.max(1, spec.fontSize / 14);
2355
+ const y = run.underline ? baseline + spec.fontSize * 0.4 : baseline;
2356
+ ctx.fillRect(x, y - thickness / 2, width, thickness);
2357
+ }
2358
+ x += width;
2359
+ }
2360
+ }
2361
+ return canvas;
2362
+ }
2363
+ function runFont(style, spec) {
2364
+ const parts = [];
2365
+ if (style.italic) parts.push("italic");
2366
+ if (style.bold) parts.push("bold");
2367
+ parts.push(`${spec.fontSize}px`, spec.fontFamily);
2368
+ return parts.join(" ");
2369
+ }
2370
+ function round(v) {
2371
+ return Math.round(v * 100) / 100;
2372
+ }
2373
+ var TEXT_ALIGNS = ["start", "middle", "end"];
2374
+ function readTextProps(shape) {
2375
+ const p = propsOf(shape);
2376
+ return {
2377
+ color: readStyle(p, "color", DefaultColorStyle),
2378
+ size: readStyle(p, "size", DefaultSizeStyle),
2379
+ font: readStyle(p, "font", DefaultFontStyle),
2380
+ textAlign: readEnum(p, "textAlign", TEXT_ALIGNS, "start"),
2381
+ w: readNumber(p, "w", 100),
2382
+ richText: readRichText(p),
2383
+ text: readText(p),
2384
+ scale: readNumber(p, "scale", 1),
2385
+ autoSize: readBoolean(p, "autoSize", true)
2386
+ };
2387
+ }
2388
+ function getTextShapeSizeFor(shape, editor) {
2389
+ const { richText, size, scale, w, font, autoSize } = readTextProps(shape);
2390
+ return getTextShapeSize({
2391
+ text: richText,
2392
+ fontFamily: font,
2393
+ fontSize: FONT_SIZES[size] * scale,
2394
+ autoSize,
2395
+ w,
2396
+ editor: editor ?? null
2397
+ });
2398
+ }
2399
+ function getTextShapeHeight(shape, editor) {
2400
+ return getTextShapeSizeFor(shape, editor).h;
2401
+ }
2402
+ function getTextShapeBox(shape, editor) {
2403
+ const { w, h } = getTextShapeSizeFor(shape, editor);
2404
+ const props = readTextProps(shape);
2405
+ return { w: Math.max(1, props.autoSize ? Math.max(w, props.w) : props.w), h };
2406
+ }
2407
+ function getTextShapeTextureSpec(editor, shape) {
2408
+ const { text, font, size, scale, color, textAlign, autoSize } = readTextProps(shape);
2409
+ const box = getTextShapeBox(shape, editor);
2410
+ return {
2411
+ text,
2412
+ fontFamily: getFontFamily(font, getTheme(editor)),
2413
+ fontSize: FONT_SIZES[size] * scale,
2414
+ color: getTextCssColor(color, getThemeColors(editor)),
2415
+ align: textAlign,
2416
+ verticalAlign: "start",
2417
+ lineHeight: LINE_HEIGHT,
2418
+ width: box.w,
2419
+ height: box.h,
2420
+ ...autoSize ? {} : { maxWidth: box.w },
2421
+ resolution: editor.getTextureResolution()
2422
+ };
2423
+ }
2424
+ function canRasterizeText() {
2425
+ return typeof document !== "undefined";
2426
+ }
2427
+ var SIZE_KEYS = ["richText", "text", "font", "size", "scale", "autoSize"];
2428
+ function getTextDisplayValues(editor, shape, theme, colorMode) {
2429
+ const base = getDefaultDisplayValues(editor, shape, theme, colorMode);
2430
+ const scale = readNumber(propsOf(shape), "scale", 1);
2431
+ return {
2432
+ ...base,
2433
+ labelFontSize: base.fontSize * scale,
2434
+ labelLineHeight: base.fontSize * scale * LINE_HEIGHT,
2435
+ labelFontFamily: base.fontFamily
2436
+ };
2437
+ }
2438
+ var TextShapeUtil = class extends ShapeUtil {
2439
+ static type = "text";
2440
+ static props = textShapeProps;
2441
+ static migrations = textShapeMigrations;
2442
+ static options = { getDefaultDisplayValues: getTextDisplayValues };
2443
+ getDefaultProps() {
2444
+ return {
2445
+ color: "black",
2446
+ size: "m",
2447
+ font: "draw",
2448
+ textAlign: "start",
2449
+ w: 100,
2450
+ richText: toRichText(""),
2451
+ text: "",
2452
+ scale: 1,
2453
+ autoSize: true
2454
+ };
2455
+ }
2456
+ getGeometry(shape) {
2457
+ const box = getTextShapeBox(shape, this.editor);
2458
+ return new Rectangle2d({ width: box.w, height: box.h, isFilled: true });
2459
+ }
2460
+ /** A text shape is nothing but a label, so it always needs its family's faces. */
2461
+ getFontFaces(shape) {
2462
+ return getLabelFontFaces(readTextProps(shape).font);
2463
+ }
2464
+ /**
2465
+ * A texture of the rasterized label, so the GPU draws the text instead of the
2466
+ * DOM. The shape being edited (and any environment without a canvas) keeps
2467
+ * the DOM path; `fill` is the text colour so the level-of-detail quad the
2468
+ * engine draws below a few pixels still looks right.
2469
+ */
2470
+ getRenderStyle(shape) {
2471
+ const key = this.getTextureKey(shape);
2472
+ if (!key) return null;
2473
+ const texture = this.editor.textures.acquire(key.key, async () => renderTextToCanvas(key.spec));
2474
+ if (!texture) return null;
2475
+ return {
2476
+ fill: getStrokeRgba(readTextProps(shape).color, getThemeColors(this.editor)),
2477
+ stroke: 0,
2478
+ strokeWidth: 0,
2479
+ dash: 0,
2480
+ opacity: 1,
2481
+ texture
2482
+ };
2483
+ }
2484
+ /** The DOM label stands in while editing and until the texture is ready. */
2485
+ needsOverlay(shape) {
2486
+ const key = this.getTextureKey(shape);
2487
+ return key === null || !this.editor.textures.isReady(key.key);
2488
+ }
2489
+ getTextureKey(shape) {
2490
+ if (this.editor.getEditingShapeId() === shape.id) return null;
2491
+ if (readText(shape.props).length === 0) return null;
2492
+ if (!canRasterizeText()) return null;
2493
+ const spec = getTextShapeTextureSpec(this.editor, shape);
2494
+ return { key: getTextTextureKey(spec), spec };
2495
+ }
2496
+ component(shape) {
2497
+ const { richText, text, font, size, scale, textAlign, w, autoSize } = readTextProps(shape);
2498
+ const display = getDisplayValues(this, shape);
2499
+ return /* @__PURE__ */ jsx(
2500
+ TextLabel,
2501
+ {
2502
+ shape,
2503
+ text,
2504
+ richText,
2505
+ isEditing: this.editor.getEditingShapeId() === shape.id,
2506
+ fontFamily: font,
2507
+ fontSize: FONT_SIZES[size] * scale,
2508
+ color: display.color,
2509
+ textAlign,
2510
+ verticalAlign: "start",
2511
+ wrap: !autoSize,
2512
+ width: Math.max(1, w),
2513
+ onChange: (next) => this.editor.updateShape({
2514
+ id: shape.id,
2515
+ type: "text",
2516
+ props: { text: next, richText: applyPlainTextToRichText(richText, next) }
2517
+ }),
2518
+ onChangeRichText: (next) => this.editor.updateShape({
2519
+ id: shape.id,
2520
+ type: "text",
2521
+ props: { richText: next, text: richTextToText(next) }
2522
+ })
2523
+ }
2524
+ );
2525
+ }
2526
+ getIndicatorPath(shape) {
2527
+ const b = this.getGeometry(shape).bounds;
2528
+ return rectPath(b.w, b.h);
2529
+ }
2530
+ canEdit(_shape) {
2531
+ return true;
2532
+ }
2533
+ isAspectRatioLocked(_shape) {
2534
+ return false;
2535
+ }
2536
+ getText(shape) {
2537
+ return readText(shape.props);
2538
+ }
2539
+ /** Auto-sized text keeps `w` in sync with its measured width. */
2540
+ onBeforeCreate(next) {
2541
+ return this.fitWidth(next);
2542
+ }
2543
+ onBeforeUpdate(prev, next) {
2544
+ if (!readTextProps(next).autoSize) return;
2545
+ if (!SIZE_KEYS.some((k) => propsOf(prev)[k] !== propsOf(next)[k])) return;
2546
+ return this.fitWidth(next);
2547
+ }
2548
+ fitWidth(shape) {
2549
+ const props = readTextProps(shape);
2550
+ if (!props.autoSize) return;
2551
+ const { w } = getTextShapeSizeFor(shape, this.editor);
2552
+ if (w !== props.w) return { ...shape, props: { ...shape.props, w } };
2553
+ }
2554
+ /** A text shape left empty after editing is removed. */
2555
+ onEditEnd(shape) {
2556
+ if (readText(shape.props).trim().length === 0) this.editor.deleteShapes([shape.id]);
2557
+ }
2558
+ /** Resizing a text shape changes its wrap width and turns auto-size off. */
2559
+ onResize(shape, info) {
2560
+ const { scaleX, initialShape, newPoint } = info;
2561
+ const w = Math.max(1, Math.abs(readTextProps(initialShape).w * scaleX));
2562
+ return { x: newPoint.x, y: newPoint.y, props: { ...shape.props, w, autoSize: false } };
2563
+ }
2564
+ };
2565
+ var VIDEO_WIDTH = 640;
2566
+ var VIDEO_HEIGHT = 360;
2567
+ var VIDEO_PLACEHOLDER_FILL = "#eceff3";
2568
+ var VIDEO_PLACEHOLDER_STROKE = "#9fa8b2";
2569
+ var VIDEO_PLAY_COLOR = "#5f6670";
2570
+ var VIDEO_PLAY_SIZE = 48;
2571
+ var VIDEO_TIME_EPSILON = 0.1;
2572
+ function isVideoAutoplayAllowed(editor) {
2573
+ return editor?.options?.allowVideoAutoplay !== false;
2574
+ }
2575
+ function getVideoSource(editor, shape) {
2576
+ const assetId = readString(propsOf(shape), "assetId", "");
2577
+ if (!assetId) return null;
2578
+ const asset = editor.getAsset(assetId);
2579
+ if (!asset || asset.type !== "video") return null;
2580
+ return asset.props.src ?? null;
2581
+ }
2582
+ function readVideoBox(shape) {
2583
+ const p = propsOf(shape);
2584
+ return { w: readNumber(p, "w", VIDEO_WIDTH), h: readNumber(p, "h", VIDEO_HEIGHT) };
2585
+ }
2586
+ function getVideoPlayTriangle(w, h, size = VIDEO_PLAY_SIZE) {
2587
+ const s = Math.max(0, Math.min(size, w * 0.6, h * 0.6));
2588
+ const cx = w / 2;
2589
+ const cy = h / 2;
2590
+ const half = s / 2;
2591
+ return [
2592
+ { x: cx - half * 0.6, y: cy - half },
2593
+ { x: cx - half * 0.6, y: cy + half },
2594
+ { x: cx + half * 0.9, y: cy }
2595
+ ];
2596
+ }
2597
+ function VideoPlayer({ src, time, playing, controls, altText }) {
2598
+ const ref = useRef(null);
2599
+ useEffect(() => {
2600
+ const el = ref.current;
2601
+ if (!el) return;
2602
+ const seek = () => {
2603
+ if (Math.abs(el.currentTime - time) <= VIDEO_TIME_EPSILON) return;
2604
+ try {
2605
+ el.currentTime = time;
2606
+ } catch {
2607
+ }
2608
+ };
2609
+ seek();
2610
+ el.addEventListener("loadedmetadata", seek);
2611
+ return () => el.removeEventListener("loadedmetadata", seek);
2612
+ }, [time, src]);
2613
+ useEffect(() => {
2614
+ const el = ref.current;
2615
+ if (!el) return;
2616
+ if (playing) void el.play().catch(() => {
2617
+ });
2618
+ else el.pause();
2619
+ }, [playing, src]);
2620
+ return /* @__PURE__ */ jsx(
2621
+ "video",
2622
+ {
2623
+ ref,
2624
+ src,
2625
+ controls,
2626
+ playsInline: true,
2627
+ muted: true,
2628
+ loop: true,
2629
+ preload: "metadata",
2630
+ "aria-label": altText || void 0,
2631
+ draggable: false,
2632
+ style: {
2633
+ display: "block",
2634
+ width: "100%",
2635
+ height: "100%",
2636
+ objectFit: "cover",
2637
+ // Controls are only reachable while editing; otherwise the canvas
2638
+ // keeps the pointer so a drag over a video still pans.
2639
+ pointerEvents: controls ? "auto" : "none",
2640
+ userSelect: "none"
2641
+ }
2642
+ }
2643
+ );
2644
+ }
2645
+ function getVideoDisplayValues(editor, shape, theme, colorMode) {
2646
+ return {
2647
+ ...getDefaultDisplayValues(editor, shape, theme, colorMode),
2648
+ placeholderFill: VIDEO_PLACEHOLDER_FILL,
2649
+ placeholderStroke: VIDEO_PLACEHOLDER_STROKE,
2650
+ playColor: VIDEO_PLAY_COLOR,
2651
+ playSize: VIDEO_PLAY_SIZE
2652
+ };
2653
+ }
2654
+ var VideoShapeUtil = class extends BaseBoxShapeUtil {
2655
+ static type = "video";
2656
+ static props = videoShapeProps;
2657
+ static migrations = videoShapeMigrations;
2658
+ static options = { getDefaultDisplayValues: getVideoDisplayValues };
2659
+ getDefaultProps() {
2660
+ return { w: VIDEO_WIDTH, h: VIDEO_HEIGHT, assetId: null, time: 0, playing: true, url: "", altText: "" };
2661
+ }
2662
+ getGeometry(shape) {
2663
+ const { w, h } = readVideoBox(shape);
2664
+ return new Rectangle2d({ width: Math.max(1, w), height: Math.max(1, h), isFilled: true });
2665
+ }
2666
+ /** A moving picture is not a texture the engine owns: video renders in the DOM overlay. */
2667
+ getRenderStyle(_shape) {
2668
+ return null;
2669
+ }
2670
+ component(shape) {
2671
+ const { w, h } = readVideoBox(shape);
2672
+ const p = propsOf(shape);
2673
+ const altText = readString(p, "altText", "");
2674
+ const src = getVideoSource(this.editor, shape);
2675
+ const box = { position: "absolute", left: 0, top: 0, width: w, height: h, overflow: "hidden", boxSizing: "border-box" };
2676
+ if (!src) {
2677
+ const [a, b, c] = getVideoPlayTriangle(w, h);
2678
+ return /* @__PURE__ */ jsx(
2679
+ "div",
2680
+ {
2681
+ style: { ...box, background: VIDEO_PLACEHOLDER_FILL, border: `1px dashed ${VIDEO_PLACEHOLDER_STROKE}`, pointerEvents: "none" },
2682
+ "aria-label": altText || "video",
2683
+ children: /* @__PURE__ */ jsx("svg", { width: w, height: h, viewBox: `0 0 ${Math.max(1, w)} ${Math.max(1, h)}`, style: { display: "block" }, "aria-hidden": "true", children: /* @__PURE__ */ jsx("polygon", { points: `${a.x},${a.y} ${b.x},${b.y} ${c.x},${c.y}`, fill: VIDEO_PLAY_COLOR }) })
2684
+ }
2685
+ );
2686
+ }
2687
+ return /* @__PURE__ */ jsx("div", { style: box, children: /* @__PURE__ */ jsx(
2688
+ VideoPlayer,
2689
+ {
2690
+ src,
2691
+ time: readNumber(p, "time", 0),
2692
+ playing: readBoolean(p, "playing", true) && isVideoAutoplayAllowed(this.editor),
2693
+ controls: this.editor.getEditingShapeId() === shape.id,
2694
+ altText
2695
+ }
2696
+ ) });
2697
+ }
2698
+ getIndicatorPath(shape) {
2699
+ const { w, h } = readVideoBox(shape);
2700
+ return rectPath(w, h);
2701
+ }
2702
+ /** Editing a video means taking its controls, not typing into it. */
2703
+ canEdit(_shape) {
2704
+ return true;
2705
+ }
2706
+ isAspectRatioLocked(_shape) {
2707
+ return true;
2708
+ }
2709
+ };
2710
+ function svgNum(v) {
2711
+ if (!Number.isFinite(v)) return "0";
2712
+ const r = Math.round(v * 100) / 100;
2713
+ return String(r === 0 ? 0 : r);
2714
+ }
2715
+ function escapeXml(text) {
2716
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
2717
+ }
2718
+ function rgbaToHex(word) {
2719
+ const w = word >>> 0;
2720
+ const a = w & 255;
2721
+ if (a === 0) return void 0;
2722
+ const r = w >>> 24 & 255;
2723
+ const g = w >>> 16 & 255;
2724
+ const b = w >>> 8 & 255;
2725
+ const hex = (n) => n.toString(16).padStart(2, "0");
2726
+ return a === 255 ? `#${hex(r)}${hex(g)}${hex(b)}` : `#${hex(r)}${hex(g)}${hex(b)}${hex(a)}`;
2727
+ }
2728
+ function matrixAttr(m) {
2729
+ return `matrix(${svgNum(m.a)} ${svgNum(m.b)} ${svgNum(m.c)} ${svgNum(m.d)} ${svgNum(m.e)} ${svgNum(m.f)})`;
2730
+ }
2731
+ function dashArray(dash, strokeWidth) {
2732
+ const sw = Math.max(0.5, strokeWidth);
2733
+ switch (dash) {
2734
+ case 1:
2735
+ return `${svgNum(sw * 2)} ${svgNum(sw * 2)}`;
2736
+ case 2:
2737
+ return `0.1 ${svgNum(sw * 2)}`;
2738
+ default:
2739
+ return void 0;
2740
+ }
2741
+ }
2742
+ function attrs(values) {
2743
+ const parts = [];
2744
+ for (const [k, v] of Object.entries(values)) {
2745
+ if (v === void 0) continue;
2746
+ parts.push(`${k}="${typeof v === "number" ? svgNum(v) : escapeXml(v)}"`);
2747
+ }
2748
+ return parts.join(" ");
2749
+ }
2750
+ function strokeAttrs(style) {
2751
+ const stroke = rgbaToHex(style.stroke);
2752
+ if (!stroke || style.strokeWidth <= 0) return { stroke: "none" };
2753
+ return {
2754
+ stroke,
2755
+ "stroke-width": style.strokeWidth,
2756
+ "stroke-linejoin": "round",
2757
+ "stroke-linecap": "round",
2758
+ "stroke-dasharray": dashArray(style.dash, style.strokeWidth)
2759
+ };
2760
+ }
2761
+ function isGroup(g) {
2762
+ return Array.isArray(g.children);
2763
+ }
2764
+ function geometryLeaves(geometry) {
2765
+ if (!isGroup(geometry)) return geometry.isLabel ? [] : [geometry];
2766
+ return geometry.children.flatMap((c) => geometryLeaves(c));
2767
+ }
2768
+ function geometryLabels(geometry) {
2769
+ if (!isGroup(geometry)) return geometry.isLabel ? [geometry] : [];
2770
+ return geometry.children.flatMap((c) => geometryLabels(c));
2771
+ }
2772
+ function hasPathWords(words) {
2773
+ return words.length > 0 && words[0] === PATH_OP.MOVE;
2774
+ }
2775
+ function geometryToSvgPaths(geometry, style) {
2776
+ const fill = rgbaToHex(style.fill);
2777
+ const stroke = strokeAttrs(style);
2778
+ const out = [];
2779
+ for (const leaf of geometryLeaves(geometry)) {
2780
+ const words = leaf.toPathWords();
2781
+ if (!hasPathWords(words)) continue;
2782
+ const filled = fill !== void 0 && leaf.isClosed && leaf.isFilled;
2783
+ out.push(`<path ${attrs({ d: pathWordsToSvgD(words), fill: filled ? fill : "none", ...stroke })}/>`);
2784
+ }
2785
+ return out.join("");
2786
+ }
2787
+
2788
+ // src/export/text-svg.ts
2789
+ function alignOf(opts) {
2790
+ return opts.textAlign ?? opts.align ?? "middle";
2791
+ }
2792
+ function wrapTextLines(text, fontSize, maxWidth = Infinity) {
2793
+ const charW = fontSize * AVG_CHAR_WIDTH;
2794
+ const limit = Number.isFinite(maxWidth) && charW > 0 ? Math.max(1, Math.floor(maxWidth / charW)) : Infinity;
2795
+ const lines = [];
2796
+ for (const paragraph of text.split("\n")) {
2797
+ if (paragraph.length <= limit) {
2798
+ lines.push(paragraph);
2799
+ continue;
2800
+ }
2801
+ let current = "";
2802
+ for (const word of paragraph.split(" ")) {
2803
+ const candidate = current.length === 0 ? word : `${current} ${word}`;
2804
+ if (candidate.length <= limit) {
2805
+ current = candidate;
2806
+ continue;
2807
+ }
2808
+ if (current.length > 0) lines.push(current);
2809
+ let rest = word;
2810
+ while (rest.length > limit) {
2811
+ lines.push(rest.slice(0, limit));
2812
+ rest = rest.slice(limit);
2813
+ }
2814
+ current = rest;
2815
+ }
2816
+ lines.push(current);
2817
+ }
2818
+ return lines;
2819
+ }
2820
+ function anchorFor(align) {
2821
+ switch (align) {
2822
+ case "start":
2823
+ case "start-legacy":
2824
+ return "start";
2825
+ case "end":
2826
+ case "end-legacy":
2827
+ return "end";
2828
+ default:
2829
+ return "middle";
2830
+ }
2831
+ }
2832
+ function wrapRichTextLines(source, fontSize, maxWidth = Infinity) {
2833
+ const charW = fontSize * AVG_CHAR_WIDTH;
2834
+ const limit = Number.isFinite(maxWidth) && charW > 0 ? Math.max(1, Math.floor(maxWidth / charW)) : Infinity;
2835
+ const lines = [];
2836
+ for (const block of richTextToBlocks(source)) {
2837
+ let line = [];
2838
+ let length = 0;
2839
+ const push = () => {
2840
+ while (line.length > 0 && /^\s+$/u.test(line[line.length - 1].text)) line.pop();
2841
+ lines.push(mergeSvgRuns(line));
2842
+ line = [];
2843
+ length = 0;
2844
+ };
2845
+ for (const run of block.runs) {
2846
+ for (const token of run.text.split(/(\s+)/u).filter((t) => t.length > 0)) {
2847
+ const isSpace = /^\s+$/u.test(token);
2848
+ if (!isSpace && length > 0 && length + token.length > limit) push();
2849
+ if (isSpace && length === 0) continue;
2850
+ if (!isSpace && token.length > limit) {
2851
+ let rest = token;
2852
+ while (rest.length > limit) {
2853
+ if (length > 0) push();
2854
+ line.push({ ...run, text: rest.slice(0, limit) });
2855
+ length = limit;
2856
+ rest = rest.slice(limit);
2857
+ push();
2858
+ }
2859
+ if (rest.length > 0) {
2860
+ line.push({ ...run, text: rest });
2861
+ length += rest.length;
2862
+ }
2863
+ continue;
2864
+ }
2865
+ line.push({ ...run, text: token });
2866
+ length += token.length;
2867
+ }
2868
+ }
2869
+ push();
2870
+ }
2871
+ return lines;
2872
+ }
2873
+ function mergeSvgRuns(runs) {
2874
+ const out = [];
2875
+ for (const run of runs) {
2876
+ const last = out[out.length - 1];
2877
+ if (last && last.bold === run.bold && last.italic === run.italic && last.underline === run.underline && last.strike === run.strike && last.code === run.code && last.href === run.href) last.text += run.text;
2878
+ else out.push({ ...run });
2879
+ }
2880
+ return out;
2881
+ }
2882
+ function runAttrs(run, opts) {
2883
+ const decorations = [];
2884
+ if (run.underline) decorations.push("underline");
2885
+ if (run.strike) decorations.push("line-through");
2886
+ return {
2887
+ "font-weight": run.bold ? "bold" : opts.fontWeight === void 0 ? void 0 : String(opts.fontWeight),
2888
+ "font-style": run.italic ? "italic" : void 0,
2889
+ "text-decoration": decorations.length === 0 ? void 0 : decorations.join(" ")
2890
+ };
2891
+ }
2892
+ function textToSvg(source, box, opts) {
2893
+ const rich = isRichText(source);
2894
+ const plain = richTextToText(source);
2895
+ if (plain.length === 0) return "";
2896
+ const padding = opts.padding ?? 0;
2897
+ const innerX = box.x + padding;
2898
+ const innerY = box.y + padding;
2899
+ const innerW = Math.max(0, box.w - padding * 2);
2900
+ const innerH = Math.max(0, box.h - padding * 2);
2901
+ const wrapWidth = opts.wrap ? innerW : Infinity;
2902
+ const lineCount = rich ? wrapRichTextLines(source, opts.fontSize, wrapWidth).length : wrapTextLines(plain, opts.fontSize, wrapWidth).length;
2903
+ const lineH = opts.fontSize * LINE_HEIGHT;
2904
+ const blockH = lineCount * lineH;
2905
+ const anchor = anchorFor(alignOf(opts));
2906
+ const x = anchor === "start" ? innerX : anchor === "end" ? innerX + innerW : innerX + innerW / 2;
2907
+ const top = opts.verticalAlign === "start" ? innerY : opts.verticalAlign === "end" ? innerY + innerH - blockH : innerY + (innerH - blockH) / 2;
2908
+ const lineY = (i) => top + lineH * i + lineH / 2;
2909
+ const spans = rich ? wrapRichTextLines(source, opts.fontSize, wrapWidth).map((runs, i) => {
2910
+ const content = runs.length === 0 ? "\u200B" : runs.map((run) => {
2911
+ const a2 = attrs(runAttrs(run, opts));
2912
+ const inner = `<tspan${a2.length === 0 ? "" : ` ${a2}`}>${escapeXml(run.text)}</tspan>`;
2913
+ const href = safeHref(run.href);
2914
+ return href === null ? inner : `<a ${attrs({ href })}>${inner}</a>`;
2915
+ }).join("");
2916
+ return `<tspan x="${svgNum(x)}" y="${svgNum(lineY(i))}">${content}</tspan>`;
2917
+ }).join("") : wrapTextLines(plain, opts.fontSize, wrapWidth).map((line, i) => `<tspan x="${svgNum(x)}" y="${svgNum(lineY(i))}">${line.length === 0 ? "\u200B" : escapeXml(line)}</tspan>`).join("");
2918
+ const a = attrs({
2919
+ "font-family": opts.fontFamily,
2920
+ "font-size": opts.fontSize,
2921
+ "font-weight": opts.fontWeight === void 0 ? void 0 : String(opts.fontWeight),
2922
+ fill: opts.color,
2923
+ "text-anchor": anchor,
2924
+ "dominant-baseline": "central",
2925
+ "xml:space": "preserve"
2926
+ });
2927
+ return `<text ${a}>${spans}</text>`;
2928
+ }
2929
+
2930
+ // src/export/shape-svg.ts
2931
+ function geometryFallbackSvg(editor, shape) {
2932
+ const geometry = editor.getShapeGeometry(shape);
2933
+ const style = editor.getShapeUtil(shape).getRenderStyle(shape) ?? {
2934
+ fill: 0,
2935
+ stroke: hexToRgba(LIGHT_THEME.text),
2936
+ strokeWidth: 1,
2937
+ dash: 0};
2938
+ return geometryToSvgPaths(geometry, style);
2939
+ }
2940
+ var defaultShapeSvgRenderer = (editor, shape) => geometryFallbackSvg(editor, shape);
2941
+ function svgId(id) {
2942
+ return id.replace(/[^a-zA-Z0-9_-]/g, "-");
2943
+ }
2944
+ function labelBox(editor, shape) {
2945
+ const label = geometryLabels(editor.getShapeGeometry(shape))[0];
2946
+ if (!label) return void 0;
2947
+ const b = label.bounds;
2948
+ return { x: b.x, y: b.y, w: b.w, h: b.h };
2949
+ }
2950
+ var geoSvg = (editor, shape) => {
2951
+ const { text, font, size, scale, labelColor, align, verticalAlign } = shape.props;
2952
+ let out = geometryFallbackSvg(editor, shape);
2953
+ if (!text) return out;
2954
+ const box = labelBox(editor, shape);
2955
+ if (!box) return out;
2956
+ out += textToSvg(text, box, {
2957
+ fontFamily: getFontFamily(font),
2958
+ fontSize: FONT_SIZES[size] * scale,
2959
+ color: getTextCssColor(labelColor),
2960
+ align,
2961
+ verticalAlign,
2962
+ padding: GEO_LABEL_PADDING * scale,
2963
+ wrap: true
2964
+ });
2965
+ return out;
2966
+ };
2967
+ var drawSvg = (editor, shape) => geometryFallbackSvg(editor, shape);
2968
+ var lineSvg = (editor, shape) => geometryFallbackSvg(editor, shape);
2969
+ var arrowSvg = (editor, shape, ctx) => {
2970
+ const { text, font, size, scale, labelColor } = shape.props;
2971
+ let out = geometryFallbackSvg(editor, shape);
2972
+ if (!text) return out;
2973
+ const box = labelBox(editor, shape);
2974
+ if (!box) return out;
2975
+ out += `<rect ${attrs({ x: box.x, y: box.y, width: box.w, height: box.h, rx: 4, fill: ctx.background })}/>`;
2976
+ out += textToSvg(text, box, {
2977
+ fontFamily: getFontFamily(font),
2978
+ fontSize: FONT_SIZES[size] * scale,
2979
+ color: getTextCssColor(labelColor),
2980
+ align: "middle",
2981
+ verticalAlign: "middle",
2982
+ padding: ARROW_LABEL_PADDING * scale,
2983
+ wrap: false
2984
+ });
2985
+ return out;
2986
+ };
2987
+ var textSvg = (editor, shape) => {
2988
+ const { text, font, size, scale, color, textAlign, autoSize } = shape.props;
2989
+ const b = editor.getShapeGeometry(shape).bounds;
2990
+ return textToSvg(text, { x: b.x, y: b.y, w: b.w, h: b.h }, {
2991
+ fontFamily: getFontFamily(font),
2992
+ fontSize: FONT_SIZES[size] * scale,
2993
+ color: getTextCssColor(color),
2994
+ align: textAlign,
2995
+ verticalAlign: "start",
2996
+ wrap: !autoSize
2997
+ });
2998
+ };
2999
+ function noteTrimDefs(shape, bottom, ids) {
3000
+ const scale = shape.props.scale;
3001
+ const gradient = `<linearGradient ${attrs({ id: ids.gradient, x1: 0, y1: 0, x2: 0, y2: 1 })}><stop ${attrs({ offset: 0, "stop-color": getNoteGradientTopFrom(bottom) })}/><stop ${attrs({ offset: 1, "stop-color": bottom })}/></linearGradient>`;
3002
+ const filter = `<filter ${attrs({ id: ids.shadow, x: "-50%", y: "-50%", width: "200%", height: "200%" })}><feGaussianBlur ${attrs({ stdDeviation: getNoteShadowSvgRect(0, 0, scale).stdDeviation })}/></filter>`;
3003
+ return `<defs>${gradient}${filter}</defs>`;
3004
+ }
3005
+ var noteSvg = (editor, shape) => {
3006
+ const { text, font, color, labelColor, align, verticalAlign, scale, growY } = shape.props;
3007
+ const w = NOTE_SIZE * scale;
3008
+ const h = NOTE_SIZE * scale + growY;
3009
+ const style = editor.getShapeUtil(shape).getRenderStyle(shape);
3010
+ const bottom = (style && rgbaToHex(style.fill)) ?? getNoteFillCssColor(color);
3011
+ const suffix = svgId(shape.id);
3012
+ const ids = { gradient: `mc-note-fill-${suffix}`, shadow: `mc-note-shadow-${suffix}` };
3013
+ let out = noteTrimDefs(shape, bottom, ids);
3014
+ const sh = getNoteShadowSvgRect(w, h, scale);
3015
+ if (sh.w > 0 && sh.h > 0) {
3016
+ out += `<rect ${attrs({
3017
+ x: sh.x,
3018
+ y: sh.y,
3019
+ width: sh.w,
3020
+ height: sh.h,
3021
+ fill: NOTE_SHADOW_COLOR,
3022
+ "fill-opacity": NOTE_SHADOW_OPACITY,
3023
+ filter: `url(#${ids.shadow})`
3024
+ })}/>`;
3025
+ }
3026
+ out += `<rect ${attrs({ x: 0, y: 0, width: w, height: h, fill: `url(#${ids.gradient})` })}/>`;
3027
+ if (!text) return out;
3028
+ const textColor = labelColor === "black" ? getNoteTextCssColor(color) : LIGHT_THEME[labelColor].solid;
3029
+ out += textToSvg(text, { x: 0, y: 0, w, h }, {
3030
+ fontFamily: getFontFamily(font),
3031
+ fontSize: getNoteFontSize(shape),
3032
+ color: textColor,
3033
+ align,
3034
+ verticalAlign,
3035
+ padding: NOTE_PADDING * scale,
3036
+ wrap: true
3037
+ });
3038
+ return out;
3039
+ };
3040
+ var frameSvg = (_editor, shape) => {
3041
+ const { w, h, name } = shape.props;
3042
+ let out = `<rect ${attrs({ x: 0, y: 0, width: w, height: h, fill: FRAME_FILL, stroke: FRAME_STROKE, "stroke-width": FRAME_STROKE_WIDTH })}/>`;
3043
+ if (name) {
3044
+ const [firstLine = ""] = name.split("\n");
3045
+ out += textToSvg(firstLine, { x: 0, y: -FRAME_NAME_OFFSET, w, h: FRAME_NAME_HEIGHT }, {
3046
+ fontFamily: getFontFamily("sans"),
3047
+ fontSize: FRAME_NAME_FONT_SIZE,
3048
+ color: FRAME_NAME_COLOR,
3049
+ align: "start",
3050
+ verticalAlign: "end",
3051
+ wrap: false
3052
+ });
3053
+ }
3054
+ return out;
3055
+ };
3056
+ var bookmarkSvg = (editor, shape) => {
3057
+ const { w, h } = shape.props;
3058
+ const card = getBookmarkCard(editor, shape);
3059
+ const layout = getBookmarkLayout(w, h);
3060
+ const sans = getFontFamily("sans");
3061
+ let out = `<rect ${attrs({
3062
+ x: 0,
3063
+ y: 0,
3064
+ width: w,
3065
+ height: h,
3066
+ rx: BOOKMARK_RADIUS,
3067
+ fill: BOOKMARK_FILL,
3068
+ stroke: BOOKMARK_STROKE,
3069
+ "stroke-width": BOOKMARK_STROKE_WIDTH
3070
+ })}/>`;
3071
+ if (layout.banner.h > 0) {
3072
+ out += `<rect ${attrs({ x: 0, y: 0, width: layout.banner.w, height: layout.banner.h, fill: BOOKMARK_BANNER_FILL })}/>`;
3073
+ }
3074
+ const title = card.hasAsset ? card.title : card.hostname || card.url;
3075
+ out += textToSvg(title, layout.title, {
3076
+ fontFamily: sans,
3077
+ fontSize: BOOKMARK_TITLE_FONT_SIZE,
3078
+ color: card.hasAsset ? BOOKMARK_TITLE_COLOR : BOOKMARK_META_COLOR,
3079
+ align: "start",
3080
+ verticalAlign: "start",
3081
+ ...card.hasAsset ? { fontWeight: 600 } : {},
3082
+ wrap: true
3083
+ });
3084
+ if (!card.hasAsset) return out;
3085
+ out += textToSvg(card.description, layout.description, {
3086
+ fontFamily: sans,
3087
+ fontSize: BOOKMARK_TEXT_FONT_SIZE,
3088
+ color: BOOKMARK_TEXT_COLOR,
3089
+ align: "start",
3090
+ verticalAlign: "start",
3091
+ wrap: true
3092
+ });
3093
+ if (card.favicon) {
3094
+ out += `<rect ${attrs({ ...boxAttrs(layout.favicon), rx: 2, fill: BOOKMARK_BANNER_FILL })}/>`;
3095
+ }
3096
+ out += textToSvg(card.hostname, layout.hostname, {
3097
+ fontFamily: sans,
3098
+ fontSize: BOOKMARK_META_FONT_SIZE,
3099
+ color: BOOKMARK_META_COLOR,
3100
+ align: "start",
3101
+ verticalAlign: "middle",
3102
+ wrap: false
3103
+ });
3104
+ return out;
3105
+ };
3106
+ function boxAttrs(box) {
3107
+ return { x: box.x, y: box.y, width: box.w, height: box.h };
3108
+ }
3109
+ var embedSvg = (_editor, shape) => {
3110
+ const { w, h, url } = shape.props;
3111
+ const match = getEmbedDefinition(url);
3112
+ const label = match ? `${match.definition.title}
3113
+ ${url}` : url;
3114
+ let out = `<rect ${attrs({
3115
+ x: 0,
3116
+ y: 0,
3117
+ width: w,
3118
+ height: h,
3119
+ rx: EMBED_RADIUS,
3120
+ fill: EMBED_PLACEHOLDER_FILL,
3121
+ stroke: EMBED_PLACEHOLDER_STROKE,
3122
+ "stroke-width": 1,
3123
+ "stroke-dasharray": "4 4"
3124
+ })}/>`;
3125
+ out += textToSvg(label, { x: 0, y: 0, w, h }, {
3126
+ fontFamily: getFontFamily("sans"),
3127
+ fontSize: EMBED_PLACEHOLDER_FONT_SIZE,
3128
+ color: EMBED_PLACEHOLDER_TEXT,
3129
+ align: "middle",
3130
+ verticalAlign: "middle",
3131
+ padding: EMBED_PLACEHOLDER_PADDING,
3132
+ wrap: true
3133
+ });
3134
+ return out;
3135
+ };
3136
+ var videoSvg = (editor, shape) => {
3137
+ const { w, h, altText } = shape.props;
3138
+ const hasSource = getVideoSource(editor, shape) !== null;
3139
+ let out = `<rect ${attrs({
3140
+ x: 0,
3141
+ y: 0,
3142
+ width: w,
3143
+ height: h,
3144
+ fill: VIDEO_PLACEHOLDER_FILL,
3145
+ stroke: VIDEO_PLACEHOLDER_STROKE,
3146
+ "stroke-width": 1,
3147
+ "stroke-dasharray": hasSource ? void 0 : "4 4"
3148
+ })}/>`;
3149
+ const points = getVideoPlayTriangle(w, h).map((p) => `${svgNum(p.x)},${svgNum(p.y)}`).join(" ");
3150
+ out += `<polygon ${attrs({ points, fill: VIDEO_PLAY_COLOR })}/>`;
3151
+ if (altText) {
3152
+ out += `<title>${escapeXml(altText)}</title>`;
3153
+ }
3154
+ return out;
3155
+ };
3156
+ var shapeSvgRenderers = /* @__PURE__ */ new Map([
3157
+ ["geo", geoSvg],
3158
+ ["draw", drawSvg],
3159
+ ["line", lineSvg],
3160
+ ["arrow", arrowSvg],
3161
+ ["text", textSvg],
3162
+ ["note", noteSvg],
3163
+ ["frame", frameSvg],
3164
+ ["bookmark", bookmarkSvg],
3165
+ ["embed", embedSvg],
3166
+ ["video", videoSvg]
3167
+ ]);
3168
+ function registerShapeSvgRenderer(type, renderer) {
3169
+ shapeSvgRenderers.set(type, renderer);
3170
+ }
3171
+ function svgResultToMarkup(value) {
3172
+ if (value === void 0 || value === null || typeof value === "boolean") return void 0;
3173
+ if (typeof value === "string") return value;
3174
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
3175
+ return renderToStaticMarkup(value);
3176
+ }
3177
+ function shapeToSvg(editor, shape, ctx) {
3178
+ const util = editor.getShapeUtil(shape);
3179
+ const own = svgResultToMarkup(util.toSvg?.(shape, ctx));
3180
+ if (own !== void 0) return own;
3181
+ const renderer = shapeSvgRenderers.get(shape.type) ?? defaultShapeSvgRenderer;
3182
+ return renderer(editor, shape, ctx);
3183
+ }
3184
+ function shapeToBackgroundSvg(editor, shape, ctx) {
3185
+ return svgResultToMarkup(editor.getShapeUtil(shape).toBackgroundSvg?.(shape, ctx));
3186
+ }
3187
+
3188
+ // src/export/svg.ts
3189
+ var SVG_EXPORT_DEFAULT_PADDING = 32;
3190
+ var SVG_LIGHT_BACKGROUND = "#f9fafb";
3191
+ var SVG_DARK_BACKGROUND = "#101011";
3192
+ function getExportShapes(editor, ids) {
3193
+ const sorted = editor.getCurrentPageShapesSorted();
3194
+ let roots = ids;
3195
+ if (!roots) {
3196
+ const selected = editor.getSelectedShapeIds();
3197
+ if (selected.length === 0) return sorted;
3198
+ roots = selected;
3199
+ }
3200
+ const included = /* @__PURE__ */ new Set();
3201
+ const visit = (id) => {
3202
+ if (included.has(id)) return;
3203
+ included.add(id);
3204
+ for (const child of editor.getSortedChildIdsForParent(id)) visit(child);
3205
+ };
3206
+ for (const id of roots) if (editor.getShape(id)) visit(id);
3207
+ return sorted.filter((s) => included.has(s.id));
3208
+ }
3209
+ function getExportBounds(editor, shapes) {
3210
+ const boxes = [];
3211
+ for (const shape of shapes) {
3212
+ const b = editor.getShapePageBounds(shape);
3213
+ if (b) boxes.push(b);
3214
+ }
3215
+ return boxes.length === 0 ? void 0 : Box.Common(boxes);
3216
+ }
3217
+ var clipCounter = 0;
3218
+ function getSvgString(editor, ids, opts = {}) {
3219
+ const padding = typeof opts.padding === "number" ? opts.padding : SVG_EXPORT_DEFAULT_PADDING;
3220
+ const scale = opts.scale ?? 1;
3221
+ const darkMode = opts.darkMode ?? false;
3222
+ const shapes = getExportShapes(editor, ids);
3223
+ if (shapes.length === 0) return void 0;
3224
+ const bounds = getExportBounds(editor, shapes);
3225
+ if (!bounds) return void 0;
3226
+ const view = Box.Expand(bounds, padding);
3227
+ const width = Math.max(1, Math.ceil(view.w * scale));
3228
+ const height = Math.max(1, Math.ceil(view.h * scale));
3229
+ const background = darkMode ? SVG_DARK_BACKGROUND : SVG_LIGHT_BACKGROUND;
3230
+ const ctx = { darkMode, background };
3231
+ const included = new Set(shapes.map((s) => s.id));
3232
+ const defs = [];
3233
+ const backdrop = [];
3234
+ const body = [];
3235
+ const prefix = `mc${(clipCounter++).toString(36)}`;
3236
+ const emit = (shape) => {
3237
+ const transform = editor.getShapePageTransform(shape);
3238
+ const g = attrs({
3239
+ transform: matrixAttr(transform),
3240
+ opacity: shape.opacity < 1 ? shape.opacity : void 0,
3241
+ "data-shape-type": shape.type
3242
+ });
3243
+ const behind = shapeToBackgroundSvg(editor, shape, ctx);
3244
+ if (behind !== void 0) backdrop.push(`<g ${g} data-shape-background="true">${behind}</g>`);
3245
+ const inner = shapeToSvg(editor, shape, ctx);
3246
+ body.push(`<g ${g}>${inner}</g>`);
3247
+ const children = editor.getSortedChildIdsForParent(shape.id).filter((id) => included.has(id));
3248
+ if (children.length === 0) return;
3249
+ if (shape.type === "frame") {
3250
+ const b = editor.getShapeGeometry(shape).bounds;
3251
+ const clipId = `${prefix}-clip-${defs.length}`;
3252
+ defs.push(
3253
+ `<clipPath id="${clipId}"><rect ${attrs({ transform: matrixAttr(transform), x: b.x, y: b.y, width: b.w, height: b.h })}/></clipPath>`
3254
+ );
3255
+ body.push(`<g clip-path="url(#${clipId})">`);
3256
+ for (const id of children) {
3257
+ const child = editor.getShape(id);
3258
+ if (child) emit(child);
3259
+ }
3260
+ body.push(`</g>`);
3261
+ return;
3262
+ }
3263
+ for (const id of children) {
3264
+ const child = editor.getShape(id);
3265
+ if (child) emit(child);
3266
+ }
3267
+ };
3268
+ for (const shape of shapes) {
3269
+ const parentIncluded = included.has(shape.parentId);
3270
+ if (!parentIncluded) emit(shape);
3271
+ }
3272
+ const viewBox = `${svgNum(view.x)} ${svgNum(view.y)} ${svgNum(view.w)} ${svgNum(view.h)}`;
3273
+ const parts = [];
3274
+ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${viewBox}">`);
3275
+ if (defs.length > 0) parts.push(`<defs>${defs.join("")}</defs>`);
3276
+ if (opts.background) {
3277
+ parts.push(`<rect ${attrs({ x: view.x, y: view.y, width: view.w, height: view.h, fill: background })}/>`);
3278
+ }
3279
+ parts.push(...backdrop);
3280
+ parts.push(...body);
3281
+ parts.push(`</svg>`);
3282
+ return { svg: parts.join(""), width, height };
3283
+ }
3284
+
3285
+ // src/export/image.ts
3286
+ var MIME = {
3287
+ svg: "image/svg+xml",
3288
+ png: "image/png",
3289
+ jpeg: "image/jpeg",
3290
+ webp: "image/webp"
3291
+ };
3292
+ function hasDom() {
3293
+ return typeof document !== "undefined" && typeof window !== "undefined";
3294
+ }
3295
+ async function exportToBlob(editor, opts) {
3296
+ const { ids, format, quality, pixelRatio, ...svgOpts } = opts;
3297
+ const result = getSvgString(editor, ids, svgOpts);
3298
+ if (!result) throw new Error("Nothing to export");
3299
+ const svgBlob = new Blob([result.svg], { type: MIME.svg });
3300
+ if (format === "svg") return svgBlob;
3301
+ if (!hasDom()) {
3302
+ throw new Error(`Raster export ("${format}") needs a browser environment; only "svg" is available here`);
3303
+ }
3304
+ const ratio = pixelRatio ?? (window.devicePixelRatio || 1);
3305
+ const url = URL.createObjectURL(svgBlob);
3306
+ try {
3307
+ const image = await loadImage(url);
3308
+ const canvas = document.createElement("canvas");
3309
+ canvas.width = Math.max(1, Math.round(result.width * ratio));
3310
+ canvas.height = Math.max(1, Math.round(result.height * ratio));
3311
+ const ctx = canvas.getContext("2d");
3312
+ if (!ctx) throw new Error("Could not create a 2D canvas context");
3313
+ if (format === "jpeg" && !svgOpts.background) {
3314
+ ctx.fillStyle = svgOpts.darkMode ? "#101011" : "#f9fafb";
3315
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
3316
+ }
3317
+ ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
3318
+ return await canvasToBlob(canvas, MIME[format], quality);
3319
+ } finally {
3320
+ URL.revokeObjectURL(url);
3321
+ }
3322
+ }
3323
+ function loadImage(url) {
3324
+ return new Promise((resolve, reject) => {
3325
+ const image = new Image();
3326
+ image.decoding = "sync";
3327
+ image.onload = () => resolve(image);
3328
+ image.onerror = () => reject(new Error("Could not rasterize the SVG"));
3329
+ image.src = url;
3330
+ });
3331
+ }
3332
+ function canvasToBlob(canvas, type, quality) {
3333
+ return new Promise((resolve, reject) => {
3334
+ canvas.toBlob(
3335
+ (blob) => {
3336
+ if (blob) resolve(blob);
3337
+ else reject(new Error(`Canvas could not encode "${type}"`));
3338
+ },
3339
+ type,
3340
+ quality
3341
+ );
3342
+ });
3343
+ }
3344
+ function downloadBlob(blob, filename) {
3345
+ if (!hasDom()) return;
3346
+ const url = URL.createObjectURL(blob);
3347
+ const anchor = document.createElement("a");
3348
+ anchor.href = url;
3349
+ anchor.download = filename;
3350
+ anchor.rel = "noopener";
3351
+ anchor.style.display = "none";
3352
+ document.body.appendChild(anchor);
3353
+ anchor.click();
3354
+ anchor.remove();
3355
+ setTimeout(() => URL.revokeObjectURL(url), 1e3);
3356
+ }
3357
+ async function copyBlobToClipboard(blob) {
3358
+ const clipboard = typeof navigator === "undefined" ? void 0 : navigator.clipboard;
3359
+ if (!clipboard) throw new Error("Clipboard API is not available");
3360
+ if (blob.type === MIME.svg || !("write" in clipboard) || typeof ClipboardItem === "undefined") {
3361
+ if (blob.type !== MIME.svg && !("writeText" in clipboard)) throw new Error("Clipboard does not accept images");
3362
+ await clipboard.writeText(await blob.text());
3363
+ return;
3364
+ }
3365
+ await clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
3366
+ }
3367
+
3368
+ export { ARROW_KINDS, ARROW_LABEL_PADDING, ARROW_TERMINAL_GAP_STROKES, AVG_CHAR_WIDTH, ArrowShapeUtil, DEFAULT_GEO_TYPE_DEFINITIONS, ELBOW_CORNER_STROKES, FALLBACK_THEME_COLORS, FRAME_FILL, FRAME_NAME_COLOR, FRAME_NAME_FONT_SIZE, FRAME_NAME_GAP, FRAME_NAME_HEIGHT, FRAME_NAME_OFFSET, FRAME_STROKE, FRAME_STROKE_WIDTH, FrameShapeUtil, GEO_DEFAULT_SIZE, GEO_LABEL_PADDING, GeoShapeUtil, LINE_HEIGHT, MAX_TEXT_TEXTURE_PX, NOTE_GRADIENT_TOP_SCALE, NOTE_PADDING, NOTE_SHADOW_BLUR, NOTE_SHADOW_COLOR, NOTE_SHADOW_OFFSET_Y, NOTE_SHADOW_OPACITY, NOTE_SHADOW_SPREAD, NOTE_SIZE, NoteShapeUtil, RICH_TEXT_BLOCK_CSS, SVG_DARK_BACKGROUND, SVG_EXPORT_DEFAULT_PADDING, SVG_LIGHT_BACKGROUND, TEXT_SHAPE_MIN_WIDTH, TextLabel, TextMeasure, TextShapeUtil, VIDEO_HEIGHT, VIDEO_PLACEHOLDER_FILL, VIDEO_PLACEHOLDER_STROKE, VIDEO_PLAY_COLOR, VIDEO_PLAY_SIZE, VIDEO_TIME_EPSILON, VIDEO_WIDTH, VideoShapeUtil, alignToJustify, alignToTextAlign, applyTransform, bodyToGeometry, computeGrowY, copyBlobToClipboard, dashArray, defaultShapeSvgRenderer, downloadBlob, escapeXml, estimateTextSize, exportToBlob, geoShapeMigrations, geoShapeVersions, geometryFallbackSvg, geometryToSvgPaths, getAnchorInShapeSpace, getArrowBindingTargetAtPoint, getArrowBindings, getArrowBody, getArrowDisplayValues, getArrowTerminalGap, getArrowTerminalsInArrowSpace, getArrowheadGeometry, getArrowheadInset, getArrowheadLength, getBendFromPoint, getBodyLength, getBoundElbowAxes, getDashId, getDominantAxis, getElbowBody, getElbowMidPointFromPoint, getElbowRoute, getExportBounds, getExportShapes, getFillRgba, getFontFamily, getFrameDisplayValues, getGeoDisplayValues, getGeoGrowY, getGeoTypeDefinition, getLabelFontFaces, getLabelOpticalLift, getNormalizedAnchor, getNoteBodyGradientCss, getNoteDisplayValues, getNoteFillCssColor, getNoteFillRgba, getNoteFontSize, getNoteGradientTopCssColor, getNoteGradientTopFrom, getNoteGrowY, getNoteShadowCss, getNoteShadowSvgRect, getNoteTextCssColor, getOutlineSegments, getPointOnBody, getRichTextExtensions, getStrokeRgba, getSvgString, getTangentOnBody, getTextCssColor, getTextDisplayValues, getTextMeasure, getTextShapeBox, getTextShapeHeight, getTextShapeSize, getTextShapeSizeFor, getTextShapeTextureSpec, getTextTextureKey, getTextTextureScale, getTheme, getThemeColors, getVideoDisplayValues, getVideoPlayTriangle, getVideoSource, hexToCssRgba, horizontalAlignToFlex, horizontalAlignToTextAlign, intersectSegments, isVideoAutoplayAllowed, labelFontFamily, labelFontStyle, labelGapOnBody, labelTextAlign, matrixAttr, measureGeoLabel, measureLabel, measureRichText, readArrowProps, readArrowShape, readGeoProps, readNoteProps, readTextProps, registerShapeSvgRenderer, renderHtmlFromRichTextForMeasurement, renderHtmlFromRichTextWithExtensions, renderTextToCanvas, rgbaToHex, roundElbowCorners, shapeSvgRenderers, shapeToBackgroundSvg, shapeToSvg, shortenBody, svgResultToMarkup, textTextureAlign, textTextureText, textToSvg, toDisplayText, trimTrailingWhitespace, verticalAlignToAlignItems, verticalAlignToFlex, wrapTextLines, wrapTextTextureLines, wrapTextTextureRuns };
3369
+ //# sourceMappingURL=chunk-DMCI6V5Y.js.map
3370
+ //# sourceMappingURL=chunk-DMCI6V5Y.js.map