@rasamjs/tools 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +122 -2
- package/CHANGELOG.md +9 -1
- package/README.md +37 -0
- package/RELEASING.md +16 -3
- package/dist/advanced/context.d.ts +15 -0
- package/dist/advanced/context.d.ts.map +1 -0
- package/dist/advanced/context.js +2 -0
- package/dist/advanced/context.js.map +1 -0
- package/dist/advanced/curves.d.ts +5 -0
- package/dist/advanced/curves.d.ts.map +1 -0
- package/dist/advanced/curves.js +65 -0
- package/dist/advanced/curves.js.map +1 -0
- package/dist/advanced/geometry.d.ts +17 -0
- package/dist/advanced/geometry.d.ts.map +1 -0
- package/dist/advanced/geometry.js +61 -0
- package/dist/advanced/geometry.js.map +1 -0
- package/dist/advanced/rotation.d.ts +4 -0
- package/dist/advanced/rotation.d.ts.map +1 -0
- package/dist/advanced/rotation.js +86 -0
- package/dist/advanced/rotation.js.map +1 -0
- package/dist/advanced/session.d.ts +5 -0
- package/dist/advanced/session.d.ts.map +1 -0
- package/dist/advanced/session.js +240 -0
- package/dist/advanced/session.js.map +1 -0
- package/dist/advanced/snapper.d.ts +29 -0
- package/dist/advanced/snapper.d.ts.map +1 -0
- package/dist/advanced/snapper.js +118 -0
- package/dist/advanced/snapper.js.map +1 -0
- package/dist/advanced/types.d.ts +30 -0
- package/dist/advanced/types.d.ts.map +1 -0
- package/dist/advanced/types.js +2 -0
- package/dist/advanced/types.js.map +1 -0
- package/dist/drawing.d.ts +5 -0
- package/dist/drawing.d.ts.map +1 -0
- package/dist/drawing.js +3 -0
- package/dist/drawing.js.map +1 -0
- package/dist/editor/draw-tool.d.ts +35 -0
- package/dist/editor/draw-tool.d.ts.map +1 -0
- package/dist/editor/draw-tool.js +174 -0
- package/dist/editor/draw-tool.js.map +1 -0
- package/dist/editor/path-editor.d.ts +34 -0
- package/dist/editor/path-editor.d.ts.map +1 -0
- package/dist/editor/path-editor.js +208 -0
- package/dist/editor/path-editor.js.map +1 -0
- package/dist/editor/pointer.d.ts +21 -0
- package/dist/editor/pointer.d.ts.map +1 -0
- package/dist/editor/pointer.js +86 -0
- package/dist/editor/pointer.js.map +1 -0
- package/dist/editor/surface.d.ts +31 -0
- package/dist/editor/surface.d.ts.map +1 -0
- package/dist/editor/surface.js +138 -0
- package/dist/editor/surface.js.map +1 -0
- package/dist/grid.d.ts +23 -0
- package/dist/grid.d.ts.map +1 -0
- package/dist/grid.js +84 -0
- package/dist/grid.js.map +1 -0
- package/dist/rulers.d.ts +30 -0
- package/dist/rulers.d.ts.map +1 -0
- package/dist/rulers.js +234 -0
- package/dist/rulers.js.map +1 -0
- package/dist/snapping/advanced.d.ts +3 -0
- package/dist/snapping/advanced.d.ts.map +1 -0
- package/dist/snapping/advanced.js +2 -0
- package/dist/snapping/advanced.js.map +1 -0
- package/dist/snapping/targets.d.ts +1 -0
- package/dist/snapping/targets.d.ts.map +1 -1
- package/dist/snapping/targets.js +5 -1
- package/dist/snapping/targets.js.map +1 -1
- package/package.json +24 -4
- package/src/advanced/context.ts +15 -0
- package/src/advanced/curves.ts +65 -0
- package/src/advanced/geometry.ts +74 -0
- package/src/advanced/rotation.ts +93 -0
- package/src/advanced/session.ts +276 -0
- package/src/advanced/snapper.ts +124 -0
- package/src/advanced/types.ts +31 -0
- package/src/drawing.ts +4 -0
- package/src/editor/draw-tool.ts +190 -0
- package/src/editor/path-editor.ts +224 -0
- package/src/editor/pointer.ts +85 -0
- package/src/editor/surface.ts +141 -0
- package/src/grid.ts +99 -0
- package/src/rulers.ts +245 -0
- package/src/snapping/advanced.ts +8 -0
- package/src/snapping/targets.ts +11 -2
|
@@ -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
|
+
}
|
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
|
+
}
|
package/src/snapping/targets.ts
CHANGED
|
@@ -43,11 +43,11 @@ function visible(node: AnyNode): boolean {
|
|
|
43
43
|
return true;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export function
|
|
46
|
+
export function targetNodes(
|
|
47
47
|
drawing: Svg,
|
|
48
48
|
nodes: readonly AnyNode[],
|
|
49
49
|
options: ResolvedOptions,
|
|
50
|
-
):
|
|
50
|
+
): AnyNode[] {
|
|
51
51
|
const candidates = options.targets
|
|
52
52
|
? options.targets(nodes)
|
|
53
53
|
: nodes.length
|
|
@@ -70,7 +70,16 @@ export function measureTargets(
|
|
|
70
70
|
const ordered = [...eligible].sort((a, b) =>
|
|
71
71
|
a === b ? 0 : a.el.compareDocumentPosition(b.el) & 4 ? -1 : 1,
|
|
72
72
|
);
|
|
73
|
+
return ordered;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function measureTargets(
|
|
77
|
+
drawing: Svg,
|
|
78
|
+
nodes: readonly AnyNode[],
|
|
79
|
+
options: ResolvedOptions,
|
|
80
|
+
): Box[] {
|
|
73
81
|
const boxes: Box[] = [];
|
|
82
|
+
const ordered = targetNodes(drawing, nodes, options);
|
|
74
83
|
for (const node of ordered) {
|
|
75
84
|
const bounds = boundsIn(node, drawing);
|
|
76
85
|
if ('box' in bounds) boxes.push(bounds.box);
|