@weasel-js/core 1.15.1 → 1.15.2
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 +115 -0
- package/dist/animation/poseTarget.js +3 -3
- package/dist/animation/poseTarget.js.map +1 -1
- package/dist/animation/reflow.js +7 -7
- package/dist/animation/reflow.js.map +1 -1
- package/dist/animation/rig/rigNode.js +2 -2
- package/dist/animation/rig/rigNode.js.map +1 -1
- package/dist/barrel/input.js +2 -1
- package/dist/barrel/paint.js +2 -2
- package/dist/barrel/viewport.js +3 -3
- package/dist/canvas/SceneCanvas/PointerProviderIfRoot.js +17 -64
- package/dist/canvas/SceneCanvas/PointerProviderIfRoot.js.map +1 -1
- package/dist/canvas/accentColor.js +3 -3
- package/dist/canvas/accentColor.js.map +1 -1
- package/dist/canvas/deps/enteredGroup.js +5 -4
- package/dist/canvas/deps/enteredGroup.js.map +1 -1
- package/dist/canvas/deps/selectionSnapshot.js +7 -2
- package/dist/canvas/deps/selectionSnapshot.js.map +1 -1
- package/dist/canvas/slopsDebugLayer.js +8 -2
- package/dist/canvas/slopsDebugLayer.js.map +1 -1
- package/dist/canvas/useViewRegistrySurface.js +6 -3
- package/dist/canvas/useViewRegistrySurface.js.map +1 -1
- package/dist/canvas/viewRegistry.js +5 -5
- package/dist/canvas/viewRegistry.js.map +1 -1
- package/dist/core/ops/reorder/index.js +2 -6
- package/dist/core/ops/reorder/index.js.map +1 -1
- package/dist/core/pages/fitViewToPage.js +2 -7
- package/dist/core/pages/fitViewToPage.js.map +1 -1
- package/dist/core/pages/pages.js +3 -9
- package/dist/core/pages/pages.js.map +1 -1
- package/dist/core/paint/meshKindLazy.js +9 -5
- package/dist/core/paint/meshKindLazy.js.map +1 -1
- package/dist/core/scene/pathEffects/blend.js +2 -2
- package/dist/core/scene/pathEffects/blend.js.map +1 -1
- package/dist/core/scene/pathEffects/textAlong.js +5 -4
- package/dist/core/scene/pathEffects/textAlong.js.map +1 -1
- package/dist/core/scene/scene.js +12 -1
- package/dist/core/scene/scene.js.map +1 -1
- package/dist/core/viewport/clientToWorld.js +7 -1
- package/dist/core/viewport/clientToWorld.js.map +1 -1
- package/dist/core/viewport/zoomAt.js +6 -6
- package/dist/core/viewport/zoomAt.js.map +1 -1
- package/dist/devtools/root.js +11 -4
- package/dist/devtools/root.js.map +1 -1
- package/dist/devtools/transport.js +12 -16
- package/dist/devtools/transport.js.map +1 -1
- package/dist/devtools-protocol.d.ts +0 -11
- package/dist/domains/measure/measureHit.js +2 -8
- package/dist/domains/measure/measureHit.js.map +1 -1
- package/dist/domains/paths/anchorMarkers.js +5 -11
- package/dist/domains/paths/anchorMarkers.js.map +1 -1
- package/dist/domains/paths/pathEditingOverlayLayer.js +5 -9
- package/dist/domains/paths/pathEditingOverlayLayer.js.map +1 -1
- package/dist/domains/paths/penPreviewLayer.js +9 -12
- package/dist/domains/paths/penPreviewLayer.js.map +1 -1
- package/dist/domains/paths/shapeGenerator.js +1 -2
- package/dist/domains/paths/shapeGenerator.js.map +1 -1
- package/dist/domains/snap/angle2d.js +2 -1
- package/dist/domains/snap/angle2d.js.map +1 -1
- package/dist/domains/snap/outcomeStore.js +5 -8
- package/dist/domains/snap/outcomeStore.js.map +1 -1
- package/dist/domains/text/useSceneTextEdit.js +9 -4
- package/dist/domains/text/useSceneTextEdit.js.map +1 -1
- package/dist/domains/viewports/aim.js +3 -8
- package/dist/domains/viewports/aim.js.map +1 -1
- package/dist/domains/viewports/viewResolver.js +5 -3
- package/dist/domains/viewports/viewResolver.js.map +1 -1
- package/dist/index.d.ts +20 -21
- package/dist/index.js +5 -4
- package/dist/input/usePublishPointer.js +59 -0
- package/dist/input/usePublishPointer.js.map +1 -0
- package/dist/interactions/actions/defaults/containerScroll.js +9 -3
- package/dist/interactions/actions/defaults/containerScroll.js.map +1 -1
- package/dist/interactions/actions/defaults/enterGroup.js +3 -3
- package/dist/interactions/actions/defaults/enterGroup.js.map +1 -1
- package/dist/interactions/actions/defaults/pathEditMarks.js +2 -5
- package/dist/interactions/actions/defaults/pathEditMarks.js.map +1 -1
- package/dist/interactions/actions/edit-anchors/handleDrag.js +4 -7
- package/dist/interactions/actions/edit-anchors/handleDrag.js.map +1 -1
- package/dist/interactions/actions/move/behaviors/clampMove.js +42 -0
- package/dist/interactions/actions/move/behaviors/clampMove.js.map +1 -0
- package/dist/interactions/actions/move/layout/container.js +3 -6
- package/dist/interactions/actions/move/layout/container.js.map +1 -1
- package/dist/interactions/actions/move/layout/exits.js +2 -2
- package/dist/interactions/actions/move/layout/exits.js.map +1 -1
- package/dist/interactions/actions/transform-again/memory.js +2 -2
- package/dist/interactions/actions/transform-again/memory.js.map +1 -1
- package/dist/move.d.ts +18 -3
- package/dist/move.js +2 -1
- package/dist/version.js +1 -1
- package/package.json +25 -25
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pathEditingOverlayLayer.js","names":[],"sources":["../../../src/domains/paths/pathEditingOverlayLayer.ts"],"sourcesContent":["/**\n * Screen-space render layer for path-editing chrome — the open path's\n * outline, anchor markers, tangent lines, and control-point dots for a\n * polygon path that is the current edit target, under them whatever marks\n * its path-edit extensions draw. Marker sizes stay constant regardless of zoom.\n */\n\nimport type { DrawCommand } from '@weasel-js/renderer';\nimport type { RenderLayer } from 'core/layers/render';\nimport {\n type AnchorSet, type Path, type PolygonPath, type View, pathToAnchors, rectMarkerPath,\n} from '@weasel-js/geom';\nimport type { GesturePreviewSource } from 'canvas/gestureBounds';\nimport { chromeStateFrom, previewSourcesFrom, isVisibleFrom } from 'canvas/drawEnvelope';\nimport { paintsLayer } from 'core/selection/chromeState';\nimport {\n anchorMarkerCommands, DEFAULT_OVERLAY_STYLE,
|
|
1
|
+
{"version":3,"file":"pathEditingOverlayLayer.js","names":[],"sources":["../../../src/domains/paths/pathEditingOverlayLayer.ts"],"sourcesContent":["/**\n * Screen-space render layer for path-editing chrome — the open path's\n * outline, anchor markers, tangent lines, and control-point dots for a\n * polygon path that is the current edit target, under them whatever marks\n * its path-edit extensions draw. Marker sizes stay constant regardless of zoom.\n */\n\nimport type { DrawCommand } from '@weasel-js/renderer';\nimport type { RenderLayer } from 'core/layers/render';\nimport {\n type AnchorSet, type Path, type PolygonPath, type View, pathToAnchors, rectMarkerPath, viewRectToScreen, viewWorldToScreen,\n} from '@weasel-js/geom';\nimport type { GesturePreviewSource } from 'canvas/gestureBounds';\nimport { chromeStateFrom, previewSourcesFrom, isVisibleFrom } from 'canvas/drawEnvelope';\nimport { paintsLayer } from 'core/selection/chromeState';\nimport {\n anchorMarkerCommands, DEFAULT_OVERLAY_STYLE, type PathEditingOverlayStyle,\n} from './anchorMarkers';\nimport type { PathEditExtension, PathEditMark } from './pathEditExtension';\nimport type { AnchorMarkerShape } from './pathRepresentation';\n\nexport type { PathEditingOverlayStyle } from './anchorMarkers';\n\n/** Options for `createPathEditingOverlayLayer`. Everything is read per frame,\n * so the layer follows a changing edit target without being rebuilt. */\nexport interface CreatePathEditingOverlayLayerOptions {\n /** Returns the id of the polygon currently in anchor-edit mode, or null\n * when no node is being edited. */\n getEditingId(): string | null;\n /**\n * Returns the pose for an id, or null if the node has been deleted.\n * Non-polygon poses are tolerated (the layer no-ops on them).\n *\n * `previews` are the drawing view's in-flight preview surfaces, so an\n * anchor dragged in one panel does not move in the others. Resolve against\n * them before falling back to the committed pose.\n *\n * `view` is the drawing view's camera. Answer in its world: a node whose\n * layer is a parallax plane stores its path in the plane's.\n */\n getPose(id: string, previews: readonly GesturePreviewSource[], view: View): Path | null;\n /** The marker of each anchor, by flat index, for a node whose path was\n * drawn from a form (`PathRepresentation.anchorMarkers`); null, or an\n * anchor with none, draws a square. Resolve against `previews` as\n * `getPose` does. */\n getAnchorMarkers?(id: string, previews: readonly GesturePreviewSource[]): readonly (AnchorMarkerShape | null)[] | null;\n /** Flat indices of the selected anchors. Selected anchors render\n * filled; unselected ones hollow. */\n getSelectedAnchors?(): ReadonlySet<number>;\n /** In-flight anchor-marquee rect in world coords, or null. Drawn as a\n * rubber band while `marqueeAnchorsAction` is dragging. */\n getMarquee?(view: View): { x: number; y: number; width: number; height: number } | null;\n /** The edited node's committed pose and data, which extensions' marks read. */\n getNode?(id: string): { pose: unknown; data: unknown; layer?: string } | null;\n /** The path-edit extensions whose marks are drawn, in order. */\n getExtensions?(): readonly PathEditExtension[];\n /** The selected extension mark, or null. */\n getSelectedMark?(): PathEditMark | null;\n /** Optional styling overrides. */\n style?: PathEditingOverlayStyle;\n}\n\nconst EMPTY_SELECTION: ReadonlySet<number> = new Set();\nconst ALWAYS = (): boolean => true;\n\n/** Render layer that draws the outline and the anchors and control handles\n * of the path currently being anchor-edited. Draws nothing when no path is\n * being edited. */\nexport function createPathEditingOverlayLayer(\n opts: CreatePathEditingOverlayLayerOptions,\n): RenderLayer<unknown> {\n const style = { ...DEFAULT_OVERLAY_STYLE, ...(opts.style ?? {}) };\n\n return {\n id: 'path-editing-overlay',\n label: 'Path editing',\n space: 'screen',\n draw: (data, view) => {\n const id = opts.getEditingId();\n if (!id) return [];\n // Chrome-caps gate off the envelope, so paint and the anchor hit-test in\n // `affordanceAt` consult the same rule — otherwise a consumer that hides\n // the chrome still gets grabbable invisible anchors, or vice versa.\n const isVisible = isVisibleFrom(data);\n if (isVisible && !isVisible('path-edit.anchors')) return [];\n const node = opts.getNode?.(id);\n if (!paintsLayer(chromeStateFrom(data), node?.layer)) return [];\n const previews = previewSourcesFrom(data);\n const pose = opts.getPose(id, previews, view);\n if (!pose || pose.kind !== 'polygon') return [];\n const markers = opts.getAnchorMarkers?.(id, previews) ?? null;\n\n const selected = opts.getSelectedAnchors?.() ?? EMPTY_SELECTION;\n // Extensions' marks under the markers, which are what a press reaches first.\n const out: DrawCommand[] = [];\n if (node) {\n const mark = opts.getSelectedMark?.() ?? null;\n const anchors = pathToAnchors(pose as PolygonPath) as AnchorSet;\n const toScreen = (p: { x: number; y: number }) => viewWorldToScreen(view, p);\n for (const ext of opts.getExtensions?.() ?? []) {\n if (!ext.marks) continue;\n out.push(...ext.marks({\n id, node, path: pose as PolygonPath, anchors, view, toScreen,\n isVisible: isVisible ?? ALWAYS,\n selected: mark?.owner === ext.id ? mark.index : null,\n }));\n }\n }\n out.push(...anchorMarkerCommands(pose as PolygonPath, view, selected, style, markers));\n\n // Marquee rubber band, on top of everything.\n const marquee = opts.getMarquee?.(view);\n if (marquee) {\n const m = viewRectToScreen(view, marquee);\n out.push({\n kind: 'path',\n path: rectMarkerPath(m.x, m.y, m.width, m.height),\n fill: { paint: { kind: 'solid', color: style.marqueeFill } },\n stroke: { paint: { kind: 'solid', color: style.marqueeStroke }, width: 1 },\n });\n }\n\n return out;\n },\n };\n}\n"],"mappings":";;;;;;AA8DA,IAAM,kCAAuC,IAAI,IAAI;AACrD,IAAM,eAAwB;;;;AAK9B,SAAgB,8BACd,MACsB;CACtB,MAAM,QAAQ;EAAE,GAAG;EAAuB,GAAI,KAAK,SAAS,CAAC;CAAG;CAEhE,OAAO;EACL,IAAI;EACJ,OAAO;EACP,OAAO;EACP,OAAO,MAAM,SAAS;GACpB,MAAM,KAAK,KAAK,aAAa;GAC7B,IAAI,CAAC,IAAI,OAAO,CAAC;GAIjB,MAAM,YAAY,cAAc,IAAI;GACpC,IAAI,aAAa,CAAC,UAAU,mBAAmB,GAAG,OAAO,CAAC;GAC1D,MAAM,OAAO,KAAK,UAAU,EAAE;GAC9B,IAAI,CAAC,YAAY,gBAAgB,IAAI,GAAG,MAAM,KAAK,GAAG,OAAO,CAAC;GAC9D,MAAM,WAAW,mBAAmB,IAAI;GACxC,MAAM,OAAO,KAAK,QAAQ,IAAI,UAAU,IAAI;GAC5C,IAAI,CAAC,QAAQ,KAAK,SAAS,WAAW,OAAO,CAAC;GAC9C,MAAM,UAAU,KAAK,mBAAmB,IAAI,QAAQ,KAAK;GAEzD,MAAM,WAAW,KAAK,qBAAqB,KAAK;GAEhD,MAAM,MAAqB,CAAC;GAC5B,IAAI,MAAM;IACR,MAAM,OAAO,KAAK,kBAAkB,KAAK;IACzC,MAAM,UAAU,cAAc,IAAmB;IACjD,MAAM,YAAY,MAAgC,kBAAkB,MAAM,CAAC;IAC3E,KAAK,MAAM,OAAO,KAAK,gBAAgB,KAAK,CAAC,GAAG;KAC9C,IAAI,CAAC,IAAI,OAAO;KAChB,IAAI,KAAK,GAAG,IAAI,MAAM;MACpB;MAAI;MAAM,MAAM;MAAqB;MAAS;MAAM;MACpD,WAAW,aAAa;MACxB,UAAU,MAAM,UAAU,IAAI,KAAK,KAAK,QAAQ;KAClD,CAAC,CAAC;IACJ;GACF;GACA,IAAI,KAAK,GAAG,qBAAqB,MAAqB,MAAM,UAAU,OAAO,OAAO,CAAC;GAGrF,MAAM,UAAU,KAAK,aAAa,IAAI;GACtC,IAAI,SAAS;IACX,MAAM,IAAI,iBAAiB,MAAM,OAAO;IACxC,IAAI,KAAK;KACP,MAAM;KACN,MAAM,eAAe,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM;KAChD,MAAM,EAAE,OAAO;MAAE,MAAM;MAAS,OAAO,MAAM;KAAY,EAAE;KAC3D,QAAQ;MAAE,OAAO;OAAE,MAAM;OAAS,OAAO,MAAM;MAAc;MAAG,OAAO;KAAE;IAC3E,CAAC;GACH;GAEA,OAAO;EACT;CACF;AACF"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PATH_C, PATH_L, PATH_M, anchorsToPath, circlePath } from "@weasel-js/geom";
|
|
1
|
+
import { PATH_C, PATH_L, PATH_M, anchorsToPath, circlePath, viewWorldToScreen } from "@weasel-js/geom";
|
|
2
2
|
|
|
3
3
|
// src/domains/paths/penPreviewLayer.ts
|
|
4
4
|
var DEFAULT_STYLE = {
|
|
@@ -21,9 +21,6 @@ var radiiOf = (kit) => ({
|
|
|
21
21
|
handleDot: kit?.get("targets.penHandleDotRadiusPx") ?? HANDLE_DOT_RADIUS_PX,
|
|
22
22
|
closeHint: kit?.get("targets.penCloseHintRadiusPx") ?? CLOSE_HINT_RADIUS_PX
|
|
23
23
|
});
|
|
24
|
-
function w2s(wx, wy, view) {
|
|
25
|
-
return [(wx - view.x) * view.scale.x, (wy - view.y) * view.scale.y];
|
|
26
|
-
}
|
|
27
24
|
function approximateCircle(cx, cy, r) {
|
|
28
25
|
return circlePath(cx, cy, r);
|
|
29
26
|
}
|
|
@@ -32,7 +29,7 @@ function approximateCircle(cx, cy, r) {
|
|
|
32
29
|
function subpathToPath(sp, view) {
|
|
33
30
|
if (sp.anchors.length === 0) return null;
|
|
34
31
|
const pt = (p) => {
|
|
35
|
-
const
|
|
32
|
+
const { x, y } = viewWorldToScreen(view, p);
|
|
36
33
|
return {
|
|
37
34
|
x,
|
|
38
35
|
y
|
|
@@ -95,11 +92,11 @@ function createPenPreviewLayer(opts) {
|
|
|
95
92
|
if (cur && cur.anchors.length > 0 && s.cursor && s.draggingHandleAt === null) {
|
|
96
93
|
const last = cur.anchors[cur.anchors.length - 1];
|
|
97
94
|
const out2 = last.outHandle;
|
|
98
|
-
const
|
|
99
|
-
const
|
|
95
|
+
const { x: sx, y: sy } = viewWorldToScreen(view, last);
|
|
96
|
+
const { x: cx, y: cy } = viewWorldToScreen(view, s.cursor);
|
|
100
97
|
let path;
|
|
101
98
|
if (out2) {
|
|
102
|
-
const
|
|
99
|
+
const { x: c1x, y: c1y } = viewWorldToScreen(view, out2);
|
|
103
100
|
path = {
|
|
104
101
|
kind: "polygon",
|
|
105
102
|
commands: new Uint8Array([PATH_M, PATH_C]),
|
|
@@ -141,7 +138,7 @@ function createPenPreviewLayer(opts) {
|
|
|
141
138
|
if (cur) {
|
|
142
139
|
for (let i = 0; i < cur.anchors.length; i++) {
|
|
143
140
|
const a = cur.anchors[i];
|
|
144
|
-
const
|
|
141
|
+
const { x: ax, y: ay } = viewWorldToScreen(view, a);
|
|
145
142
|
out.push({
|
|
146
143
|
kind: "path",
|
|
147
144
|
path: approximateCircle(ax, ay, r.anchor),
|
|
@@ -158,7 +155,7 @@ function createPenPreviewLayer(opts) {
|
|
|
158
155
|
}
|
|
159
156
|
});
|
|
160
157
|
if (i === cur.anchors.length - 1 && a.outHandle) {
|
|
161
|
-
const
|
|
158
|
+
const { x: hx, y: hy } = viewWorldToScreen(view, a.outHandle);
|
|
162
159
|
const linePath = {
|
|
163
160
|
kind: "polygon",
|
|
164
161
|
commands: new Uint8Array([PATH_M, PATH_L]),
|
|
@@ -191,7 +188,7 @@ function createPenPreviewLayer(opts) {
|
|
|
191
188
|
});
|
|
192
189
|
const inSide = a.inHandle;
|
|
193
190
|
if (inSide) {
|
|
194
|
-
const
|
|
191
|
+
const { x: mx, y: my } = viewWorldToScreen(view, inSide);
|
|
195
192
|
const inLine = {
|
|
196
193
|
kind: "polygon",
|
|
197
194
|
commands: new Uint8Array([PATH_M, PATH_L]),
|
|
@@ -227,7 +224,7 @@ function createPenPreviewLayer(opts) {
|
|
|
227
224
|
}
|
|
228
225
|
if (s.closeHintActive && cur.anchors.length >= 3) {
|
|
229
226
|
const first = cur.anchors[0];
|
|
230
|
-
const
|
|
227
|
+
const { x: fx, y: fy } = viewWorldToScreen(view, first);
|
|
231
228
|
out.push({
|
|
232
229
|
kind: "path",
|
|
233
230
|
path: approximateCircle(fx, fy, r.closeHint),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"penPreviewLayer.js","names":[],"sources":["../../../src/domains/paths/penPreviewLayer.ts"],"sourcesContent":["/**\n * Screen-space preview layer for `usePenTool`. Reads the pen's state each\n * frame and renders the in-progress path: finished\n * subpaths (faded), the current subpath (bright + anchor dots + handles),\n * a rubber-band segment from the latest anchor to the cursor, and a\n * close-hint ring on the first anchor when the tool flags it as hot.\n *\n * Runs in screen space so anchor/handle sizes stay constant under zoom.\n * All world-space coords go through `worldToScreen` via the layer's `view`\n * argument.\n */\n\nimport type { DrawCommand } from '@weasel-js/renderer';\nimport type { RenderLayer } from 'core/layers/render';\nimport type { KitSettingsStore } from '@weasel-js/react';\nimport type { PenScratch, PenAnchor, PenSubpath } from 'tools/builtin/pen';\nimport { PATH_C, PATH_L, PATH_M, type PolygonPath, anchorsToPath, circlePath } from '@weasel-js/geom';\n\n/** Appearance of the pen tool's in-progress path. */\nexport interface PenPreviewStyle {\n anchorFill?: string;\n anchorStroke?: string;\n handleStroke?: string;\n rubberBandStroke?: string;\n closeHintFill?: string;\n finishedSubpathStroke?: string;\n}\n\n/** Options for `createPenPreviewLayer`. */\nexport interface CreatePenPreviewLayerOptions {\n /** The pen's state. One object for the pen's life, changed in place, so\n * the layer reads the same one on every frame. */\n state: PenScratch;\n style?: PenPreviewStyle;\n /** The kit settings whose `targets.pen*RadiusPx` size the dots, read each frame. */\n kitSettings?: () => KitSettingsStore;\n}\n\nconst DEFAULT_STYLE: Required<PenPreviewStyle> = {\n anchorFill: '#d4c4a8',\n anchorStroke: '#d4c4a8',\n handleStroke: '#d4c4a8',\n rubberBandStroke: '#d4c4a8',\n closeHintFill: '#d4c4a8',\n finishedSubpathStroke: '#8a7a5e',\n};\n\nexport const ANCHOR_RADIUS_PX = 4;\nexport const HANDLE_DOT_RADIUS_PX = 3;\nexport const CLOSE_HINT_RADIUS_PX = 8;\n\n/** How near the pen's first anchor, in screen px, a click closes the subpath. */\nexport const PEN_CLOSE_HIT_PX = 8;\n\n/** The pen's reach in screen px: its own option, else the kit setting, else the constant. */\nexport const penReachOf = (o: { closeHitRadius?: number; kitSettings?: KitSettingsStore }) => ({\n close: o.closeHitRadius ?? o.kitSettings?.get('targets.penCloseHitPx') ?? PEN_CLOSE_HIT_PX,\n});\n\nconst radiiOf = (kit: KitSettingsStore | undefined) => ({\n anchor: kit?.get('targets.penAnchorRadiusPx') ?? ANCHOR_RADIUS_PX,\n handleDot: kit?.get('targets.penHandleDotRadiusPx') ?? HANDLE_DOT_RADIUS_PX,\n closeHint: kit?.get('targets.penCloseHintRadiusPx') ?? CLOSE_HINT_RADIUS_PX,\n});\n\nfunction w2s(wx: number, wy: number, view: { x: number; y: number; scale: { x: number; y: number } }): [number, number] {\n return [(wx - view.x) * view.scale.x, (wy - view.y) * view.scale.y];\n}\n\n// approximateCircle has moved to `./markers.ts` as `circlePath`. Re-exported\n// locally so we don't rename callers in this file.\nfunction approximateCircle(cx: number, cy: number, r: number): PolygonPath {\n return circlePath(cx, cy, r);\n}\n\n/** A pen subpath as a screen-space `PolygonPath`, segmented by the same rule\n * the pen commits with. */\nfunction subpathToPath(\n sp: PenSubpath,\n view: { x: number; y: number; scale: { x: number; y: number } },\n): PolygonPath | null {\n if (sp.anchors.length === 0) return null;\n const pt = (p: { x: number; y: number }) => {\n const [x, y] = w2s(p.x, p.y, view);\n return { x, y };\n };\n const anchors = sp.anchors.map((a: PenAnchor) => ({\n ...pt(a),\n ...(a.inHandle ? { inHandle: pt(a.inHandle) } : {}),\n ...(a.outHandle ? { outHandle: pt(a.outHandle) } : {}),\n }));\n return anchorsToPath([anchors], [sp.closed]);\n}\n\n/** Render layer that draws the pen tool's path as it is being placed —\n * committed anchors, the rubber band to the cursor, and the close hint. Draws\n * nothing when the pen is idle. */\nexport function createPenPreviewLayer(\n opts: CreatePenPreviewLayerOptions,\n): RenderLayer<unknown> {\n const style = { ...DEFAULT_STYLE, ...(opts.style ?? {}) };\n const s = opts.state;\n\n return {\n id: 'penPreview',\n label: 'Pen preview',\n space: 'screen',\n draw: (_data, view) => {\n const out: DrawCommand[] = [];\n const r = radiiOf(opts.kitSettings?.());\n // (1) Finished subpaths — faded outline.\n for (const sp of s.finishedSubpaths) {\n const path = subpathToPath(sp, view);\n if (path === null) continue;\n out.push({\n kind: 'path',\n path,\n stroke: { paint: { kind: 'solid', color: style.finishedSubpathStroke }, width: 1 },\n });\n }\n\n const cur = s.current;\n if (!cur && s.cursor === null) return out;\n\n // (2) Current subpath — bright outline.\n if (cur) {\n const path = subpathToPath(cur, view);\n if (path !== null) {\n out.push({\n kind: 'path',\n path,\n stroke: { paint: { kind: 'solid', color: style.rubberBandStroke }, width: 1 },\n });\n }\n }\n\n // (3) Rubber-band from latest anchor to cursor.\n if (cur && cur.anchors.length > 0 && s.cursor && s.draggingHandleAt === null) {\n const last = cur.anchors[cur.anchors.length - 1];\n const out2 = last.outHandle;\n const [sx, sy] = w2s(last.x, last.y, view);\n const [cx, cy] = w2s(s.cursor.x, s.cursor.y, view);\n let path: PolygonPath;\n if (out2) {\n const [c1x, c1y] = w2s(out2.x, out2.y, view);\n path = {\n kind: 'polygon',\n commands: new Uint8Array([PATH_M, PATH_C]),\n coords: new Float32Array([sx, sy, c1x, c1y, cx, cy, cx, cy]),\n fillRule: 'nonzero',\n };\n } else {\n path = {\n kind: 'polygon',\n commands: new Uint8Array([PATH_M, PATH_L]),\n coords: new Float32Array([sx, sy, cx, cy]),\n fillRule: 'nonzero',\n };\n }\n out.push({\n kind: 'path',\n path,\n stroke: { paint: { kind: 'solid', color: style.rubberBandStroke }, width: 1 },\n });\n }\n\n // (4) Anchor dots + handles.\n if (cur) {\n for (let i = 0; i < cur.anchors.length; i++) {\n const a = cur.anchors[i];\n const [ax, ay] = w2s(a.x, a.y, view);\n out.push({\n kind: 'path',\n path: approximateCircle(ax, ay, r.anchor),\n fill: { paint: { kind: 'solid', color: style.anchorFill } },\n stroke: { paint: { kind: 'solid', color: style.anchorStroke }, width: 1 },\n });\n // Handle line + dot (only the latest anchor, per spec).\n if (i === cur.anchors.length - 1 && a.outHandle) {\n const [hx, hy] = w2s(a.outHandle.x, a.outHandle.y, view);\n const linePath: PolygonPath = {\n kind: 'polygon',\n commands: new Uint8Array([PATH_M, PATH_L]),\n coords: new Float32Array([ax, ay, hx, hy]),\n fillRule: 'nonzero',\n };\n out.push({\n kind: 'path',\n path: linePath,\n stroke: { paint: { kind: 'solid', color: style.handleStroke }, width: 1 },\n });\n out.push({\n kind: 'path',\n path: approximateCircle(hx, hy, r.handleDot),\n fill: { paint: { kind: 'solid', color: style.handleStroke } },\n });\n const inSide = a.inHandle;\n if (inSide) {\n const [mx, my] = w2s(inSide.x, inSide.y, view);\n const inLine: PolygonPath = {\n kind: 'polygon',\n commands: new Uint8Array([PATH_M, PATH_L]),\n coords: new Float32Array([ax, ay, mx, my]),\n fillRule: 'nonzero',\n };\n out.push({\n kind: 'path',\n path: inLine,\n stroke: { paint: { kind: 'solid', color: style.handleStroke }, width: 1 },\n });\n out.push({\n kind: 'path',\n path: approximateCircle(mx, my, r.handleDot),\n fill: { paint: { kind: 'solid', color: style.handleStroke } },\n });\n }\n }\n }\n\n // (5) Close-hint ring on first anchor.\n if (s.closeHintActive && cur.anchors.length >= 3) {\n const first = cur.anchors[0];\n const [fx, fy] = w2s(first.x, first.y, view);\n out.push({\n kind: 'path',\n path: approximateCircle(fx, fy, r.closeHint),\n stroke: { paint: { kind: 'solid', color: style.closeHintFill }, width: 1.5 },\n });\n }\n }\n\n return out;\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"penPreviewLayer.js","names":[],"sources":["../../../src/domains/paths/penPreviewLayer.ts"],"sourcesContent":["/**\n * Screen-space preview layer for `usePenTool`. Reads the pen's state each\n * frame and renders the in-progress path: finished\n * subpaths (faded), the current subpath (bright + anchor dots + handles),\n * a rubber-band segment from the latest anchor to the cursor, and a\n * close-hint ring on the first anchor when the tool flags it as hot.\n *\n * Runs in screen space so anchor/handle sizes stay constant under zoom.\n * All world-space coords go through `viewWorldToScreen` with the layer's `view`\n * argument.\n */\n\nimport type { DrawCommand } from '@weasel-js/renderer';\nimport type { RenderLayer } from 'core/layers/render';\nimport type { KitSettingsStore } from '@weasel-js/react';\nimport type { PenScratch, PenAnchor, PenSubpath } from 'tools/builtin/pen';\nimport { PATH_C, PATH_L, PATH_M, type PolygonPath, anchorsToPath, circlePath, viewWorldToScreen } from '@weasel-js/geom';\n\n/** Appearance of the pen tool's in-progress path. */\nexport interface PenPreviewStyle {\n anchorFill?: string;\n anchorStroke?: string;\n handleStroke?: string;\n rubberBandStroke?: string;\n closeHintFill?: string;\n finishedSubpathStroke?: string;\n}\n\n/** Options for `createPenPreviewLayer`. */\nexport interface CreatePenPreviewLayerOptions {\n /** The pen's state. One object for the pen's life, changed in place, so\n * the layer reads the same one on every frame. */\n state: PenScratch;\n style?: PenPreviewStyle;\n /** The kit settings whose `targets.pen*RadiusPx` size the dots, read each frame. */\n kitSettings?: () => KitSettingsStore;\n}\n\nconst DEFAULT_STYLE: Required<PenPreviewStyle> = {\n anchorFill: '#d4c4a8',\n anchorStroke: '#d4c4a8',\n handleStroke: '#d4c4a8',\n rubberBandStroke: '#d4c4a8',\n closeHintFill: '#d4c4a8',\n finishedSubpathStroke: '#8a7a5e',\n};\n\nexport const ANCHOR_RADIUS_PX = 4;\nexport const HANDLE_DOT_RADIUS_PX = 3;\nexport const CLOSE_HINT_RADIUS_PX = 8;\n\n/** How near the pen's first anchor, in screen px, a click closes the subpath. */\nexport const PEN_CLOSE_HIT_PX = 8;\n\n/** The pen's reach in screen px: its own option, else the kit setting, else the constant. */\nexport const penReachOf = (o: { closeHitRadius?: number; kitSettings?: KitSettingsStore }) => ({\n close: o.closeHitRadius ?? o.kitSettings?.get('targets.penCloseHitPx') ?? PEN_CLOSE_HIT_PX,\n});\n\nconst radiiOf = (kit: KitSettingsStore | undefined) => ({\n anchor: kit?.get('targets.penAnchorRadiusPx') ?? ANCHOR_RADIUS_PX,\n handleDot: kit?.get('targets.penHandleDotRadiusPx') ?? HANDLE_DOT_RADIUS_PX,\n closeHint: kit?.get('targets.penCloseHintRadiusPx') ?? CLOSE_HINT_RADIUS_PX,\n});\n\n// approximateCircle has moved to `./markers.ts` as `circlePath`. Re-exported\n// locally so we don't rename callers in this file.\nfunction approximateCircle(cx: number, cy: number, r: number): PolygonPath {\n return circlePath(cx, cy, r);\n}\n\n/** A pen subpath as a screen-space `PolygonPath`, segmented by the same rule\n * the pen commits with. */\nfunction subpathToPath(\n sp: PenSubpath,\n view: { x: number; y: number; scale: { x: number; y: number } },\n): PolygonPath | null {\n if (sp.anchors.length === 0) return null;\n const pt = (p: { x: number; y: number }) => {\n const { x: x, y: y } = viewWorldToScreen(view, p);\n return { x, y };\n };\n const anchors = sp.anchors.map((a: PenAnchor) => ({\n ...pt(a),\n ...(a.inHandle ? { inHandle: pt(a.inHandle) } : {}),\n ...(a.outHandle ? { outHandle: pt(a.outHandle) } : {}),\n }));\n return anchorsToPath([anchors], [sp.closed]);\n}\n\n/** Render layer that draws the pen tool's path as it is being placed —\n * committed anchors, the rubber band to the cursor, and the close hint. Draws\n * nothing when the pen is idle. */\nexport function createPenPreviewLayer(\n opts: CreatePenPreviewLayerOptions,\n): RenderLayer<unknown> {\n const style = { ...DEFAULT_STYLE, ...(opts.style ?? {}) };\n const s = opts.state;\n\n return {\n id: 'penPreview',\n label: 'Pen preview',\n space: 'screen',\n draw: (_data, view) => {\n const out: DrawCommand[] = [];\n const r = radiiOf(opts.kitSettings?.());\n // (1) Finished subpaths — faded outline.\n for (const sp of s.finishedSubpaths) {\n const path = subpathToPath(sp, view);\n if (path === null) continue;\n out.push({\n kind: 'path',\n path,\n stroke: { paint: { kind: 'solid', color: style.finishedSubpathStroke }, width: 1 },\n });\n }\n\n const cur = s.current;\n if (!cur && s.cursor === null) return out;\n\n // (2) Current subpath — bright outline.\n if (cur) {\n const path = subpathToPath(cur, view);\n if (path !== null) {\n out.push({\n kind: 'path',\n path,\n stroke: { paint: { kind: 'solid', color: style.rubberBandStroke }, width: 1 },\n });\n }\n }\n\n // (3) Rubber-band from latest anchor to cursor.\n if (cur && cur.anchors.length > 0 && s.cursor && s.draggingHandleAt === null) {\n const last = cur.anchors[cur.anchors.length - 1];\n const out2 = last.outHandle;\n const { x: sx, y: sy } = viewWorldToScreen(view, last);\n const { x: cx, y: cy } = viewWorldToScreen(view, s.cursor);\n let path: PolygonPath;\n if (out2) {\n const { x: c1x, y: c1y } = viewWorldToScreen(view, out2);\n path = {\n kind: 'polygon',\n commands: new Uint8Array([PATH_M, PATH_C]),\n coords: new Float32Array([sx, sy, c1x, c1y, cx, cy, cx, cy]),\n fillRule: 'nonzero',\n };\n } else {\n path = {\n kind: 'polygon',\n commands: new Uint8Array([PATH_M, PATH_L]),\n coords: new Float32Array([sx, sy, cx, cy]),\n fillRule: 'nonzero',\n };\n }\n out.push({\n kind: 'path',\n path,\n stroke: { paint: { kind: 'solid', color: style.rubberBandStroke }, width: 1 },\n });\n }\n\n // (4) Anchor dots + handles.\n if (cur) {\n for (let i = 0; i < cur.anchors.length; i++) {\n const a = cur.anchors[i];\n const { x: ax, y: ay } = viewWorldToScreen(view, a);\n out.push({\n kind: 'path',\n path: approximateCircle(ax, ay, r.anchor),\n fill: { paint: { kind: 'solid', color: style.anchorFill } },\n stroke: { paint: { kind: 'solid', color: style.anchorStroke }, width: 1 },\n });\n // Handle line + dot (only the latest anchor, per spec).\n if (i === cur.anchors.length - 1 && a.outHandle) {\n const { x: hx, y: hy } = viewWorldToScreen(view, a.outHandle);\n const linePath: PolygonPath = {\n kind: 'polygon',\n commands: new Uint8Array([PATH_M, PATH_L]),\n coords: new Float32Array([ax, ay, hx, hy]),\n fillRule: 'nonzero',\n };\n out.push({\n kind: 'path',\n path: linePath,\n stroke: { paint: { kind: 'solid', color: style.handleStroke }, width: 1 },\n });\n out.push({\n kind: 'path',\n path: approximateCircle(hx, hy, r.handleDot),\n fill: { paint: { kind: 'solid', color: style.handleStroke } },\n });\n const inSide = a.inHandle;\n if (inSide) {\n const { x: mx, y: my } = viewWorldToScreen(view, inSide);\n const inLine: PolygonPath = {\n kind: 'polygon',\n commands: new Uint8Array([PATH_M, PATH_L]),\n coords: new Float32Array([ax, ay, mx, my]),\n fillRule: 'nonzero',\n };\n out.push({\n kind: 'path',\n path: inLine,\n stroke: { paint: { kind: 'solid', color: style.handleStroke }, width: 1 },\n });\n out.push({\n kind: 'path',\n path: approximateCircle(mx, my, r.handleDot),\n fill: { paint: { kind: 'solid', color: style.handleStroke } },\n });\n }\n }\n }\n\n // (5) Close-hint ring on first anchor.\n if (s.closeHintActive && cur.anchors.length >= 3) {\n const first = cur.anchors[0];\n const { x: fx, y: fy } = viewWorldToScreen(view, first);\n out.push({\n kind: 'path',\n path: approximateCircle(fx, fy, r.closeHint),\n stroke: { paint: { kind: 'solid', color: style.closeHintFill }, width: 1.5 },\n });\n }\n }\n\n return out;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { boundsOfPath, boxToBox, originOf, poseFrameOf, regularPolygonPath, starPath, transformPath, worldCoords, worldEditToStorage } from "@weasel-js/geom";
|
|
1
|
+
import { boundsOfPath, boxToBox, clamp, originOf, poseFrameOf, regularPolygonPath, starPath, transformPath, worldCoords, worldEditToStorage } from "@weasel-js/geom";
|
|
2
2
|
|
|
3
3
|
// src/domains/paths/shapeGenerator.ts
|
|
4
4
|
/**
|
|
@@ -37,7 +37,6 @@ function shapeGeneratorOf(data) {
|
|
|
37
37
|
const g = data?.generator;
|
|
38
38
|
return isShapeGenerator(g) ? g : null;
|
|
39
39
|
}
|
|
40
|
-
var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
41
40
|
var count = (v) => clamp(Math.round(v), SHAPE_COUNT_RANGE.min, SHAPE_COUNT_RANGE.max);
|
|
42
41
|
/** `g` with its counts rounded and every field held in range. */
|
|
43
42
|
function clampShapeGenerator(g) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shapeGenerator.js","names":[],"sources":["../../../src/domains/paths/shapeGenerator.ts"],"sourcesContent":["/**\n * The parameters a polygon or star was drawn with, kept on the node beside the\n * geometry they made (`data.generator` beside `data.path`), so the shape stays\n * editable as a polygon or a star after it is drawn.\n *\n * `data.path` stays the geometry every reader uses; the generator only says how\n * to make it again. Editing the path itself turns the shape into plain\n * geometry: the generator is dropped, since it could no longer reproduce what\n * is drawn. A generator whose path no longer matches it (a writer that changed\n * the path without dropping it) is treated the same way.\n */\nimport {\n boundsOfPath,\n boxToBox,\n poseFrameOf,\n regularPolygonPath,\n starPath,\n transformPath,\n originOf,\n worldCoords,\n worldEditToStorage,\n type PathInWorldPose,\n type PolygonPath,\n} from '@weasel-js/geom';\n\n/** A regular polygon's parameters. `rotation` is the angle of its first\n * vertex, in radians, inside the pose's own rotation. */\nexport interface PolygonGenerator {\n kind: 'polygon';\n sides: number;\n rotation: number;\n}\n\n/** A star's parameters. `innerRadiusRatio` is the inner radius over the outer\n * one; `rotation` is the angle of its first point, in radians, inside the\n * pose's own rotation. */\nexport interface StarGenerator {\n kind: 'star';\n points: number;\n innerRadiusRatio: number;\n rotation: number;\n}\n\n/** The parameters a node's `data.path` was generated from. */\nexport type ShapeGenerator = PolygonGenerator | StarGenerator;\n\n/** The range of a polygon's side count and a star's point count. */\nexport const SHAPE_COUNT_RANGE = { min: 3, max: 32 } as const;\n\n/** The range of a star's `innerRadiusRatio`. */\nexport const STAR_RATIO_RANGE = { min: 0.01, max: 1 } as const;\n\nconst finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);\n\n/** Whether `v` is a well-formed {@link ShapeGenerator}. */\nexport function isShapeGenerator(v: unknown): v is ShapeGenerator {\n if (v === null || typeof v !== 'object') return false;\n const g = v as Record<string, unknown>;\n if (!finite(g.rotation)) return false;\n if (g.kind === 'polygon') return finite(g.sides);\n if (g.kind === 'star') return finite(g.points) && finite(g.innerRadiusRatio);\n return false;\n}\n\n/** The generator a node's `data` holds, or `null`. */\nexport function shapeGeneratorOf(data: unknown): ShapeGenerator | null {\n const g = (data as { generator?: unknown } | null | undefined)?.generator;\n return isShapeGenerator(g) ? g : null;\n}\n\nconst clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));\nconst count = (v: number): number => clamp(Math.round(v), SHAPE_COUNT_RANGE.min, SHAPE_COUNT_RANGE.max);\n\n/** `g` with its counts rounded and every field held in range. */\nexport function clampShapeGenerator(g: ShapeGenerator): ShapeGenerator {\n if (g.kind === 'polygon') return { kind: 'polygon', sides: count(g.sides), rotation: g.rotation };\n return {\n kind: 'star',\n points: count(g.points),\n innerRadiusRatio: clamp(g.innerRadiusRatio, STAR_RATIO_RANGE.min, STAR_RATIO_RANGE.max),\n rotation: g.rotation,\n };\n}\n\n/** The shape `g` makes, on a circle of `radius` around `center`. */\nexport function shapeGeneratorPath(\n g: ShapeGenerator,\n center: { x: number; y: number } = { x: 0, y: 0 },\n radius = 1,\n): PolygonPath {\n const c = clampShapeGenerator(g);\n return c.kind === 'polygon'\n ? regularPolygonPath(center, radius, c.sides, c.rotation)\n : starPath(center, radius, c.points, radius * c.innerRadiusRatio, c.rotation);\n}\n\n/** `data` without its generator: the shape as plain geometry. */\nexport function withoutShapeGenerator<T>(data: T): T {\n if (data === null || typeof data !== 'object' || !('generator' in data)) return data;\n const { generator: _dropped, ...rest } = data as Record<string, unknown>;\n void _dropped;\n return rest as T;\n}\n\n/** Whether `path` is what `g` makes, up to the box-to-box map storage applies. */\nfunction pathMatches(path: unknown, g: ShapeGenerator): boolean {\n const p = path as PolygonPath | undefined;\n if (p?.kind !== 'polygon') return false;\n const unit = shapeGeneratorPath(g);\n if (p.coords.length !== unit.coords.length) return false;\n const u = boundsOfPath(unit);\n const b = boundsOfPath(p);\n const m = boxToBox(u.x, u.y, u.width, u.height, b.x, b.y, b.width, b.height);\n // Paths store float32 coordinates, so the slack scales with how far they are from the point they are measured from.\n const o = originOf(p);\n const tol = 1e-4 * Math.max(1, Math.abs(b.x - o.x) + b.width, Math.abs(b.y - o.y) + b.height);\n const at = worldCoords(p);\n for (let i = 0; i + 1 < unit.coords.length; i += 2) {\n const x = m[0] * unit.coords[i] + m[4];\n const y = m[3] * unit.coords[i + 1] + m[5];\n if (Math.abs(x - at[i]) > tol || Math.abs(y - at[i + 1]) > tol) return false;\n }\n return true;\n}\n\n/** A node's two halves, as a regeneration reads and writes them. */\nexport interface GeneratedShape<P extends PathInWorldPose = PathInWorldPose> {\n pose: P;\n data: unknown;\n}\n\n/**\n * `node` made again from `next`, in place: the new shape takes the box the old\n * one's circle had, under the same rotation and transform, so a star given\n * more points keeps its center and its size. Returns `null` when the node holds\n * no generator, or its path no longer matches the one it holds.\n */\nexport function regenerateShape<P extends PathInWorldPose>(\n node: GeneratedShape<P>,\n next: ShapeGenerator,\n): GeneratedShape<P> | null {\n const held = shapeGeneratorOf(node.data);\n if (!held || !pathMatches((node.data as { path?: unknown }).path, held)) return null;\n const g = clampShapeGenerator(next);\n const was = boundsOfPath(shapeGeneratorPath(held));\n const { pose } = node;\n // Where the unit circle of the held generator lands in the pose's local frame.\n const toLocal = boxToBox(was.x, was.y, was.width, was.height, pose.x, pose.y, pose.width, pose.height);\n const local = transformPath(shapeGeneratorPath(g), toLocal) as PolygonPath;\n const frame = poseFrameOf(pose);\n const world = frame ? (transformPath(local, frame) as PolygonPath) : local;\n const stored = worldEditToStorage(pose, world);\n return { pose: stored.pose, data: { ...(node.data as object), path: stored.path, generator: g } };\n}\n\n/**\n * What a write that left a node as `next`, from `prev`, stores: when the write\n * changed the generator, the shape made again from it. A generator written\n * onto a node that held none, or of another kind, or onto one whose path no\n * longer matches, is dropped, leaving the shape as plain geometry. Any other\n * write is stored as written. Fits `NodePropertiesEntry.settle`.\n */\nexport function settleShapeGenerator<N extends { pose: unknown; data: unknown }>(next: N, prev: N): N {\n const data = next.data as Record<string, unknown> | null;\n if (data === null || typeof data !== 'object' || !('generator' in data)) return next;\n const raw = data.generator;\n const held = shapeGeneratorOf(prev.data);\n if (held !== null && JSON.stringify(raw) === JSON.stringify(held)) return next;\n const regenerated = held !== null && isShapeGenerator(raw) && raw.kind === held.kind\n ? regenerateShape({ pose: next.pose as PathInWorldPose, data: { ...data, generator: held } }, raw)\n : null;\n return { ...next, ...(regenerated ?? { data: withoutShapeGenerator(next.data) }) };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"shapeGenerator.js","names":[],"sources":["../../../src/domains/paths/shapeGenerator.ts"],"sourcesContent":["/**\n * The parameters a polygon or star was drawn with, kept on the node beside the\n * geometry they made (`data.generator` beside `data.path`), so the shape stays\n * editable as a polygon or a star after it is drawn.\n *\n * `data.path` stays the geometry every reader uses; the generator only says how\n * to make it again. Editing the path itself turns the shape into plain\n * geometry: the generator is dropped, since it could no longer reproduce what\n * is drawn. A generator whose path no longer matches it (a writer that changed\n * the path without dropping it) is treated the same way.\n */\nimport {\n boundsOfPath,\n boxToBox,\n clamp,\n poseFrameOf,\n regularPolygonPath,\n starPath,\n transformPath,\n originOf,\n worldCoords,\n worldEditToStorage,\n type PathInWorldPose,\n type PolygonPath,\n} from '@weasel-js/geom';\n\n/** A regular polygon's parameters. `rotation` is the angle of its first\n * vertex, in radians, inside the pose's own rotation. */\nexport interface PolygonGenerator {\n kind: 'polygon';\n sides: number;\n rotation: number;\n}\n\n/** A star's parameters. `innerRadiusRatio` is the inner radius over the outer\n * one; `rotation` is the angle of its first point, in radians, inside the\n * pose's own rotation. */\nexport interface StarGenerator {\n kind: 'star';\n points: number;\n innerRadiusRatio: number;\n rotation: number;\n}\n\n/** The parameters a node's `data.path` was generated from. */\nexport type ShapeGenerator = PolygonGenerator | StarGenerator;\n\n/** The range of a polygon's side count and a star's point count. */\nexport const SHAPE_COUNT_RANGE = { min: 3, max: 32 } as const;\n\n/** The range of a star's `innerRadiusRatio`. */\nexport const STAR_RATIO_RANGE = { min: 0.01, max: 1 } as const;\n\nconst finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);\n\n/** Whether `v` is a well-formed {@link ShapeGenerator}. */\nexport function isShapeGenerator(v: unknown): v is ShapeGenerator {\n if (v === null || typeof v !== 'object') return false;\n const g = v as Record<string, unknown>;\n if (!finite(g.rotation)) return false;\n if (g.kind === 'polygon') return finite(g.sides);\n if (g.kind === 'star') return finite(g.points) && finite(g.innerRadiusRatio);\n return false;\n}\n\n/** The generator a node's `data` holds, or `null`. */\nexport function shapeGeneratorOf(data: unknown): ShapeGenerator | null {\n const g = (data as { generator?: unknown } | null | undefined)?.generator;\n return isShapeGenerator(g) ? g : null;\n}\n\nconst count = (v: number): number => clamp(Math.round(v), SHAPE_COUNT_RANGE.min, SHAPE_COUNT_RANGE.max);\n\n/** `g` with its counts rounded and every field held in range. */\nexport function clampShapeGenerator(g: ShapeGenerator): ShapeGenerator {\n if (g.kind === 'polygon') return { kind: 'polygon', sides: count(g.sides), rotation: g.rotation };\n return {\n kind: 'star',\n points: count(g.points),\n innerRadiusRatio: clamp(g.innerRadiusRatio, STAR_RATIO_RANGE.min, STAR_RATIO_RANGE.max),\n rotation: g.rotation,\n };\n}\n\n/** The shape `g` makes, on a circle of `radius` around `center`. */\nexport function shapeGeneratorPath(\n g: ShapeGenerator,\n center: { x: number; y: number } = { x: 0, y: 0 },\n radius = 1,\n): PolygonPath {\n const c = clampShapeGenerator(g);\n return c.kind === 'polygon'\n ? regularPolygonPath(center, radius, c.sides, c.rotation)\n : starPath(center, radius, c.points, radius * c.innerRadiusRatio, c.rotation);\n}\n\n/** `data` without its generator: the shape as plain geometry. */\nexport function withoutShapeGenerator<T>(data: T): T {\n if (data === null || typeof data !== 'object' || !('generator' in data)) return data;\n const { generator: _dropped, ...rest } = data as Record<string, unknown>;\n void _dropped;\n return rest as T;\n}\n\n/** Whether `path` is what `g` makes, up to the box-to-box map storage applies. */\nfunction pathMatches(path: unknown, g: ShapeGenerator): boolean {\n const p = path as PolygonPath | undefined;\n if (p?.kind !== 'polygon') return false;\n const unit = shapeGeneratorPath(g);\n if (p.coords.length !== unit.coords.length) return false;\n const u = boundsOfPath(unit);\n const b = boundsOfPath(p);\n const m = boxToBox(u.x, u.y, u.width, u.height, b.x, b.y, b.width, b.height);\n // Paths store float32 coordinates, so the slack scales with how far they are from the point they are measured from.\n const o = originOf(p);\n const tol = 1e-4 * Math.max(1, Math.abs(b.x - o.x) + b.width, Math.abs(b.y - o.y) + b.height);\n const at = worldCoords(p);\n for (let i = 0; i + 1 < unit.coords.length; i += 2) {\n const x = m[0] * unit.coords[i] + m[4];\n const y = m[3] * unit.coords[i + 1] + m[5];\n if (Math.abs(x - at[i]) > tol || Math.abs(y - at[i + 1]) > tol) return false;\n }\n return true;\n}\n\n/** A node's two halves, as a regeneration reads and writes them. */\nexport interface GeneratedShape<P extends PathInWorldPose = PathInWorldPose> {\n pose: P;\n data: unknown;\n}\n\n/**\n * `node` made again from `next`, in place: the new shape takes the box the old\n * one's circle had, under the same rotation and transform, so a star given\n * more points keeps its center and its size. Returns `null` when the node holds\n * no generator, or its path no longer matches the one it holds.\n */\nexport function regenerateShape<P extends PathInWorldPose>(\n node: GeneratedShape<P>,\n next: ShapeGenerator,\n): GeneratedShape<P> | null {\n const held = shapeGeneratorOf(node.data);\n if (!held || !pathMatches((node.data as { path?: unknown }).path, held)) return null;\n const g = clampShapeGenerator(next);\n const was = boundsOfPath(shapeGeneratorPath(held));\n const { pose } = node;\n // Where the unit circle of the held generator lands in the pose's local frame.\n const toLocal = boxToBox(was.x, was.y, was.width, was.height, pose.x, pose.y, pose.width, pose.height);\n const local = transformPath(shapeGeneratorPath(g), toLocal) as PolygonPath;\n const frame = poseFrameOf(pose);\n const world = frame ? (transformPath(local, frame) as PolygonPath) : local;\n const stored = worldEditToStorage(pose, world);\n return { pose: stored.pose, data: { ...(node.data as object), path: stored.path, generator: g } };\n}\n\n/**\n * What a write that left a node as `next`, from `prev`, stores: when the write\n * changed the generator, the shape made again from it. A generator written\n * onto a node that held none, or of another kind, or onto one whose path no\n * longer matches, is dropped, leaving the shape as plain geometry. Any other\n * write is stored as written. Fits `NodePropertiesEntry.settle`.\n */\nexport function settleShapeGenerator<N extends { pose: unknown; data: unknown }>(next: N, prev: N): N {\n const data = next.data as Record<string, unknown> | null;\n if (data === null || typeof data !== 'object' || !('generator' in data)) return next;\n const raw = data.generator;\n const held = shapeGeneratorOf(prev.data);\n if (held !== null && JSON.stringify(raw) === JSON.stringify(held)) return next;\n const regenerated = held !== null && isShapeGenerator(raw) && raw.kind === held.kind\n ? regenerateShape({ pose: next.pose as PathInWorldPose, data: { ...data, generator: held } }, raw)\n : null;\n return { ...next, ...(regenerated ?? { data: withoutShapeGenerator(next.data) }) };\n}\n"],"mappings":"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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { distinct, markFromPlane } from "./marks.js";
|
|
2
2
|
import { screenOf } from "./screen.js";
|
|
3
3
|
import { stepAngles } from "./stepAngles.js";
|
|
4
|
+
import { wrapPositive } from "@weasel-js/geom";
|
|
4
5
|
import { resolveSnap } from "@weasel-js/geom/nd";
|
|
5
6
|
|
|
6
7
|
// src/domains/snap/angle2d.ts
|
|
@@ -140,7 +141,7 @@ function rayMarks(inputs, theta) {
|
|
|
140
141
|
x: pivot.x + r * Math.cos(dir),
|
|
141
142
|
y: pivot.y + r * Math.sin(dir)
|
|
142
143
|
};
|
|
143
|
-
const deg = (Math.round(theta * 180 / Math.PI)
|
|
144
|
+
const deg = wrapPositive(Math.round(theta * 180 / Math.PI), 360);
|
|
144
145
|
return [{
|
|
145
146
|
kind: "line",
|
|
146
147
|
from: pivot,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"angle2d.js","names":[],"sources":["../../../src/domains/snap/angle2d.ts"],"sourcesContent":["/** A snap session's one-dimensional path: an angle about a pivot, snapped by\n * the same resolver as a point, with reach carried through the radius. */\nimport { resolveSnap, type SnapCandidate } from '@weasel-js/geom/nd';\nimport type { SnapMods } from 'interactions/actions/snapDep';\nimport { distinct, markFromPlane } from './marks';\nimport { screenOf } from './screen';\nimport { stepAngles } from './stepAngles';\nimport type { Session2dInputs } from './session2d';\nimport type { AngleCollect, SnapMark, SnapMarks, SnapSource } from './types';\n\nconst TURN = Math.PI * 2;\nconst PROBE = 'angle';\n/** A press near the pivot turns px reach into a wide angle; it reaches no\n * further than this. */\nconst MAX_REACH_RAD = Math.PI / 6;\n\n/** Where `theta` lands, and what it met. Writes the marks to the store. */\nexport function angleAt(\n inputs: Session2dInputs, sources: readonly SnapSource[], theta: number, mods: SnapMods,\n): { theta: number; marks: readonly SnapMark[] } {\n const { req, store } = inputs;\n // Screen px per radian, or 1 when the gesture gives its reach in radians.\n const scale = req.reachRad !== undefined ? 1 : pressRadiusPx(inputs);\n const reachOf = (s: SnapSource) => req.reachRad\n ?? Math.min(s.reachPx ?? inputs.reachPx, MAX_REACH_RAD * scale);\n const candidates = collectAngles(inputs, sources, theta, mods, reachOf);\n if (candidates.length === 0) {\n store.set([]);\n return { theta, marks: [] };\n }\n const res = resolveSnap({\n probes: [{ id: PROBE, at: [0], role: 'pointer' }],\n input: { at: [0], dirs: [[1]] },\n delta0: [theta],\n candidates,\n // No radius, no reach: only a required angle can win.\n toScreen: ([t]) => [scale > 0 ? t * scale : NaN],\n });\n const out = res.delta[0];\n const marks = distinct(res.satisfied.flatMap((c) => {\n const m = c.mark ?? rayMarks(inputs, out);\n return 'kind' in m ? [m] : m;\n }));\n const plane = req.plane?.() ?? null;\n store.set(plane ? marks.map((m) => markFromPlane(m, plane)) : marks);\n return { theta: out, marks };\n}\n\nfunction collectAngles(\n inputs: Session2dInputs, sources: readonly SnapSource[], theta: number, mods: SnapMods,\n reachOf: (s: SnapSource) => number,\n): SnapCandidate<SnapMarks>[] {\n const { req } = inputs;\n const out: SnapCandidate<SnapMarks>[] = [];\n const c = req.constrain;\n if (mods.constrain && typeof c === 'object' && c.step > 0) {\n for (const at of stepAngles(theta, c.step)) {\n out.push({ probe: PROBE, locus: { at: [at], dirs: [] }, source: 'constrain', order: -1, tier: 'target', reachPx: 0, required: true });\n }\n }\n const theta0 = req.theta0 ?? 0;\n if (mods.hold) {\n const at = theta0 + TURN * Math.round((theta - theta0) / TURN);\n out.push({ probe: PROBE, locus: { at: [at], dirs: [] }, source: 'hold', order: -1, tier: 'target', reachPx: 0, required: true, mark: [] });\n }\n if (mods.suspend) return out;\n const q: AngleCollect = {\n pivot: req.pivot ?? req.press,\n press: req.press,\n theta0,\n theta,\n ids: req.ids ?? [],\n exclude: inputs.exclude ?? req.ids ?? [],\n mods,\n ...(req.also ? { also: req.also } : {}),\n ...(inputs.scene ? { scene: inputs.scene } : {}),\n ...(inputs.rotationOf ? { rotationOf: inputs.rotationOf } : {}),\n };\n // Runs across sources and their yields: an earlier source, then an earlier\n // yield, wins a tie.\n let order = 0;\n sources.forEach((source) => {\n if (!source.angles) return;\n const reachPx = reachOf(source);\n for (const a of source.angles(q)) {\n // The whole turn of the angle nearest `theta`: 0 catches 359°.\n const at = a.angle + TURN * Math.round((theta - a.angle) / TURN);\n out.push({\n probe: PROBE, locus: { at: [at], dirs: [] }, source: source.id, order: order++, tier: source.tier ?? 'target', reachPx,\n ...(a.required ? { required: true } : {}),\n ...(a.mark ? { mark: a.mark } : {}),\n });\n }\n });\n return out;\n}\n\n/** The screen distance from the pivot to the press: what turns reach in px\n * into radians. */\nfunction pressRadiusPx(inputs: Session2dInputs): number {\n const { req } = inputs;\n const pivot = req.pivot ?? req.press;\n const toScreen = screenOf(inputs.view(), req.plane?.() ?? null);\n const [ax, ay] = toScreen([pivot.x, pivot.y]);\n const [bx, by] = toScreen([req.press.x, req.press.y]);\n return Math.hypot(bx - ax, by - ay);\n}\n\n/** A ray from the pivot out to the press radius, along where the pointer\n * lands at `theta`, labeled with `theta` in degrees. */\nfunction rayMarks(inputs: Session2dInputs, theta: number): SnapMark[] {\n const { req } = inputs;\n const pivot = req.pivot ?? req.press;\n const r = Math.hypot(req.press.x - pivot.x, req.press.y - pivot.y);\n const dir = Math.atan2(req.press.y - pivot.y, req.press.x - pivot.x) + theta - (req.theta0 ?? 0);\n const end = { x: pivot.x + r * Math.cos(dir), y: pivot.y + r * Math.sin(dir) };\n const deg = (
|
|
1
|
+
{"version":3,"file":"angle2d.js","names":[],"sources":["../../../src/domains/snap/angle2d.ts"],"sourcesContent":["/** A snap session's one-dimensional path: an angle about a pivot, snapped by\n * the same resolver as a point, with reach carried through the radius. */\nimport { wrapPositive } from '@weasel-js/geom';\nimport { resolveSnap, type SnapCandidate } from '@weasel-js/geom/nd';\nimport type { SnapMods } from 'interactions/actions/snapDep';\nimport { distinct, markFromPlane } from './marks';\nimport { screenOf } from './screen';\nimport { stepAngles } from './stepAngles';\nimport type { Session2dInputs } from './session2d';\nimport type { AngleCollect, SnapMark, SnapMarks, SnapSource } from './types';\n\nconst TURN = Math.PI * 2;\nconst PROBE = 'angle';\n/** A press near the pivot turns px reach into a wide angle; it reaches no\n * further than this. */\nconst MAX_REACH_RAD = Math.PI / 6;\n\n/** Where `theta` lands, and what it met. Writes the marks to the store. */\nexport function angleAt(\n inputs: Session2dInputs, sources: readonly SnapSource[], theta: number, mods: SnapMods,\n): { theta: number; marks: readonly SnapMark[] } {\n const { req, store } = inputs;\n // Screen px per radian, or 1 when the gesture gives its reach in radians.\n const scale = req.reachRad !== undefined ? 1 : pressRadiusPx(inputs);\n const reachOf = (s: SnapSource) => req.reachRad\n ?? Math.min(s.reachPx ?? inputs.reachPx, MAX_REACH_RAD * scale);\n const candidates = collectAngles(inputs, sources, theta, mods, reachOf);\n if (candidates.length === 0) {\n store.set([]);\n return { theta, marks: [] };\n }\n const res = resolveSnap({\n probes: [{ id: PROBE, at: [0], role: 'pointer' }],\n input: { at: [0], dirs: [[1]] },\n delta0: [theta],\n candidates,\n // No radius, no reach: only a required angle can win.\n toScreen: ([t]) => [scale > 0 ? t * scale : NaN],\n });\n const out = res.delta[0];\n const marks = distinct(res.satisfied.flatMap((c) => {\n const m = c.mark ?? rayMarks(inputs, out);\n return 'kind' in m ? [m] : m;\n }));\n const plane = req.plane?.() ?? null;\n store.set(plane ? marks.map((m) => markFromPlane(m, plane)) : marks);\n return { theta: out, marks };\n}\n\nfunction collectAngles(\n inputs: Session2dInputs, sources: readonly SnapSource[], theta: number, mods: SnapMods,\n reachOf: (s: SnapSource) => number,\n): SnapCandidate<SnapMarks>[] {\n const { req } = inputs;\n const out: SnapCandidate<SnapMarks>[] = [];\n const c = req.constrain;\n if (mods.constrain && typeof c === 'object' && c.step > 0) {\n for (const at of stepAngles(theta, c.step)) {\n out.push({ probe: PROBE, locus: { at: [at], dirs: [] }, source: 'constrain', order: -1, tier: 'target', reachPx: 0, required: true });\n }\n }\n const theta0 = req.theta0 ?? 0;\n if (mods.hold) {\n const at = theta0 + TURN * Math.round((theta - theta0) / TURN);\n out.push({ probe: PROBE, locus: { at: [at], dirs: [] }, source: 'hold', order: -1, tier: 'target', reachPx: 0, required: true, mark: [] });\n }\n if (mods.suspend) return out;\n const q: AngleCollect = {\n pivot: req.pivot ?? req.press,\n press: req.press,\n theta0,\n theta,\n ids: req.ids ?? [],\n exclude: inputs.exclude ?? req.ids ?? [],\n mods,\n ...(req.also ? { also: req.also } : {}),\n ...(inputs.scene ? { scene: inputs.scene } : {}),\n ...(inputs.rotationOf ? { rotationOf: inputs.rotationOf } : {}),\n };\n // Runs across sources and their yields: an earlier source, then an earlier\n // yield, wins a tie.\n let order = 0;\n sources.forEach((source) => {\n if (!source.angles) return;\n const reachPx = reachOf(source);\n for (const a of source.angles(q)) {\n // The whole turn of the angle nearest `theta`: 0 catches 359°.\n const at = a.angle + TURN * Math.round((theta - a.angle) / TURN);\n out.push({\n probe: PROBE, locus: { at: [at], dirs: [] }, source: source.id, order: order++, tier: source.tier ?? 'target', reachPx,\n ...(a.required ? { required: true } : {}),\n ...(a.mark ? { mark: a.mark } : {}),\n });\n }\n });\n return out;\n}\n\n/** The screen distance from the pivot to the press: what turns reach in px\n * into radians. */\nfunction pressRadiusPx(inputs: Session2dInputs): number {\n const { req } = inputs;\n const pivot = req.pivot ?? req.press;\n const toScreen = screenOf(inputs.view(), req.plane?.() ?? null);\n const [ax, ay] = toScreen([pivot.x, pivot.y]);\n const [bx, by] = toScreen([req.press.x, req.press.y]);\n return Math.hypot(bx - ax, by - ay);\n}\n\n/** A ray from the pivot out to the press radius, along where the pointer\n * lands at `theta`, labeled with `theta` in degrees. */\nfunction rayMarks(inputs: Session2dInputs, theta: number): SnapMark[] {\n const { req } = inputs;\n const pivot = req.pivot ?? req.press;\n const r = Math.hypot(req.press.x - pivot.x, req.press.y - pivot.y);\n const dir = Math.atan2(req.press.y - pivot.y, req.press.x - pivot.x) + theta - (req.theta0 ?? 0);\n const end = { x: pivot.x + r * Math.cos(dir), y: pivot.y + r * Math.sin(dir) };\n const deg = wrapPositive(Math.round(theta * 180 / Math.PI), 360);\n return [\n { kind: 'line', from: pivot, to: end },\n { kind: 'point', at: end, glyph: 'quadrant', label: `${deg}°` },\n ];\n}\n"],"mappings":"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"}
|
|
@@ -1,22 +1,19 @@
|
|
|
1
|
+
import { createEmitter } from "@weasel-js/registry";
|
|
2
|
+
|
|
1
3
|
// src/domains/snap/outcomeStore.ts
|
|
2
4
|
var NONE = [];
|
|
3
5
|
/** An empty outcome store, which notifies only when the marks change. */
|
|
4
6
|
function createSnapOutcomeStore() {
|
|
5
7
|
let marks = NONE;
|
|
6
|
-
const
|
|
8
|
+
const changed = createEmitter();
|
|
7
9
|
return {
|
|
8
10
|
get: () => marks,
|
|
9
11
|
set(next) {
|
|
10
12
|
if (next.length === 0 && marks.length === 0) return;
|
|
11
13
|
marks = next.length === 0 ? NONE : next;
|
|
12
|
-
|
|
14
|
+
changed.emit();
|
|
13
15
|
},
|
|
14
|
-
subscribe
|
|
15
|
-
listeners.add(listener);
|
|
16
|
-
return () => {
|
|
17
|
-
listeners.delete(listener);
|
|
18
|
-
};
|
|
19
|
-
}
|
|
16
|
+
subscribe: changed.subscribe
|
|
20
17
|
};
|
|
21
18
|
}
|
|
22
19
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"outcomeStore.js","names":[],"sources":["../../../src/domains/snap/outcomeStore.ts"],"sourcesContent":["import type { SnapMark } from './types';\nimport type { Point } from '@weasel-js/geom';\n\n/** The marks of the snap in flight, for the readout to draw. */\nexport interface SnapOutcomeStore<P = Point> {\n get(): readonly SnapMark<P>[];\n set(marks: readonly SnapMark<P>[]): void;\n subscribe(listener: () => void): () => void;\n}\n\nconst NONE: readonly never[] = [];\n\n/** An empty outcome store, which notifies only when the marks change. */\nexport function createSnapOutcomeStore<P = Point>(): SnapOutcomeStore<P> {\n let marks: readonly SnapMark<P>[] = NONE;\n const
|
|
1
|
+
{"version":3,"file":"outcomeStore.js","names":[],"sources":["../../../src/domains/snap/outcomeStore.ts"],"sourcesContent":["import type { SnapMark } from './types';\nimport type { Point } from '@weasel-js/geom';\nimport { createEmitter } from '@weasel-js/registry';\n\n/** The marks of the snap in flight, for the readout to draw. */\nexport interface SnapOutcomeStore<P = Point> {\n get(): readonly SnapMark<P>[];\n set(marks: readonly SnapMark<P>[]): void;\n subscribe(listener: () => void): () => void;\n}\n\nconst NONE: readonly never[] = [];\n\n/** An empty outcome store, which notifies only when the marks change. */\nexport function createSnapOutcomeStore<P = Point>(): SnapOutcomeStore<P> {\n let marks: readonly SnapMark<P>[] = NONE;\n const changed = createEmitter();\n return {\n get: () => marks,\n set(next) {\n if (next.length === 0 && marks.length === 0) return;\n marks = next.length === 0 ? NONE : next;\n changed.emit();\n },\n subscribe: changed.subscribe,\n };\n}\n"],"mappings":";;;AAWA,IAAM,OAAyB,CAAC;;AAGhC,SAAgB,yBAAyD;CACvE,IAAI,QAAgC;CACpC,MAAM,UAAU,cAAc;CAC9B,OAAO;EACL,WAAW;EACX,IAAI,MAAM;GACR,IAAI,KAAK,WAAW,KAAK,MAAM,WAAW,GAAG;GAC7C,QAAQ,KAAK,WAAW,IAAI,OAAO;GACnC,QAAQ,KAAK;EACf;EACA,WAAW,QAAQ;CACrB;AACF"}
|
|
@@ -7,7 +7,7 @@ import { editedData, sceneTextDrafts } from "./sceneTextDrafts.js";
|
|
|
7
7
|
import { inheritedPaintIn, withInheritedPaint } from "../../core/scene/inheritedPaint.js";
|
|
8
8
|
import { findMountedCanvas } from "../../canvas/mountedCanvases.js";
|
|
9
9
|
import { textBoxOf } from "../../canvas/nodeShapes/textPainter.js";
|
|
10
|
-
import { poseFrameOf } from "@weasel-js/geom";
|
|
10
|
+
import { poseFrameOf, viewScreenToWorld } from "@weasel-js/geom";
|
|
11
11
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
12
12
|
import { useLatest } from "@weasel-js/react";
|
|
13
13
|
import { clientToCanvas } from "@weasel-js/routing";
|
|
@@ -132,9 +132,14 @@ function useSceneTextEditor(scene, container, options = {}) {
|
|
|
132
132
|
const [canvasX, canvasY] = clientToCanvas(canvas, e.clientX, e.clientY);
|
|
133
133
|
const root = containerRef.current;
|
|
134
134
|
const view = resolveView(optsRef.current.view) ?? (root ? findMountedCanvas(root, canvas)?.getView() : void 0);
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
const at = view ? viewScreenToWorld(view, {
|
|
136
|
+
x: canvasX,
|
|
137
|
+
y: canvasY
|
|
138
|
+
}) : {
|
|
139
|
+
x: canvasX,
|
|
140
|
+
y: canvasY
|
|
141
|
+
};
|
|
142
|
+
const hit = textHitAt(sceneRef.current, read, at.x, at.y);
|
|
138
143
|
if (!hit) return;
|
|
139
144
|
openedOnRef.current = canvas;
|
|
140
145
|
edit.startEdit(hit.id, { caret: hit.caret });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSceneTextEdit.js","names":[],"sources":["../../../src/domains/text/useSceneTextEdit.ts"],"sourcesContent":["/**\n * `useSceneTextEdit` — scene-aware wrapper around `useTextEdit`: scene\n * lookups for text / style / runs / screen-pose, and\n * `scene.update(id, { data })` for commits. The overlay and a double-click\n * both read the pose a node is painted at, so text inside a container whose\n * pose is a frame is edited where it is drawn.\n *\n * Defaults assume the node's `data` carries `text` / `style` / `runs`\n * directly. For custom data shapes, override any of the optional projections.\n * Consumers with non-rect poses should drop down to raw `useTextEdit` and\n * supply their own `getScreenPose`.\n */\nimport { useCallback, useEffect, useMemo, useRef } from 'react';\nimport { useLatest } from '@weasel-js/react';\nimport { asNodeId } from '../../core/scene/types';\nimport { scenePaintedPoseOf } from '../../canvas/derivedPath';\nimport type { Scene } from '../../core/scene/types';\nimport { drawOneEditing, type EditingDrawOne } from './editingDrawOne';\nimport { textHitAt } from './textHitAt';\nimport { registerHostTextEditor } from './hostTextEditors';\nimport { editedData, sceneTextDrafts } from './sceneTextDrafts';\nimport type { Stroke, Fill, Inherit } from '@weasel-js/paint';\nimport { resolveScreenLength } from '@weasel-js/paint';\nimport { inheritedPaintIn } from '../../canvas/inheritedPaints';\nimport { withInheritedPaint } from '../../core/scene/inheritedPaint';\nimport { clientToCanvas } from '@weasel-js/routing';\nimport { findMountedCanvas, type MountedCanvas } from '../../canvas/mountedCanvases';\nimport type { View } from '../../core/viewport/view';\nimport type { RectPose } from 'core/geometry/unionBounds';\nimport { poseFrameOf } from '@weasel-js/geom';\nimport { isTextData, type StyledRun, type TextPaint, type TextStyle, type TextVerticalAlign } from '@weasel-js/text';\nimport { textBoxOf, type TextOverflow } from '../../canvas/nodeShapes/textPainter';\nimport { useTextEdit, type TextEditClipRect, type TextEditScreenPose, type UseTextEditReturn } from '@weasel-js/text-dom';\n\n/** Shape the default projections expect `data` to satisfy. All fields\n * optional so any object satisfies the constraint — projections fill in\n * the gaps for non-default shapes. */\nexport interface DefaultTextData {\n text?: string;\n style?: TextStyle;\n runs?: readonly StyledRun[];\n fill?: Fill | Inherit | null;\n stroke?: Stroke | Inherit | null;\n verticalAlign?: TextVerticalAlign;\n padding?: number;\n overflow?: TextOverflow;\n}\n\n/** All-optional projections + fontSize fallback. */\nexport interface UseSceneTextEditOptions<TData> {\n /**\n * Is this node text? A double-click opens an edit only on a node that\n * answers yes, and passes through any other to the text under it. Default:\n * `isTextData`, the test `inferredNodeRouting` classifies a text node by. Pass one when `getText` reads some other shape.\n */\n isText?: (data: TData) => boolean;\n /** Read plain text from `data`. Default: `data.text ?? ''`. */\n getText?: (data: TData) => string;\n /** Read style from `data`. Default: `data.style`. */\n getStyle?: (data: TData) => TextStyle | undefined;\n /** Read the node's paint from `data`. Default: `data.fill` / `data.stroke`. */\n getPaint?: (data: TData) => TextPaint | undefined;\n /** Read rich-text runs from `data`. Default: `data.runs`. */\n getRuns?: (data: TData) => readonly StyledRun[] | undefined;\n /** Read the box vertical alignment from `data`. Default: `data.verticalAlign`. */\n getVerticalAlign?: (data: TData) => TextVerticalAlign | undefined;\n /** Read the inset from the node's box to its text from `data`. Default: `data.padding`. */\n getPadding?: (data: TData) => number | undefined;\n /** Read whether the node cuts its text at its box from `data`, which the\n * overlay then does too. Default: `data.overflow`. */\n getOverflow?: (data: TData) => TextOverflow | undefined;\n /** Produce updated data with new text. Default: `{ ...data, text }`. */\n setText?: (data: TData, text: string) => TData;\n /** Produce updated data with new runs. Default: `{ ...data, runs }`. */\n setRuns?: (data: TData, runs: StyledRun[]) => TData;\n /**\n * Produce updated data with a new node-wide style. Default:\n * `{ ...data, style }`. Written only when a range toggle has to *clear* a\n * flag the node itself sets — the run algebra is additive, so that edit\n * lowers the node flag and raises it on the runs outside the range. See\n * `useTextEdit`'s `setStyle`.\n *\n * An edit's text, runs, and style are folded through these three and\n * written as one `scene.update`, so a commit is one undo step.\n */\n setStyle?: (data: TData, style: TextStyle) => TData;\n /** Fallback fontSize when `style.fontSize` is unset. Default `16`. */\n defaultFontSize?: number;\n /**\n * The view to project through. Omit it and the view is read live from the\n * weasel canvas mounted inside `container` (the one a double-click landed\n * on, when there are several). With no canvas there, the node's world box\n * is passed through as screen pixels.\n *\n * A thunk is re-read on every projection; a plain `View` is the value from\n * the render that supplied it.\n */\n view?: View | (() => View);\n /**\n * Forwarded to `useTextEdit`: is `el` part of the editor's own chrome?\n * Focus moving into it does not end the edit. Wire it to whatever renders\n * the character controls.\n */\n isEditorChrome?: (el: Element) => boolean;\n /** Forwarded to `useTextEdit`: what Escape does to the edit. */\n escape?: 'cancel' | 'commit';\n /** Forwarded to `useTextEdit`: how long an edit waits for its face. */\n fontHold?: number;\n /** Forwarded to `useTextEdit`: called as an edit of the node `id` opens,\n * whichever way it was started. */\n onStart?: (id: string) => void;\n /** Forwarded to `useTextEdit`: called as the edit of `id` closes, after\n * any commit has been written to the scene. */\n onEnd?: (id: string) => void;\n}\n\nfunction resolveView(view: View | (() => View) | undefined): View | undefined {\n return typeof view === 'function' ? view() : view;\n}\n\n/** What `onDoubleClick` reads of the event: a React or a DOM `MouseEvent`. */\nexport type TextEditDoubleClick = Pick<MouseEvent, 'target' | 'clientX' | 'clientY'>;\n\n/** Return shape extends `UseTextEditReturn` with an `onDoubleClick`\n * binding for the canvas container. Double-clicking inside a text\n * node enters edit mode with the caret seeded at the clicked glyph. */\nexport interface UseSceneTextEditReturn extends UseTextEditReturn {\n /** Bind to the element wrapping `<SceneCanvas>` (the same element\n * passed as `container`, or any ancestor that receives the dblclick\n * bubble). Starts an edit on the top-most text node under the cursor. */\n onDoubleClick: (e: TextEditDoubleClick) => void;\n /** The `isText` option asked of the node with this id: what\n * `TextEditDep.isTextNode` wants, so both ways into an edit agree. */\n isTextNode: (id: string) => boolean;\n /**\n * `defaultDrawOne`, less the glyphs of the node being edited: the overlay\n * stands in for its text, and the rest of the node, a `background` among\n * it, goes on painting. Pass it as `layers.scene.drawOne`, with\n * `paintBounds: defaultPaintBounds`, in place of an `alphaFor` that hides\n * the whole node.\n */\n drawOne: EditingDrawOne;\n}\n\n/** How each field is read from a node's `data`: the option's projection, else the default shape. */\nfunction projectionsOf<TData extends DefaultTextData>(opts: { readonly current: UseSceneTextEditOptions<TData> }) {\n return {\n isText: (data: TData): boolean =>\n opts.current.isText ? opts.current.isText(data) : isTextData(data),\n text: (data: TData): string =>\n opts.current.getText ? opts.current.getText(data) : (data.text ?? ''),\n style: (data: TData): TextStyle | undefined =>\n opts.current.getStyle ? opts.current.getStyle(data) : data.style,\n runs: (data: TData): readonly StyledRun[] | undefined =>\n opts.current.getRuns ? opts.current.getRuns(data) : data.runs,\n verticalAlign: (data: TData): TextVerticalAlign | undefined =>\n opts.current.getVerticalAlign ? opts.current.getVerticalAlign(data) : data.verticalAlign,\n padding: (data: TData): number | undefined =>\n opts.current.getPadding ? opts.current.getPadding(data) : data.padding,\n overflow: (data: TData): TextOverflow | undefined =>\n opts.current.getOverflow ? opts.current.getOverflow(data) : data.overflow,\n };\n}\n\n/** Wire double-click-to-edit text on a scene: mounts a DOM editing overlay\n * over the node being edited and writes the result back as an undoable op.\n * `<SceneCanvas>`'s own editor stands down for any canvas inside `container`. */\nexport function useSceneTextEdit<\n TData extends DefaultTextData,\n TLayer extends string,\n TPose extends RectPose = RectPose,\n>(\n scene: Scene<TData, TLayer, TPose>,\n container: HTMLElement | null,\n options: UseSceneTextEditOptions<TData> = {},\n): UseSceneTextEditReturn {\n useEffect(() => (container ? registerHostTextEditor(container) : undefined), [container]);\n return useSceneTextEditor(scene, container, options);\n}\n\n/** `useSceneTextEdit` without announcing itself as a host's editor: what\n * `<SceneCanvas>` mounts as its own. */\nexport function useSceneTextEditor<\n TData extends DefaultTextData,\n TLayer extends string,\n TPose extends RectPose = RectPose,\n>(\n scene: Scene<TData, TLayer, TPose>,\n container: HTMLElement | null,\n options: UseSceneTextEditOptions<TData> = {},\n): UseSceneTextEditReturn {\n // Refs, so the useTextEdit callbacks always see the current values\n // without re-binding.\n const sceneRef = useLatest(scene);\n const optsRef = useLatest(options);\n const containerRef = useLatest(container);\n // The last double-click's target, so the overlay projects through the\n // canvas the edit was opened on.\n const openedOnRef = useRef<EventTarget | null>(null);\n const drafts = useMemo(() => sceneTextDrafts(sceneRef, optsRef), [sceneRef, optsRef]);\n useEffect(() => () => drafts.close(), [drafts]);\n\n // The canvas's box in container pixels is both the origin its view projects\n // from and the region its glyphs are clipped to.\n const surface = (): { canvas: MountedCanvas; box: TextEditClipRect } | null => {\n const root = containerRef.current;\n const canvas = root ? findMountedCanvas(root, openedOnRef.current) : null;\n if (!root || !canvas) return null;\n const a = canvas.element.getBoundingClientRect();\n const b = root.getBoundingClientRect();\n return {\n canvas,\n box: {\n x: a.left - b.left - root.clientLeft + root.scrollLeft,\n y: a.top - b.top - root.clientTop + root.scrollTop,\n width: a.width,\n height: a.height,\n },\n };\n };\n\n const read = useMemo(() => projectionsOf(optsRef), [optsRef]);\n\n const edit = useTextEdit({\n container,\n isEditorChrome: (el) => optsRef.current.isEditorChrome?.(el) ?? false,\n escape: options.escape,\n fontHold: options.fontHold,\n onStart: (id) => optsRef.current.onStart?.(id),\n onEnd: (id) => {\n drafts.end(id);\n optsRef.current.onEnd?.(id);\n },\n onDraft: drafts.draft,\n getText: (id) => {\n const node = sceneRef.current.get(asNodeId(id));\n return node ? read.text(node.data) : '';\n },\n getStyle: (id) => {\n const node = sceneRef.current.get(asNodeId(id));\n return node ? read.style(node.data) : undefined;\n },\n getPaint: (id) => {\n const node = sceneRef.current.get(asNodeId(id));\n if (!node) return undefined;\n const get = optsRef.current.getPaint;\n return get ? get(node.data) : shownTextPaint(sceneRef.current, node);\n },\n getScreenPose: (id) => {\n const scene = sceneRef.current;\n const node = scene.get(asNodeId(id));\n if (!node) return null;\n const at = surface();\n const view = resolveView(optsRef.current.view) ?? at?.canvas.getView();\n return screenPoseOf(node.data, scenePaintedPoseOf(scene)(node), read, view, at?.box, optsRef.current.defaultFontSize);\n },\n getClipRect: () => surface()?.box ?? null,\n getRuns: (id) => {\n const node = sceneRef.current.get(asNodeId(id));\n return node ? read.runs(node.data) : undefined;\n },\n commit: (id, { text, runs, style }) => {\n const nid = asNodeId(id);\n const node = sceneRef.current.get(nid);\n if (!node) return;\n drafts.commit(id, editedData(node.data, { text, runs, style }, optsRef.current));\n },\n });\n\n const onDoubleClick = useCallback((e: TextEditDoubleClick) => {\n // The dblclick bubbles from the canvas up to any wrapping container; the\n // element it started on is what the click coordinates are relative to.\n const canvas = e.target instanceof HTMLElement ? e.target : null;\n if (!canvas) return;\n // Inside the editor a double-click selects a word, and its target is a run\n // span whose box the click would be measured against as if it were a canvas.\n if (canvas.closest('[contenteditable=\"true\"]')) return;\n\n // The click is un-projected here; `textHitAt` takes it back through the\n // frame the node is drawn in.\n const [canvasX, canvasY] = clientToCanvas(canvas, e.clientX, e.clientY);\n const root = containerRef.current;\n const view = resolveView(optsRef.current.view)\n ?? (root ? findMountedCanvas(root, canvas)?.getView() : undefined);\n const cx = view ? canvasX / view.scale.x + view.x : canvasX;\n const cy = view ? canvasY / view.scale.y + view.y : canvasY;\n const hit = textHitAt(sceneRef.current, read, cx, cy);\n if (!hit) return;\n openedOnRef.current = canvas;\n edit.startEdit(hit.id, { caret: hit.caret });\n }, [edit, containerRef, optsRef, read, sceneRef]);\n\n const isTextNode = useCallback((id: string): boolean => {\n const node = sceneRef.current.get(asNodeId(id));\n return node !== undefined && read.isText(node.data);\n }, [read, sceneRef]);\n\n const drawOne = useMemo(() => drawOneEditing(edit.editingId), [edit.editingId]);\n return { ...edit, onDoubleClick, isTextNode, drawOne };\n}\n\n/** The overlay's pose for a node painted at `painted`: its text box, inset\n * about the box center, which is all the overlay's frame needs, projected\n * through `view`. */\nfunction screenPoseOf<TData extends DefaultTextData>(\n d: TData,\n painted: RectPose,\n read: ReturnType<typeof projectionsOf<TData>>,\n view: View | undefined,\n origin: TextEditClipRect | undefined,\n defaultFontSize = 16,\n): TextEditScreenPose {\n const inset = textBoxOf({ padding: read.padding(d) }, painted);\n const box = screenBoxOf({ ...painted, ...inset }, view, origin);\n return {\n ...box,\n // Against `zoom`, the same factor the overlay scales back up by, so a\n // `{ px }` size arrives on screen at the pixel count it names.\n fontSize: resolveScreenLength(read.style(d)?.fontSize ?? defaultFontSize, box.zoom),\n verticalAlign: read.verticalAlign(d),\n ...(read.overflow(d) === 'clip' ? { clip: clipToBox(inset, painted) } : {}),\n };\n}\n\n/** How far `box` reaches past `inner` on each side: the overlay's cut at\n * `box` when its own pose is `inner`. */\nfunction clipToBox(inner: RectPose, box: RectPose): NonNullable<TextEditScreenPose['clip']> {\n return {\n top: inner.y - box.y,\n left: inner.x - box.x,\n bottom: box.y + box.height - (inner.y + inner.height),\n right: box.x + box.width - (inner.x + inner.width),\n };\n}\n\n/**\n * `pose`'s box as the overlay takes it, `origin` being the canvas's corner in\n * container pixels. Only the box center is projected, since the overlay's\n * frame is about it. Width and height stay in world units and reach the\n * screen through the overlay's own `scale(zoom)` — see\n * `TextEditScreenPose.zoom`.\n */\nfunction screenBoxOf(\n pose: RectPose,\n view: View | undefined,\n origin: { x: number; y: number } | undefined,\n): Pick<TextEditScreenPose, 'x' | 'y' | 'width' | 'height' | 'frame'> & { zoom: number } {\n const zoom = view ? view.scale.x : 1;\n const zoomY = view ? view.scale.y : 1;\n const cx = (origin?.x ?? 0) + (pose.x + pose.width / 2 - (view?.x ?? 0)) * zoom;\n const cy = (origin?.y ?? 0) + (pose.y + pose.height / 2 - (view?.y ?? 0)) * zoomY;\n return {\n x: cx - (pose.width * zoom) / 2,\n y: cy - (pose.height * zoom) / 2,\n width: pose.width,\n height: pose.height,\n zoom,\n frame: frameLinearOf(pose, zoomY / zoom),\n };\n}\n\n/** The linear part of the frame `pose` is drawn in, as the overlay takes it.\n * `stretch` is the view's y scale over its x scale: the overlay scales both\n * axes by the x scale, so the frame carries what is left of y. */\nfunction frameLinearOf(pose: unknown, stretch: number): TextEditScreenPose['frame'] {\n const m = poseFrameOf(pose);\n if (!m && stretch === 1) return undefined;\n const [a, b, c, d] = m ?? [1, 0, 0, 1];\n return [a, b * stretch, c, d * stretch];\n}\n\n/** A text node's paint as the canvas paints it: an inherited one is its container's. */\nfunction shownTextPaint<TData extends DefaultTextData>(\n scene: Scene<TData, string, unknown>,\n node: NonNullable<ReturnType<Scene<TData, string, unknown>['get']>>,\n): TextPaint {\n const { fill, stroke } = withInheritedPaint(node.data, inheritedPaintIn(scene, node));\n return { fill, stroke };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"useSceneTextEdit.js","names":[],"sources":["../../../src/domains/text/useSceneTextEdit.ts"],"sourcesContent":["/**\n * `useSceneTextEdit` — scene-aware wrapper around `useTextEdit`: scene\n * lookups for text / style / runs / screen-pose, and\n * `scene.update(id, { data })` for commits. The overlay and a double-click\n * both read the pose a node is painted at, so text inside a container whose\n * pose is a frame is edited where it is drawn.\n *\n * Defaults assume the node's `data` carries `text` / `style` / `runs`\n * directly. For custom data shapes, override any of the optional projections.\n * Consumers with non-rect poses should drop down to raw `useTextEdit` and\n * supply their own `getScreenPose`.\n */\nimport { useCallback, useEffect, useMemo, useRef } from 'react';\nimport { useLatest } from '@weasel-js/react';\nimport { asNodeId } from '../../core/scene/types';\nimport { scenePaintedPoseOf } from '../../canvas/derivedPath';\nimport type { Scene } from '../../core/scene/types';\nimport { drawOneEditing, type EditingDrawOne } from './editingDrawOne';\nimport { textHitAt } from './textHitAt';\nimport { registerHostTextEditor } from './hostTextEditors';\nimport { editedData, sceneTextDrafts } from './sceneTextDrafts';\nimport type { Stroke, Fill, Inherit } from '@weasel-js/paint';\nimport { resolveScreenLength } from '@weasel-js/paint';\nimport { inheritedPaintIn } from '../../canvas/inheritedPaints';\nimport { withInheritedPaint } from '../../core/scene/inheritedPaint';\nimport { clientToCanvas } from '@weasel-js/routing';\nimport { findMountedCanvas, type MountedCanvas } from '../../canvas/mountedCanvases';\nimport type { View } from '../../core/viewport/view';\nimport type { RectPose } from 'core/geometry/unionBounds';\nimport { poseFrameOf, viewScreenToWorld } from '@weasel-js/geom';\nimport { isTextData, type StyledRun, type TextPaint, type TextStyle, type TextVerticalAlign } from '@weasel-js/text';\nimport { textBoxOf, type TextOverflow } from '../../canvas/nodeShapes/textPainter';\nimport { useTextEdit, type TextEditClipRect, type TextEditScreenPose, type UseTextEditReturn } from '@weasel-js/text-dom';\n\n/** Shape the default projections expect `data` to satisfy. All fields\n * optional so any object satisfies the constraint — projections fill in\n * the gaps for non-default shapes. */\nexport interface DefaultTextData {\n text?: string;\n style?: TextStyle;\n runs?: readonly StyledRun[];\n fill?: Fill | Inherit | null;\n stroke?: Stroke | Inherit | null;\n verticalAlign?: TextVerticalAlign;\n padding?: number;\n overflow?: TextOverflow;\n}\n\n/** All-optional projections + fontSize fallback. */\nexport interface UseSceneTextEditOptions<TData> {\n /**\n * Is this node text? A double-click opens an edit only on a node that\n * answers yes, and passes through any other to the text under it. Default:\n * `isTextData`, the test `inferredNodeRouting` classifies a text node by. Pass one when `getText` reads some other shape.\n */\n isText?: (data: TData) => boolean;\n /** Read plain text from `data`. Default: `data.text ?? ''`. */\n getText?: (data: TData) => string;\n /** Read style from `data`. Default: `data.style`. */\n getStyle?: (data: TData) => TextStyle | undefined;\n /** Read the node's paint from `data`. Default: `data.fill` / `data.stroke`. */\n getPaint?: (data: TData) => TextPaint | undefined;\n /** Read rich-text runs from `data`. Default: `data.runs`. */\n getRuns?: (data: TData) => readonly StyledRun[] | undefined;\n /** Read the box vertical alignment from `data`. Default: `data.verticalAlign`. */\n getVerticalAlign?: (data: TData) => TextVerticalAlign | undefined;\n /** Read the inset from the node's box to its text from `data`. Default: `data.padding`. */\n getPadding?: (data: TData) => number | undefined;\n /** Read whether the node cuts its text at its box from `data`, which the\n * overlay then does too. Default: `data.overflow`. */\n getOverflow?: (data: TData) => TextOverflow | undefined;\n /** Produce updated data with new text. Default: `{ ...data, text }`. */\n setText?: (data: TData, text: string) => TData;\n /** Produce updated data with new runs. Default: `{ ...data, runs }`. */\n setRuns?: (data: TData, runs: StyledRun[]) => TData;\n /**\n * Produce updated data with a new node-wide style. Default:\n * `{ ...data, style }`. Written only when a range toggle has to *clear* a\n * flag the node itself sets — the run algebra is additive, so that edit\n * lowers the node flag and raises it on the runs outside the range. See\n * `useTextEdit`'s `setStyle`.\n *\n * An edit's text, runs, and style are folded through these three and\n * written as one `scene.update`, so a commit is one undo step.\n */\n setStyle?: (data: TData, style: TextStyle) => TData;\n /** Fallback fontSize when `style.fontSize` is unset. Default `16`. */\n defaultFontSize?: number;\n /**\n * The view to project through. Omit it and the view is read live from the\n * weasel canvas mounted inside `container` (the one a double-click landed\n * on, when there are several). With no canvas there, the node's world box\n * is passed through as screen pixels.\n *\n * A thunk is re-read on every projection; a plain `View` is the value from\n * the render that supplied it.\n */\n view?: View | (() => View);\n /**\n * Forwarded to `useTextEdit`: is `el` part of the editor's own chrome?\n * Focus moving into it does not end the edit. Wire it to whatever renders\n * the character controls.\n */\n isEditorChrome?: (el: Element) => boolean;\n /** Forwarded to `useTextEdit`: what Escape does to the edit. */\n escape?: 'cancel' | 'commit';\n /** Forwarded to `useTextEdit`: how long an edit waits for its face. */\n fontHold?: number;\n /** Forwarded to `useTextEdit`: called as an edit of the node `id` opens,\n * whichever way it was started. */\n onStart?: (id: string) => void;\n /** Forwarded to `useTextEdit`: called as the edit of `id` closes, after\n * any commit has been written to the scene. */\n onEnd?: (id: string) => void;\n}\n\nfunction resolveView(view: View | (() => View) | undefined): View | undefined {\n return typeof view === 'function' ? view() : view;\n}\n\n/** What `onDoubleClick` reads of the event: a React or a DOM `MouseEvent`. */\nexport type TextEditDoubleClick = Pick<MouseEvent, 'target' | 'clientX' | 'clientY'>;\n\n/** Return shape extends `UseTextEditReturn` with an `onDoubleClick`\n * binding for the canvas container. Double-clicking inside a text\n * node enters edit mode with the caret seeded at the clicked glyph. */\nexport interface UseSceneTextEditReturn extends UseTextEditReturn {\n /** Bind to the element wrapping `<SceneCanvas>` (the same element\n * passed as `container`, or any ancestor that receives the dblclick\n * bubble). Starts an edit on the top-most text node under the cursor. */\n onDoubleClick: (e: TextEditDoubleClick) => void;\n /** The `isText` option asked of the node with this id: what\n * `TextEditDep.isTextNode` wants, so both ways into an edit agree. */\n isTextNode: (id: string) => boolean;\n /**\n * `defaultDrawOne`, less the glyphs of the node being edited: the overlay\n * stands in for its text, and the rest of the node, a `background` among\n * it, goes on painting. Pass it as `layers.scene.drawOne`, with\n * `paintBounds: defaultPaintBounds`, in place of an `alphaFor` that hides\n * the whole node.\n */\n drawOne: EditingDrawOne;\n}\n\n/** How each field is read from a node's `data`: the option's projection, else the default shape. */\nfunction projectionsOf<TData extends DefaultTextData>(opts: { readonly current: UseSceneTextEditOptions<TData> }) {\n return {\n isText: (data: TData): boolean =>\n opts.current.isText ? opts.current.isText(data) : isTextData(data),\n text: (data: TData): string =>\n opts.current.getText ? opts.current.getText(data) : (data.text ?? ''),\n style: (data: TData): TextStyle | undefined =>\n opts.current.getStyle ? opts.current.getStyle(data) : data.style,\n runs: (data: TData): readonly StyledRun[] | undefined =>\n opts.current.getRuns ? opts.current.getRuns(data) : data.runs,\n verticalAlign: (data: TData): TextVerticalAlign | undefined =>\n opts.current.getVerticalAlign ? opts.current.getVerticalAlign(data) : data.verticalAlign,\n padding: (data: TData): number | undefined =>\n opts.current.getPadding ? opts.current.getPadding(data) : data.padding,\n overflow: (data: TData): TextOverflow | undefined =>\n opts.current.getOverflow ? opts.current.getOverflow(data) : data.overflow,\n };\n}\n\n/** Wire double-click-to-edit text on a scene: mounts a DOM editing overlay\n * over the node being edited and writes the result back as an undoable op.\n * `<SceneCanvas>`'s own editor stands down for any canvas inside `container`. */\nexport function useSceneTextEdit<\n TData extends DefaultTextData,\n TLayer extends string,\n TPose extends RectPose = RectPose,\n>(\n scene: Scene<TData, TLayer, TPose>,\n container: HTMLElement | null,\n options: UseSceneTextEditOptions<TData> = {},\n): UseSceneTextEditReturn {\n useEffect(() => (container ? registerHostTextEditor(container) : undefined), [container]);\n return useSceneTextEditor(scene, container, options);\n}\n\n/** `useSceneTextEdit` without announcing itself as a host's editor: what\n * `<SceneCanvas>` mounts as its own. */\nexport function useSceneTextEditor<\n TData extends DefaultTextData,\n TLayer extends string,\n TPose extends RectPose = RectPose,\n>(\n scene: Scene<TData, TLayer, TPose>,\n container: HTMLElement | null,\n options: UseSceneTextEditOptions<TData> = {},\n): UseSceneTextEditReturn {\n // Refs, so the useTextEdit callbacks always see the current values\n // without re-binding.\n const sceneRef = useLatest(scene);\n const optsRef = useLatest(options);\n const containerRef = useLatest(container);\n // The last double-click's target, so the overlay projects through the\n // canvas the edit was opened on.\n const openedOnRef = useRef<EventTarget | null>(null);\n const drafts = useMemo(() => sceneTextDrafts(sceneRef, optsRef), [sceneRef, optsRef]);\n useEffect(() => () => drafts.close(), [drafts]);\n\n // The canvas's box in container pixels is both the origin its view projects\n // from and the region its glyphs are clipped to.\n const surface = (): { canvas: MountedCanvas; box: TextEditClipRect } | null => {\n const root = containerRef.current;\n const canvas = root ? findMountedCanvas(root, openedOnRef.current) : null;\n if (!root || !canvas) return null;\n const a = canvas.element.getBoundingClientRect();\n const b = root.getBoundingClientRect();\n return {\n canvas,\n box: {\n x: a.left - b.left - root.clientLeft + root.scrollLeft,\n y: a.top - b.top - root.clientTop + root.scrollTop,\n width: a.width,\n height: a.height,\n },\n };\n };\n\n const read = useMemo(() => projectionsOf(optsRef), [optsRef]);\n\n const edit = useTextEdit({\n container,\n isEditorChrome: (el) => optsRef.current.isEditorChrome?.(el) ?? false,\n escape: options.escape,\n fontHold: options.fontHold,\n onStart: (id) => optsRef.current.onStart?.(id),\n onEnd: (id) => {\n drafts.end(id);\n optsRef.current.onEnd?.(id);\n },\n onDraft: drafts.draft,\n getText: (id) => {\n const node = sceneRef.current.get(asNodeId(id));\n return node ? read.text(node.data) : '';\n },\n getStyle: (id) => {\n const node = sceneRef.current.get(asNodeId(id));\n return node ? read.style(node.data) : undefined;\n },\n getPaint: (id) => {\n const node = sceneRef.current.get(asNodeId(id));\n if (!node) return undefined;\n const get = optsRef.current.getPaint;\n return get ? get(node.data) : shownTextPaint(sceneRef.current, node);\n },\n getScreenPose: (id) => {\n const scene = sceneRef.current;\n const node = scene.get(asNodeId(id));\n if (!node) return null;\n const at = surface();\n const view = resolveView(optsRef.current.view) ?? at?.canvas.getView();\n return screenPoseOf(node.data, scenePaintedPoseOf(scene)(node), read, view, at?.box, optsRef.current.defaultFontSize);\n },\n getClipRect: () => surface()?.box ?? null,\n getRuns: (id) => {\n const node = sceneRef.current.get(asNodeId(id));\n return node ? read.runs(node.data) : undefined;\n },\n commit: (id, { text, runs, style }) => {\n const nid = asNodeId(id);\n const node = sceneRef.current.get(nid);\n if (!node) return;\n drafts.commit(id, editedData(node.data, { text, runs, style }, optsRef.current));\n },\n });\n\n const onDoubleClick = useCallback((e: TextEditDoubleClick) => {\n // The dblclick bubbles from the canvas up to any wrapping container; the\n // element it started on is what the click coordinates are relative to.\n const canvas = e.target instanceof HTMLElement ? e.target : null;\n if (!canvas) return;\n // Inside the editor a double-click selects a word, and its target is a run\n // span whose box the click would be measured against as if it were a canvas.\n if (canvas.closest('[contenteditable=\"true\"]')) return;\n\n // The click is un-projected here; `textHitAt` takes it back through the\n // frame the node is drawn in.\n const [canvasX, canvasY] = clientToCanvas(canvas, e.clientX, e.clientY);\n const root = containerRef.current;\n const view = resolveView(optsRef.current.view)\n ?? (root ? findMountedCanvas(root, canvas)?.getView() : undefined);\n const at = view ? viewScreenToWorld(view, { x: canvasX, y: canvasY }) : { x: canvasX, y: canvasY };\n const hit = textHitAt(sceneRef.current, read, at.x, at.y);\n if (!hit) return;\n openedOnRef.current = canvas;\n edit.startEdit(hit.id, { caret: hit.caret });\n }, [edit, containerRef, optsRef, read, sceneRef]);\n\n const isTextNode = useCallback((id: string): boolean => {\n const node = sceneRef.current.get(asNodeId(id));\n return node !== undefined && read.isText(node.data);\n }, [read, sceneRef]);\n\n const drawOne = useMemo(() => drawOneEditing(edit.editingId), [edit.editingId]);\n return { ...edit, onDoubleClick, isTextNode, drawOne };\n}\n\n/** The overlay's pose for a node painted at `painted`: its text box, inset\n * about the box center, which is all the overlay's frame needs, projected\n * through `view`. */\nfunction screenPoseOf<TData extends DefaultTextData>(\n d: TData,\n painted: RectPose,\n read: ReturnType<typeof projectionsOf<TData>>,\n view: View | undefined,\n origin: TextEditClipRect | undefined,\n defaultFontSize = 16,\n): TextEditScreenPose {\n const inset = textBoxOf({ padding: read.padding(d) }, painted);\n const box = screenBoxOf({ ...painted, ...inset }, view, origin);\n return {\n ...box,\n // Against `zoom`, the same factor the overlay scales back up by, so a\n // `{ px }` size arrives on screen at the pixel count it names.\n fontSize: resolveScreenLength(read.style(d)?.fontSize ?? defaultFontSize, box.zoom),\n verticalAlign: read.verticalAlign(d),\n ...(read.overflow(d) === 'clip' ? { clip: clipToBox(inset, painted) } : {}),\n };\n}\n\n/** How far `box` reaches past `inner` on each side: the overlay's cut at\n * `box` when its own pose is `inner`. */\nfunction clipToBox(inner: RectPose, box: RectPose): NonNullable<TextEditScreenPose['clip']> {\n return {\n top: inner.y - box.y,\n left: inner.x - box.x,\n bottom: box.y + box.height - (inner.y + inner.height),\n right: box.x + box.width - (inner.x + inner.width),\n };\n}\n\n/**\n * `pose`'s box as the overlay takes it, `origin` being the canvas's corner in\n * container pixels. Only the box center is projected, since the overlay's\n * frame is about it. Width and height stay in world units and reach the\n * screen through the overlay's own `scale(zoom)` — see\n * `TextEditScreenPose.zoom`.\n */\nfunction screenBoxOf(\n pose: RectPose,\n view: View | undefined,\n origin: { x: number; y: number } | undefined,\n): Pick<TextEditScreenPose, 'x' | 'y' | 'width' | 'height' | 'frame'> & { zoom: number } {\n const zoom = view ? view.scale.x : 1;\n const zoomY = view ? view.scale.y : 1;\n const cx = (origin?.x ?? 0) + (pose.x + pose.width / 2 - (view?.x ?? 0)) * zoom;\n const cy = (origin?.y ?? 0) + (pose.y + pose.height / 2 - (view?.y ?? 0)) * zoomY;\n return {\n x: cx - (pose.width * zoom) / 2,\n y: cy - (pose.height * zoom) / 2,\n width: pose.width,\n height: pose.height,\n zoom,\n frame: frameLinearOf(pose, zoomY / zoom),\n };\n}\n\n/** The linear part of the frame `pose` is drawn in, as the overlay takes it.\n * `stretch` is the view's y scale over its x scale: the overlay scales both\n * axes by the x scale, so the frame carries what is left of y. */\nfunction frameLinearOf(pose: unknown, stretch: number): TextEditScreenPose['frame'] {\n const m = poseFrameOf(pose);\n if (!m && stretch === 1) return undefined;\n const [a, b, c, d] = m ?? [1, 0, 0, 1];\n return [a, b * stretch, c, d * stretch];\n}\n\n/** A text node's paint as the canvas paints it: an inherited one is its container's. */\nfunction shownTextPaint<TData extends DefaultTextData>(\n scene: Scene<TData, string, unknown>,\n node: NonNullable<ReturnType<Scene<TData, string, unknown>['get']>>,\n): TextPaint {\n const { fill, stroke } = withInheritedPaint(node.data, inheritedPaintIn(scene, node));\n return { fill, stroke };\n}\n"],"mappings":"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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fitViewToBounds } from "../../core/viewport/fitViewToBounds.js";
|
|
2
2
|
import { normalizeView } from "../../core/viewport/view.js";
|
|
3
3
|
import { ZOOM_FLOOR } from "../../core/viewport/zoomBounds.js";
|
|
4
|
+
import { viewScreenToWorld, viewWorldToScreen } from "@weasel-js/geom";
|
|
4
5
|
|
|
5
6
|
// src/domains/viewports/aim.ts
|
|
6
7
|
/** The aim of view `viewId` (`null`: a surface's root) showing world rect `rect`. */
|
|
@@ -61,14 +62,8 @@ function aimSpace2d(viewId, view, size) {
|
|
|
61
62
|
width: size.width,
|
|
62
63
|
height: size.height
|
|
63
64
|
},
|
|
64
|
-
project: (p) => (
|
|
65
|
-
|
|
66
|
-
y: (p.y - view.y) * view.scale.y
|
|
67
|
-
}),
|
|
68
|
-
unproject: (px) => ({
|
|
69
|
-
x: px.x / view.scale.x + view.x,
|
|
70
|
-
y: px.y / view.scale.y + view.y
|
|
71
|
-
})
|
|
65
|
+
project: (p) => viewWorldToScreen(view, p),
|
|
66
|
+
unproject: (px) => viewScreenToWorld(view, px)
|
|
72
67
|
};
|
|
73
68
|
}
|
|
74
69
|
var UNUSED_CURRENT = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"aim.js","names":[],"sources":["../../../src/domains/viewports/aim.ts"],"sourcesContent":["import { fitViewToBounds, type Bounds } from 'core/viewport/fitViewToBounds';\nimport type { Dims } from 'core/layers/render';\nimport { normalizeView, type View } from 'core/viewport/view';\nimport { ZOOM_FLOOR } from 'core/viewport/zoomBounds';\n\n/** A point in a view's world. A 2D world is the plane z = 0, so `z` is absent there. */\nexport interface AimPoint {\n x: number;\n y: number;\n z?: number;\n}\n\n/** A closed outline in a frame's CSS px, from the frame's top-left; at least 3 points. */\nexport type AimOutline = readonly { x: number; y: number }[];\n\n/** A target view, as an aim is drawn into it: its world, and its frame. */\nexport interface AimSpace {\n /** The view whose frame this is; `null` for a surface's root view. */\n readonly viewId: string | null;\n /** `'2d'` for core's views; any other value is its owner's (kernel3d: `'3d'`). */\n readonly kind: string;\n readonly size: { width: number; height: number };\n /** World point to frame px; `null` where the point does not draw (behind a 3D camera). */\n project(p: AimPoint): { x: number; y: number } | null;\n /** Frame px to the world point under it; `null` where there is none (a 3D ray missing its plane). */\n unproject(px: { x: number; y: number }): AimPoint | null;\n}\n\n/** What a viewport shows, read in the world of the view it names. */\nexport interface ViewAim<TExtent = unknown> {\n readonly viewId: string | null;\n /** Discriminates `extent`: `'rect'` for core's 2D views. */\n readonly kind: string;\n /** Where the aim lies in its world, in its owner's own terms. */\n readonly extent: TExtent;\n /** The aim's outline in `space`'s frame, or `null` when it cannot be drawn there. */\n outlineIn(space: AimSpace): AimOutline | null;\n /** This aim moved so the world point under frame px `from` in `space` ends up under `to`; `null` when either misses. */\n pannedIn(space: AimSpace, from: { x: number; y: number }, to: { x: number; y: number }): ViewAim<TExtent> | null;\n}\n\n/** Core's 2D aim: a world rect a view shows. */\nexport type RectAim = ViewAim<Bounds> & { readonly kind: 'rect' };\n\n/** The aim of view `viewId` (`null`: a surface's root) showing world rect `rect`. */\nexport function rectAim(viewId: string | null, rect: Bounds): RectAim {\n return {\n viewId,\n kind: 'rect',\n extent: rect,\n outlineIn(space) {\n const { x, y, width, height } = rect;\n const out: { x: number; y: number }[] = [];\n for (const corner of [\n { x, y },\n { x: x + width, y },\n { x: x + width, y: y + height },\n { x, y: y + height },\n ]) {\n const p = space.project(corner);\n if (!p) return null;\n out.push(p);\n }\n return out;\n },\n pannedIn(space, from, to) {\n const a = space.unproject(from);\n const b = space.unproject(to);\n if (!a || !b) return null;\n return rectAim(viewId, { ...rect, x: rect.x + (b.x - a.x), y: rect.y + (b.y - a.y) });\n },\n };\n}\n\n/** Whether `aim` is core's 2D rect aim, so its `extent` is a world rect. */\nexport function isRectAim(aim: ViewAim): aim is RectAim {\n return aim.kind === 'rect';\n}\n\n/** A 2D view's frame: `view` is its camera and `size` its frame; frame px are measured from the frame's top-left. */\nexport function aimSpace2d(viewId: string | null, view: View, size: Dims): AimSpace {\n return {\n viewId,\n kind: '2d',\n size: { width: size.width, height: size.height },\n project: (p) => (
|
|
1
|
+
{"version":3,"file":"aim.js","names":[],"sources":["../../../src/domains/viewports/aim.ts"],"sourcesContent":["import { fitViewToBounds, type Bounds } from 'core/viewport/fitViewToBounds';\nimport type { Dims } from 'core/layers/render';\nimport { normalizeView, type View } from 'core/viewport/view';\nimport { ZOOM_FLOOR } from 'core/viewport/zoomBounds';\nimport { viewScreenToWorld, viewWorldToScreen } from '@weasel-js/geom';\n\n/** A point in a view's world. A 2D world is the plane z = 0, so `z` is absent there. */\nexport interface AimPoint {\n x: number;\n y: number;\n z?: number;\n}\n\n/** A closed outline in a frame's CSS px, from the frame's top-left; at least 3 points. */\nexport type AimOutline = readonly { x: number; y: number }[];\n\n/** A target view, as an aim is drawn into it: its world, and its frame. */\nexport interface AimSpace {\n /** The view whose frame this is; `null` for a surface's root view. */\n readonly viewId: string | null;\n /** `'2d'` for core's views; any other value is its owner's (kernel3d: `'3d'`). */\n readonly kind: string;\n readonly size: { width: number; height: number };\n /** World point to frame px; `null` where the point does not draw (behind a 3D camera). */\n project(p: AimPoint): { x: number; y: number } | null;\n /** Frame px to the world point under it; `null` where there is none (a 3D ray missing its plane). */\n unproject(px: { x: number; y: number }): AimPoint | null;\n}\n\n/** What a viewport shows, read in the world of the view it names. */\nexport interface ViewAim<TExtent = unknown> {\n readonly viewId: string | null;\n /** Discriminates `extent`: `'rect'` for core's 2D views. */\n readonly kind: string;\n /** Where the aim lies in its world, in its owner's own terms. */\n readonly extent: TExtent;\n /** The aim's outline in `space`'s frame, or `null` when it cannot be drawn there. */\n outlineIn(space: AimSpace): AimOutline | null;\n /** This aim moved so the world point under frame px `from` in `space` ends up under `to`; `null` when either misses. */\n pannedIn(space: AimSpace, from: { x: number; y: number }, to: { x: number; y: number }): ViewAim<TExtent> | null;\n}\n\n/** Core's 2D aim: a world rect a view shows. */\nexport type RectAim = ViewAim<Bounds> & { readonly kind: 'rect' };\n\n/** The aim of view `viewId` (`null`: a surface's root) showing world rect `rect`. */\nexport function rectAim(viewId: string | null, rect: Bounds): RectAim {\n return {\n viewId,\n kind: 'rect',\n extent: rect,\n outlineIn(space) {\n const { x, y, width, height } = rect;\n const out: { x: number; y: number }[] = [];\n for (const corner of [\n { x, y },\n { x: x + width, y },\n { x: x + width, y: y + height },\n { x, y: y + height },\n ]) {\n const p = space.project(corner);\n if (!p) return null;\n out.push(p);\n }\n return out;\n },\n pannedIn(space, from, to) {\n const a = space.unproject(from);\n const b = space.unproject(to);\n if (!a || !b) return null;\n return rectAim(viewId, { ...rect, x: rect.x + (b.x - a.x), y: rect.y + (b.y - a.y) });\n },\n };\n}\n\n/** Whether `aim` is core's 2D rect aim, so its `extent` is a world rect. */\nexport function isRectAim(aim: ViewAim): aim is RectAim {\n return aim.kind === 'rect';\n}\n\n/** A 2D view's frame: `view` is its camera and `size` its frame; frame px are measured from the frame's top-left. */\nexport function aimSpace2d(viewId: string | null, view: View, size: Dims): AimSpace {\n return {\n viewId,\n kind: '2d',\n size: { width: size.width, height: size.height },\n project: (p) => viewWorldToScreen(view, p),\n unproject: (px) => viewScreenToWorld(view, px),\n };\n}\n\nconst UNUSED_CURRENT: View = { x: 0, y: 0, scale: { x: 1, y: 1 } };\n\n/**\n * The camera that shows `rect` in a frame of `size`: `'exact'` per axis (the inverse of `viewportWorldRect`),\n * `'contain'` uniform and centered. Not clamped to a zoom range, and finite for an empty rect or frame. Normalized.\n */\nexport function viewForAim(rect: Bounds, size: Dims, fit: 'exact' | 'contain'): View {\n if (!(rect.width > 0 && rect.height > 0 && size.width > 0 && size.height > 0)) {\n return normalizeView({ x: rect.x, y: rect.y, scale: { x: ZOOM_FLOOR, y: ZOOM_FLOOR } });\n }\n return normalizeView(fitViewToBounds(rect, size, UNUSED_CURRENT, {\n mode: fit === 'exact' ? 'stretch' : 'contain', padding: 0, minScale: 0, maxScale: Infinity,\n }));\n}\n"],"mappings":";;;;;;;AA8CA,SAAgB,QAAQ,QAAuB,MAAuB;CACpE,OAAO;EACL;EACA,MAAM;EACN,QAAQ;EACR,UAAU,OAAO;GACf,MAAM,EAAE,GAAG,GAAG,OAAO,WAAW;GAChC,MAAM,MAAkC,CAAC;GACzC,KAAK,MAAM,UAAU;IACnB;KAAE;KAAG;IAAE;IACP;KAAE,GAAG,IAAI;KAAO;IAAE;IAClB;KAAE,GAAG,IAAI;KAAO,GAAG,IAAI;IAAO;IAC9B;KAAE;KAAG,GAAG,IAAI;IAAO;GACrB,GAAG;IACD,MAAM,IAAI,MAAM,QAAQ,MAAM;IAC9B,IAAI,CAAC,GAAG,OAAO;IACf,IAAI,KAAK,CAAC;GACZ;GACA,OAAO;EACT;EACA,SAAS,OAAO,MAAM,IAAI;GACxB,MAAM,IAAI,MAAM,UAAU,IAAI;GAC9B,MAAM,IAAI,MAAM,UAAU,EAAE;GAC5B,IAAI,CAAC,KAAK,CAAC,GAAG,OAAO;GACrB,OAAO,QAAQ,QAAQ;IAAE,GAAG;IAAM,GAAG,KAAK,KAAK,EAAE,IAAI,EAAE;IAAI,GAAG,KAAK,KAAK,EAAE,IAAI,EAAE;GAAG,CAAC;EACtF;CACF;AACF;;AAGA,SAAgB,UAAU,KAA8B;CACtD,OAAO,IAAI,SAAS;AACtB;;AAGA,SAAgB,WAAW,QAAuB,MAAY,MAAsB;CAClF,OAAO;EACL;EACA,MAAM;EACN,MAAM;GAAE,OAAO,KAAK;GAAO,QAAQ,KAAK;EAAO;EAC/C,UAAU,MAAM,kBAAkB,MAAM,CAAC;EACzC,YAAY,OAAO,kBAAkB,MAAM,EAAE;CAC/C;AACF;AAEA,IAAM,iBAAuB;CAAE,GAAG;CAAG,GAAG;CAAG,OAAO;EAAE,GAAG;EAAG,GAAG;CAAE;AAAE;;;;;AAMjE,SAAgB,WAAW,MAAc,MAAY,KAAgC;CACnF,IAAI,EAAE,KAAK,QAAQ,KAAK,KAAK,SAAS,KAAK,KAAK,QAAQ,KAAK,KAAK,SAAS,IACzE,OAAO,cAAc;EAAE,GAAG,KAAK;EAAG,GAAG,KAAK;EAAG,OAAO;GAAE,GAAG;GAAY,GAAG;EAAW;CAAE,CAAC;CAExF,OAAO,cAAc,gBAAgB,MAAM,MAAM,gBAAgB;EAC/D,MAAM,QAAQ,UAAU,YAAY;EAAW,SAAS;EAAG,UAAU;EAAG,UAAU;CACpF,CAAC,CAAC;AACJ"}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { viewScreenToWorld } from "@weasel-js/geom";
|
|
2
|
+
|
|
1
3
|
// src/domains/viewports/viewResolver.ts
|
|
2
4
|
/** Right and bottom edges are exclusive, so neighbouring views never both
|
|
3
5
|
* claim a pixel. */
|
|
@@ -11,9 +13,9 @@ function createViewResolver(opts) {
|
|
|
11
13
|
const { views, root, toLocal, occluded } = opts;
|
|
12
14
|
const pinned = /* @__PURE__ */ new Map();
|
|
13
15
|
/** `at`, a canvas point `from` its surface's top-left, through `view`. */
|
|
14
|
-
const worldOf = (at, from, view) => ({
|
|
15
|
-
x:
|
|
16
|
-
y:
|
|
16
|
+
const worldOf = (at, from, view) => viewScreenToWorld(view, {
|
|
17
|
+
x: at.x - from.x,
|
|
18
|
+
y: at.y - from.y
|
|
17
19
|
});
|
|
18
20
|
function rootTarget(at) {
|
|
19
21
|
const view = root();
|