@rasamjs/tools 0.0.0-stage → 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 (131) hide show
  1. package/API.md +308 -0
  2. package/CHANGELOG.md +53 -0
  3. package/LICENSE +21 -0
  4. package/README.md +138 -2
  5. package/RELEASING.md +88 -0
  6. package/dist/advanced/context.d.ts +15 -0
  7. package/dist/advanced/context.d.ts.map +1 -0
  8. package/dist/advanced/context.js +2 -0
  9. package/dist/advanced/context.js.map +1 -0
  10. package/dist/advanced/curves.d.ts +5 -0
  11. package/dist/advanced/curves.d.ts.map +1 -0
  12. package/dist/advanced/curves.js +65 -0
  13. package/dist/advanced/curves.js.map +1 -0
  14. package/dist/advanced/geometry.d.ts +17 -0
  15. package/dist/advanced/geometry.d.ts.map +1 -0
  16. package/dist/advanced/geometry.js +61 -0
  17. package/dist/advanced/geometry.js.map +1 -0
  18. package/dist/advanced/rotation.d.ts +4 -0
  19. package/dist/advanced/rotation.d.ts.map +1 -0
  20. package/dist/advanced/rotation.js +86 -0
  21. package/dist/advanced/rotation.js.map +1 -0
  22. package/dist/advanced/session.d.ts +5 -0
  23. package/dist/advanced/session.d.ts.map +1 -0
  24. package/dist/advanced/session.js +240 -0
  25. package/dist/advanced/session.js.map +1 -0
  26. package/dist/advanced/snapper.d.ts +29 -0
  27. package/dist/advanced/snapper.d.ts.map +1 -0
  28. package/dist/advanced/snapper.js +118 -0
  29. package/dist/advanced/snapper.js.map +1 -0
  30. package/dist/advanced/types.d.ts +30 -0
  31. package/dist/advanced/types.d.ts.map +1 -0
  32. package/dist/advanced/types.js +2 -0
  33. package/dist/advanced/types.js.map +1 -0
  34. package/dist/drawing.d.ts +5 -0
  35. package/dist/drawing.d.ts.map +1 -0
  36. package/dist/drawing.js +3 -0
  37. package/dist/drawing.js.map +1 -0
  38. package/dist/editor/draw-tool.d.ts +35 -0
  39. package/dist/editor/draw-tool.d.ts.map +1 -0
  40. package/dist/editor/draw-tool.js +174 -0
  41. package/dist/editor/draw-tool.js.map +1 -0
  42. package/dist/editor/path-editor.d.ts +34 -0
  43. package/dist/editor/path-editor.d.ts.map +1 -0
  44. package/dist/editor/path-editor.js +208 -0
  45. package/dist/editor/path-editor.js.map +1 -0
  46. package/dist/editor/pointer.d.ts +21 -0
  47. package/dist/editor/pointer.d.ts.map +1 -0
  48. package/dist/editor/pointer.js +86 -0
  49. package/dist/editor/pointer.js.map +1 -0
  50. package/dist/editor/surface.d.ts +31 -0
  51. package/dist/editor/surface.d.ts.map +1 -0
  52. package/dist/editor/surface.js +138 -0
  53. package/dist/editor/surface.js.map +1 -0
  54. package/dist/grid.d.ts +23 -0
  55. package/dist/grid.d.ts.map +1 -0
  56. package/dist/grid.js +84 -0
  57. package/dist/grid.js.map +1 -0
  58. package/dist/rulers.d.ts +30 -0
  59. package/dist/rulers.d.ts.map +1 -0
  60. package/dist/rulers.js +234 -0
  61. package/dist/rulers.js.map +1 -0
  62. package/dist/snapping/advanced.d.ts +3 -0
  63. package/dist/snapping/advanced.d.ts.map +1 -0
  64. package/dist/snapping/advanced.js +2 -0
  65. package/dist/snapping/advanced.js.map +1 -0
  66. package/dist/snapping/guides.d.ts +14 -0
  67. package/dist/snapping/guides.d.ts.map +1 -0
  68. package/dist/snapping/guides.js +109 -0
  69. package/dist/snapping/guides.js.map +1 -0
  70. package/dist/snapping/lines.d.ts +43 -0
  71. package/dist/snapping/lines.d.ts.map +1 -0
  72. package/dist/snapping/lines.js +74 -0
  73. package/dist/snapping/lines.js.map +1 -0
  74. package/dist/snapping/options.d.ts +18 -0
  75. package/dist/snapping/options.d.ts.map +1 -0
  76. package/dist/snapping/options.js +44 -0
  77. package/dist/snapping/options.js.map +1 -0
  78. package/dist/snapping/overlay.d.ts +22 -0
  79. package/dist/snapping/overlay.d.ts.map +1 -0
  80. package/dist/snapping/overlay.js +165 -0
  81. package/dist/snapping/overlay.js.map +1 -0
  82. package/dist/snapping/session.d.ts +25 -0
  83. package/dist/snapping/session.d.ts.map +1 -0
  84. package/dist/snapping/session.js +89 -0
  85. package/dist/snapping/session.js.map +1 -0
  86. package/dist/snapping/snapper.d.ts +22 -0
  87. package/dist/snapping/snapper.d.ts.map +1 -0
  88. package/dist/snapping/snapper.js +140 -0
  89. package/dist/snapping/snapper.js.map +1 -0
  90. package/dist/snapping/spacing.d.ts +27 -0
  91. package/dist/snapping/spacing.d.ts.map +1 -0
  92. package/dist/snapping/spacing.js +127 -0
  93. package/dist/snapping/spacing.js.map +1 -0
  94. package/dist/snapping/targets.d.ts +7 -0
  95. package/dist/snapping/targets.d.ts.map +1 -0
  96. package/dist/snapping/targets.js +71 -0
  97. package/dist/snapping/targets.js.map +1 -0
  98. package/dist/snapping/types.d.ts +35 -0
  99. package/dist/snapping/types.d.ts.map +1 -0
  100. package/dist/snapping/types.js +2 -0
  101. package/dist/snapping/types.js.map +1 -0
  102. package/dist/snapping.d.ts +4 -0
  103. package/dist/snapping.d.ts.map +1 -0
  104. package/dist/snapping.js +3 -0
  105. package/dist/snapping.js.map +1 -0
  106. package/package.json +73 -4
  107. package/src/advanced/context.ts +15 -0
  108. package/src/advanced/curves.ts +65 -0
  109. package/src/advanced/geometry.ts +74 -0
  110. package/src/advanced/rotation.ts +93 -0
  111. package/src/advanced/session.ts +276 -0
  112. package/src/advanced/snapper.ts +124 -0
  113. package/src/advanced/types.ts +31 -0
  114. package/src/drawing.ts +4 -0
  115. package/src/editor/draw-tool.ts +190 -0
  116. package/src/editor/path-editor.ts +224 -0
  117. package/src/editor/pointer.ts +85 -0
  118. package/src/editor/surface.ts +141 -0
  119. package/src/grid.ts +99 -0
  120. package/src/rulers.ts +245 -0
  121. package/src/snapping/advanced.ts +8 -0
  122. package/src/snapping/guides.ts +132 -0
  123. package/src/snapping/lines.ts +108 -0
  124. package/src/snapping/options.ts +47 -0
  125. package/src/snapping/overlay.ts +186 -0
  126. package/src/snapping/session.ts +116 -0
  127. package/src/snapping/snapper.ts +147 -0
  128. package/src/snapping/spacing.ts +157 -0
  129. package/src/snapping/targets.ts +88 -0
  130. package/src/snapping/types.ts +27 -0
  131. package/src/snapping.ts +3 -0
@@ -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
+ }
@@ -0,0 +1,141 @@
1
+ import { Matrix } from '@rasamjs/core';
2
+ import type { Point, Svg } from '@rasamjs/core';
3
+ import { Guards } from '@rasamjs/core/advanced';
4
+
5
+ export function svg<Tag extends keyof SVGElementTagNameMap>(
6
+ document: Document,
7
+ tag: Tag,
8
+ attributes: Record<string, string | number> = {},
9
+ ): SVGElementTagNameMap[Tag] {
10
+ const element = document.createElementNS('http://www.w3.org/2000/svg', tag);
11
+ for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value));
12
+ return element;
13
+ }
14
+
15
+ /** Owns an editor-only SVG and every observer/listener registered by its tool. */
16
+ export class Surface {
17
+ readonly element: SVGSVGElement;
18
+ readonly view: Window & typeof globalThis;
19
+ private readonly cleanups: (() => void)[] = [];
20
+ private pending: number | null = null;
21
+ private disposed = false;
22
+ constructor(
23
+ readonly drawing: Svg,
24
+ name: string,
25
+ private readonly render: () => void,
26
+ ) {
27
+ if (
28
+ !Guards.isTransformable(drawing) ||
29
+ drawing.isDestroyed ||
30
+ drawing.el.localName !== 'svg' ||
31
+ !drawing.el.isConnected ||
32
+ !drawing.el.ownerDocument.defaultView
33
+ )
34
+ throw new TypeError('Editor overlay needs a live mounted drawing');
35
+ this.view = drawing.el.ownerDocument.defaultView;
36
+ this.element = svg(drawing.el.ownerDocument, 'svg', {
37
+ class: name,
38
+ width: '100%',
39
+ height: '100%',
40
+ 'aria-label': name.replaceAll('-', ' '),
41
+ });
42
+ Object.assign(this.element.style, {
43
+ position: 'absolute',
44
+ inset: '0',
45
+ overflow: 'visible',
46
+ pointerEvents: 'none',
47
+ });
48
+ let outer = drawing.el;
49
+ while (outer.ownerSVGElement) outer = outer.ownerSVGElement;
50
+ const host = outer.parentElement!;
51
+ host.append(this.element);
52
+ const observer = new this.view.MutationObserver(this.schedule);
53
+ for (
54
+ let ancestor: Element | null = drawing.el;
55
+ ancestor && ancestor !== host;
56
+ ancestor = ancestor.parentElement
57
+ )
58
+ observer.observe(ancestor, { attributes: true });
59
+ this.cleanup(() => observer.disconnect());
60
+ const resize = new this.view.ResizeObserver(this.schedule);
61
+ resize.observe(host);
62
+ this.cleanup(() => resize.disconnect());
63
+ this.listen(this.view, 'resize', this.schedule);
64
+ this.listen(this.view, 'scroll', this.schedule, true);
65
+ this.cleanup(drawing.onDispose(() => this.destroy()));
66
+ this.schedule();
67
+ }
68
+ cleanup(callback: () => void): void {
69
+ this.cleanups.push(callback);
70
+ }
71
+ get isDestroyed(): boolean {
72
+ return this.disposed;
73
+ }
74
+ listen(target: EventTarget, type: string, listener: EventListener, capture = false): void {
75
+ target.addEventListener(type, listener, capture);
76
+ this.cleanup(() => target.removeEventListener(type, listener, capture));
77
+ }
78
+ readonly schedule = (): void => {
79
+ if (!this.disposed && this.pending === null)
80
+ this.pending = this.view.requestAnimationFrame(() => {
81
+ this.pending = null;
82
+ this.update();
83
+ });
84
+ };
85
+ update(): this {
86
+ if (this.pending !== null) this.view.cancelAnimationFrame(this.pending);
87
+ this.pending = null;
88
+ if (!this.disposed) this.render();
89
+ return this;
90
+ }
91
+ screenMatrix(): Matrix | null {
92
+ const m = this.element.getScreenCTM?.();
93
+ return m ? new Matrix(m.a, m.b, m.c, m.d, m.e, m.f) : null;
94
+ }
95
+ fromClient(point: Point): Point | null {
96
+ return this.screenMatrix()?.inverse()?.apply(point) ?? null;
97
+ }
98
+ fromRoot(point: Point): Point | null {
99
+ const client = this.drawing.toClient(point);
100
+ return client && this.fromClient(client);
101
+ }
102
+ extent(): Point {
103
+ const rect = this.element.getBoundingClientRect();
104
+ return {
105
+ x: this.element.clientWidth || rect.width,
106
+ y: this.element.clientHeight || rect.height,
107
+ };
108
+ }
109
+ rootBounds(): { left: number; top: number; right: number; bottom: number } | null {
110
+ const matrix = this.screenMatrix(),
111
+ size = this.extent();
112
+ if (!matrix || !size.x || !size.y) return null;
113
+ const points = [
114
+ { x: 0, y: 0 },
115
+ { x: size.x, y: 0 },
116
+ { x: size.x, y: size.y },
117
+ { x: 0, y: size.y },
118
+ ].map((p) => this.drawing.fromClient(matrix.apply(p)));
119
+ if (points.some((p) => !p)) return null;
120
+ return {
121
+ left: Math.min(...points.map((p) => p!.x)),
122
+ right: Math.max(...points.map((p) => p!.x)),
123
+ top: Math.min(...points.map((p) => p!.y)),
124
+ bottom: Math.max(...points.map((p) => p!.y)),
125
+ };
126
+ }
127
+ destroy(): void {
128
+ if (this.disposed) return;
129
+ this.disposed = true;
130
+ if (this.pending !== null) this.view.cancelAnimationFrame(this.pending);
131
+ const failures: unknown[] = [];
132
+ for (const callback of this.cleanups.reverse())
133
+ try {
134
+ callback();
135
+ } catch (e) {
136
+ failures.push(e);
137
+ }
138
+ this.element.remove();
139
+ if (failures.length) throw new AggregateError(failures, 'Editor overlay cleanup failed');
140
+ }
141
+ }
package/src/grid.ts ADDED
@@ -0,0 +1,99 @@
1
+ import type { Point, Svg } from '@rasamjs/core';
2
+ import { assertPoint } from './snapping/options.js';
3
+ import { Surface, svg } from './editor/surface.js';
4
+
5
+ export interface GridOptions {
6
+ readonly size: number;
7
+ readonly origin?: Point;
8
+ readonly stroke?: string;
9
+ }
10
+ /** A camera-aware grid outside the exported SVG, with bounded rendering at extreme zoom. */
11
+ export class GridOverlay {
12
+ private readonly surface: Surface;
13
+ private readonly path: SVGPathElement;
14
+ private readonly origin: Point;
15
+ private enabledState = true;
16
+ constructor(
17
+ private readonly drawing: Svg,
18
+ private readonly options: GridOptions,
19
+ ) {
20
+ if (!Number.isFinite(options?.size) || options.size <= 0)
21
+ throw new RangeError('Grid size must be positive and finite');
22
+ this.options = { ...options };
23
+ this.origin = { ...(options.origin ?? { x: 0, y: 0 }) };
24
+ assertPoint(this.origin, 'Grid origin');
25
+ if (
26
+ options.stroke !== undefined &&
27
+ (typeof options.stroke !== 'string' || !options.stroke.trim())
28
+ )
29
+ throw new TypeError('Grid stroke must be nonempty');
30
+ this.surface = new Surface(drawing, 'rasam-grid', () => this.render());
31
+ this.surface.element.style.zIndex = '-1';
32
+ this.surface.element.setAttribute('aria-hidden', 'true');
33
+ this.path = svg(drawing.el.ownerDocument, 'path', {
34
+ fill: 'none',
35
+ stroke: options.stroke ?? '#d9dee8',
36
+ 'stroke-width': 1,
37
+ 'vector-effect': 'non-scaling-stroke',
38
+ });
39
+ this.surface.element.append(this.path);
40
+ this.update();
41
+ }
42
+ get enabled(): boolean {
43
+ return this.enabledState;
44
+ }
45
+ get isDestroyed(): boolean {
46
+ return this.surface.isDestroyed;
47
+ }
48
+ set enabled(value: boolean) {
49
+ if (typeof value !== 'boolean') throw new TypeError('enabled must be boolean');
50
+ this.enabledState = value;
51
+ this.update();
52
+ }
53
+ update(): this {
54
+ this.surface.update();
55
+ return this;
56
+ }
57
+ destroy(): void {
58
+ this.surface.destroy();
59
+ }
60
+ private render(): void {
61
+ this.surface.element.style.display = this.enabledState ? '' : 'none';
62
+ const bounds = this.surface.rootBounds();
63
+ if (!this.enabledState || !bounds) {
64
+ this.path.setAttribute('d', '');
65
+ return;
66
+ }
67
+ const a = this.drawing.toClient({ x: 0, y: 0 }),
68
+ b = this.drawing.toClient({ x: this.options.size, y: 0 });
69
+ const c = this.drawing.toClient({ x: 0, y: this.options.size });
70
+ if (!a || !b || !c) return;
71
+ let step = this.options.size;
72
+ const pixels = Math.min(Math.hypot(b.x - a.x, b.y - a.y), Math.hypot(c.x - a.x, c.y - a.y));
73
+ if (pixels <= 0) return;
74
+ while (
75
+ (step / this.options.size) * pixels < 8 ||
76
+ (bounds.right - bounds.left + bounds.bottom - bounds.top) / step > 512
77
+ )
78
+ step *= 2;
79
+ const segments: string[] = [];
80
+ const line = (a: Point, b: Point) => {
81
+ const from = this.surface.fromRoot(a),
82
+ to = this.surface.fromRoot(b);
83
+ if (from && to) segments.push(`M${from.x},${from.y}L${to.x},${to.y}`);
84
+ };
85
+ for (
86
+ let x = this.origin.x + Math.ceil((bounds.left - this.origin.x) / step) * step;
87
+ x <= bounds.right;
88
+ x += step
89
+ )
90
+ line({ x, y: bounds.top }, { x, y: bounds.bottom });
91
+ for (
92
+ let y = this.origin.y + Math.ceil((bounds.top - this.origin.y) / step) * step;
93
+ y <= bounds.bottom;
94
+ y += step
95
+ )
96
+ line({ x: bounds.left, y }, { x: bounds.right, y });
97
+ this.path.setAttribute('d', segments.join(''));
98
+ }
99
+ }