@rasamjs/tools 0.0.0-stage → 0.1.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 (56) hide show
  1. package/API.md +188 -0
  2. package/CHANGELOG.md +45 -0
  3. package/LICENSE +21 -0
  4. package/README.md +101 -2
  5. package/RELEASING.md +75 -0
  6. package/dist/snapping/guides.d.ts +14 -0
  7. package/dist/snapping/guides.d.ts.map +1 -0
  8. package/dist/snapping/guides.js +109 -0
  9. package/dist/snapping/guides.js.map +1 -0
  10. package/dist/snapping/lines.d.ts +43 -0
  11. package/dist/snapping/lines.d.ts.map +1 -0
  12. package/dist/snapping/lines.js +74 -0
  13. package/dist/snapping/lines.js.map +1 -0
  14. package/dist/snapping/options.d.ts +18 -0
  15. package/dist/snapping/options.d.ts.map +1 -0
  16. package/dist/snapping/options.js +44 -0
  17. package/dist/snapping/options.js.map +1 -0
  18. package/dist/snapping/overlay.d.ts +22 -0
  19. package/dist/snapping/overlay.d.ts.map +1 -0
  20. package/dist/snapping/overlay.js +165 -0
  21. package/dist/snapping/overlay.js.map +1 -0
  22. package/dist/snapping/session.d.ts +25 -0
  23. package/dist/snapping/session.d.ts.map +1 -0
  24. package/dist/snapping/session.js +89 -0
  25. package/dist/snapping/session.js.map +1 -0
  26. package/dist/snapping/snapper.d.ts +22 -0
  27. package/dist/snapping/snapper.d.ts.map +1 -0
  28. package/dist/snapping/snapper.js +140 -0
  29. package/dist/snapping/snapper.js.map +1 -0
  30. package/dist/snapping/spacing.d.ts +27 -0
  31. package/dist/snapping/spacing.d.ts.map +1 -0
  32. package/dist/snapping/spacing.js +127 -0
  33. package/dist/snapping/spacing.js.map +1 -0
  34. package/dist/snapping/targets.d.ts +6 -0
  35. package/dist/snapping/targets.d.ts.map +1 -0
  36. package/dist/snapping/targets.js +67 -0
  37. package/dist/snapping/targets.js.map +1 -0
  38. package/dist/snapping/types.d.ts +35 -0
  39. package/dist/snapping/types.d.ts.map +1 -0
  40. package/dist/snapping/types.js +2 -0
  41. package/dist/snapping/types.js.map +1 -0
  42. package/dist/snapping.d.ts +4 -0
  43. package/dist/snapping.d.ts.map +1 -0
  44. package/dist/snapping.js +3 -0
  45. package/dist/snapping.js.map +1 -0
  46. package/package.json +53 -4
  47. package/src/snapping/guides.ts +132 -0
  48. package/src/snapping/lines.ts +108 -0
  49. package/src/snapping/options.ts +47 -0
  50. package/src/snapping/overlay.ts +186 -0
  51. package/src/snapping/session.ts +116 -0
  52. package/src/snapping/snapper.ts +147 -0
  53. package/src/snapping/spacing.ts +157 -0
  54. package/src/snapping/targets.ts +79 -0
  55. package/src/snapping/types.ts +27 -0
  56. package/src/snapping.ts +3 -0
@@ -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,79 @@
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 measureTargets(
47
+ drawing: Svg,
48
+ nodes: readonly AnyNode[],
49
+ options: ResolvedOptions,
50
+ ): Box[] {
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
+ const boxes: Box[] = [];
74
+ for (const node of ordered) {
75
+ const bounds = boundsIn(node, drawing);
76
+ if ('box' in bounds) boxes.push(bounds.box);
77
+ }
78
+ return boxes;
79
+ }
@@ -0,0 +1,27 @@
1
+ import type { AnyNode, Point } from '@rasamjs/core';
2
+
3
+ export interface SnapperOptions {
4
+ readonly tolerance?: number;
5
+ readonly shapes?: boolean;
6
+ readonly spacing?: boolean;
7
+ readonly grid?: number | { readonly size: number; readonly origin?: Point } | null;
8
+ readonly targets?: (nodes: readonly AnyNode[]) => Iterable<AnyNode>;
9
+ readonly modifier?: 'ctrl' | 'none';
10
+ readonly style?: { readonly stroke?: string; readonly gapStroke?: string };
11
+ }
12
+
13
+ export type SnapRequest =
14
+ | { readonly kind: 'point'; readonly client: Point }
15
+ | {
16
+ readonly kind: 'move' | 'resize';
17
+ readonly nodes: readonly AnyNode[];
18
+ readonly client: Point;
19
+ readonly corners: readonly Point[];
20
+ readonly anchor?: Point;
21
+ readonly centered?: boolean;
22
+ };
23
+
24
+ export interface SnapSession {
25
+ snap(client: Point, modifiers: { readonly ignore: boolean; readonly centered?: boolean }): Point;
26
+ end(): void;
27
+ }
@@ -0,0 +1,3 @@
1
+ /** Opt-in snapping tools for rasam drawings. */
2
+ export { Snapper } from './snapping/snapper.js';
3
+ export type { SnapperOptions, SnapRequest, SnapSession } from './snapping/types.js';