@adecore/drawing 0.39.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.
@@ -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
- const corners = [
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.fill && element.fill !== 'none'
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;
@@ -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
- const inside = hitsElement({ ...element, fill: 'solid' }, point, LABEL_REACH);
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.forEach((element, i) => {
61
- if (placed.has(element.id) || !resolved.has(element.id)) {
62
- return;
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
- const below = following
65
- .slice(0, i)
66
- .map((candidate) => candidate.id)
67
- .findLast((id) => placed.has(id));
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 family = options.fonts?.[fontOf(element.font)] ?? DEFAULT_FONT_STACKS[fontOf(element.font)];
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 textLines(text).flatMap((paragraph) => wrapParagraph(paragraph, maxWidth, measure));
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) : textLines(element.text);
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.39.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.2",
44
- "@types/node": "^24.13.6",
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.5"
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
- const corners = [
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.fill && element.fill !== 'none'
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
- const options = optionsOf(element);
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
  }
@@ -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
- const inside = hitsElement({ ...element, fill: 'solid' }, point, LABEL_REACH);
38
- if (!inside && distance > LABEL_REACH) {
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.forEach((element, i) => {
68
- if (placed.has(element.id) || !resolved.has(element.id)) {
69
- return;
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
- const below = following
72
- .slice(0, i)
73
- .map((candidate) => candidate.id)
74
- .findLast((id) => placed.has(id));
75
- order.splice(below === undefined ? 0 : order.indexOf(below) + 1, 0, element.id);
76
- placed.add(element.id);
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 family = options.fonts?.[fontOf(element.font)] ?? DEFAULT_FONT_STACKS[fontOf(element.font)];
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 textLines(text).flatMap((paragraph) => wrapParagraph(paragraph, maxWidth, measure));
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
- return element.sized ? wrapLines(element.text, element.w, measure) : textLines(element.text);
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. */