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.
- package/CHANGELOG.md +632 -0
- package/dist/agent-ui.cjs.js +592 -0
- package/dist/agent-ui.cjs.js.map +1 -0
- package/dist/agent-ui.esm.js +582 -0
- package/dist/agent-ui.esm.js.map +1 -0
- package/dist/api.cjs.js +63 -0
- package/dist/api.cjs.js.map +1 -0
- package/dist/api.esm.js +2 -0
- package/dist/api.esm.js.map +1 -0
- package/dist/astro/index.cjs.js +968 -4
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +968 -4
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +73 -45
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +73 -45
- package/dist/astro/server.mjs.map +1 -1
- package/dist/canvas.cjs.js +2919 -0
- package/dist/canvas.cjs.js.map +1 -0
- package/dist/canvas.esm.js +2866 -0
- package/dist/canvas.esm.js.map +1 -0
- package/dist/devtools.cjs.js +38 -6
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.esm.js +38 -6
- package/dist/devtools.esm.js.map +1 -1
- package/dist/diagnostics.cjs.js +224 -20
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +224 -20
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +1 -1
- package/dist/guide/ai-compose.md +150 -0
- package/dist/guide/behaviors.md +2 -1
- package/dist/guide/collaborative-state.md +265 -0
- package/dist/guide/element-commands.md +36 -2
- package/dist/guide/forms-reference.md +5 -5
- package/dist/guide/forms.md +3 -2
- package/dist/guide/http.md +28 -1
- package/dist/guide/islands.md +240 -0
- package/dist/guide/recipes/code-editor.md +1 -1
- package/dist/guide/renderers/agent-ui.md +138 -0
- package/dist/guide/renderers/custom-targets.md +126 -0
- package/dist/guide/renderers/overview.md +120 -0
- package/dist/guide/renderers/react-native.md +128 -0
- package/dist/guide/renderers/terminal.md +306 -0
- package/dist/guide/renderers/three.md +332 -0
- package/dist/guide/renderers/write-once.md +137 -0
- package/dist/guide/resources.md +2 -0
- package/dist/guide/sandboxed-components.md +223 -0
- package/dist/guide/timers.md +32 -5
- package/dist/guide/typed-apis.md +442 -0
- package/dist/guide/ui/accordion.md +11 -0
- package/dist/guide/ui/combobox.md +112 -8
- package/dist/guide/ui/dialog.md +96 -20
- package/dist/guide/ui/menu.md +57 -5
- package/dist/guide/ui/overview.md +8 -4
- package/dist/guide/ui/popover.md +69 -9
- package/dist/guide/ui/select.md +54 -4
- package/dist/guide/ui/tabs.md +1 -1
- package/dist/guide/ui/tooltip.md +7 -5
- package/dist/guide/virtual-collections.md +3 -0
- package/dist/guide/widgets.md +90 -1
- package/dist/host.cjs.js +2181 -0
- package/dist/host.cjs.js.map +1 -0
- package/dist/host.esm.js +2146 -0
- package/dist/host.esm.js.map +1 -0
- package/dist/index.cjs.js +3948 -586
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +584 -44
- package/dist/index.esm.js +3918 -586
- package/dist/index.esm.js.map +1 -1
- package/dist/primitives.cjs.js +143 -0
- package/dist/primitives.cjs.js.map +1 -0
- package/dist/primitives.esm.js +133 -0
- package/dist/primitives.esm.js.map +1 -0
- package/dist/react-native.cjs.js +248 -0
- package/dist/react-native.cjs.js.map +1 -0
- package/dist/react-native.esm.js +244 -0
- package/dist/react-native.esm.js.map +1 -0
- package/dist/replay.cjs.js +39 -0
- package/dist/replay.cjs.js.map +1 -0
- package/dist/replay.esm.js +2 -0
- package/dist/replay.esm.js.map +1 -0
- package/dist/sandbox-build.cjs.js +140 -0
- package/dist/sandbox-build.cjs.js.map +1 -0
- package/dist/sandbox-build.esm.js +137 -0
- package/dist/sandbox-build.esm.js.map +1 -0
- package/dist/sandbox-kernel.esm.js +212 -0
- package/dist/sandbox-kernel.esm.js.map +1 -0
- package/dist/sandbox-worker.cjs.js +857 -0
- package/dist/sandbox-worker.cjs.js.map +1 -0
- package/dist/sandbox-worker.esm.js +854 -0
- package/dist/sandbox-worker.esm.js.map +1 -0
- package/dist/sandbox.cjs.js +1425 -0
- package/dist/sandbox.cjs.js.map +1 -0
- package/dist/sandbox.esm.js +1406 -0
- package/dist/sandbox.esm.js.map +1 -0
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/terminal.cjs.js +1314 -0
- package/dist/terminal.cjs.js.map +1 -0
- package/dist/terminal.esm.js +1304 -0
- package/dist/terminal.esm.js.map +1 -0
- package/dist/three.cjs.js +1111 -0
- package/dist/three.cjs.js.map +1 -0
- package/dist/three.esm.js +1079 -0
- package/dist/three.esm.js.map +1 -0
- package/dist/ui-combobox.cjs.js +256 -18
- package/dist/ui-combobox.cjs.js.map +1 -1
- package/dist/ui-combobox.esm.js +256 -18
- package/dist/ui-combobox.esm.js.map +1 -1
- package/dist/ui-menu.cjs.js +150 -16
- package/dist/ui-menu.cjs.js.map +1 -1
- package/dist/ui-menu.esm.js +151 -17
- package/dist/ui-menu.esm.js.map +1 -1
- package/dist/ui-select.cjs.js +95 -9
- package/dist/ui-select.cjs.js.map +1 -1
- package/dist/ui-select.esm.js +95 -9
- package/dist/ui-select.esm.js.map +1 -1
- package/dist/ui.cjs.js +679 -173
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.esm.js +679 -175
- package/dist/ui.esm.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vite/plugin.cjs.js +968 -4
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +968 -4
- package/dist/vite/plugin.mjs.map +1 -1
- package/dist/worker-page.cjs.js +669 -0
- package/dist/worker-page.cjs.js.map +1 -0
- package/dist/worker-page.esm.js +665 -0
- package/dist/worker-page.esm.js.map +1 -0
- package/dist/worker.cjs.js +377 -0
- package/dist/worker.cjs.js.map +1 -0
- package/dist/worker.esm.js +374 -0
- package/dist/worker.esm.js.map +1 -0
- package/dist/yjs-testing.cjs.js +98 -0
- package/dist/yjs-testing.cjs.js.map +1 -0
- package/dist/yjs-testing.esm.js +77 -0
- package/dist/yjs-testing.esm.js.map +1 -0
- package/dist/yjs.cjs.js +923 -0
- package/dist/yjs.cjs.js.map +1 -0
- package/dist/yjs.esm.js +900 -0
- package/dist/yjs.esm.js.map +1 -0
- package/dist/zag.cjs.js +11 -1
- package/dist/zag.cjs.js.map +1 -1
- package/dist/zag.esm.js +11 -1
- package/dist/zag.esm.js.map +1 -1
- package/llms/ai.md +65 -0
- package/llms/collaboration.md +5 -0
- package/llms/graphics.md +10 -0
- package/llms/renderers.md +7 -0
- package/llms/replay.md +5 -0
- package/llms/sandbox.md +5 -0
- package/llms/typed-apis.md +5 -0
- package/llms/ui.md +10 -0
- package/llms-full.txt +444 -0
- package/llms.txt +37 -35
- package/package.json +139 -3
- package/src/agent-ui/mcp.ts +152 -0
- package/src/agent-ui/snapshot.ts +212 -0
- package/src/agent-ui.d.ts +106 -0
- package/src/agent-ui.ts +249 -0
- package/src/ai.d.ts +95 -2
- package/src/api.d.ts +303 -0
- package/src/api.ts +9 -0
- package/src/canvas/Canvas.ts +585 -0
- package/src/canvas/backend.ts +627 -0
- package/src/canvas/geometry.ts +187 -0
- package/src/canvas/gesture.ts +23 -0
- package/src/canvas/image.ts +197 -0
- package/src/canvas/kit.ts +130 -0
- package/src/canvas/paint.ts +260 -0
- package/src/canvas/pointer.ts +25 -0
- package/src/canvas/snap.ts +68 -0
- package/src/canvas/svg.ts +99 -0
- package/src/canvas.d.ts +373 -0
- package/src/canvas.ts +46 -0
- package/src/core/actions.ts +14 -3
- package/src/core/cell.ts +12 -4
- package/src/core/debug.ts +8 -3
- package/src/core/hooks.ts +16 -2
- package/src/core/hosts/collection.ts +1 -1
- package/src/core/hosts/switchable.ts +26 -8
- package/src/core/instance.ts +98 -45
- package/src/core/markers/portal.ts +4 -0
- package/src/core/registry.ts +4 -0
- package/src/core/runtime.ts +34 -25
- package/src/core/statics.ts +5 -5
- package/src/cycle/dom/EventDelegator.ts +37 -33
- package/src/cycle/dom/MainDOMSource.ts +16 -14
- package/src/cycle/dom/enrichEventStream.ts +13 -9
- package/src/cycle/dom/fromEvent.ts +6 -10
- package/src/cycle/dom/index.ts +11 -7
- package/src/cycle/dom/isolate.ts +13 -6
- package/src/cycle/dom/makeDOMDriver.ts +6 -2
- package/src/cycle/dom/utils.ts +9 -2
- package/src/cycle/run/adapt.ts +4 -12
- package/src/devtools.d.ts +9 -0
- package/src/devtools.ts +2 -1
- package/src/extra/ai/compose/catalog.ts +2 -1
- package/src/extra/ai/compose/decisions.ts +1 -2
- package/src/extra/ai/compose/generated.ts +234 -0
- package/src/extra/ai/compose/index.ts +6 -2
- package/src/extra/ai/compose/layout.ts +3 -1
- package/src/extra/ai/compose/llm.ts +7 -3
- package/src/extra/ai/compose/types.ts +2 -2
- package/src/extra/ai/transports/shared.ts +3 -1
- package/src/extra/api/contract.ts +485 -0
- package/src/extra/api/index.ts +26 -0
- package/src/extra/behaviors.ts +3 -3
- package/src/extra/controlled.ts +44 -0
- package/src/extra/copyAsTest.ts +22 -0
- package/src/extra/devtools.ts +5 -3
- package/src/extra/devtoolsActions.ts +8 -0
- package/src/extra/devtoolsNext.ts +1 -1
- package/src/extra/diagnostics/checks/actionLog.ts +8 -3
- package/src/extra/diagnostics/checks/behaviors.ts +25 -0
- package/src/extra/diagnostics/checks/elementCommands.ts +17 -5
- package/src/extra/diagnostics/checks/index.ts +1 -1
- package/src/extra/diagnostics/checks/next.ts +78 -4
- package/src/extra/diagnostics/checks/props.ts +6 -1
- package/src/extra/diagnostics/checks/replies.ts +11 -7
- package/src/extra/diagnostics/checks/timers.ts +8 -2
- package/src/extra/diagnostics/checks/virtual.ts +11 -0
- package/src/extra/diagnostics/codes.ts +51 -1
- package/src/extra/elementCommands.ts +20 -4
- package/src/extra/fetchDriver.ts +4 -1
- package/src/extra/fetchEventSource.ts +6 -2
- package/src/extra/field.ts +29 -0
- package/src/extra/form.ts +42 -16
- package/src/extra/frame.ts +54 -0
- package/src/extra/keymap.ts +41 -0
- package/src/extra/pager.ts +2 -0
- package/src/extra/persist.ts +2 -1
- package/src/extra/pointer.ts +262 -0
- package/src/extra/processForm.ts +34 -1
- package/src/extra/replay/address.ts +49 -0
- package/src/extra/replay/entropy.ts +99 -0
- package/src/extra/replay/format.ts +263 -0
- package/src/extra/replay/index.ts +8 -0
- package/src/extra/replay/inert.ts +22 -0
- package/src/extra/replay/recorder.ts +247 -0
- package/src/extra/replay/replayer.ts +234 -0
- package/src/extra/replay/report.ts +126 -0
- package/src/extra/replay/schema.ts +52 -0
- package/src/extra/replay/version.ts +3 -0
- package/src/extra/router.ts +7 -3
- package/src/extra/selection.ts +2 -0
- package/src/extra/socketDriver.ts +51 -6
- package/src/extra/ssr.ts +128 -42
- package/src/extra/testing.ts +295 -39
- package/src/extra/timers.ts +96 -7
- package/src/extra/undo.ts +2 -0
- package/src/extra/virtual.ts +25 -2
- package/src/extra/widget.ts +106 -17
- package/src/host/actions.ts +383 -0
- package/src/host/delegation.ts +101 -0
- package/src/host/dom.ts +553 -0
- package/src/host/driver.ts +185 -0
- package/src/host/selector.ts +119 -0
- package/src/host/style.ts +51 -0
- package/src/host.d.ts +298 -0
- package/src/host.ts +46 -0
- package/src/index.d.ts +302 -38
- package/src/index.ts +15 -3
- package/src/pragma/blocks.ts +211 -0
- package/src/pragma/index.ts +44 -10
- package/src/primitives.d.ts +54 -0
- package/src/primitives.ts +136 -0
- package/src/react-native.d.ts +57 -0
- package/src/react-native.ts +285 -0
- package/src/react-peers.d.ts +21 -3
- package/src/replay.d.ts +185 -0
- package/src/replay.ts +6 -0
- package/src/sandbox/build.ts +119 -0
- package/src/sandbox/create.ts +187 -0
- package/src/sandbox/engine.ts +267 -0
- package/src/sandbox/generated.ts +195 -0
- package/src/sandbox/kernel.ts +180 -0
- package/src/sandbox/mirror.ts +269 -0
- package/src/sandbox/protocol.ts +128 -0
- package/src/sandbox/quickjs.ts +74 -0
- package/src/sandbox/ssr.ts +41 -0
- package/src/sandbox/widget.ts +60 -0
- package/src/sandbox/worker-entry.ts +59 -0
- package/src/sandbox/worker.ts +137 -0
- package/src/sandbox-build.d.ts +12 -0
- package/src/sandbox-build.ts +6 -0
- package/src/sandbox.d.ts +299 -0
- package/src/sandbox.ts +25 -0
- package/src/shared.ts +12 -0
- package/src/terminal/paint.ts +712 -0
- package/src/terminal.d.ts +167 -0
- package/src/terminal.ts +530 -0
- package/src/three/assets.ts +170 -0
- package/src/three/host.ts +36 -0
- package/src/three/scene.ts +217 -0
- package/src/three/target.ts +252 -0
- package/src/three.d.ts +221 -0
- package/src/three.ts +30 -0
- package/src/ui/accordion.ts +21 -7
- package/src/ui/dialog.ts +192 -90
- package/src/ui/disclosure.ts +2 -0
- package/src/ui/dom.ts +30 -0
- package/src/ui/layers.ts +132 -0
- package/src/ui/popover.ts +120 -21
- package/src/ui/shared.ts +98 -12
- package/src/ui/stack.ts +18 -0
- package/src/ui/tabs.ts +2 -0
- package/src/ui/toaster.ts +16 -15
- package/src/ui/tooltip.ts +24 -8
- package/src/ui/tooltips.ts +26 -14
- package/src/ui/zag/combobox.ts +180 -13
- package/src/ui/zag/menu.ts +97 -17
- package/src/ui/zag/select.ts +15 -4
- package/src/ui/zag/shared.ts +60 -8
- package/src/ui-combobox.d.ts +16 -1
- package/src/ui-menu.d.ts +22 -7
- package/src/ui-zag-types.d.ts +13 -5
- package/src/ui.d.ts +62 -7
- package/src/ui.ts +2 -2
- package/src/vite/api.ts +186 -0
- package/src/vite/compile.ts +280 -0
- package/src/vite/plugin.d.ts +34 -0
- package/src/vite/plugin.ts +78 -4
- package/src/vite/sandbox.ts +16 -0
- package/src/worker/page.ts +396 -0
- package/src/worker/protocol.ts +48 -0
- package/src/worker/target.ts +282 -0
- package/src/worker-page.d.ts +42 -0
- package/src/worker-page.ts +12 -0
- package/src/worker.d.ts +65 -0
- package/src/worker.ts +16 -0
- package/src/yjs/driver.ts +253 -0
- package/src/yjs/mapping.ts +389 -0
- package/src/yjs/shared.ts +138 -0
- package/src/yjs/testing.ts +91 -0
- package/src/yjs-testing.d.ts +34 -0
- package/src/yjs-testing.ts +7 -0
- package/src/yjs.d.ts +109 -0
- package/src/yjs.ts +18 -0
- package/src/zag.d.ts +2 -0
- 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 => ({'&': '&', '<': '<', '>': '>', '"': '"'} 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
|
+
}
|