@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
package/src/rulers.ts ADDED
@@ -0,0 +1,245 @@
1
+ import type { Point, Svg } from '@rasamjs/core';
2
+ import type { RulerGuide } from './advanced/types.js';
3
+ import { Surface, svg } from './editor/surface.js';
4
+ import { PointerDrag } from './editor/pointer.js';
5
+
6
+ export interface RulerOptions {
7
+ readonly stroke?: string;
8
+ readonly onChange?: (guides: readonly NamedGuide[]) => void;
9
+ }
10
+ export interface NamedGuide extends RulerGuide {
11
+ readonly id: number;
12
+ }
13
+ export class RulerGuides {
14
+ private readonly surface: Surface;
15
+ private readonly values = new Map<number, NamedGuide>();
16
+ private next = 1;
17
+ private drag: { id: number; before: number | null } | null = null;
18
+ constructor(
19
+ private readonly drawing: Svg,
20
+ private readonly options: RulerOptions = {},
21
+ ) {
22
+ if (
23
+ options.stroke !== undefined &&
24
+ (typeof options.stroke !== 'string' || !options.stroke.trim())
25
+ )
26
+ throw new TypeError('Guide stroke must be nonempty');
27
+ if (options.onChange !== undefined && typeof options.onChange !== 'function')
28
+ throw new TypeError('onChange must be a function');
29
+ this.options = { ...options };
30
+ this.surface = new Surface(drawing, 'rasam-rulers', () => this.render());
31
+ this.surface.element.style.zIndex = '30';
32
+ new PointerDrag(this.surface, {
33
+ start: (event) => {
34
+ const target = event.target as Element;
35
+ const existing = target.closest('[data-guide-id]');
36
+ const ruler = target.closest('[data-ruler-axis]');
37
+ if (!existing && !ruler) return false;
38
+ const point = this.drawing.fromClient({ x: event.clientX, y: event.clientY });
39
+ if (!point) return false;
40
+ if (existing) {
41
+ const id = Number(existing.getAttribute('data-guide-id'));
42
+ this.drag = { id, before: this.values.get(id)!.position };
43
+ } else {
44
+ const axis = ruler!.getAttribute('data-ruler-axis') as 'x' | 'y';
45
+ this.drag = { id: this.add(axis, point[axis]), before: null };
46
+ }
47
+ return true;
48
+ },
49
+ move: (event) => {
50
+ const point = this.drawing.fromClient({ x: event.clientX, y: event.clientY });
51
+ if (this.drag && point) {
52
+ const guide = this.values.get(this.drag.id)!;
53
+ this.move(guide.id, point[guide.axis]);
54
+ }
55
+ },
56
+ end: (cancelled, event) => {
57
+ const drag = this.drag;
58
+ this.drag = null;
59
+ if (!drag) return;
60
+ if (this.surface.isDestroyed) return;
61
+ if (cancelled) {
62
+ if (drag.before === null) this.remove(drag.id);
63
+ else this.move(drag.id, drag.before);
64
+ } else if (event) {
65
+ const point = this.surface.fromClient({ x: event.clientX, y: event.clientY }),
66
+ size = this.surface.extent();
67
+ if (!point || point.x < 20 || point.y < 20 || point.x > size.x || point.y > size.y)
68
+ this.remove(drag.id);
69
+ }
70
+ },
71
+ });
72
+ this.surface.listen(this.surface.element, 'dblclick', ((event: MouseEvent) => {
73
+ const guide = (event.target as Element).closest('[data-guide-id]');
74
+ if (guide) {
75
+ event.preventDefault();
76
+ this.remove(Number(guide.getAttribute('data-guide-id')));
77
+ }
78
+ }) as EventListener);
79
+ this.surface.listen(this.surface.element, 'keydown', ((event: KeyboardEvent) => {
80
+ const target = event.target as Element,
81
+ handle = target.closest('[data-guide-id]');
82
+ if (handle) {
83
+ const id = Number(handle.getAttribute('data-guide-id')),
84
+ guide = this.values.get(id)!;
85
+ const direction =
86
+ guide.axis === 'x'
87
+ ? ({ ArrowLeft: -1, ArrowRight: 1 } as Record<string, number>)
88
+ : ({ ArrowUp: -1, ArrowDown: 1 } as Record<string, number>);
89
+ if (direction[event.key]) {
90
+ event.preventDefault();
91
+ event.stopPropagation();
92
+ this.move(id, guide.position + direction[event.key] * (event.shiftKey ? 10 : 1));
93
+ } else if (event.key === 'Delete' || event.key === 'Backspace') {
94
+ event.preventDefault();
95
+ event.stopPropagation();
96
+ this.remove(id);
97
+ }
98
+ } else if (event.key === 'Enter' || event.key === ' ') {
99
+ const ruler = target.closest('[data-ruler-axis]'),
100
+ bounds = this.surface.rootBounds();
101
+ if (!ruler || !bounds) return;
102
+ event.preventDefault();
103
+ event.stopPropagation();
104
+ const axis = ruler.getAttribute('data-ruler-axis') as 'x' | 'y';
105
+ const id = this.add(
106
+ axis,
107
+ axis === 'x' ? (bounds.left + bounds.right) / 2 : (bounds.top + bounds.bottom) / 2,
108
+ );
109
+ this.surface.element.querySelector<SVGElement>(`[data-guide-id="${id}"]`)?.focus();
110
+ }
111
+ }) as EventListener);
112
+ this.surface.cleanup(() => {
113
+ this.drag = null;
114
+ this.values.clear();
115
+ });
116
+ this.update();
117
+ }
118
+ get guides(): readonly NamedGuide[] {
119
+ return Object.freeze([...this.values.values()].map((g) => Object.freeze({ ...g })));
120
+ }
121
+ get isDestroyed(): boolean {
122
+ return this.surface.isDestroyed;
123
+ }
124
+ add(axis: 'x' | 'y', position: number): number {
125
+ this.assertLive();
126
+ if (!['x', 'y'].includes(axis) || !Number.isFinite(position))
127
+ throw new TypeError('Guide needs an axis and finite position');
128
+ const id = this.next++;
129
+ this.values.set(id, { id, axis, position });
130
+ this.changed();
131
+ return id;
132
+ }
133
+ move(id: number, position: number): void {
134
+ this.assertLive();
135
+ const guide = this.values.get(id);
136
+ if (!guide) throw new RangeError('Unknown guide');
137
+ if (!Number.isFinite(position)) throw new TypeError('Guide position must be finite');
138
+ this.values.set(id, { ...guide, position });
139
+ this.changed();
140
+ }
141
+ remove(id: number): boolean {
142
+ this.assertLive();
143
+ const removed = this.values.delete(id);
144
+ if (removed) this.changed();
145
+ return removed;
146
+ }
147
+ clear(): void {
148
+ this.assertLive();
149
+ this.values.clear();
150
+ this.changed();
151
+ }
152
+ update(): this {
153
+ this.surface.update();
154
+ return this;
155
+ }
156
+ destroy(): void {
157
+ this.surface.destroy();
158
+ this.values.clear();
159
+ }
160
+ private changed(): void {
161
+ this.update();
162
+ this.options.onChange?.(this.guides);
163
+ }
164
+ private assertLive(): void {
165
+ if (this.isDestroyed) throw new Error('Ruler guides are destroyed');
166
+ }
167
+ private render(): void {
168
+ const document = this.drawing.el.ownerDocument,
169
+ size = this.surface.extent(),
170
+ bounds = this.surface.rootBounds();
171
+ const focus = document.activeElement;
172
+ const activeId =
173
+ focus && this.surface.element.contains(focus) ? focus.getAttribute('data-guide-id') : null;
174
+ const parts: SVGElement[] = [];
175
+ for (const axis of ['x', 'y'] as const) {
176
+ const rect = svg(document, 'rect', {
177
+ x: 0,
178
+ y: 0,
179
+ width: axis === 'x' ? 20 : size.x,
180
+ height: axis === 'x' ? size.y : 20,
181
+ fill: '#edf0f5',
182
+ stroke: '#bbc3cf',
183
+ 'data-ruler-axis': axis,
184
+ tabindex: 0,
185
+ role: 'button',
186
+ 'aria-label': `Drag ${axis === 'x' ? 'vertical' : 'horizontal'} guide`,
187
+ });
188
+ rect.style.pointerEvents = 'all';
189
+ parts.push(rect);
190
+ }
191
+ if (bounds) {
192
+ for (const guide of this.values.values()) {
193
+ const a = this.surface.fromRoot(
194
+ guide.axis === 'x'
195
+ ? { x: guide.position, y: bounds.top }
196
+ : { x: bounds.left, y: guide.position },
197
+ );
198
+ const b = this.surface.fromRoot(
199
+ guide.axis === 'x'
200
+ ? { x: guide.position, y: bounds.bottom }
201
+ : { x: bounds.right, y: guide.position },
202
+ );
203
+ if (!a || !b) continue;
204
+ const line = svg(document, 'line', {
205
+ x1: a.x,
206
+ y1: a.y,
207
+ x2: b.x,
208
+ y2: b.y,
209
+ stroke: this.options.stroke ?? '#d23cdd',
210
+ 'stroke-width': 1,
211
+ 'vector-effect': 'non-scaling-stroke',
212
+ 'data-guide-id': guide.id,
213
+ tabindex: 0,
214
+ role: 'button',
215
+ 'aria-label': `${guide.axis} guide at ${guide.position}`,
216
+ });
217
+ line.style.pointerEvents = 'stroke';
218
+ line.style.cursor = guide.axis === 'x' ? 'ew-resize' : 'ns-resize';
219
+ parts.push(line);
220
+ }
221
+ for (const axis of ['x', 'y'] as const) {
222
+ const low = axis === 'x' ? bounds.left : bounds.top,
223
+ high = axis === 'x' ? bounds.right : bounds.bottom;
224
+ const step = Math.pow(10, Math.ceil(Math.log10(Math.max(1, (high - low) / 10))));
225
+ for (let value = Math.ceil(low / step) * step; value <= high; value += step) {
226
+ const p = this.surface.fromRoot(
227
+ axis === 'x' ? { x: value, y: bounds.top } : { x: bounds.left, y: value },
228
+ );
229
+ if (!p) continue;
230
+ const text = svg(document, 'text', {
231
+ x: axis === 'x' ? p.x + 2 : 2,
232
+ y: axis === 'x' ? 12 : p.y + 12,
233
+ 'font-size': 10,
234
+ fill: '#536174',
235
+ });
236
+ text.textContent = String(value);
237
+ parts.push(text);
238
+ }
239
+ }
240
+ }
241
+ this.surface.element.replaceChildren(...parts);
242
+ if (activeId)
243
+ this.surface.element.querySelector<SVGElement>(`[data-guide-id="${activeId}"]`)?.focus();
244
+ }
245
+ }
@@ -0,0 +1,8 @@
1
+ export { AdvancedSnapper } from '../advanced/snapper.js';
2
+ export type {
3
+ AdvancedSnapperOptions,
4
+ AdvancedSnapRequest,
5
+ RulerGuide,
6
+ RotationSnapRequest,
7
+ RotationSnapSession,
8
+ } from '../advanced/types.js';
@@ -0,0 +1,132 @@
1
+ import type { AnyNode, Box, Point, Svg } from '@rasamjs/core';
2
+ import { Guards } from '@rasamjs/core/advanced';
3
+ import { movingBox } from './targets.js';
4
+ import type { Match } from './lines.js';
5
+ import type { SnapRequest } from './types.js';
6
+
7
+ export interface Guide {
8
+ readonly axis: 'x' | 'y';
9
+ readonly position: number;
10
+ readonly from: number;
11
+ readonly to: number;
12
+ readonly marks: readonly number[];
13
+ readonly gap?: boolean;
14
+ }
15
+
16
+ function rootMatrix(drawing: Svg, node: AnyNode) {
17
+ return Guards.isTransformable(node)
18
+ ? drawing.worldMatrix().inverse()?.multiply(node.worldMatrix())
19
+ : null;
20
+ }
21
+
22
+ /** Follow the dragged anchor, including group flips that exchange the outer edges. */
23
+ function draggedAnchor(drawing: Svg, request: SnapRequest): () => Point | null {
24
+ if (request.kind !== 'resize') return () => null;
25
+ const nodes = [...new Set(request.nodes)];
26
+ const anchor = { ...request.anchor! };
27
+ if (nodes.length === 1)
28
+ return () => {
29
+ const matrix = rootMatrix(drawing, nodes[0]),
30
+ bounds = nodes[0].bounds();
31
+ return matrix && 'box' in bounds
32
+ ? matrix.apply({
33
+ x: bounds.box.x + bounds.box.width * anchor.x,
34
+ y: bounds.box.y + bounds.box.height * anchor.y,
35
+ })
36
+ : null;
37
+ };
38
+ const inverse = rootMatrix(drawing, nodes[0])?.inverse();
39
+ const corners = request.corners.map((point) => drawing.fromClient(point));
40
+ if (!inverse || corners.some((point) => !point)) return () => null;
41
+ const [a, b, , d] = corners as Point[];
42
+ const initial = {
43
+ x: a.x + (b.x - a.x) * anchor.x + (d.x - a.x) * anchor.y,
44
+ y: a.y + (b.y - a.y) * anchor.x + (d.y - a.y) * anchor.y,
45
+ };
46
+ return () => rootMatrix(drawing, nodes[0])?.multiply(inverse).apply(initial) ?? null;
47
+ }
48
+
49
+ /** Read accepted geometry after the transformer has applied its own constraints. */
50
+ export function guideReader(drawing: Svg, request: SnapRequest, boxes: readonly Box[]) {
51
+ const pointOnly = request.kind === 'point',
52
+ resizing = request.kind === 'resize';
53
+ const nodes = pointOnly ? [] : [...new Set(request.nodes)];
54
+ const dragged = draggedAnchor(drawing, request);
55
+ return (matches: readonly (Match | null)[], point: Point | null): Guide[] => {
56
+ if (
57
+ !point ||
58
+ drawing.isDestroyed ||
59
+ nodes.some((node) => node.isDestroyed || !drawing.el.contains(node.el))
60
+ )
61
+ return [];
62
+ const box = pointOnly ? { ...point, width: 0, height: 0 } : movingBox(nodes, drawing);
63
+ if (!box) return [];
64
+ const active = dragged();
65
+ const guides: Guide[] = [];
66
+ for (const [index, axis] of (['x', 'y'] as const).entries()) {
67
+ const match = matches[index];
68
+ if (!match) continue;
69
+ const other = axis === 'x' ? 'y' : 'x',
70
+ size = axis === 'x' ? 'width' : 'height',
71
+ span = other === 'x' ? 'width' : 'height';
72
+ const values = resizing
73
+ ? active
74
+ ? [active[axis]]
75
+ : []
76
+ : [box[axis], box[axis] + box[size] / 2, box[axis] + box[size]];
77
+ const value = values.reduce(
78
+ (best, next) =>
79
+ Math.abs(next - match.target) < Math.abs(best - match.target) ? next : best,
80
+ Infinity,
81
+ );
82
+ if (!Number.isFinite(value)) continue;
83
+ const from = drawing.toClient({ ...point, [axis]: value }),
84
+ to = drawing.toClient({ ...point, [axis]: match.target });
85
+ if (!from || !to || Math.hypot(to.x - from.x, to.y - from.y) > 0.5) continue;
86
+ if (match.gaps) {
87
+ let valid = true;
88
+ const gaps = match.gaps.map<Guide>((gap) => {
89
+ const from = gap.moving === 'from' ? box[axis] + box[size] : gap.from;
90
+ const to = gap.moving === 'to' ? box[axis] : gap.to;
91
+ const expected = drawing.toClient({ ...point, [axis]: gap.to - gap.from });
92
+ const accepted = drawing.toClient({ ...point, [axis]: to - from });
93
+ const neighbor = gap.neighbor === undefined ? null : boxes[gap.neighbor];
94
+ const low = neighbor ? Math.max(box[other], neighbor[other]) : gap.at;
95
+ const high = neighbor
96
+ ? Math.min(box[other] + box[span], neighbor[other] + neighbor[span])
97
+ : gap.at;
98
+ if (
99
+ !expected ||
100
+ !accepted ||
101
+ to <= from ||
102
+ low > high ||
103
+ Math.hypot(accepted.x - expected.x, accepted.y - expected.y) > 0.5
104
+ )
105
+ valid = false;
106
+ return {
107
+ axis: other,
108
+ position: (low + high) / 2,
109
+ from,
110
+ to,
111
+ marks: [from, to],
112
+ gap: true,
113
+ };
114
+ });
115
+ if (valid) guides.push(...gaps);
116
+ continue;
117
+ }
118
+ const target = !match.grid && boxes[match.order];
119
+ guides.push({
120
+ axis,
121
+ position: match.target,
122
+ from: Math.min(box[other], target ? target[other] : box[other]),
123
+ to: Math.max(
124
+ box[other] + box[span],
125
+ target ? target[other] + target[span] : box[other] + box[span],
126
+ ),
127
+ marks: [box[other] + box[span] / 2, ...(target ? [target[other] + target[span] / 2] : [])],
128
+ });
129
+ }
130
+ return guides;
131
+ };
132
+ }
@@ -0,0 +1,108 @@
1
+ import type { Box } from '@rasamjs/core';
2
+ import type { Gap } from './spacing.js';
3
+
4
+ export interface MovingLine {
5
+ readonly value: number;
6
+ readonly edge: boolean;
7
+ }
8
+ export interface TargetLine {
9
+ readonly value: number;
10
+ readonly order: number;
11
+ }
12
+ export interface Features {
13
+ readonly x: readonly MovingLine[];
14
+ readonly y: readonly MovingLine[];
15
+ }
16
+ export interface Targets {
17
+ readonly x: readonly TargetLine[];
18
+ readonly y: readonly TargetLine[];
19
+ readonly boxes: readonly Box[];
20
+ }
21
+ export interface Match {
22
+ readonly delta: number;
23
+ readonly target: number;
24
+ readonly order: number;
25
+ readonly grid: boolean;
26
+ readonly gaps?: readonly Gap[];
27
+ }
28
+ interface AxisRequest {
29
+ readonly moving: readonly MovingLine[];
30
+ readonly targets: readonly TargetLine[];
31
+ readonly offset: number;
32
+ readonly tolerance: number;
33
+ readonly grid: { readonly size: number; readonly origin: number } | null;
34
+ }
35
+
36
+ function axisLines(value: number, size: number): MovingLine[] {
37
+ return [
38
+ { value, edge: true },
39
+ { value: value + size / 2, edge: false },
40
+ { value: value + size, edge: true },
41
+ ];
42
+ }
43
+
44
+ export function boxLines(box: Box): Features {
45
+ return { x: axisLines(box.x, box.width), y: axisLines(box.y, box.height) };
46
+ }
47
+
48
+ function sorted(lines: TargetLine[]): TargetLine[] {
49
+ return lines
50
+ .sort((a, b) => a.value - b.value || a.order - b.order)
51
+ .filter((line, index, all) => index === 0 || line.value !== all[index - 1].value);
52
+ }
53
+
54
+ export function targetLines(boxes: readonly Box[]): Targets {
55
+ const x: TargetLine[] = [],
56
+ y: TargetLine[] = [];
57
+ boxes.forEach((box, order) => {
58
+ const lines = boxLines(box);
59
+ for (const line of lines.x) x.push({ value: line.value, order });
60
+ for (const line of lines.y) y.push({ value: line.value, order });
61
+ });
62
+ return { x: sorted(x), y: sorted(y), boxes: boxes.map((box) => ({ ...box })) };
63
+ }
64
+
65
+ export function prefer(current: Match | null, candidate: Match): Match {
66
+ if (!current) return candidate;
67
+ const distance = Math.abs(candidate.delta) - Math.abs(current.delta);
68
+ if (distance !== 0) return distance < 0 ? candidate : current;
69
+ if (!!candidate.gaps !== !!current.gaps) return candidate.gaps ? current : candidate;
70
+ if (candidate.grid !== current.grid) return candidate.grid ? current : candidate;
71
+ return candidate.order < current.order ? candidate : current;
72
+ }
73
+
74
+ export function lowerBound(lines: readonly TargetLine[], value: number): number {
75
+ let low = 0,
76
+ high = lines.length;
77
+ while (low < high) {
78
+ const middle = Math.floor((low + high) / 2);
79
+ if (lines[middle].value < value) low = middle + 1;
80
+ else high = middle;
81
+ }
82
+ return low;
83
+ }
84
+
85
+ export function snapAxis(request: AxisRequest): Match | null {
86
+ let best: Match | null = null;
87
+ for (const line of request.moving) {
88
+ const value = line.value + request.offset;
89
+ const index = lowerBound(request.targets, value);
90
+ for (const neighbor of [index - 1, index]) {
91
+ const target = request.targets[neighbor];
92
+ if (target && Math.abs(target.value - value) <= request.tolerance)
93
+ best = prefer(best, {
94
+ delta: target.value - value,
95
+ target: target.value,
96
+ order: target.order,
97
+ grid: false,
98
+ });
99
+ }
100
+ if (request.grid && line.edge) {
101
+ const { size, origin } = request.grid;
102
+ const target = origin + Math.round((value - origin) / size) * size;
103
+ if (Math.abs(target - value) <= request.tolerance)
104
+ best = prefer(best, { delta: target - value, target, order: Infinity, grid: true });
105
+ }
106
+ }
107
+ return best;
108
+ }
@@ -0,0 +1,47 @@
1
+ import type { Point } from '@rasamjs/core';
2
+ import type { SnapperOptions } from './types.js';
3
+
4
+ export function assertPoint(point: Point, name = 'Point'): void {
5
+ if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y))
6
+ throw new TypeError(`${name} must have finite x and y coordinates`);
7
+ }
8
+
9
+ export function resolveOptions(options: SnapperOptions = {}) {
10
+ const tolerance = options.tolerance ?? 6;
11
+ if (!Number.isFinite(tolerance) || tolerance < 0)
12
+ throw new RangeError('Snapping tolerance must be nonnegative and finite');
13
+ const shapes = options.shapes ?? true;
14
+ const spacing = options.spacing ?? true;
15
+ if (typeof spacing !== 'boolean') throw new TypeError('spacing must be a boolean');
16
+ if (typeof shapes !== 'boolean') throw new TypeError('shapes must be a boolean');
17
+ if (options.targets !== undefined && typeof options.targets !== 'function')
18
+ throw new TypeError('targets must be a function');
19
+ const modifier = options.modifier ?? 'ctrl';
20
+ if (modifier !== 'ctrl' && modifier !== 'none') throw new TypeError('Invalid snapping modifier');
21
+ const stroke = options.style?.stroke ?? '#f00';
22
+ const gapStroke = options.style?.gapStroke ?? stroke;
23
+ if ([stroke, gapStroke].some((value) => typeof value !== 'string' || !value.trim()))
24
+ throw new TypeError('Guide stroke must be a nonempty string');
25
+ let grid: { readonly size: number; readonly origin: Point } | null = null;
26
+ if (options.grid !== undefined && options.grid !== null) {
27
+ const size = typeof options.grid === 'number' ? options.grid : options.grid.size;
28
+ if (!Number.isFinite(size) || size <= 0)
29
+ throw new RangeError('Grid size must be positive and finite');
30
+ const origin =
31
+ typeof options.grid === 'number' ? { x: 0, y: 0 } : (options.grid.origin ?? { x: 0, y: 0 });
32
+ assertPoint(origin, 'Grid origin');
33
+ grid = { size, origin: { x: origin.x, y: origin.y } };
34
+ }
35
+ return {
36
+ tolerance,
37
+ shapes,
38
+ spacing,
39
+ grid,
40
+ targets: options.targets,
41
+ modifier,
42
+ stroke,
43
+ gapStroke,
44
+ };
45
+ }
46
+
47
+ export type ResolvedOptions = ReturnType<typeof resolveOptions>;