@adecore/drawing 0.0.1 → 0.17.0-beta.1
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/LICENSE +110 -0
- package/README.md +42 -0
- package/dist/geometry.d.ts +32 -0
- package/dist/geometry.js +233 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/paths.d.ts +12 -0
- package/dist/paths.js +129 -0
- package/dist/protocol.d.ts +1118 -0
- package/dist/protocol.js +102 -0
- package/dist/reading-order.d.ts +2 -0
- package/dist/reading-order.js +75 -0
- package/dist/svg.d.ts +16 -0
- package/dist/svg.js +109 -0
- package/dist/text.d.ts +19 -0
- package/dist/text.js +74 -0
- package/package.json +42 -2
- package/src/geometry.ts +273 -0
- package/src/index.ts +42 -0
- package/src/paths.ts +162 -0
- package/src/protocol.ts +130 -0
- package/src/reading-order.ts +82 -0
- package/src/svg.ts +138 -0
- package/src/text.ts +90 -0
package/src/geometry.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type { DrawingElement } from './protocol.ts';
|
|
2
|
+
|
|
3
|
+
export interface Point {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Rect {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
w: number;
|
|
12
|
+
h: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const RESIZE_HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const;
|
|
16
|
+
export type ResizeHandle = (typeof RESIZE_HANDLES)[number];
|
|
17
|
+
|
|
18
|
+
export function boundsOf(element: Pick<DrawingElement, 'x' | 'y' | 'w' | 'h'>): Rect {
|
|
19
|
+
return { x: element.x, y: element.y, w: element.w, h: element.h };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function centerOf(rect: Rect): Point {
|
|
23
|
+
return { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* A point on the whole pixel, for a layout that lines its boxes and its lines up on a pixel grid. */
|
|
27
|
+
export function roundPoint(point: Point): Point {
|
|
28
|
+
return { x: Math.round(point.x), y: Math.round(point.y) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function rotatePoint(point: Point, around: Point, angle: number): Point {
|
|
32
|
+
if (angle === 0) {
|
|
33
|
+
return point;
|
|
34
|
+
}
|
|
35
|
+
const cos = Math.cos(angle);
|
|
36
|
+
const sin = Math.sin(angle);
|
|
37
|
+
const dx = point.x - around.x;
|
|
38
|
+
const dy = point.y - around.y;
|
|
39
|
+
return { x: around.x + dx * cos - dy * sin, y: around.y + dx * sin + dy * cos };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* A point in the element's own frame, so every hit test can pretend nothing is turned. */
|
|
43
|
+
export function toLocal(element: DrawingElement, point: Point): Point {
|
|
44
|
+
return rotatePoint(point, centerOf(boundsOf(element)), -(element.angle ?? 0));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function unionOf(rects: readonly Rect[]): Rect | null {
|
|
48
|
+
if (rects.length === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
let left = Infinity;
|
|
52
|
+
let top = Infinity;
|
|
53
|
+
let right = -Infinity;
|
|
54
|
+
let bottom = -Infinity;
|
|
55
|
+
for (const rect of rects) {
|
|
56
|
+
left = Math.min(left, rect.x, rect.x + rect.w);
|
|
57
|
+
top = Math.min(top, rect.y, rect.y + rect.h);
|
|
58
|
+
right = Math.max(right, rect.x, rect.x + rect.w);
|
|
59
|
+
bottom = Math.max(bottom, rect.y, rect.y + rect.h);
|
|
60
|
+
}
|
|
61
|
+
return { x: left, y: top, w: right - left, h: bottom - top };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function boundsOfElements(elements: readonly DrawingElement[]): Rect | null {
|
|
65
|
+
return unionOf(elements.map(boundsOf));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function intersects(a: Rect, b: Rect): boolean {
|
|
69
|
+
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function rectFromPoints(from: Point, to: Point): Rect {
|
|
73
|
+
return {
|
|
74
|
+
x: Math.min(from.x, to.x),
|
|
75
|
+
y: Math.min(from.y, to.y),
|
|
76
|
+
w: Math.abs(to.x - from.x),
|
|
77
|
+
h: Math.abs(to.y - from.y)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function distanceToSegment(point: Point, a: Point, b: Point): number {
|
|
82
|
+
const dx = b.x - a.x;
|
|
83
|
+
const dy = b.y - a.y;
|
|
84
|
+
const length = dx * dx + dy * dy;
|
|
85
|
+
if (length === 0) {
|
|
86
|
+
return Math.hypot(point.x - a.x, point.y - a.y);
|
|
87
|
+
}
|
|
88
|
+
const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / length));
|
|
89
|
+
return Math.hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function absolutePoints(element: DrawingElement): Point[] {
|
|
93
|
+
if (element.kind !== 'line' && element.kind !== 'freehand') {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
return element.points.map(([x, y]) => ({ x: element.x + x, y: element.y + y }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isFilled(element: DrawingElement): boolean {
|
|
100
|
+
return element.fill !== undefined && element.fill !== 'none';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function cornersOfDiamond(rect: Rect): Point[] {
|
|
104
|
+
return [
|
|
105
|
+
{ x: rect.x + rect.w / 2, y: rect.y },
|
|
106
|
+
{ x: rect.x + rect.w, y: rect.y + rect.h / 2 },
|
|
107
|
+
{ x: rect.x + rect.w / 2, y: rect.y + rect.h },
|
|
108
|
+
{ x: rect.x, y: rect.y + rect.h / 2 }
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function insidePolygon(point: Point, polygon: readonly Point[]): boolean {
|
|
113
|
+
let inside = false;
|
|
114
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
115
|
+
const a = polygon[i]!;
|
|
116
|
+
const b = polygon[j]!;
|
|
117
|
+
if (a.y > point.y !== b.y > point.y && point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x) {
|
|
118
|
+
inside = !inside;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return inside;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function nearPolygon(point: Point, polygon: readonly Point[], tolerance: number): boolean {
|
|
125
|
+
return polygon.some((corner, index) => distanceToSegment(point, corner, polygon[(index + 1) % polygon.length]!) <= tolerance);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function nearPolyline(point: Point, points: readonly Point[], tolerance: number): boolean {
|
|
129
|
+
if (points.length === 1) {
|
|
130
|
+
return Math.hypot(point.x - points[0]!.x, point.y - points[0]!.y) <= tolerance;
|
|
131
|
+
}
|
|
132
|
+
return points.slice(0, -1).some((from, index) => distanceToSegment(point, from, points[index + 1]!) <= tolerance);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function insideRect(point: Point, rect: Rect): boolean {
|
|
136
|
+
return point.x >= rect.x && point.x <= rect.x + rect.w && point.y >= rect.y && point.y <= rect.y + rect.h;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/*
|
|
140
|
+
* Whether a point lands on an element. A shape with a fill is hit anywhere inside it, one without
|
|
141
|
+
* only near its outline, which is what makes an empty rectangle easy to draw inside of.
|
|
142
|
+
*/
|
|
143
|
+
export function hitsElement(element: DrawingElement, point: Point, tolerance: number): boolean {
|
|
144
|
+
const local = toLocal(element, point);
|
|
145
|
+
const rect = boundsOf(element);
|
|
146
|
+
const reach = Math.max(tolerance, element.strokeWidth);
|
|
147
|
+
switch (element.kind) {
|
|
148
|
+
// Both are their own sheet: anywhere on it is on them.
|
|
149
|
+
case 'text':
|
|
150
|
+
case 'note':
|
|
151
|
+
return insideRect(local, rect);
|
|
152
|
+
case 'rect': {
|
|
153
|
+
const corners = [
|
|
154
|
+
{ x: rect.x, y: rect.y },
|
|
155
|
+
{ x: rect.x + rect.w, y: rect.y },
|
|
156
|
+
{ x: rect.x + rect.w, y: rect.y + rect.h },
|
|
157
|
+
{ x: rect.x, y: rect.y + rect.h }
|
|
158
|
+
];
|
|
159
|
+
return isFilled(element) ? insideRect(local, rect) : nearPolygon(local, corners, reach);
|
|
160
|
+
}
|
|
161
|
+
case 'diamond': {
|
|
162
|
+
const corners = cornersOfDiamond(rect);
|
|
163
|
+
return isFilled(element) ? insidePolygon(local, corners) : nearPolygon(local, corners, reach);
|
|
164
|
+
}
|
|
165
|
+
case 'ellipse': {
|
|
166
|
+
const center = centerOf(rect);
|
|
167
|
+
const rx = Math.max(rect.w / 2, 0.01);
|
|
168
|
+
const ry = Math.max(rect.h / 2, 0.01);
|
|
169
|
+
const distance = Math.hypot((local.x - center.x) / rx, (local.y - center.y) / ry);
|
|
170
|
+
if (isFilled(element)) {
|
|
171
|
+
return distance <= 1;
|
|
172
|
+
}
|
|
173
|
+
return Math.abs(distance - 1) <= reach / Math.min(rx, ry);
|
|
174
|
+
}
|
|
175
|
+
case 'line':
|
|
176
|
+
case 'freehand':
|
|
177
|
+
return nearPolyline(local, absolutePoints(element), reach);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function elementAt(elements: readonly DrawingElement[], point: Point, tolerance: number): DrawingElement | undefined {
|
|
182
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
183
|
+
const element = elements[i]!;
|
|
184
|
+
if (!element.locked && hitsElement(element, point, tolerance)) {
|
|
185
|
+
return element;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function elementsIn(elements: readonly DrawingElement[], rect: Rect): DrawingElement[] {
|
|
192
|
+
return elements.filter((element) => !element.locked && intersects(boundsOf(element), rect));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function handlePoint(rect: Rect, handle: ResizeHandle): Point {
|
|
196
|
+
const x = handle.includes('w') ? rect.x : handle.includes('e') ? rect.x + rect.w : rect.x + rect.w / 2;
|
|
197
|
+
const y = handle.startsWith('n') ? rect.y : handle.startsWith('s') ? rect.y + rect.h : rect.y + rect.h / 2;
|
|
198
|
+
return { x, y };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/*
|
|
202
|
+
* The box a resize drag makes: the handle follows the pointer and the side across from it stays
|
|
203
|
+
* put. `aspect` keeps the proportions, which is what Shift asks for.
|
|
204
|
+
*/
|
|
205
|
+
export function resizeRect(rect: Rect, handle: ResizeHandle, point: Point, aspect = false): Rect {
|
|
206
|
+
let { x, y, w, h } = rect;
|
|
207
|
+
if (handle.includes('w')) {
|
|
208
|
+
w = rect.x + rect.w - point.x;
|
|
209
|
+
x = point.x;
|
|
210
|
+
}
|
|
211
|
+
if (handle.includes('e')) {
|
|
212
|
+
w = point.x - rect.x;
|
|
213
|
+
}
|
|
214
|
+
if (handle.startsWith('n')) {
|
|
215
|
+
h = rect.y + rect.h - point.y;
|
|
216
|
+
y = point.y;
|
|
217
|
+
}
|
|
218
|
+
if (handle.startsWith('s')) {
|
|
219
|
+
h = point.y - rect.y;
|
|
220
|
+
}
|
|
221
|
+
if (aspect && rect.w > 0 && rect.h > 0) {
|
|
222
|
+
const ratio = rect.h / rect.w;
|
|
223
|
+
// A side handle moves one dimension, which sets the other; a corner follows the smaller pull.
|
|
224
|
+
const width = handle === 'e' || handle === 'w' ? w : handle === 'n' || handle === 's' ? h / ratio : Math.abs(w) < Math.abs(h / ratio) ? w : h / ratio;
|
|
225
|
+
const height = width * ratio;
|
|
226
|
+
if (handle.includes('w')) {
|
|
227
|
+
x = rect.x + rect.w - width;
|
|
228
|
+
}
|
|
229
|
+
if (handle.startsWith('n')) {
|
|
230
|
+
y = rect.y + rect.h - height;
|
|
231
|
+
}
|
|
232
|
+
w = width;
|
|
233
|
+
h = height;
|
|
234
|
+
}
|
|
235
|
+
return { x, y, w, h };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function scaleElement(element: DrawingElement, from: Rect, to: Rect): DrawingElement {
|
|
239
|
+
const scaleX = from.w === 0 ? 1 : to.w / from.w;
|
|
240
|
+
const scaleY = from.h === 0 ? 1 : to.h / from.h;
|
|
241
|
+
const placed = {
|
|
242
|
+
...element,
|
|
243
|
+
x: to.x + (element.x - from.x) * scaleX,
|
|
244
|
+
y: to.y + (element.y - from.y) * scaleY,
|
|
245
|
+
w: element.w * scaleX,
|
|
246
|
+
h: element.h * scaleY
|
|
247
|
+
};
|
|
248
|
+
if (placed.kind === 'line') {
|
|
249
|
+
return { ...placed, points: placed.points.map(([x, y]) => [x * scaleX, y * scaleY] as [number, number]) };
|
|
250
|
+
}
|
|
251
|
+
if (placed.kind === 'freehand') {
|
|
252
|
+
return { ...placed, points: placed.points.map(([x, y, pressure]) => [x * scaleX, y * scaleY, pressure] as [number, number, number?]) };
|
|
253
|
+
}
|
|
254
|
+
if (placed.kind === 'text') {
|
|
255
|
+
// From here on the box is the person's, so typing no longer refits it.
|
|
256
|
+
return { ...placed, sized: true };
|
|
257
|
+
}
|
|
258
|
+
return placed;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function arrowHead(tip: Point, from: Point, size: number): [Point, Point][] {
|
|
262
|
+
const angle = Math.atan2(tip.y - from.y, tip.x - from.x);
|
|
263
|
+
const spread = Math.PI / 7;
|
|
264
|
+
return [
|
|
265
|
+
[tip, { x: tip.x - size * Math.cos(angle - spread), y: tip.y - size * Math.sin(angle - spread) }],
|
|
266
|
+
[tip, { x: tip.x - size * Math.cos(angle + spread), y: tip.y - size * Math.sin(angle + spread) }]
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* How long an arrow head is for a given stroke: heavier lines carry a bigger head. */
|
|
271
|
+
export function arrowHeadSize(strokeWidth: number): number {
|
|
272
|
+
return 12 + strokeWidth * 4;
|
|
273
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type Point,
|
|
3
|
+
type Rect,
|
|
4
|
+
RESIZE_HANDLES,
|
|
5
|
+
type ResizeHandle,
|
|
6
|
+
boundsOf,
|
|
7
|
+
centerOf,
|
|
8
|
+
roundPoint,
|
|
9
|
+
rotatePoint,
|
|
10
|
+
toLocal,
|
|
11
|
+
unionOf,
|
|
12
|
+
boundsOfElements,
|
|
13
|
+
intersects,
|
|
14
|
+
rectFromPoints,
|
|
15
|
+
distanceToSegment,
|
|
16
|
+
absolutePoints,
|
|
17
|
+
hitsElement,
|
|
18
|
+
elementAt,
|
|
19
|
+
elementsIn,
|
|
20
|
+
handlePoint,
|
|
21
|
+
resizeRect,
|
|
22
|
+
scaleElement,
|
|
23
|
+
arrowHead,
|
|
24
|
+
arrowHeadSize
|
|
25
|
+
} from './geometry.ts';
|
|
26
|
+
export { type ElementPath, outlineToPath, freehandOutline, pathsOfElement } from './paths.ts';
|
|
27
|
+
export { readingOrder } from './reading-order.ts';
|
|
28
|
+
export { type SvgOptions, DEFAULT_SVG_MARGIN, DEFAULT_PALETTE, DEFAULT_PAPER, DEFAULT_EDGE, toSvg } from './svg.ts';
|
|
29
|
+
export {
|
|
30
|
+
LINE_HEIGHT,
|
|
31
|
+
DEFAULT_FONT_STACKS,
|
|
32
|
+
type WrittenElement,
|
|
33
|
+
NOTE_PADDING,
|
|
34
|
+
NOTE_RADIUS,
|
|
35
|
+
writingFrameOf,
|
|
36
|
+
textLines,
|
|
37
|
+
type MeasureLine,
|
|
38
|
+
approximateMeasure,
|
|
39
|
+
wrapLines,
|
|
40
|
+
linesOf,
|
|
41
|
+
fontOf
|
|
42
|
+
} from './text.ts';
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { DrawingElement } from './protocol.ts';
|
|
2
|
+
import { getStroke } from 'perfect-freehand';
|
|
3
|
+
// Rough's unbundled modules use extensionless imports; its package entry also runs in Node.
|
|
4
|
+
import rough from 'roughjs';
|
|
5
|
+
import type { RoughGenerator } from 'roughjs/bin/generator.js';
|
|
6
|
+
import type { Options } from 'roughjs/bin/core.js';
|
|
7
|
+
import { arrowHead, arrowHeadSize, type Point } from './geometry.ts';
|
|
8
|
+
import { NOTE_RADIUS } from './text.ts';
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* What a painter has to do with one path. The colors are not in here: a drawing names palette
|
|
12
|
+
* colors and the painter resolves them against the theme it is drawing in.
|
|
13
|
+
*/
|
|
14
|
+
export interface ElementPath {
|
|
15
|
+
d: string;
|
|
16
|
+
/* `stroke` outlines in the stroke color, `fill` fills in the fill color, `ink` fills in the stroke color. */
|
|
17
|
+
role: 'stroke' | 'fill' | 'ink';
|
|
18
|
+
strokeWidth: number;
|
|
19
|
+
/* Whole units on and off, or null for a solid line. */
|
|
20
|
+
dash: number[] | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Sentinels, not colors: rough hands them back on every path, which is how a path says what it is.
|
|
24
|
+
const STROKE = '#000001';
|
|
25
|
+
const FILL = '#000002';
|
|
26
|
+
|
|
27
|
+
let generator: RoughGenerator | undefined;
|
|
28
|
+
|
|
29
|
+
function drawingGenerator(): RoughGenerator {
|
|
30
|
+
return (generator ??= rough.generator());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function dashOf(element: DrawingElement): number[] | null {
|
|
34
|
+
if (element.strokeStyle === 'dashed') {
|
|
35
|
+
return [element.strokeWidth * 4, element.strokeWidth * 4];
|
|
36
|
+
}
|
|
37
|
+
if (element.strokeStyle === 'dotted') {
|
|
38
|
+
return [element.strokeWidth, element.strokeWidth * 3];
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Architect, Artist, Cartoonist. Absent reads as Artist, which is what a hand-written file gets. */
|
|
44
|
+
function roughnessOf(element: DrawingElement): Pick<Options, 'roughness' | 'bowing' | 'disableMultiStroke'> {
|
|
45
|
+
switch (element.roughness ?? 1) {
|
|
46
|
+
case 0:
|
|
47
|
+
return { roughness: 0, bowing: 0, disableMultiStroke: true };
|
|
48
|
+
case 2:
|
|
49
|
+
return { roughness: 2.5, bowing: 2 };
|
|
50
|
+
default:
|
|
51
|
+
return { roughness: 1, bowing: 1 };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function optionsOf(element: DrawingElement): Options {
|
|
56
|
+
return {
|
|
57
|
+
seed: element.seed + 1,
|
|
58
|
+
stroke: STROKE,
|
|
59
|
+
strokeWidth: element.strokeWidth,
|
|
60
|
+
...roughnessOf(element),
|
|
61
|
+
...(element.fill && element.fill !== 'none'
|
|
62
|
+
? { fill: FILL, fillStyle: element.fill === 'solid' ? 'solid' : 'hachure', fillWeight: element.strokeWidth / 2, hachureGap: 8 }
|
|
63
|
+
: {})
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Rough has no rounded-rectangle primitive.
|
|
68
|
+
function roundedRectPath(w: number, h: number, radius: number): string {
|
|
69
|
+
const r = Math.min(radius, w / 2, h / 2);
|
|
70
|
+
return `M ${r} 0 L ${w - r} 0 Q ${w} 0 ${w} ${r} L ${w} ${h - r} Q ${w} ${h} ${w - r} ${h} L ${r} ${h} Q 0 ${h} 0 ${h - r} L 0 ${r} Q 0 0 ${r} 0 Z`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function outlineToPath(outline: readonly (readonly number[])[]): string {
|
|
74
|
+
if (outline.length === 0) {
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
const [first, ...rest] = outline;
|
|
78
|
+
return `M ${first![0]} ${first![1]} ${rest.map((point) => `L ${point[0]} ${point[1]}`).join(' ')} Z`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function freehandOutline(element: DrawingElement & { kind: 'freehand' }): string {
|
|
82
|
+
const points = element.points.map(([x, y, pressure]) => [x, y, pressure ?? 0.5] as [number, number, number]);
|
|
83
|
+
const outline = getStroke(points, {
|
|
84
|
+
size: element.strokeWidth * 4 + 2,
|
|
85
|
+
thinning: 0.6,
|
|
86
|
+
smoothing: 0.5,
|
|
87
|
+
streamline: 0.5,
|
|
88
|
+
// A pressure of exactly 0.5 on every point is a mouse; the stroke gets its taper from speed.
|
|
89
|
+
simulatePressure: element.points.every((point) => point[2] === undefined)
|
|
90
|
+
});
|
|
91
|
+
return outlineToPath(outline);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function linesToPath(segments: readonly [Point, Point][]): string {
|
|
95
|
+
return segments.map(([from, to]) => `M ${from.x} ${from.y} L ${to.x} ${to.y}`).join(' ');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/*
|
|
99
|
+
* One element as paths in its own frame: the origin is the element's own (x, y), so the painter
|
|
100
|
+
* translates and turns, and a path can be cached until the element's shape itself changes.
|
|
101
|
+
*/
|
|
102
|
+
export function pathsOfElement(element: DrawingElement): ElementPath[] {
|
|
103
|
+
if (element.kind === 'text') {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
// A note is a sheet of paper, not a drawn shape: no wobble, and an edge of its own paper color.
|
|
107
|
+
if (element.kind === 'note') {
|
|
108
|
+
const d = roundedRectPath(element.w, element.h, NOTE_RADIUS);
|
|
109
|
+
return [
|
|
110
|
+
{ d, role: 'fill', strokeWidth: 0, dash: null },
|
|
111
|
+
{ d, role: 'stroke', strokeWidth: element.strokeWidth, dash: null }
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
const options = optionsOf(element);
|
|
115
|
+
const dash = dashOf(element);
|
|
116
|
+
if (element.kind === 'freehand') {
|
|
117
|
+
return [{ d: freehandOutline(element), role: 'ink', strokeWidth: 0, dash: null }];
|
|
118
|
+
}
|
|
119
|
+
const generator = drawingGenerator();
|
|
120
|
+
const drawable = (() => {
|
|
121
|
+
switch (element.kind) {
|
|
122
|
+
case 'rect':
|
|
123
|
+
return element.radius
|
|
124
|
+
? generator.path(roundedRectPath(element.w, element.h, element.radius), options)
|
|
125
|
+
: generator.rectangle(0, 0, element.w, element.h, options);
|
|
126
|
+
case 'diamond':
|
|
127
|
+
return generator.polygon(
|
|
128
|
+
[
|
|
129
|
+
[element.w / 2, 0],
|
|
130
|
+
[element.w, element.h / 2],
|
|
131
|
+
[element.w / 2, element.h],
|
|
132
|
+
[0, element.h / 2]
|
|
133
|
+
],
|
|
134
|
+
options
|
|
135
|
+
);
|
|
136
|
+
case 'ellipse':
|
|
137
|
+
return generator.ellipse(element.w / 2, element.h / 2, element.w, element.h, options);
|
|
138
|
+
case 'line':
|
|
139
|
+
return generator.linearPath(
|
|
140
|
+
element.points.map(([x, y]) => [x, y] as [number, number]),
|
|
141
|
+
options
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
})();
|
|
145
|
+
const paths: ElementPath[] = generator.toPaths(drawable).map((path) => ({
|
|
146
|
+
d: path.d,
|
|
147
|
+
role: path.fill === FILL ? 'fill' : path.stroke === FILL ? 'fill' : 'stroke',
|
|
148
|
+
strokeWidth: path.strokeWidth,
|
|
149
|
+
dash: path.stroke === STROKE ? dash : null
|
|
150
|
+
}));
|
|
151
|
+
if (element.kind !== 'line') {
|
|
152
|
+
return paths;
|
|
153
|
+
}
|
|
154
|
+
const points = element.points.map(([x, y]) => ({ x, y }));
|
|
155
|
+
const size = arrowHeadSize(element.strokeWidth);
|
|
156
|
+
const heads: [Point, Point][] = [
|
|
157
|
+
...(element.arrowEnd ? arrowHead(points.at(-1)!, points.at(-2)!, size) : []),
|
|
158
|
+
...(element.arrowStart ? arrowHead(points[0]!, points[1]!, size) : [])
|
|
159
|
+
];
|
|
160
|
+
// The head is always solid: a dashed arrow still points at something.
|
|
161
|
+
return heads.length === 0 ? paths : [...paths, { d: linesToPath(heads), role: 'stroke', strokeWidth: element.strokeWidth, dash: null }];
|
|
162
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const DrawingFontSchema = z.enum(['hand', 'sans', 'mono']);
|
|
4
|
+
export type DrawingFont = z.infer<typeof DrawingFontSchema>;
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Palette names keep a document independent of the theme that renders it.
|
|
8
|
+
*/
|
|
9
|
+
export const DrawingColorSchema = z.enum(['ink', 'muted', 'accent', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink']);
|
|
10
|
+
export type DrawingColor = z.infer<typeof DrawingColorSchema>;
|
|
11
|
+
|
|
12
|
+
/* In the order the color menu shows them. */
|
|
13
|
+
export const DRAWING_COLORS = DrawingColorSchema.options;
|
|
14
|
+
|
|
15
|
+
export const DrawingFillSchema = z.enum(['none', 'solid', 'hachure']);
|
|
16
|
+
export type DrawingFill = z.infer<typeof DrawingFillSchema>;
|
|
17
|
+
|
|
18
|
+
export const DrawingStrokeStyleSchema = z.enum(['solid', 'dashed', 'dotted']);
|
|
19
|
+
export type DrawingStrokeStyle = z.infer<typeof DrawingStrokeStyleSchema>;
|
|
20
|
+
|
|
21
|
+
export const DrawingStrokeWidthSchema = z.union([z.literal(1), z.literal(2), z.literal(4)]);
|
|
22
|
+
export type DrawingStrokeWidth = z.infer<typeof DrawingStrokeWidthSchema>;
|
|
23
|
+
|
|
24
|
+
/* Architect, Artist, Cartoonist. Absent reads as 1, so a file written by hand looks hand drawn. */
|
|
25
|
+
export const DrawingRoughnessSchema = z.union([z.literal(0), z.literal(1), z.literal(2)]);
|
|
26
|
+
export type DrawingRoughness = z.infer<typeof DrawingRoughnessSchema>;
|
|
27
|
+
|
|
28
|
+
export const DrawingAlignSchema = z.enum(['left', 'center', 'right']);
|
|
29
|
+
export type DrawingAlign = z.infer<typeof DrawingAlignSchema>;
|
|
30
|
+
|
|
31
|
+
export const DRAWING_TEXT_SIZE_MIN = 12;
|
|
32
|
+
export const DRAWING_TEXT_SIZE_MAX = 96;
|
|
33
|
+
|
|
34
|
+
const ElementBaseSchema = z.object({
|
|
35
|
+
id: z.string().min(1),
|
|
36
|
+
x: z.number(),
|
|
37
|
+
y: z.number(),
|
|
38
|
+
w: z.number(),
|
|
39
|
+
h: z.number(),
|
|
40
|
+
// Radians around the center; absent means 0.
|
|
41
|
+
angle: z.number().optional(),
|
|
42
|
+
stroke: DrawingColorSchema,
|
|
43
|
+
strokeWidth: DrawingStrokeWidthSchema,
|
|
44
|
+
strokeStyle: DrawingStrokeStyleSchema.optional(),
|
|
45
|
+
fill: DrawingFillSchema.optional(),
|
|
46
|
+
fillColor: DrawingColorSchema.optional(),
|
|
47
|
+
roughness: DrawingRoughnessSchema.optional(),
|
|
48
|
+
// Keeps the wobble the same on every render, on every machine.
|
|
49
|
+
seed: z.number().int().nonnegative(),
|
|
50
|
+
locked: z.boolean().optional()
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const DrawingElementSchema = z.discriminatedUnion('kind', [
|
|
54
|
+
ElementBaseSchema.extend({ kind: z.literal('rect'), radius: z.number().nonnegative().optional() }),
|
|
55
|
+
ElementBaseSchema.extend({ kind: z.literal('diamond') }),
|
|
56
|
+
ElementBaseSchema.extend({ kind: z.literal('ellipse') }),
|
|
57
|
+
// Points are relative to (x, y) and their bounds are the box. An arrow is a line with a head.
|
|
58
|
+
ElementBaseSchema.extend({
|
|
59
|
+
kind: z.literal('line'),
|
|
60
|
+
points: z.array(z.tuple([z.number(), z.number()])).min(2),
|
|
61
|
+
arrowStart: z.boolean().optional(),
|
|
62
|
+
arrowEnd: z.boolean().optional()
|
|
63
|
+
}),
|
|
64
|
+
// A stroke: relative points with an optional pressure, drawn through perfect-freehand.
|
|
65
|
+
ElementBaseSchema.extend({
|
|
66
|
+
kind: z.literal('freehand'),
|
|
67
|
+
points: z.array(z.tuple([z.number(), z.number(), z.number().optional()])).min(1)
|
|
68
|
+
}),
|
|
69
|
+
ElementBaseSchema.extend({
|
|
70
|
+
kind: z.literal('text'),
|
|
71
|
+
text: z.string(),
|
|
72
|
+
size: z.number().int().min(DRAWING_TEXT_SIZE_MIN).max(DRAWING_TEXT_SIZE_MAX),
|
|
73
|
+
// Absent means 'hand', the default a drawing is written in.
|
|
74
|
+
font: DrawingFontSchema.optional(),
|
|
75
|
+
align: DrawingAlignSchema.optional(),
|
|
76
|
+
// Set once the box was dragged by hand; absent means the box follows the glyphs.
|
|
77
|
+
sized: z.boolean().optional()
|
|
78
|
+
}),
|
|
79
|
+
/*
|
|
80
|
+
* A sticky note: a sheet of paper in `fillColor` with the text written on it. It is one element
|
|
81
|
+
* rather than a shape with a text on top, so moving the note takes what it says along.
|
|
82
|
+
*/
|
|
83
|
+
ElementBaseSchema.extend({
|
|
84
|
+
kind: z.literal('note'),
|
|
85
|
+
text: z.string(),
|
|
86
|
+
size: z.number().int().min(DRAWING_TEXT_SIZE_MIN).max(DRAWING_TEXT_SIZE_MAX),
|
|
87
|
+
font: DrawingFontSchema.optional(),
|
|
88
|
+
align: DrawingAlignSchema.optional()
|
|
89
|
+
})
|
|
90
|
+
]);
|
|
91
|
+
export type DrawingElement = z.infer<typeof DrawingElementSchema>;
|
|
92
|
+
export type DrawingElementKind = DrawingElement['kind'];
|
|
93
|
+
|
|
94
|
+
export const DRAWING_VERSION = 1;
|
|
95
|
+
|
|
96
|
+
export const DrawingContentSchema = z.object({
|
|
97
|
+
// In stacking order, back to front.
|
|
98
|
+
elements: z.array(DrawingElementSchema)
|
|
99
|
+
});
|
|
100
|
+
export type DrawingContent = z.infer<typeof DrawingContentSchema>;
|
|
101
|
+
|
|
102
|
+
export const DrawingDocumentSchema = DrawingContentSchema.extend({
|
|
103
|
+
version: z.literal(DRAWING_VERSION),
|
|
104
|
+
// Goes up by one on every write; a save that names an older rev is a conflict.
|
|
105
|
+
rev: z.number().int().nonnegative()
|
|
106
|
+
});
|
|
107
|
+
export type DrawingDocument = z.infer<typeof DrawingDocumentSchema>;
|
|
108
|
+
|
|
109
|
+
export const EMPTY_DRAWING: DrawingDocument = { version: 1, rev: 0, elements: [] };
|
|
110
|
+
|
|
111
|
+
/* One version so far, so reading is a parse. A file that is not a drawing at all reads as null. */
|
|
112
|
+
export function migrateDrawing(value: unknown): DrawingDocument | null {
|
|
113
|
+
const parsed = DrawingDocumentSchema.safeParse(value);
|
|
114
|
+
return parsed.success ? parsed.data : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/*
|
|
118
|
+
* Element ids never leave their own file, so they only have to be unique inside it. A repeat would
|
|
119
|
+
* make selection and hit tests pick two elements at once, so such a file is refused by name.
|
|
120
|
+
*/
|
|
121
|
+
export function duplicateElementIdIn(elements: DrawingElement[]): string | null {
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
for (const element of elements) {
|
|
124
|
+
if (seen.has(element.id)) {
|
|
125
|
+
return element.id;
|
|
126
|
+
}
|
|
127
|
+
seen.add(element.id);
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { DrawingElement } from './protocol.ts';
|
|
2
|
+
import { absolutePoints, boundsOf, centerOf, hitsElement, type Point } from './geometry.ts';
|
|
3
|
+
|
|
4
|
+
/* Two texts within this many units of each other are on the same line and read left to right. */
|
|
5
|
+
const ROW_TOLERANCE = 24;
|
|
6
|
+
|
|
7
|
+
/* How far from an arrow's end a label may sit and still be what the arrow points at. */
|
|
8
|
+
const LABEL_REACH = 48;
|
|
9
|
+
|
|
10
|
+
function labelOf(element: DrawingElement): string | null {
|
|
11
|
+
return element.kind === 'text' || element.kind === 'note' ? element.text.trim() || null : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* A shape says what it is by the text inside it, which is how a box on a diagram is named. */
|
|
15
|
+
function labelInside(element: DrawingElement, elements: readonly DrawingElement[]): string | null {
|
|
16
|
+
for (const candidate of elements) {
|
|
17
|
+
if (candidate.kind !== 'text' || candidate.id === element.id) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const center = centerOf(boundsOf(candidate));
|
|
21
|
+
if (hitsElement({ ...element, fill: 'solid' }, center, 0)) {
|
|
22
|
+
return labelOf(candidate);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* What an arrow points at: the text at that end, or the shape there and the text it holds. */
|
|
29
|
+
function labelAt(point: Point, elements: readonly DrawingElement[], arrowId: string): string | null {
|
|
30
|
+
let best: { label: string; distance: number } | null = null;
|
|
31
|
+
for (const element of elements) {
|
|
32
|
+
if (element.id === arrowId || element.kind === 'line' || element.kind === 'freehand') {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const center = centerOf(boundsOf(element));
|
|
36
|
+
const distance = Math.hypot(center.x - point.x, center.y - point.y);
|
|
37
|
+
const inside = hitsElement({ ...element, fill: 'solid' }, point, LABEL_REACH);
|
|
38
|
+
if (!inside && distance > LABEL_REACH) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const label = labelOf(element) ?? labelInside(element, elements);
|
|
42
|
+
if (label && (!best || distance < best.distance)) {
|
|
43
|
+
best = { label, distance };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return best?.label ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/*
|
|
50
|
+
* A drawing as lines an agent can read: the texts top to bottom and left to right, then every
|
|
51
|
+
* arrow as the two things it connects. A diagram becomes a list without anyone looking at it.
|
|
52
|
+
*/
|
|
53
|
+
export function readingOrder(elements: readonly DrawingElement[]): string[] {
|
|
54
|
+
// A row is measured from its top text, so a staircase of texts cannot chain into one long line.
|
|
55
|
+
const rows: DrawingElement[][] = [];
|
|
56
|
+
const labeled = elements.filter((element) => labelOf(element) !== null).sort((left, right) => left.y - right.y || left.x - right.x);
|
|
57
|
+
for (const element of labeled) {
|
|
58
|
+
const row = rows.at(-1);
|
|
59
|
+
if (row && element.y - row[0]!.y <= ROW_TOLERANCE) {
|
|
60
|
+
row.push(element);
|
|
61
|
+
} else {
|
|
62
|
+
rows.push([element]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const texts = rows.flatMap((row) => row.sort((left, right) => left.x - right.x || left.y - right.y)).map((element) => labelOf(element)!);
|
|
66
|
+
|
|
67
|
+
const arrows: string[] = [];
|
|
68
|
+
for (const element of elements) {
|
|
69
|
+
if (element.kind !== 'line' || (!element.arrowEnd && !element.arrowStart)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const points = absolutePoints(element);
|
|
73
|
+
const from = labelAt(points[0]!, elements, element.id);
|
|
74
|
+
const to = labelAt(points.at(-1)!, elements, element.id);
|
|
75
|
+
if (!from || !to || from === to) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// An arrow that only has a head at its start reads the other way around.
|
|
79
|
+
arrows.push(element.arrowEnd ? `${from} -> ${to}` : `${to} -> ${from}`);
|
|
80
|
+
}
|
|
81
|
+
return [...texts, ...arrows];
|
|
82
|
+
}
|