@oh-just-another/renderer-core 0.60.1 → 0.61.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.
Files changed (154) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/caches/edge-cache-bitmap.d.ts.map +1 -0
  4. package/dist/caches/edge-cache-bitmap.js.map +1 -0
  5. package/dist/caches/edge-cache.d.ts.map +1 -0
  6. package/dist/caches/edge-cache.js.map +1 -0
  7. package/dist/caches/layer-cache-composite.d.ts.map +1 -0
  8. package/dist/caches/layer-cache-composite.js.map +1 -0
  9. package/dist/caches/lru-cache.d.ts.map +1 -0
  10. package/dist/caches/lru-cache.js.map +1 -0
  11. package/dist/caches/shape-cache-bitmap.d.ts.map +1 -0
  12. package/dist/caches/shape-cache-bitmap.js.map +1 -0
  13. package/dist/caches/shape-cache.d.ts.map +1 -0
  14. package/dist/caches/shape-cache.js.map +1 -0
  15. package/dist/caches/tile-renderer.d.ts.map +1 -0
  16. package/dist/caches/tile-renderer.js.map +1 -0
  17. package/dist/constants.d.ts +95 -9
  18. package/dist/constants.d.ts.map +1 -1
  19. package/dist/constants.js +98 -11
  20. package/dist/constants.js.map +1 -1
  21. package/dist/index.d.ts +40 -37
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +24 -21
  24. package/dist/index.js.map +1 -1
  25. package/dist/raster/animation-adapter.d.ts.map +1 -0
  26. package/dist/raster/animation-adapter.js.map +1 -0
  27. package/dist/raster/image-source-guard.d.ts +2 -0
  28. package/dist/raster/image-source-guard.d.ts.map +1 -0
  29. package/dist/raster/image-source-guard.js +44 -0
  30. package/dist/raster/image-source-guard.js.map +1 -0
  31. package/dist/raster/js-rasterizer.d.ts.map +1 -0
  32. package/dist/raster/js-rasterizer.js.map +1 -0
  33. package/dist/raster/rasterizer.d.ts.map +1 -0
  34. package/dist/raster/rasterizer.js.map +1 -0
  35. package/dist/raster/wasm-bytes.d.ts.map +1 -0
  36. package/dist/raster/wasm-bytes.js.map +1 -0
  37. package/dist/rendering/built-in-renderers.d.ts +85 -0
  38. package/dist/rendering/built-in-renderers.d.ts.map +1 -0
  39. package/dist/rendering/built-in-renderers.js +1087 -0
  40. package/dist/rendering/built-in-renderers.js.map +1 -0
  41. package/dist/{edge-renderer.d.ts → rendering/edge-renderer.d.ts} +3 -3
  42. package/dist/rendering/edge-renderer.d.ts.map +1 -0
  43. package/dist/{edge-renderer.js → rendering/edge-renderer.js} +4 -4
  44. package/dist/rendering/edge-renderer.js.map +1 -0
  45. package/dist/{grid-renderer.d.ts → rendering/grid-renderer.d.ts} +1 -1
  46. package/dist/rendering/grid-renderer.d.ts.map +1 -0
  47. package/dist/{grid-renderer.js → rendering/grid-renderer.js} +1 -1
  48. package/dist/rendering/grid-renderer.js.map +1 -0
  49. package/dist/rendering/lod.d.ts +26 -0
  50. package/dist/rendering/lod.d.ts.map +1 -0
  51. package/dist/rendering/lod.js +5 -0
  52. package/dist/rendering/lod.js.map +1 -0
  53. package/dist/{scene-renderer.d.ts → rendering/scene-renderer.d.ts} +21 -21
  54. package/dist/rendering/scene-renderer.d.ts.map +1 -0
  55. package/dist/{scene-renderer.js → rendering/scene-renderer.js} +26 -9
  56. package/dist/rendering/scene-renderer.js.map +1 -0
  57. package/dist/{shape-renderer.d.ts → rendering/shape-renderer.d.ts} +32 -2
  58. package/dist/rendering/shape-renderer.d.ts.map +1 -0
  59. package/dist/rendering/shape-renderer.js.map +1 -0
  60. package/dist/targets/dim-target.d.ts.map +1 -0
  61. package/dist/targets/dim-target.js.map +1 -0
  62. package/dist/targets/layers.d.ts.map +1 -0
  63. package/dist/targets/layers.js.map +1 -0
  64. package/dist/{render-target.d.ts → targets/render-target.d.ts} +8 -0
  65. package/dist/targets/render-target.d.ts.map +1 -0
  66. package/dist/targets/render-target.js.map +1 -0
  67. package/dist/{text-editing.d.ts → text/text-editing.d.ts} +22 -1
  68. package/dist/text/text-editing.d.ts.map +1 -0
  69. package/dist/{text-editing.js → text/text-editing.js} +53 -12
  70. package/dist/text/text-editing.js.map +1 -0
  71. package/dist/{text-layout.d.ts → text/text-layout.d.ts} +1 -1
  72. package/dist/text/text-layout.d.ts.map +1 -0
  73. package/dist/text/text-layout.js.map +1 -0
  74. package/dist/{text-shaper.d.ts → text/text-shaper.d.ts} +9 -1
  75. package/dist/text/text-shaper.d.ts.map +1 -0
  76. package/dist/text/text-shaper.js +32 -0
  77. package/dist/text/text-shaper.js.map +1 -0
  78. package/package.json +3 -3
  79. package/dist/animation-adapter.d.ts.map +0 -1
  80. package/dist/animation-adapter.js.map +0 -1
  81. package/dist/built-in-renderers.d.ts +0 -25
  82. package/dist/built-in-renderers.d.ts.map +0 -1
  83. package/dist/built-in-renderers.js +0 -601
  84. package/dist/built-in-renderers.js.map +0 -1
  85. package/dist/dim-target.d.ts.map +0 -1
  86. package/dist/dim-target.js.map +0 -1
  87. package/dist/edge-cache-bitmap.d.ts.map +0 -1
  88. package/dist/edge-cache-bitmap.js.map +0 -1
  89. package/dist/edge-cache.d.ts.map +0 -1
  90. package/dist/edge-cache.js.map +0 -1
  91. package/dist/edge-renderer.d.ts.map +0 -1
  92. package/dist/edge-renderer.js.map +0 -1
  93. package/dist/grid-renderer.d.ts.map +0 -1
  94. package/dist/grid-renderer.js.map +0 -1
  95. package/dist/js-rasterizer.d.ts.map +0 -1
  96. package/dist/js-rasterizer.js.map +0 -1
  97. package/dist/layer-cache-composite.d.ts.map +0 -1
  98. package/dist/layer-cache-composite.js.map +0 -1
  99. package/dist/layers.d.ts.map +0 -1
  100. package/dist/layers.js.map +0 -1
  101. package/dist/lru-cache.d.ts.map +0 -1
  102. package/dist/lru-cache.js.map +0 -1
  103. package/dist/rasterizer.d.ts.map +0 -1
  104. package/dist/rasterizer.js.map +0 -1
  105. package/dist/render-target.d.ts.map +0 -1
  106. package/dist/render-target.js.map +0 -1
  107. package/dist/scene-renderer.d.ts.map +0 -1
  108. package/dist/scene-renderer.js.map +0 -1
  109. package/dist/shape-cache-bitmap.d.ts.map +0 -1
  110. package/dist/shape-cache-bitmap.js.map +0 -1
  111. package/dist/shape-cache.d.ts.map +0 -1
  112. package/dist/shape-cache.js.map +0 -1
  113. package/dist/shape-renderer.d.ts.map +0 -1
  114. package/dist/shape-renderer.js.map +0 -1
  115. package/dist/text-editing.d.ts.map +0 -1
  116. package/dist/text-editing.js.map +0 -1
  117. package/dist/text-layout.d.ts.map +0 -1
  118. package/dist/text-layout.js.map +0 -1
  119. package/dist/text-shaper.d.ts.map +0 -1
  120. package/dist/text-shaper.js +0 -16
  121. package/dist/text-shaper.js.map +0 -1
  122. package/dist/tile-renderer.d.ts.map +0 -1
  123. package/dist/tile-renderer.js.map +0 -1
  124. package/dist/wasm-bytes.d.ts.map +0 -1
  125. package/dist/wasm-bytes.js.map +0 -1
  126. /package/dist/{edge-cache-bitmap.d.ts → caches/edge-cache-bitmap.d.ts} +0 -0
  127. /package/dist/{edge-cache-bitmap.js → caches/edge-cache-bitmap.js} +0 -0
  128. /package/dist/{edge-cache.d.ts → caches/edge-cache.d.ts} +0 -0
  129. /package/dist/{edge-cache.js → caches/edge-cache.js} +0 -0
  130. /package/dist/{layer-cache-composite.d.ts → caches/layer-cache-composite.d.ts} +0 -0
  131. /package/dist/{layer-cache-composite.js → caches/layer-cache-composite.js} +0 -0
  132. /package/dist/{lru-cache.d.ts → caches/lru-cache.d.ts} +0 -0
  133. /package/dist/{lru-cache.js → caches/lru-cache.js} +0 -0
  134. /package/dist/{shape-cache-bitmap.d.ts → caches/shape-cache-bitmap.d.ts} +0 -0
  135. /package/dist/{shape-cache-bitmap.js → caches/shape-cache-bitmap.js} +0 -0
  136. /package/dist/{shape-cache.d.ts → caches/shape-cache.d.ts} +0 -0
  137. /package/dist/{shape-cache.js → caches/shape-cache.js} +0 -0
  138. /package/dist/{tile-renderer.d.ts → caches/tile-renderer.d.ts} +0 -0
  139. /package/dist/{tile-renderer.js → caches/tile-renderer.js} +0 -0
  140. /package/dist/{animation-adapter.d.ts → raster/animation-adapter.d.ts} +0 -0
  141. /package/dist/{animation-adapter.js → raster/animation-adapter.js} +0 -0
  142. /package/dist/{js-rasterizer.d.ts → raster/js-rasterizer.d.ts} +0 -0
  143. /package/dist/{js-rasterizer.js → raster/js-rasterizer.js} +0 -0
  144. /package/dist/{rasterizer.d.ts → raster/rasterizer.d.ts} +0 -0
  145. /package/dist/{rasterizer.js → raster/rasterizer.js} +0 -0
  146. /package/dist/{wasm-bytes.d.ts → raster/wasm-bytes.d.ts} +0 -0
  147. /package/dist/{wasm-bytes.js → raster/wasm-bytes.js} +0 -0
  148. /package/dist/{shape-renderer.js → rendering/shape-renderer.js} +0 -0
  149. /package/dist/{dim-target.d.ts → targets/dim-target.d.ts} +0 -0
  150. /package/dist/{dim-target.js → targets/dim-target.js} +0 -0
  151. /package/dist/{layers.d.ts → targets/layers.d.ts} +0 -0
  152. /package/dist/{layers.js → targets/layers.js} +0 -0
  153. /package/dist/{render-target.js → targets/render-target.js} +0 -0
  154. /package/dist/{text-layout.js → text/text-layout.js} +0 -0
@@ -0,0 +1,1087 @@
1
+ import { polygon as polygonMath } from "@oh-just-another/math";
2
+ import { getCornerRadius, getElementLocalBounds, registerRenderOverflow, FRAME_HEADER_HEIGHT, FRAME_HEADER_PADDING_X, FRAME_HEADER_FONT_SIZE, sliceRuns, listMarkers, paragraphCount, brushBodyColor, brushOutline, pickTextPlaceholder, } from "@oh-just-another/scene";
3
+ import { registerElementRenderer } from "./shape-renderer.js";
4
+ import { isTextBelowLod } from "./lod.js";
5
+ import { DEFAULT_LINE_HEIGHT_FACTOR, layoutText, lineLeft, } from "../text/text-editing.js";
6
+ import { resolveImageSource } from "../raster/animation-adapter.js";
7
+ import { isDrawableImageSource } from "../raster/image-source-guard.js";
8
+ import { LABEL_PADDING_EM, LABEL_AUTOFIT_MIN_PX, LABEL_AUTOFIT_MAX_PX, STICKY_DEFAULT_FILL, STICKY_CORNER_RADIUS, STICKY_AUTHOR_FONT_SIZE, STICKY_AUTHOR_COLOR, STICKY_SHADOW_COLOR, STICKY_SHADOW_OFFSET_Y, STICKY_TAG_FONT_SIZE, STICKY_TAG_PAD_X, STICKY_TAG_HEIGHT, STICKY_TAG_GAP, STICKY_TAG_BG, STICKY_TAG_COLOR, STICKY_REACTION_FONT_SIZE, STICKY_REACTION_HEIGHT, STICKY_REACTION_PAD_X, STICKY_REACTION_GAP, STICKY_REACTION_BG, STICKY_REACTION_ADD_COLOR, STICKY_REACTION_MIN_ZOOM, STICKY_REACTION_COLOR, LIST_MARKER_GAP_EM, TEXT_DECORATION_THICKNESS, TEXT_UNDERLINE_OFFSET, TEXT_STRIKETHROUGH_OFFSET, ARROWHEAD_HEAD_RATIO, ARROWHEAD_BODY_THICKNESS, ARROWHEAD_RATIO_MIN, ARROWHEAD_RATIO_MAX, FRAME_STROKE_COLOR, FRAME_FILL_COLOR, FRAME_HEADER_BG_COLOR, FRAME_HEADER_TEXT_COLOR, TEXT_PLACEHOLDER_COLOR, } from "../constants.js";
9
+ import { req } from "@oh-just-another/types";
10
+ /**
11
+ * Applies common style fields to a target. Returns whether any fill or stroke
12
+ * was configured — shape renderers use the result to decide which paint call
13
+ * to issue.
14
+ */
15
+ const applyStyle = (style, target) => {
16
+ const hasFill = style.fill !== undefined && style.fill !== "transparent";
17
+ const hasStroke = style.stroke !== undefined && style.stroke !== "transparent" && (style.strokeWidth ?? 1) > 0;
18
+ if (hasFill)
19
+ target.setFill(style.fill);
20
+ if (hasStroke) {
21
+ target.setStroke(style.stroke);
22
+ target.setStrokeWidth(style.strokeWidth ?? 1);
23
+ if (style.lineCap)
24
+ target.setLineCap(style.lineCap);
25
+ if (style.lineJoin)
26
+ target.setLineJoin(style.lineJoin);
27
+ if (style.dashArray)
28
+ target.setDashArray(style.dashArray);
29
+ }
30
+ if (style.opacity !== undefined)
31
+ target.setOpacity(style.opacity);
32
+ return { fill: hasFill, stroke: hasStroke };
33
+ };
34
+ const drawRectangle = (shape, target) => {
35
+ const { fill, stroke } = applyStyle(shape.style, target);
36
+ if (!fill && !stroke)
37
+ return;
38
+ const r = getCornerRadius(shape.style.roundness, shape.width, shape.height);
39
+ // Fill path — always uses the original shape geometry.
40
+ if (fill) {
41
+ target.beginPath();
42
+ if (r > 0) {
43
+ buildRoundedRectPath(target, 0, 0, shape.width, shape.height, r);
44
+ }
45
+ else {
46
+ target.rect(0, 0, shape.width, shape.height);
47
+ }
48
+ target.fill();
49
+ }
50
+ // Stroke path — offset by `strokeAlign` so the stroke sits inside
51
+ // / centred-on / outside the fill region. The default (omitted /
52
+ // `center`) reuses the fill geometry. Implemented at this layer so
53
+ // every backend (Canvas2D, WebGL2, SVG) honours strokeAlign without
54
+ // backend-specific work — the math is purely on the rect bounds.
55
+ if (stroke) {
56
+ const offset = strokeAlignOffset(shape.style);
57
+ const sx = offset;
58
+ const sy = offset;
59
+ const sw = shape.width - 2 * offset;
60
+ const sh = shape.height - 2 * offset;
61
+ if (sw <= 0 || sh <= 0)
62
+ return; // degenerate offset — skip
63
+ const sr = r > 0 ? Math.max(0, r - offset) : 0;
64
+ target.beginPath();
65
+ if (sr > 0) {
66
+ buildRoundedRectPath(target, sx, sy, sw, sh, sr);
67
+ }
68
+ else {
69
+ target.rect(sx, sy, sw, sh);
70
+ }
71
+ target.stroke();
72
+ }
73
+ };
74
+ /**
75
+ * Translate `Style.strokeAlign` into a path-offset distance in world
76
+ * units. The rendered stroke geometry shifts by `±half-width` along
77
+ * the inward / outward normal:
78
+ * center → 0 (path centred — Canvas2D / SVG default).
79
+ * inside → +half-width (path moves inward so the stroke's outer
80
+ * edge sits on the original fill boundary).
81
+ * outside → -half-width (path moves outward so the stroke's inner
82
+ * edge sits on the boundary).
83
+ *
84
+ * Only used by axis-aligned primitives (rectangle, container) where
85
+ * "inward" reduces to "subtract from bbox".
86
+ */
87
+ const strokeAlignOffset = (style) => {
88
+ const align = style.strokeAlign ?? "center";
89
+ if (align === "center")
90
+ return 0;
91
+ const half = (style.strokeWidth ?? 1) / 2;
92
+ return align === "inside" ? half : -half;
93
+ };
94
+ /**
95
+ * Build a rounded-rect path via the standard "4 corners with
96
+ * quadratic Bezier arcs" pattern — same shape every backend
97
+ * understands without a special `roundRect()` API:
98
+ *
99
+ * ┌───arc───┐
100
+ * │ │
101
+ * arc arc
102
+ * │ │
103
+ * └───arc───┘
104
+ *
105
+ * Quadratic control points sit at each corner of the rect; the
106
+ * curve goes from `r` units along one side to `r` units along the
107
+ * adjacent side.
108
+ *
109
+ * Radius `r` is pre-clamped by `getCornerRadius` to half the
110
+ * smaller side, so no overlap-handling is needed here.
111
+ */
112
+ export const buildRoundedRectPath = (target, x, y, w, h, r) => {
113
+ target.moveTo(x + r, y);
114
+ target.lineTo(x + w - r, y);
115
+ target.quadraticCurveTo(x + w, y, x + w, y + r);
116
+ target.lineTo(x + w, y + h - r);
117
+ target.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
118
+ target.lineTo(x + r, y + h);
119
+ target.quadraticCurveTo(x, y + h, x, y + h - r);
120
+ target.lineTo(x, y + r);
121
+ target.quadraticCurveTo(x, y, x + r, y);
122
+ target.closePath();
123
+ };
124
+ const drawEllipse = (shape, target) => {
125
+ const { fill, stroke } = applyStyle(shape.style, target);
126
+ if (!fill && !stroke)
127
+ return;
128
+ const rx = shape.width / 2;
129
+ const ry = shape.height / 2;
130
+ if (fill) {
131
+ target.beginPath();
132
+ target.ellipse(rx, ry, rx, ry);
133
+ target.fill();
134
+ }
135
+ if (stroke) {
136
+ // Inset / outset radii by `strokeAlignOffset` so the stroke
137
+ // sits inside / centred-on / outside the fill ellipse. Centre
138
+ // stays the same; radii shift uniformly. Degenerate (radius ≤ 0)
139
+ // skips the pass.
140
+ const offset = strokeAlignOffset(shape.style);
141
+ const srx = rx - offset;
142
+ const sry = ry - offset;
143
+ if (srx <= 0 || sry <= 0)
144
+ return;
145
+ target.beginPath();
146
+ target.ellipse(rx, ry, srx, sry);
147
+ target.stroke();
148
+ }
149
+ };
150
+ const drawPolygon = (shape, target) => {
151
+ if (shape.points.length < 2)
152
+ return;
153
+ const { fill, stroke } = applyStyle(shape.style, target);
154
+ if (!fill && !stroke)
155
+ return;
156
+ if (fill) {
157
+ target.beginPath();
158
+ polygonPath(target, shape.points);
159
+ target.fill();
160
+ }
161
+ if (stroke) {
162
+ const offset = strokeAlignOffset(shape.style);
163
+ const pts = offset !== 0 ? polygonMath.offsetClosedPath(shape.points, offset) : shape.points;
164
+ target.beginPath();
165
+ polygonPath(target, pts);
166
+ target.stroke();
167
+ }
168
+ };
169
+ /** Emit a closed polygon outline as `moveTo` + `lineTo`s + `closePath`. */
170
+ const polygonPath = (target, pts) => {
171
+ const first = pts[0];
172
+ if (first === undefined)
173
+ return;
174
+ target.moveTo(first.x, first.y);
175
+ for (let i = 1; i < pts.length; i++) {
176
+ const p = pts[i];
177
+ if (p === undefined)
178
+ continue;
179
+ target.lineTo(p.x, p.y);
180
+ }
181
+ target.closePath();
182
+ };
183
+ const drawPath = (shape, target) => {
184
+ if (shape.commands.length === 0)
185
+ return;
186
+ const { fill, stroke } = applyStyle(shape.style, target);
187
+ if (!fill && !stroke)
188
+ return;
189
+ target.beginPath();
190
+ for (const cmd of shape.commands) {
191
+ switch (cmd.kind) {
192
+ case "M":
193
+ target.moveTo(cmd.to.x, cmd.to.y);
194
+ break;
195
+ case "L":
196
+ target.lineTo(cmd.to.x, cmd.to.y);
197
+ break;
198
+ case "Q":
199
+ target.quadraticCurveTo(cmd.control.x, cmd.control.y, cmd.to.x, cmd.to.y);
200
+ break;
201
+ case "C":
202
+ target.bezierCurveTo(cmd.control1.x, cmd.control1.y, cmd.control2.x, cmd.control2.y, cmd.to.x, cmd.to.y);
203
+ break;
204
+ case "Z":
205
+ target.closePath();
206
+ break;
207
+ }
208
+ }
209
+ if (fill)
210
+ target.fill();
211
+ if (stroke)
212
+ target.stroke();
213
+ };
214
+ /**
215
+ * Rich-text path: draw a text element whose glyphs carry per-run styling
216
+ * (bold / italic / colour / decoration). Each visual line is split into
217
+ * style segments (via `sliceRuns` against the line's source offsets) and each
218
+ * segment is painted with its own font + fill at an accumulated x offset —
219
+ * so it renders identically on Canvas2D, WebGL2 and SVG through the shared
220
+ * `RenderTarget`. Line breaking uses the ELEMENT's base font metrics (matches
221
+ * the plain-text path); per-run weight only affects glyph paint + segment
222
+ * widths, an acceptable etap-1 approximation for wrapping.
223
+ */
224
+ /**
225
+ * Sticky note: a rounded card filled with `style.fill` (default sticky
226
+ * yellow); the text itself is the shared embedded label, drawn by the
227
+ * scene renderer's label pass. The author name renders along the bottom
228
+ * edge when `showAuthor` is set.
229
+ */
230
+ /**
231
+ * The pill scale factor keeping reaction chrome at a CONSTANT on-screen
232
+ * size: world size = base / zoom, with the divisor clamped at
233
+ * `STICKY_REACTION_MIN_ZOOM` so a zoomed-out board gets shrinking (not
234
+ * card-swallowing) pills.
235
+ */
236
+ const stickyReactionScale = (zoom) => 1 / Math.max(zoom > 0 ? zoom : 1, STICKY_REACTION_MIN_ZOOM);
237
+ /**
238
+ * Layout of a sticky's reaction pills + the "+" add button under its
239
+ * bottom edge, in the shape's LOCAL space. Pills flow left-to-right and
240
+ * WRAP onto new rows (inline-block style) when they'd overrun the card
241
+ * width — every reaction is always laid out, none are dropped. ONE
242
+ * implementation shared by the canvas renderer and the DOM click-zone
243
+ * overlay so the hit areas always match the painted pills.
244
+ *
245
+ * `measure` must be bound to `STICKY_REACTION_FONT_SIZE`-sized system-ui
246
+ * text (base px); `zoom` is the current view scale — pill sizes are
247
+ * divided by it so they stay visually constant (see
248
+ * {@link STICKY_REACTION_MIN_ZOOM} for the low-zoom clamp).
249
+ */
250
+ export const stickyReactionLayout = (shape, measure, zoom = 1) => {
251
+ const k = stickyReactionScale(zoom);
252
+ const gap = STICKY_REACTION_GAP * k;
253
+ const h = STICKY_REACTION_HEIGHT * k;
254
+ const x0 = STICKY_CORNER_RADIUS + 2;
255
+ const pills = [];
256
+ let x = x0;
257
+ let y = shape.height + gap;
258
+ for (const reaction of shape.reactions ?? []) {
259
+ const users = reaction.users;
260
+ const count = users?.length ?? reaction.count ?? 0;
261
+ const label = `${reaction.glyph} ${String(count)}`;
262
+ const width = (measure(label) + 2 * STICKY_REACTION_PAD_X) * k;
263
+ if (x > x0 && x + width > shape.width) {
264
+ x = x0;
265
+ y += h + gap;
266
+ }
267
+ pills.push({ glyph: reaction.glyph, label, x, y, width, height: h });
268
+ x += width + gap;
269
+ }
270
+ if (x > x0 && x + h > shape.width) {
271
+ x = x0;
272
+ y += h + gap;
273
+ }
274
+ return { pills, add: { x, y, width: h, height: h } };
275
+ };
276
+ /** Pills half of {@link stickyReactionLayout} (click-zone overlay helper). */
277
+ export const stickyReactionPillRects = (shape, measure, zoom = 1) => stickyReactionLayout(shape, measure, zoom).pills;
278
+ /** "+" button half of {@link stickyReactionLayout} (click-zone overlay helper). */
279
+ export const stickyReactionAddRect = (shape, measure, zoom = 1) => stickyReactionLayout(shape, measure, zoom).add;
280
+ const drawSticky = (shape, target, ctx) => {
281
+ const fill = shape.style.fill ?? STICKY_DEFAULT_FILL;
282
+ if (shape.style.opacity !== undefined)
283
+ target.setOpacity(shape.style.opacity);
284
+ const w = shape.width;
285
+ const h = shape.height;
286
+ const r = STICKY_CORNER_RADIUS;
287
+ // Soft drop shadow under the card, offset downwards.
288
+ target.setFill(STICKY_SHADOW_COLOR);
289
+ target.beginPath();
290
+ buildRoundedRectPath(target, 1, STICKY_SHADOW_OFFSET_Y, w - 2, h - 2, r);
291
+ target.fill();
292
+ // The card body — a plain rounded sheet over its drop shadow.
293
+ target.setFill(fill);
294
+ target.beginPath();
295
+ buildRoundedRectPath(target, 0, 0, w, h, r);
296
+ target.fill();
297
+ // Tag pills along the bottom edge.
298
+ if (ctx?.content?.stickyTags !== false && shape.tags !== undefined && shape.tags.length > 0) {
299
+ target.setFont("system-ui, sans-serif", STICKY_TAG_FONT_SIZE, {});
300
+ target.setTextAlign("left");
301
+ target.setTextBaseline("top");
302
+ let x = r + 2;
303
+ const y = h - STICKY_TAG_HEIGHT - 3;
304
+ for (const tag of shape.tags) {
305
+ const tw = target.measureText(tag).width + 2 * STICKY_TAG_PAD_X;
306
+ if (x + tw > w - r)
307
+ break;
308
+ target.setFill(STICKY_TAG_BG);
309
+ target.beginPath();
310
+ buildRoundedRectPath(target, x, y, tw, STICKY_TAG_HEIGHT, STICKY_TAG_HEIGHT / 2);
311
+ target.fill();
312
+ target.setFill(STICKY_TAG_COLOR);
313
+ target.fillText(tag, x + STICKY_TAG_PAD_X, y + (STICKY_TAG_HEIGHT - STICKY_TAG_FONT_SIZE) / 2);
314
+ x += tw + STICKY_TAG_GAP;
315
+ }
316
+ }
317
+ if (ctx?.content?.stickyAuthor !== false &&
318
+ shape.showAuthor === true &&
319
+ shape.authorName !== undefined &&
320
+ shape.authorName !== "") {
321
+ target.setFont("system-ui, sans-serif", STICKY_AUTHOR_FONT_SIZE, {});
322
+ target.setTextAlign("left");
323
+ target.setTextBaseline("top");
324
+ target.setFill(STICKY_AUTHOR_COLOR);
325
+ const authorY = shape.tags !== undefined && shape.tags.length > 0
326
+ ? h - STICKY_TAG_HEIGHT - STICKY_AUTHOR_FONT_SIZE - 8
327
+ : h - STICKY_AUTHOR_FONT_SIZE - 4;
328
+ target.fillText(shape.authorName, r + 2, authorY);
329
+ }
330
+ // Reaction pills under the bottom-left edge — canvas is the single
331
+ // visual source (exports included); the DOM overlay only overlays
332
+ // transparent click zones on the same rects.
333
+ const zoom = ctx?.zoom ?? 1;
334
+ const k = 1 / Math.max(zoom > 0 ? zoom : 1, STICKY_REACTION_MIN_ZOOM);
335
+ // Below the min zoom the whole reaction chrome is hidden — constant
336
+ // on-screen pills would swallow the cards on a zoomed-out board.
337
+ const chromeVisible = zoom >= STICKY_REACTION_MIN_ZOOM;
338
+ const drawReactions = chromeVisible && ctx?.content?.stickyReactions !== false;
339
+ // "+" add-reaction button — UI chrome drawn on the canvas so it tracks
340
+ // the shape 1:1 while dragging. Shown only for the HOVERED sticky in
341
+ // interactive renders; exports and read-only views switch it off.
342
+ const drawAdd = chromeVisible && ctx?.content?.stickyAddButton !== false && ctx?.hoveredElement === shape.id;
343
+ if (drawReactions || drawAdd) {
344
+ // Measure at the BASE font size (the layout contract). Text is also
345
+ // DRAWN at the base size inside a `scale(k)` transform: a fractional
346
+ // per-frame font size would defeat the backend's string-bitmap cache
347
+ // during smooth zoom (a fresh rasterisation per pill per frame).
348
+ target.setFont("system-ui, sans-serif", STICKY_REACTION_FONT_SIZE, {});
349
+ target.setTextAlign("left");
350
+ target.setTextBaseline("top");
351
+ const layout = stickyReactionLayout(shape, (t) => target.measureText(t).width, zoom);
352
+ if (drawReactions) {
353
+ for (const pill of layout.pills) {
354
+ target.setFill(STICKY_REACTION_BG);
355
+ target.beginPath();
356
+ buildRoundedRectPath(target, pill.x, pill.y, pill.width, pill.height, pill.height / 2);
357
+ target.fill();
358
+ target.setFill(STICKY_REACTION_COLOR);
359
+ target.save();
360
+ target.translate(pill.x, pill.y);
361
+ target.scale(k, k);
362
+ target.fillText(pill.label, STICKY_REACTION_PAD_X, (STICKY_REACTION_HEIGHT - STICKY_REACTION_FONT_SIZE) / 2);
363
+ target.restore();
364
+ }
365
+ }
366
+ if (drawAdd) {
367
+ const add = layout.add;
368
+ target.setFill(STICKY_REACTION_BG);
369
+ target.beginPath();
370
+ buildRoundedRectPath(target, add.x, add.y, add.width, add.height, add.height / 2);
371
+ target.fill();
372
+ // Vector "+" cross as two filled bars — crisper than a glyph at any
373
+ // zoom, and immune to per-backend multi-subpath stroke quirks.
374
+ const cx = add.x + add.width / 2;
375
+ const cy = add.y + add.height / 2;
376
+ const arm = add.height * 0.22;
377
+ const bar = 1.4 * k;
378
+ target.setFill(STICKY_REACTION_ADD_COLOR);
379
+ target.beginPath();
380
+ target.rect(cx - arm, cy - bar / 2, arm * 2, bar);
381
+ target.fill();
382
+ target.beginPath();
383
+ target.rect(cx - bar / 2, cy - arm, bar, arm * 2);
384
+ target.fill();
385
+ }
386
+ }
387
+ };
388
+ /** Emoji element: one glyph filling the element's square via the text path. */
389
+ const drawEmoji = (shape, target) => {
390
+ if (shape.style.opacity !== undefined)
391
+ target.setOpacity(shape.style.opacity);
392
+ target.setFont("system-ui, sans-serif", shape.size, {});
393
+ target.setTextAlign("left");
394
+ target.setTextBaseline("top");
395
+ target.setFill(shape.style.fill ?? "#000");
396
+ target.fillText(shape.glyph, 0, 0);
397
+ };
398
+ /**
399
+ * Geometry of an embedded shape label: the synthetic text element the
400
+ * text renderer can draw, plus the local-space offset where it starts.
401
+ * Shared by the renderer and the inline-edit caret path (state) so the
402
+ * glyphs and the caret can't drift apart.
403
+ */
404
+ /**
405
+ * Auto-fit font sizing for `ShapeLabel.autoFit` (sticky notes): the
406
+ * largest size in [`LABEL_AUTOFIT_MIN_PX`, `LABEL_AUTOFIT_MAX_PX`]
407
+ * whose wrapped layout fits the padded shape body, found by binary
408
+ * search over `layoutText`. Memoized — the measure callback varies by
409
+ * backend, so the cache key folds in a coarse measure fingerprint.
410
+ */
411
+ const autoFitCache = new Map();
412
+ const autoFitFontSize = (text, boxW, boxH, measure, baseSize, paragraphs) => {
413
+ // The measure callback is bound to `baseSize`; normalise so the cache
414
+ // key (and the search) are stable across backends and base sizes.
415
+ const fingerprint = Math.round((measure("Mg водоём") / baseSize) * 1000);
416
+ const key = `${text}|${String(Math.round(boxW))}x${String(Math.round(boxH))}|${String(fingerprint)}`;
417
+ const cached = autoFitCache.get(key);
418
+ if (cached !== undefined)
419
+ return cached;
420
+ const fits = (size) => {
421
+ const pad = LABEL_PADDING_EM * size;
422
+ const maxWidth = boxW - 2 * pad;
423
+ if (maxWidth < size)
424
+ return false;
425
+ // Rescale the base-size measurement to the candidate size so the
426
+ // wrap decisions inside layoutText are internally consistent.
427
+ const scaled = (t) => (measure(t) * size) / baseSize;
428
+ const layout = layoutText(text, scaled, {
429
+ fontSize: size,
430
+ maxWidth,
431
+ ...(paragraphs !== undefined ? { paragraphs } : {}),
432
+ });
433
+ return layout.lines.length * layout.lineHeight <= boxH - 2 * pad;
434
+ };
435
+ let lo = LABEL_AUTOFIT_MIN_PX;
436
+ let hi = LABEL_AUTOFIT_MAX_PX;
437
+ while (hi - lo > 1) {
438
+ const mid = Math.floor((lo + hi) / 2);
439
+ if (fits(mid))
440
+ lo = mid;
441
+ else
442
+ hi = mid;
443
+ }
444
+ if (autoFitCache.size > 512)
445
+ autoFitCache.clear();
446
+ autoFitCache.set(key, lo);
447
+ return lo;
448
+ };
449
+ export const shapeLabelLayout = (shape, measure) => {
450
+ const label = shape.label;
451
+ if (label === undefined)
452
+ return null;
453
+ const bounds = getElementLocalBounds(shape);
454
+ const fontSize = label.autoFit === true && label.text !== ""
455
+ ? autoFitFontSize(label.text, bounds.width, bounds.height, measure, label.fontSize, label.paragraphs)
456
+ : label.fontSize;
457
+ const pad = LABEL_PADDING_EM * fontSize;
458
+ const maxWidth = Math.max(fontSize, bounds.width - 2 * pad);
459
+ // `measure` arrives bound to the label's BASE font size; when auto-fit
460
+ // picked a different size, rescale so wrap decisions match the glyphs
461
+ // that will actually be drawn.
462
+ const scaledMeasure = fontSize === label.fontSize
463
+ ? measure
464
+ : (t) => (measure(t) * fontSize) / label.fontSize;
465
+ // Block-level vertical alignment is applied via `offsetY` below; the
466
+ // synthetic's glyph baseline stays "top" so drawn glyphs, the caret and
467
+ // selection rects all share top-anchored line coordinates.
468
+ const valign = label.style?.textBaseline ?? "middle";
469
+ const style = {
470
+ textAlign: "center",
471
+ ...label.style,
472
+ textBaseline: "top",
473
+ };
474
+ const layout = layoutText(label.text, scaledMeasure, {
475
+ fontSize,
476
+ maxWidth,
477
+ ...(label.paragraphs !== undefined ? { paragraphs: label.paragraphs } : {}),
478
+ });
479
+ // Text never escapes the shape body: only the lines inside the padded
480
+ // window are painted (the flat text keeps the rest for editing). While
481
+ // the inline editor is open the transient `metadata.labelScrollLines`
482
+ // scrolls that window so the caret stays visible.
483
+ const innerHeight = Math.max(0, bounds.height - 2 * pad);
484
+ const clipLines = Math.max(0, Math.floor(innerHeight / layout.lineHeight));
485
+ const rawScroll = shape.metadata?.labelScrollLines;
486
+ const maxScroll = Math.max(0, layout.lines.length - clipLines);
487
+ const scroll = Math.max(0, Math.min(maxScroll, typeof rawScroll === "number" ? Math.floor(rawScroll) : 0));
488
+ const synthetic = {
489
+ id: shape.id,
490
+ layerId: shape.layerId,
491
+ type: "text",
492
+ position: { x: 0, y: 0 },
493
+ rotation: 0,
494
+ scale: { x: 1, y: 1 },
495
+ order: shape.order,
496
+ style,
497
+ text: label.text,
498
+ fontFamily: label.fontFamily,
499
+ fontSize,
500
+ maxWidth,
501
+ clipStart: scroll,
502
+ clipLines,
503
+ ...(label.runs !== undefined ? { runs: label.runs } : {}),
504
+ ...(label.paragraphs !== undefined ? { paragraphs: label.paragraphs } : {}),
505
+ };
506
+ const textH = Math.min(layout.lines.length - scroll, clipLines) * layout.lineHeight;
507
+ const windowAnchor = valign === "top"
508
+ ? bounds.y + pad
509
+ : valign === "bottom"
510
+ ? bounds.y + bounds.height - textH - pad
511
+ : bounds.y + Math.max(pad, (bounds.height - textH) / 2);
512
+ // Lines keep their absolute layout Y (line × lineHeight); shifting the
513
+ // whole block up by the scroll puts the visible window at the anchor.
514
+ const offsetY = windowAnchor - scroll * layout.lineHeight;
515
+ return {
516
+ synthetic,
517
+ offsetX: bounds.x + pad,
518
+ offsetY,
519
+ windowTop: scroll * layout.lineHeight,
520
+ windowBottom: (scroll + clipLines) * layout.lineHeight,
521
+ };
522
+ };
523
+ /**
524
+ * Draw a shape's embedded label inside its local bounds. Reuses the text
525
+ * renderer wholesale (wrap, runs, lists, highlight); vertical alignment
526
+ * places the whole block, `textAlign` centres lines within the padded
527
+ * width. Called by the scene renderer after the shape body.
528
+ */
529
+ export const drawShapeLabel = (shape, target,
530
+ /** Readable-text LOD: skip the label when its resolved font size is below the floor on screen. */
531
+ lod) => {
532
+ const label = shape.label;
533
+ if (label === undefined || label.text === "")
534
+ return;
535
+ // Fast path on the base size: an auto-fit label can only grow from it.
536
+ if (lod && !label.autoFit && isTextBelowLod(label.fontSize, lod.zoom, lod.lod))
537
+ return;
538
+ // Measure with the label's base font — same metrics drawText wraps with.
539
+ const weight = label.style?.fontWeight;
540
+ const fontStyle = label.style?.fontStyle;
541
+ target.setFont(label.fontFamily, label.fontSize, {
542
+ ...(weight ? { weight } : {}),
543
+ ...(fontStyle ? { style: fontStyle } : {}),
544
+ });
545
+ const placed = shapeLabelLayout(shape, (s) => target.measureText(s).width);
546
+ if (!placed)
547
+ return;
548
+ if (lod && isTextBelowLod(placed.synthetic.fontSize, lod.zoom, lod.lod))
549
+ return;
550
+ target.save();
551
+ target.translate(placed.offsetX, placed.offsetY);
552
+ drawText(placed.synthetic, target);
553
+ target.restore();
554
+ };
555
+ /**
556
+ * Internal draw hint carried by label synthetics: paint at most this many
557
+ * visual lines so the text never escapes the shape body. Never serialized.
558
+ */
559
+ const clipWindowOf = (shape) => {
560
+ const hint = shape;
561
+ if (hint.clipLines === undefined)
562
+ return undefined;
563
+ const start = hint.clipStart ?? 0;
564
+ return { start, end: start + hint.clipLines };
565
+ };
566
+ /**
567
+ * Draw the derived list markers ("•" / "1.") for every paragraph's first
568
+ * visual line, right-aligned into the indent slot the layout reserved.
569
+ * Uses the element's base font + fill; leaves the fill set to `color`.
570
+ */
571
+ const drawListMarkersForLayout = (shape, layout, target, color) => {
572
+ if (shape.paragraphs === undefined)
573
+ return;
574
+ const align = shape.style.textAlign ?? "left";
575
+ const markers = listMarkers(shape.paragraphs, paragraphCount(shape.text));
576
+ const gap = LIST_MARKER_GAP_EM * shape.fontSize;
577
+ target.setFill(color);
578
+ const markerClip = clipWindowOf(shape);
579
+ layout.lines.forEach((line, i) => {
580
+ if (markerClip !== undefined && (i < markerClip.start || i >= markerClip.end))
581
+ return;
582
+ if (!line.paraFirst)
583
+ return;
584
+ const marker = markers[line.para];
585
+ if (marker == null)
586
+ return;
587
+ const w = target.measureText(marker).width;
588
+ const left = lineLeft(line, layout.blockWidth, align);
589
+ target.fillText(marker, left - gap - w, i * layout.lineHeight);
590
+ });
591
+ };
592
+ const drawStyledText = (shape, target) => {
593
+ const align = shape.style.textAlign ?? "left";
594
+ const fontSize = shape.fontSize;
595
+ target.setTextAlign("left");
596
+ target.setTextBaseline(shape.style.textBaseline ?? "top");
597
+ // Apply the resolved font for a run: run overlay wins, element style is
598
+ // the fallback for any field the run omits.
599
+ const setSegFont = (st) => {
600
+ const weight = st?.fontWeight ?? shape.style.fontWeight;
601
+ const style = st?.fontStyle ?? shape.style.fontStyle;
602
+ target.setFont(shape.fontFamily, fontSize, {
603
+ ...(weight ? { weight } : {}),
604
+ ...(style ? { style } : {}),
605
+ });
606
+ };
607
+ // Base-font line breaking — same metrics the plain path wraps with.
608
+ setSegFont(undefined);
609
+ const layout = layoutText(shape.text, (s) => target.measureText(s).width, {
610
+ fontSize,
611
+ ...(shape.maxWidth !== undefined ? { maxWidth: shape.maxWidth } : {}),
612
+ ...(shape.paragraphs !== undefined ? { paragraphs: shape.paragraphs } : {}),
613
+ });
614
+ const perLine = layout.lines.map((line) => {
615
+ const segs = sliceRuns(shape, line.start, line.end).map((r) => {
616
+ setSegFont(r.style);
617
+ return { text: r.text, style: r.style, width: target.measureText(r.text).width };
618
+ });
619
+ const total = segs.reduce((a, s) => a + s.width, 0);
620
+ return { segs, total };
621
+ });
622
+ // Alignment box: fixed budget, or the widest STYLED line so bold text
623
+ // stays self-consistently aligned.
624
+ const blockWidth = shape.maxWidth ??
625
+ perLine.reduce((m, l, i) => Math.max(m, l.total + req(layout.lines[i]).indentX), 0);
626
+ const thickness = Math.max(1, fontSize * TEXT_DECORATION_THICKNESS);
627
+ const styledClip = clipWindowOf(shape);
628
+ perLine.forEach((line, i) => {
629
+ if (styledClip !== undefined && (i < styledClip.start || i >= styledClip.end))
630
+ return;
631
+ const top = i * layout.lineHeight;
632
+ const indentX = req(layout.lines[i]).indentX;
633
+ let x = indentX +
634
+ (align === "center"
635
+ ? (blockWidth - indentX) / 2 - line.total / 2
636
+ : align === "right"
637
+ ? blockWidth - indentX - line.total
638
+ : 0);
639
+ for (const seg of line.segs) {
640
+ const color = seg.style?.fill ?? shape.style.fill ?? "#000";
641
+ const opacity = seg.style?.opacity ?? shape.style.opacity;
642
+ setSegFont(seg.style);
643
+ if (opacity !== undefined)
644
+ target.setOpacity(opacity);
645
+ // Highlight first — a full line-height rect under the glyphs, so the
646
+ // text paints on top of its own marker stripe.
647
+ const highlight = seg.style?.highlight ?? shape.style.highlight;
648
+ if (seg.width > 0 && highlight !== undefined && highlight !== "transparent") {
649
+ target.setFill(highlight);
650
+ target.beginPath();
651
+ target.rect(x, top, seg.width, layout.lineHeight);
652
+ target.fill();
653
+ }
654
+ target.setFill(color);
655
+ target.fillText(seg.text, x, top);
656
+ const deco = seg.style?.textDecoration ?? shape.style.textDecoration;
657
+ if (seg.width > 0 && (deco?.underline || deco?.strikethrough)) {
658
+ if (deco.underline) {
659
+ target.beginPath();
660
+ target.rect(x, top + fontSize * TEXT_UNDERLINE_OFFSET, seg.width, thickness);
661
+ target.fill();
662
+ }
663
+ if (deco.strikethrough) {
664
+ target.beginPath();
665
+ target.rect(x, top + fontSize * TEXT_STRIKETHROUGH_OFFSET - thickness / 2, seg.width, thickness);
666
+ target.fill();
667
+ }
668
+ }
669
+ x += seg.width;
670
+ }
671
+ });
672
+ // Markers use the element's base font/colour, after the segments so the
673
+ // font state is deterministic.
674
+ setSegFont(undefined);
675
+ drawListMarkersForLayout(shape, layout, target, shape.style.fill ?? "#000");
676
+ };
677
+ const drawText = (shape, target, ctx) => {
678
+ // Empty text while writing: draw the element's placeholder prompt in the
679
+ // neutral grey, with the element's own font / alignment so the caret and
680
+ // the prompt line up. Interactive rendering only (`ctx.textPlaceholders`).
681
+ if (shape.text === "" && ctx?.textPlaceholders === true) {
682
+ const { runs: _runs, ...plain } = shape;
683
+ drawText({
684
+ ...plain,
685
+ text: pickTextPlaceholder(shape.id),
686
+ style: { ...shape.style, fill: TEXT_PLACEHOLDER_COLOR },
687
+ }, target);
688
+ return;
689
+ }
690
+ // Rich text (styled runs) takes a dedicated path; plain text keeps the
691
+ // original single-style path byte-for-byte (golden-SVG compatible).
692
+ if (shape.runs !== undefined && shape.runs.length > 0) {
693
+ drawStyledText(shape, target);
694
+ return;
695
+ }
696
+ const align = shape.style.textAlign ?? "left";
697
+ const weight = shape.style.fontWeight;
698
+ const fontStyle = shape.style.fontStyle;
699
+ target.setFont(shape.fontFamily, shape.fontSize, {
700
+ ...(weight ? { weight } : {}),
701
+ ...(fontStyle ? { style: fontStyle } : {}),
702
+ });
703
+ // Lines are positioned manually (per-line x below) so the caret
704
+ // geometry computed from the same `layoutText` lines up exactly, so
705
+ // the target always draws left-anchored.
706
+ target.setTextAlign("left");
707
+ target.setTextBaseline(shape.style.textBaseline ?? "top");
708
+ // Color: use fill if specified, otherwise default to black.
709
+ const color = shape.style.fill ?? "#000";
710
+ target.setFill(color);
711
+ if (shape.style.opacity !== undefined)
712
+ target.setOpacity(shape.style.opacity);
713
+ // Resolve per-line geometry once (x = align offset, top = i ×
714
+ // lineHeight). Single-line text without list attrs skips the wrap engine.
715
+ const fontSize = shape.fontSize;
716
+ let lines;
717
+ if (shape.maxWidth === undefined &&
718
+ !shape.text.includes("\n") &&
719
+ shape.paragraphs === undefined) {
720
+ lines = [{ text: shape.text, x: 0, width: target.measureText(shape.text).width, top: 0 }];
721
+ }
722
+ else {
723
+ // Measure with the target's own `measureText` so wrapping matches
724
+ // exactly what this backend draws.
725
+ const measure = (s) => target.measureText(s).width;
726
+ const layout = layoutText(shape.text, measure, {
727
+ fontSize,
728
+ ...(shape.maxWidth !== undefined ? { maxWidth: shape.maxWidth } : {}),
729
+ ...(shape.paragraphs !== undefined ? { paragraphs: shape.paragraphs } : {}),
730
+ });
731
+ lines = layout.lines.map((line, i) => ({
732
+ text: line.text,
733
+ x: lineLeft(line, layout.blockWidth, align),
734
+ width: line.width,
735
+ top: i * layout.lineHeight,
736
+ }));
737
+ const clip = clipWindowOf(shape);
738
+ if (clip !== undefined)
739
+ lines = lines.filter((_, i) => i >= clip.start && i < clip.end);
740
+ drawListMarkersForLayout(shape, layout, target, color);
741
+ }
742
+ // Highlight stripes under the glyphs (marker-style), then the text on top.
743
+ const highlight = shape.style.highlight;
744
+ if (highlight !== undefined && highlight !== "transparent") {
745
+ const lineHeight = fontSize * DEFAULT_LINE_HEIGHT_FACTOR;
746
+ target.setFill(highlight);
747
+ for (const l of lines) {
748
+ if (l.width <= 0)
749
+ continue;
750
+ target.beginPath();
751
+ target.rect(l.x, l.top, l.width, lineHeight);
752
+ target.fill();
753
+ }
754
+ target.setFill(color);
755
+ }
756
+ for (const l of lines)
757
+ target.fillText(l.text, l.x, l.top);
758
+ // Underline / strikethrough — thin filled rects per line, same on
759
+ // Canvas2D and WebGL2 (uses the current text fill colour).
760
+ const deco = shape.style.textDecoration;
761
+ if (deco?.underline || deco?.strikethrough) {
762
+ const thickness = Math.max(1, fontSize * TEXT_DECORATION_THICKNESS);
763
+ for (const l of lines) {
764
+ if (l.width <= 0)
765
+ continue;
766
+ if (deco.underline) {
767
+ target.beginPath();
768
+ target.rect(l.x, l.top + fontSize * TEXT_UNDERLINE_OFFSET, l.width, thickness);
769
+ target.fill();
770
+ }
771
+ if (deco.strikethrough) {
772
+ target.beginPath();
773
+ target.rect(l.x, l.top + fontSize * TEXT_STRIKETHROUGH_OFFSET - thickness / 2, l.width, thickness);
774
+ target.fill();
775
+ }
776
+ }
777
+ }
778
+ };
779
+ /**
780
+ * Variable-width brush stroke. Each segment between two `BrushPoint`s
781
+ * is drawn as a quad (two triangles) — its width interpolates from
782
+ * `p.width` at the head to `q.width` at the tail. Renders
783
+ * pressure-sensitive ink that gets thicker / thinner along the path
784
+ * without needing per-segment `setStrokeWidth` calls (which most 2D
785
+ * APIs treat as a single line width).
786
+ */
787
+ const drawBrush = (shape, target) => {
788
+ const pts = shape.points;
789
+ if (pts.length === 0)
790
+ return;
791
+ // Honour the stroke's opacity (drawBrush paints fills directly, so it can't
792
+ // rely on the shared `applyStyle` the other renderers use). Set once up front
793
+ // so both the enclosed-area fill and the body get it; the scene renderer resets
794
+ // opacity to 1 between shapes.
795
+ if (shape.style.opacity !== undefined)
796
+ target.setOpacity(shape.style.opacity);
797
+ // Closed stroke with a fill colour: paint the enclosed area FIRST (under the
798
+ // stroke body) as a polygon through the centreline points. Needs ≥3 points to
799
+ // enclose an area. Open strokes skip this entirely and are unchanged.
800
+ if (shape.closed === true && shape.style.fill !== undefined && pts.length >= 3) {
801
+ target.setFill(shape.style.fill);
802
+ target.setStroke(null);
803
+ target.beginPath();
804
+ const start = req(pts[0]);
805
+ target.moveTo(start.x, start.y);
806
+ for (let i = 1; i < pts.length; i++) {
807
+ const p = req(pts[i]);
808
+ target.lineTo(p.x, p.y);
809
+ }
810
+ target.closePath();
811
+ target.fill();
812
+ }
813
+ // The variable-width stroke body is painted with the shared brush-body colour
814
+ // (the same resolution the live preview uses — see `brushBodyColor`).
815
+ const paint = brushBodyColor(shape.style);
816
+ target.setFill(paint);
817
+ target.setStroke(null);
818
+ // Single dot for one-point strokes — degenerate quad would be invisible.
819
+ if (pts.length === 1) {
820
+ const p = req(pts[0]);
821
+ target.beginPath();
822
+ target.ellipse(p.x, p.y, p.width, p.width);
823
+ target.fill();
824
+ return;
825
+ }
826
+ // Body as ONE closed outline polygon, filled once. Per-segment quads + joint
827
+ // discs (the old approach) overlap, so at `opacity < 1` the joins double-blend
828
+ // into dark blotches; a single fill paints every pixel exactly once.
829
+ const outline = brushOutline(pts);
830
+ if (outline.length >= 3) {
831
+ target.beginPath();
832
+ const first = req(outline[0]);
833
+ target.moveTo(first.x, first.y);
834
+ for (let i = 1; i < outline.length; i++) {
835
+ const p = req(outline[i]);
836
+ target.lineTo(p.x, p.y);
837
+ }
838
+ target.closePath();
839
+ target.fill();
840
+ }
841
+ };
842
+ const drawImage = (shape, target, ctx) => {
843
+ // Priority: for an animated source prefer the per-frame image the
844
+ // registered adapter returns; otherwise a preloaded handle in
845
+ // `metadata.image`; otherwise the static `src` fallback.
846
+ // `resolveImageSource` returns `null` while an async decode is still
847
+ // in flight, which the backend's drawImage guard skips.
848
+ // Sample at the per-instance clock when the caller threaded one via the
849
+ // render context; `undefined` defers to `resolveImageSource`'s process-global
850
+ // fallback clock (headless / preview paths).
851
+ const t = ctx?.clock?.(shape);
852
+ const handle = shape.animationKind
853
+ ? resolveImageSource(shape, t)
854
+ : (shape.metadata?.image ?? resolveImageSource(shape, t));
855
+ // A non-drawable handle with a `fileId` is a TRANSIENT state, not a
856
+ // problem: the first paint after a scene restore runs before async
857
+ // rehydration re-attaches a live handle from `Scene.files`. Skip the
858
+ // frame silently — rehydration repaints when it lands, and reports its
859
+ // own failure if the bytes are missing or won't decode. Only handles
860
+ // with no rehydration source fall through to the backend, which warns
861
+ // (the image really will stay blank).
862
+ if (!isDrawableImageSource(handle)) {
863
+ if (handle == null || shape.fileId)
864
+ return;
865
+ }
866
+ // `dynamic` → backends that cache the upload (WebGL2) re-upload the
867
+ // current frame. GIF / video sources flag `metadata.animated`, and
868
+ // any adapter-driven source is dynamic by definition.
869
+ const dynamic = shape.metadata?.animated === true || shape.animationKind !== undefined;
870
+ const mask = shape.mask;
871
+ if (mask) {
872
+ target.save();
873
+ target.beginPath();
874
+ buildImageMaskPath(target, mask, shape.width, shape.height);
875
+ target.clip();
876
+ }
877
+ target.drawImage(handle, 0, 0, shape.width, shape.height, dynamic, shape.crop);
878
+ if (mask)
879
+ target.restore();
880
+ };
881
+ /**
882
+ * Build an {@link ImageMask}'s path in the shape's LOCAL space
883
+ * (normalised mask coordinates × the element box). Exported so overlays
884
+ * (crop/mask preview) can trace the same outline the renderer clips by.
885
+ */
886
+ export const buildImageMaskPath = (target, mask, width, height) => {
887
+ switch (mask.kind) {
888
+ case "ellipse":
889
+ target.ellipse(width / 2, height / 2, width / 2, height / 2);
890
+ return;
891
+ case "round-rect": {
892
+ const r = Math.max(0, Math.min(0.5, mask.radius)) * Math.min(width, height);
893
+ buildRoundedRectPath(target, 0, 0, width, height, r);
894
+ return;
895
+ }
896
+ case "polygon": {
897
+ const pts = mask.points;
898
+ if (pts.length < 3)
899
+ return;
900
+ const first = req(pts[0]);
901
+ target.moveTo(first.x * width, first.y * height);
902
+ for (let i = 1; i < pts.length; i++) {
903
+ const p = req(pts[i]);
904
+ target.lineTo(p.x * width, p.y * height);
905
+ }
906
+ target.closePath();
907
+ return;
908
+ }
909
+ }
910
+ };
911
+ /**
912
+ * Registers renderers for every `BuiltinElement` type.
913
+ */
914
+ export const installBuiltinRenderers = () => {
915
+ registerElementRenderer("rectangle", drawRectangle);
916
+ registerElementRenderer("ellipse", drawEllipse);
917
+ registerElementRenderer("polygon", drawPolygon);
918
+ registerElementRenderer("path", drawPath);
919
+ registerElementRenderer("text", drawText);
920
+ registerElementRenderer("image", drawImage);
921
+ // Group shapes are invisible containers — the shape itself paints nothing.
922
+ registerElementRenderer("group", () => {
923
+ /* intentional no-op: group shapes are invisible containers and paint nothing */
924
+ });
925
+ registerElementRenderer("frame", drawFrame);
926
+ // The frame paints its header strip ABOVE the rectangle, so its dirty
927
+ // region must extend up by the header height — otherwise deleting a
928
+ // frame leaves the header behind.
929
+ registerRenderOverflow("frame", () => ({ top: FRAME_HEADER_HEIGHT }));
930
+ registerElementRenderer("block-arrow", drawBlockArrow);
931
+ registerElementRenderer("brush", drawBrush);
932
+ registerElementRenderer("sticky", drawSticky);
933
+ // The sticky's drop shadow paints below its box — extend the dirty
934
+ // region so moving/deleting it doesn't leave the shadow behind.
935
+ registerRenderOverflow("sticky", (shape) => {
936
+ // Worst-case invalidation bound for the reaction rows: every pill on
937
+ // its own row (+ the "+" button row), at the largest world size the
938
+ // low-zoom clamp allows (1 / STICKY_REACTION_MIN_ZOOM). Overflow
939
+ // providers have no zoom access, so this over-approximates — costs
940
+ // only redraw area, never leaves ghosts.
941
+ const n = (shape.reactions?.length ?? 0) + 1;
942
+ const kMax = 1 / STICKY_REACTION_MIN_ZOOM;
943
+ return {
944
+ bottom: STICKY_SHADOW_OFFSET_Y + (STICKY_REACTION_GAP + STICKY_REACTION_HEIGHT) * kMax * n + 2,
945
+ right: (STICKY_REACTION_GAP + STICKY_REACTION_HEIGHT) * kMax + 2,
946
+ };
947
+ });
948
+ registerElementRenderer("emoji", drawEmoji);
949
+ };
950
+ /**
951
+ * Block-arrow silhouette: a rectangle body whose tip is replaced
952
+ * by a triangle, oriented by `direction`. Path is closed and filled
953
+ * with `style.fill`; stroke applies to the outline.
954
+ *
955
+ * right → ┌────┐▶
956
+ * │ body │
957
+ * └────┘
958
+ *
959
+ * up ↑ ▲
960
+ * ┌──┐
961
+ * │ │
962
+ * └──┘
963
+ */
964
+ const drawBlockArrow = (shape, target) => {
965
+ const { fill, stroke } = applyStyle(shape.style, target);
966
+ const direction = shape.direction ?? "right";
967
+ const headRatio = Math.max(ARROWHEAD_RATIO_MIN, Math.min(ARROWHEAD_RATIO_MAX, shape.headRatio ?? ARROWHEAD_HEAD_RATIO));
968
+ const bodyT = Math.max(ARROWHEAD_RATIO_MIN, Math.min(ARROWHEAD_RATIO_MAX, shape.bodyThickness ?? ARROWHEAD_BODY_THICKNESS));
969
+ const w = shape.width;
970
+ const h = shape.height;
971
+ // Compute the local path for a `right`-pointing arrow inside
972
+ // [0, w] × [0, h], then rotate the resulting points if the
973
+ // direction is different. Keeps the drawing primitives in one
974
+ // place.
975
+ const headW = w * headRatio;
976
+ const bodyW = w - headW;
977
+ const bodyHalfH = (h * bodyT) / 2;
978
+ const cy = h / 2;
979
+ let points = [
980
+ [0, cy - bodyHalfH],
981
+ [bodyW, cy - bodyHalfH],
982
+ [bodyW, 0],
983
+ [w, cy],
984
+ [bodyW, h],
985
+ [bodyW, cy + bodyHalfH],
986
+ [0, cy + bodyHalfH],
987
+ ];
988
+ if (direction !== "right") {
989
+ points = points.map(([x, y]) => rotateLocal([x, y], direction, w, h));
990
+ }
991
+ const ptObjs = points.map(([x, y]) => ({ x, y }));
992
+ if (fill) {
993
+ target.beginPath();
994
+ polygonPath(target, ptObjs);
995
+ target.fill();
996
+ }
997
+ if (stroke) {
998
+ const offset = strokeAlignOffset(shape.style);
999
+ const sPts = offset !== 0 ? polygonMath.offsetClosedPath(ptObjs, offset) : ptObjs;
1000
+ target.beginPath();
1001
+ polygonPath(target, sPts);
1002
+ target.stroke();
1003
+ }
1004
+ };
1005
+ const rotateLocal = ([x, y], direction, w, h) => {
1006
+ switch (direction) {
1007
+ case "left":
1008
+ return [w - x, y];
1009
+ case "up":
1010
+ // Rotate 90° CCW around the box centre, then translate so the
1011
+ // result still fits inside [0, w] × [0, h].
1012
+ return [y * (w / h), h - x * (h / w)];
1013
+ case "down":
1014
+ return [(h - y) * (w / h), x * (h / w)];
1015
+ }
1016
+ };
1017
+ const FRAME_HEADER_ELLIPSIS = "…";
1018
+ /**
1019
+ * Trim `text` with a trailing ellipsis until it fits `maxWidth` (in the
1020
+ * font already set on `target`). Returns the full text when it fits, the
1021
+ * longest prefix + "…" otherwise, or just "…" when even one char can't
1022
+ * fit. Binary-searches the prefix length to keep `measureText` calls ~log.
1023
+ */
1024
+ const ellipsizeToWidth = (text, maxWidth, target) => {
1025
+ if (maxWidth <= 0)
1026
+ return "";
1027
+ if (target.measureText(text).width <= maxWidth)
1028
+ return text;
1029
+ let lo = 0;
1030
+ let hi = text.length;
1031
+ while (lo < hi) {
1032
+ const mid = Math.ceil((lo + hi) / 2);
1033
+ const w = target.measureText(text.slice(0, mid) + FRAME_HEADER_ELLIPSIS).width;
1034
+ if (w <= maxWidth)
1035
+ lo = mid;
1036
+ else
1037
+ hi = mid - 1;
1038
+ }
1039
+ return lo > 0 ? text.slice(0, lo) + FRAME_HEADER_ELLIPSIS : FRAME_HEADER_ELLIPSIS;
1040
+ };
1041
+ const drawFrame = (shape, target, ctx) => {
1042
+ // Body — solid fill + thin solid outline. Frames sit at the bottom
1043
+ // z-order, so the fill backs their members without covering them.
1044
+ // Honours an explicit `style.fill`, else default white.
1045
+ target.setFill(shape.style.fill ?? FRAME_FILL_COLOR);
1046
+ target.setStroke(null);
1047
+ target.setDashArray(null);
1048
+ target.beginPath();
1049
+ target.rect(0, 0, shape.width, shape.height);
1050
+ target.fill();
1051
+ // Outline on top of the fill — a 1px SCREEN-constant hairline (doesn't scale
1052
+ // with zoom). The renderer draws in local coords where 1 unit = zoom × scale
1053
+ // device px, so divide to keep the stroke at one device pixel. Falls back to
1054
+ // 1 world-px when no zoom context is supplied (preview / export at 1:1).
1055
+ const screenScale = (ctx?.zoom ?? 1) * (shape.scale.x || 1);
1056
+ target.setFill(null);
1057
+ target.setStroke(FRAME_STROKE_COLOR);
1058
+ target.setStrokeWidth(1 / (screenScale || 1));
1059
+ target.setDashArray(null);
1060
+ target.beginPath();
1061
+ target.rect(0, 0, shape.width, shape.height);
1062
+ target.stroke();
1063
+ // Header label: the strip hugs the text width but never exceeds the
1064
+ // frame's right edge; a name too long for the frame is ellipsised.
1065
+ const name = shape.name ?? "Frame";
1066
+ target.setFont("system-ui, sans-serif", FRAME_HEADER_FONT_SIZE);
1067
+ const avail = shape.width - FRAME_HEADER_PADDING_X * 2;
1068
+ const fits = target.measureText(name).width <= avail;
1069
+ // Fits → the strip hugs the text. Too long → ellipsise the text and
1070
+ // stretch the strip to the frame's full width (the label runs to the
1071
+ // right edge).
1072
+ const label = fits ? name : ellipsizeToWidth(name, avail, target);
1073
+ const headerWidth = fits
1074
+ ? Math.min(target.measureText(name).width + FRAME_HEADER_PADDING_X * 2, shape.width)
1075
+ : shape.width;
1076
+ // Header label background — stretches to fit the (possibly truncated) text.
1077
+ target.setFill(FRAME_HEADER_BG_COLOR);
1078
+ target.beginPath();
1079
+ target.rect(0, -FRAME_HEADER_HEIGHT, headerWidth, FRAME_HEADER_HEIGHT);
1080
+ target.fill();
1081
+ // Header label text.
1082
+ target.setFill(FRAME_HEADER_TEXT_COLOR);
1083
+ target.setTextBaseline("middle");
1084
+ target.setTextAlign("left");
1085
+ target.fillText(label, FRAME_HEADER_PADDING_X, -FRAME_HEADER_HEIGHT / 2);
1086
+ };
1087
+ //# sourceMappingURL=built-in-renderers.js.map