@adecore/drawing 0.37.2 → 0.38.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/edits.d.ts +811 -0
- package/dist/edits.js +158 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/rebase.d.ts +9 -0
- package/dist/rebase.js +72 -0
- package/package.json +1 -1
- package/src/edits.ts +200 -0
- package/src/index.ts +14 -0
- package/src/rebase.ts +79 -0
package/dist/edits.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { DRAWING_TEXT_SIZE_MAX, DRAWING_TEXT_SIZE_MIN, DrawingAlignSchema, DrawingColorSchema, DrawingElementSchema, DrawingFillSchema, DrawingFontSchema, DrawingRoughnessSchema, DrawingStrokeStyleSchema, DrawingStrokeWidthSchema } from './protocol.js';
|
|
3
|
+
/* How much one edit may touch, and how many elements a drawing may hold after it. */
|
|
4
|
+
export const DRAWING_EDIT_LIMITS = { perEdit: 200, elements: 5000 };
|
|
5
|
+
const point = z.tuple([z.number(), z.number()]);
|
|
6
|
+
const pressurePoint = z.tuple([z.number(), z.number(), z.number().optional()]);
|
|
7
|
+
const textSize = z.number().int().min(DRAWING_TEXT_SIZE_MIN).max(DRAWING_TEXT_SIZE_MAX);
|
|
8
|
+
/* What an edit may set on any element. `id` and `seed` belong to whoever applies the edit, and `locked` to a person. */
|
|
9
|
+
const placed = {
|
|
10
|
+
x: z.number(),
|
|
11
|
+
y: z.number(),
|
|
12
|
+
w: z.number(),
|
|
13
|
+
h: z.number(),
|
|
14
|
+
angle: z.number().optional(),
|
|
15
|
+
stroke: DrawingColorSchema.optional(),
|
|
16
|
+
strokeWidth: DrawingStrokeWidthSchema.optional(),
|
|
17
|
+
strokeStyle: DrawingStrokeStyleSchema.optional(),
|
|
18
|
+
fill: DrawingFillSchema.optional(),
|
|
19
|
+
fillColor: DrawingColorSchema.optional(),
|
|
20
|
+
roughness: DrawingRoughnessSchema.optional()
|
|
21
|
+
};
|
|
22
|
+
const written = { text: z.string(), size: textSize.optional(), font: DrawingFontSchema.optional(), align: DrawingAlignSchema.optional() };
|
|
23
|
+
/* A new element without its id and seed; `stroke`, `strokeWidth` and a text's `size` take the defaults a drawing starts with. */
|
|
24
|
+
export const NewDrawingElementSchema = z.discriminatedUnion('kind', [
|
|
25
|
+
z.strictObject({ kind: z.literal('rect'), ...placed, radius: z.number().nonnegative().optional() }),
|
|
26
|
+
z.strictObject({ kind: z.literal('diamond'), ...placed }),
|
|
27
|
+
z.strictObject({ kind: z.literal('ellipse'), ...placed }),
|
|
28
|
+
z.strictObject({ kind: z.literal('line'), ...placed, points: z.array(point).min(2), arrowStart: z.boolean().optional(), arrowEnd: z.boolean().optional() }),
|
|
29
|
+
z.strictObject({ kind: z.literal('freehand'), ...placed, points: z.array(pressurePoint).min(1) }),
|
|
30
|
+
z.strictObject({ kind: z.literal('text'), ...placed, ...written }),
|
|
31
|
+
z.strictObject({ kind: z.literal('note'), ...placed, ...written })
|
|
32
|
+
]);
|
|
33
|
+
/* The fields an update may change on an element of one kind, besides the ones every kind has. */
|
|
34
|
+
const KIND_FIELDS = {
|
|
35
|
+
rect: ['radius'],
|
|
36
|
+
diamond: [],
|
|
37
|
+
ellipse: [],
|
|
38
|
+
line: ['points', 'arrowStart', 'arrowEnd'],
|
|
39
|
+
freehand: ['points'],
|
|
40
|
+
text: ['text', 'size', 'font', 'align'],
|
|
41
|
+
note: ['text', 'size', 'font', 'align']
|
|
42
|
+
};
|
|
43
|
+
/* What changes on one element, by its id; its kind stays, and a field of another kind is refused once the element is known. */
|
|
44
|
+
export const DrawingElementPatchSchema = z.strictObject({
|
|
45
|
+
id: z.string().min(1),
|
|
46
|
+
x: z.number().optional(),
|
|
47
|
+
y: z.number().optional(),
|
|
48
|
+
w: z.number().optional(),
|
|
49
|
+
h: z.number().optional(),
|
|
50
|
+
angle: z.number().optional(),
|
|
51
|
+
stroke: DrawingColorSchema.optional(),
|
|
52
|
+
strokeWidth: DrawingStrokeWidthSchema.optional(),
|
|
53
|
+
strokeStyle: DrawingStrokeStyleSchema.optional(),
|
|
54
|
+
fill: DrawingFillSchema.optional(),
|
|
55
|
+
fillColor: DrawingColorSchema.optional(),
|
|
56
|
+
roughness: DrawingRoughnessSchema.optional(),
|
|
57
|
+
radius: z.number().nonnegative().optional(),
|
|
58
|
+
points: z.array(pressurePoint).min(1).optional(),
|
|
59
|
+
arrowStart: z.boolean().optional(),
|
|
60
|
+
arrowEnd: z.boolean().optional(),
|
|
61
|
+
text: z.string().optional(),
|
|
62
|
+
size: textSize.optional(),
|
|
63
|
+
font: DrawingFontSchema.optional(),
|
|
64
|
+
align: DrawingAlignSchema.optional()
|
|
65
|
+
});
|
|
66
|
+
/* Elements to add on top, changes by id and ids to take away, in one step. */
|
|
67
|
+
export const DrawingEditSchema = z.strictObject({
|
|
68
|
+
add: z.array(NewDrawingElementSchema).optional(),
|
|
69
|
+
update: z.array(DrawingElementPatchSchema).optional(),
|
|
70
|
+
remove: z.array(z.string().min(1)).optional()
|
|
71
|
+
});
|
|
72
|
+
function touched(edit) {
|
|
73
|
+
return (edit.add?.length ?? 0) + (edit.update?.length ?? 0) + (edit.remove?.length ?? 0);
|
|
74
|
+
}
|
|
75
|
+
/* The problems of the ids an update or a remove names: unknown, locked, or named twice. */
|
|
76
|
+
function idProblems(byId, edit) {
|
|
77
|
+
const problems = [];
|
|
78
|
+
const named = new Set();
|
|
79
|
+
const check = (id, path) => {
|
|
80
|
+
const element = byId.get(id);
|
|
81
|
+
if (element === undefined) {
|
|
82
|
+
problems.push({ path, message: `the drawing has no element ${id}` });
|
|
83
|
+
}
|
|
84
|
+
else if (element.locked === true) {
|
|
85
|
+
problems.push({ path, message: `${id} is locked, so an edit leaves it as it is` });
|
|
86
|
+
}
|
|
87
|
+
else if (named.has(id)) {
|
|
88
|
+
problems.push({ path, message: `${id} is named twice in this edit` });
|
|
89
|
+
}
|
|
90
|
+
named.add(id);
|
|
91
|
+
};
|
|
92
|
+
edit.update?.forEach((patch, i) => check(patch.id, ['update', i, 'id']));
|
|
93
|
+
edit.remove?.forEach((id, i) => check(id, ['remove', i]));
|
|
94
|
+
return problems;
|
|
95
|
+
}
|
|
96
|
+
/* The problems of fields a patch sets that its element's kind does not have. */
|
|
97
|
+
function kindProblems(byId, edit) {
|
|
98
|
+
const shared = new Set(Object.keys(placed));
|
|
99
|
+
return (edit.update ?? []).flatMap((patch, i) => {
|
|
100
|
+
const element = byId.get(patch.id);
|
|
101
|
+
if (element === undefined) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
return Object.keys(patch)
|
|
105
|
+
.filter((key) => key !== 'id' && !shared.has(key) && !KIND_FIELDS[element.kind].includes(key))
|
|
106
|
+
.map((key) => ({ path: ['update', i, key], message: `${patch.id} is a ${element.kind}, which has no field ${key}` }));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function created(element, mint) {
|
|
110
|
+
const base = { stroke: 'ink', strokeWidth: 2, ...element, id: mint.id(), seed: mint.seed() };
|
|
111
|
+
return DrawingElementSchema.parse(base.kind === 'text' || base.kind === 'note' ? { size: 20, ...base } : base);
|
|
112
|
+
}
|
|
113
|
+
/*
|
|
114
|
+
* One edit on the elements of a drawing: the removed ones go, the updated ones change in place and keep
|
|
115
|
+
* their place in the stacking order, and the added ones go on top in the order given. Nothing is applied
|
|
116
|
+
* when any part is wrong, so an edit is one step or none.
|
|
117
|
+
*/
|
|
118
|
+
export function applyDrawingEdit(elements, edit, mint) {
|
|
119
|
+
if (touched(edit) > DRAWING_EDIT_LIMITS.perEdit) {
|
|
120
|
+
return { ok: false, problems: [{ path: [], message: `an edit touches at most ${DRAWING_EDIT_LIMITS.perEdit} elements; split it` }] };
|
|
121
|
+
}
|
|
122
|
+
const byId = new Map(elements.map((element) => [element.id, element]));
|
|
123
|
+
const problems = [...idProblems(byId, edit), ...kindProblems(byId, edit)];
|
|
124
|
+
const patches = new Map((edit.update ?? []).map((patch) => [patch.id, patch]));
|
|
125
|
+
const removed = new Set(edit.remove ?? []);
|
|
126
|
+
const next = [];
|
|
127
|
+
for (const element of elements) {
|
|
128
|
+
if (removed.has(element.id)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const patch = patches.get(element.id);
|
|
132
|
+
if (patch === undefined) {
|
|
133
|
+
next.push(element);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const parsed = DrawingElementSchema.safeParse({ ...element, ...patch });
|
|
137
|
+
if (!parsed.success) {
|
|
138
|
+
const index = edit.update.indexOf(patch);
|
|
139
|
+
problems.push(...parsed.error.issues.map((issue) => ({
|
|
140
|
+
path: ['update', index, ...issue.path.map((key) => (typeof key === 'number' ? key : String(key)))],
|
|
141
|
+
message: issue.message
|
|
142
|
+
})));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
next.push(parsed.data);
|
|
146
|
+
}
|
|
147
|
+
const added = (edit.add ?? []).map((element) => created(element, mint));
|
|
148
|
+
if (next.length + added.length > DRAWING_EDIT_LIMITS.elements) {
|
|
149
|
+
problems.push({
|
|
150
|
+
path: ['add'],
|
|
151
|
+
message: `a drawing holds at most ${DRAWING_EDIT_LIMITS.elements} elements, and this edit would leave it at ${next.length + added.length}`
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (problems.length > 0) {
|
|
155
|
+
return { ok: false, problems };
|
|
156
|
+
}
|
|
157
|
+
return { ok: true, elements: [...next, ...added], added: added.map((element) => element.id) };
|
|
158
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { type Point, type Rect, RESIZE_HANDLES, type ResizeHandle, boundsOf, centerOf, roundPoint, rotatePoint, unionOf, boundsOfElements, intersects, rectFromPoints, elementAt, elementsIn, handlePoint, resizeRect, scaleElement } from './geometry.ts';
|
|
2
|
+
export { DRAWING_EDIT_LIMITS, NewDrawingElementSchema, type NewDrawingElement, DrawingElementPatchSchema, type DrawingElementPatch, DrawingEditSchema, type DrawingEdit, type DrawingEditProblem, type DrawingEditOutcome, type DrawingEditMint, applyDrawingEdit } from './edits.ts';
|
|
2
3
|
export { type ElementPath, pathsOfElement } from './paths.ts';
|
|
4
|
+
export { type DrawingRebase, rebaseDrawingElements } from './rebase.ts';
|
|
3
5
|
export { readingOrder } from './reading-order.ts';
|
|
4
6
|
export { type SvgOptions, DEFAULT_SVG_MARGIN, DEFAULT_PALETTE, DEFAULT_PAPER, DEFAULT_EDGE, escapeXml, toSvg } from './svg.ts';
|
|
5
7
|
export { LINE_HEIGHT, DEFAULT_FONT_STACKS, type WrittenElement, NOTE_PADDING, writingFrameOf, type MeasureLine, approximateMeasure, linesOf, fontOf } from './text.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { RESIZE_HANDLES, boundsOf, centerOf, roundPoint, rotatePoint, unionOf, boundsOfElements, intersects, rectFromPoints, elementAt, elementsIn, handlePoint, resizeRect, scaleElement } from './geometry.js';
|
|
2
|
+
export { DRAWING_EDIT_LIMITS, NewDrawingElementSchema, DrawingElementPatchSchema, DrawingEditSchema, applyDrawingEdit } from './edits.js';
|
|
2
3
|
export { pathsOfElement } from './paths.js';
|
|
4
|
+
export { rebaseDrawingElements } from './rebase.js';
|
|
3
5
|
export { readingOrder } from './reading-order.js';
|
|
4
6
|
export { DEFAULT_SVG_MARGIN, DEFAULT_PALETTE, DEFAULT_PAPER, DEFAULT_EDGE, escapeXml, toSvg } from './svg.js';
|
|
5
7
|
export { LINE_HEIGHT, DEFAULT_FONT_STACKS, NOTE_PADDING, writingFrameOf, approximateMeasure, linesOf, fontOf } from './text.js';
|
package/dist/rebase.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DrawingElement } from './protocol.ts';
|
|
2
|
+
export type DrawingRebase = {
|
|
3
|
+
ok: true;
|
|
4
|
+
elements: DrawingElement[];
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
conflicts: string[];
|
|
8
|
+
};
|
|
9
|
+
export declare function rebaseDrawingElements(base: readonly DrawingElement[], mine: readonly DrawingElement[], theirs: readonly DrawingElement[]): DrawingRebase;
|
package/dist/rebase.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* The same value whatever order its keys were written in, so a document read back from disk equals the one sent. */
|
|
2
|
+
function canonical(value) {
|
|
3
|
+
if (Array.isArray(value)) {
|
|
4
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
5
|
+
}
|
|
6
|
+
if (value !== null && typeof value === 'object') {
|
|
7
|
+
const entries = Object.entries(value)
|
|
8
|
+
.filter(([, entry]) => entry !== undefined)
|
|
9
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
10
|
+
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(',')}}`;
|
|
11
|
+
}
|
|
12
|
+
return JSON.stringify(value);
|
|
13
|
+
}
|
|
14
|
+
function byId(elements) {
|
|
15
|
+
return new Map(elements.map((element) => [element.id, { element, text: canonical(element) }]));
|
|
16
|
+
}
|
|
17
|
+
/* Whether a side stacked the elements it shares with the base in another order. */
|
|
18
|
+
function restacked(base, side) {
|
|
19
|
+
const shared = new Set(base.map((element) => element.id));
|
|
20
|
+
const order = side.map((element) => element.id).filter((id) => shared.has(id));
|
|
21
|
+
const inSide = new Set(order);
|
|
22
|
+
const before = base.map((element) => element.id).filter((id) => inSide.has(id));
|
|
23
|
+
return order.some((id, i) => id !== before[i]);
|
|
24
|
+
}
|
|
25
|
+
/*
|
|
26
|
+
* Puts the elements `mine` changed since `base` onto `theirs`, by id. Only an element both sides
|
|
27
|
+
* changed, each in its own way, is a conflict; an element added, changed or removed on one side only
|
|
28
|
+
* merges. The stacking order is the one a side changed, `theirs` when neither or both did, and an
|
|
29
|
+
* element only the other side added goes in just above the element it stood on there.
|
|
30
|
+
*/
|
|
31
|
+
export function rebaseDrawingElements(base, mine, theirs) {
|
|
32
|
+
const baseById = byId(base);
|
|
33
|
+
const mineById = byId(mine);
|
|
34
|
+
const theirsById = byId(theirs);
|
|
35
|
+
const ids = new Set([...baseById.keys(), ...mineById.keys(), ...theirsById.keys()]);
|
|
36
|
+
const resolved = new Map();
|
|
37
|
+
const conflicts = [];
|
|
38
|
+
for (const id of ids) {
|
|
39
|
+
const original = baseById.get(id)?.text;
|
|
40
|
+
const ours = mineById.get(id);
|
|
41
|
+
const their = theirsById.get(id);
|
|
42
|
+
const oursChanged = ours?.text !== original;
|
|
43
|
+
const theirsChanged = their?.text !== original;
|
|
44
|
+
if (oursChanged && theirsChanged && ours?.text !== their?.text) {
|
|
45
|
+
conflicts.push(id);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const kept = oursChanged ? ours : their;
|
|
49
|
+
if (kept !== undefined) {
|
|
50
|
+
resolved.set(id, kept.element);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (conflicts.length > 0) {
|
|
54
|
+
return { ok: false, conflicts };
|
|
55
|
+
}
|
|
56
|
+
const mineLeads = restacked(base, mine) && !restacked(base, theirs);
|
|
57
|
+
const [leading, following] = mineLeads ? [mine, theirs] : [theirs, mine];
|
|
58
|
+
const order = leading.map((element) => element.id).filter((id) => resolved.has(id));
|
|
59
|
+
const placed = new Set(order);
|
|
60
|
+
following.forEach((element, i) => {
|
|
61
|
+
if (placed.has(element.id) || !resolved.has(element.id)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
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
|
+
});
|
|
71
|
+
return { ok: true, elements: order.flatMap((id) => resolved.get(id) ?? []) };
|
|
72
|
+
}
|
package/package.json
CHANGED
package/src/edits.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
DRAWING_TEXT_SIZE_MAX,
|
|
4
|
+
DRAWING_TEXT_SIZE_MIN,
|
|
5
|
+
DrawingAlignSchema,
|
|
6
|
+
DrawingColorSchema,
|
|
7
|
+
DrawingElementSchema,
|
|
8
|
+
DrawingFillSchema,
|
|
9
|
+
DrawingFontSchema,
|
|
10
|
+
DrawingRoughnessSchema,
|
|
11
|
+
DrawingStrokeStyleSchema,
|
|
12
|
+
DrawingStrokeWidthSchema,
|
|
13
|
+
type DrawingElement
|
|
14
|
+
} from './protocol.ts';
|
|
15
|
+
|
|
16
|
+
/* How much one edit may touch, and how many elements a drawing may hold after it. */
|
|
17
|
+
export const DRAWING_EDIT_LIMITS = { perEdit: 200, elements: 5000 } as const;
|
|
18
|
+
|
|
19
|
+
const point = z.tuple([z.number(), z.number()]);
|
|
20
|
+
const pressurePoint = z.tuple([z.number(), z.number(), z.number().optional()]);
|
|
21
|
+
const textSize = z.number().int().min(DRAWING_TEXT_SIZE_MIN).max(DRAWING_TEXT_SIZE_MAX);
|
|
22
|
+
|
|
23
|
+
/* What an edit may set on any element. `id` and `seed` belong to whoever applies the edit, and `locked` to a person. */
|
|
24
|
+
const placed = {
|
|
25
|
+
x: z.number(),
|
|
26
|
+
y: z.number(),
|
|
27
|
+
w: z.number(),
|
|
28
|
+
h: z.number(),
|
|
29
|
+
angle: z.number().optional(),
|
|
30
|
+
stroke: DrawingColorSchema.optional(),
|
|
31
|
+
strokeWidth: DrawingStrokeWidthSchema.optional(),
|
|
32
|
+
strokeStyle: DrawingStrokeStyleSchema.optional(),
|
|
33
|
+
fill: DrawingFillSchema.optional(),
|
|
34
|
+
fillColor: DrawingColorSchema.optional(),
|
|
35
|
+
roughness: DrawingRoughnessSchema.optional()
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const written = { text: z.string(), size: textSize.optional(), font: DrawingFontSchema.optional(), align: DrawingAlignSchema.optional() };
|
|
39
|
+
|
|
40
|
+
/* A new element without its id and seed; `stroke`, `strokeWidth` and a text's `size` take the defaults a drawing starts with. */
|
|
41
|
+
export const NewDrawingElementSchema = z.discriminatedUnion('kind', [
|
|
42
|
+
z.strictObject({ kind: z.literal('rect'), ...placed, radius: z.number().nonnegative().optional() }),
|
|
43
|
+
z.strictObject({ kind: z.literal('diamond'), ...placed }),
|
|
44
|
+
z.strictObject({ kind: z.literal('ellipse'), ...placed }),
|
|
45
|
+
z.strictObject({ kind: z.literal('line'), ...placed, points: z.array(point).min(2), arrowStart: z.boolean().optional(), arrowEnd: z.boolean().optional() }),
|
|
46
|
+
z.strictObject({ kind: z.literal('freehand'), ...placed, points: z.array(pressurePoint).min(1) }),
|
|
47
|
+
z.strictObject({ kind: z.literal('text'), ...placed, ...written }),
|
|
48
|
+
z.strictObject({ kind: z.literal('note'), ...placed, ...written })
|
|
49
|
+
]);
|
|
50
|
+
export type NewDrawingElement = z.infer<typeof NewDrawingElementSchema>;
|
|
51
|
+
|
|
52
|
+
/* The fields an update may change on an element of one kind, besides the ones every kind has. */
|
|
53
|
+
const KIND_FIELDS: Record<DrawingElement['kind'], readonly string[]> = {
|
|
54
|
+
rect: ['radius'],
|
|
55
|
+
diamond: [],
|
|
56
|
+
ellipse: [],
|
|
57
|
+
line: ['points', 'arrowStart', 'arrowEnd'],
|
|
58
|
+
freehand: ['points'],
|
|
59
|
+
text: ['text', 'size', 'font', 'align'],
|
|
60
|
+
note: ['text', 'size', 'font', 'align']
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/* What changes on one element, by its id; its kind stays, and a field of another kind is refused once the element is known. */
|
|
64
|
+
export const DrawingElementPatchSchema = z.strictObject({
|
|
65
|
+
id: z.string().min(1),
|
|
66
|
+
x: z.number().optional(),
|
|
67
|
+
y: z.number().optional(),
|
|
68
|
+
w: z.number().optional(),
|
|
69
|
+
h: z.number().optional(),
|
|
70
|
+
angle: z.number().optional(),
|
|
71
|
+
stroke: DrawingColorSchema.optional(),
|
|
72
|
+
strokeWidth: DrawingStrokeWidthSchema.optional(),
|
|
73
|
+
strokeStyle: DrawingStrokeStyleSchema.optional(),
|
|
74
|
+
fill: DrawingFillSchema.optional(),
|
|
75
|
+
fillColor: DrawingColorSchema.optional(),
|
|
76
|
+
roughness: DrawingRoughnessSchema.optional(),
|
|
77
|
+
radius: z.number().nonnegative().optional(),
|
|
78
|
+
points: z.array(pressurePoint).min(1).optional(),
|
|
79
|
+
arrowStart: z.boolean().optional(),
|
|
80
|
+
arrowEnd: z.boolean().optional(),
|
|
81
|
+
text: z.string().optional(),
|
|
82
|
+
size: textSize.optional(),
|
|
83
|
+
font: DrawingFontSchema.optional(),
|
|
84
|
+
align: DrawingAlignSchema.optional()
|
|
85
|
+
});
|
|
86
|
+
export type DrawingElementPatch = z.infer<typeof DrawingElementPatchSchema>;
|
|
87
|
+
|
|
88
|
+
/* Elements to add on top, changes by id and ids to take away, in one step. */
|
|
89
|
+
export const DrawingEditSchema = z.strictObject({
|
|
90
|
+
add: z.array(NewDrawingElementSchema).optional(),
|
|
91
|
+
update: z.array(DrawingElementPatchSchema).optional(),
|
|
92
|
+
remove: z.array(z.string().min(1)).optional()
|
|
93
|
+
});
|
|
94
|
+
export type DrawingEdit = z.infer<typeof DrawingEditSchema>;
|
|
95
|
+
|
|
96
|
+
/* Where an edit went wrong, as the path into the edit and what is wrong there. */
|
|
97
|
+
export interface DrawingEditProblem {
|
|
98
|
+
path: (string | number)[];
|
|
99
|
+
message: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type DrawingEditOutcome = { ok: true; elements: DrawingElement[]; added: string[] } | { ok: false; problems: DrawingEditProblem[] };
|
|
103
|
+
|
|
104
|
+
/* Makes the id and the seed of a new element; an id must not be one the drawing already holds. */
|
|
105
|
+
export interface DrawingEditMint {
|
|
106
|
+
id(): string;
|
|
107
|
+
seed(): number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function touched(edit: DrawingEdit): number {
|
|
111
|
+
return (edit.add?.length ?? 0) + (edit.update?.length ?? 0) + (edit.remove?.length ?? 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* The problems of the ids an update or a remove names: unknown, locked, or named twice. */
|
|
115
|
+
function idProblems(byId: ReadonlyMap<string, DrawingElement>, edit: DrawingEdit): DrawingEditProblem[] {
|
|
116
|
+
const problems: DrawingEditProblem[] = [];
|
|
117
|
+
const named = new Set<string>();
|
|
118
|
+
const check = (id: string, path: (string | number)[]): void => {
|
|
119
|
+
const element = byId.get(id);
|
|
120
|
+
if (element === undefined) {
|
|
121
|
+
problems.push({ path, message: `the drawing has no element ${id}` });
|
|
122
|
+
} else if (element.locked === true) {
|
|
123
|
+
problems.push({ path, message: `${id} is locked, so an edit leaves it as it is` });
|
|
124
|
+
} else if (named.has(id)) {
|
|
125
|
+
problems.push({ path, message: `${id} is named twice in this edit` });
|
|
126
|
+
}
|
|
127
|
+
named.add(id);
|
|
128
|
+
};
|
|
129
|
+
edit.update?.forEach((patch, i) => check(patch.id, ['update', i, 'id']));
|
|
130
|
+
edit.remove?.forEach((id, i) => check(id, ['remove', i]));
|
|
131
|
+
return problems;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* The problems of fields a patch sets that its element's kind does not have. */
|
|
135
|
+
function kindProblems(byId: ReadonlyMap<string, DrawingElement>, edit: DrawingEdit): DrawingEditProblem[] {
|
|
136
|
+
const shared = new Set(Object.keys(placed));
|
|
137
|
+
return (edit.update ?? []).flatMap((patch, i) => {
|
|
138
|
+
const element = byId.get(patch.id);
|
|
139
|
+
if (element === undefined) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
return Object.keys(patch)
|
|
143
|
+
.filter((key) => key !== 'id' && !shared.has(key) && !KIND_FIELDS[element.kind].includes(key))
|
|
144
|
+
.map((key) => ({ path: ['update', i, key], message: `${patch.id} is a ${element.kind}, which has no field ${key}` }));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function created(element: NewDrawingElement, mint: DrawingEditMint): DrawingElement {
|
|
149
|
+
const base = { stroke: 'ink' as const, strokeWidth: 2 as const, ...element, id: mint.id(), seed: mint.seed() };
|
|
150
|
+
return DrawingElementSchema.parse(base.kind === 'text' || base.kind === 'note' ? { size: 20, ...base } : base);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/*
|
|
154
|
+
* One edit on the elements of a drawing: the removed ones go, the updated ones change in place and keep
|
|
155
|
+
* their place in the stacking order, and the added ones go on top in the order given. Nothing is applied
|
|
156
|
+
* when any part is wrong, so an edit is one step or none.
|
|
157
|
+
*/
|
|
158
|
+
export function applyDrawingEdit(elements: readonly DrawingElement[], edit: DrawingEdit, mint: DrawingEditMint): DrawingEditOutcome {
|
|
159
|
+
if (touched(edit) > DRAWING_EDIT_LIMITS.perEdit) {
|
|
160
|
+
return { ok: false, problems: [{ path: [], message: `an edit touches at most ${DRAWING_EDIT_LIMITS.perEdit} elements; split it` }] };
|
|
161
|
+
}
|
|
162
|
+
const byId = new Map(elements.map((element) => [element.id, element]));
|
|
163
|
+
const problems = [...idProblems(byId, edit), ...kindProblems(byId, edit)];
|
|
164
|
+
const patches = new Map((edit.update ?? []).map((patch) => [patch.id, patch]));
|
|
165
|
+
const removed = new Set(edit.remove ?? []);
|
|
166
|
+
const next: DrawingElement[] = [];
|
|
167
|
+
for (const element of elements) {
|
|
168
|
+
if (removed.has(element.id)) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const patch = patches.get(element.id);
|
|
172
|
+
if (patch === undefined) {
|
|
173
|
+
next.push(element);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const parsed = DrawingElementSchema.safeParse({ ...element, ...patch });
|
|
177
|
+
if (!parsed.success) {
|
|
178
|
+
const index = edit.update!.indexOf(patch);
|
|
179
|
+
problems.push(
|
|
180
|
+
...parsed.error.issues.map((issue) => ({
|
|
181
|
+
path: ['update', index, ...issue.path.map((key) => (typeof key === 'number' ? key : String(key)))],
|
|
182
|
+
message: issue.message
|
|
183
|
+
}))
|
|
184
|
+
);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
next.push(parsed.data);
|
|
188
|
+
}
|
|
189
|
+
const added = (edit.add ?? []).map((element) => created(element, mint));
|
|
190
|
+
if (next.length + added.length > DRAWING_EDIT_LIMITS.elements) {
|
|
191
|
+
problems.push({
|
|
192
|
+
path: ['add'],
|
|
193
|
+
message: `a drawing holds at most ${DRAWING_EDIT_LIMITS.elements} elements, and this edit would leave it at ${next.length + added.length}`
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (problems.length > 0) {
|
|
197
|
+
return { ok: false, problems };
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, elements: [...next, ...added], added: added.map((element) => element.id) };
|
|
200
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,21 @@ export {
|
|
|
17
17
|
resizeRect,
|
|
18
18
|
scaleElement
|
|
19
19
|
} from './geometry.ts';
|
|
20
|
+
export {
|
|
21
|
+
DRAWING_EDIT_LIMITS,
|
|
22
|
+
NewDrawingElementSchema,
|
|
23
|
+
type NewDrawingElement,
|
|
24
|
+
DrawingElementPatchSchema,
|
|
25
|
+
type DrawingElementPatch,
|
|
26
|
+
DrawingEditSchema,
|
|
27
|
+
type DrawingEdit,
|
|
28
|
+
type DrawingEditProblem,
|
|
29
|
+
type DrawingEditOutcome,
|
|
30
|
+
type DrawingEditMint,
|
|
31
|
+
applyDrawingEdit
|
|
32
|
+
} from './edits.ts';
|
|
20
33
|
export { type ElementPath, pathsOfElement } from './paths.ts';
|
|
34
|
+
export { type DrawingRebase, rebaseDrawingElements } from './rebase.ts';
|
|
21
35
|
export { readingOrder } from './reading-order.ts';
|
|
22
36
|
export { type SvgOptions, DEFAULT_SVG_MARGIN, DEFAULT_PALETTE, DEFAULT_PAPER, DEFAULT_EDGE, escapeXml, toSvg } from './svg.ts';
|
|
23
37
|
export {
|
package/src/rebase.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { DrawingElement } from './protocol.ts';
|
|
2
|
+
|
|
3
|
+
export type DrawingRebase = { ok: true; elements: DrawingElement[] } | { ok: false; conflicts: string[] };
|
|
4
|
+
|
|
5
|
+
/* The same value whatever order its keys were written in, so a document read back from disk equals the one sent. */
|
|
6
|
+
function canonical(value: unknown): string {
|
|
7
|
+
if (Array.isArray(value)) {
|
|
8
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
9
|
+
}
|
|
10
|
+
if (value !== null && typeof value === 'object') {
|
|
11
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
12
|
+
.filter(([, entry]) => entry !== undefined)
|
|
13
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
14
|
+
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(',')}}`;
|
|
15
|
+
}
|
|
16
|
+
return JSON.stringify(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function byId(elements: readonly DrawingElement[]): Map<string, { element: DrawingElement; text: string }> {
|
|
20
|
+
return new Map(elements.map((element) => [element.id, { element, text: canonical(element) }]));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Whether a side stacked the elements it shares with the base in another order. */
|
|
24
|
+
function restacked(base: readonly DrawingElement[], side: readonly DrawingElement[]): boolean {
|
|
25
|
+
const shared = new Set(base.map((element) => element.id));
|
|
26
|
+
const order = side.map((element) => element.id).filter((id) => shared.has(id));
|
|
27
|
+
const inSide = new Set(order);
|
|
28
|
+
const before = base.map((element) => element.id).filter((id) => inSide.has(id));
|
|
29
|
+
return order.some((id, i) => id !== before[i]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* Puts the elements `mine` changed since `base` onto `theirs`, by id. Only an element both sides
|
|
34
|
+
* changed, each in its own way, is a conflict; an element added, changed or removed on one side only
|
|
35
|
+
* merges. The stacking order is the one a side changed, `theirs` when neither or both did, and an
|
|
36
|
+
* element only the other side added goes in just above the element it stood on there.
|
|
37
|
+
*/
|
|
38
|
+
export function rebaseDrawingElements(base: readonly DrawingElement[], mine: readonly DrawingElement[], theirs: readonly DrawingElement[]): DrawingRebase {
|
|
39
|
+
const baseById = byId(base);
|
|
40
|
+
const mineById = byId(mine);
|
|
41
|
+
const theirsById = byId(theirs);
|
|
42
|
+
const ids = new Set([...baseById.keys(), ...mineById.keys(), ...theirsById.keys()]);
|
|
43
|
+
const resolved = new Map<string, DrawingElement>();
|
|
44
|
+
const conflicts: string[] = [];
|
|
45
|
+
for (const id of ids) {
|
|
46
|
+
const original = baseById.get(id)?.text;
|
|
47
|
+
const ours = mineById.get(id);
|
|
48
|
+
const their = theirsById.get(id);
|
|
49
|
+
const oursChanged = ours?.text !== original;
|
|
50
|
+
const theirsChanged = their?.text !== original;
|
|
51
|
+
if (oursChanged && theirsChanged && ours?.text !== their?.text) {
|
|
52
|
+
conflicts.push(id);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const kept = oursChanged ? ours : their;
|
|
56
|
+
if (kept !== undefined) {
|
|
57
|
+
resolved.set(id, kept.element);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (conflicts.length > 0) {
|
|
61
|
+
return { ok: false, conflicts };
|
|
62
|
+
}
|
|
63
|
+
const mineLeads = restacked(base, mine) && !restacked(base, theirs);
|
|
64
|
+
const [leading, following] = mineLeads ? [mine, theirs] : [theirs, mine];
|
|
65
|
+
const order = leading.map((element) => element.id).filter((id) => resolved.has(id));
|
|
66
|
+
const placed = new Set(order);
|
|
67
|
+
following.forEach((element, i) => {
|
|
68
|
+
if (placed.has(element.id) || !resolved.has(element.id)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
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
|
+
});
|
|
78
|
+
return { ok: true, elements: order.flatMap((id) => resolved.get(id) ?? []) };
|
|
79
|
+
}
|