@adecore/drawing 0.38.0 → 0.40.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/dist/geometry.d.ts +1 -0
- package/dist/geometry.js +12 -16
- package/dist/paths.js +4 -5
- package/dist/protocol.js +1 -3
- package/dist/reading-order.js +1 -2
- package/dist/rebase.js +10 -10
- package/dist/svg.js +2 -1
- package/dist/text.d.ts +0 -1
- package/dist/text.js +2 -5
- package/package.json +4 -4
- package/src/edits.ts +23 -0
- package/src/geometry.ts +51 -16
- package/src/paths.ts +21 -5
- package/src/protocol.ts +5 -3
- package/src/reading-order.ts +18 -2
- package/src/rebase.ts +24 -10
- package/src/svg.ts +12 -1
- package/src/text.ts +15 -6
package/dist/geometry.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export declare function drawnBoundsOfElements(elements: readonly DrawingElement[
|
|
|
22
22
|
export declare function intersects(a: Rect, b: Rect): boolean;
|
|
23
23
|
export declare function rectFromPoints(from: Point, to: Point): Rect;
|
|
24
24
|
export declare function absolutePoints(element: DrawingElement): Point[];
|
|
25
|
+
export declare function isFilled(element: DrawingElement): boolean;
|
|
25
26
|
export declare function hitsElement(element: DrawingElement, point: Point, tolerance: number): boolean;
|
|
26
27
|
export declare function elementAt(elements: readonly DrawingElement[], point: Point, tolerance: number): DrawingElement | undefined;
|
|
27
28
|
export declare function elementsIn(elements: readonly DrawingElement[], rect: Rect): DrawingElement[];
|
package/dist/geometry.js
CHANGED
|
@@ -42,6 +42,14 @@ export function unionOf(rects) {
|
|
|
42
42
|
export function boundsOfElements(elements) {
|
|
43
43
|
return unionOf(elements.map(boundsOf));
|
|
44
44
|
}
|
|
45
|
+
function cornersOf(rect) {
|
|
46
|
+
return [
|
|
47
|
+
{ x: rect.x, y: rect.y },
|
|
48
|
+
{ x: rect.x + rect.w, y: rect.y },
|
|
49
|
+
{ x: rect.x + rect.w, y: rect.y + rect.h },
|
|
50
|
+
{ x: rect.x, y: rect.y + rect.h }
|
|
51
|
+
];
|
|
52
|
+
}
|
|
45
53
|
/* The box around an element as it is drawn: the stored box, turned by its angle. */
|
|
46
54
|
export function drawnBoundsOf(element) {
|
|
47
55
|
const rect = boundsOf(element);
|
|
@@ -50,12 +58,7 @@ export function drawnBoundsOf(element) {
|
|
|
50
58
|
return rect;
|
|
51
59
|
}
|
|
52
60
|
const center = centerOf(rect);
|
|
53
|
-
const corners =
|
|
54
|
-
{ x: rect.x, y: rect.y },
|
|
55
|
-
{ x: rect.x + rect.w, y: rect.y },
|
|
56
|
-
{ x: rect.x + rect.w, y: rect.y + rect.h },
|
|
57
|
-
{ x: rect.x, y: rect.y + rect.h }
|
|
58
|
-
].map((corner) => rotatePoint(corner, center, angle));
|
|
61
|
+
const corners = cornersOf(rect).map((corner) => rotatePoint(corner, center, angle));
|
|
59
62
|
const xs = corners.map((corner) => corner.x);
|
|
60
63
|
const ys = corners.map((corner) => corner.y);
|
|
61
64
|
const left = Math.min(...xs);
|
|
@@ -92,7 +95,7 @@ export function absolutePoints(element) {
|
|
|
92
95
|
}
|
|
93
96
|
return element.points.map(([x, y]) => ({ x: element.x + x, y: element.y + y }));
|
|
94
97
|
}
|
|
95
|
-
function isFilled(element) {
|
|
98
|
+
export function isFilled(element) {
|
|
96
99
|
return element.fill !== undefined && element.fill !== 'none';
|
|
97
100
|
}
|
|
98
101
|
function cornersOfDiamond(rect) {
|
|
@@ -139,15 +142,8 @@ export function hitsElement(element, point, tolerance) {
|
|
|
139
142
|
case 'text':
|
|
140
143
|
case 'note':
|
|
141
144
|
return insideRect(local, rect);
|
|
142
|
-
case 'rect':
|
|
143
|
-
|
|
144
|
-
{ x: rect.x, y: rect.y },
|
|
145
|
-
{ x: rect.x + rect.w, y: rect.y },
|
|
146
|
-
{ x: rect.x + rect.w, y: rect.y + rect.h },
|
|
147
|
-
{ x: rect.x, y: rect.y + rect.h }
|
|
148
|
-
];
|
|
149
|
-
return isFilled(element) ? insideRect(local, rect) : nearPolygon(local, corners, reach);
|
|
150
|
-
}
|
|
145
|
+
case 'rect':
|
|
146
|
+
return isFilled(element) ? insideRect(local, rect) : nearPolygon(local, cornersOf(rect), reach);
|
|
151
147
|
case 'diamond': {
|
|
152
148
|
const corners = cornersOfDiamond(rect);
|
|
153
149
|
return isFilled(element) ? insidePolygon(local, corners) : nearPolygon(local, corners, reach);
|
package/dist/paths.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getStroke } from 'perfect-freehand';
|
|
2
2
|
// Rough's unbundled modules use extensionless imports; its package entry also runs in Node.
|
|
3
3
|
import rough from 'roughjs';
|
|
4
|
-
import { arrowHead, arrowHeadSize } from './geometry.js';
|
|
4
|
+
import { arrowHead, arrowHeadSize, isFilled } from './geometry.js';
|
|
5
5
|
import { NOTE_RADIUS } from './text.js';
|
|
6
6
|
// Sentinels, not colors: rough hands them back on every path, which is how a path says what it is.
|
|
7
7
|
const STROKE = '#000001';
|
|
@@ -19,7 +19,6 @@ function dashOf(element) {
|
|
|
19
19
|
}
|
|
20
20
|
return null;
|
|
21
21
|
}
|
|
22
|
-
/* Architect, Artist, Cartoonist. Absent reads as Artist, which is what a hand-written file gets. */
|
|
23
22
|
function roughnessOf(element) {
|
|
24
23
|
switch (element.roughness ?? 1) {
|
|
25
24
|
case 0:
|
|
@@ -36,7 +35,7 @@ function optionsOf(element) {
|
|
|
36
35
|
stroke: STROKE,
|
|
37
36
|
strokeWidth: element.strokeWidth,
|
|
38
37
|
...roughnessOf(element),
|
|
39
|
-
...(element
|
|
38
|
+
...(isFilled(element)
|
|
40
39
|
? { fill: FILL, fillStyle: element.fill === 'solid' ? 'solid' : 'hachure', fillWeight: element.strokeWidth / 2, hachureGap: 8 }
|
|
41
40
|
: {})
|
|
42
41
|
};
|
|
@@ -84,11 +83,11 @@ export function pathsOfElement(element) {
|
|
|
84
83
|
{ d: sheet, role: 'stroke', strokeWidth: element.strokeWidth, dash: null }
|
|
85
84
|
];
|
|
86
85
|
}
|
|
87
|
-
const options = optionsOf(element);
|
|
88
|
-
const dash = dashOf(element);
|
|
89
86
|
if (element.kind === 'freehand') {
|
|
90
87
|
return [{ d: freehandOutline(element), role: 'ink', strokeWidth: 0, dash: null }];
|
|
91
88
|
}
|
|
89
|
+
const options = optionsOf(element);
|
|
90
|
+
const dash = dashOf(element);
|
|
92
91
|
const generator = drawingGenerator();
|
|
93
92
|
const drawable = (() => {
|
|
94
93
|
switch (element.kind) {
|
package/dist/protocol.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export const DrawingFontSchema = z.enum(['hand', 'sans', 'mono']);
|
|
3
|
-
/*
|
|
4
|
-
* Palette names keep a document independent of the theme that renders it.
|
|
5
|
-
*/
|
|
3
|
+
/* Palette names keep a document independent of the theme that renders it. */
|
|
6
4
|
export const DrawingColorSchema = z.enum(['ink', 'muted', 'accent', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink']);
|
|
7
5
|
/* In the order the color menu shows them. */
|
|
8
6
|
export const DRAWING_COLORS = DrawingColorSchema.options;
|
package/dist/reading-order.js
CHANGED
|
@@ -28,8 +28,7 @@ function labelAt(point, elements, arrowId) {
|
|
|
28
28
|
}
|
|
29
29
|
const center = centerOf(boundsOf(element));
|
|
30
30
|
const distance = Math.hypot(center.x - point.x, center.y - point.y);
|
|
31
|
-
|
|
32
|
-
if (!inside && distance > LABEL_REACH) {
|
|
31
|
+
if (distance > LABEL_REACH && !hitsElement({ ...element, fill: 'solid' }, point, LABEL_REACH)) {
|
|
33
32
|
continue;
|
|
34
33
|
}
|
|
35
34
|
const label = labelOf(element) ?? labelInside(element, elements);
|
package/dist/rebase.js
CHANGED
|
@@ -57,16 +57,16 @@ export function rebaseDrawingElements(base, mine, theirs) {
|
|
|
57
57
|
const [leading, following] = mineLeads ? [mine, theirs] : [theirs, mine];
|
|
58
58
|
const order = leading.map((element) => element.id).filter((id) => resolved.has(id));
|
|
59
59
|
const placed = new Set(order);
|
|
60
|
-
following
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// The last element before this one on the following side that has a place, which it goes just above.
|
|
61
|
+
let below;
|
|
62
|
+
for (const element of following) {
|
|
63
|
+
if (!placed.has(element.id) && resolved.has(element.id)) {
|
|
64
|
+
order.splice(below === undefined ? 0 : order.indexOf(below) + 1, 0, element.id);
|
|
65
|
+
placed.add(element.id);
|
|
63
66
|
}
|
|
64
|
-
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
order.splice(below === undefined ? 0 : order.indexOf(below) + 1, 0, element.id);
|
|
69
|
-
placed.add(element.id);
|
|
70
|
-
});
|
|
67
|
+
if (placed.has(element.id)) {
|
|
68
|
+
below = element.id;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
71
|
return { ok: true, elements: order.flatMap((id) => resolved.get(id) ?? []) };
|
|
72
72
|
}
|
package/dist/svg.js
CHANGED
|
@@ -56,7 +56,8 @@ function transformOf(element) {
|
|
|
56
56
|
return `translate(${round(element.x)} ${round(element.y)})${turn}`;
|
|
57
57
|
}
|
|
58
58
|
function textSvg(element, options) {
|
|
59
|
-
const
|
|
59
|
+
const font = fontOf(element.font);
|
|
60
|
+
const family = options.fonts?.[font] ?? DEFAULT_FONT_STACKS[font];
|
|
60
61
|
const anchor = element.align === 'center' ? 'middle' : element.align === 'right' ? 'end' : 'start';
|
|
61
62
|
const frame = writingFrameOf(element);
|
|
62
63
|
const dx = frame.x + (element.align === 'center' ? frame.w / 2 : element.align === 'right' ? frame.w : 0);
|
package/dist/text.d.ts
CHANGED
|
@@ -11,7 +11,6 @@ export declare function writingFrameOf(element: WrittenElement): {
|
|
|
11
11
|
y: number;
|
|
12
12
|
w: number;
|
|
13
13
|
};
|
|
14
|
-
export declare function textLines(text: string): string[];
|
|
15
14
|
export type MeasureLine = (line: string) => number;
|
|
16
15
|
export declare function approximateMeasure(size: number, font: DrawingFont | undefined): MeasureLine;
|
|
17
16
|
export declare function wrapLines(text: string, maxWidth: number, measure: MeasureLine): string[];
|
package/dist/text.js
CHANGED
|
@@ -17,9 +17,6 @@ export const NOTE_RADIUS = 8;
|
|
|
17
17
|
export function writingFrameOf(element) {
|
|
18
18
|
return element.kind === 'note' ? { x: NOTE_PADDING, y: NOTE_PADDING, w: Math.max(1, element.w - NOTE_PADDING * 2) } : { x: 0, y: 0, w: element.w };
|
|
19
19
|
}
|
|
20
|
-
export function textLines(text) {
|
|
21
|
-
return text.split('\n');
|
|
22
|
-
}
|
|
23
20
|
/* A measure for where no font exists, such as on a server: most faces average a glyph a little over half its size wide. */
|
|
24
21
|
export function approximateMeasure(size, font) {
|
|
25
22
|
const glyph = fontOf(font) === 'mono' ? 0.6 : 0.55;
|
|
@@ -56,14 +53,14 @@ function wrapParagraph(paragraph, maxWidth, measure) {
|
|
|
56
53
|
return lines;
|
|
57
54
|
}
|
|
58
55
|
export function wrapLines(text, maxWidth, measure) {
|
|
59
|
-
return
|
|
56
|
+
return text.split('\n').flatMap((paragraph) => wrapParagraph(paragraph, maxWidth, measure));
|
|
60
57
|
}
|
|
61
58
|
// An unsized text element follows its content; once sized, its box controls wrapping.
|
|
62
59
|
export function linesOf(element, measure) {
|
|
63
60
|
if (element.kind === 'note') {
|
|
64
61
|
return wrapLines(element.text, writingFrameOf(element).w, measure);
|
|
65
62
|
}
|
|
66
|
-
return element.sized ? wrapLines(element.text, element.w, measure) :
|
|
63
|
+
return element.sized ? wrapLines(element.text, element.w, measure) : element.text.split('\n');
|
|
67
64
|
}
|
|
68
65
|
/* Absent means hand: a drawing is written by hand unless it says otherwise. */
|
|
69
66
|
export function fontOf(font) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/drawing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Drawing schemas, geometry, deterministic paths, SVG export and reading order.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
6
|
"author": "Bas Milius",
|
|
@@ -40,13 +40,13 @@
|
|
|
40
40
|
"test": "bun test --conditions=source"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@types/bun": "^1.4.
|
|
44
|
-
"@types/node": "^24.
|
|
43
|
+
"@types/bun": "^1.4.4",
|
|
44
|
+
"@types/node": "^24.19.2",
|
|
45
45
|
"typescript": "^7.0.2"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"perfect-freehand": "^1.2.3",
|
|
49
49
|
"roughjs": "^4.6.6",
|
|
50
|
-
"zod": "^4.6.
|
|
50
|
+
"zod": "^4.6.7"
|
|
51
51
|
}
|
|
52
52
|
}
|
package/src/edits.ts
CHANGED
|
@@ -115,8 +115,10 @@ function touched(edit: DrawingEdit): number {
|
|
|
115
115
|
function idProblems(byId: ReadonlyMap<string, DrawingElement>, edit: DrawingEdit): DrawingEditProblem[] {
|
|
116
116
|
const problems: DrawingEditProblem[] = [];
|
|
117
117
|
const named = new Set<string>();
|
|
118
|
+
|
|
118
119
|
const check = (id: string, path: (string | number)[]): void => {
|
|
119
120
|
const element = byId.get(id);
|
|
121
|
+
|
|
120
122
|
if (element === undefined) {
|
|
121
123
|
problems.push({ path, message: `the drawing has no element ${id}` });
|
|
122
124
|
} else if (element.locked === true) {
|
|
@@ -124,21 +126,27 @@ function idProblems(byId: ReadonlyMap<string, DrawingElement>, edit: DrawingEdit
|
|
|
124
126
|
} else if (named.has(id)) {
|
|
125
127
|
problems.push({ path, message: `${id} is named twice in this edit` });
|
|
126
128
|
}
|
|
129
|
+
|
|
127
130
|
named.add(id);
|
|
128
131
|
};
|
|
132
|
+
|
|
129
133
|
edit.update?.forEach((patch, i) => check(patch.id, ['update', i, 'id']));
|
|
130
134
|
edit.remove?.forEach((id, i) => check(id, ['remove', i]));
|
|
135
|
+
|
|
131
136
|
return problems;
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
/* The problems of fields a patch sets that its element's kind does not have. */
|
|
135
140
|
function kindProblems(byId: ReadonlyMap<string, DrawingElement>, edit: DrawingEdit): DrawingEditProblem[] {
|
|
136
141
|
const shared = new Set(Object.keys(placed));
|
|
142
|
+
|
|
137
143
|
return (edit.update ?? []).flatMap((patch, i) => {
|
|
138
144
|
const element = byId.get(patch.id);
|
|
145
|
+
|
|
139
146
|
if (element === undefined) {
|
|
140
147
|
return [];
|
|
141
148
|
}
|
|
149
|
+
|
|
142
150
|
return Object.keys(patch)
|
|
143
151
|
.filter((key) => key !== 'id' && !shared.has(key) && !KIND_FIELDS[element.kind].includes(key))
|
|
144
152
|
.map((key) => ({ path: ['update', i, key], message: `${patch.id} is a ${element.kind}, which has no field ${key}` }));
|
|
@@ -147,6 +155,7 @@ function kindProblems(byId: ReadonlyMap<string, DrawingElement>, edit: DrawingEd
|
|
|
147
155
|
|
|
148
156
|
function created(element: NewDrawingElement, mint: DrawingEditMint): DrawingElement {
|
|
149
157
|
const base = { stroke: 'ink' as const, strokeWidth: 2 as const, ...element, id: mint.id(), seed: mint.seed() };
|
|
158
|
+
|
|
150
159
|
return DrawingElementSchema.parse(base.kind === 'text' || base.kind === 'note' ? { size: 20, ...base } : base);
|
|
151
160
|
}
|
|
152
161
|
|
|
@@ -159,42 +168,56 @@ export function applyDrawingEdit(elements: readonly DrawingElement[], edit: Draw
|
|
|
159
168
|
if (touched(edit) > DRAWING_EDIT_LIMITS.perEdit) {
|
|
160
169
|
return { ok: false, problems: [{ path: [], message: `an edit touches at most ${DRAWING_EDIT_LIMITS.perEdit} elements; split it` }] };
|
|
161
170
|
}
|
|
171
|
+
|
|
162
172
|
const byId = new Map(elements.map((element) => [element.id, element]));
|
|
163
173
|
const problems = [...idProblems(byId, edit), ...kindProblems(byId, edit)];
|
|
164
174
|
const patches = new Map((edit.update ?? []).map((patch) => [patch.id, patch]));
|
|
165
175
|
const removed = new Set(edit.remove ?? []);
|
|
166
176
|
const next: DrawingElement[] = [];
|
|
177
|
+
|
|
167
178
|
for (const element of elements) {
|
|
168
179
|
if (removed.has(element.id)) {
|
|
169
180
|
continue;
|
|
170
181
|
}
|
|
182
|
+
|
|
171
183
|
const patch = patches.get(element.id);
|
|
184
|
+
|
|
172
185
|
if (patch === undefined) {
|
|
173
186
|
next.push(element);
|
|
187
|
+
|
|
174
188
|
continue;
|
|
175
189
|
}
|
|
190
|
+
|
|
176
191
|
const parsed = DrawingElementSchema.safeParse({ ...element, ...patch });
|
|
192
|
+
|
|
177
193
|
if (!parsed.success) {
|
|
178
194
|
const index = edit.update!.indexOf(patch);
|
|
195
|
+
|
|
179
196
|
problems.push(
|
|
180
197
|
...parsed.error.issues.map((issue) => ({
|
|
181
198
|
path: ['update', index, ...issue.path.map((key) => (typeof key === 'number' ? key : String(key)))],
|
|
182
199
|
message: issue.message
|
|
183
200
|
}))
|
|
184
201
|
);
|
|
202
|
+
|
|
185
203
|
continue;
|
|
186
204
|
}
|
|
205
|
+
|
|
187
206
|
next.push(parsed.data);
|
|
188
207
|
}
|
|
208
|
+
|
|
189
209
|
const added = (edit.add ?? []).map((element) => created(element, mint));
|
|
210
|
+
|
|
190
211
|
if (next.length + added.length > DRAWING_EDIT_LIMITS.elements) {
|
|
191
212
|
problems.push({
|
|
192
213
|
path: ['add'],
|
|
193
214
|
message: `a drawing holds at most ${DRAWING_EDIT_LIMITS.elements} elements, and this edit would leave it at ${next.length + added.length}`
|
|
194
215
|
});
|
|
195
216
|
}
|
|
217
|
+
|
|
196
218
|
if (problems.length > 0) {
|
|
197
219
|
return { ok: false, problems };
|
|
198
220
|
}
|
|
221
|
+
|
|
199
222
|
return { ok: true, elements: [...next, ...added], added: added.map((element) => element.id) };
|
|
200
223
|
}
|
package/src/geometry.ts
CHANGED
|
@@ -32,10 +32,12 @@ export function rotatePoint(point: Point, around: Point, angle: number): Point {
|
|
|
32
32
|
if (angle === 0) {
|
|
33
33
|
return point;
|
|
34
34
|
}
|
|
35
|
+
|
|
35
36
|
const cos = Math.cos(angle);
|
|
36
37
|
const sin = Math.sin(angle);
|
|
37
38
|
const dx = point.x - around.x;
|
|
38
39
|
const dy = point.y - around.y;
|
|
40
|
+
|
|
39
41
|
return { x: around.x + dx * cos - dy * sin, y: around.y + dx * sin + dy * cos };
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -48,16 +50,19 @@ export function unionOf(rects: readonly Rect[]): Rect | null {
|
|
|
48
50
|
if (rects.length === 0) {
|
|
49
51
|
return null;
|
|
50
52
|
}
|
|
53
|
+
|
|
51
54
|
let left = Infinity;
|
|
52
55
|
let top = Infinity;
|
|
53
56
|
let right = -Infinity;
|
|
54
57
|
let bottom = -Infinity;
|
|
58
|
+
|
|
55
59
|
for (const rect of rects) {
|
|
56
60
|
left = Math.min(left, rect.x, rect.x + rect.w);
|
|
57
61
|
top = Math.min(top, rect.y, rect.y + rect.h);
|
|
58
62
|
right = Math.max(right, rect.x, rect.x + rect.w);
|
|
59
63
|
bottom = Math.max(bottom, rect.y, rect.y + rect.h);
|
|
60
64
|
}
|
|
65
|
+
|
|
61
66
|
return { x: left, y: top, w: right - left, h: bottom - top };
|
|
62
67
|
}
|
|
63
68
|
|
|
@@ -65,24 +70,31 @@ export function boundsOfElements(elements: readonly DrawingElement[]): Rect | nu
|
|
|
65
70
|
return unionOf(elements.map(boundsOf));
|
|
66
71
|
}
|
|
67
72
|
|
|
73
|
+
function cornersOf(rect: Rect): Point[] {
|
|
74
|
+
return [
|
|
75
|
+
{ x: rect.x, y: rect.y },
|
|
76
|
+
{ x: rect.x + rect.w, y: rect.y },
|
|
77
|
+
{ x: rect.x + rect.w, y: rect.y + rect.h },
|
|
78
|
+
{ x: rect.x, y: rect.y + rect.h }
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
/* The box around an element as it is drawn: the stored box, turned by its angle. */
|
|
69
83
|
export function drawnBoundsOf(element: DrawingElement): Rect {
|
|
70
84
|
const rect = boundsOf(element);
|
|
71
85
|
const angle = element.angle ?? 0;
|
|
86
|
+
|
|
72
87
|
if (angle === 0) {
|
|
73
88
|
return rect;
|
|
74
89
|
}
|
|
90
|
+
|
|
75
91
|
const center = centerOf(rect);
|
|
76
|
-
const corners =
|
|
77
|
-
{ x: rect.x, y: rect.y },
|
|
78
|
-
{ x: rect.x + rect.w, y: rect.y },
|
|
79
|
-
{ x: rect.x + rect.w, y: rect.y + rect.h },
|
|
80
|
-
{ x: rect.x, y: rect.y + rect.h }
|
|
81
|
-
].map((corner) => rotatePoint(corner, center, angle));
|
|
92
|
+
const corners = cornersOf(rect).map((corner) => rotatePoint(corner, center, angle));
|
|
82
93
|
const xs = corners.map((corner) => corner.x);
|
|
83
94
|
const ys = corners.map((corner) => corner.y);
|
|
84
95
|
const left = Math.min(...xs);
|
|
85
96
|
const top = Math.min(...ys);
|
|
97
|
+
|
|
86
98
|
return { x: left, y: top, w: Math.max(...xs) - left, h: Math.max(...ys) - top };
|
|
87
99
|
}
|
|
88
100
|
|
|
@@ -107,10 +119,13 @@ function distanceToSegment(point: Point, a: Point, b: Point): number {
|
|
|
107
119
|
const dx = b.x - a.x;
|
|
108
120
|
const dy = b.y - a.y;
|
|
109
121
|
const length = dx * dx + dy * dy;
|
|
122
|
+
|
|
110
123
|
if (length === 0) {
|
|
111
124
|
return Math.hypot(point.x - a.x, point.y - a.y);
|
|
112
125
|
}
|
|
126
|
+
|
|
113
127
|
const along = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / length));
|
|
128
|
+
|
|
114
129
|
return Math.hypot(point.x - (a.x + along * dx), point.y - (a.y + along * dy));
|
|
115
130
|
}
|
|
116
131
|
|
|
@@ -118,10 +133,11 @@ export function absolutePoints(element: DrawingElement): Point[] {
|
|
|
118
133
|
if (element.kind !== 'line' && element.kind !== 'freehand') {
|
|
119
134
|
return [];
|
|
120
135
|
}
|
|
136
|
+
|
|
121
137
|
return element.points.map(([x, y]) => ({ x: element.x + x, y: element.y + y }));
|
|
122
138
|
}
|
|
123
139
|
|
|
124
|
-
function isFilled(element: DrawingElement): boolean {
|
|
140
|
+
export function isFilled(element: DrawingElement): boolean {
|
|
125
141
|
return element.fill !== undefined && element.fill !== 'none';
|
|
126
142
|
}
|
|
127
143
|
|
|
@@ -136,13 +152,16 @@ function cornersOfDiamond(rect: Rect): Point[] {
|
|
|
136
152
|
|
|
137
153
|
function insidePolygon(point: Point, polygon: readonly Point[]): boolean {
|
|
138
154
|
let inside = false;
|
|
155
|
+
|
|
139
156
|
for (let i = 0, previous = polygon.length - 1; i < polygon.length; previous = i++) {
|
|
140
157
|
const corner = polygon[i]!;
|
|
141
158
|
const before = polygon[previous]!;
|
|
159
|
+
|
|
142
160
|
if (corner.y > point.y !== before.y > point.y && point.x < ((before.x - corner.x) * (point.y - corner.y)) / (before.y - corner.y) + corner.x) {
|
|
143
161
|
inside = !inside;
|
|
144
162
|
}
|
|
145
163
|
}
|
|
164
|
+
|
|
146
165
|
return inside;
|
|
147
166
|
}
|
|
148
167
|
|
|
@@ -154,6 +173,7 @@ function nearPolyline(point: Point, points: readonly Point[], tolerance: number)
|
|
|
154
173
|
if (points.length === 1) {
|
|
155
174
|
return Math.hypot(point.x - points[0]!.x, point.y - points[0]!.y) <= tolerance;
|
|
156
175
|
}
|
|
176
|
+
|
|
157
177
|
return points.slice(0, -1).some((from, index) => distanceToSegment(point, from, points[index + 1]!) <= tolerance);
|
|
158
178
|
}
|
|
159
179
|
|
|
@@ -169,22 +189,17 @@ export function hitsElement(element: DrawingElement, point: Point, tolerance: nu
|
|
|
169
189
|
const local = toLocal(element, point);
|
|
170
190
|
const rect = boundsOf(element);
|
|
171
191
|
const reach = Math.max(tolerance, element.strokeWidth);
|
|
192
|
+
|
|
172
193
|
switch (element.kind) {
|
|
173
194
|
// Both are their own sheet: anywhere on it is on them.
|
|
174
195
|
case 'text':
|
|
175
196
|
case 'note':
|
|
176
197
|
return insideRect(local, rect);
|
|
177
|
-
case 'rect':
|
|
178
|
-
|
|
179
|
-
{ x: rect.x, y: rect.y },
|
|
180
|
-
{ x: rect.x + rect.w, y: rect.y },
|
|
181
|
-
{ x: rect.x + rect.w, y: rect.y + rect.h },
|
|
182
|
-
{ x: rect.x, y: rect.y + rect.h }
|
|
183
|
-
];
|
|
184
|
-
return isFilled(element) ? insideRect(local, rect) : nearPolygon(local, corners, reach);
|
|
185
|
-
}
|
|
198
|
+
case 'rect':
|
|
199
|
+
return isFilled(element) ? insideRect(local, rect) : nearPolygon(local, cornersOf(rect), reach);
|
|
186
200
|
case 'diamond': {
|
|
187
201
|
const corners = cornersOfDiamond(rect);
|
|
202
|
+
|
|
188
203
|
return isFilled(element) ? insidePolygon(local, corners) : nearPolygon(local, corners, reach);
|
|
189
204
|
}
|
|
190
205
|
case 'ellipse': {
|
|
@@ -192,9 +207,11 @@ export function hitsElement(element: DrawingElement, point: Point, tolerance: nu
|
|
|
192
207
|
const rx = Math.max(rect.w / 2, 0.01);
|
|
193
208
|
const ry = Math.max(rect.h / 2, 0.01);
|
|
194
209
|
const distance = Math.hypot((local.x - center.x) / rx, (local.y - center.y) / ry);
|
|
210
|
+
|
|
195
211
|
if (isFilled(element)) {
|
|
196
212
|
return distance <= 1;
|
|
197
213
|
}
|
|
214
|
+
|
|
198
215
|
return Math.abs(distance - 1) <= reach / Math.min(rx, ry);
|
|
199
216
|
}
|
|
200
217
|
case 'line':
|
|
@@ -206,10 +223,12 @@ export function hitsElement(element: DrawingElement, point: Point, tolerance: nu
|
|
|
206
223
|
export function elementAt(elements: readonly DrawingElement[], point: Point, tolerance: number): DrawingElement | undefined {
|
|
207
224
|
for (let i = elements.length - 1; i >= 0; i--) {
|
|
208
225
|
const element = elements[i]!;
|
|
226
|
+
|
|
209
227
|
if (!element.locked && hitsElement(element, point, tolerance)) {
|
|
210
228
|
return element;
|
|
211
229
|
}
|
|
212
230
|
}
|
|
231
|
+
|
|
213
232
|
return undefined;
|
|
214
233
|
}
|
|
215
234
|
|
|
@@ -220,6 +239,7 @@ export function elementsIn(elements: readonly DrawingElement[], rect: Rect): Dra
|
|
|
220
239
|
export function handlePoint(rect: Rect, handle: ResizeHandle): Point {
|
|
221
240
|
const x = handle.includes('w') ? rect.x : handle.includes('e') ? rect.x + rect.w : rect.x + rect.w / 2;
|
|
222
241
|
const y = handle.startsWith('n') ? rect.y : handle.startsWith('s') ? rect.y + rect.h : rect.y + rect.h / 2;
|
|
242
|
+
|
|
223
243
|
return { x, y };
|
|
224
244
|
}
|
|
225
245
|
|
|
@@ -229,40 +249,50 @@ export function handlePoint(rect: Rect, handle: ResizeHandle): Point {
|
|
|
229
249
|
*/
|
|
230
250
|
export function resizeRect(rect: Rect, handle: ResizeHandle, point: Point, aspect = false): Rect {
|
|
231
251
|
let { x, y, w, h } = rect;
|
|
252
|
+
|
|
232
253
|
if (handle.includes('w')) {
|
|
233
254
|
w = rect.x + rect.w - point.x;
|
|
234
255
|
x = point.x;
|
|
235
256
|
}
|
|
257
|
+
|
|
236
258
|
if (handle.includes('e')) {
|
|
237
259
|
w = point.x - rect.x;
|
|
238
260
|
}
|
|
261
|
+
|
|
239
262
|
if (handle.startsWith('n')) {
|
|
240
263
|
h = rect.y + rect.h - point.y;
|
|
241
264
|
y = point.y;
|
|
242
265
|
}
|
|
266
|
+
|
|
243
267
|
if (handle.startsWith('s')) {
|
|
244
268
|
h = point.y - rect.y;
|
|
245
269
|
}
|
|
270
|
+
|
|
246
271
|
if (aspect && rect.w > 0 && rect.h > 0) {
|
|
247
272
|
const ratio = rect.h / rect.w;
|
|
248
273
|
// A side handle moves one dimension, which sets the other; a corner follows the smaller pull.
|
|
249
274
|
const width = handle === 'e' || handle === 'w' ? w : handle === 'n' || handle === 's' ? h / ratio : Math.abs(w) < Math.abs(h / ratio) ? w : h / ratio;
|
|
250
275
|
const height = width * ratio;
|
|
276
|
+
|
|
251
277
|
if (handle.includes('w')) {
|
|
252
278
|
x = rect.x + rect.w - width;
|
|
253
279
|
}
|
|
280
|
+
|
|
254
281
|
if (handle.startsWith('n')) {
|
|
255
282
|
y = rect.y + rect.h - height;
|
|
256
283
|
}
|
|
284
|
+
|
|
257
285
|
w = width;
|
|
258
286
|
h = height;
|
|
259
287
|
}
|
|
288
|
+
|
|
260
289
|
return { x, y, w, h };
|
|
261
290
|
}
|
|
262
291
|
|
|
263
292
|
export function scaleElement(element: DrawingElement, from: Rect, to: Rect): DrawingElement {
|
|
264
293
|
const scaleX = from.w === 0 ? 1 : to.w / from.w;
|
|
265
294
|
const scaleY = from.h === 0 ? 1 : to.h / from.h;
|
|
295
|
+
|
|
266
296
|
const placed = {
|
|
267
297
|
...element,
|
|
268
298
|
x: to.x + (element.x - from.x) * scaleX,
|
|
@@ -270,22 +300,27 @@ export function scaleElement(element: DrawingElement, from: Rect, to: Rect): Dra
|
|
|
270
300
|
w: element.w * scaleX,
|
|
271
301
|
h: element.h * scaleY
|
|
272
302
|
};
|
|
303
|
+
|
|
273
304
|
if (placed.kind === 'line') {
|
|
274
305
|
return { ...placed, points: placed.points.map(([x, y]) => [x * scaleX, y * scaleY] as [number, number]) };
|
|
275
306
|
}
|
|
307
|
+
|
|
276
308
|
if (placed.kind === 'freehand') {
|
|
277
309
|
return { ...placed, points: placed.points.map(([x, y, pressure]) => [x * scaleX, y * scaleY, pressure] as [number, number, number?]) };
|
|
278
310
|
}
|
|
311
|
+
|
|
279
312
|
if (placed.kind === 'text') {
|
|
280
313
|
// From here on the box is the person's, so typing no longer refits it.
|
|
281
314
|
return { ...placed, sized: true };
|
|
282
315
|
}
|
|
316
|
+
|
|
283
317
|
return placed;
|
|
284
318
|
}
|
|
285
319
|
|
|
286
320
|
export function arrowHead(tip: Point, from: Point, size: number): [Point, Point][] {
|
|
287
321
|
const angle = Math.atan2(tip.y - from.y, tip.x - from.x);
|
|
288
322
|
const spread = Math.PI / 7;
|
|
323
|
+
|
|
289
324
|
return [
|
|
290
325
|
[tip, { x: tip.x - size * Math.cos(angle - spread), y: tip.y - size * Math.sin(angle - spread) }],
|
|
291
326
|
[tip, { x: tip.x - size * Math.cos(angle + spread), y: tip.y - size * Math.sin(angle + spread) }]
|
package/src/paths.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { getStroke } from 'perfect-freehand';
|
|
|
4
4
|
import rough from 'roughjs';
|
|
5
5
|
import type { RoughGenerator } from 'roughjs/bin/generator.js';
|
|
6
6
|
import type { Options } from 'roughjs/bin/core.js';
|
|
7
|
-
import { arrowHead, arrowHeadSize, type Point } from './geometry.ts';
|
|
7
|
+
import { arrowHead, arrowHeadSize, isFilled, type Point } from './geometry.ts';
|
|
8
8
|
import { NOTE_RADIUS } from './text.ts';
|
|
9
9
|
|
|
10
10
|
/*
|
|
@@ -34,13 +34,14 @@ function dashOf(element: DrawingElement): number[] | null {
|
|
|
34
34
|
if (element.strokeStyle === 'dashed') {
|
|
35
35
|
return [element.strokeWidth * 4, element.strokeWidth * 4];
|
|
36
36
|
}
|
|
37
|
+
|
|
37
38
|
if (element.strokeStyle === 'dotted') {
|
|
38
39
|
return [element.strokeWidth, element.strokeWidth * 3];
|
|
39
40
|
}
|
|
41
|
+
|
|
40
42
|
return null;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
/* Architect, Artist, Cartoonist. Absent reads as Artist, which is what a hand-written file gets. */
|
|
44
45
|
function roughnessOf(element: DrawingElement): Pick<Options, 'roughness' | 'bowing' | 'disableMultiStroke'> {
|
|
45
46
|
switch (element.roughness ?? 1) {
|
|
46
47
|
case 0:
|
|
@@ -58,7 +59,7 @@ function optionsOf(element: DrawingElement): Options {
|
|
|
58
59
|
stroke: STROKE,
|
|
59
60
|
strokeWidth: element.strokeWidth,
|
|
60
61
|
...roughnessOf(element),
|
|
61
|
-
...(element
|
|
62
|
+
...(isFilled(element)
|
|
62
63
|
? { fill: FILL, fillStyle: element.fill === 'solid' ? 'solid' : 'hachure', fillWeight: element.strokeWidth / 2, hachureGap: 8 }
|
|
63
64
|
: {})
|
|
64
65
|
};
|
|
@@ -67,6 +68,7 @@ function optionsOf(element: DrawingElement): Options {
|
|
|
67
68
|
// Rough has no rounded-rectangle primitive.
|
|
68
69
|
function roundedRectPath(w: number, h: number, radius: number): string {
|
|
69
70
|
const corner = Math.min(radius, w / 2, h / 2);
|
|
71
|
+
|
|
70
72
|
return `M ${corner} 0 L ${w - corner} 0 Q ${w} 0 ${w} ${corner} L ${w} ${h - corner} Q ${w} ${h} ${w - corner} ${h} L ${corner} ${h} Q 0 ${h} 0 ${h - corner} L 0 ${corner} Q 0 0 ${corner} 0 Z`;
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -74,12 +76,15 @@ function outlineToPath(outline: readonly (readonly number[])[]): string {
|
|
|
74
76
|
if (outline.length === 0) {
|
|
75
77
|
return '';
|
|
76
78
|
}
|
|
79
|
+
|
|
77
80
|
const [first, ...rest] = outline;
|
|
81
|
+
|
|
78
82
|
return `M ${first![0]} ${first![1]} ${rest.map((point) => `L ${point[0]} ${point[1]}`).join(' ')} Z`;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
function freehandOutline(element: DrawingElement & { kind: 'freehand' }): string {
|
|
82
86
|
const points = element.points.map(([x, y, pressure]) => [x, y, pressure ?? 0.5] as [number, number, number]);
|
|
87
|
+
|
|
83
88
|
const outline = getStroke(points, {
|
|
84
89
|
size: element.strokeWidth * 4 + 2,
|
|
85
90
|
thinning: 0.6,
|
|
@@ -88,6 +93,7 @@ function freehandOutline(element: DrawingElement & { kind: 'freehand' }): string
|
|
|
88
93
|
// A pressure of exactly 0.5 on every point is a mouse; the stroke gets its taper from speed.
|
|
89
94
|
simulatePressure: element.points.every((point) => point[2] === undefined)
|
|
90
95
|
});
|
|
96
|
+
|
|
91
97
|
return outlineToPath(outline);
|
|
92
98
|
}
|
|
93
99
|
|
|
@@ -103,20 +109,25 @@ export function pathsOfElement(element: DrawingElement): ElementPath[] {
|
|
|
103
109
|
if (element.kind === 'text') {
|
|
104
110
|
return [];
|
|
105
111
|
}
|
|
112
|
+
|
|
106
113
|
// A note is a sheet of paper, not a drawn shape: no wobble, and an edge of its own paper color.
|
|
107
114
|
if (element.kind === 'note') {
|
|
108
115
|
const sheet = roundedRectPath(element.w, element.h, NOTE_RADIUS);
|
|
116
|
+
|
|
109
117
|
return [
|
|
110
118
|
{ d: sheet, role: 'fill', strokeWidth: 0, dash: null },
|
|
111
119
|
{ d: sheet, role: 'stroke', strokeWidth: element.strokeWidth, dash: null }
|
|
112
120
|
];
|
|
113
121
|
}
|
|
114
|
-
|
|
115
|
-
const dash = dashOf(element);
|
|
122
|
+
|
|
116
123
|
if (element.kind === 'freehand') {
|
|
117
124
|
return [{ d: freehandOutline(element), role: 'ink', strokeWidth: 0, dash: null }];
|
|
118
125
|
}
|
|
126
|
+
|
|
127
|
+
const options = optionsOf(element);
|
|
128
|
+
const dash = dashOf(element);
|
|
119
129
|
const generator = drawingGenerator();
|
|
130
|
+
|
|
120
131
|
const drawable = (() => {
|
|
121
132
|
switch (element.kind) {
|
|
122
133
|
case 'rect':
|
|
@@ -142,21 +153,26 @@ export function pathsOfElement(element: DrawingElement): ElementPath[] {
|
|
|
142
153
|
);
|
|
143
154
|
}
|
|
144
155
|
})();
|
|
156
|
+
|
|
145
157
|
const paths: ElementPath[] = generator.toPaths(drawable).map((path) => ({
|
|
146
158
|
d: path.d,
|
|
147
159
|
role: path.fill === FILL || path.stroke === FILL ? 'fill' : 'stroke',
|
|
148
160
|
strokeWidth: path.strokeWidth,
|
|
149
161
|
dash: path.stroke === STROKE ? dash : null
|
|
150
162
|
}));
|
|
163
|
+
|
|
151
164
|
if (element.kind !== 'line') {
|
|
152
165
|
return paths;
|
|
153
166
|
}
|
|
167
|
+
|
|
154
168
|
const points = element.points.map(([x, y]) => ({ x, y }));
|
|
155
169
|
const size = arrowHeadSize(element.strokeWidth);
|
|
170
|
+
|
|
156
171
|
const heads: [Point, Point][] = [
|
|
157
172
|
...(element.arrowEnd ? arrowHead(points.at(-1)!, points.at(-2)!, size) : []),
|
|
158
173
|
...(element.arrowStart ? arrowHead(points[0]!, points[1]!, size) : [])
|
|
159
174
|
];
|
|
175
|
+
|
|
160
176
|
// The head is always solid: a dashed arrow still points at something.
|
|
161
177
|
return heads.length === 0 ? paths : [...paths, { d: linesToPath(heads), role: 'stroke', strokeWidth: element.strokeWidth, dash: null }];
|
|
162
178
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -3,9 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
export const DrawingFontSchema = z.enum(['hand', 'sans', 'mono']);
|
|
4
4
|
export type DrawingFont = z.infer<typeof DrawingFontSchema>;
|
|
5
5
|
|
|
6
|
-
/*
|
|
7
|
-
* Palette names keep a document independent of the theme that renders it.
|
|
8
|
-
*/
|
|
6
|
+
/* Palette names keep a document independent of the theme that renders it. */
|
|
9
7
|
export const DrawingColorSchema = z.enum(['ink', 'muted', 'accent', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink']);
|
|
10
8
|
export type DrawingColor = z.infer<typeof DrawingColorSchema>;
|
|
11
9
|
|
|
@@ -111,6 +109,7 @@ export const EMPTY_DRAWING: DrawingDocument = { version: 1, rev: 0, elements: []
|
|
|
111
109
|
/* One version so far, so reading is a parse. A file that is not a drawing at all reads as null. */
|
|
112
110
|
export function migrateDrawing(value: unknown): DrawingDocument | null {
|
|
113
111
|
const parsed = DrawingDocumentSchema.safeParse(value);
|
|
112
|
+
|
|
114
113
|
return parsed.success ? parsed.data : null;
|
|
115
114
|
}
|
|
116
115
|
|
|
@@ -120,11 +119,14 @@ export function migrateDrawing(value: unknown): DrawingDocument | null {
|
|
|
120
119
|
*/
|
|
121
120
|
export function duplicateElementIdIn(elements: DrawingElement[]): string | null {
|
|
122
121
|
const seen = new Set<string>();
|
|
122
|
+
|
|
123
123
|
for (const element of elements) {
|
|
124
124
|
if (seen.has(element.id)) {
|
|
125
125
|
return element.id;
|
|
126
126
|
}
|
|
127
|
+
|
|
127
128
|
seen.add(element.id);
|
|
128
129
|
}
|
|
130
|
+
|
|
129
131
|
return null;
|
|
130
132
|
}
|
package/src/reading-order.ts
CHANGED
|
@@ -17,32 +17,40 @@ function labelInside(element: DrawingElement, elements: readonly DrawingElement[
|
|
|
17
17
|
if (candidate.kind !== 'text' || candidate.id === element.id) {
|
|
18
18
|
continue;
|
|
19
19
|
}
|
|
20
|
+
|
|
20
21
|
const center = centerOf(boundsOf(candidate));
|
|
22
|
+
|
|
21
23
|
if (hitsElement({ ...element, fill: 'solid' }, center, 0)) {
|
|
22
24
|
return labelOf(candidate);
|
|
23
25
|
}
|
|
24
26
|
}
|
|
27
|
+
|
|
25
28
|
return null;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/* What an arrow points at: the text at that end, or the shape there and the text it holds. */
|
|
29
32
|
function labelAt(point: Point, elements: readonly DrawingElement[], arrowId: string): string | null {
|
|
30
33
|
let best: { label: string; distance: number } | null = null;
|
|
34
|
+
|
|
31
35
|
for (const element of elements) {
|
|
32
36
|
if (element.id === arrowId || element.kind === 'line' || element.kind === 'freehand') {
|
|
33
37
|
continue;
|
|
34
38
|
}
|
|
39
|
+
|
|
35
40
|
const center = centerOf(boundsOf(element));
|
|
36
41
|
const distance = Math.hypot(center.x - point.x, center.y - point.y);
|
|
37
|
-
|
|
38
|
-
if (
|
|
42
|
+
|
|
43
|
+
if (distance > LABEL_REACH && !hitsElement({ ...element, fill: 'solid' }, point, LABEL_REACH)) {
|
|
39
44
|
continue;
|
|
40
45
|
}
|
|
46
|
+
|
|
41
47
|
const label = labelOf(element) ?? labelInside(element, elements);
|
|
48
|
+
|
|
42
49
|
if (label && (!best || distance < best.distance)) {
|
|
43
50
|
best = { label, distance };
|
|
44
51
|
}
|
|
45
52
|
}
|
|
53
|
+
|
|
46
54
|
return best?.label ?? null;
|
|
47
55
|
}
|
|
48
56
|
|
|
@@ -54,29 +62,37 @@ export function readingOrder(elements: readonly DrawingElement[]): string[] {
|
|
|
54
62
|
// A row is measured from its top text, so a staircase of texts cannot chain into one long line.
|
|
55
63
|
const rows: DrawingElement[][] = [];
|
|
56
64
|
const labeled = elements.filter((element) => labelOf(element) !== null).sort((left, right) => left.y - right.y || left.x - right.x);
|
|
65
|
+
|
|
57
66
|
for (const element of labeled) {
|
|
58
67
|
const row = rows.at(-1);
|
|
68
|
+
|
|
59
69
|
if (row && element.y - row[0]!.y <= ROW_TOLERANCE) {
|
|
60
70
|
row.push(element);
|
|
61
71
|
} else {
|
|
62
72
|
rows.push([element]);
|
|
63
73
|
}
|
|
64
74
|
}
|
|
75
|
+
|
|
65
76
|
const texts = rows.flatMap((row) => row.sort((left, right) => left.x - right.x || left.y - right.y)).map((element) => labelOf(element)!);
|
|
66
77
|
|
|
67
78
|
const arrows: string[] = [];
|
|
79
|
+
|
|
68
80
|
for (const element of elements) {
|
|
69
81
|
if (element.kind !== 'line' || (!element.arrowEnd && !element.arrowStart)) {
|
|
70
82
|
continue;
|
|
71
83
|
}
|
|
84
|
+
|
|
72
85
|
const points = absolutePoints(element);
|
|
73
86
|
const from = labelAt(points[0]!, elements, element.id);
|
|
74
87
|
const to = labelAt(points.at(-1)!, elements, element.id);
|
|
88
|
+
|
|
75
89
|
if (!from || !to || from === to) {
|
|
76
90
|
continue;
|
|
77
91
|
}
|
|
92
|
+
|
|
78
93
|
// An arrow that only has a head at its start reads the other way around.
|
|
79
94
|
arrows.push(element.arrowEnd ? `${from} -> ${to}` : `${to} -> ${from}`);
|
|
80
95
|
}
|
|
96
|
+
|
|
81
97
|
return [...texts, ...arrows];
|
|
82
98
|
}
|
package/src/rebase.ts
CHANGED
|
@@ -7,12 +7,15 @@ function canonical(value: unknown): string {
|
|
|
7
7
|
if (Array.isArray(value)) {
|
|
8
8
|
return `[${value.map(canonical).join(',')}]`;
|
|
9
9
|
}
|
|
10
|
+
|
|
10
11
|
if (value !== null && typeof value === 'object') {
|
|
11
12
|
const entries = Object.entries(value as Record<string, unknown>)
|
|
12
13
|
.filter(([, entry]) => entry !== undefined)
|
|
13
14
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
15
|
+
|
|
14
16
|
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(',')}}`;
|
|
15
17
|
}
|
|
18
|
+
|
|
16
19
|
return JSON.stringify(value);
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -26,6 +29,7 @@ function restacked(base: readonly DrawingElement[], side: readonly DrawingElemen
|
|
|
26
29
|
const order = side.map((element) => element.id).filter((id) => shared.has(id));
|
|
27
30
|
const inSide = new Set(order);
|
|
28
31
|
const before = base.map((element) => element.id).filter((id) => inSide.has(id));
|
|
32
|
+
|
|
29
33
|
return order.some((id, i) => id !== before[i]);
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -42,38 +46,48 @@ export function rebaseDrawingElements(base: readonly DrawingElement[], mine: rea
|
|
|
42
46
|
const ids = new Set([...baseById.keys(), ...mineById.keys(), ...theirsById.keys()]);
|
|
43
47
|
const resolved = new Map<string, DrawingElement>();
|
|
44
48
|
const conflicts: string[] = [];
|
|
49
|
+
|
|
45
50
|
for (const id of ids) {
|
|
46
51
|
const original = baseById.get(id)?.text;
|
|
47
52
|
const ours = mineById.get(id);
|
|
48
53
|
const their = theirsById.get(id);
|
|
49
54
|
const oursChanged = ours?.text !== original;
|
|
50
55
|
const theirsChanged = their?.text !== original;
|
|
56
|
+
|
|
51
57
|
if (oursChanged && theirsChanged && ours?.text !== their?.text) {
|
|
52
58
|
conflicts.push(id);
|
|
59
|
+
|
|
53
60
|
continue;
|
|
54
61
|
}
|
|
62
|
+
|
|
55
63
|
const kept = oursChanged ? ours : their;
|
|
64
|
+
|
|
56
65
|
if (kept !== undefined) {
|
|
57
66
|
resolved.set(id, kept.element);
|
|
58
67
|
}
|
|
59
68
|
}
|
|
69
|
+
|
|
60
70
|
if (conflicts.length > 0) {
|
|
61
71
|
return { ok: false, conflicts };
|
|
62
72
|
}
|
|
73
|
+
|
|
63
74
|
const mineLeads = restacked(base, mine) && !restacked(base, theirs);
|
|
64
75
|
const [leading, following] = mineLeads ? [mine, theirs] : [theirs, mine];
|
|
65
76
|
const order = leading.map((element) => element.id).filter((id) => resolved.has(id));
|
|
66
77
|
const placed = new Set(order);
|
|
67
|
-
following
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
// The last element before this one on the following side that has a place, which it goes just above.
|
|
79
|
+
let below: string | undefined;
|
|
80
|
+
|
|
81
|
+
for (const element of following) {
|
|
82
|
+
if (!placed.has(element.id) && resolved.has(element.id)) {
|
|
83
|
+
order.splice(below === undefined ? 0 : order.indexOf(below) + 1, 0, element.id);
|
|
84
|
+
placed.add(element.id);
|
|
70
85
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
});
|
|
86
|
+
|
|
87
|
+
if (placed.has(element.id)) {
|
|
88
|
+
below = element.id;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
return { ok: true, elements: order.flatMap((id) => resolved.get(id) ?? []) };
|
|
79
93
|
}
|
package/src/svg.ts
CHANGED
|
@@ -80,31 +80,39 @@ function round(value: number): number {
|
|
|
80
80
|
function transformOf(element: DrawingElement): string {
|
|
81
81
|
// After the translate the paths are in the element's own frame, so its center is half its size.
|
|
82
82
|
const turn = element.angle ? ` rotate(${round((element.angle * 180) / Math.PI)} ${round(element.w / 2)} ${round(element.h / 2)})` : '';
|
|
83
|
+
|
|
83
84
|
return `translate(${round(element.x)} ${round(element.y)})${turn}`;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
function textSvg(element: WrittenElement, options: PaintOptions): string {
|
|
87
|
-
const
|
|
88
|
+
const font = fontOf(element.font);
|
|
89
|
+
const family = options.fonts?.[font] ?? DEFAULT_FONT_STACKS[font];
|
|
88
90
|
const anchor = element.align === 'center' ? 'middle' : element.align === 'right' ? 'end' : 'start';
|
|
89
91
|
const frame = writingFrameOf(element);
|
|
90
92
|
const dx = frame.x + (element.align === 'center' ? frame.w / 2 : element.align === 'right' ? frame.w : 0);
|
|
91
93
|
const measure = options.measure?.(element) ?? approximateMeasure(element.size, element.font);
|
|
94
|
+
|
|
92
95
|
const lines = linesOf(element, measure)
|
|
93
96
|
.map((line, index) => `<tspan x="${round(dx)}" y="${round(frame.y + (index + 0.8) * element.size * LINE_HEIGHT)}">${escapeXml(line)}</tspan>`)
|
|
94
97
|
.join('');
|
|
98
|
+
|
|
95
99
|
return `<text font-family="${escapeXml(family)}" font-size="${element.size}" fill="${options.palette[element.stroke]}" text-anchor="${anchor}">${lines}</text>`;
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
function pathSvg(path: ElementPath, element: DrawingElement, options: PaintOptions): string {
|
|
99
103
|
const note = element.kind === 'note';
|
|
104
|
+
|
|
100
105
|
if (path.role === 'stroke') {
|
|
101
106
|
const dash = path.dash ? ` stroke-dasharray="${path.dash.join(' ')}"` : '';
|
|
102
107
|
const line = note ? (options.edge ?? DEFAULT_EDGE)[element.fillColor ?? element.stroke] : options.palette[element.stroke];
|
|
108
|
+
|
|
103
109
|
return `<path d="${path.d}" fill="none" stroke="${line}" stroke-width="${path.strokeWidth}" stroke-linecap="round"${dash}/>`;
|
|
104
110
|
}
|
|
111
|
+
|
|
105
112
|
// A note is filled with its paper, which is a palette of pale sheets of its own.
|
|
106
113
|
const fill = note ? (options.paper ?? DEFAULT_PAPER) : options.palette;
|
|
107
114
|
const color = path.role === 'ink' ? options.palette[element.stroke] : fill[element.fillColor ?? element.stroke];
|
|
115
|
+
|
|
108
116
|
// A hachure fill is a bundle of lines, so it arrives as a stroke with its own width.
|
|
109
117
|
return path.strokeWidth > 0 && path.role === 'fill' && element.fill === 'hachure'
|
|
110
118
|
? `<path d="${path.d}" fill="none" stroke="${color}" stroke-width="${path.strokeWidth}"/>`
|
|
@@ -118,8 +126,10 @@ function elementSvg(element: DrawingElement, options: PaintOptions): string {
|
|
|
118
126
|
: pathsOfElement(element)
|
|
119
127
|
.map((path) => pathSvg(path, element, options))
|
|
120
128
|
.join('');
|
|
129
|
+
|
|
121
130
|
// The paper is drawn first and the note's own words go on top of it.
|
|
122
131
|
const written = element.kind === 'note' ? textSvg(element, options) : '';
|
|
132
|
+
|
|
123
133
|
return `<g transform="${transformOf(element)}">${body}${written}</g>`;
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -137,5 +147,6 @@ export function toSvg(elements: readonly DrawingElement[], options: SvgOptions =
|
|
|
137
147
|
const h = round(bounds.h + margin * 2);
|
|
138
148
|
const paper = options.background ? `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${options.background}"/>` : '';
|
|
139
149
|
const body = elements.map((element) => elementSvg(element, paint)).join('');
|
|
150
|
+
|
|
140
151
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="${x} ${y} ${w} ${h}">${paper}${body}</svg>`;
|
|
141
152
|
}
|
package/src/text.ts
CHANGED
|
@@ -26,51 +26,59 @@ export function writingFrameOf(element: WrittenElement): { x: number; y: number;
|
|
|
26
26
|
return element.kind === 'note' ? { x: NOTE_PADDING, y: NOTE_PADDING, w: Math.max(1, element.w - NOTE_PADDING * 2) } : { x: 0, y: 0, w: element.w };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function textLines(text: string): string[] {
|
|
30
|
-
return text.split('\n');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
29
|
export type MeasureLine = (line: string) => number;
|
|
34
30
|
|
|
35
31
|
/* A measure for where no font exists, such as on a server: most faces average a glyph a little over half its size wide. */
|
|
36
32
|
export function approximateMeasure(size: number, font: DrawingFont | undefined): MeasureLine {
|
|
37
33
|
const glyph = fontOf(font) === 'mono' ? 0.6 : 0.55;
|
|
34
|
+
|
|
38
35
|
return (line) => line.length * size * glyph;
|
|
39
36
|
}
|
|
40
37
|
|
|
41
38
|
function wrapParagraph(paragraph: string, maxWidth: number, measure: MeasureLine): string[] {
|
|
42
39
|
const lines: string[] = [];
|
|
43
40
|
let line = '';
|
|
41
|
+
|
|
44
42
|
const push = (): void => {
|
|
45
43
|
lines.push(line);
|
|
46
44
|
line = '';
|
|
47
45
|
};
|
|
46
|
+
|
|
48
47
|
for (const word of paragraph.split(' ')) {
|
|
49
48
|
const candidate = line === '' ? word : `${line} ${word}`;
|
|
49
|
+
|
|
50
50
|
if (measure(candidate) <= maxWidth) {
|
|
51
51
|
line = candidate;
|
|
52
|
+
|
|
52
53
|
continue;
|
|
53
54
|
}
|
|
55
|
+
|
|
54
56
|
if (line !== '') {
|
|
55
57
|
push();
|
|
56
58
|
}
|
|
59
|
+
|
|
57
60
|
if (measure(word) <= maxWidth) {
|
|
58
61
|
line = word;
|
|
62
|
+
|
|
59
63
|
continue;
|
|
60
64
|
}
|
|
65
|
+
|
|
61
66
|
for (const glyph of word) {
|
|
62
67
|
if (line !== '' && measure(line + glyph) > maxWidth) {
|
|
63
68
|
push();
|
|
64
69
|
}
|
|
70
|
+
|
|
65
71
|
line += glyph;
|
|
66
72
|
}
|
|
67
73
|
}
|
|
74
|
+
|
|
68
75
|
lines.push(line);
|
|
76
|
+
|
|
69
77
|
return lines;
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
export function wrapLines(text: string, maxWidth: number, measure: MeasureLine): string[] {
|
|
73
|
-
return
|
|
81
|
+
return text.split('\n').flatMap((paragraph) => wrapParagraph(paragraph, maxWidth, measure));
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
// An unsized text element follows its content; once sized, its box controls wrapping.
|
|
@@ -78,7 +86,8 @@ export function linesOf(element: WrittenElement, measure: MeasureLine): string[]
|
|
|
78
86
|
if (element.kind === 'note') {
|
|
79
87
|
return wrapLines(element.text, writingFrameOf(element).w, measure);
|
|
80
88
|
}
|
|
81
|
-
|
|
89
|
+
|
|
90
|
+
return element.sized ? wrapLines(element.text, element.w, measure) : element.text.split('\n');
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
/* Absent means hand: a drawing is written by hand unless it says otherwise. */
|