@rasamjs/tools 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/API.md +122 -2
  2. package/CHANGELOG.md +9 -1
  3. package/README.md +37 -0
  4. package/RELEASING.md +16 -3
  5. package/dist/advanced/context.d.ts +15 -0
  6. package/dist/advanced/context.d.ts.map +1 -0
  7. package/dist/advanced/context.js +2 -0
  8. package/dist/advanced/context.js.map +1 -0
  9. package/dist/advanced/curves.d.ts +5 -0
  10. package/dist/advanced/curves.d.ts.map +1 -0
  11. package/dist/advanced/curves.js +65 -0
  12. package/dist/advanced/curves.js.map +1 -0
  13. package/dist/advanced/geometry.d.ts +17 -0
  14. package/dist/advanced/geometry.d.ts.map +1 -0
  15. package/dist/advanced/geometry.js +61 -0
  16. package/dist/advanced/geometry.js.map +1 -0
  17. package/dist/advanced/rotation.d.ts +4 -0
  18. package/dist/advanced/rotation.d.ts.map +1 -0
  19. package/dist/advanced/rotation.js +86 -0
  20. package/dist/advanced/rotation.js.map +1 -0
  21. package/dist/advanced/session.d.ts +5 -0
  22. package/dist/advanced/session.d.ts.map +1 -0
  23. package/dist/advanced/session.js +240 -0
  24. package/dist/advanced/session.js.map +1 -0
  25. package/dist/advanced/snapper.d.ts +29 -0
  26. package/dist/advanced/snapper.d.ts.map +1 -0
  27. package/dist/advanced/snapper.js +118 -0
  28. package/dist/advanced/snapper.js.map +1 -0
  29. package/dist/advanced/types.d.ts +30 -0
  30. package/dist/advanced/types.d.ts.map +1 -0
  31. package/dist/advanced/types.js +2 -0
  32. package/dist/advanced/types.js.map +1 -0
  33. package/dist/drawing.d.ts +5 -0
  34. package/dist/drawing.d.ts.map +1 -0
  35. package/dist/drawing.js +3 -0
  36. package/dist/drawing.js.map +1 -0
  37. package/dist/editor/draw-tool.d.ts +35 -0
  38. package/dist/editor/draw-tool.d.ts.map +1 -0
  39. package/dist/editor/draw-tool.js +174 -0
  40. package/dist/editor/draw-tool.js.map +1 -0
  41. package/dist/editor/path-editor.d.ts +34 -0
  42. package/dist/editor/path-editor.d.ts.map +1 -0
  43. package/dist/editor/path-editor.js +208 -0
  44. package/dist/editor/path-editor.js.map +1 -0
  45. package/dist/editor/pointer.d.ts +21 -0
  46. package/dist/editor/pointer.d.ts.map +1 -0
  47. package/dist/editor/pointer.js +86 -0
  48. package/dist/editor/pointer.js.map +1 -0
  49. package/dist/editor/surface.d.ts +31 -0
  50. package/dist/editor/surface.d.ts.map +1 -0
  51. package/dist/editor/surface.js +138 -0
  52. package/dist/editor/surface.js.map +1 -0
  53. package/dist/grid.d.ts +23 -0
  54. package/dist/grid.d.ts.map +1 -0
  55. package/dist/grid.js +84 -0
  56. package/dist/grid.js.map +1 -0
  57. package/dist/rulers.d.ts +30 -0
  58. package/dist/rulers.d.ts.map +1 -0
  59. package/dist/rulers.js +234 -0
  60. package/dist/rulers.js.map +1 -0
  61. package/dist/snapping/advanced.d.ts +3 -0
  62. package/dist/snapping/advanced.d.ts.map +1 -0
  63. package/dist/snapping/advanced.js +2 -0
  64. package/dist/snapping/advanced.js.map +1 -0
  65. package/dist/snapping/targets.d.ts +1 -0
  66. package/dist/snapping/targets.d.ts.map +1 -1
  67. package/dist/snapping/targets.js +5 -1
  68. package/dist/snapping/targets.js.map +1 -1
  69. package/package.json +24 -4
  70. package/src/advanced/context.ts +15 -0
  71. package/src/advanced/curves.ts +65 -0
  72. package/src/advanced/geometry.ts +74 -0
  73. package/src/advanced/rotation.ts +93 -0
  74. package/src/advanced/session.ts +276 -0
  75. package/src/advanced/snapper.ts +124 -0
  76. package/src/advanced/types.ts +31 -0
  77. package/src/drawing.ts +4 -0
  78. package/src/editor/draw-tool.ts +190 -0
  79. package/src/editor/path-editor.ts +224 -0
  80. package/src/editor/pointer.ts +85 -0
  81. package/src/editor/surface.ts +141 -0
  82. package/src/grid.ts +99 -0
  83. package/src/rulers.ts +245 -0
  84. package/src/snapping/advanced.ts +8 -0
  85. package/src/snapping/targets.ts +11 -2
@@ -0,0 +1,124 @@
1
+ import type { Svg } from '@rasamjs/core';
2
+ import { Guards } from '@rasamjs/core/advanced';
3
+ import { resolveOptions } from '../snapping/options.js';
4
+ import type { ResolvedOptions } from '../snapping/options.js';
5
+ import { GuideOverlay } from '../snapping/overlay.js';
6
+ import type { SnapSession } from '../snapping/types.js';
7
+ import type {
8
+ AdvancedSnapperOptions,
9
+ AdvancedSnapRequest,
10
+ RotationSnapRequest,
11
+ RotationSnapSession,
12
+ } from './types.js';
13
+ import type { SessionContext } from './context.js';
14
+ import { createAdvancedSession } from './session.js';
15
+ import { createRotationSession } from './rotation.js';
16
+
17
+ /** Owns enhanced sessions while sharing the original engine's geometry and guides. */
18
+ export class AdvancedSnapper {
19
+ readonly rotatedResize = true;
20
+ readonly keyboard: boolean;
21
+ private readonly options: ResolvedOptions;
22
+ private readonly advanced: AdvancedSnapperOptions;
23
+ private readonly overlay: GuideOverlay;
24
+ private readonly dispose: () => void;
25
+ private active: SnapSession | null = null;
26
+ private version = 0;
27
+ private disposed = false;
28
+ private enabledState = true;
29
+ constructor(
30
+ private readonly drawing: Svg,
31
+ advanced: AdvancedSnapperOptions = {},
32
+ ) {
33
+ if (
34
+ !Guards.isTransformable(drawing) ||
35
+ drawing.isDestroyed ||
36
+ drawing.el.localName !== 'svg' ||
37
+ !drawing.el.isConnected ||
38
+ !drawing.el.ownerDocument.defaultView
39
+ )
40
+ throw new TypeError('AdvancedSnapper needs a live, mounted drawing');
41
+ this.advanced = { ...advanced };
42
+ this.options = resolveOptions(advanced);
43
+ this.keyboard = advanced.keyboard ?? true;
44
+ if (
45
+ typeof this.keyboard !== 'boolean' ||
46
+ (advanced.curves !== undefined && typeof advanced.curves !== 'boolean')
47
+ )
48
+ throw new TypeError('Keyboard and curve snapping options must be booleans');
49
+ if (advanced.guides !== undefined && typeof advanced.guides !== 'function')
50
+ throw new TypeError('guides must be a function');
51
+ for (const [name, value] of [
52
+ ['curveResolution', advanced.curveResolution ?? 0.25],
53
+ ['angleTolerance', advanced.angleTolerance ?? 5],
54
+ ] as const)
55
+ if (!Number.isFinite(value) || value <= 0)
56
+ throw new RangeError(`${name} must be positive and finite`);
57
+ this.overlay = new GuideOverlay(drawing, this.options.stroke, this.options.gapStroke);
58
+ this.dispose = drawing.onDispose(() => this.destroy());
59
+ }
60
+ get isDestroyed(): boolean {
61
+ return this.disposed;
62
+ }
63
+ get enabled(): boolean {
64
+ return this.enabledState;
65
+ }
66
+ set enabled(value: boolean) {
67
+ if (typeof value !== 'boolean') throw new TypeError('enabled must be a boolean');
68
+ this.enabledState = value;
69
+ if (!value) this.overlay.clear();
70
+ }
71
+ update(): this {
72
+ if (!this.disposed) this.overlay.update();
73
+ return this;
74
+ }
75
+ destroy(): void {
76
+ if (this.disposed) return;
77
+ this.disposed = true;
78
+ try {
79
+ this.active?.end();
80
+ } finally {
81
+ this.overlay.destroy();
82
+ this.dispose();
83
+ }
84
+ }
85
+ private reset(): number {
86
+ const version = ++this.version;
87
+ const previous = this.active;
88
+ this.active = null;
89
+ previous?.end();
90
+ return version;
91
+ }
92
+ private ended(version: number): void {
93
+ if (version !== this.version) return;
94
+ this.active = null;
95
+ this.overlay.clear();
96
+ }
97
+
98
+ begin(request: AdvancedSnapRequest): SnapSession | null {
99
+ const version = this.reset();
100
+ if (!this.enabled || this.disposed) return null;
101
+ const session = createAdvancedSession(this.context(request, version));
102
+ if (session) this.active = session;
103
+ return session;
104
+ }
105
+ rotate(request: RotationSnapRequest): RotationSnapSession | null {
106
+ const version = this.reset();
107
+ if (!this.enabled || this.disposed) return null;
108
+ const session = createRotationSession(this.context(request, version));
109
+ if (session) this.active = session;
110
+ return session;
111
+ }
112
+ private context<Request>(request: Request, version: number): SessionContext<Request> {
113
+ return {
114
+ drawing: this.drawing,
115
+ options: this.options,
116
+ advanced: this.advanced,
117
+ overlay: this.overlay,
118
+ request,
119
+ isEnabled: () => this.enabled && !this.disposed,
120
+ isCurrent: () => version === this.version && !this.disposed,
121
+ onEnd: () => this.ended(version),
122
+ };
123
+ }
124
+ }
@@ -0,0 +1,31 @@
1
+ import type { AnyNode, Point } from '@rasamjs/core';
2
+ import type { SnapperOptions, SnapRequest, SnapSession } from '../snapping/types.js';
3
+
4
+ export interface RulerGuide {
5
+ readonly axis: 'x' | 'y';
6
+ readonly position: number;
7
+ }
8
+ export interface AdvancedSnapperOptions extends SnapperOptions {
9
+ readonly guides?: () => Iterable<RulerGuide>;
10
+ readonly curves?: boolean;
11
+ readonly curveResolution?: number;
12
+ readonly angleTolerance?: number;
13
+ readonly keyboard?: boolean;
14
+ }
15
+ export interface RotationSnapRequest {
16
+ readonly nodes: readonly AnyNode[];
17
+ readonly client: Point;
18
+ readonly pivot: Point;
19
+ readonly corners: readonly Point[];
20
+ }
21
+ export type AdvancedSnapRequest = (
22
+ | SnapRequest
23
+ | {
24
+ readonly kind: 'point';
25
+ readonly client: Point;
26
+ readonly nodes?: readonly AnyNode[];
27
+ }
28
+ ) & { readonly keyboard?: boolean };
29
+ export interface RotationSnapSession extends SnapSession {
30
+ readonly angleSnapped: boolean;
31
+ }
package/src/drawing.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { DrawTool } from './editor/draw-tool.js';
2
+ export type { DrawToolOptions, DrawingKind, PointSnapper } from './editor/draw-tool.js';
3
+ export { PathEditor } from './editor/path-editor.js';
4
+ export type { PathEditorOptions, EditablePath, PathHandle } from './editor/path-editor.js';
@@ -0,0 +1,190 @@
1
+ import type { AnyNode, Point, Svg } from '@rasamjs/core';
2
+ import { beginChangeGroup } from '@rasamjs/core/changes';
3
+ import type { SnapSession } from '../snapping/types.js';
4
+ import { assertPoint } from '../snapping/options.js';
5
+ import { Surface, svg } from './surface.js';
6
+ import { PointerDrag } from './pointer.js';
7
+
8
+ export interface PointSnapper {
9
+ begin(request: {
10
+ readonly kind: 'point';
11
+ readonly client: Point;
12
+ readonly nodes?: readonly AnyNode[];
13
+ }): SnapSession | null;
14
+ }
15
+ export type DrawingKind = 'rect' | 'ellipse' | 'line' | 'path';
16
+ export interface DrawToolOptions {
17
+ readonly snapper: PointSnapper;
18
+ readonly kind?: DrawingKind;
19
+ readonly paint?: Readonly<Record<string, string | number>>;
20
+ readonly onCreate?: (node: AnyNode) => void;
21
+ }
22
+ export class DrawTool {
23
+ private readonly surface: Surface;
24
+ private readonly hit: SVGRectElement;
25
+ private enabledState = true;
26
+ private active: {
27
+ start: Point;
28
+ session: SnapSession | null;
29
+ attributes: Record<string, string | number>;
30
+ preview: SVGElement;
31
+ } | null = null;
32
+ constructor(
33
+ private readonly drawing: Svg,
34
+ private readonly options: DrawToolOptions,
35
+ ) {
36
+ if (!options?.snapper || typeof options.snapper.begin !== 'function')
37
+ throw new TypeError('Drawing needs a point snapper');
38
+ if (!['rect', 'ellipse', 'line', 'path'].includes(options.kind ?? 'rect'))
39
+ throw new TypeError('Unknown drawing tool');
40
+ if (options.onCreate !== undefined && typeof options.onCreate !== 'function')
41
+ throw new TypeError('onCreate must be a function');
42
+ this.options = { ...options, paint: { ...options.paint } };
43
+ this.surface = new Surface(drawing, 'rasam-draw-tool', () => this.render());
44
+ this.surface.element.style.zIndex = '10';
45
+ this.hit = svg(drawing.el.ownerDocument, 'rect', {
46
+ width: '100%',
47
+ height: '100%',
48
+ fill: 'transparent',
49
+ });
50
+ this.hit.style.pointerEvents = 'all';
51
+ this.hit.style.cursor = 'crosshair';
52
+ this.surface.element.append(this.hit);
53
+ new PointerDrag(this.surface, {
54
+ start: (event) => {
55
+ if (!this.enabled) return false;
56
+ this.begin({ x: event.clientX, y: event.clientY }, event.ctrlKey || event.metaKey);
57
+ return true;
58
+ },
59
+ move: (event) =>
60
+ this.move({ x: event.clientX, y: event.clientY }, event.ctrlKey || event.metaKey),
61
+ end: (cancelled, event) => {
62
+ if (event && !cancelled)
63
+ this.move({ x: event.clientX, y: event.clientY }, event.ctrlKey || event.metaKey);
64
+ this.end(cancelled);
65
+ },
66
+ });
67
+ this.surface.cleanup(() => this.end(true));
68
+ }
69
+ get enabled(): boolean {
70
+ return this.enabledState;
71
+ }
72
+ get isDestroyed(): boolean {
73
+ return this.surface.isDestroyed;
74
+ }
75
+ set enabled(value: boolean) {
76
+ if (typeof value !== 'boolean') throw new TypeError('enabled must be boolean');
77
+ this.enabledState = value;
78
+ if (!value) this.end(true);
79
+ this.render();
80
+ }
81
+ begin(client: Point, ignore = false): void {
82
+ if (this.isDestroyed) throw new Error('Drawing tool is destroyed');
83
+ this.end(true);
84
+ assertPoint(client);
85
+ const session = this.options.snapper.begin({ kind: 'point', client });
86
+ let start: Point | null;
87
+ try {
88
+ start = this.drawing.fromClient(session?.snap(client, { ignore }) ?? client);
89
+ if (!start) throw new Error('Drawing mapping is unavailable');
90
+ } catch (error) {
91
+ session?.end();
92
+ throw error;
93
+ }
94
+ const kind = this.options.kind ?? 'rect';
95
+ const preview = svg(this.drawing.el.ownerDocument, kind);
96
+ preview.style.pointerEvents = 'none';
97
+ this.surface.element.append(preview);
98
+ this.active = { start, session, preview, attributes: {} };
99
+ this.move(client, ignore);
100
+ }
101
+ move(client: Point, ignore = false): void {
102
+ assertPoint(client);
103
+ const active = this.active;
104
+ if (!active) return;
105
+ try {
106
+ const end = this.drawing.fromClient(active.session?.snap(client, { ignore }) ?? client);
107
+ if (!end) return;
108
+ const { start } = active,
109
+ x = Math.min(start.x, end.x),
110
+ y = Math.min(start.y, end.y);
111
+ const width = Math.abs(end.x - start.x),
112
+ height = Math.abs(end.y - start.y);
113
+ const paint = { fill: '#8bbbf4', stroke: '#2563a6', ...(this.options.paint ?? {}) };
114
+ const kind = this.options.kind ?? 'rect';
115
+ active.attributes =
116
+ kind === 'ellipse'
117
+ ? { ...paint, cx: x + width / 2, cy: y + height / 2, rx: width / 2, ry: height / 2 }
118
+ : kind === 'line'
119
+ ? { ...paint, x1: start.x, y1: start.y, x2: end.x, y2: end.y }
120
+ : kind === 'path'
121
+ ? { ...paint, d: `M${start.x} ${start.y} L${end.x} ${end.y}` }
122
+ : { ...paint, x, y, width, height };
123
+ for (const [key, value] of Object.entries(active.attributes))
124
+ active.preview.setAttribute(key, String(value));
125
+ this.update();
126
+ } catch (error) {
127
+ this.end(true);
128
+ throw error;
129
+ }
130
+ }
131
+ end(cancelled = false): AnyNode | null {
132
+ const active = this.active;
133
+ this.active = null;
134
+ if (!active) return null;
135
+ try {
136
+ active.preview.remove();
137
+ if (cancelled) return null;
138
+ const group = beginChangeGroup(this.drawing, { source: 'drawing-tool' });
139
+ let node: AnyNode;
140
+ try {
141
+ node = group.run(() => {
142
+ switch (this.options.kind ?? 'rect') {
143
+ case 'ellipse':
144
+ return this.drawing.ellipse({
145
+ ...active.attributes,
146
+ rx: Number(active.attributes.rx),
147
+ ry: Number(active.attributes.ry),
148
+ });
149
+ case 'line':
150
+ return this.drawing.line({
151
+ ...active.attributes,
152
+ x2: Number(active.attributes.x2),
153
+ y2: Number(active.attributes.y2),
154
+ });
155
+ case 'path':
156
+ return this.drawing.path({ ...active.attributes, d: String(active.attributes.d) });
157
+ default:
158
+ return this.drawing.rect(active.attributes);
159
+ }
160
+ });
161
+ } finally {
162
+ group.end();
163
+ }
164
+ this.options.onCreate?.(node);
165
+ return node;
166
+ } finally {
167
+ active.session?.end();
168
+ }
169
+ }
170
+
171
+ update(): this {
172
+ this.surface.update();
173
+ return this;
174
+ }
175
+ destroy(): void {
176
+ this.surface.destroy();
177
+ }
178
+ private render(): void {
179
+ this.surface.element.style.display = this.enabled ? '' : 'none';
180
+ if (!this.active) return;
181
+ const origin = this.surface.fromRoot({ x: 0, y: 0 }),
182
+ u = this.surface.fromRoot({ x: 1, y: 0 }),
183
+ v = this.surface.fromRoot({ x: 0, y: 1 });
184
+ if (origin && u && v)
185
+ this.active.preview.setAttribute(
186
+ 'transform',
187
+ `matrix(${u.x - origin.x} ${u.y - origin.y} ${v.x - origin.x} ${v.y - origin.y} ${origin.x} ${origin.y})`,
188
+ );
189
+ }
190
+ }
@@ -0,0 +1,224 @@
1
+ import { Path, PathArray, PointArray } from '@rasamjs/core';
2
+ import type { Point, Polygon, Polyline, Svg, PathCommand } from '@rasamjs/core';
3
+ import { beginChangeGroup } from '@rasamjs/core/changes';
4
+ import type { SnapSession } from '../snapping/types.js';
5
+ import { assertPoint } from '../snapping/options.js';
6
+ import type { PointSnapper } from './draw-tool.js';
7
+ import { Surface, svg } from './surface.js';
8
+ import { PointerDrag } from './pointer.js';
9
+
10
+ export type EditablePath = Path | Polyline | Polygon;
11
+ export interface PathHandle {
12
+ readonly point: Point;
13
+ readonly command: number;
14
+ readonly coordinate: number;
15
+ readonly control: boolean;
16
+ }
17
+ export interface PathEditorOptions {
18
+ readonly snapper: PointSnapper;
19
+ readonly stroke?: string;
20
+ }
21
+ export class PathEditor {
22
+ private readonly surface: Surface;
23
+ private node: EditablePath | null = null;
24
+ private observe: MutationObserver | null = null;
25
+ private stopNode: (() => void) | null = null;
26
+ private active: {
27
+ handle: PathHandle;
28
+ before: PathCommand[] | Point[];
29
+ serialized: string;
30
+ session: SnapSession | null;
31
+ group: ReturnType<typeof beginChangeGroup>;
32
+ } | null = null;
33
+ constructor(
34
+ private readonly drawing: Svg,
35
+ private readonly options: PathEditorOptions,
36
+ ) {
37
+ if (!options?.snapper || typeof options.snapper.begin !== 'function')
38
+ throw new TypeError('Path editing needs a point snapper');
39
+ if (
40
+ options.stroke !== undefined &&
41
+ (typeof options.stroke !== 'string' || !options.stroke.trim())
42
+ )
43
+ throw new TypeError('Path stroke must be nonempty');
44
+ this.options = { ...options };
45
+ this.surface = new Surface(drawing, 'rasam-path-editor', () => this.render());
46
+ this.surface.element.style.zIndex = '20';
47
+ new PointerDrag(this.surface, {
48
+ start: (event) => {
49
+ const handle = (event.target as Element).closest('[data-path-handle]');
50
+ if (!handle) return false;
51
+ this.begin(Number(handle.getAttribute('data-path-handle')));
52
+ return true;
53
+ },
54
+ move: (event) =>
55
+ this.move({ x: event.clientX, y: event.clientY }, event.ctrlKey || event.metaKey),
56
+ end: (cancelled, event) => {
57
+ if (event && !cancelled)
58
+ this.move({ x: event.clientX, y: event.clientY }, event.ctrlKey || event.metaKey);
59
+ this.end(cancelled);
60
+ },
61
+ });
62
+ this.surface.cleanup(() => {
63
+ this.end(true);
64
+ this.observe?.disconnect();
65
+ this.stopNode?.();
66
+ });
67
+ }
68
+ attach(node: EditablePath | null): this {
69
+ if (node && this.surface.isDestroyed) throw new Error('Path editor is destroyed');
70
+ this.end(true);
71
+ this.observe?.disconnect();
72
+ this.stopNode?.();
73
+ this.stopNode = null;
74
+ if (
75
+ node &&
76
+ (node.isDestroyed ||
77
+ node.context !== this.drawing.context ||
78
+ !this.drawing.el.contains(node.el) ||
79
+ !['path', 'polyline', 'polygon'].includes(node.el.localName))
80
+ )
81
+ throw new TypeError('Expected a live path in this drawing');
82
+ this.node = node;
83
+ if (node) {
84
+ this.observe = new this.surface.view.MutationObserver(this.surface.schedule);
85
+ this.observe.observe(this.drawing.el, {
86
+ attributes: true,
87
+ childList: true,
88
+ subtree: true,
89
+ });
90
+ this.stopNode = node.onDispose(() => this.attach(null));
91
+ }
92
+ return this.update();
93
+ }
94
+ get handles(): readonly PathHandle[] {
95
+ if (!this.node || this.node.isDestroyed) return [];
96
+ if (!(this.node instanceof Path))
97
+ return this.node
98
+ .array()
99
+ .toArray()
100
+ .map((point, command) => ({ point, command, coordinate: 0, control: false }));
101
+ return this.node
102
+ .array()
103
+ .toArray()
104
+ .flatMap((command, index) => {
105
+ const offsets =
106
+ command[0] === 'Z'
107
+ ? []
108
+ : command[0] === 'A'
109
+ ? [6]
110
+ : command[0] === 'C'
111
+ ? [1, 3, 5]
112
+ : command[0] === 'Q'
113
+ ? [1, 3]
114
+ : [1];
115
+ return offsets.map((coordinate) => ({
116
+ command: index,
117
+ coordinate,
118
+ point: { x: command[coordinate] as number, y: command[coordinate + 1] as number },
119
+ control:
120
+ coordinate !==
121
+ (command[0] === 'C' ? 5 : command[0] === 'Q' ? 3 : command[0] === 'A' ? 6 : 1),
122
+ }));
123
+ });
124
+ }
125
+ begin(index: number): void {
126
+ if (this.surface.isDestroyed) throw new Error('Path editor is destroyed');
127
+ this.end(true);
128
+ const node = this.node,
129
+ handle = this.handles[index];
130
+ if (!node || !handle) throw new RangeError('Unknown path handle');
131
+ const client = node.toClient(handle.point);
132
+ if (!client) throw new Error('Path mapping is unavailable');
133
+ const session = this.options.snapper.begin({ kind: 'point', client, nodes: [node] });
134
+ this.active = {
135
+ handle,
136
+ before: node.array().toArray(),
137
+ serialized: node instanceof Path ? String(node.d) : String(node.attr('points')),
138
+ session,
139
+ group: beginChangeGroup(node, { source: 'path-editor' }),
140
+ };
141
+ }
142
+ move(client: Point, ignore = false): void {
143
+ assertPoint(client);
144
+ const active = this.active,
145
+ node = this.node;
146
+ if (!active || !node) return;
147
+ try {
148
+ const point = node.fromClient(active.session?.snap(client, { ignore }) ?? client);
149
+ if (!point) return;
150
+ active.group.run(() => {
151
+ if (node instanceof Path) {
152
+ const commands = (active.before as PathCommand[]).map((command) => [...command]);
153
+ commands[active.handle.command][active.handle.coordinate] = point.x;
154
+ commands[active.handle.command][active.handle.coordinate + 1] = point.y;
155
+ node.plot(new PathArray(commands as unknown as PathCommand[]));
156
+ } else {
157
+ const points = (active.before as Point[]).map((point) => ({ ...point }));
158
+ points[active.handle.command] = point;
159
+ node.plot(new PointArray(points));
160
+ }
161
+ });
162
+ this.update();
163
+ } catch (error) {
164
+ this.end(true);
165
+ throw error;
166
+ }
167
+ }
168
+ end(cancelled = false): void {
169
+ const active = this.active;
170
+ this.active = null;
171
+ if (!active) return;
172
+ try {
173
+ const node = this.node;
174
+ if (cancelled && node && !node.isDestroyed)
175
+ active.group.run(() => {
176
+ if (node instanceof Path) node.attr('d', active.serialized);
177
+ else node.attr('points', active.serialized);
178
+ });
179
+ } finally {
180
+ try {
181
+ active.group.end(cancelled);
182
+ } finally {
183
+ active.session?.end();
184
+ }
185
+ }
186
+ this.update();
187
+ }
188
+ update(): this {
189
+ this.surface.update();
190
+ return this;
191
+ }
192
+ get isDestroyed(): boolean {
193
+ return this.surface.isDestroyed;
194
+ }
195
+ destroy(): void {
196
+ this.surface.destroy();
197
+ }
198
+ private render(): void {
199
+ const node = this.node,
200
+ document = this.drawing.el.ownerDocument;
201
+ this.surface.element.replaceChildren(
202
+ ...this.handles.flatMap((handle, index) => {
203
+ const client = node?.toClient(handle.point),
204
+ point = client && this.surface.fromClient(client);
205
+ if (!point) return [];
206
+ const circle = svg(document, 'circle', {
207
+ cx: point.x,
208
+ cy: point.y,
209
+ r: handle.control ? 4 : 5,
210
+ fill: handle.control ? '#fff' : '#3f82df',
211
+ stroke: this.options.stroke ?? '#215eb3',
212
+ 'vector-effect': 'non-scaling-stroke',
213
+ 'data-path-handle': index,
214
+ tabindex: 0,
215
+ role: 'button',
216
+ 'aria-label': `${handle.control ? 'Control' : 'Anchor'} point ${index + 1}`,
217
+ });
218
+ circle.style.pointerEvents = 'all';
219
+ circle.style.cursor = 'move';
220
+ return [circle];
221
+ }),
222
+ );
223
+ }
224
+ }
@@ -0,0 +1,85 @@
1
+ import type { Surface } from './surface.js';
2
+
3
+ interface Callbacks {
4
+ start(event: PointerEvent): boolean;
5
+ move(event: PointerEvent): void;
6
+ end(cancelled: boolean, event?: PointerEvent): void;
7
+ }
8
+ /** Capture and cancellation are shared by drawing, path handles and ruler drags. */
9
+ export class PointerDrag {
10
+ private pointer: number | null = null;
11
+ constructor(
12
+ private readonly surface: Surface,
13
+ private readonly callbacks: Callbacks,
14
+ ) {
15
+ surface.element.style.touchAction = 'none';
16
+ surface.listen(surface.element, 'pointerdown', this.down as EventListener);
17
+ surface.listen(surface.view, 'pointermove', this.move as EventListener);
18
+ surface.listen(surface.view, 'pointerup', this.up as EventListener);
19
+ surface.listen(surface.view, 'pointercancel', this.lost as EventListener);
20
+ surface.listen(surface.element, 'lostpointercapture', this.lost as EventListener);
21
+ surface.listen(surface.view, 'blur', () => this.cancel());
22
+ surface.listen(
23
+ surface.view,
24
+ 'keydown',
25
+ ((event: KeyboardEvent) => {
26
+ if (this.pointer !== null && event.key === 'Escape') {
27
+ event.preventDefault();
28
+ event.stopPropagation();
29
+ this.cancel();
30
+ }
31
+ }) as EventListener,
32
+ true,
33
+ );
34
+ surface.cleanup(() => this.cancel());
35
+ }
36
+ private readonly down = (event: PointerEvent): void => {
37
+ if (event.defaultPrevented || this.pointer !== null || event.button !== 0 || !event.isPrimary)
38
+ return;
39
+ try {
40
+ if (!this.callbacks.start(event)) return;
41
+ this.pointer = event.pointerId;
42
+ this.surface.element.setPointerCapture(event.pointerId);
43
+ event.preventDefault();
44
+ event.stopPropagation();
45
+ } catch (error) {
46
+ try {
47
+ if (this.pointer === null) this.callbacks.end(true);
48
+ else this.cancel();
49
+ } catch (cleanup) {
50
+ throw new AggregateError([error, cleanup], 'Pointer acquisition and recovery failed');
51
+ }
52
+ throw error;
53
+ }
54
+ };
55
+ private readonly move = (event: PointerEvent): void => {
56
+ if (event.pointerId !== this.pointer) return;
57
+ try {
58
+ this.callbacks.move(event);
59
+ } catch (error) {
60
+ this.cancel();
61
+ throw error;
62
+ }
63
+ };
64
+ private readonly up = (event: PointerEvent): void => {
65
+ if (event.pointerId !== this.pointer) return;
66
+ this.finish(false, event);
67
+ };
68
+ private readonly lost = (event: PointerEvent): void => {
69
+ if (event.pointerId === this.pointer) this.cancel();
70
+ };
71
+ cancel(): void {
72
+ this.finish(true);
73
+ }
74
+ private finish(cancelled: boolean, event?: PointerEvent): void {
75
+ const pointer = this.pointer;
76
+ if (pointer === null) return;
77
+ this.pointer = null;
78
+ try {
79
+ this.callbacks.end(cancelled, event);
80
+ } finally {
81
+ if (this.surface.element.hasPointerCapture?.(pointer))
82
+ this.surface.element.releasePointerCapture(pointer);
83
+ }
84
+ }
85
+ }