sygnal 7.0.0 → 7.2.0-preview.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 (343) hide show
  1. package/CHANGELOG.md +632 -0
  2. package/dist/agent-ui.cjs.js +592 -0
  3. package/dist/agent-ui.cjs.js.map +1 -0
  4. package/dist/agent-ui.esm.js +582 -0
  5. package/dist/agent-ui.esm.js.map +1 -0
  6. package/dist/api.cjs.js +63 -0
  7. package/dist/api.cjs.js.map +1 -0
  8. package/dist/api.esm.js +2 -0
  9. package/dist/api.esm.js.map +1 -0
  10. package/dist/astro/index.cjs.js +968 -4
  11. package/dist/astro/index.cjs.js.map +1 -1
  12. package/dist/astro/index.mjs +968 -4
  13. package/dist/astro/index.mjs.map +1 -1
  14. package/dist/astro/server.cjs.js +73 -45
  15. package/dist/astro/server.cjs.js.map +1 -1
  16. package/dist/astro/server.mjs +73 -45
  17. package/dist/astro/server.mjs.map +1 -1
  18. package/dist/canvas.cjs.js +2919 -0
  19. package/dist/canvas.cjs.js.map +1 -0
  20. package/dist/canvas.esm.js +2866 -0
  21. package/dist/canvas.esm.js.map +1 -0
  22. package/dist/devtools.cjs.js +38 -6
  23. package/dist/devtools.cjs.js.map +1 -1
  24. package/dist/devtools.esm.js +38 -6
  25. package/dist/devtools.esm.js.map +1 -1
  26. package/dist/diagnostics.cjs.js +224 -20
  27. package/dist/diagnostics.cjs.js.map +1 -1
  28. package/dist/diagnostics.esm.js +224 -20
  29. package/dist/diagnostics.esm.js.map +1 -1
  30. package/dist/guide/accessibility.md +1 -1
  31. package/dist/guide/ai-compose.md +150 -0
  32. package/dist/guide/behaviors.md +2 -1
  33. package/dist/guide/collaborative-state.md +265 -0
  34. package/dist/guide/element-commands.md +36 -2
  35. package/dist/guide/forms-reference.md +5 -5
  36. package/dist/guide/forms.md +3 -2
  37. package/dist/guide/http.md +28 -1
  38. package/dist/guide/islands.md +240 -0
  39. package/dist/guide/recipes/code-editor.md +1 -1
  40. package/dist/guide/renderers/agent-ui.md +138 -0
  41. package/dist/guide/renderers/custom-targets.md +126 -0
  42. package/dist/guide/renderers/overview.md +120 -0
  43. package/dist/guide/renderers/react-native.md +128 -0
  44. package/dist/guide/renderers/terminal.md +306 -0
  45. package/dist/guide/renderers/three.md +332 -0
  46. package/dist/guide/renderers/write-once.md +137 -0
  47. package/dist/guide/resources.md +2 -0
  48. package/dist/guide/sandboxed-components.md +223 -0
  49. package/dist/guide/timers.md +32 -5
  50. package/dist/guide/typed-apis.md +442 -0
  51. package/dist/guide/ui/accordion.md +11 -0
  52. package/dist/guide/ui/combobox.md +112 -8
  53. package/dist/guide/ui/dialog.md +96 -20
  54. package/dist/guide/ui/menu.md +57 -5
  55. package/dist/guide/ui/overview.md +8 -4
  56. package/dist/guide/ui/popover.md +69 -9
  57. package/dist/guide/ui/select.md +54 -4
  58. package/dist/guide/ui/tabs.md +1 -1
  59. package/dist/guide/ui/tooltip.md +7 -5
  60. package/dist/guide/virtual-collections.md +3 -0
  61. package/dist/guide/widgets.md +90 -1
  62. package/dist/host.cjs.js +2181 -0
  63. package/dist/host.cjs.js.map +1 -0
  64. package/dist/host.esm.js +2146 -0
  65. package/dist/host.esm.js.map +1 -0
  66. package/dist/index.cjs.js +3948 -586
  67. package/dist/index.cjs.js.map +1 -1
  68. package/dist/index.d.ts +584 -44
  69. package/dist/index.esm.js +3918 -586
  70. package/dist/index.esm.js.map +1 -1
  71. package/dist/primitives.cjs.js +143 -0
  72. package/dist/primitives.cjs.js.map +1 -0
  73. package/dist/primitives.esm.js +133 -0
  74. package/dist/primitives.esm.js.map +1 -0
  75. package/dist/react-native.cjs.js +248 -0
  76. package/dist/react-native.cjs.js.map +1 -0
  77. package/dist/react-native.esm.js +244 -0
  78. package/dist/react-native.esm.js.map +1 -0
  79. package/dist/replay.cjs.js +39 -0
  80. package/dist/replay.cjs.js.map +1 -0
  81. package/dist/replay.esm.js +2 -0
  82. package/dist/replay.esm.js.map +1 -0
  83. package/dist/sandbox-build.cjs.js +140 -0
  84. package/dist/sandbox-build.cjs.js.map +1 -0
  85. package/dist/sandbox-build.esm.js +137 -0
  86. package/dist/sandbox-build.esm.js.map +1 -0
  87. package/dist/sandbox-kernel.esm.js +212 -0
  88. package/dist/sandbox-kernel.esm.js.map +1 -0
  89. package/dist/sandbox-worker.cjs.js +857 -0
  90. package/dist/sandbox-worker.cjs.js.map +1 -0
  91. package/dist/sandbox-worker.esm.js +854 -0
  92. package/dist/sandbox-worker.esm.js.map +1 -0
  93. package/dist/sandbox.cjs.js +1425 -0
  94. package/dist/sandbox.cjs.js.map +1 -0
  95. package/dist/sandbox.esm.js +1406 -0
  96. package/dist/sandbox.esm.js.map +1 -0
  97. package/dist/sygnal.min.js +1 -1
  98. package/dist/sygnal.min.js.map +1 -1
  99. package/dist/terminal.cjs.js +1314 -0
  100. package/dist/terminal.cjs.js.map +1 -0
  101. package/dist/terminal.esm.js +1304 -0
  102. package/dist/terminal.esm.js.map +1 -0
  103. package/dist/three.cjs.js +1111 -0
  104. package/dist/three.cjs.js.map +1 -0
  105. package/dist/three.esm.js +1079 -0
  106. package/dist/three.esm.js.map +1 -0
  107. package/dist/ui-combobox.cjs.js +256 -18
  108. package/dist/ui-combobox.cjs.js.map +1 -1
  109. package/dist/ui-combobox.esm.js +256 -18
  110. package/dist/ui-combobox.esm.js.map +1 -1
  111. package/dist/ui-menu.cjs.js +150 -16
  112. package/dist/ui-menu.cjs.js.map +1 -1
  113. package/dist/ui-menu.esm.js +151 -17
  114. package/dist/ui-menu.esm.js.map +1 -1
  115. package/dist/ui-select.cjs.js +95 -9
  116. package/dist/ui-select.cjs.js.map +1 -1
  117. package/dist/ui-select.esm.js +95 -9
  118. package/dist/ui-select.esm.js.map +1 -1
  119. package/dist/ui.cjs.js +679 -173
  120. package/dist/ui.cjs.js.map +1 -1
  121. package/dist/ui.esm.js +679 -175
  122. package/dist/ui.esm.js.map +1 -1
  123. package/dist/vike/config/package.json +1 -1
  124. package/dist/vite/plugin.cjs.js +968 -4
  125. package/dist/vite/plugin.cjs.js.map +1 -1
  126. package/dist/vite/plugin.mjs +968 -4
  127. package/dist/vite/plugin.mjs.map +1 -1
  128. package/dist/worker-page.cjs.js +669 -0
  129. package/dist/worker-page.cjs.js.map +1 -0
  130. package/dist/worker-page.esm.js +665 -0
  131. package/dist/worker-page.esm.js.map +1 -0
  132. package/dist/worker.cjs.js +377 -0
  133. package/dist/worker.cjs.js.map +1 -0
  134. package/dist/worker.esm.js +374 -0
  135. package/dist/worker.esm.js.map +1 -0
  136. package/dist/yjs-testing.cjs.js +98 -0
  137. package/dist/yjs-testing.cjs.js.map +1 -0
  138. package/dist/yjs-testing.esm.js +77 -0
  139. package/dist/yjs-testing.esm.js.map +1 -0
  140. package/dist/yjs.cjs.js +923 -0
  141. package/dist/yjs.cjs.js.map +1 -0
  142. package/dist/yjs.esm.js +900 -0
  143. package/dist/yjs.esm.js.map +1 -0
  144. package/dist/zag.cjs.js +11 -1
  145. package/dist/zag.cjs.js.map +1 -1
  146. package/dist/zag.esm.js +11 -1
  147. package/dist/zag.esm.js.map +1 -1
  148. package/llms/ai.md +65 -0
  149. package/llms/collaboration.md +5 -0
  150. package/llms/graphics.md +10 -0
  151. package/llms/renderers.md +7 -0
  152. package/llms/replay.md +5 -0
  153. package/llms/sandbox.md +5 -0
  154. package/llms/typed-apis.md +5 -0
  155. package/llms/ui.md +10 -0
  156. package/llms-full.txt +444 -0
  157. package/llms.txt +37 -35
  158. package/package.json +139 -3
  159. package/src/agent-ui/mcp.ts +152 -0
  160. package/src/agent-ui/snapshot.ts +212 -0
  161. package/src/agent-ui.d.ts +106 -0
  162. package/src/agent-ui.ts +249 -0
  163. package/src/ai.d.ts +95 -2
  164. package/src/api.d.ts +303 -0
  165. package/src/api.ts +9 -0
  166. package/src/canvas/Canvas.ts +585 -0
  167. package/src/canvas/backend.ts +627 -0
  168. package/src/canvas/geometry.ts +187 -0
  169. package/src/canvas/gesture.ts +23 -0
  170. package/src/canvas/image.ts +197 -0
  171. package/src/canvas/kit.ts +130 -0
  172. package/src/canvas/paint.ts +260 -0
  173. package/src/canvas/pointer.ts +25 -0
  174. package/src/canvas/snap.ts +68 -0
  175. package/src/canvas/svg.ts +99 -0
  176. package/src/canvas.d.ts +373 -0
  177. package/src/canvas.ts +46 -0
  178. package/src/core/actions.ts +14 -3
  179. package/src/core/cell.ts +12 -4
  180. package/src/core/debug.ts +8 -3
  181. package/src/core/hooks.ts +16 -2
  182. package/src/core/hosts/collection.ts +1 -1
  183. package/src/core/hosts/switchable.ts +26 -8
  184. package/src/core/instance.ts +98 -45
  185. package/src/core/markers/portal.ts +4 -0
  186. package/src/core/registry.ts +4 -0
  187. package/src/core/runtime.ts +34 -25
  188. package/src/core/statics.ts +5 -5
  189. package/src/cycle/dom/EventDelegator.ts +37 -33
  190. package/src/cycle/dom/MainDOMSource.ts +16 -14
  191. package/src/cycle/dom/enrichEventStream.ts +13 -9
  192. package/src/cycle/dom/fromEvent.ts +6 -10
  193. package/src/cycle/dom/index.ts +11 -7
  194. package/src/cycle/dom/isolate.ts +13 -6
  195. package/src/cycle/dom/makeDOMDriver.ts +6 -2
  196. package/src/cycle/dom/utils.ts +9 -2
  197. package/src/cycle/run/adapt.ts +4 -12
  198. package/src/devtools.d.ts +9 -0
  199. package/src/devtools.ts +2 -1
  200. package/src/extra/ai/compose/catalog.ts +2 -1
  201. package/src/extra/ai/compose/decisions.ts +1 -2
  202. package/src/extra/ai/compose/generated.ts +234 -0
  203. package/src/extra/ai/compose/index.ts +6 -2
  204. package/src/extra/ai/compose/layout.ts +3 -1
  205. package/src/extra/ai/compose/llm.ts +7 -3
  206. package/src/extra/ai/compose/types.ts +2 -2
  207. package/src/extra/ai/transports/shared.ts +3 -1
  208. package/src/extra/api/contract.ts +485 -0
  209. package/src/extra/api/index.ts +26 -0
  210. package/src/extra/behaviors.ts +3 -3
  211. package/src/extra/controlled.ts +44 -0
  212. package/src/extra/copyAsTest.ts +22 -0
  213. package/src/extra/devtools.ts +5 -3
  214. package/src/extra/devtoolsActions.ts +8 -0
  215. package/src/extra/devtoolsNext.ts +1 -1
  216. package/src/extra/diagnostics/checks/actionLog.ts +8 -3
  217. package/src/extra/diagnostics/checks/behaviors.ts +25 -0
  218. package/src/extra/diagnostics/checks/elementCommands.ts +17 -5
  219. package/src/extra/diagnostics/checks/index.ts +1 -1
  220. package/src/extra/diagnostics/checks/next.ts +78 -4
  221. package/src/extra/diagnostics/checks/props.ts +6 -1
  222. package/src/extra/diagnostics/checks/replies.ts +11 -7
  223. package/src/extra/diagnostics/checks/timers.ts +8 -2
  224. package/src/extra/diagnostics/checks/virtual.ts +11 -0
  225. package/src/extra/diagnostics/codes.ts +51 -1
  226. package/src/extra/elementCommands.ts +20 -4
  227. package/src/extra/fetchDriver.ts +4 -1
  228. package/src/extra/fetchEventSource.ts +6 -2
  229. package/src/extra/field.ts +29 -0
  230. package/src/extra/form.ts +42 -16
  231. package/src/extra/frame.ts +54 -0
  232. package/src/extra/keymap.ts +41 -0
  233. package/src/extra/pager.ts +2 -0
  234. package/src/extra/persist.ts +2 -1
  235. package/src/extra/pointer.ts +262 -0
  236. package/src/extra/processForm.ts +34 -1
  237. package/src/extra/replay/address.ts +49 -0
  238. package/src/extra/replay/entropy.ts +99 -0
  239. package/src/extra/replay/format.ts +263 -0
  240. package/src/extra/replay/index.ts +8 -0
  241. package/src/extra/replay/inert.ts +22 -0
  242. package/src/extra/replay/recorder.ts +247 -0
  243. package/src/extra/replay/replayer.ts +234 -0
  244. package/src/extra/replay/report.ts +126 -0
  245. package/src/extra/replay/schema.ts +52 -0
  246. package/src/extra/replay/version.ts +3 -0
  247. package/src/extra/router.ts +7 -3
  248. package/src/extra/selection.ts +2 -0
  249. package/src/extra/socketDriver.ts +51 -6
  250. package/src/extra/ssr.ts +128 -42
  251. package/src/extra/testing.ts +295 -39
  252. package/src/extra/timers.ts +96 -7
  253. package/src/extra/undo.ts +2 -0
  254. package/src/extra/virtual.ts +25 -2
  255. package/src/extra/widget.ts +106 -17
  256. package/src/host/actions.ts +383 -0
  257. package/src/host/delegation.ts +101 -0
  258. package/src/host/dom.ts +553 -0
  259. package/src/host/driver.ts +185 -0
  260. package/src/host/selector.ts +119 -0
  261. package/src/host/style.ts +51 -0
  262. package/src/host.d.ts +298 -0
  263. package/src/host.ts +46 -0
  264. package/src/index.d.ts +302 -38
  265. package/src/index.ts +15 -3
  266. package/src/pragma/blocks.ts +211 -0
  267. package/src/pragma/index.ts +44 -10
  268. package/src/primitives.d.ts +54 -0
  269. package/src/primitives.ts +136 -0
  270. package/src/react-native.d.ts +57 -0
  271. package/src/react-native.ts +285 -0
  272. package/src/react-peers.d.ts +21 -3
  273. package/src/replay.d.ts +185 -0
  274. package/src/replay.ts +6 -0
  275. package/src/sandbox/build.ts +119 -0
  276. package/src/sandbox/create.ts +187 -0
  277. package/src/sandbox/engine.ts +267 -0
  278. package/src/sandbox/generated.ts +195 -0
  279. package/src/sandbox/kernel.ts +180 -0
  280. package/src/sandbox/mirror.ts +269 -0
  281. package/src/sandbox/protocol.ts +128 -0
  282. package/src/sandbox/quickjs.ts +74 -0
  283. package/src/sandbox/ssr.ts +41 -0
  284. package/src/sandbox/widget.ts +60 -0
  285. package/src/sandbox/worker-entry.ts +59 -0
  286. package/src/sandbox/worker.ts +137 -0
  287. package/src/sandbox-build.d.ts +12 -0
  288. package/src/sandbox-build.ts +6 -0
  289. package/src/sandbox.d.ts +299 -0
  290. package/src/sandbox.ts +25 -0
  291. package/src/shared.ts +12 -0
  292. package/src/terminal/paint.ts +712 -0
  293. package/src/terminal.d.ts +167 -0
  294. package/src/terminal.ts +530 -0
  295. package/src/three/assets.ts +170 -0
  296. package/src/three/host.ts +36 -0
  297. package/src/three/scene.ts +217 -0
  298. package/src/three/target.ts +252 -0
  299. package/src/three.d.ts +221 -0
  300. package/src/three.ts +30 -0
  301. package/src/ui/accordion.ts +21 -7
  302. package/src/ui/dialog.ts +192 -90
  303. package/src/ui/disclosure.ts +2 -0
  304. package/src/ui/dom.ts +30 -0
  305. package/src/ui/layers.ts +132 -0
  306. package/src/ui/popover.ts +120 -21
  307. package/src/ui/shared.ts +98 -12
  308. package/src/ui/stack.ts +18 -0
  309. package/src/ui/tabs.ts +2 -0
  310. package/src/ui/toaster.ts +16 -15
  311. package/src/ui/tooltip.ts +24 -8
  312. package/src/ui/tooltips.ts +26 -14
  313. package/src/ui/zag/combobox.ts +180 -13
  314. package/src/ui/zag/menu.ts +97 -17
  315. package/src/ui/zag/select.ts +15 -4
  316. package/src/ui/zag/shared.ts +60 -8
  317. package/src/ui-combobox.d.ts +16 -1
  318. package/src/ui-menu.d.ts +22 -7
  319. package/src/ui-zag-types.d.ts +13 -5
  320. package/src/ui.d.ts +62 -7
  321. package/src/ui.ts +2 -2
  322. package/src/vite/api.ts +186 -0
  323. package/src/vite/compile.ts +280 -0
  324. package/src/vite/plugin.d.ts +34 -0
  325. package/src/vite/plugin.ts +78 -4
  326. package/src/vite/sandbox.ts +16 -0
  327. package/src/worker/page.ts +396 -0
  328. package/src/worker/protocol.ts +48 -0
  329. package/src/worker/target.ts +282 -0
  330. package/src/worker-page.d.ts +42 -0
  331. package/src/worker-page.ts +12 -0
  332. package/src/worker.d.ts +65 -0
  333. package/src/worker.ts +16 -0
  334. package/src/yjs/driver.ts +253 -0
  335. package/src/yjs/mapping.ts +389 -0
  336. package/src/yjs/shared.ts +138 -0
  337. package/src/yjs/testing.ts +91 -0
  338. package/src/yjs-testing.d.ts +34 -0
  339. package/src/yjs-testing.ts +7 -0
  340. package/src/yjs.d.ts +109 -0
  341. package/src/yjs.ts +18 -0
  342. package/src/zag.d.ts +2 -0
  343. package/src/zag.ts +8 -1
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The Canvas 2D painter and hit test: nodes are plain data in one
3
+ * vocabulary, whether they come from a data layer (`<shapes items={…}>`) or from JSX children.
4
+ *
5
+ * { id, type: 'rect' | 'ellipse' | 'text' | 'frame' | 'line' | 'path' | 'image',
6
+ * x, y, width, height, rotation (deg), radius, fill, stroke, strokeWidth, dash, opacity,
7
+ * text, fontSize, fontFamily, fontWeight, hidden, locked, parent (the frame that clips it), name }
8
+ *
9
+ * - line: x1 y1 x2 y2 (and no box); path: d (Path2D, browser only)
10
+ * - frame: a rect that clips the nodes whose `parent` is its id, with its `name` drawn above it
11
+ * - text: wraps to its `width` (`wrap` 'word' | 'char' | 'none', `\n`, `maxLines` + `ellipsis`,
12
+ * `align`, `lineHeight`); no width: one line per `\n`, its box measured
13
+ * - image (image.ts): `src` (loaded once per URL) or `image` (a loaded source), the crop `sx sy sw sh`,
14
+ * `fit` as CSS object-fit, `flipX` / `flipY`, `smoothing: false` for pixel art, `alt`
15
+ * - hidden: not painted, not hit; locked: painted, not hit
16
+ */
17
+ import {aabb, overlaps, hitBox, indexOf, type AABB, type Camera, type Pt} from './geometry'
18
+ import {imageOf, placement, natural} from './image'
19
+ export {indexOf}
20
+
21
+ export type Node = {[k: string]: any, id?: any, type: string}
22
+
23
+ const DEG = Math.PI / 180
24
+ export const font = (n: Node) => `${n.fontWeight || 400} ${n.fontSize || 16}px ${n.fontFamily || 'Inter, system-ui, sans-serif'}`
25
+
26
+ let mctx: any
27
+ const measurer = () => {
28
+ if (mctx === undefined) {
29
+ try { mctx = typeof OffscreenCanvas != 'undefined' ? new OffscreenCanvas(1, 1).getContext('2d') : null } catch (_) { mctx = null }
30
+ }
31
+ return mctx
32
+ }
33
+
34
+ export type TextOptions = {
35
+ fontSize?: number, fontFamily?: string, fontWeight?: any,
36
+ /** the box's width: lines wrap to it (unless `wrap` is 'none') */
37
+ width?: number | null
38
+ /** 'word' (default: at spaces and after hyphens; a longer word overflows, as in the DOM), 'char' (anywhere), 'none' */
39
+ wrap?: 'word' | 'char' | 'none'
40
+ /** at most this many lines; the last one kept ends in '…' (unless `ellipsis: false`) */
41
+ maxLines?: number | null
42
+ /** '…' where text is cut: by `maxLines` (default true), or, with `wrap: 'none'` and a width, a line wider than the box (only when true) */
43
+ ellipsis?: boolean
44
+ /** the line box as a multiple of fontSize (default 1.25) */
45
+ lineHeight?: number
46
+ }
47
+ export type TextLayout = {lines: string[], width: number, height: number, lineHeight: number}
48
+
49
+ const ELLIPSIS = '\u2026'
50
+ // a break opportunity: a run of spaces, a word up to and with its hyphens, a word
51
+ const SEGMENTS = /\s+|[^\s-]*-+|\S+/g
52
+
53
+ /**
54
+ * Lines and size of `text` in world units: wrapped to `width` (word by word, or anywhere with
55
+ * `wrap: 'char'`), cut to `maxLines`. Measured on an offscreen canvas where there is one, estimated
56
+ * elsewhere (Node, jsdom). `width` / `height` are the widest line's and the lines' (not the box's).
57
+ */
58
+ export function layoutText(text: any, n: TextOptions = {}): TextLayout {
59
+ const size = n.fontSize || 16, lh = size * (n.lineHeight || 1.25), ctx = measurer()
60
+ if (ctx) ctx.font = font({type: 'text', ...n})
61
+ const m = (s: string) => ctx ? ctx.measureText(s).width : s.length * size * 0.55
62
+ const max = typeof n.width == 'number' && n.wrap != 'none' ? n.width + 0.5 : Infinity
63
+ const lines: string[] = []
64
+ for (const para of String(text ?? '').split('\n')) {
65
+ if (max == Infinity) { lines.push(para); continue }
66
+ if (n.wrap == 'char') {
67
+ let line = ''
68
+ for (const ch of para) {
69
+ if (line && m(line + ch) > max) { lines.push(line); line = ch.trim() ? ch : '' }
70
+ else line += ch
71
+ }
72
+ lines.push(line)
73
+ continue
74
+ }
75
+ let line = '', space = '', first = true
76
+ for (const seg of para.match(SEGMENTS) || []) {
77
+ if (!seg.trim()) { space += seg; continue }
78
+ // (a paragraph's leading spaces stay; the spaces at a break go; a word longer than the box overflows)
79
+ if (first) line = space + seg, first = false
80
+ else if (m(line + space + seg) > max) { lines.push(line); line = seg }
81
+ else line += space + seg
82
+ space = ''
83
+ }
84
+ lines.push(first ? space : line)
85
+ }
86
+ const cut = n.maxLines != null && n.maxLines > 0 && lines.length > n.maxLines
87
+ if (cut) lines.length = n.maxLines!
88
+ const room = typeof n.width == 'number' ? n.width + 0.5 : Infinity
89
+ const shorten = (l: string) => {
90
+ let s = l.trimEnd()
91
+ while (s && m(s + ELLIPSIS) > room) s = Array.from(s).slice(0, -1).join('').trimEnd()
92
+ return s + ELLIPSIS
93
+ }
94
+ if (cut && n.ellipsis !== false) lines[lines.length - 1] = shorten(lines[lines.length - 1])
95
+ if (n.ellipsis === true && n.wrap == 'none' && room < Infinity) lines.forEach((l, i) => { if (m(l) > room) lines[i] = shorten(l) })
96
+ let w = 0
97
+ for (const l of lines) w = Math.max(w, m(l))
98
+ return {lines, width: Math.ceil(w), height: Math.ceil(lines.length * lh), lineHeight: lh}
99
+ }
100
+
101
+ /**
102
+ * text's natural size in world units: one line per `\n`, unwrapped (its font from `n`, which may be
103
+ * the node itself: its width and wrapping are ignored). An auto-width text box's size; `layoutText`
104
+ * for the lines a box of a given width shows
105
+ */
106
+ export function measureText(text: string, n: TextOptions = {}) {
107
+ const l = layoutText(text, {fontSize: n.fontSize, fontFamily: n.fontFamily, fontWeight: n.fontWeight, lineHeight: n.lineHeight})
108
+ return {width: l.width, height: l.height}
109
+ }
110
+
111
+ // one layout per text node (nodes are immutable data: identity is the key)
112
+ const layouts = new WeakMap<object, TextLayout>()
113
+ export const textLayout = (n: Node): TextLayout => {
114
+ let l = layouts.get(n)
115
+ if (!l) layouts.set(n, l = layoutText(n.text, n as TextOptions))
116
+ return l
117
+ }
118
+
119
+ const sizes = new WeakMap<object, Node>()
120
+ /**
121
+ * the node with its box: a text node without width / height gets its laid-out size, an image
122
+ * without them its natural size once loaded (the DOM's <img>). Others as they are
123
+ */
124
+ export function sized(n: Node): Node {
125
+ if (n.width != null && n.height != null || n.type != 'text' && n.type != 'image') return n
126
+ let s = sizes.get(n)
127
+ if (s) return s
128
+ let w = 0, h = 0
129
+ if (n.type == 'text') { const l = textLayout(n); w = l.width; h = l.height }
130
+ else {
131
+ const img = imageOf(n)
132
+ if (!img) return {...n, width: n.width ?? 0, height: n.height ?? 0}
133
+ const [W, H] = natural(img), sw = n.sw ?? W - (n.sx ?? 0), sh = n.sh ?? H - (n.sy ?? 0)
134
+ // one side given: the other keeps the aspect ratio
135
+ w = n.width ?? (n.height != null && sh ? n.height * sw / sh : sw); h = n.height ?? (n.width != null && sw ? n.width * sh / sw : sh)
136
+ }
137
+ sizes.set(n, s = {...n, width: n.width ?? w, height: n.height ?? h})
138
+ return s
139
+ }
140
+
141
+
142
+ function shape(ctx: any, n: Node) {
143
+ ctx.beginPath()
144
+ const t = n.type
145
+ if (t == 'ellipse') ctx.ellipse(n.x + n.width / 2, n.y + n.height / 2, Math.abs(n.width / 2), Math.abs(n.height / 2), 0, 0, Math.PI * 2)
146
+ else if (t == 'line') { ctx.moveTo(n.x1, n.y1); ctx.lineTo(n.x2, n.y2) }
147
+ else if (n.radius && ctx.roundRect) ctx.roundRect(n.x, n.y, n.width, n.height, Math.min(n.radius, Math.abs(n.width) / 2, Math.abs(n.height) / 2))
148
+ else ctx.rect(n.x, n.y, n.width, n.height)
149
+ }
150
+
151
+ /** one node in the current transform (world or screen) */
152
+ export function paintNode(ctx: any, n: Node, unit = 1) {
153
+ const rot = n.rotation || 0
154
+ ctx.globalAlpha = n.opacity ?? 1
155
+ if (rot) {
156
+ ctx.save()
157
+ const b = sized(n), cx = n.x + b.width / 2, cy = n.y + b.height / 2
158
+ ctx.translate(cx, cy); ctx.rotate(rot * DEG); ctx.translate(-cx, -cy)
159
+ }
160
+ const fill = n.fill ?? (n.type == 'line' ? null : n.type == 'text' ? '#000' : null)
161
+ if (n.type == 'text') {
162
+ const L = textLayout(n), size = n.fontSize || 16, a = n.align
163
+ ctx.font = font(n)
164
+ ctx.textBaseline = 'top'
165
+ ctx.textAlign = a == 'center' || a == 'right' ? a : 'left'
166
+ ctx.fillStyle = fill
167
+ // aligned in the box (its width, else the widest line's); the glyphs centred in each line box
168
+ const x = n.x + (a == 'center' ? (n.width ?? L.width) / 2 : a == 'right' ? n.width ?? L.width : 0)
169
+ const off = size * 0.12 + (L.lineHeight - size * 1.25) / 2
170
+ for (let i = 0; i < L.lines.length; i++) ctx.fillText(L.lines[i], x, n.y + i * L.lineHeight + off)
171
+ } else if (n.type == 'image') {
172
+ const img = imageOf(n), p = img && placement(n, img)
173
+ if (p) {
174
+ const flip = n.flipX || n.flipY
175
+ if (flip) {
176
+ const b = sized(n), cx = n.x + b.width / 2, cy = n.y + b.height / 2
177
+ ctx.save(); ctx.translate(cx, cy); ctx.scale(n.flipX ? -1 : 1, n.flipY ? -1 : 1); ctx.translate(-cx, -cy)
178
+ }
179
+ if (n.smoothing === false) ctx.imageSmoothingEnabled = false
180
+ ctx.drawImage(img, p.sx, p.sy, p.sw, p.sh, p.dx, p.dy, p.dw, p.dh)
181
+ if (n.smoothing === false) ctx.imageSmoothingEnabled = true
182
+ if (flip) ctx.restore()
183
+ }
184
+ } else if (n.type == 'path') {
185
+ const p = n.$path ||= new (globalThis as any).Path2D(n.d)
186
+ if (fill) { ctx.fillStyle = fill; ctx.fill(p) }
187
+ if (n.stroke) { ctx.strokeStyle = n.stroke; ctx.lineWidth = (n.strokeWidth ?? 1) * unit; ctx.stroke(p) }
188
+ } else {
189
+ shape(ctx, n)
190
+ if (fill && fill != 'none') { ctx.fillStyle = fill; ctx.fill() }
191
+ if (n.stroke && n.stroke != 'none' && (n.strokeWidth ?? 1) > 0) {
192
+ ctx.strokeStyle = n.stroke
193
+ ctx.lineWidth = (n.strokeWidth ?? 1) * unit
194
+ if (n.dash) ctx.setLineDash(n.dash.map((d: number) => d * unit))
195
+ ctx.stroke()
196
+ if (n.dash) ctx.setLineDash([])
197
+ }
198
+ }
199
+ if (rot) ctx.restore()
200
+ }
201
+
202
+ export type PaintStats = {nodes: number, culled: number, ms: number}
203
+
204
+ /**
205
+ * paint `items` in world coordinates under camera `cam`; `view` culls (world AABB); `skip` leaves
206
+ * out ids (the layered strategy paints them elsewhere)
207
+ */
208
+ export function paintWorld(ctx: any, items: Node[], cam: Camera, dpr: number, view: AABB | null, skip?: Set<any> | null, st?: PaintStats) {
209
+ const z = cam.zoom
210
+ ctx.setTransform(dpr * z, 0, 0, dpr * z, -cam.x * z * dpr, -cam.y * z * dpr)
211
+ const idx = indexOf(items)
212
+ let clip: any = undefined, drawn = 0, culled = 0
213
+ for (const n of items) {
214
+ if (n.hidden || skip?.has(n.id)) continue
215
+ const parent = n.parent != null ? idx.get(n.parent) : null
216
+ if (parent?.hidden) continue
217
+ if (view && n.type != 'line' && n.type != 'path' && !overlaps(aabb(sized(n) as any), view)) { culled++; continue }
218
+ // a frame clips its children: one clip per run of siblings
219
+ if (parent !== clip) {
220
+ if (clip) ctx.restore()
221
+ clip = parent
222
+ if (clip) {
223
+ ctx.save()
224
+ if (clip.rotation) { const cx = clip.x + clip.width / 2, cy = clip.y + clip.height / 2; ctx.translate(cx, cy); ctx.rotate(clip.rotation * DEG); ctx.translate(-cx, -cy) }
225
+ shape(ctx, {...clip, type: 'rect'})
226
+ ctx.clip()
227
+ if (clip.rotation) { const cx = clip.x + clip.width / 2, cy = clip.y + clip.height / 2; ctx.translate(cx, cy); ctx.rotate(-clip.rotation * DEG); ctx.translate(-cx, -cy) }
228
+ }
229
+ }
230
+ paintNode(ctx, n.type == 'frame' ? {...n, type: 'rect', fill: n.fill ?? '#fff'} : n, 1 / z)
231
+ drawn++
232
+ }
233
+ if (clip) ctx.restore()
234
+ // frame names above their frames, at a constant screen size
235
+ ctx.globalAlpha = 1
236
+ for (const n of items) if (n.type == 'frame' && !n.hidden && !skip?.has(n.id) && n.parent == null && (!view || overlaps(aabb(n as any), view))) {
237
+ ctx.font = `${11 / z}px Inter, system-ui, sans-serif`
238
+ ctx.textBaseline = 'bottom'
239
+ ctx.fillStyle = '#666'
240
+ ctx.fillText(n.name || 'Frame', n.x, n.y - 4 / z)
241
+ }
242
+ if (st) { st.nodes += drawn; st.culled += culled }
243
+ }
244
+
245
+ /** the topmost node at world point p: reverse paint order, frames clip their children */
246
+ export function hitNodes(items: Node[], p: Pt, slop = 0): Node | null {
247
+ const idx = indexOf(items)
248
+ for (let i = items.length - 1; i >= 0; i--) {
249
+ const n = items[i]
250
+ if (n.hidden || n.locked || n.pointerEvents == 'none') continue
251
+ const parent = n.parent != null ? idx.get(n.parent) : null
252
+ if (parent && (parent.hidden || !hitBox(parent as any, p))) continue
253
+ if (n.type == 'line') {
254
+ const dx = n.x2 - n.x1, dy = n.y2 - n.y1, l = dx * dx + dy * dy
255
+ const t = l ? Math.max(0, Math.min(1, ((p.x - n.x1) * dx + (p.y - n.y1) * dy) / l)) : 0
256
+ if (Math.hypot(p.x - n.x1 - t * dx, p.y - n.y1 - t * dy) <= Math.max(slop, (n.strokeWidth || 1) / 2)) return n
257
+ } else if (n.type != 'path' && hitBox(sized(n) as any, p, slop)) return n
258
+ }
259
+ return null
260
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A canvas pointer event as plain data, for intents: `DOM.select('.board').events('pointerdown').map(pointer)`.
3
+ * Plain data so reducers stay pure and the action log / replay / a worker can carry it.
4
+ *
5
+ * `part`: the `data-part` of the element hit (a handle: `<rect data={{ part: 'se' }} …>`), or null.
6
+ */
7
+ export type Pointer = {
8
+ x: number, y: number, // world
9
+ sx: number, sy: number, // canvas pixels
10
+ hit: any, // a data layer node's id, or null
11
+ part: string | null, // data-part of the JSX node hit
12
+ shift: boolean, alt: boolean, mod: boolean, button: number,
13
+ }
14
+
15
+ const isMac = typeof navigator != 'undefined' && /Mac|iP/.test(navigator.platform || navigator.userAgent)
16
+
17
+ export function pointer(e: any): Pointer {
18
+ const w = e.world || {x: 0, y: 0}, s = e.screen || {x: 0, y: 0}
19
+ return {
20
+ x: w.x, y: w.y, sx: s.x, sy: s.y,
21
+ hit: e.hit ?? null,
22
+ part: e.target?.dataset?.part ?? e.target?.getAttribute?.('data-part') ?? null,
23
+ shift: !!e.shiftKey, alt: !!e.altKey, mod: !!(isMac ? e.metaKey : e.ctrlKey), button: e.button || 0,
24
+ }
25
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Smart snapping: edges and centres of a moving box to other boxes' edges and centres. Pure: a
3
+ * reducer calls it per move.
4
+ *
5
+ * const targets = snapTargets(others) // once per gesture (sorted, O(n log n))
6
+ * const {dx, dy, guides} = snap(movingBox, dx, dy, targets, 6 / zoom)
7
+ *
8
+ * Guides are world-space segments `{ x1, y1, x2, y2 }` across the boxes that line up.
9
+ */
10
+ import type {AABB} from './geometry'
11
+
12
+ export type Targets = {xs: Float64Array, xb: AABB[], ys: Float64Array, yb: AABB[]}
13
+ export type Guide = {x1: number, y1: number, x2: number, y2: number}
14
+
15
+ /** the candidate lines of `boxes`: three per axis per box (start, centre, end), sorted */
16
+ export function snapTargets(boxes: AABB[]): Targets {
17
+ const xs: [number, AABB][] = [], ys: [number, AABB][] = []
18
+ for (const b of boxes) {
19
+ xs.push([b.x0, b], [(b.x0 + b.x1) / 2, b], [b.x1, b])
20
+ ys.push([b.y0, b], [(b.y0 + b.y1) / 2, b], [b.y1, b])
21
+ }
22
+ xs.sort((a, b) => a[0] - b[0]); ys.sort((a, b) => a[0] - b[0])
23
+ return {xs: Float64Array.from(xs, a => a[0]), xb: xs.map(a => a[1]), ys: Float64Array.from(ys, a => a[0]), yb: ys.map(a => a[1])}
24
+ }
25
+
26
+ // the index of the first value >= v
27
+ function lower(a: Float64Array, v: number) {
28
+ let lo = 0, hi = a.length
29
+ while (lo < hi) { const m = (lo + hi) >> 1; if (a[m] < v) lo = m + 1; else hi = m }
30
+ return lo
31
+ }
32
+
33
+ /** the nearest candidate to any of `vals` within `tol`: [offset, candidate value] */
34
+ function nearest(a: Float64Array, vals: number[], tol: number): [number, number] | null {
35
+ let best: [number, number] | null = null
36
+ for (const v of vals) {
37
+ const i = lower(a, v - tol)
38
+ for (let j = i; j < a.length && a[j] <= v + tol; j++) {
39
+ const d = a[j] - v
40
+ if (!best || Math.abs(d) < Math.abs(best[0])) best = [d, a[j]]
41
+ }
42
+ }
43
+ return best
44
+ }
45
+
46
+ export function snap(box: AABB, dx: number, dy: number, t: Targets, tol: number): {dx: number, dy: number, guides: Guide[]} {
47
+ const m = {x0: box.x0 + dx, y0: box.y0 + dy, x1: box.x1 + dx, y1: box.y1 + dy}
48
+ const vx = [m.x0, (m.x0 + m.x1) / 2, m.x1], vy = [m.y0, (m.y0 + m.y1) / 2, m.y1]
49
+ const sx = nearest(t.xs, vx, tol), sy = nearest(t.ys, vy, tol)
50
+ if (sx) dx += sx[0]
51
+ if (sy) dy += sy[0]
52
+ const s = {x0: box.x0 + dx, y0: box.y0 + dy, x1: box.x1 + dx, y1: box.y1 + dy}
53
+ const guides: Guide[] = []
54
+ // a guide through every box on the snapped line, spanning them and the moving box
55
+ if (sx) {
56
+ const x = sx[1]
57
+ let y1 = s.y0, y2 = s.y1
58
+ for (let i = lower(t.xs, x - 1e-6); i < t.xs.length && t.xs[i] <= x + 1e-6; i++) { y1 = Math.min(y1, t.xb[i].y0); y2 = Math.max(y2, t.xb[i].y1) }
59
+ guides.push({x1: x, y1, x2: x, y2})
60
+ }
61
+ if (sy) {
62
+ const y = sy[1]
63
+ let x1 = s.x0, x2 = s.x1
64
+ for (let i = lower(t.ys, y - 1e-6); i < t.ys.length && t.ys[i] <= y + 1e-6; i++) { x1 = Math.min(x1, t.yb[i].x0); x2 = Math.max(x2, t.yb[i].x1) }
65
+ guides.push({x1, y1: y, x2, y2: y})
66
+ }
67
+ return {dx, dy, guides}
68
+ }
@@ -0,0 +1,99 @@
1
+ /** Nodes (paint.ts's vocabulary) → an SVG document string: what the canvas paints, as SVG (`exportSVG`). */
2
+ import {union, type AABB} from './geometry'
3
+ import {indexOf, sized, textLayout, type Node} from './paint'
4
+ import {imageOf, natural, placement} from './image'
5
+
6
+ const esc = (s: any) => String(s).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'} as any)[c])
7
+ const r = (n: number) => Math.round(n * 100) / 100
8
+
9
+ function style(n: Node, frame = false) {
10
+ const fill = n.fill ?? (n.type == 'text' ? '#000' : frame ? '#fff' : 'none')
11
+ let s = ` fill="${esc(fill)}"`
12
+ if (n.stroke && n.stroke != 'none' && (n.strokeWidth ?? 1) > 0) s += ` stroke="${esc(n.stroke)}" stroke-width="${n.strokeWidth ?? 1}"`
13
+ if (n.opacity != null && n.opacity != 1) s += ` opacity="${n.opacity}"`
14
+ if (n.rotation) { const b = sized(n); s += ` transform="rotate(${r(n.rotation)} ${r(n.x + b.width / 2)} ${r(n.y + b.height / 2)})"` }
15
+ return s
16
+ }
17
+
18
+ function one(n: Node): string {
19
+ const s = style(n, n.type == 'frame')
20
+ switch (n.type) {
21
+ case 'ellipse': return `<ellipse cx="${r(n.x + n.width / 2)}" cy="${r(n.y + n.height / 2)}" rx="${r(n.width / 2)}" ry="${r(n.height / 2)}"${s}/>`
22
+ case 'text': {
23
+ // the canvas's lines (wrapped, cut), one tspan each, aligned in the box
24
+ const size = n.fontSize || 16, L = textLayout(n), a = n.align, b = sized(n)
25
+ const x = r(n.x + (a == 'center' ? b.width / 2 : a == 'right' ? b.width : 0))
26
+ const tspans = L.lines.map((l, i) => `<tspan x="${x}" dy="${i ? r(L.lineHeight) : 0}">${esc(l)}</tspan>`).join('')
27
+ return `<text x="${x}" y="${r(n.y + size * 0.12 + (L.lineHeight - size * 1.25) / 2)}" dominant-baseline="hanging"${a == 'center' ? ' text-anchor="middle"' : a == 'right' ? ' text-anchor="end"' : ''} font-size="${size}" font-family="${esc(n.fontFamily || 'Inter, system-ui, sans-serif')}"${n.fontWeight ? ` font-weight="${n.fontWeight}"` : ''}${n.text && /^\s|\s\s|\s$/m.test(n.text) ? ' xml:space="preserve"' : ''}${s}>${tspans}</text>`
28
+ }
29
+ case 'image': return image(n)
30
+ case 'line': return `<line x1="${n.x1}" y1="${n.y1}" x2="${n.x2}" y2="${n.y2}" stroke="${esc(n.stroke || '#000')}" stroke-width="${n.strokeWidth ?? 1}"/>`
31
+ case 'path': return `<path d="${esc(n.d)}"${s}/>`
32
+ default: return `<rect x="${r(n.x)}" y="${r(n.y)}" width="${r(n.width)}" height="${r(n.height)}"${n.radius ? ` rx="${n.radius}"` : ''}${s}/>`
33
+ }
34
+ }
35
+
36
+ // an image source's URL: its src, a loaded element's, a canvas's pixels (when not tainted)
37
+ function hrefOf(n: Node): string {
38
+ if (n.src && !n.image) return n.src
39
+ const i = n.image
40
+ if (i?.currentSrc || i?.src) return i.currentSrc || i.src
41
+ try { return i?.toDataURL?.() || '' } catch (_) { return '' }
42
+ }
43
+
44
+ /**
45
+ * an image: the crop fitted into its box as on the canvas (a nested <svg> whose viewBox is the
46
+ * crop); before it has loaded (its size unknown), the whole image with SVG's own fit
47
+ */
48
+ function image(n: Node): string {
49
+ const href = hrefOf(n), b = sized(n)
50
+ if (!href) return ''
51
+ const cx = n.x + b.width / 2, cy = n.y + b.height / 2
52
+ const t = [n.rotation ? `rotate(${r(n.rotation)} ${r(cx)} ${r(cy)})` : '', n.flipX || n.flipY ? `matrix(${n.flipX ? -1 : 1} 0 0 ${n.flipY ? -1 : 1} ${n.flipX ? r(2 * cx) : 0} ${n.flipY ? r(2 * cy) : 0})` : ''].filter(Boolean).join(' ')
53
+ const g = (body: string) => t || (n.opacity != null && n.opacity != 1) ? `<g${t ? ` transform="${t}"` : ''}${n.opacity != null && n.opacity != 1 ? ` opacity="${n.opacity}"` : ''}>${body}</g>` : body
54
+ const pix = n.smoothing === false ? ' style="image-rendering:pixelated"' : ''
55
+ const img = imageOf(n), p = img && placement(n, img)
56
+ if (!p) {
57
+ const fit = n.fit == 'contain' || n.fit == 'scale-down' ? 'xMidYMid meet' : n.fit == 'cover' || n.fit == 'none' ? 'xMidYMid slice' : 'none'
58
+ return g(`<image href="${esc(href)}" x="${r(n.x)}" y="${r(n.y)}" width="${r(b.width)}" height="${r(b.height)}" preserveAspectRatio="${fit}"${pix}/>`)
59
+ }
60
+ const [W, H] = natural(img)
61
+ return g(`<svg x="${r(p.dx)}" y="${r(p.dy)}" width="${r(p.dw)}" height="${r(p.dh)}" viewBox="${r(p.sx)} ${r(p.sy)} ${r(p.sw)} ${r(p.sh)}" preserveAspectRatio="none"><image href="${esc(href)}" width="${W}" height="${H}"${pix}/></svg>`)
62
+ }
63
+
64
+ /**
65
+ * `items` in paint order; `ids`: only these (and their descendants), else all. Frames clip their
66
+ * children (a clipPath per frame), as on the canvas.
67
+ */
68
+ export function exportSVG(items: Node[], ids?: any[] | null, pad = 0): string {
69
+ const idx = indexOf(items)
70
+ let keep = items.filter(n => !n.hidden)
71
+ if (ids) {
72
+ const want = new Set(ids)
73
+ const inside = (n: Node): boolean => want.has(n.id) || (n.parent != null && !!idx.get(n.parent) && inside(idx.get(n.parent)!))
74
+ keep = keep.filter(inside)
75
+ }
76
+ const b: AABB = union(keep.filter(n => n.type != 'line' && n.type != 'path').map(sized) as any) || {x0: 0, y0: 0, x1: 0, y1: 0}
77
+ const x = b.x0 - pad, y = b.y0 - pad, w = b.x1 - b.x0 + pad * 2, h = b.y1 - b.y0 + pad * 2
78
+ const clips: string[] = [], body: string[] = []
79
+ const kids = new Map<any, Node[]>()
80
+ for (const n of keep) {
81
+ const p = n.parent != null && keep.includes(idx.get(n.parent)!) ? n.parent : null
82
+ if (!kids.has(p)) kids.set(p, [])
83
+ kids.get(p)!.push(n)
84
+ }
85
+ const emit = (n: Node) => {
86
+ body.push(one(n))
87
+ const c = kids.get(n.id)
88
+ if (n.type == 'frame' && c?.length) {
89
+ const cid = `clip-${String(n.id).replace(/[^\w-]/g, '_')}`
90
+ clips.push(`<clipPath id="${cid}"><rect x="${r(n.x)}" y="${r(n.y)}" width="${r(n.width)}" height="${r(n.height)}"${n.rotation ? ` transform="rotate(${r(n.rotation)} ${r(n.x + n.width / 2)} ${r(n.y + n.height / 2)})"` : ''}/></clipPath>`)
91
+ body.push(`<g clip-path="url(#${cid})">`)
92
+ c.forEach(emit)
93
+ body.push('</g>')
94
+ }
95
+ }
96
+ ;(kids.get(null) || []).forEach(emit)
97
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${r(w)}" height="${r(h)}" viewBox="${r(x)} ${r(y)} ${r(w)} ${r(h)}">` +
98
+ (clips.length ? `<defs>${clips.join('')}</defs>` : '') + body.join('') + '</svg>'
99
+ }