@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.
- package/CHANGELOG.md +42 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/caches/edge-cache-bitmap.d.ts.map +1 -0
- package/dist/caches/edge-cache-bitmap.js.map +1 -0
- package/dist/caches/edge-cache.d.ts.map +1 -0
- package/dist/caches/edge-cache.js.map +1 -0
- package/dist/caches/layer-cache-composite.d.ts.map +1 -0
- package/dist/caches/layer-cache-composite.js.map +1 -0
- package/dist/caches/lru-cache.d.ts.map +1 -0
- package/dist/caches/lru-cache.js.map +1 -0
- package/dist/caches/shape-cache-bitmap.d.ts.map +1 -0
- package/dist/caches/shape-cache-bitmap.js.map +1 -0
- package/dist/caches/shape-cache.d.ts.map +1 -0
- package/dist/caches/shape-cache.js.map +1 -0
- package/dist/caches/tile-renderer.d.ts.map +1 -0
- package/dist/caches/tile-renderer.js.map +1 -0
- package/dist/constants.d.ts +95 -9
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +98 -11
- package/dist/constants.js.map +1 -1
- package/dist/index.d.ts +40 -37
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -21
- package/dist/index.js.map +1 -1
- package/dist/raster/animation-adapter.d.ts.map +1 -0
- package/dist/raster/animation-adapter.js.map +1 -0
- package/dist/raster/image-source-guard.d.ts +2 -0
- package/dist/raster/image-source-guard.d.ts.map +1 -0
- package/dist/raster/image-source-guard.js +44 -0
- package/dist/raster/image-source-guard.js.map +1 -0
- package/dist/raster/js-rasterizer.d.ts.map +1 -0
- package/dist/raster/js-rasterizer.js.map +1 -0
- package/dist/raster/rasterizer.d.ts.map +1 -0
- package/dist/raster/rasterizer.js.map +1 -0
- package/dist/raster/wasm-bytes.d.ts.map +1 -0
- package/dist/raster/wasm-bytes.js.map +1 -0
- package/dist/rendering/built-in-renderers.d.ts +85 -0
- package/dist/rendering/built-in-renderers.d.ts.map +1 -0
- package/dist/rendering/built-in-renderers.js +1087 -0
- package/dist/rendering/built-in-renderers.js.map +1 -0
- package/dist/{edge-renderer.d.ts → rendering/edge-renderer.d.ts} +3 -3
- package/dist/rendering/edge-renderer.d.ts.map +1 -0
- package/dist/{edge-renderer.js → rendering/edge-renderer.js} +4 -4
- package/dist/rendering/edge-renderer.js.map +1 -0
- package/dist/{grid-renderer.d.ts → rendering/grid-renderer.d.ts} +1 -1
- package/dist/rendering/grid-renderer.d.ts.map +1 -0
- package/dist/{grid-renderer.js → rendering/grid-renderer.js} +1 -1
- package/dist/rendering/grid-renderer.js.map +1 -0
- package/dist/rendering/lod.d.ts +26 -0
- package/dist/rendering/lod.d.ts.map +1 -0
- package/dist/rendering/lod.js +5 -0
- package/dist/rendering/lod.js.map +1 -0
- package/dist/{scene-renderer.d.ts → rendering/scene-renderer.d.ts} +21 -21
- package/dist/rendering/scene-renderer.d.ts.map +1 -0
- package/dist/{scene-renderer.js → rendering/scene-renderer.js} +26 -9
- package/dist/rendering/scene-renderer.js.map +1 -0
- package/dist/{shape-renderer.d.ts → rendering/shape-renderer.d.ts} +32 -2
- package/dist/rendering/shape-renderer.d.ts.map +1 -0
- package/dist/rendering/shape-renderer.js.map +1 -0
- package/dist/targets/dim-target.d.ts.map +1 -0
- package/dist/targets/dim-target.js.map +1 -0
- package/dist/targets/layers.d.ts.map +1 -0
- package/dist/targets/layers.js.map +1 -0
- package/dist/{render-target.d.ts → targets/render-target.d.ts} +8 -0
- package/dist/targets/render-target.d.ts.map +1 -0
- package/dist/targets/render-target.js.map +1 -0
- package/dist/{text-editing.d.ts → text/text-editing.d.ts} +22 -1
- package/dist/text/text-editing.d.ts.map +1 -0
- package/dist/{text-editing.js → text/text-editing.js} +53 -12
- package/dist/text/text-editing.js.map +1 -0
- package/dist/{text-layout.d.ts → text/text-layout.d.ts} +1 -1
- package/dist/text/text-layout.d.ts.map +1 -0
- package/dist/text/text-layout.js.map +1 -0
- package/dist/{text-shaper.d.ts → text/text-shaper.d.ts} +9 -1
- package/dist/text/text-shaper.d.ts.map +1 -0
- package/dist/text/text-shaper.js +32 -0
- package/dist/text/text-shaper.js.map +1 -0
- package/package.json +3 -3
- package/dist/animation-adapter.d.ts.map +0 -1
- package/dist/animation-adapter.js.map +0 -1
- package/dist/built-in-renderers.d.ts +0 -25
- package/dist/built-in-renderers.d.ts.map +0 -1
- package/dist/built-in-renderers.js +0 -601
- package/dist/built-in-renderers.js.map +0 -1
- package/dist/dim-target.d.ts.map +0 -1
- package/dist/dim-target.js.map +0 -1
- package/dist/edge-cache-bitmap.d.ts.map +0 -1
- package/dist/edge-cache-bitmap.js.map +0 -1
- package/dist/edge-cache.d.ts.map +0 -1
- package/dist/edge-cache.js.map +0 -1
- package/dist/edge-renderer.d.ts.map +0 -1
- package/dist/edge-renderer.js.map +0 -1
- package/dist/grid-renderer.d.ts.map +0 -1
- package/dist/grid-renderer.js.map +0 -1
- package/dist/js-rasterizer.d.ts.map +0 -1
- package/dist/js-rasterizer.js.map +0 -1
- package/dist/layer-cache-composite.d.ts.map +0 -1
- package/dist/layer-cache-composite.js.map +0 -1
- package/dist/layers.d.ts.map +0 -1
- package/dist/layers.js.map +0 -1
- package/dist/lru-cache.d.ts.map +0 -1
- package/dist/lru-cache.js.map +0 -1
- package/dist/rasterizer.d.ts.map +0 -1
- package/dist/rasterizer.js.map +0 -1
- package/dist/render-target.d.ts.map +0 -1
- package/dist/render-target.js.map +0 -1
- package/dist/scene-renderer.d.ts.map +0 -1
- package/dist/scene-renderer.js.map +0 -1
- package/dist/shape-cache-bitmap.d.ts.map +0 -1
- package/dist/shape-cache-bitmap.js.map +0 -1
- package/dist/shape-cache.d.ts.map +0 -1
- package/dist/shape-cache.js.map +0 -1
- package/dist/shape-renderer.d.ts.map +0 -1
- package/dist/shape-renderer.js.map +0 -1
- package/dist/text-editing.d.ts.map +0 -1
- package/dist/text-editing.js.map +0 -1
- package/dist/text-layout.d.ts.map +0 -1
- package/dist/text-layout.js.map +0 -1
- package/dist/text-shaper.d.ts.map +0 -1
- package/dist/text-shaper.js +0 -16
- package/dist/text-shaper.js.map +0 -1
- package/dist/tile-renderer.d.ts.map +0 -1
- package/dist/tile-renderer.js.map +0 -1
- package/dist/wasm-bytes.d.ts.map +0 -1
- package/dist/wasm-bytes.js.map +0 -1
- /package/dist/{edge-cache-bitmap.d.ts → caches/edge-cache-bitmap.d.ts} +0 -0
- /package/dist/{edge-cache-bitmap.js → caches/edge-cache-bitmap.js} +0 -0
- /package/dist/{edge-cache.d.ts → caches/edge-cache.d.ts} +0 -0
- /package/dist/{edge-cache.js → caches/edge-cache.js} +0 -0
- /package/dist/{layer-cache-composite.d.ts → caches/layer-cache-composite.d.ts} +0 -0
- /package/dist/{layer-cache-composite.js → caches/layer-cache-composite.js} +0 -0
- /package/dist/{lru-cache.d.ts → caches/lru-cache.d.ts} +0 -0
- /package/dist/{lru-cache.js → caches/lru-cache.js} +0 -0
- /package/dist/{shape-cache-bitmap.d.ts → caches/shape-cache-bitmap.d.ts} +0 -0
- /package/dist/{shape-cache-bitmap.js → caches/shape-cache-bitmap.js} +0 -0
- /package/dist/{shape-cache.d.ts → caches/shape-cache.d.ts} +0 -0
- /package/dist/{shape-cache.js → caches/shape-cache.js} +0 -0
- /package/dist/{tile-renderer.d.ts → caches/tile-renderer.d.ts} +0 -0
- /package/dist/{tile-renderer.js → caches/tile-renderer.js} +0 -0
- /package/dist/{animation-adapter.d.ts → raster/animation-adapter.d.ts} +0 -0
- /package/dist/{animation-adapter.js → raster/animation-adapter.js} +0 -0
- /package/dist/{js-rasterizer.d.ts → raster/js-rasterizer.d.ts} +0 -0
- /package/dist/{js-rasterizer.js → raster/js-rasterizer.js} +0 -0
- /package/dist/{rasterizer.d.ts → raster/rasterizer.d.ts} +0 -0
- /package/dist/{rasterizer.js → raster/rasterizer.js} +0 -0
- /package/dist/{wasm-bytes.d.ts → raster/wasm-bytes.d.ts} +0 -0
- /package/dist/{wasm-bytes.js → raster/wasm-bytes.js} +0 -0
- /package/dist/{shape-renderer.js → rendering/shape-renderer.js} +0 -0
- /package/dist/{dim-target.d.ts → targets/dim-target.d.ts} +0 -0
- /package/dist/{dim-target.js → targets/dim-target.js} +0 -0
- /package/dist/{layers.d.ts → targets/layers.d.ts} +0 -0
- /package/dist/{layers.js → targets/layers.js} +0 -0
- /package/dist/{render-target.js → targets/render-target.js} +0 -0
- /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
|