@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,186 @@
1
+ import { Matrix } from '@rasamjs/core';
2
+ import type { Point, Svg } from '@rasamjs/core';
3
+ import type { Guide } from './guides.js';
4
+
5
+ const namespace = 'http://www.w3.org/2000/svg';
6
+ const guidePoint = (guide: Guide, value: number): Point =>
7
+ guide.axis === 'x' ? { x: guide.position, y: value } : { x: value, y: guide.position };
8
+
9
+ /** Native SVG outside the managed drawing; geometry is projected through client pixels. */
10
+ export class GuideOverlay {
11
+ private readonly element: SVGSVGElement;
12
+ private readonly lines: SVGLineElement[];
13
+ private readonly marks: SVGPathElement;
14
+ private readonly gaps: SVGPathElement;
15
+ private readonly view: Window;
16
+ private pending: number | null = null;
17
+ private read: (() => readonly Guide[]) | null = null;
18
+
19
+ constructor(
20
+ private readonly drawing: Svg,
21
+ stroke: string,
22
+ gapStroke = stroke,
23
+ ) {
24
+ const document = drawing.el.ownerDocument;
25
+ this.view = document.defaultView!;
26
+ let outer = drawing.el;
27
+ while (outer.ownerSVGElement) outer = outer.ownerSVGElement;
28
+ if (!outer.parentElement) throw new TypeError('Guides need a mounted drawing container');
29
+ this.element = document.createElementNS(namespace, 'svg');
30
+ for (const [key, value] of Object.entries({
31
+ class: 'rasam-snapping',
32
+ width: '100%',
33
+ height: '100%',
34
+ 'aria-hidden': 'true',
35
+ focusable: 'false',
36
+ fill: 'none',
37
+ stroke,
38
+ }))
39
+ this.element.setAttribute(key, value);
40
+ Object.assign(this.element.style, {
41
+ position: 'absolute',
42
+ inset: '0',
43
+ overflow: 'visible',
44
+ pointerEvents: 'none',
45
+ display: 'none',
46
+ });
47
+ this.lines = ['x', 'y'].map((axis) => {
48
+ const line = this.create('line');
49
+ line.setAttribute('data-axis', axis);
50
+ return line;
51
+ });
52
+ this.marks = this.create('path');
53
+ this.gaps = this.create('path');
54
+ this.gaps.setAttribute('data-gaps', '');
55
+ this.gaps.setAttribute('stroke', gapStroke);
56
+ outer.parentElement.append(this.element);
57
+ this.view.addEventListener('scroll', this.schedule, true);
58
+ this.view.addEventListener('resize', this.schedule);
59
+ }
60
+
61
+ private create<Tag extends 'line' | 'path'>(tag: Tag): SVGElementTagNameMap[Tag] {
62
+ const element = this.element.ownerDocument.createElementNS(namespace, tag);
63
+ element.setAttribute('stroke-width', '1');
64
+ element.setAttribute('vector-effect', 'non-scaling-stroke');
65
+ this.element.append(element);
66
+ return element;
67
+ }
68
+
69
+ show(read: () => readonly Guide[]): void {
70
+ this.read = read;
71
+ this.schedule();
72
+ }
73
+ private readonly schedule = (): void => {
74
+ if (this.read && this.pending === null)
75
+ this.pending = this.view.requestAnimationFrame(() => {
76
+ this.pending = null;
77
+ this.update();
78
+ });
79
+ };
80
+
81
+ update(): void {
82
+ if (this.pending !== null) this.view.cancelAnimationFrame(this.pending);
83
+ this.pending = null;
84
+ const guides = this.read?.() ?? [];
85
+ if (!guides.length || !this.element.isConnected) {
86
+ this.element.style.display = 'none';
87
+ return;
88
+ }
89
+ this.element.style.display = '';
90
+ const screen = this.element.getScreenCTM?.();
91
+ const inverse =
92
+ screen && new Matrix(screen.a, screen.b, screen.c, screen.d, screen.e, screen.f).inverse();
93
+ if (!inverse) {
94
+ this.element.style.display = 'none';
95
+ return;
96
+ }
97
+ const marks: string[] = [];
98
+ for (const [index, axis] of (['x', 'y'] as const).entries()) {
99
+ const line = this.lines[index],
100
+ guide = guides.find((guide) => !guide.gap && guide.axis === axis);
101
+ line.style.display = guide ? '' : 'none';
102
+ if (!guide) continue;
103
+ const point = (value: number) => guidePoint(guide, value);
104
+ const a = this.drawing.toClient(point(guide.from)),
105
+ b = this.drawing.toClient(point(guide.to)),
106
+ next = this.drawing.toClient(point(guide.from + 1));
107
+ if (!a || !b || !next) {
108
+ this.element.style.display = 'none';
109
+ return;
110
+ }
111
+ const length = Math.hypot(next.x - a.x, next.y - a.y);
112
+ if (!length) {
113
+ line.style.display = 'none';
114
+ continue;
115
+ }
116
+ const pad = { x: (8 * (next.x - a.x)) / length, y: (8 * (next.y - a.y)) / length };
117
+ const start = inverse.apply({ x: a.x - pad.x, y: a.y - pad.y }),
118
+ end = inverse.apply({ x: b.x + pad.x, y: b.y + pad.y });
119
+ for (const [name, value] of Object.entries({
120
+ x1: start.x,
121
+ y1: start.y,
122
+ x2: end.x,
123
+ y2: end.y,
124
+ }))
125
+ line.setAttribute(name, String(value));
126
+ for (const value of guide.marks) {
127
+ const client = this.drawing.toClient(point(value));
128
+ if (!client) continue;
129
+ for (const [a, b] of [
130
+ [-3, 3],
131
+ [3, -3],
132
+ ]) {
133
+ const start = inverse.apply({ x: client.x - 3, y: client.y + a }),
134
+ end = inverse.apply({ x: client.x + 3, y: client.y + b });
135
+ marks.push(`M${start.x},${start.y}L${end.x},${end.y}`);
136
+ }
137
+ }
138
+ }
139
+ this.marks.setAttribute('d', marks.join(''));
140
+ this.gaps.setAttribute(
141
+ 'd',
142
+ guides
143
+ .filter((guide) => guide.gap)
144
+ .map((guide) => this.gapPath(guide, inverse))
145
+ .join(''),
146
+ );
147
+ }
148
+
149
+ private gapPath(guide: Guide, inverse: Matrix): string {
150
+ const a = this.drawing.toClient(guidePoint(guide, guide.from));
151
+ const b = this.drawing.toClient(guidePoint(guide, guide.to));
152
+ if (!a || !b) return '';
153
+ const length = Math.hypot(b.x - a.x, b.y - a.y);
154
+ if (!length) return '';
155
+ const cap = { x: (3 * (b.y - a.y)) / length, y: (3 * (b.x - a.x)) / length };
156
+ const segment = (from: Point, to: Point) => {
157
+ const start = inverse.apply(from),
158
+ end = inverse.apply(to);
159
+ return `M${start.x},${start.y}L${end.x},${end.y}`;
160
+ };
161
+ return (
162
+ segment(a, b) +
163
+ [a, b]
164
+ .map((point) =>
165
+ segment(
166
+ { x: point.x + cap.x, y: point.y - cap.y },
167
+ { x: point.x - cap.x, y: point.y + cap.y },
168
+ ),
169
+ )
170
+ .join('')
171
+ );
172
+ }
173
+
174
+ clear(): void {
175
+ if (this.pending !== null) this.view.cancelAnimationFrame(this.pending);
176
+ this.pending = null;
177
+ this.read = null;
178
+ this.element.style.display = 'none';
179
+ }
180
+ destroy(): void {
181
+ this.clear();
182
+ this.element.remove();
183
+ this.view.removeEventListener('scroll', this.schedule, true);
184
+ this.view.removeEventListener('resize', this.schedule);
185
+ }
186
+ }
@@ -0,0 +1,116 @@
1
+ import type { Point } from '@rasamjs/core';
2
+ import type { Features, Match, Targets } from './lines.js';
3
+ import { prefer, snapAxis } from './lines.js';
4
+ import type { Spacing } from './spacing.js';
5
+ import type { ResolvedOptions } from './options.js';
6
+ import { assertPoint } from './options.js';
7
+ import type { SnapSession } from './types.js';
8
+
9
+ interface Mapping {
10
+ fromClient(point: Point): Point | null;
11
+ toClient(point: Point): Point | null;
12
+ }
13
+ interface SessionConfig {
14
+ readonly start: Point;
15
+ readonly features: Features;
16
+ readonly targets: Targets;
17
+ readonly mapping: Mapping;
18
+ readonly options: ResolvedOptions;
19
+ readonly isEnabled: () => boolean;
20
+ readonly onEnd: () => void;
21
+ readonly onSnap?: (matches: readonly (Match | null)[], point: Point | null) => void;
22
+ readonly spacing?: Spacing | null;
23
+ }
24
+
25
+ /** Unit-frame anchors can map to either root axis after a quarter turn or reflection. */
26
+ export function resizeLines(corners: readonly Point[], anchor: Point): Features | null {
27
+ if (corners.length !== 4) return null;
28
+ const [a, b, c, d] = corners;
29
+ const u = { x: b.x - a.x, y: b.y - a.y },
30
+ v = { x: d.x - a.x, y: d.y - a.y };
31
+ const epsilon = 1e-7 * Math.max(Math.abs(u.x), Math.abs(u.y), Math.abs(v.x), Math.abs(v.y));
32
+ const horizontal = (edge: Point) => Math.abs(edge.x) > 1e-7 * Math.hypot(edge.x, edge.y);
33
+ const vertical = (edge: Point) => Math.abs(edge.y) > 1e-7 * Math.hypot(edge.x, edge.y);
34
+ if (
35
+ [u, v].some((edge) => horizontal(edge) && vertical(edge)) ||
36
+ (horizontal(u) && horizontal(v)) ||
37
+ (vertical(u) && vertical(v)) ||
38
+ Math.abs(c.x - b.x - d.x + a.x) > epsilon ||
39
+ Math.abs(c.y - b.y - d.y + a.y) > epsilon
40
+ )
41
+ return null;
42
+ const point = {
43
+ x: a.x + u.x * anchor.x + v.x * anchor.y,
44
+ y: a.y + u.y * anchor.x + v.y * anchor.y,
45
+ };
46
+ const x = (anchor.x !== 0.5 && horizontal(u)) || (anchor.y !== 0.5 && horizontal(v));
47
+ const y = (anchor.x !== 0.5 && vertical(u)) || (anchor.y !== 0.5 && vertical(v));
48
+ return x || y
49
+ ? { x: x ? [{ value: point.x, edge: true }] : [], y: y ? [{ value: point.y, edge: true }] : [] }
50
+ : null;
51
+ }
52
+
53
+ function clientScale(mapping: Mapping): Point | null {
54
+ const origin = mapping.toClient({ x: 0, y: 0 }),
55
+ x = mapping.toClient({ x: 1, y: 0 }),
56
+ y = mapping.toClient({ x: 0, y: 1 });
57
+ if (!origin || !x || !y) return null;
58
+ const scale = {
59
+ x: Math.hypot(x.x - origin.x, x.y - origin.y),
60
+ y: Math.hypot(y.x - origin.x, y.y - origin.y),
61
+ };
62
+ return Number.isFinite(scale.x) && Number.isFinite(scale.y) && scale.x > 0 && scale.y > 0
63
+ ? scale
64
+ : null;
65
+ }
66
+
67
+ function correctedClient(client: Point, config: SessionConfig, centered?: boolean): Point {
68
+ assertPoint(client, 'Client pointer');
69
+ const point = config.mapping.fromClient(client),
70
+ scale = point && clientScale(config.mapping);
71
+ if (!point || !scale || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
72
+ config.onSnap?.([], null);
73
+ return client;
74
+ }
75
+ const offset = { x: point.x - config.start.x, y: point.y - config.start.y };
76
+ const correction = (axis: 'x' | 'y') => {
77
+ const tolerance = config.options.tolerance / scale[axis];
78
+ const alignment = snapAxis({
79
+ moving: config.features[axis],
80
+ targets: config.options.shapes ? config.targets[axis] : [],
81
+ offset: offset[axis],
82
+ tolerance,
83
+ grid: config.options.grid && {
84
+ size: config.options.grid.size,
85
+ origin: config.options.grid.origin[axis],
86
+ },
87
+ });
88
+ const spacing = config.spacing?.({ axis, offset, tolerance, centered });
89
+ return spacing ? prefer(alignment, spacing) : alignment;
90
+ };
91
+ const x = correction('x'),
92
+ y = correction('y');
93
+ const corrected = { x: point.x + (x?.delta ?? 0), y: point.y + (y?.delta ?? 0) };
94
+ const result = x?.delta || y?.delta ? config.mapping.toClient(corrected) : client;
95
+ config.onSnap?.(result ? [x, y] : [], result ? corrected : null);
96
+ return result ?? client;
97
+ }
98
+
99
+ export function createSession(config: SessionConfig): SnapSession {
100
+ let ended = false;
101
+ return {
102
+ snap(client, modifiers) {
103
+ if (ended) return client;
104
+ if (!config.isEnabled() || (modifiers.ignore && config.options.modifier !== 'none')) {
105
+ config.onSnap?.([], null);
106
+ return client;
107
+ }
108
+ return correctedClient(client, config, modifiers.centered);
109
+ },
110
+ end() {
111
+ if (ended) return;
112
+ ended = true;
113
+ config.onEnd();
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,147 @@
1
+ import type { Box, Point, Svg } from '@rasamjs/core';
2
+ import { Guards } from '@rasamjs/core/advanced';
3
+ import { boxLines, targetLines } from './lines.js';
4
+ import type { Features } from './lines.js';
5
+ import { assertPoint, resolveOptions } from './options.js';
6
+ import type { ResolvedOptions } from './options.js';
7
+ import { createSession, resizeLines } from './session.js';
8
+ import { measureTargets, movingBox, validateNodes } from './targets.js';
9
+ import type { SnapperOptions, SnapRequest, SnapSession } from './types.js';
10
+ import { guideReader } from './guides.js';
11
+ import { GuideOverlay } from './overlay.js';
12
+ import { createSpacing } from './spacing.js';
13
+
14
+ /** Owns snapping state for one mounted drawing. */
15
+ export class Snapper {
16
+ private enabledState = true;
17
+ private readonly overlay: GuideOverlay;
18
+ private readonly stopDisposal: () => void;
19
+ private readonly options: ResolvedOptions;
20
+ private active: SnapSession | null = null;
21
+ private sessionVersion = 0;
22
+ private disposed = false;
23
+
24
+ constructor(
25
+ private readonly drawing: Svg,
26
+ options: SnapperOptions = {},
27
+ ) {
28
+ if (
29
+ !Guards.isTransformable(drawing) ||
30
+ drawing.isDestroyed ||
31
+ drawing.el.localName !== 'svg' ||
32
+ !drawing.el.isConnected ||
33
+ !drawing.el.ownerDocument.defaultView
34
+ )
35
+ throw new TypeError('Snapper needs a live, mounted rasam SVG drawing');
36
+ this.options = resolveOptions(options);
37
+ this.overlay = new GuideOverlay(drawing, this.options.stroke, this.options.gapStroke);
38
+ this.stopDisposal = drawing.onDispose(() => this.destroy());
39
+ }
40
+
41
+ get isDestroyed(): boolean {
42
+ return this.disposed;
43
+ }
44
+
45
+ get enabled(): boolean {
46
+ return this.enabledState;
47
+ }
48
+ set enabled(value: boolean) {
49
+ if (typeof value !== 'boolean') throw new TypeError('enabled must be a boolean');
50
+ this.enabledState = value;
51
+ if (!value) this.overlay.clear();
52
+ }
53
+
54
+ update(): this {
55
+ if (!this.disposed) this.overlay.update();
56
+ return this;
57
+ }
58
+
59
+ begin(request: SnapRequest): SnapSession | null {
60
+ const version = ++this.sessionVersion;
61
+ this.active?.end();
62
+ if (this.disposed || !this.enabled) return null;
63
+ if (!request || !['move', 'resize', 'point'].includes(request.kind))
64
+ throw new TypeError('Unknown snap request kind');
65
+ assertPoint(request.client, 'Start pointer');
66
+ if (request.kind !== 'point') validateNodes(request.nodes, this.drawing);
67
+ const start = this.drawing.fromClient(request.client);
68
+ if (!start) return null;
69
+ const geometry = this.geometry(request, start);
70
+ if (!geometry) return null;
71
+ const nodes = Object.freeze(request.kind === 'point' ? [] : [...new Set(request.nodes)]);
72
+ const targets = targetLines(
73
+ this.options.shapes || this.options.spacing
74
+ ? measureTargets(this.drawing, nodes, this.options)
75
+ : [],
76
+ );
77
+ if (version !== this.sessionVersion || this.disposed || !this.enabled) return null;
78
+ const spacing = this.options.spacing
79
+ ? createSpacing({
80
+ ...geometry,
81
+ targets: targets.boxes,
82
+ kind: request.kind,
83
+ centered: request.kind === 'resize' && request.centered,
84
+ })
85
+ : null;
86
+ if (
87
+ !this.options.grid &&
88
+ !(this.options.shapes && (targets.x.length || targets.y.length)) &&
89
+ !spacing
90
+ )
91
+ return null;
92
+ const read = guideReader(this.drawing, request, targets.boxes);
93
+ this.active = createSession({
94
+ start: { x: start.x, y: start.y },
95
+ features: geometry.features,
96
+ spacing,
97
+ targets,
98
+ mapping: this.drawing,
99
+ options: this.options,
100
+ isEnabled: () => this.enabled && !this.disposed,
101
+ onEnd: () => {
102
+ this.active = null;
103
+ this.overlay.clear();
104
+ },
105
+ onSnap: (matches, point) => {
106
+ if (version !== this.sessionVersion || this.disposed) return;
107
+ if (!point || !matches.some((match) => match)) this.overlay.clear();
108
+ else this.overlay.show(() => read(matches, point));
109
+ },
110
+ });
111
+ return this.active;
112
+ }
113
+
114
+ private geometry(request: SnapRequest, start: Point): { box: Box; features: Features } | null {
115
+ const box =
116
+ request.kind === 'point'
117
+ ? { ...start, width: 0, height: 0 }
118
+ : movingBox(request.nodes, this.drawing);
119
+ if (!box) return null;
120
+ if (request.kind !== 'resize') return { box, features: boxLines(box) };
121
+ if (
122
+ !request.anchor ||
123
+ ![0, 0.5, 1].includes(request.anchor.x) ||
124
+ ![0, 0.5, 1].includes(request.anchor.y) ||
125
+ (request.anchor.x === 0.5 && request.anchor.y === 0.5)
126
+ )
127
+ throw new TypeError('Resize snapping needs a unit-frame edge or corner anchor');
128
+ if (!Array.isArray(request.corners) || request.corners.length !== 4)
129
+ throw new TypeError('Resize snapping needs four frame corners');
130
+ const corners = request.corners.map((point) => {
131
+ assertPoint(point, 'Frame corner');
132
+ return this.drawing.fromClient(point);
133
+ });
134
+ const features = corners.every((point) => point !== null)
135
+ ? resizeLines(corners as Point[], request.anchor)
136
+ : null;
137
+ return features ? { box, features } : null;
138
+ }
139
+
140
+ destroy(): void {
141
+ if (this.disposed) return;
142
+ this.disposed = true;
143
+ this.active?.end();
144
+ this.overlay.destroy();
145
+ this.stopDisposal();
146
+ }
147
+ }
@@ -0,0 +1,157 @@
1
+ import type { Box, Point } from '@rasamjs/core';
2
+ import type { Features, Match } from './lines.js';
3
+ import { lowerBound } from './lines.js';
4
+
5
+ type Axis = 'x' | 'y';
6
+ type Side = 'before' | 'after';
7
+ const extent = { x: 'width', y: 'height' } as const;
8
+ const end = (box: Box, axis: Axis) => box[axis] + box[extent[axis]];
9
+ const across = (axis: Axis): Axis => (axis === 'x' ? 'y' : 'x');
10
+ const overlaps = (a: Box, b: Box, axis: Axis) => a[axis] <= end(b, axis) && b[axis] <= end(a, axis);
11
+ const middle = (a: Box, b: Box, axis: Axis) =>
12
+ (Math.max(a[axis], b[axis]) + Math.min(end(a, axis), end(b, axis))) / 2;
13
+
14
+ export interface Gap {
15
+ readonly from: number;
16
+ readonly to: number;
17
+ readonly at: number;
18
+ readonly moving?: 'from' | 'to';
19
+ readonly neighbor?: number;
20
+ }
21
+ interface Setup {
22
+ readonly box: Box;
23
+ readonly targets: readonly Box[];
24
+ readonly features: Features;
25
+ readonly kind: 'move' | 'resize' | 'point';
26
+ readonly centered?: boolean;
27
+ }
28
+ export type Spacing = (request: {
29
+ readonly axis: Axis;
30
+ readonly offset: Point;
31
+ readonly tolerance: number;
32
+ readonly centered?: boolean;
33
+ }) => Match | null;
34
+
35
+ /**
36
+ * Only gaps after the furthest preceding overlapping box are unobstructed. Boxes that
37
+ * contain the next box on this axis are backgrounds, not obstructions.
38
+ */
39
+ function referenceGaps(targets: readonly Box[], axis: Axis): Gap[] {
40
+ const gaps: Gap[] = [];
41
+ // Far edges ascending; equal edges scan the earliest document target first.
42
+ const ordered = targets
43
+ .map((box, order) => ({ box, order, value: end(box, axis) }))
44
+ .sort((a, b) => a.value - b.value || b.order - a.order);
45
+ for (const next of targets)
46
+ for (let index = lowerBound(ordered, end(next, axis)) - 1; index >= 0; index--) {
47
+ const previous = ordered[index];
48
+ if (previous.box[axis] >= next[axis] || !overlaps(previous.box, next, across(axis))) continue;
49
+ if (previous.value < next[axis])
50
+ gaps.push({
51
+ from: previous.value,
52
+ to: next[axis],
53
+ at: middle(previous.box, next, across(axis)),
54
+ });
55
+ break;
56
+ }
57
+ return gaps;
58
+ }
59
+
60
+ function projectedBox(setup: Setup, offset: Point, centered = setup.centered): Box {
61
+ const box = { ...setup.box };
62
+ for (const axis of ['x', 'y'] as const) {
63
+ if (setup.kind !== 'resize') {
64
+ box[axis] += offset[axis];
65
+ continue;
66
+ }
67
+ const feature = setup.features[axis][0];
68
+ if (!feature) continue;
69
+ const start = setup.box[axis],
70
+ finish = end(setup.box, axis);
71
+ const fixed =
72
+ Math.abs(feature.value - start) < Math.abs(feature.value - finish) ? finish : start;
73
+ const opposite = fixed - (centered ? offset[axis] : 0),
74
+ active = feature.value + offset[axis];
75
+ box[axis] = Math.min(active, opposite);
76
+ box[extent[axis]] = Math.abs(active - opposite);
77
+ }
78
+ return box;
79
+ }
80
+
81
+ /** Targets that contain the box on this axis (backgrounds, panels) are not neighbors. */
82
+ function neighbors(box: Box, targets: readonly Box[], axis: Axis): [number, number] {
83
+ let left = -1,
84
+ right = -1;
85
+ targets.forEach((target, index) => {
86
+ if (!overlaps(box, target, across(axis))) return;
87
+ const before = target[axis] <= box[axis],
88
+ after = end(target, axis) >= end(box, axis);
89
+ if (before && after) return;
90
+ if (before && (left < 0 || end(target, axis) > end(targets[left], axis))) left = index;
91
+ if (after && (right < 0 || target[axis] < targets[right][axis])) right = index;
92
+ });
93
+ return [left, right];
94
+ }
95
+
96
+ /** Snapshot once; pointer queries scan cached boxes/gaps without any DOM reads. */
97
+ export function createSpacing(input: Setup): Spacing | null {
98
+ const setup = {
99
+ ...input,
100
+ box: { ...input.box },
101
+ targets: input.targets.map((box) => ({ ...box })),
102
+ };
103
+ const references = { x: referenceGaps(setup.targets, 'x'), y: referenceGaps(setup.targets, 'y') };
104
+ if (!references.x.length && !references.y.length) return null;
105
+ return ({ axis, offset, tolerance, centered = setup.centered }) => {
106
+ const resizing = setup.kind === 'resize',
107
+ feature = setup.features[axis][0];
108
+ if (resizing && !feature) return null;
109
+ const box = projectedBox(setup, offset, centered),
110
+ [left, right] = neighbors(box, setup.targets, axis);
111
+ const active = feature && feature.value + offset[axis];
112
+ const dragged: Side = resizing && active! > box[axis] ? 'after' : 'before';
113
+ let best: Match | null = null;
114
+ const mark = (next: Box, side: Side): Gap => {
115
+ const index = side === 'before' ? left : right,
116
+ neighbor = setup.targets[index];
117
+ return {
118
+ from: side === 'before' ? end(neighbor, axis) : end(next, axis),
119
+ to: side === 'before' ? next[axis] : neighbor[axis],
120
+ at: middle(next, neighbor, across(axis)),
121
+ moving: side === 'before' ? 'to' : 'from',
122
+ neighbor: index,
123
+ };
124
+ };
125
+ const offer = (delta: number, side: Side, reference?: Gap) => {
126
+ if (Math.abs(delta) > tolerance || (best && Math.abs(delta) >= Math.abs(best.delta))) return;
127
+ const next = projectedBox(setup, { ...offset, [axis]: offset[axis] + delta }, centered);
128
+ const target = side === 'before' ? next[axis] : end(next, axis);
129
+ if (resizing && Math.abs(active! + delta - target) > 1e-7) return;
130
+ const gaps = reference
131
+ ? [reference, mark(next, side)]
132
+ : [mark(next, 'before'), mark(next, 'after')];
133
+ const length = gaps[0].to - gaps[0].from;
134
+ if (
135
+ length <= 0 ||
136
+ gaps.some((gap) => Math.abs(gap.to - gap.from - length) > 1e-7 * Math.max(1, length))
137
+ )
138
+ return;
139
+ best = { delta, target, order: side === 'before' ? left : right, grid: false, gaps };
140
+ };
141
+ if (left >= 0 && right >= 0 && left !== right) {
142
+ const before = box[axis] - end(setup.targets[left], axis),
143
+ after = setup.targets[right][axis] - end(box, axis);
144
+ let delta = (after - before) / 2;
145
+ if (resizing) delta = centered ? 0 : after - before;
146
+ offer(delta, dragged);
147
+ }
148
+ for (const reference of references[axis]) {
149
+ const length = reference.to - reference.from;
150
+ if (left >= 0 && (!resizing || dragged === 'before'))
151
+ offer(end(setup.targets[left], axis) + length - box[axis], 'before', reference);
152
+ if (right >= 0 && (!resizing || dragged === 'after'))
153
+ offer(setup.targets[right][axis] - length - end(box, axis), 'after', reference);
154
+ }
155
+ return best;
156
+ };
157
+ }
@@ -0,0 +1,88 @@
1
+ import type { AnyNode, Box, Svg } from '@rasamjs/core';
2
+ import { Guards } from '@rasamjs/core/advanced';
3
+ import { boundsIn } from '@rasamjs/core/viewport';
4
+ import type { ResolvedOptions } from './options.js';
5
+
6
+ export function validateNodes(nodes: readonly AnyNode[], drawing: Svg): void {
7
+ if (!Array.isArray(nodes)) throw new TypeError('Snap nodes must be an array');
8
+ for (const node of nodes)
9
+ if (
10
+ !Guards.isTransformable(node) ||
11
+ node.isDestroyed ||
12
+ node === drawing ||
13
+ node.context !== drawing.context ||
14
+ !drawing.el.contains(node.el)
15
+ )
16
+ throw new TypeError('Snap nodes must be live shapes inside this drawing');
17
+ }
18
+
19
+ export function movingBox(nodes: readonly AnyNode[], drawing: Svg): Box | null {
20
+ let left = Infinity,
21
+ top = Infinity,
22
+ right = -Infinity,
23
+ bottom = -Infinity;
24
+ for (const node of nodes) {
25
+ const bounds = boundsIn(node, drawing);
26
+ if (bounds.status === 'unavailable') return null;
27
+ if (!('box' in bounds)) continue;
28
+ const box = bounds.box;
29
+ left = Math.min(left, box.x);
30
+ top = Math.min(top, box.y);
31
+ right = Math.max(right, box.x + box.width);
32
+ bottom = Math.max(bottom, box.y + box.height);
33
+ }
34
+ return left === Infinity ? null : { x: left, y: top, width: right - left, height: bottom - top };
35
+ }
36
+
37
+ function visible(node: AnyNode): boolean {
38
+ const view = node.el.ownerDocument.defaultView!;
39
+ const visibility = view.getComputedStyle(node.el).visibility;
40
+ if (visibility === 'hidden' || visibility === 'collapse') return false;
41
+ for (let element: Element | null = node.el; element; element = element.parentElement)
42
+ if (view.getComputedStyle(element).display === 'none') return false;
43
+ return true;
44
+ }
45
+
46
+ export function targetNodes(
47
+ drawing: Svg,
48
+ nodes: readonly AnyNode[],
49
+ options: ResolvedOptions,
50
+ ): AnyNode[] {
51
+ const candidates = options.targets
52
+ ? options.targets(nodes)
53
+ : nodes.length
54
+ ? nodes.flatMap((node) => node.parent?.children ?? [])
55
+ : drawing.children;
56
+ const eligible = new Set<AnyNode>();
57
+ for (const node of candidates) {
58
+ if (
59
+ !Guards.isTransformable(node) ||
60
+ node.isDestroyed ||
61
+ node.context !== drawing.context ||
62
+ !drawing.el.contains(node.el) ||
63
+ nodes.some((selected) => node.el.contains(selected.el) || selected.el.contains(node.el)) ||
64
+ !visible(node)
65
+ )
66
+ continue;
67
+ eligible.add(node);
68
+ }
69
+ // All eligible nodes are attached to the same document; bit 4 means FOLLOWING.
70
+ const ordered = [...eligible].sort((a, b) =>
71
+ a === b ? 0 : a.el.compareDocumentPosition(b.el) & 4 ? -1 : 1,
72
+ );
73
+ return ordered;
74
+ }
75
+
76
+ export function measureTargets(
77
+ drawing: Svg,
78
+ nodes: readonly AnyNode[],
79
+ options: ResolvedOptions,
80
+ ): Box[] {
81
+ const boxes: Box[] = [];
82
+ const ordered = targetNodes(drawing, nodes, options);
83
+ for (const node of ordered) {
84
+ const bounds = boundsIn(node, drawing);
85
+ if ('box' in bounds) boxes.push(bounds.box);
86
+ }
87
+ return boxes;
88
+ }