@mocanvas/editor 1.0.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/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/index.d.ts +2066 -0
- package/dist/index.js +4801 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4801 @@
|
|
|
1
|
+
import { atom, computed, transact, unsafe__withoutCapture, react } from '@mocanvas/state';
|
|
2
|
+
import { createRecordType, uniqueId, isRecordsDiffEmpty, squashRecordDiffs, reverseRecordsDiff, ZERO_INDEX_KEY, sortByIndex, getIndexAbove, getIndexBetween, getIndexBelow, getIndicesAbove, indexKeyToZKey, StoreSchema, Store } from '@mocanvas/store';
|
|
3
|
+
import { PATH_OP, FLAG, EngineBridge, VERTEX_FLOATS, BATCH_WORDS, readClip } from '@mocanvas/wasm';
|
|
4
|
+
export { EngineBridge, FLAG, PATH_OP, loadEngine, loadEngineSync } from '@mocanvas/wasm';
|
|
5
|
+
import { track, useValue } from '@mocanvas/state/react';
|
|
6
|
+
export { track, useAtom, useComputed, useQuickReactor, useReactor, useValue } from '@mocanvas/state/react';
|
|
7
|
+
import { createContext, useContext, useRef, useState, useLayoutEffect, useEffect, useMemo } from 'react';
|
|
8
|
+
import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
|
|
9
|
+
|
|
10
|
+
// src/editor/Editor.ts
|
|
11
|
+
var Vec = class _Vec {
|
|
12
|
+
constructor(x = 0, y = 0) {
|
|
13
|
+
this.x = x;
|
|
14
|
+
this.y = y;
|
|
15
|
+
}
|
|
16
|
+
x;
|
|
17
|
+
y;
|
|
18
|
+
static From(p) {
|
|
19
|
+
return new _Vec(p.x, p.y);
|
|
20
|
+
}
|
|
21
|
+
static Add(a, b) {
|
|
22
|
+
return new _Vec(a.x + b.x, a.y + b.y);
|
|
23
|
+
}
|
|
24
|
+
static Sub(a, b) {
|
|
25
|
+
return new _Vec(a.x - b.x, a.y - b.y);
|
|
26
|
+
}
|
|
27
|
+
static Mul(a, s) {
|
|
28
|
+
return new _Vec(a.x * s, a.y * s);
|
|
29
|
+
}
|
|
30
|
+
static Div(a, s) {
|
|
31
|
+
return new _Vec(a.x / s, a.y / s);
|
|
32
|
+
}
|
|
33
|
+
static Dist(a, b) {
|
|
34
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
35
|
+
}
|
|
36
|
+
static Dist2(a, b) {
|
|
37
|
+
const dx = a.x - b.x;
|
|
38
|
+
const dy = a.y - b.y;
|
|
39
|
+
return dx * dx + dy * dy;
|
|
40
|
+
}
|
|
41
|
+
static Len(a) {
|
|
42
|
+
return Math.hypot(a.x, a.y);
|
|
43
|
+
}
|
|
44
|
+
static Dot(a, b) {
|
|
45
|
+
return a.x * b.x + a.y * b.y;
|
|
46
|
+
}
|
|
47
|
+
static Cross(a, b) {
|
|
48
|
+
return a.x * b.y - a.y * b.x;
|
|
49
|
+
}
|
|
50
|
+
static Lrp(a, b, t) {
|
|
51
|
+
return new _Vec(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
|
|
52
|
+
}
|
|
53
|
+
static Angle(a, b) {
|
|
54
|
+
return Math.atan2(b.y - a.y, b.x - a.x);
|
|
55
|
+
}
|
|
56
|
+
static Rot(a, r) {
|
|
57
|
+
const s = Math.sin(r);
|
|
58
|
+
const c = Math.cos(r);
|
|
59
|
+
return new _Vec(a.x * c - a.y * s, a.x * s + a.y * c);
|
|
60
|
+
}
|
|
61
|
+
static RotWith(a, center, r) {
|
|
62
|
+
const p = _Vec.Rot(_Vec.Sub(a, center), r);
|
|
63
|
+
return new _Vec(p.x + center.x, p.y + center.y);
|
|
64
|
+
}
|
|
65
|
+
static Uni(a) {
|
|
66
|
+
const l = _Vec.Len(a);
|
|
67
|
+
return l === 0 ? new _Vec() : _Vec.Div(a, l);
|
|
68
|
+
}
|
|
69
|
+
static Per(a) {
|
|
70
|
+
return new _Vec(-a.y, a.x);
|
|
71
|
+
}
|
|
72
|
+
static Equals(a, b) {
|
|
73
|
+
return a.x === b.x && a.y === b.y;
|
|
74
|
+
}
|
|
75
|
+
static NearestPointOnLineSegment(a, b, p) {
|
|
76
|
+
const ab = _Vec.Sub(b, a);
|
|
77
|
+
const l2 = _Vec.Dot(ab, ab);
|
|
78
|
+
if (l2 === 0) return _Vec.From(a);
|
|
79
|
+
const t = Math.max(0, Math.min(1, _Vec.Dot(_Vec.Sub(p, a), ab) / l2));
|
|
80
|
+
return _Vec.Add(a, _Vec.Mul(ab, t));
|
|
81
|
+
}
|
|
82
|
+
static DistanceToLineSegment(a, b, p) {
|
|
83
|
+
return _Vec.Dist(p, _Vec.NearestPointOnLineSegment(a, b, p));
|
|
84
|
+
}
|
|
85
|
+
add(b) {
|
|
86
|
+
this.x += b.x;
|
|
87
|
+
this.y += b.y;
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
sub(b) {
|
|
91
|
+
this.x -= b.x;
|
|
92
|
+
this.y -= b.y;
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
mul(s) {
|
|
96
|
+
this.x *= s;
|
|
97
|
+
this.y *= s;
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
clone() {
|
|
101
|
+
return new _Vec(this.x, this.y);
|
|
102
|
+
}
|
|
103
|
+
toJson() {
|
|
104
|
+
return { x: this.x, y: this.y };
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
var Box = class _Box {
|
|
108
|
+
constructor(x = 0, y = 0, w = 0, h = 0) {
|
|
109
|
+
this.x = x;
|
|
110
|
+
this.y = y;
|
|
111
|
+
this.w = w;
|
|
112
|
+
this.h = h;
|
|
113
|
+
}
|
|
114
|
+
x;
|
|
115
|
+
y;
|
|
116
|
+
w;
|
|
117
|
+
h;
|
|
118
|
+
static From(b) {
|
|
119
|
+
return new _Box(b.x, b.y, b.w, b.h);
|
|
120
|
+
}
|
|
121
|
+
static FromPoints(points) {
|
|
122
|
+
if (points.length === 0) return new _Box();
|
|
123
|
+
let minX = Infinity;
|
|
124
|
+
let minY = Infinity;
|
|
125
|
+
let maxX = -Infinity;
|
|
126
|
+
let maxY = -Infinity;
|
|
127
|
+
for (const p of points) {
|
|
128
|
+
if (p.x < minX) minX = p.x;
|
|
129
|
+
if (p.y < minY) minY = p.y;
|
|
130
|
+
if (p.x > maxX) maxX = p.x;
|
|
131
|
+
if (p.y > maxY) maxY = p.y;
|
|
132
|
+
}
|
|
133
|
+
return new _Box(minX, minY, maxX - minX, maxY - minY);
|
|
134
|
+
}
|
|
135
|
+
static FromMinMax(minX, minY, maxX, maxY) {
|
|
136
|
+
return new _Box(minX, minY, maxX - minX, maxY - minY);
|
|
137
|
+
}
|
|
138
|
+
static Common(boxes) {
|
|
139
|
+
if (boxes.length === 0) return new _Box();
|
|
140
|
+
let minX = Infinity;
|
|
141
|
+
let minY = Infinity;
|
|
142
|
+
let maxX = -Infinity;
|
|
143
|
+
let maxY = -Infinity;
|
|
144
|
+
for (const b of boxes) {
|
|
145
|
+
minX = Math.min(minX, b.x);
|
|
146
|
+
minY = Math.min(minY, b.y);
|
|
147
|
+
maxX = Math.max(maxX, b.x + b.w);
|
|
148
|
+
maxY = Math.max(maxY, b.y + b.h);
|
|
149
|
+
}
|
|
150
|
+
return _Box.FromMinMax(minX, minY, maxX, maxY);
|
|
151
|
+
}
|
|
152
|
+
static Contains(a, b) {
|
|
153
|
+
return b.x >= a.x && b.y >= a.y && b.x + b.w <= a.x + a.w && b.y + b.h <= a.y + a.h;
|
|
154
|
+
}
|
|
155
|
+
static ContainsPoint(a, p, margin = 0) {
|
|
156
|
+
return p.x >= a.x - margin && p.y >= a.y - margin && p.x <= a.x + a.w + margin && p.y <= a.y + a.h + margin;
|
|
157
|
+
}
|
|
158
|
+
static Collides(a, b) {
|
|
159
|
+
return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
|
|
160
|
+
}
|
|
161
|
+
static Expand(a, d) {
|
|
162
|
+
return new _Box(a.x - d, a.y - d, a.w + 2 * d, a.h + 2 * d);
|
|
163
|
+
}
|
|
164
|
+
get minX() {
|
|
165
|
+
return this.x;
|
|
166
|
+
}
|
|
167
|
+
get minY() {
|
|
168
|
+
return this.y;
|
|
169
|
+
}
|
|
170
|
+
get maxX() {
|
|
171
|
+
return this.x + this.w;
|
|
172
|
+
}
|
|
173
|
+
get maxY() {
|
|
174
|
+
return this.y + this.h;
|
|
175
|
+
}
|
|
176
|
+
get width() {
|
|
177
|
+
return this.w;
|
|
178
|
+
}
|
|
179
|
+
get height() {
|
|
180
|
+
return this.h;
|
|
181
|
+
}
|
|
182
|
+
get center() {
|
|
183
|
+
return new Vec(this.x + this.w / 2, this.y + this.h / 2);
|
|
184
|
+
}
|
|
185
|
+
get corners() {
|
|
186
|
+
return [
|
|
187
|
+
new Vec(this.x, this.y),
|
|
188
|
+
new Vec(this.maxX, this.y),
|
|
189
|
+
new Vec(this.maxX, this.maxY),
|
|
190
|
+
new Vec(this.x, this.maxY)
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
clone() {
|
|
194
|
+
return new _Box(this.x, this.y, this.w, this.h);
|
|
195
|
+
}
|
|
196
|
+
toJson() {
|
|
197
|
+
return { x: this.x, y: this.y, w: this.w, h: this.h };
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
var Geometry2d = class {
|
|
201
|
+
isFilled;
|
|
202
|
+
isClosed;
|
|
203
|
+
isLabel;
|
|
204
|
+
_vertices;
|
|
205
|
+
_bounds;
|
|
206
|
+
constructor(opts) {
|
|
207
|
+
this.isFilled = opts.isFilled;
|
|
208
|
+
this.isClosed = opts.isClosed;
|
|
209
|
+
this.isLabel = opts.isLabel ?? false;
|
|
210
|
+
}
|
|
211
|
+
get vertices() {
|
|
212
|
+
return this._vertices ??= this.getVertices();
|
|
213
|
+
}
|
|
214
|
+
get bounds() {
|
|
215
|
+
return this._bounds ??= Box.FromPoints(this.vertices);
|
|
216
|
+
}
|
|
217
|
+
get center() {
|
|
218
|
+
return this.bounds.center;
|
|
219
|
+
}
|
|
220
|
+
nearestPoint(point) {
|
|
221
|
+
const v = this.vertices;
|
|
222
|
+
if (v.length === 0) return new Vec();
|
|
223
|
+
if (v.length === 1) return v[0].clone();
|
|
224
|
+
let best = v[0];
|
|
225
|
+
let bestD = Infinity;
|
|
226
|
+
const n = this.isClosed ? v.length : v.length - 1;
|
|
227
|
+
for (let i = 0; i < n; i++) {
|
|
228
|
+
const a = v[i];
|
|
229
|
+
const b = v[(i + 1) % v.length];
|
|
230
|
+
const p = Vec.NearestPointOnLineSegment(a, b, point);
|
|
231
|
+
const d = Vec.Dist2(p, point);
|
|
232
|
+
if (d < bestD) {
|
|
233
|
+
bestD = d;
|
|
234
|
+
best = p;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return best;
|
|
238
|
+
}
|
|
239
|
+
distanceToPoint(point, hitInside = false) {
|
|
240
|
+
const d = Vec.Dist(point, this.nearestPoint(point));
|
|
241
|
+
if (hitInside && this.isClosed && this.isFilled && pointInPolygon(point, this.vertices)) return -d;
|
|
242
|
+
return d;
|
|
243
|
+
}
|
|
244
|
+
hitTestPoint(point, margin = 0, hitInside = false) {
|
|
245
|
+
if (hitInside && this.isClosed && pointInPolygon(point, this.vertices)) return true;
|
|
246
|
+
return Vec.Dist(point, this.nearestPoint(point)) <= margin;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
function pointInPolygon(p, poly) {
|
|
250
|
+
let inside = false;
|
|
251
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
252
|
+
const a = poly[i];
|
|
253
|
+
const b = poly[j];
|
|
254
|
+
if (a.y > p.y !== b.y > p.y && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
|
255
|
+
}
|
|
256
|
+
return inside;
|
|
257
|
+
}
|
|
258
|
+
function polygonWords(points, close) {
|
|
259
|
+
const out = [];
|
|
260
|
+
if (points.length === 0) return out;
|
|
261
|
+
out.push(PATH_OP.MOVE, points[0].x, points[0].y);
|
|
262
|
+
for (let i = 1; i < points.length; i++) out.push(PATH_OP.LINE, points[i].x, points[i].y);
|
|
263
|
+
if (close) out.push(PATH_OP.CLOSE);
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
var Rectangle2d = class extends Geometry2d {
|
|
267
|
+
x;
|
|
268
|
+
y;
|
|
269
|
+
w;
|
|
270
|
+
h;
|
|
271
|
+
constructor(opts) {
|
|
272
|
+
super({ isFilled: opts.isFilled, isClosed: true, ...opts.isLabel !== void 0 ? { isLabel: opts.isLabel } : {} });
|
|
273
|
+
this.x = opts.x ?? 0;
|
|
274
|
+
this.y = opts.y ?? 0;
|
|
275
|
+
this.w = opts.width;
|
|
276
|
+
this.h = opts.height;
|
|
277
|
+
}
|
|
278
|
+
getVertices() {
|
|
279
|
+
return new Box(this.x, this.y, this.w, this.h).corners;
|
|
280
|
+
}
|
|
281
|
+
toPathWords() {
|
|
282
|
+
return polygonWords(this.getVertices(), true);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
var KAPPA = 0.5522848;
|
|
286
|
+
var Ellipse2d = class extends Geometry2d {
|
|
287
|
+
w;
|
|
288
|
+
h;
|
|
289
|
+
constructor(opts) {
|
|
290
|
+
super({ isFilled: opts.isFilled, isClosed: true });
|
|
291
|
+
this.w = opts.width;
|
|
292
|
+
this.h = opts.height;
|
|
293
|
+
}
|
|
294
|
+
getVertices() {
|
|
295
|
+
const rx = this.w / 2;
|
|
296
|
+
const ry = this.h / 2;
|
|
297
|
+
const n = Math.max(16, Math.min(128, Math.ceil((rx + ry) / 4)));
|
|
298
|
+
const out = [];
|
|
299
|
+
for (let i = 0; i < n; i++) {
|
|
300
|
+
const t = i / n * Math.PI * 2;
|
|
301
|
+
out.push(new Vec(rx + rx * Math.cos(t), ry + ry * Math.sin(t)));
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
toPathWords() {
|
|
306
|
+
const rx = this.w / 2;
|
|
307
|
+
const ry = this.h / 2;
|
|
308
|
+
const cx = rx;
|
|
309
|
+
const cy = ry;
|
|
310
|
+
const kx = KAPPA * rx;
|
|
311
|
+
const ky = KAPPA * ry;
|
|
312
|
+
const C = PATH_OP.CUBIC;
|
|
313
|
+
return [
|
|
314
|
+
PATH_OP.MOVE,
|
|
315
|
+
cx + rx,
|
|
316
|
+
cy,
|
|
317
|
+
C,
|
|
318
|
+
cx + rx,
|
|
319
|
+
cy + ky,
|
|
320
|
+
cx + kx,
|
|
321
|
+
cy + ry,
|
|
322
|
+
cx,
|
|
323
|
+
cy + ry,
|
|
324
|
+
C,
|
|
325
|
+
cx - kx,
|
|
326
|
+
cy + ry,
|
|
327
|
+
cx - rx,
|
|
328
|
+
cy + ky,
|
|
329
|
+
cx - rx,
|
|
330
|
+
cy,
|
|
331
|
+
C,
|
|
332
|
+
cx - rx,
|
|
333
|
+
cy - ky,
|
|
334
|
+
cx - kx,
|
|
335
|
+
cy - ry,
|
|
336
|
+
cx,
|
|
337
|
+
cy - ry,
|
|
338
|
+
C,
|
|
339
|
+
cx + kx,
|
|
340
|
+
cy - ry,
|
|
341
|
+
cx + rx,
|
|
342
|
+
cy - ky,
|
|
343
|
+
cx + rx,
|
|
344
|
+
cy,
|
|
345
|
+
PATH_OP.CLOSE
|
|
346
|
+
];
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
var Circle2d = class extends Ellipse2d {
|
|
350
|
+
constructor(opts) {
|
|
351
|
+
super({ width: opts.radius * 2, height: opts.radius * 2, isFilled: opts.isFilled });
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
var Polygon2d = class extends Geometry2d {
|
|
355
|
+
points;
|
|
356
|
+
constructor(opts) {
|
|
357
|
+
super({ isFilled: opts.isFilled, isClosed: true });
|
|
358
|
+
this.points = opts.points.map(Vec.From);
|
|
359
|
+
}
|
|
360
|
+
getVertices() {
|
|
361
|
+
return this.points;
|
|
362
|
+
}
|
|
363
|
+
toPathWords() {
|
|
364
|
+
return polygonWords(this.points, true);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
var Polyline2d = class extends Geometry2d {
|
|
368
|
+
points;
|
|
369
|
+
constructor(opts) {
|
|
370
|
+
super({ isFilled: false, isClosed: false });
|
|
371
|
+
this.points = opts.points.map(Vec.From);
|
|
372
|
+
}
|
|
373
|
+
getVertices() {
|
|
374
|
+
return this.points;
|
|
375
|
+
}
|
|
376
|
+
toPathWords() {
|
|
377
|
+
return polygonWords(this.points, false);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
var Edge2d = class extends Polyline2d {
|
|
381
|
+
constructor(opts) {
|
|
382
|
+
super({ points: [opts.start, opts.end] });
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
var CubicSpline2d = class extends Geometry2d {
|
|
386
|
+
segments;
|
|
387
|
+
constructor(opts) {
|
|
388
|
+
super({ isFilled: opts.isFilled ?? false, isClosed: opts.isClosed ?? false });
|
|
389
|
+
this.segments = opts.segments.map((s) => ({ p0: Vec.From(s.p0), c1: Vec.From(s.c1), c2: Vec.From(s.c2), p1: Vec.From(s.p1) }));
|
|
390
|
+
}
|
|
391
|
+
getVertices() {
|
|
392
|
+
const out = [];
|
|
393
|
+
for (const s of this.segments) {
|
|
394
|
+
const n = 12;
|
|
395
|
+
for (let i = 0; i < n; i++) {
|
|
396
|
+
const t = i / n;
|
|
397
|
+
const u = 1 - t;
|
|
398
|
+
out.push(
|
|
399
|
+
new Vec(
|
|
400
|
+
u * u * u * s.p0.x + 3 * u * u * t * s.c1.x + 3 * u * t * t * s.c2.x + t * t * t * s.p1.x,
|
|
401
|
+
u * u * u * s.p0.y + 3 * u * u * t * s.c1.y + 3 * u * t * t * s.c2.y + t * t * t * s.p1.y
|
|
402
|
+
)
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const last = this.segments.at(-1);
|
|
407
|
+
if (last) out.push(last.p1.clone());
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
toPathWords() {
|
|
411
|
+
const out = [];
|
|
412
|
+
const first = this.segments[0];
|
|
413
|
+
if (!first) return out;
|
|
414
|
+
out.push(PATH_OP.MOVE, first.p0.x, first.p0.y);
|
|
415
|
+
for (const s of this.segments) out.push(PATH_OP.CUBIC, s.c1.x, s.c1.y, s.c2.x, s.c2.y, s.p1.x, s.p1.y);
|
|
416
|
+
if (this.isClosed) out.push(PATH_OP.CLOSE);
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
var Group2d = class extends Geometry2d {
|
|
421
|
+
children;
|
|
422
|
+
constructor(opts) {
|
|
423
|
+
super({ isFilled: opts.children.some((c) => c.isFilled), isClosed: opts.children.some((c) => c.isClosed) });
|
|
424
|
+
this.children = opts.children;
|
|
425
|
+
}
|
|
426
|
+
getVertices() {
|
|
427
|
+
return this.children.flatMap((c) => c.vertices);
|
|
428
|
+
}
|
|
429
|
+
toPathWords() {
|
|
430
|
+
return this.children.filter((c) => !c.isLabel).flatMap((c) => c.toPathWords());
|
|
431
|
+
}
|
|
432
|
+
nearestPoint(point) {
|
|
433
|
+
let best = new Vec();
|
|
434
|
+
let bestD = Infinity;
|
|
435
|
+
for (const c of this.children) {
|
|
436
|
+
const p = c.nearestPoint(point);
|
|
437
|
+
const d = Vec.Dist2(p, point);
|
|
438
|
+
if (d < bestD) {
|
|
439
|
+
bestD = d;
|
|
440
|
+
best = p;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return best;
|
|
444
|
+
}
|
|
445
|
+
hitTestPoint(point, margin = 0, hitInside = false) {
|
|
446
|
+
return this.children.some((c) => c.hitTestPoint(point, margin, hitInside));
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
var DocumentRecordType = createRecordType("document", { scope: "document" }).withDefaultProperties(
|
|
450
|
+
() => ({ gridSize: 10, name: "", meta: {} })
|
|
451
|
+
);
|
|
452
|
+
var DOCUMENT_ID = DocumentRecordType.createId("document");
|
|
453
|
+
var PageRecordType = createRecordType("page", { scope: "document" }).withDefaultProperties(() => ({
|
|
454
|
+
meta: {}
|
|
455
|
+
}));
|
|
456
|
+
function isPageId(id) {
|
|
457
|
+
return id.startsWith("page:");
|
|
458
|
+
}
|
|
459
|
+
var CameraRecordType = createRecordType("camera", { scope: "session" }).withDefaultProperties(() => ({
|
|
460
|
+
x: 0,
|
|
461
|
+
y: 0,
|
|
462
|
+
z: 1,
|
|
463
|
+
meta: {}
|
|
464
|
+
}));
|
|
465
|
+
var InstanceRecordType = createRecordType("instance", { scope: "session" }).withDefaultProperties(
|
|
466
|
+
() => ({
|
|
467
|
+
followingUserId: null,
|
|
468
|
+
isFocusMode: false,
|
|
469
|
+
isDebugMode: false,
|
|
470
|
+
isToolLocked: false,
|
|
471
|
+
isGridMode: false,
|
|
472
|
+
isReadonly: false,
|
|
473
|
+
isFocused: false,
|
|
474
|
+
isPenMode: false,
|
|
475
|
+
isChangingStyle: false,
|
|
476
|
+
exportBackground: true,
|
|
477
|
+
screenBounds: { x: 0, y: 0, w: 1080, h: 720 },
|
|
478
|
+
insets: [false, false, false, false],
|
|
479
|
+
cursor: { type: "default", rotation: 0 },
|
|
480
|
+
scribbles: [],
|
|
481
|
+
brush: null,
|
|
482
|
+
zoomBrush: null,
|
|
483
|
+
openMenus: [],
|
|
484
|
+
devicePixelRatio: typeof window === "undefined" ? 1 : window.devicePixelRatio,
|
|
485
|
+
isCoarsePointer: false,
|
|
486
|
+
isHoveringCanvas: null,
|
|
487
|
+
stylesForNextShape: {},
|
|
488
|
+
duplicateProps: null,
|
|
489
|
+
meta: {}
|
|
490
|
+
})
|
|
491
|
+
);
|
|
492
|
+
var INSTANCE_ID = InstanceRecordType.createId("instance");
|
|
493
|
+
var InstancePageStateRecordType = createRecordType("instance_page_state", {
|
|
494
|
+
scope: "session"
|
|
495
|
+
}).withDefaultProperties(() => ({
|
|
496
|
+
selectedShapeIds: [],
|
|
497
|
+
hintingShapeIds: [],
|
|
498
|
+
erasingShapeIds: [],
|
|
499
|
+
hoveredShapeId: null,
|
|
500
|
+
editingShapeId: null,
|
|
501
|
+
croppingShapeId: null,
|
|
502
|
+
focusedGroupId: null,
|
|
503
|
+
meta: {}
|
|
504
|
+
}));
|
|
505
|
+
var ShapeRecordType = createRecordType("shape", { scope: "document" }).withDefaultProperties(
|
|
506
|
+
() => ({ x: 0, y: 0, rotation: 0, isLocked: false, opacity: 1, meta: {} })
|
|
507
|
+
);
|
|
508
|
+
function createShapeId(id) {
|
|
509
|
+
return ShapeRecordType.createId(id);
|
|
510
|
+
}
|
|
511
|
+
function isShapeId(id) {
|
|
512
|
+
return typeof id === "string" && id.startsWith("shape:");
|
|
513
|
+
}
|
|
514
|
+
function isShape(record) {
|
|
515
|
+
return typeof record === "object" && record !== null && record.typeName === "shape";
|
|
516
|
+
}
|
|
517
|
+
var BindingRecordType = createRecordType("binding", { scope: "document" }).withDefaultProperties(
|
|
518
|
+
() => ({ meta: {} })
|
|
519
|
+
);
|
|
520
|
+
function createBindingId(id) {
|
|
521
|
+
return BindingRecordType.createId(id);
|
|
522
|
+
}
|
|
523
|
+
function isBindingId(id) {
|
|
524
|
+
return typeof id === "string" && id.startsWith("binding:");
|
|
525
|
+
}
|
|
526
|
+
function isBinding(record) {
|
|
527
|
+
return typeof record === "object" && record !== null && record.typeName === "binding";
|
|
528
|
+
}
|
|
529
|
+
var AssetRecordType = createRecordType("asset", { scope: "document" }).withDefaultProperties(() => ({
|
|
530
|
+
meta: {}
|
|
531
|
+
}));
|
|
532
|
+
function createAssetId(id) {
|
|
533
|
+
return AssetRecordType.createId(id);
|
|
534
|
+
}
|
|
535
|
+
function isAssetId(id) {
|
|
536
|
+
return typeof id === "string" && id.startsWith("asset:");
|
|
537
|
+
}
|
|
538
|
+
function isAsset(record) {
|
|
539
|
+
return typeof record === "object" && record !== null && record.typeName === "asset";
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/editor/events.ts
|
|
543
|
+
var EVENT_NAME_MAP = {
|
|
544
|
+
pointer_down: "onPointerDown",
|
|
545
|
+
pointer_move: "onPointerMove",
|
|
546
|
+
pointer_up: "onPointerUp",
|
|
547
|
+
right_click: "onRightClick",
|
|
548
|
+
middle_click: "onMiddleClick",
|
|
549
|
+
double_click: "onDoubleClick",
|
|
550
|
+
triple_click: "onTripleClick",
|
|
551
|
+
quadruple_click: "onQuadrupleClick",
|
|
552
|
+
key_down: "onKeyDown",
|
|
553
|
+
key_up: "onKeyUp",
|
|
554
|
+
key_repeat: "onKeyRepeat",
|
|
555
|
+
wheel: "onWheel",
|
|
556
|
+
cancel: "onCancel",
|
|
557
|
+
complete: "onComplete",
|
|
558
|
+
interrupt: "onInterrupt",
|
|
559
|
+
tick: "onTick"
|
|
560
|
+
};
|
|
561
|
+
var EventEmitter = class {
|
|
562
|
+
listeners = /* @__PURE__ */ new Map();
|
|
563
|
+
on(name, fn) {
|
|
564
|
+
let set = this.listeners.get(name);
|
|
565
|
+
if (!set) {
|
|
566
|
+
set = /* @__PURE__ */ new Set();
|
|
567
|
+
this.listeners.set(name, set);
|
|
568
|
+
}
|
|
569
|
+
set.add(fn);
|
|
570
|
+
return () => this.off(name, fn);
|
|
571
|
+
}
|
|
572
|
+
once(name, fn) {
|
|
573
|
+
const off = this.on(name, ((...args) => {
|
|
574
|
+
off();
|
|
575
|
+
fn(...args);
|
|
576
|
+
}));
|
|
577
|
+
return off;
|
|
578
|
+
}
|
|
579
|
+
off(name, fn) {
|
|
580
|
+
this.listeners.get(name)?.delete(fn);
|
|
581
|
+
}
|
|
582
|
+
emit(name, ...args) {
|
|
583
|
+
const set = this.listeners.get(name);
|
|
584
|
+
if (!set) return;
|
|
585
|
+
for (const fn of Array.from(set)) fn(...args);
|
|
586
|
+
}
|
|
587
|
+
removeAllListeners() {
|
|
588
|
+
this.listeners.clear();
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
// src/tools/StateNode.ts
|
|
593
|
+
var StateNode = class {
|
|
594
|
+
static id;
|
|
595
|
+
static initial;
|
|
596
|
+
static children;
|
|
597
|
+
static isLockable = true;
|
|
598
|
+
static useCoalescedEvents = false;
|
|
599
|
+
id;
|
|
600
|
+
type;
|
|
601
|
+
initial;
|
|
602
|
+
children;
|
|
603
|
+
parent;
|
|
604
|
+
editor;
|
|
605
|
+
_isActive;
|
|
606
|
+
_current;
|
|
607
|
+
_path;
|
|
608
|
+
/** Whether the shape kind this tool creates should be kept selected after creation etc. */
|
|
609
|
+
shapeType;
|
|
610
|
+
/** Tools can declare the interaction they perform when locked. */
|
|
611
|
+
static get isLockableTool() {
|
|
612
|
+
return this.isLockable;
|
|
613
|
+
}
|
|
614
|
+
constructor(editor, parent) {
|
|
615
|
+
const ctor = this.constructor;
|
|
616
|
+
this.editor = editor;
|
|
617
|
+
this.parent = parent;
|
|
618
|
+
this.id = ctor.id;
|
|
619
|
+
this._isActive = atom(`${this.id}.isActive`, false);
|
|
620
|
+
this._current = atom(`${this.id}.current`, void 0);
|
|
621
|
+
const childCtors = ctor.children?.();
|
|
622
|
+
if (childCtors && childCtors.length > 0) {
|
|
623
|
+
this.type = parent ? "branch" : "root";
|
|
624
|
+
this.initial = ctor.initial;
|
|
625
|
+
const children = {};
|
|
626
|
+
for (const C of childCtors) {
|
|
627
|
+
children[C.id] = new C(editor, this);
|
|
628
|
+
}
|
|
629
|
+
this.children = children;
|
|
630
|
+
if (this.type === "branch" && !this.initial) {
|
|
631
|
+
throw new Error(`StateNode "${this.id}" has children but no initial state`);
|
|
632
|
+
}
|
|
633
|
+
} else {
|
|
634
|
+
this.type = "leaf";
|
|
635
|
+
}
|
|
636
|
+
this._path = computed(`${this.id}.path`, () => {
|
|
637
|
+
const cur = this._current.get();
|
|
638
|
+
return `${this.id}${cur ? `.${cur.getPath()}` : ""}`;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
getPath() {
|
|
642
|
+
return this._path.get();
|
|
643
|
+
}
|
|
644
|
+
getCurrent() {
|
|
645
|
+
return this._current.get();
|
|
646
|
+
}
|
|
647
|
+
getIsActive() {
|
|
648
|
+
return this._isActive.get();
|
|
649
|
+
}
|
|
650
|
+
getDescendant(path) {
|
|
651
|
+
const [head, ...rest] = path.split(".");
|
|
652
|
+
const child = head ? this.children?.[head] : void 0;
|
|
653
|
+
if (!child) return void 0;
|
|
654
|
+
return rest.length ? child.getDescendant(rest.join(".")) : child;
|
|
655
|
+
}
|
|
656
|
+
/** Dispatch an event to this node, then to its active child. */
|
|
657
|
+
handleEvent(info) {
|
|
658
|
+
const handlerName = EVENT_NAME_MAP[info.name];
|
|
659
|
+
const current = this._current.get();
|
|
660
|
+
if (handlerName) {
|
|
661
|
+
const handler = this[handlerName];
|
|
662
|
+
handler?.call(this, info);
|
|
663
|
+
}
|
|
664
|
+
if (current && current === this._current.get() && current.getIsActive()) {
|
|
665
|
+
current.handleEvent(info);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/** Move this node's current child to `id`, exiting the old one and entering the new one. */
|
|
669
|
+
transition(id, info = {}) {
|
|
670
|
+
if (!this.children) throw new Error(`StateNode "${this.id}" has no children to transition to`);
|
|
671
|
+
const next = this.children[id];
|
|
672
|
+
if (!next) throw new Error(`StateNode "${this.id}" has no child "${id}"`);
|
|
673
|
+
const i = info;
|
|
674
|
+
const prev = this._current.get();
|
|
675
|
+
if (prev) prev.exit(i, id);
|
|
676
|
+
this._current.set(next);
|
|
677
|
+
next.enter(i, prev?.id ?? "initial");
|
|
678
|
+
return this;
|
|
679
|
+
}
|
|
680
|
+
enter(info, from) {
|
|
681
|
+
this._isActive.set(true);
|
|
682
|
+
this.onEnter?.(info, from);
|
|
683
|
+
if (this.children && this.initial && this.getIsActive()) {
|
|
684
|
+
const initialChild = this.children[this.initial];
|
|
685
|
+
if (!initialChild) throw new Error(`StateNode "${this.id}" initial child "${this.initial}" not found`);
|
|
686
|
+
this._current.set(initialChild);
|
|
687
|
+
initialChild.enter(info, from);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
exit(info, to) {
|
|
691
|
+
const cur = this._current.get();
|
|
692
|
+
if (cur) cur.exit(info, to);
|
|
693
|
+
this._current.set(void 0);
|
|
694
|
+
this._isActive.set(false);
|
|
695
|
+
this.onExit?.(info, to);
|
|
696
|
+
}
|
|
697
|
+
/** Change the active tool from anywhere in the tree. */
|
|
698
|
+
setCurrentToolIdMask(_mask) {
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
// src/tools/RootState.ts
|
|
703
|
+
var RootState = class extends StateNode {
|
|
704
|
+
static id = "root";
|
|
705
|
+
static initial = "";
|
|
706
|
+
static children = () => [];
|
|
707
|
+
onKeyDown(info) {
|
|
708
|
+
if (info.key === "Escape" && !this.editor.getEditingShapeId()) {
|
|
709
|
+
this.editor.cancel();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
function createRootState(tools, initial) {
|
|
714
|
+
class Root extends RootState {
|
|
715
|
+
static id = "root";
|
|
716
|
+
static initial = initial;
|
|
717
|
+
static children = () => [...tools];
|
|
718
|
+
}
|
|
719
|
+
return Root;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// src/editor/HandleTable.ts
|
|
723
|
+
var HandleTable = class {
|
|
724
|
+
toHandle = /* @__PURE__ */ new Map();
|
|
725
|
+
toId = [void 0];
|
|
726
|
+
free = [];
|
|
727
|
+
get size() {
|
|
728
|
+
return this.toHandle.size;
|
|
729
|
+
}
|
|
730
|
+
handle(id) {
|
|
731
|
+
const h = this.toHandle.get(id);
|
|
732
|
+
if (h !== void 0) return h;
|
|
733
|
+
const nh = this.free.pop() ?? this.toId.length;
|
|
734
|
+
this.toId[nh] = id;
|
|
735
|
+
this.toHandle.set(id, nh);
|
|
736
|
+
return nh;
|
|
737
|
+
}
|
|
738
|
+
peek(id) {
|
|
739
|
+
return this.toHandle.get(id);
|
|
740
|
+
}
|
|
741
|
+
id(handle) {
|
|
742
|
+
return this.toId[handle];
|
|
743
|
+
}
|
|
744
|
+
release(id) {
|
|
745
|
+
const h = this.toHandle.get(id);
|
|
746
|
+
if (h === void 0) return void 0;
|
|
747
|
+
this.toHandle.delete(id);
|
|
748
|
+
this.toId[h] = void 0;
|
|
749
|
+
this.free.push(h);
|
|
750
|
+
return h;
|
|
751
|
+
}
|
|
752
|
+
clear() {
|
|
753
|
+
this.toHandle.clear();
|
|
754
|
+
this.toId = [void 0];
|
|
755
|
+
this.free = [];
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
// src/editor/TextureManager.ts
|
|
760
|
+
var MAX_TEXTURE_RESOLUTION = 8;
|
|
761
|
+
var MAX_TEXTURE_ZOOM = 4;
|
|
762
|
+
var MIN_TEXTURE_ZOOM = 0.25;
|
|
763
|
+
function bucketTextureResolution(zoom, dpr = 1) {
|
|
764
|
+
const z = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
|
|
765
|
+
const clamped = Math.min(MAX_TEXTURE_ZOOM, Math.max(MIN_TEXTURE_ZOOM, z));
|
|
766
|
+
const bucket = 2 ** Math.ceil(Math.log2(clamped));
|
|
767
|
+
const d = Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
|
|
768
|
+
return Math.min(MAX_TEXTURE_RESOLUTION, Math.max(MIN_TEXTURE_ZOOM, d * bucket));
|
|
769
|
+
}
|
|
770
|
+
var TextureManager = class {
|
|
771
|
+
constructor(options = {}) {
|
|
772
|
+
this.options = options;
|
|
773
|
+
}
|
|
774
|
+
options;
|
|
775
|
+
entries = /* @__PURE__ */ new Map();
|
|
776
|
+
/** owner id → keys that owner currently holds a reference to. */
|
|
777
|
+
ownerKeys = /* @__PURE__ */ new Map();
|
|
778
|
+
/** key → owner ids, so the host can find the shapes to re-write. */
|
|
779
|
+
keyOwners = /* @__PURE__ */ new Map();
|
|
780
|
+
backend = null;
|
|
781
|
+
nextId = 1;
|
|
782
|
+
disposed = false;
|
|
783
|
+
owner = null;
|
|
784
|
+
touched = null;
|
|
785
|
+
/** Number of live texture entries. */
|
|
786
|
+
get size() {
|
|
787
|
+
return this.entries.size;
|
|
788
|
+
}
|
|
789
|
+
getBackend() {
|
|
790
|
+
return this.backend;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Reference the texture for `key`, starting `load` the first time. Returns
|
|
794
|
+
* the host texture id, or `0` when the key previously failed to load.
|
|
795
|
+
*/
|
|
796
|
+
acquire(key, load, opts) {
|
|
797
|
+
if (this.disposed) return 0;
|
|
798
|
+
let entry = this.entries.get(key);
|
|
799
|
+
if (!entry) {
|
|
800
|
+
entry = { key, id: this.nextId++, refs: 0, state: "pending", width: 0, height: 0, source: null, opts };
|
|
801
|
+
this.entries.set(key, entry);
|
|
802
|
+
this.load(entry, load);
|
|
803
|
+
}
|
|
804
|
+
const owner = this.owner;
|
|
805
|
+
if (owner === null) {
|
|
806
|
+
entry.refs++;
|
|
807
|
+
} else {
|
|
808
|
+
let held = this.ownerKeys.get(owner);
|
|
809
|
+
if (!held) {
|
|
810
|
+
held = /* @__PURE__ */ new Set();
|
|
811
|
+
this.ownerKeys.set(owner, held);
|
|
812
|
+
}
|
|
813
|
+
(this.touched ??= /* @__PURE__ */ new Set()).add(key);
|
|
814
|
+
if (!held.has(key)) {
|
|
815
|
+
held.add(key);
|
|
816
|
+
this.addOwner(key, owner);
|
|
817
|
+
entry.refs++;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return entry.state === "error" ? 0 : entry.id;
|
|
821
|
+
}
|
|
822
|
+
/** Drop one reference. The texture is deleted when the last one goes. */
|
|
823
|
+
release(key) {
|
|
824
|
+
const entry = this.entries.get(key);
|
|
825
|
+
if (!entry) return;
|
|
826
|
+
entry.refs--;
|
|
827
|
+
if (entry.refs > 0) return;
|
|
828
|
+
this.entries.delete(key);
|
|
829
|
+
this.keyOwners.delete(key);
|
|
830
|
+
this.destroy(entry);
|
|
831
|
+
}
|
|
832
|
+
/** Whether the texture for `key` is uploaded and safe to draw. */
|
|
833
|
+
isReady(key) {
|
|
834
|
+
return this.entries.get(key)?.state === "ready";
|
|
835
|
+
}
|
|
836
|
+
getState(key) {
|
|
837
|
+
return this.entries.get(key)?.state;
|
|
838
|
+
}
|
|
839
|
+
/** Current id for `key`: `0` when unknown or failed. */
|
|
840
|
+
getId(key) {
|
|
841
|
+
const entry = this.entries.get(key);
|
|
842
|
+
if (!entry || entry.state === "error") return 0;
|
|
843
|
+
return entry.id;
|
|
844
|
+
}
|
|
845
|
+
getInfo(key) {
|
|
846
|
+
const e = this.entries.get(key);
|
|
847
|
+
return e && { id: e.id, refs: e.refs, state: e.state, width: e.width, height: e.height };
|
|
848
|
+
}
|
|
849
|
+
/** Owners (shape ids) currently holding `key`. */
|
|
850
|
+
getOwners(key) {
|
|
851
|
+
const owners = this.keyOwners.get(key);
|
|
852
|
+
return owners ? [...owners] : [];
|
|
853
|
+
}
|
|
854
|
+
/** Every owner holding at least one texture. */
|
|
855
|
+
getAllOwners() {
|
|
856
|
+
return [...this.ownerKeys.keys()];
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Run `fn` with every `acquire` inside it attributed to `owner`. Keys the
|
|
860
|
+
* owner held before but did not acquire again are released, so re-deriving a
|
|
861
|
+
* shape's style neither leaks references nor keeps a stale texture alive.
|
|
862
|
+
*/
|
|
863
|
+
withOwner(owner, fn) {
|
|
864
|
+
if (this.disposed) return fn();
|
|
865
|
+
const prevOwner = this.owner;
|
|
866
|
+
const prevTouched = this.touched;
|
|
867
|
+
this.owner = owner;
|
|
868
|
+
this.touched = null;
|
|
869
|
+
try {
|
|
870
|
+
return fn();
|
|
871
|
+
} finally {
|
|
872
|
+
const touched = this.touched;
|
|
873
|
+
const held = this.ownerKeys.get(owner);
|
|
874
|
+
if (held) {
|
|
875
|
+
for (const key of [...held]) {
|
|
876
|
+
if (touched?.has(key)) continue;
|
|
877
|
+
held.delete(key);
|
|
878
|
+
this.removeOwner(key, owner);
|
|
879
|
+
this.release(key);
|
|
880
|
+
}
|
|
881
|
+
if (held.size === 0) this.ownerKeys.delete(owner);
|
|
882
|
+
}
|
|
883
|
+
this.owner = prevOwner;
|
|
884
|
+
this.touched = prevTouched;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/** Release every key an owner holds (the shape was deleted). */
|
|
888
|
+
releaseOwner(owner) {
|
|
889
|
+
const held = this.ownerKeys.get(owner);
|
|
890
|
+
if (!held) return;
|
|
891
|
+
this.ownerKeys.delete(owner);
|
|
892
|
+
for (const key of held) {
|
|
893
|
+
this.removeOwner(key, owner);
|
|
894
|
+
this.release(key);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Point the manager at the backend that owns the GPU textures. Everything
|
|
899
|
+
* already decoded is uploaded again; anything still loading uploads when it
|
|
900
|
+
* resolves. The previous backend keeps (and frees) its own GL objects.
|
|
901
|
+
*/
|
|
902
|
+
setBackend(backend) {
|
|
903
|
+
if (this.disposed || this.backend === backend) return;
|
|
904
|
+
this.backend = backend;
|
|
905
|
+
if (!backend) return;
|
|
906
|
+
for (const entry of this.entries.values()) {
|
|
907
|
+
if (entry.state === "ready" && entry.source) this.upload(entry);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
dispose() {
|
|
911
|
+
if (this.disposed) return;
|
|
912
|
+
this.disposed = true;
|
|
913
|
+
for (const entry of this.entries.values()) this.destroy(entry);
|
|
914
|
+
this.entries.clear();
|
|
915
|
+
this.ownerKeys.clear();
|
|
916
|
+
this.keyOwners.clear();
|
|
917
|
+
this.backend = null;
|
|
918
|
+
}
|
|
919
|
+
addOwner(key, owner) {
|
|
920
|
+
let owners = this.keyOwners.get(key);
|
|
921
|
+
if (!owners) {
|
|
922
|
+
owners = /* @__PURE__ */ new Set();
|
|
923
|
+
this.keyOwners.set(key, owners);
|
|
924
|
+
}
|
|
925
|
+
owners.add(owner);
|
|
926
|
+
}
|
|
927
|
+
removeOwner(key, owner) {
|
|
928
|
+
const owners = this.keyOwners.get(key);
|
|
929
|
+
if (!owners) return;
|
|
930
|
+
owners.delete(owner);
|
|
931
|
+
if (owners.size === 0) this.keyOwners.delete(key);
|
|
932
|
+
}
|
|
933
|
+
load(entry, load) {
|
|
934
|
+
let promise;
|
|
935
|
+
try {
|
|
936
|
+
promise = load();
|
|
937
|
+
} catch (err) {
|
|
938
|
+
promise = Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
|
939
|
+
}
|
|
940
|
+
void promise.then(
|
|
941
|
+
(source) => {
|
|
942
|
+
if (this.disposed || this.entries.get(entry.key) !== entry) {
|
|
943
|
+
closeSource(source);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const [w, h] = sourceSize(source);
|
|
947
|
+
entry.source = source;
|
|
948
|
+
entry.width = w;
|
|
949
|
+
entry.height = h;
|
|
950
|
+
entry.state = "ready";
|
|
951
|
+
this.upload(entry);
|
|
952
|
+
this.options.onChange?.([entry.key]);
|
|
953
|
+
},
|
|
954
|
+
() => {
|
|
955
|
+
if (this.disposed || this.entries.get(entry.key) !== entry) return;
|
|
956
|
+
entry.state = "error";
|
|
957
|
+
this.options.onChange?.([entry.key]);
|
|
958
|
+
}
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
upload(entry) {
|
|
962
|
+
if (!this.backend || !entry.source) return;
|
|
963
|
+
try {
|
|
964
|
+
this.backend.uploadTexture(entry.id, entry.source, entry.opts);
|
|
965
|
+
} catch {
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
destroy(entry) {
|
|
969
|
+
if (this.backend) {
|
|
970
|
+
try {
|
|
971
|
+
this.backend.deleteTexture(entry.id);
|
|
972
|
+
} catch {
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
closeSource(entry.source);
|
|
976
|
+
entry.source = null;
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
function closeSource(source) {
|
|
980
|
+
if (source && typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap) source.close();
|
|
981
|
+
}
|
|
982
|
+
function sourceSize(source) {
|
|
983
|
+
const s = source;
|
|
984
|
+
return [s.naturalWidth || s.videoWidth || s.width || 0, s.naturalHeight || s.videoHeight || s.height || 0];
|
|
985
|
+
}
|
|
986
|
+
var PRESENCE_COLORS = [
|
|
987
|
+
"#e0575b",
|
|
988
|
+
"#ef8b3a",
|
|
989
|
+
"#d8a72e",
|
|
990
|
+
"#4f9d55",
|
|
991
|
+
"#2fa39a",
|
|
992
|
+
"#3f86d8",
|
|
993
|
+
"#7a63d8",
|
|
994
|
+
"#c05aa8"
|
|
995
|
+
];
|
|
996
|
+
function randomPresenceColor() {
|
|
997
|
+
return PRESENCE_COLORS[Math.floor(Math.random() * PRESENCE_COLORS.length)];
|
|
998
|
+
}
|
|
999
|
+
var InstancePresenceRecordType = createRecordType("instance_presence", {
|
|
1000
|
+
scope: "presence"
|
|
1001
|
+
}).withDefaultProperties(() => ({
|
|
1002
|
+
userName: "",
|
|
1003
|
+
color: PRESENCE_COLORS[0],
|
|
1004
|
+
cursor: { x: 0, y: 0, type: "default", rotation: 0 },
|
|
1005
|
+
camera: { x: 0, y: 0, z: 1 },
|
|
1006
|
+
selectedShapeIds: [],
|
|
1007
|
+
brush: null,
|
|
1008
|
+
scribbles: [],
|
|
1009
|
+
followingUserId: null,
|
|
1010
|
+
lastActivityTimestamp: 0,
|
|
1011
|
+
chatMessage: "",
|
|
1012
|
+
meta: {}
|
|
1013
|
+
}));
|
|
1014
|
+
function isInstancePresenceId(id) {
|
|
1015
|
+
return id.startsWith("instance_presence:");
|
|
1016
|
+
}
|
|
1017
|
+
function createUserPreferences(init = {}) {
|
|
1018
|
+
const id = init.id ?? `user:${uniqueId(12)}`;
|
|
1019
|
+
const state = atom("editor.user", {
|
|
1020
|
+
name: init.name ?? `User ${id.slice(-4)}`,
|
|
1021
|
+
color: init.color ?? randomPresenceColor()
|
|
1022
|
+
});
|
|
1023
|
+
return {
|
|
1024
|
+
getId: () => id,
|
|
1025
|
+
getName: () => state.get().name,
|
|
1026
|
+
getColor: () => state.get().color,
|
|
1027
|
+
setName: (name) => state.update((s) => ({ ...s, name })),
|
|
1028
|
+
setColor: (color) => state.update((s) => ({ ...s, color }))
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// src/records/styles.ts
|
|
1033
|
+
var DEFAULT_COLORS = [
|
|
1034
|
+
"black",
|
|
1035
|
+
"grey",
|
|
1036
|
+
"light-violet",
|
|
1037
|
+
"violet",
|
|
1038
|
+
"blue",
|
|
1039
|
+
"light-blue",
|
|
1040
|
+
"yellow",
|
|
1041
|
+
"orange",
|
|
1042
|
+
"green",
|
|
1043
|
+
"light-green",
|
|
1044
|
+
"light-red",
|
|
1045
|
+
"red",
|
|
1046
|
+
"white"
|
|
1047
|
+
];
|
|
1048
|
+
var DEFAULT_FILLS = ["none", "semi", "solid", "pattern", "fill"];
|
|
1049
|
+
var DEFAULT_DASHES = ["draw", "solid", "dashed", "dotted"];
|
|
1050
|
+
var DEFAULT_SIZES = ["s", "m", "l", "xl"];
|
|
1051
|
+
var DEFAULT_FONTS = ["draw", "sans", "serif", "mono"];
|
|
1052
|
+
var DEFAULT_H_ALIGNS = ["start", "middle", "end", "start-legacy", "end-legacy", "middle-legacy"];
|
|
1053
|
+
var DEFAULT_V_ALIGNS = ["start", "middle", "end"];
|
|
1054
|
+
var GEO_SHAPE_KINDS = [
|
|
1055
|
+
"rectangle",
|
|
1056
|
+
"ellipse",
|
|
1057
|
+
"triangle",
|
|
1058
|
+
"diamond",
|
|
1059
|
+
"pentagon",
|
|
1060
|
+
"hexagon",
|
|
1061
|
+
"octagon",
|
|
1062
|
+
"star",
|
|
1063
|
+
"rhombus",
|
|
1064
|
+
"rhombus-2",
|
|
1065
|
+
"oval",
|
|
1066
|
+
"trapezoid",
|
|
1067
|
+
"arrow-right",
|
|
1068
|
+
"arrow-left",
|
|
1069
|
+
"arrow-up",
|
|
1070
|
+
"arrow-down",
|
|
1071
|
+
"x-box",
|
|
1072
|
+
"check-box",
|
|
1073
|
+
"cloud",
|
|
1074
|
+
"heart"
|
|
1075
|
+
];
|
|
1076
|
+
var STROKE_SIZES = { s: 2, m: 3.5, l: 5, xl: 10 };
|
|
1077
|
+
var FONT_SIZES = { s: 18, m: 24, l: 36, xl: 44 };
|
|
1078
|
+
var LIGHT_THEME = {
|
|
1079
|
+
background: "#f9fafb",
|
|
1080
|
+
solid: "#fcfffe",
|
|
1081
|
+
text: "#000000",
|
|
1082
|
+
black: {
|
|
1083
|
+
solid: "#1d1d1d",
|
|
1084
|
+
semi: "#e8e8e8",
|
|
1085
|
+
pattern: "#494949",
|
|
1086
|
+
fill: "#1d1d1d",
|
|
1087
|
+
note: { fill: "#fce19c", text: "#000000" },
|
|
1088
|
+
highlight: { srgb: "#fddd00", p3: "color(display-p3 0.972 0.8705 0.05)" }
|
|
1089
|
+
},
|
|
1090
|
+
grey: {
|
|
1091
|
+
solid: "#9fa8b2",
|
|
1092
|
+
semi: "#eceef0",
|
|
1093
|
+
pattern: "#bac3cb",
|
|
1094
|
+
fill: "#9fa8b2",
|
|
1095
|
+
note: { fill: "#eaeaea", text: "#000000" },
|
|
1096
|
+
highlight: { srgb: "#cbe7f1", p3: "color(display-p3 0.85 0.9 0.94)" }
|
|
1097
|
+
},
|
|
1098
|
+
"light-violet": {
|
|
1099
|
+
solid: "#e085f4",
|
|
1100
|
+
semi: "#f5eafa",
|
|
1101
|
+
pattern: "#e9acf8",
|
|
1102
|
+
fill: "#e085f4",
|
|
1103
|
+
note: { fill: "#f5eafa", text: "#000000" },
|
|
1104
|
+
highlight: { srgb: "#f6c9ff", p3: "color(display-p3 0.97 0.79 1)" }
|
|
1105
|
+
},
|
|
1106
|
+
violet: {
|
|
1107
|
+
solid: "#ae3ec9",
|
|
1108
|
+
semi: "#ecdcf2",
|
|
1109
|
+
pattern: "#c26fd6",
|
|
1110
|
+
fill: "#ae3ec9",
|
|
1111
|
+
note: { fill: "#e5b7f1", text: "#000000" },
|
|
1112
|
+
highlight: { srgb: "#dfa7f1", p3: "color(display-p3 0.87 0.65 0.95)" }
|
|
1113
|
+
},
|
|
1114
|
+
blue: {
|
|
1115
|
+
solid: "#4465e9",
|
|
1116
|
+
semi: "#dce1f8",
|
|
1117
|
+
pattern: "#6681ee",
|
|
1118
|
+
fill: "#4465e9",
|
|
1119
|
+
note: { fill: "#c9d3fb", text: "#000000" },
|
|
1120
|
+
highlight: { srgb: "#8fbdff", p3: "color(display-p3 0.56 0.74 1)" }
|
|
1121
|
+
},
|
|
1122
|
+
"light-blue": {
|
|
1123
|
+
solid: "#4ba1f1",
|
|
1124
|
+
semi: "#ddedfa",
|
|
1125
|
+
pattern: "#78b7f4",
|
|
1126
|
+
fill: "#4ba1f1",
|
|
1127
|
+
note: { fill: "#c9e4fb", text: "#000000" },
|
|
1128
|
+
highlight: { srgb: "#9de0ff", p3: "color(display-p3 0.62 0.88 1)" }
|
|
1129
|
+
},
|
|
1130
|
+
yellow: {
|
|
1131
|
+
solid: "#f1ac4b",
|
|
1132
|
+
semi: "#f9f0e6",
|
|
1133
|
+
pattern: "#f3c68a",
|
|
1134
|
+
fill: "#f1ac4b",
|
|
1135
|
+
note: { fill: "#fbe9c9", text: "#000000" },
|
|
1136
|
+
highlight: { srgb: "#fddd00", p3: "color(display-p3 0.972 0.8705 0.05)" }
|
|
1137
|
+
},
|
|
1138
|
+
orange: {
|
|
1139
|
+
solid: "#e16919",
|
|
1140
|
+
semi: "#faeae1",
|
|
1141
|
+
pattern: "#eb9c6a",
|
|
1142
|
+
fill: "#e16919",
|
|
1143
|
+
note: { fill: "#f9d3b8", text: "#000000" },
|
|
1144
|
+
highlight: { srgb: "#ffa971", p3: "color(display-p3 1 0.66 0.44)" }
|
|
1145
|
+
},
|
|
1146
|
+
green: {
|
|
1147
|
+
solid: "#099268",
|
|
1148
|
+
semi: "#d3e9e3",
|
|
1149
|
+
pattern: "#4ab99c",
|
|
1150
|
+
fill: "#099268",
|
|
1151
|
+
note: { fill: "#b5dfd2", text: "#000000" },
|
|
1152
|
+
highlight: { srgb: "#98f2c8", p3: "color(display-p3 0.6 0.95 0.78)" }
|
|
1153
|
+
},
|
|
1154
|
+
"light-green": {
|
|
1155
|
+
solid: "#4cb05e",
|
|
1156
|
+
semi: "#dbf0e0",
|
|
1157
|
+
pattern: "#84c78f",
|
|
1158
|
+
fill: "#4cb05e",
|
|
1159
|
+
note: { fill: "#c8ead0", text: "#000000" },
|
|
1160
|
+
highlight: { srgb: "#98f2c8", p3: "color(display-p3 0.6 0.95 0.78)" }
|
|
1161
|
+
},
|
|
1162
|
+
"light-red": {
|
|
1163
|
+
solid: "#f87777",
|
|
1164
|
+
semi: "#f4dadb",
|
|
1165
|
+
pattern: "#f8a2a2",
|
|
1166
|
+
fill: "#f87777",
|
|
1167
|
+
note: { fill: "#fcd1d1", text: "#000000" },
|
|
1168
|
+
highlight: { srgb: "#ffb2b2", p3: "color(display-p3 1 0.7 0.7)" }
|
|
1169
|
+
},
|
|
1170
|
+
red: {
|
|
1171
|
+
solid: "#e03131",
|
|
1172
|
+
semi: "#f4dadb",
|
|
1173
|
+
pattern: "#ea6b6b",
|
|
1174
|
+
fill: "#e03131",
|
|
1175
|
+
note: { fill: "#f4b7b7", text: "#000000" },
|
|
1176
|
+
highlight: { srgb: "#ff9c9c", p3: "color(display-p3 1 0.61 0.61)" }
|
|
1177
|
+
},
|
|
1178
|
+
white: {
|
|
1179
|
+
solid: "#ffffff",
|
|
1180
|
+
semi: "#f5f5f5",
|
|
1181
|
+
pattern: "#f5f5f5",
|
|
1182
|
+
fill: "#ffffff",
|
|
1183
|
+
note: { fill: "#ffffff", text: "#000000" },
|
|
1184
|
+
highlight: { srgb: "#ffffff", p3: "color(display-p3 1 1 1)" }
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
function hexToRgba(hex, alpha = 1) {
|
|
1188
|
+
let h = hex.trim();
|
|
1189
|
+
if (h.startsWith("#")) h = h.slice(1);
|
|
1190
|
+
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
|
|
1191
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
1192
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
1193
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
1194
|
+
const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) : Math.round(alpha * 255);
|
|
1195
|
+
return (r << 24 | g << 16 | b << 8 | a) >>> 0;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// src/records/styleProp.ts
|
|
1199
|
+
var StyleProp = class _StyleProp {
|
|
1200
|
+
constructor(id, defaultValue, validator) {
|
|
1201
|
+
this.id = id;
|
|
1202
|
+
this.defaultValue = defaultValue;
|
|
1203
|
+
this.validator = validator;
|
|
1204
|
+
}
|
|
1205
|
+
id;
|
|
1206
|
+
defaultValue;
|
|
1207
|
+
validator;
|
|
1208
|
+
/** Define a style with a free-form value. */
|
|
1209
|
+
static define(id, options) {
|
|
1210
|
+
return new _StyleProp(id, options.defaultValue, options.validate);
|
|
1211
|
+
}
|
|
1212
|
+
/** Define a style whose value is one of a fixed set of strings. */
|
|
1213
|
+
static defineEnum(id, options) {
|
|
1214
|
+
return new EnumStyleProp(id, options.defaultValue, options.values);
|
|
1215
|
+
}
|
|
1216
|
+
validate(value) {
|
|
1217
|
+
return this.validator ? this.validator(value) : value;
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
var EnumStyleProp = class extends StyleProp {
|
|
1221
|
+
constructor(id, defaultValue, values) {
|
|
1222
|
+
super(id, defaultValue, (v) => {
|
|
1223
|
+
if (typeof v !== "string" || !values.includes(v)) {
|
|
1224
|
+
throw new Error(`Invalid value for style ${id}: ${String(v)}`);
|
|
1225
|
+
}
|
|
1226
|
+
return v;
|
|
1227
|
+
});
|
|
1228
|
+
this.values = values;
|
|
1229
|
+
}
|
|
1230
|
+
values;
|
|
1231
|
+
};
|
|
1232
|
+
var DefaultColorStyle = StyleProp.defineEnum("mocanvas:color", { defaultValue: "black", values: DEFAULT_COLORS });
|
|
1233
|
+
var DefaultLabelColorStyle = StyleProp.defineEnum("mocanvas:labelColor", { defaultValue: "black", values: DEFAULT_COLORS });
|
|
1234
|
+
var DefaultFillStyle = StyleProp.defineEnum("mocanvas:fill", { defaultValue: "none", values: DEFAULT_FILLS });
|
|
1235
|
+
var DefaultDashStyle = StyleProp.defineEnum("mocanvas:dash", { defaultValue: "draw", values: DEFAULT_DASHES });
|
|
1236
|
+
var DefaultSizeStyle = StyleProp.defineEnum("mocanvas:size", { defaultValue: "m", values: DEFAULT_SIZES });
|
|
1237
|
+
var DefaultFontStyle = StyleProp.defineEnum("mocanvas:font", { defaultValue: "draw", values: DEFAULT_FONTS });
|
|
1238
|
+
var DefaultHorizontalAlignStyle = StyleProp.defineEnum("mocanvas:horizontalAlign", { defaultValue: "middle", values: DEFAULT_H_ALIGNS });
|
|
1239
|
+
var DefaultVerticalAlignStyle = StyleProp.defineEnum("mocanvas:verticalAlign", { defaultValue: "middle", values: DEFAULT_V_ALIGNS });
|
|
1240
|
+
var GeoShapeGeoStyle = StyleProp.defineEnum("mocanvas:geo", { defaultValue: "rectangle", values: GEO_SHAPE_KINDS });
|
|
1241
|
+
var SharedStyleMap = class {
|
|
1242
|
+
map = /* @__PURE__ */ new Map();
|
|
1243
|
+
get size() {
|
|
1244
|
+
return this.map.size;
|
|
1245
|
+
}
|
|
1246
|
+
get(prop) {
|
|
1247
|
+
return this.map.get(prop);
|
|
1248
|
+
}
|
|
1249
|
+
getAsKnownValue(prop) {
|
|
1250
|
+
const s = this.get(prop);
|
|
1251
|
+
return s?.type === "shared" ? s.value : void 0;
|
|
1252
|
+
}
|
|
1253
|
+
has(prop) {
|
|
1254
|
+
return this.map.has(prop);
|
|
1255
|
+
}
|
|
1256
|
+
/** Record a value seen on a shape. */
|
|
1257
|
+
applyValue(prop, value) {
|
|
1258
|
+
const existing = this.map.get(prop);
|
|
1259
|
+
if (!existing) this.map.set(prop, { type: "shared", value });
|
|
1260
|
+
else if (existing.type === "shared" && existing.value !== value) this.map.set(prop, { type: "mixed" });
|
|
1261
|
+
}
|
|
1262
|
+
[Symbol.iterator]() {
|
|
1263
|
+
return this.map.entries();
|
|
1264
|
+
}
|
|
1265
|
+
keys() {
|
|
1266
|
+
return this.map.keys();
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
function getStylePropsOf(props) {
|
|
1270
|
+
const out = /* @__PURE__ */ new Map();
|
|
1271
|
+
if (!props) return out;
|
|
1272
|
+
for (const [key, v] of Object.entries(props)) {
|
|
1273
|
+
if (v instanceof StyleProp) out.set(key, v);
|
|
1274
|
+
}
|
|
1275
|
+
return out;
|
|
1276
|
+
}
|
|
1277
|
+
var HistoryManager = class {
|
|
1278
|
+
constructor(store, onBatchComplete) {
|
|
1279
|
+
this.store = store;
|
|
1280
|
+
this.onBatchComplete = onBatchComplete;
|
|
1281
|
+
this._version = atom("history.version", 0);
|
|
1282
|
+
this.dispose = store.listen(
|
|
1283
|
+
(entry) => {
|
|
1284
|
+
if (this.ignoring > 0) return;
|
|
1285
|
+
if (isRecordsDiffEmpty(entry.changes)) return;
|
|
1286
|
+
this.pushDiff(entry.changes);
|
|
1287
|
+
},
|
|
1288
|
+
{ source: "user", scope: "document" }
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
store;
|
|
1292
|
+
onBatchComplete;
|
|
1293
|
+
undos = [];
|
|
1294
|
+
redos = [];
|
|
1295
|
+
ignoring = 0;
|
|
1296
|
+
pendingDiff = null;
|
|
1297
|
+
_version;
|
|
1298
|
+
dispose;
|
|
1299
|
+
pushDiff(diff) {
|
|
1300
|
+
const last = this.undos.at(-1);
|
|
1301
|
+
if (last && last.type === "diff") {
|
|
1302
|
+
last.diff = squashRecordDiffs([last.diff, diff]);
|
|
1303
|
+
} else {
|
|
1304
|
+
this.undos.push({ type: "diff", diff });
|
|
1305
|
+
}
|
|
1306
|
+
if (this.redos.length) this.redos = [];
|
|
1307
|
+
this._version.update((v) => v + 1);
|
|
1308
|
+
}
|
|
1309
|
+
/** Reactive counter for UI. */
|
|
1310
|
+
getVersion() {
|
|
1311
|
+
return this._version.get();
|
|
1312
|
+
}
|
|
1313
|
+
getNumUndos() {
|
|
1314
|
+
return this.undos.filter((e) => e.type === "diff").length;
|
|
1315
|
+
}
|
|
1316
|
+
getNumRedos() {
|
|
1317
|
+
return this.redos.filter((e) => e.type === "diff").length;
|
|
1318
|
+
}
|
|
1319
|
+
/** Run `fn` without recording its changes. */
|
|
1320
|
+
ignore(fn) {
|
|
1321
|
+
this.ignoring++;
|
|
1322
|
+
try {
|
|
1323
|
+
return fn();
|
|
1324
|
+
} finally {
|
|
1325
|
+
this.ignoring--;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
/** Place a stopping point. Returns the mark id. */
|
|
1329
|
+
mark(id = `mark:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`) {
|
|
1330
|
+
const last = this.undos.at(-1);
|
|
1331
|
+
if (last && last.type === "mark") {
|
|
1332
|
+
last.id = id;
|
|
1333
|
+
} else {
|
|
1334
|
+
this.undos.push({ type: "mark", id });
|
|
1335
|
+
}
|
|
1336
|
+
this._version.update((v) => v + 1);
|
|
1337
|
+
return id;
|
|
1338
|
+
}
|
|
1339
|
+
apply(diff) {
|
|
1340
|
+
this.ignore(() => {
|
|
1341
|
+
transact(() => {
|
|
1342
|
+
this.store.applyDiff(diff, { runCallbacks: true });
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
undo() {
|
|
1347
|
+
this.step("undo", "toMark", void 0);
|
|
1348
|
+
return this;
|
|
1349
|
+
}
|
|
1350
|
+
redo() {
|
|
1351
|
+
this.step("redo", "toMark", void 0);
|
|
1352
|
+
return this;
|
|
1353
|
+
}
|
|
1354
|
+
/** Undo to the previous mark and drop the undone entries (no redo). */
|
|
1355
|
+
bail() {
|
|
1356
|
+
this.step("undo", "toMark", void 0, true);
|
|
1357
|
+
return this;
|
|
1358
|
+
}
|
|
1359
|
+
/** Undo back to a specific mark and drop the entries. */
|
|
1360
|
+
bailToMark(id) {
|
|
1361
|
+
this.step("undo", "toSpecificMark", id, true);
|
|
1362
|
+
return this;
|
|
1363
|
+
}
|
|
1364
|
+
/** Merge every diff since `id` into one step (the mark stays). */
|
|
1365
|
+
squashToMark(id) {
|
|
1366
|
+
const i = this.undos.findLastIndex((e) => e.type === "mark" && e.id === id);
|
|
1367
|
+
if (i < 0) return this;
|
|
1368
|
+
const tail = this.undos.splice(i + 1);
|
|
1369
|
+
const diffs = tail.filter((e) => e.type === "diff").map((e) => e.diff);
|
|
1370
|
+
if (diffs.length) this.undos.push({ type: "diff", diff: squashRecordDiffs(diffs) });
|
|
1371
|
+
this._version.update((v) => v + 1);
|
|
1372
|
+
return this;
|
|
1373
|
+
}
|
|
1374
|
+
clear() {
|
|
1375
|
+
this.undos = [];
|
|
1376
|
+
this.redos = [];
|
|
1377
|
+
this.pendingDiff = null;
|
|
1378
|
+
this._version.update((v) => v + 1);
|
|
1379
|
+
}
|
|
1380
|
+
step(dir, mode, markId, drop = false) {
|
|
1381
|
+
const from = dir === "undo" ? this.undos : this.redos;
|
|
1382
|
+
const to = dir === "undo" ? this.redos : this.undos;
|
|
1383
|
+
if (from.length === 0) return;
|
|
1384
|
+
while (from.length && from.at(-1).type === "mark") {
|
|
1385
|
+
const m = from.pop();
|
|
1386
|
+
if (!drop) to.push(m);
|
|
1387
|
+
if (mode === "toSpecificMark" && m.id === markId) {
|
|
1388
|
+
this._version.update((v) => v + 1);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
const diffs = [];
|
|
1393
|
+
while (from.length) {
|
|
1394
|
+
const e = from.at(-1);
|
|
1395
|
+
if (e.type === "mark") {
|
|
1396
|
+
if (mode === "toMark") break;
|
|
1397
|
+
from.pop();
|
|
1398
|
+
if (!drop) to.push(e);
|
|
1399
|
+
if (e.id === markId) break;
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
from.pop();
|
|
1403
|
+
diffs.push(e.diff);
|
|
1404
|
+
if (!drop) to.push(e);
|
|
1405
|
+
if (mode === "toMark") break;
|
|
1406
|
+
}
|
|
1407
|
+
if (diffs.length) {
|
|
1408
|
+
const combined = squashRecordDiffs(diffs);
|
|
1409
|
+
this.apply(dir === "undo" ? reverseRecordsDiff(combined) : combined);
|
|
1410
|
+
}
|
|
1411
|
+
this._version.update((v) => v + 1);
|
|
1412
|
+
this.onBatchComplete?.();
|
|
1413
|
+
}
|
|
1414
|
+
destroy() {
|
|
1415
|
+
this.dispose();
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1418
|
+
var SnapManager = class _SnapManager {
|
|
1419
|
+
constructor(editor) {
|
|
1420
|
+
this.editor = editor;
|
|
1421
|
+
this._lines = atom("snap.lines", []);
|
|
1422
|
+
}
|
|
1423
|
+
editor;
|
|
1424
|
+
_lines;
|
|
1425
|
+
/** Snap distance in screen pixels. */
|
|
1426
|
+
threshold = 8;
|
|
1427
|
+
getLines() {
|
|
1428
|
+
return this._lines.get();
|
|
1429
|
+
}
|
|
1430
|
+
clearLines() {
|
|
1431
|
+
if (this._lines.get().length) this._lines.set([]);
|
|
1432
|
+
}
|
|
1433
|
+
/** Bounds of shapes near the viewport that are not being moved. */
|
|
1434
|
+
getSnapTargets(exclude) {
|
|
1435
|
+
const editor = this.editor;
|
|
1436
|
+
const vp = editor.getViewportPageBounds();
|
|
1437
|
+
const pad = Math.max(vp.w, vp.h);
|
|
1438
|
+
const shapes = editor.getShapesIntersectingBounds(Box.Expand(vp, pad));
|
|
1439
|
+
const out = [];
|
|
1440
|
+
for (const s of shapes) {
|
|
1441
|
+
if (exclude.has(s.id)) continue;
|
|
1442
|
+
if (s.parentId !== editor.getCurrentPageId()) continue;
|
|
1443
|
+
const b = editor.getShapePageBounds(s);
|
|
1444
|
+
if (b) out.push(b);
|
|
1445
|
+
}
|
|
1446
|
+
return out;
|
|
1447
|
+
}
|
|
1448
|
+
static pointsOf(b) {
|
|
1449
|
+
const cx = b.x + b.w / 2;
|
|
1450
|
+
const cy = b.y + b.h / 2;
|
|
1451
|
+
return {
|
|
1452
|
+
xs: [
|
|
1453
|
+
{ value: b.x, y: cy },
|
|
1454
|
+
{ value: cx, y: cy },
|
|
1455
|
+
{ value: b.x + b.w, y: cy }
|
|
1456
|
+
],
|
|
1457
|
+
ys: [
|
|
1458
|
+
{ value: b.y, x: cx },
|
|
1459
|
+
{ value: cy, x: cx },
|
|
1460
|
+
{ value: b.y + b.h, x: cx }
|
|
1461
|
+
]
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Snap a moving box (already offset by the proposed delta) to nearby shapes.
|
|
1466
|
+
* Returns the extra nudge to apply and the guide lines to show.
|
|
1467
|
+
*/
|
|
1468
|
+
snapTranslate(moving, exclude, opts = {}) {
|
|
1469
|
+
const editor = this.editor;
|
|
1470
|
+
const th = this.threshold / editor.getZoomLevel();
|
|
1471
|
+
const targets = this.getSnapTargets(exclude);
|
|
1472
|
+
const mine = _SnapManager.pointsOf(moving);
|
|
1473
|
+
let bestX = null;
|
|
1474
|
+
let bestY = null;
|
|
1475
|
+
for (const t of targets) {
|
|
1476
|
+
const tp = _SnapManager.pointsOf(t);
|
|
1477
|
+
if (!opts.lockX) {
|
|
1478
|
+
for (const a of mine.xs) {
|
|
1479
|
+
for (const b of tp.xs) {
|
|
1480
|
+
const d = Math.abs(a.value - b.value);
|
|
1481
|
+
if (d <= th && (!bestX || d < bestX.d)) bestX = { d, nudge: b.value - a.value };
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (!opts.lockY) {
|
|
1486
|
+
for (const a of mine.ys) {
|
|
1487
|
+
for (const b of tp.ys) {
|
|
1488
|
+
const d = Math.abs(a.value - b.value);
|
|
1489
|
+
if (d <= th && (!bestY || d < bestY.d)) bestY = { d, nudge: b.value - a.value };
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
const nudge = new Vec(bestX?.nudge ?? 0, bestY?.nudge ?? 0);
|
|
1495
|
+
const snapped = new Box(moving.x + nudge.x, moving.y + nudge.y, moving.w, moving.h);
|
|
1496
|
+
const sp = _SnapManager.pointsOf(snapped);
|
|
1497
|
+
const lines = [];
|
|
1498
|
+
const eps = 0.01;
|
|
1499
|
+
for (const t of targets) {
|
|
1500
|
+
const tp = _SnapManager.pointsOf(t);
|
|
1501
|
+
for (const a of sp.xs) {
|
|
1502
|
+
for (const b of tp.xs) {
|
|
1503
|
+
if (Math.abs(a.value - b.value) < eps) {
|
|
1504
|
+
const ys = [snapped.y, snapped.maxY, t.y, t.y + t.h];
|
|
1505
|
+
lines.push({ id: `x:${a.value.toFixed(2)}`, points: [new Vec(a.value, Math.min(...ys)), new Vec(a.value, Math.max(...ys))] });
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
for (const a of sp.ys) {
|
|
1510
|
+
for (const b of tp.ys) {
|
|
1511
|
+
if (Math.abs(a.value - b.value) < eps) {
|
|
1512
|
+
const xs = [snapped.x, snapped.maxX, t.x, t.x + t.w];
|
|
1513
|
+
lines.push({ id: `y:${a.value.toFixed(2)}`, points: [new Vec(Math.min(...xs), a.value), new Vec(Math.max(...xs), a.value)] });
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1519
|
+
for (const l of lines) {
|
|
1520
|
+
const prev = merged.get(l.id);
|
|
1521
|
+
if (!prev) merged.set(l.id, l);
|
|
1522
|
+
else {
|
|
1523
|
+
const pts = [...prev.points, ...l.points];
|
|
1524
|
+
const isX = l.id.startsWith("x:");
|
|
1525
|
+
const vals = pts.map((p) => isX ? p.y : p.x);
|
|
1526
|
+
const min = Math.min(...vals);
|
|
1527
|
+
const max = Math.max(...vals);
|
|
1528
|
+
prev.points = isX ? [new Vec(pts[0].x, min), new Vec(pts[0].x, max)] : [new Vec(min, pts[0].y), new Vec(max, pts[0].y)];
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
const result = [...merged.values()];
|
|
1532
|
+
this._lines.set(result);
|
|
1533
|
+
return { nudge, lines: result };
|
|
1534
|
+
}
|
|
1535
|
+
};
|
|
1536
|
+
|
|
1537
|
+
// src/editor/Editor.ts
|
|
1538
|
+
var DEFAULT_EDITOR_CONFIG = {
|
|
1539
|
+
maxShapesPerPage: 4e3,
|
|
1540
|
+
dragDistanceSquared: 16,
|
|
1541
|
+
hitTestMargin: 8,
|
|
1542
|
+
zoomMin: 0.05,
|
|
1543
|
+
zoomMax: 8,
|
|
1544
|
+
zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8],
|
|
1545
|
+
backgroundColor: [0.976, 0.98, 0.984, 1],
|
|
1546
|
+
animationMediumMs: 320
|
|
1547
|
+
};
|
|
1548
|
+
var RAD_PER_DEG = Math.PI / 180;
|
|
1549
|
+
var Editor = class extends EventEmitter {
|
|
1550
|
+
store;
|
|
1551
|
+
engine;
|
|
1552
|
+
history;
|
|
1553
|
+
root;
|
|
1554
|
+
shapeUtils;
|
|
1555
|
+
bindingUtils;
|
|
1556
|
+
options;
|
|
1557
|
+
inputs;
|
|
1558
|
+
handles = new HandleTable();
|
|
1559
|
+
/** GPU textures referenced by shape styles (images, rasterized text). */
|
|
1560
|
+
textures = new TextureManager({ onChange: (keys) => this.onTexturesChanged(keys) });
|
|
1561
|
+
snaps;
|
|
1562
|
+
getContainer;
|
|
1563
|
+
sideEffects;
|
|
1564
|
+
kindIds = /* @__PURE__ */ new Map();
|
|
1565
|
+
_frameEpoch;
|
|
1566
|
+
_overlayShapeIds;
|
|
1567
|
+
_overlayClips;
|
|
1568
|
+
_isDisposed;
|
|
1569
|
+
_lastFrame;
|
|
1570
|
+
disposables = [];
|
|
1571
|
+
syncedPageId = null;
|
|
1572
|
+
/** Shapes whose last write to the engine threw; they are warned about once. */
|
|
1573
|
+
brokenShapeIds = /* @__PURE__ */ new Set();
|
|
1574
|
+
constructor(opts) {
|
|
1575
|
+
super();
|
|
1576
|
+
this.store = opts.store;
|
|
1577
|
+
this.engine = opts.engine;
|
|
1578
|
+
this.getContainer = opts.getContainer;
|
|
1579
|
+
this.options = { ...DEFAULT_EDITOR_CONFIG, ...opts.options };
|
|
1580
|
+
this.sideEffects = this.store.sideEffects;
|
|
1581
|
+
this.snaps = new SnapManager(this);
|
|
1582
|
+
this._frameEpoch = atom("editor.frameEpoch", 0);
|
|
1583
|
+
this._overlayShapeIds = atom("editor.overlayShapeIds", []);
|
|
1584
|
+
this._overlayClips = atom("editor.overlayClips", []);
|
|
1585
|
+
this._isDisposed = atom("editor.isDisposed", false);
|
|
1586
|
+
this._lastFrame = atom("editor.lastFrame", { drawn: 0, culled: 0, ms: 0 });
|
|
1587
|
+
this.inputs = {
|
|
1588
|
+
originPagePoint: new Vec(),
|
|
1589
|
+
originScreenPoint: new Vec(),
|
|
1590
|
+
previousPagePoint: new Vec(),
|
|
1591
|
+
previousScreenPoint: new Vec(),
|
|
1592
|
+
currentPagePoint: new Vec(),
|
|
1593
|
+
currentScreenPoint: new Vec(),
|
|
1594
|
+
keys: /* @__PURE__ */ new Set(),
|
|
1595
|
+
buttons: /* @__PURE__ */ new Set(),
|
|
1596
|
+
isPen: false,
|
|
1597
|
+
shiftKey: false,
|
|
1598
|
+
ctrlKey: false,
|
|
1599
|
+
altKey: false,
|
|
1600
|
+
metaKey: false,
|
|
1601
|
+
accelKey: false,
|
|
1602
|
+
isDragging: false,
|
|
1603
|
+
isPointing: false,
|
|
1604
|
+
isPinching: false,
|
|
1605
|
+
isEditing: false,
|
|
1606
|
+
isPanning: false,
|
|
1607
|
+
pointerVelocity: new Vec()
|
|
1608
|
+
};
|
|
1609
|
+
const utils = {};
|
|
1610
|
+
let kind = 1;
|
|
1611
|
+
for (const U of opts.shapeUtils) {
|
|
1612
|
+
if (utils[U.type]) throw new Error(`Duplicate ShapeUtil for type "${U.type}"`);
|
|
1613
|
+
utils[U.type] = new U(this);
|
|
1614
|
+
this.kindIds.set(U.type, kind++);
|
|
1615
|
+
}
|
|
1616
|
+
this.shapeUtils = utils;
|
|
1617
|
+
const bindingUtils = {};
|
|
1618
|
+
for (const B of opts.bindingUtils ?? []) {
|
|
1619
|
+
if (bindingUtils[B.type]) throw new Error(`Duplicate BindingUtil for type "${B.type}"`);
|
|
1620
|
+
bindingUtils[B.type] = new B(this);
|
|
1621
|
+
}
|
|
1622
|
+
this.bindingUtils = bindingUtils;
|
|
1623
|
+
this.ensureBaseRecords();
|
|
1624
|
+
this.registerBindingSideEffects();
|
|
1625
|
+
this.registerTextureSideEffects();
|
|
1626
|
+
this.history = new HistoryManager(this.store, () => this.emit("update"));
|
|
1627
|
+
const Root = createRootState(opts.tools, opts.initialState ?? opts.tools[0]?.id ?? "");
|
|
1628
|
+
this.root = new Root(this);
|
|
1629
|
+
this.root.enter({}, "initial");
|
|
1630
|
+
this.disposables.push(
|
|
1631
|
+
this.store.listen(
|
|
1632
|
+
(entry) => {
|
|
1633
|
+
this.syncChanges(entry.changes);
|
|
1634
|
+
this.emit("change", { source: entry.source });
|
|
1635
|
+
},
|
|
1636
|
+
{ source: "all", scope: "document" }
|
|
1637
|
+
),
|
|
1638
|
+
this.store.listen(
|
|
1639
|
+
() => {
|
|
1640
|
+
this.bumpFrame();
|
|
1641
|
+
},
|
|
1642
|
+
{ source: "all", scope: "session" }
|
|
1643
|
+
)
|
|
1644
|
+
);
|
|
1645
|
+
this.syncPage(this.getCurrentPageId());
|
|
1646
|
+
}
|
|
1647
|
+
// ---- lifecycle ----------------------------------------------------------
|
|
1648
|
+
dispose() {
|
|
1649
|
+
if (this._isDisposed.get()) return;
|
|
1650
|
+
this._isDisposed.set(true);
|
|
1651
|
+
for (const d of this.disposables) d();
|
|
1652
|
+
this.textures.dispose();
|
|
1653
|
+
this.history.destroy();
|
|
1654
|
+
this.removeAllListeners();
|
|
1655
|
+
}
|
|
1656
|
+
getIsDisposed() {
|
|
1657
|
+
return this._isDisposed.get();
|
|
1658
|
+
}
|
|
1659
|
+
ensureBaseRecords() {
|
|
1660
|
+
transact(() => {
|
|
1661
|
+
if (!this.store.has(DOCUMENT_ID)) {
|
|
1662
|
+
this.store.put([DocumentRecordType.create({ id: DOCUMENT_ID, name: this.store.props.defaultName })]);
|
|
1663
|
+
}
|
|
1664
|
+
let pages = this.store.query.records("page").get();
|
|
1665
|
+
if (pages.length === 0) {
|
|
1666
|
+
const page = PageRecordType.create({ id: PageRecordType.createId(), name: "Page 1", index: ZERO_INDEX_KEY });
|
|
1667
|
+
this.store.put([page]);
|
|
1668
|
+
pages = [page];
|
|
1669
|
+
}
|
|
1670
|
+
const sorted = sortByIndex(pages);
|
|
1671
|
+
const firstPage = sorted[0];
|
|
1672
|
+
let instance = this.store.get(INSTANCE_ID);
|
|
1673
|
+
if (!instance || !this.store.has(instance.currentPageId)) {
|
|
1674
|
+
instance = InstanceRecordType.create({
|
|
1675
|
+
id: INSTANCE_ID,
|
|
1676
|
+
currentPageId: firstPage.id
|
|
1677
|
+
});
|
|
1678
|
+
this.store.put([instance]);
|
|
1679
|
+
}
|
|
1680
|
+
for (const p of sorted) this.ensurePageSessionRecords(p.id);
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
ensurePageSessionRecords(pageId) {
|
|
1684
|
+
const camId = CameraRecordType.createId(pageId.slice("page:".length));
|
|
1685
|
+
if (!this.store.has(camId)) this.store.put([CameraRecordType.create({ id: camId })]);
|
|
1686
|
+
const psId = InstancePageStateRecordType.createId(pageId.slice("page:".length));
|
|
1687
|
+
if (!this.store.has(psId)) this.store.put([InstancePageStateRecordType.create({ id: psId, pageId })]);
|
|
1688
|
+
}
|
|
1689
|
+
// ---- reactivity helpers -------------------------------------------------
|
|
1690
|
+
/** Increments whenever the GPU frame must be redrawn. */
|
|
1691
|
+
getFrameEpoch() {
|
|
1692
|
+
return this._frameEpoch.get();
|
|
1693
|
+
}
|
|
1694
|
+
bumpFrame() {
|
|
1695
|
+
this._frameEpoch.update((v) => v + 1);
|
|
1696
|
+
}
|
|
1697
|
+
/** Stats for the last rendered frame. */
|
|
1698
|
+
getLastFrameStats() {
|
|
1699
|
+
return this._lastFrame.get();
|
|
1700
|
+
}
|
|
1701
|
+
/** Shapes the DOM overlay must render this frame, in draw order. */
|
|
1702
|
+
getOverlayShapeIds() {
|
|
1703
|
+
return this._overlayShapeIds.get();
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Page-space clip rects for the overlay shapes, parallel to
|
|
1707
|
+
* `getOverlayShapeIds()`; `undefined` where the shape is unclipped.
|
|
1708
|
+
*/
|
|
1709
|
+
getOverlayClips() {
|
|
1710
|
+
return this._overlayClips.get();
|
|
1711
|
+
}
|
|
1712
|
+
// ---- batching / history ------------------------------------------------
|
|
1713
|
+
run(fn, opts = {}) {
|
|
1714
|
+
return transact(() => {
|
|
1715
|
+
if (opts.history === "ignore") return this.history.ignore(fn);
|
|
1716
|
+
return fn();
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
/** @deprecated use `run` */
|
|
1720
|
+
batch(fn) {
|
|
1721
|
+
return this.run(fn);
|
|
1722
|
+
}
|
|
1723
|
+
markHistoryStoppingPoint(name) {
|
|
1724
|
+
return this.history.mark(name);
|
|
1725
|
+
}
|
|
1726
|
+
/** Alias of `markHistoryStoppingPoint`. */
|
|
1727
|
+
mark(name) {
|
|
1728
|
+
return this.markHistoryStoppingPoint(name);
|
|
1729
|
+
}
|
|
1730
|
+
undo() {
|
|
1731
|
+
this.history.undo();
|
|
1732
|
+
return this;
|
|
1733
|
+
}
|
|
1734
|
+
redo() {
|
|
1735
|
+
this.history.redo();
|
|
1736
|
+
return this;
|
|
1737
|
+
}
|
|
1738
|
+
bail() {
|
|
1739
|
+
this.history.bail();
|
|
1740
|
+
return this;
|
|
1741
|
+
}
|
|
1742
|
+
bailToMark(id) {
|
|
1743
|
+
this.history.bailToMark(id);
|
|
1744
|
+
return this;
|
|
1745
|
+
}
|
|
1746
|
+
squashToMark(id) {
|
|
1747
|
+
this.history.squashToMark(id);
|
|
1748
|
+
return this;
|
|
1749
|
+
}
|
|
1750
|
+
getCanUndo() {
|
|
1751
|
+
return this.history.getNumUndos() > 0;
|
|
1752
|
+
}
|
|
1753
|
+
getCanRedo() {
|
|
1754
|
+
return this.history.getNumRedos() > 0;
|
|
1755
|
+
}
|
|
1756
|
+
// ---- document / pages --------------------------------------------------
|
|
1757
|
+
getDocumentSettings() {
|
|
1758
|
+
return this.store.get(DOCUMENT_ID);
|
|
1759
|
+
}
|
|
1760
|
+
updateDocumentSettings(settings) {
|
|
1761
|
+
this.store.put([{ ...this.getDocumentSettings(), ...settings }]);
|
|
1762
|
+
return this;
|
|
1763
|
+
}
|
|
1764
|
+
_pages = computed(
|
|
1765
|
+
"editor.pages",
|
|
1766
|
+
() => sortByIndex(this.store.query.records("page").get())
|
|
1767
|
+
);
|
|
1768
|
+
getPages() {
|
|
1769
|
+
return this._pages.get();
|
|
1770
|
+
}
|
|
1771
|
+
getPage(id) {
|
|
1772
|
+
return this.store.get(id);
|
|
1773
|
+
}
|
|
1774
|
+
getInstanceState() {
|
|
1775
|
+
return this.store.get(INSTANCE_ID);
|
|
1776
|
+
}
|
|
1777
|
+
updateInstanceState(partial) {
|
|
1778
|
+
this.run(
|
|
1779
|
+
() => {
|
|
1780
|
+
this.store.put([{ ...this.getInstanceState(), ...partial }]);
|
|
1781
|
+
},
|
|
1782
|
+
{ history: "ignore" }
|
|
1783
|
+
);
|
|
1784
|
+
return this;
|
|
1785
|
+
}
|
|
1786
|
+
getCurrentPageId() {
|
|
1787
|
+
return this.getInstanceState().currentPageId;
|
|
1788
|
+
}
|
|
1789
|
+
getCurrentPage() {
|
|
1790
|
+
return this.getPage(this.getCurrentPageId());
|
|
1791
|
+
}
|
|
1792
|
+
setCurrentPage(pageId) {
|
|
1793
|
+
if (!this.store.has(pageId)) throw new Error(`Page ${pageId} does not exist`);
|
|
1794
|
+
if (pageId === this.getCurrentPageId()) return this;
|
|
1795
|
+
this.run(
|
|
1796
|
+
() => {
|
|
1797
|
+
this.ensurePageSessionRecords(pageId);
|
|
1798
|
+
this.updateInstanceState({ currentPageId: pageId });
|
|
1799
|
+
},
|
|
1800
|
+
{ history: "ignore" }
|
|
1801
|
+
);
|
|
1802
|
+
this.syncPage(pageId);
|
|
1803
|
+
return this;
|
|
1804
|
+
}
|
|
1805
|
+
createPage(page = {}) {
|
|
1806
|
+
const pages = this.getPages();
|
|
1807
|
+
const index = getIndexAbove(pages.at(-1)?.index);
|
|
1808
|
+
const record = PageRecordType.create({
|
|
1809
|
+
id: page.id ?? PageRecordType.createId(),
|
|
1810
|
+
name: page.name ?? `Page ${pages.length + 1}`,
|
|
1811
|
+
index,
|
|
1812
|
+
meta: page.meta ?? {}
|
|
1813
|
+
});
|
|
1814
|
+
this.run(() => {
|
|
1815
|
+
this.store.put([record]);
|
|
1816
|
+
this.ensurePageSessionRecords(record.id);
|
|
1817
|
+
});
|
|
1818
|
+
return this;
|
|
1819
|
+
}
|
|
1820
|
+
deletePage(id) {
|
|
1821
|
+
const pages = this.getPages();
|
|
1822
|
+
if (pages.length <= 1) return this;
|
|
1823
|
+
this.run(() => {
|
|
1824
|
+
if (this.getCurrentPageId() === id) {
|
|
1825
|
+
const next = pages.find((p) => p.id !== id);
|
|
1826
|
+
this.setCurrentPage(next.id);
|
|
1827
|
+
}
|
|
1828
|
+
const shapeIds = this.getPageShapeIds(id);
|
|
1829
|
+
this.store.remove([...shapeIds, id]);
|
|
1830
|
+
});
|
|
1831
|
+
return this;
|
|
1832
|
+
}
|
|
1833
|
+
renamePage(id, name) {
|
|
1834
|
+
const page = this.getPage(id);
|
|
1835
|
+
if (page) this.store.put([{ ...page, name }]);
|
|
1836
|
+
return this;
|
|
1837
|
+
}
|
|
1838
|
+
getCurrentPageState() {
|
|
1839
|
+
const id = InstancePageStateRecordType.createId(this.getCurrentPageId().slice("page:".length));
|
|
1840
|
+
return this.store.get(id);
|
|
1841
|
+
}
|
|
1842
|
+
updateCurrentPageState(partial) {
|
|
1843
|
+
this.run(
|
|
1844
|
+
() => {
|
|
1845
|
+
this.store.put([{ ...this.getCurrentPageState(), ...partial }]);
|
|
1846
|
+
},
|
|
1847
|
+
{ history: "ignore" }
|
|
1848
|
+
);
|
|
1849
|
+
return this;
|
|
1850
|
+
}
|
|
1851
|
+
// ---- shapes: reading ---------------------------------------------------
|
|
1852
|
+
_allShapes = computed(
|
|
1853
|
+
"editor.allShapes",
|
|
1854
|
+
() => this.store.query.records("shape").get()
|
|
1855
|
+
);
|
|
1856
|
+
_currentPageShapes = computed("editor.currentPageShapes", () => {
|
|
1857
|
+
const pageId = this.getCurrentPageId();
|
|
1858
|
+
return this._allShapes.get().filter((s) => this.getAncestorPageId(s) === pageId);
|
|
1859
|
+
});
|
|
1860
|
+
_currentPageShapeIds = computed("editor.currentPageShapeIds", () => {
|
|
1861
|
+
return new Set(this._currentPageShapes.get().map((s) => s.id));
|
|
1862
|
+
});
|
|
1863
|
+
getShape(id) {
|
|
1864
|
+
const shapeId = typeof id === "string" ? id : id.id;
|
|
1865
|
+
return this.store.get(shapeId);
|
|
1866
|
+
}
|
|
1867
|
+
getShapeUtil(shape) {
|
|
1868
|
+
const type = typeof shape === "string" ? shape : shape.type;
|
|
1869
|
+
const util = this.shapeUtils[type];
|
|
1870
|
+
if (!util) throw new Error(`No ShapeUtil registered for type "${type}"`);
|
|
1871
|
+
return util;
|
|
1872
|
+
}
|
|
1873
|
+
hasShapeUtil(type) {
|
|
1874
|
+
return type in this.shapeUtils;
|
|
1875
|
+
}
|
|
1876
|
+
getCurrentPageShapes() {
|
|
1877
|
+
return this._currentPageShapes.get();
|
|
1878
|
+
}
|
|
1879
|
+
getCurrentPageShapeIds() {
|
|
1880
|
+
return this._currentPageShapeIds.get();
|
|
1881
|
+
}
|
|
1882
|
+
getCurrentPageShapesSorted() {
|
|
1883
|
+
const result = [];
|
|
1884
|
+
const visit = (parentId) => {
|
|
1885
|
+
for (const child of this.getSortedChildIdsForParent(parentId)) {
|
|
1886
|
+
const shape = this.getShape(child);
|
|
1887
|
+
if (!shape) continue;
|
|
1888
|
+
result.push(shape);
|
|
1889
|
+
visit(shape.id);
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
visit(this.getCurrentPageId());
|
|
1893
|
+
return result;
|
|
1894
|
+
}
|
|
1895
|
+
getPageShapeIds(pageId) {
|
|
1896
|
+
return this._allShapes.get().filter((s) => this.getAncestorPageId(s) === pageId).map((s) => s.id);
|
|
1897
|
+
}
|
|
1898
|
+
getAncestorPageId(shape) {
|
|
1899
|
+
let cur = typeof shape === "string" ? this.getShape(shape) : shape;
|
|
1900
|
+
let guard = 0;
|
|
1901
|
+
while (cur && guard++ < 1e3) {
|
|
1902
|
+
if (isPageId(cur.parentId)) return cur.parentId;
|
|
1903
|
+
cur = this.getShape(cur.parentId);
|
|
1904
|
+
}
|
|
1905
|
+
return void 0;
|
|
1906
|
+
}
|
|
1907
|
+
getShapeParent(shape) {
|
|
1908
|
+
const s = typeof shape === "string" ? this.getShape(shape) : shape;
|
|
1909
|
+
if (!s || isPageId(s.parentId)) return void 0;
|
|
1910
|
+
return this.getShape(s.parentId);
|
|
1911
|
+
}
|
|
1912
|
+
getSortedChildIdsForParent(parentId) {
|
|
1913
|
+
const children = this._allShapes.get().filter((s) => s.parentId === parentId);
|
|
1914
|
+
return sortByIndex(children).map((s) => s.id);
|
|
1915
|
+
}
|
|
1916
|
+
getHighestIndexForParent(parentId) {
|
|
1917
|
+
const children = this._allShapes.get().filter((s) => s.parentId === parentId);
|
|
1918
|
+
if (children.length === 0) return ZERO_INDEX_KEY;
|
|
1919
|
+
return getIndexAbove(sortByIndex(children).at(-1).index);
|
|
1920
|
+
}
|
|
1921
|
+
getShapeGeometry(shape) {
|
|
1922
|
+
const s = typeof shape === "string" ? this.getShape(shape) : shape;
|
|
1923
|
+
if (!s) throw new Error(`Shape not found`);
|
|
1924
|
+
return this.getShapeUtil(s).getGeometry(s);
|
|
1925
|
+
}
|
|
1926
|
+
/** Local → parent transform components. */
|
|
1927
|
+
getShapeLocalTransform(shape) {
|
|
1928
|
+
const c = Math.cos(shape.rotation);
|
|
1929
|
+
const s = Math.sin(shape.rotation);
|
|
1930
|
+
return { a: c, b: s, c: -s, d: c, e: shape.x, f: shape.y };
|
|
1931
|
+
}
|
|
1932
|
+
getShapeParentTransform(shape) {
|
|
1933
|
+
const parent = this.getShapeParent(shape);
|
|
1934
|
+
return parent ? this.getShapePageTransform(parent) : { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
|
1935
|
+
}
|
|
1936
|
+
getShapePageTransform(shape) {
|
|
1937
|
+
const s = typeof shape === "string" ? this.getShape(shape) : shape;
|
|
1938
|
+
if (!s) return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
|
1939
|
+
const p = this.getShapeParentTransform(s);
|
|
1940
|
+
const l = this.getShapeLocalTransform(s);
|
|
1941
|
+
return {
|
|
1942
|
+
a: p.a * l.a + p.c * l.b,
|
|
1943
|
+
b: p.b * l.a + p.d * l.b,
|
|
1944
|
+
c: p.a * l.c + p.c * l.d,
|
|
1945
|
+
d: p.b * l.c + p.d * l.d,
|
|
1946
|
+
e: p.a * l.e + p.c * l.f + p.e,
|
|
1947
|
+
f: p.b * l.e + p.d * l.f + p.f
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
getShapePageBounds(shape) {
|
|
1951
|
+
const s = typeof shape === "string" ? this.getShape(shape) : shape;
|
|
1952
|
+
if (!s) return void 0;
|
|
1953
|
+
const m = this.getShapePageTransform(s);
|
|
1954
|
+
const b = this.getShapeGeometry(s).bounds;
|
|
1955
|
+
return Box.FromPoints(b.corners.map((c) => new Vec(m.a * c.x + m.c * c.y + m.e, m.b * c.x + m.d * c.y + m.f)));
|
|
1956
|
+
}
|
|
1957
|
+
getShapeGeometryBounds(shape) {
|
|
1958
|
+
const s = typeof shape === "string" ? this.getShape(shape) : shape;
|
|
1959
|
+
return s ? this.getShapeGeometry(s).bounds : void 0;
|
|
1960
|
+
}
|
|
1961
|
+
/** Transform a page point into the shape's local space. */
|
|
1962
|
+
getPointInShapeSpace(shape, point) {
|
|
1963
|
+
const m = this.getShapePageTransform(shape);
|
|
1964
|
+
const det = m.a * m.d - m.b * m.c;
|
|
1965
|
+
if (Math.abs(det) < 1e-12) return new Vec(point.x, point.y);
|
|
1966
|
+
const inv = 1 / det;
|
|
1967
|
+
const a = m.d * inv;
|
|
1968
|
+
const b = -m.b * inv;
|
|
1969
|
+
const c = -m.c * inv;
|
|
1970
|
+
const d = m.a * inv;
|
|
1971
|
+
const e = -(a * m.e + c * m.f);
|
|
1972
|
+
const f = -(b * m.e + d * m.f);
|
|
1973
|
+
return new Vec(a * point.x + c * point.y + e, b * point.x + d * point.y + f);
|
|
1974
|
+
}
|
|
1975
|
+
getPointInParentSpace(shape, point) {
|
|
1976
|
+
const s = typeof shape === "string" ? this.getShape(shape) : shape;
|
|
1977
|
+
if (!s) return new Vec(point.x, point.y);
|
|
1978
|
+
const parent = this.getShapeParent(s);
|
|
1979
|
+
return parent ? this.getPointInShapeSpace(parent, point) : new Vec(point.x, point.y);
|
|
1980
|
+
}
|
|
1981
|
+
getShapeParentAtPoint(_point) {
|
|
1982
|
+
return void 0;
|
|
1983
|
+
}
|
|
1984
|
+
// ---- engine-backed spatial queries -------------------------------------
|
|
1985
|
+
hitFilterBits(opts) {
|
|
1986
|
+
let bits = 0;
|
|
1987
|
+
if (opts.hitLocked) bits |= 1;
|
|
1988
|
+
return bits;
|
|
1989
|
+
}
|
|
1990
|
+
getShapeAtPoint(point, opts = {}) {
|
|
1991
|
+
this.flushEngine();
|
|
1992
|
+
const margin = (opts.margin ?? this.options.hitTestMargin) / this.getZoomLevel();
|
|
1993
|
+
const bits = this.hitFilterBits(opts) | (opts.hitInside ? 0 : 4);
|
|
1994
|
+
if (!opts.filter) {
|
|
1995
|
+
const h = this.engine.hitTest(point.x, point.y, margin, bits);
|
|
1996
|
+
const id = this.handles.id(h);
|
|
1997
|
+
return id ? this.getShape(id) : void 0;
|
|
1998
|
+
}
|
|
1999
|
+
for (const shape of this.getShapesAtPoint(point, opts)) {
|
|
2000
|
+
if (opts.filter(shape)) return shape;
|
|
2001
|
+
}
|
|
2002
|
+
return void 0;
|
|
2003
|
+
}
|
|
2004
|
+
/** Shapes under a point, topmost first. */
|
|
2005
|
+
getShapesAtPoint(point, opts = {}) {
|
|
2006
|
+
this.flushEngine();
|
|
2007
|
+
const margin = (opts.margin ?? this.options.hitTestMargin) / this.getZoomLevel();
|
|
2008
|
+
const handles = this.engine.queryBox(point.x - margin, point.y - margin, point.x + margin, point.y + margin, 0, this.hitFilterBits(opts));
|
|
2009
|
+
const out = [];
|
|
2010
|
+
for (let i = handles.length - 1; i >= 0; i--) {
|
|
2011
|
+
const id = this.handles.id(handles[i]);
|
|
2012
|
+
const shape = id ? this.getShape(id) : void 0;
|
|
2013
|
+
if (!shape) continue;
|
|
2014
|
+
if (opts.filter && !opts.filter(shape)) continue;
|
|
2015
|
+
const local = this.getPointInShapeSpace(shape, point);
|
|
2016
|
+
const geo = this.getShapeGeometry(shape);
|
|
2017
|
+
if (geo.hitTestPoint(local, margin, opts.hitInside ?? false)) out.push(shape);
|
|
2018
|
+
}
|
|
2019
|
+
return out;
|
|
2020
|
+
}
|
|
2021
|
+
/** Shapes fully inside a page box, in draw order. */
|
|
2022
|
+
getShapesInsideBounds(box, opts = {}) {
|
|
2023
|
+
this.flushEngine();
|
|
2024
|
+
const handles = this.engine.queryBox(box.x, box.y, box.x + box.w, box.y + box.h, 1, this.hitFilterBits(opts));
|
|
2025
|
+
return this.handlesToShapes(handles, opts.filter);
|
|
2026
|
+
}
|
|
2027
|
+
/** Shapes whose outline touches a page box, in draw order. */
|
|
2028
|
+
getShapesIntersectingBounds(box, opts = {}) {
|
|
2029
|
+
this.flushEngine();
|
|
2030
|
+
const handles = this.engine.queryBox(box.x, box.y, box.x + box.w, box.y + box.h, 0, this.hitFilterBits(opts));
|
|
2031
|
+
return this.handlesToShapes(handles, opts.filter);
|
|
2032
|
+
}
|
|
2033
|
+
handlesToShapes(handles, filter) {
|
|
2034
|
+
const out = [];
|
|
2035
|
+
for (const h of handles) {
|
|
2036
|
+
const id = this.handles.id(h);
|
|
2037
|
+
const shape = id ? this.getShape(id) : void 0;
|
|
2038
|
+
if (shape && (!filter || filter(shape))) out.push(shape);
|
|
2039
|
+
}
|
|
2040
|
+
return out;
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Union of every shape's page bounds on the current page, or undefined when
|
|
2044
|
+
* the page is empty.
|
|
2045
|
+
*
|
|
2046
|
+
* Geometry, not ink: this is the union of `getShapePageBounds()` and excludes
|
|
2047
|
+
* the half-stroke pad the engine keeps for culling. The pad is per-shape, so
|
|
2048
|
+
* including it would both inflate the box and shift its centre whenever the
|
|
2049
|
+
* outermost shapes carry different stroke widths.
|
|
2050
|
+
*/
|
|
2051
|
+
getCurrentPageBounds() {
|
|
2052
|
+
this.flushEngine();
|
|
2053
|
+
const b = this.engine.allGeometryBounds();
|
|
2054
|
+
return b ? Box.FromMinMax(b[0], b[1], b[2], b[3]) : void 0;
|
|
2055
|
+
}
|
|
2056
|
+
// ---- shapes: writing ---------------------------------------------------
|
|
2057
|
+
createShape(partial) {
|
|
2058
|
+
return this.createShapes([partial]);
|
|
2059
|
+
}
|
|
2060
|
+
createShapes(partials) {
|
|
2061
|
+
if (partials.length === 0) return this;
|
|
2062
|
+
const currentPageId = this.getCurrentPageId();
|
|
2063
|
+
const count = this.getCurrentPageShapeIds().size;
|
|
2064
|
+
if (count + partials.length > this.options.maxShapesPerPage) {
|
|
2065
|
+
this.emit("max-shapes", { name: this.getCurrentPage().name, pageId: currentPageId, count });
|
|
2066
|
+
}
|
|
2067
|
+
this.run(() => {
|
|
2068
|
+
const records = [];
|
|
2069
|
+
const indexCache = /* @__PURE__ */ new Map();
|
|
2070
|
+
for (const partial of partials) {
|
|
2071
|
+
const util = this.getShapeUtil(partial.type);
|
|
2072
|
+
const parentId = partial.parentId ?? currentPageId;
|
|
2073
|
+
let index = partial.index;
|
|
2074
|
+
if (!index) {
|
|
2075
|
+
const prev = indexCache.get(parentId) ?? this.getHighestIndexForParent(parentId);
|
|
2076
|
+
index = indexCache.has(parentId) ? getIndexAbove(prev) : prev;
|
|
2077
|
+
indexCache.set(parentId, index);
|
|
2078
|
+
}
|
|
2079
|
+
const props = { ...util.getDefaultProps() };
|
|
2080
|
+
const styles = this.getInstanceState().stylesForNextShape;
|
|
2081
|
+
for (const [key, style] of this.getStylePropsForType(partial.type)) {
|
|
2082
|
+
if (style.id in styles) props[key] = styles[style.id];
|
|
2083
|
+
}
|
|
2084
|
+
for (const [key, value] of Object.entries(partial.props ?? {})) {
|
|
2085
|
+
if (value !== void 0) props[key] = value;
|
|
2086
|
+
}
|
|
2087
|
+
let shape = ShapeRecordType.create({
|
|
2088
|
+
id: partial.id ?? ShapeRecordType.createId(),
|
|
2089
|
+
type: partial.type,
|
|
2090
|
+
x: partial.x ?? 0,
|
|
2091
|
+
y: partial.y ?? 0,
|
|
2092
|
+
rotation: partial.rotation ?? 0,
|
|
2093
|
+
index,
|
|
2094
|
+
parentId,
|
|
2095
|
+
isLocked: partial.isLocked ?? false,
|
|
2096
|
+
opacity: partial.opacity ?? 1,
|
|
2097
|
+
props,
|
|
2098
|
+
meta: { ...partial.meta ?? {} }
|
|
2099
|
+
});
|
|
2100
|
+
const next = util.onBeforeCreate?.(shape);
|
|
2101
|
+
if (next) shape = next;
|
|
2102
|
+
records.push(shape);
|
|
2103
|
+
}
|
|
2104
|
+
this.store.put(records);
|
|
2105
|
+
});
|
|
2106
|
+
return this;
|
|
2107
|
+
}
|
|
2108
|
+
updateShape(partial) {
|
|
2109
|
+
return partial ? this.updateShapes([partial]) : this;
|
|
2110
|
+
}
|
|
2111
|
+
updateShapes(partials) {
|
|
2112
|
+
this.run(() => {
|
|
2113
|
+
const records = [];
|
|
2114
|
+
for (const partial of partials) {
|
|
2115
|
+
if (!partial) continue;
|
|
2116
|
+
const prev = this.getShape(partial.id);
|
|
2117
|
+
if (!prev) continue;
|
|
2118
|
+
const util = this.getShapeUtil(prev);
|
|
2119
|
+
let next = {
|
|
2120
|
+
...prev,
|
|
2121
|
+
...partial,
|
|
2122
|
+
props: partial.props ? { ...prev.props, ...partial.props } : prev.props,
|
|
2123
|
+
meta: partial.meta ? { ...prev.meta, ...partial.meta } : prev.meta
|
|
2124
|
+
};
|
|
2125
|
+
const adjusted = util.onBeforeUpdate?.(prev, next);
|
|
2126
|
+
if (adjusted) next = adjusted;
|
|
2127
|
+
records.push(next);
|
|
2128
|
+
}
|
|
2129
|
+
if (records.length) this.store.put(records);
|
|
2130
|
+
});
|
|
2131
|
+
return this;
|
|
2132
|
+
}
|
|
2133
|
+
deleteShape(id) {
|
|
2134
|
+
return this.deleteShapes([id]);
|
|
2135
|
+
}
|
|
2136
|
+
deleteShapes(ids) {
|
|
2137
|
+
const shapeIds = ids.map((s) => typeof s === "string" ? s : s.id);
|
|
2138
|
+
if (shapeIds.length === 0) return this;
|
|
2139
|
+
const toDelete = /* @__PURE__ */ new Set();
|
|
2140
|
+
const collect = (id) => {
|
|
2141
|
+
if (toDelete.has(id)) return;
|
|
2142
|
+
toDelete.add(id);
|
|
2143
|
+
for (const child of this.getSortedChildIdsForParent(id)) collect(child);
|
|
2144
|
+
};
|
|
2145
|
+
for (const id of shapeIds) {
|
|
2146
|
+
const shape = this.getShape(id);
|
|
2147
|
+
if (shape && !shape.isLocked) collect(id);
|
|
2148
|
+
}
|
|
2149
|
+
if (toDelete.size === 0) return this;
|
|
2150
|
+
this.run(() => {
|
|
2151
|
+
const ps = this.getCurrentPageState();
|
|
2152
|
+
const selected = ps.selectedShapeIds.filter((id) => !toDelete.has(id));
|
|
2153
|
+
if (selected.length !== ps.selectedShapeIds.length) this.setSelectedShapes(selected);
|
|
2154
|
+
if (ps.hoveredShapeId && toDelete.has(ps.hoveredShapeId)) this.setHoveredShape(null);
|
|
2155
|
+
if (ps.editingShapeId && toDelete.has(ps.editingShapeId)) this.setEditingShape(null);
|
|
2156
|
+
this.store.remove([...toDelete]);
|
|
2157
|
+
});
|
|
2158
|
+
return this;
|
|
2159
|
+
}
|
|
2160
|
+
reparentShapes(ids, parentId, insertIndex) {
|
|
2161
|
+
this.run(() => {
|
|
2162
|
+
let index = insertIndex ?? this.getHighestIndexForParent(parentId);
|
|
2163
|
+
const updates = [];
|
|
2164
|
+
const parentTransform = isShapeId(parentId) ? this.getShapePageTransform(parentId) : { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
|
2165
|
+
for (const id of ids) {
|
|
2166
|
+
const shape = this.getShape(id);
|
|
2167
|
+
if (!shape || shape.parentId === parentId) continue;
|
|
2168
|
+
const pageXf = this.getShapePageTransform(shape);
|
|
2169
|
+
const px = pageXf.e;
|
|
2170
|
+
const py = pageXf.f;
|
|
2171
|
+
const det = parentTransform.a * parentTransform.d - parentTransform.b * parentTransform.c;
|
|
2172
|
+
const inv = 1 / (det || 1);
|
|
2173
|
+
const lx = (parentTransform.d * (px - parentTransform.e) - parentTransform.c * (py - parentTransform.f)) * inv;
|
|
2174
|
+
const ly = (-parentTransform.b * (px - parentTransform.e) + parentTransform.a * (py - parentTransform.f)) * inv;
|
|
2175
|
+
const parentRot = Math.atan2(parentTransform.b, parentTransform.a);
|
|
2176
|
+
const pageRot = Math.atan2(pageXf.b, pageXf.a);
|
|
2177
|
+
updates.push({ id, type: shape.type, parentId, index, x: lx, y: ly, rotation: pageRot - parentRot });
|
|
2178
|
+
index = getIndexAbove(index);
|
|
2179
|
+
}
|
|
2180
|
+
this.updateShapes(updates);
|
|
2181
|
+
});
|
|
2182
|
+
return this;
|
|
2183
|
+
}
|
|
2184
|
+
// ---- bindings ----------------------------------------------------------
|
|
2185
|
+
_allBindings = computed(
|
|
2186
|
+
"editor.allBindings",
|
|
2187
|
+
() => this.store.query.records("binding").get()
|
|
2188
|
+
);
|
|
2189
|
+
getBindingUtil(binding) {
|
|
2190
|
+
const type = typeof binding === "string" ? binding : binding.type;
|
|
2191
|
+
const util = this.bindingUtils[type];
|
|
2192
|
+
if (!util) throw new Error(`No BindingUtil registered for type "${type}"`);
|
|
2193
|
+
return util;
|
|
2194
|
+
}
|
|
2195
|
+
hasBindingUtil(type) {
|
|
2196
|
+
return type in this.bindingUtils;
|
|
2197
|
+
}
|
|
2198
|
+
getBinding(id) {
|
|
2199
|
+
return this.store.get(id);
|
|
2200
|
+
}
|
|
2201
|
+
getBindingsFromShape(shape, type) {
|
|
2202
|
+
const id = typeof shape === "string" ? shape : shape.id;
|
|
2203
|
+
return this._allBindings.get().filter((b) => b.fromId === id && (!type || b.type === type));
|
|
2204
|
+
}
|
|
2205
|
+
getBindingsToShape(shape, type) {
|
|
2206
|
+
const id = typeof shape === "string" ? shape : shape.id;
|
|
2207
|
+
return this._allBindings.get().filter((b) => b.toId === id && (!type || b.type === type));
|
|
2208
|
+
}
|
|
2209
|
+
getBindingsInvolvingShape(shape, type) {
|
|
2210
|
+
const id = typeof shape === "string" ? shape : shape.id;
|
|
2211
|
+
return this._allBindings.get().filter((b) => (b.fromId === id || b.toId === id) && (!type || b.type === type));
|
|
2212
|
+
}
|
|
2213
|
+
createBinding(partial) {
|
|
2214
|
+
return this.createBindings([partial]);
|
|
2215
|
+
}
|
|
2216
|
+
createBindings(partials) {
|
|
2217
|
+
if (partials.length === 0) return this;
|
|
2218
|
+
this.run(() => {
|
|
2219
|
+
const records = [];
|
|
2220
|
+
for (const partial of partials) {
|
|
2221
|
+
const util = this.getBindingUtil(partial.type);
|
|
2222
|
+
if (!this.getShape(partial.fromId) || !this.getShape(partial.toId)) continue;
|
|
2223
|
+
let binding = BindingRecordType.create({
|
|
2224
|
+
id: partial.id ?? BindingRecordType.createId(),
|
|
2225
|
+
type: partial.type,
|
|
2226
|
+
fromId: partial.fromId,
|
|
2227
|
+
toId: partial.toId,
|
|
2228
|
+
props: { ...util.getDefaultProps(), ...partial.props ?? {} },
|
|
2229
|
+
meta: { ...partial.meta ?? {} }
|
|
2230
|
+
});
|
|
2231
|
+
const next = util.onBeforeCreate?.({ binding });
|
|
2232
|
+
if (next) binding = next;
|
|
2233
|
+
records.push(binding);
|
|
2234
|
+
}
|
|
2235
|
+
this.store.put(records);
|
|
2236
|
+
});
|
|
2237
|
+
return this;
|
|
2238
|
+
}
|
|
2239
|
+
updateBinding(partial) {
|
|
2240
|
+
return this.updateBindings([partial]);
|
|
2241
|
+
}
|
|
2242
|
+
updateBindings(partials) {
|
|
2243
|
+
this.run(() => {
|
|
2244
|
+
const records = [];
|
|
2245
|
+
for (const partial of partials) {
|
|
2246
|
+
const prev = this.getBinding(partial.id);
|
|
2247
|
+
if (!prev) continue;
|
|
2248
|
+
let next = {
|
|
2249
|
+
...prev,
|
|
2250
|
+
...partial,
|
|
2251
|
+
props: partial.props ? { ...prev.props, ...partial.props } : prev.props,
|
|
2252
|
+
meta: partial.meta ? { ...prev.meta, ...partial.meta } : prev.meta
|
|
2253
|
+
};
|
|
2254
|
+
const adjusted = this.getBindingUtil(prev).onBeforeChange?.({ bindingBefore: prev, bindingAfter: next });
|
|
2255
|
+
if (adjusted) next = adjusted;
|
|
2256
|
+
records.push(next);
|
|
2257
|
+
}
|
|
2258
|
+
if (records.length) this.store.put(records);
|
|
2259
|
+
});
|
|
2260
|
+
return this;
|
|
2261
|
+
}
|
|
2262
|
+
deleteBinding(id, opts = {}) {
|
|
2263
|
+
return this.deleteBindings([id], opts);
|
|
2264
|
+
}
|
|
2265
|
+
deleteBindings(ids, opts = {}) {
|
|
2266
|
+
const bindingIds = ids.map((b) => typeof b === "string" ? b : b.id);
|
|
2267
|
+
if (bindingIds.length === 0) return this;
|
|
2268
|
+
this.run(() => {
|
|
2269
|
+
if (opts.isolateShapes) {
|
|
2270
|
+
for (const id of bindingIds) {
|
|
2271
|
+
const binding = this.getBinding(id);
|
|
2272
|
+
if (!binding) continue;
|
|
2273
|
+
const util = this.getBindingUtil(binding);
|
|
2274
|
+
util.onBeforeIsolateFromShape?.({ binding });
|
|
2275
|
+
util.onBeforeIsolateToShape?.({ binding });
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
this.store.remove(bindingIds.filter((id) => this.store.has(id)));
|
|
2279
|
+
});
|
|
2280
|
+
return this;
|
|
2281
|
+
}
|
|
2282
|
+
/** Store side effects that keep bindings consistent with their shapes. */
|
|
2283
|
+
registerBindingSideEffects() {
|
|
2284
|
+
const se = this.store.sideEffects;
|
|
2285
|
+
this.disposables.push(
|
|
2286
|
+
se.registerAfterCreateHandler("binding", (record) => {
|
|
2287
|
+
if (!isBinding(record) || !this.hasBindingUtil(record.type)) return;
|
|
2288
|
+
this.getBindingUtil(record).onAfterCreate?.({ binding: record });
|
|
2289
|
+
}),
|
|
2290
|
+
se.registerAfterChangeHandler("binding", (prev, next) => {
|
|
2291
|
+
if (!isBinding(next) || !isBinding(prev) || !this.hasBindingUtil(next.type)) return;
|
|
2292
|
+
this.getBindingUtil(next).onAfterChange?.({ bindingBefore: prev, bindingAfter: next });
|
|
2293
|
+
}),
|
|
2294
|
+
se.registerBeforeDeleteHandler("binding", (record) => {
|
|
2295
|
+
if (!isBinding(record) || !this.hasBindingUtil(record.type)) return;
|
|
2296
|
+
this.getBindingUtil(record).onBeforeDelete?.({ binding: record });
|
|
2297
|
+
}),
|
|
2298
|
+
se.registerAfterDeleteHandler("binding", (record) => {
|
|
2299
|
+
if (!isBinding(record) || !this.hasBindingUtil(record.type)) return;
|
|
2300
|
+
this.getBindingUtil(record).onAfterDelete?.({ binding: record });
|
|
2301
|
+
}),
|
|
2302
|
+
se.registerAfterChangeHandler("shape", (prev, next) => {
|
|
2303
|
+
if (prev.typeName !== "shape" || next.typeName !== "shape") return;
|
|
2304
|
+
if (this._allBindings.get().length === 0) return;
|
|
2305
|
+
for (const binding of this.getBindingsInvolvingShape(next.id)) {
|
|
2306
|
+
if (!this.hasBindingUtil(binding.type)) continue;
|
|
2307
|
+
const util = this.getBindingUtil(binding);
|
|
2308
|
+
if (binding.fromId === next.id) util.onAfterChangeFromShape?.({ binding, shapeBefore: prev, shapeAfter: next, reason: "self" });
|
|
2309
|
+
if (binding.toId === next.id) util.onAfterChangeToShape?.({ binding, shapeBefore: prev, shapeAfter: next, reason: "self" });
|
|
2310
|
+
}
|
|
2311
|
+
}),
|
|
2312
|
+
se.registerBeforeDeleteHandler("shape", (record) => {
|
|
2313
|
+
if (record.typeName !== "shape") return;
|
|
2314
|
+
const bindings = this.getBindingsInvolvingShape(record.id);
|
|
2315
|
+
if (bindings.length === 0) return;
|
|
2316
|
+
for (const binding of bindings) {
|
|
2317
|
+
if (!this.hasBindingUtil(binding.type)) continue;
|
|
2318
|
+
const util = this.getBindingUtil(binding);
|
|
2319
|
+
if (binding.fromId === record.id) util.onBeforeDeleteFromShape?.({ binding, shape: record });
|
|
2320
|
+
if (binding.toId === record.id) util.onBeforeDeleteToShape?.({ binding, shape: record });
|
|
2321
|
+
}
|
|
2322
|
+
this.store.remove(bindings.map((b) => b.id).filter((id) => this.store.has(id)));
|
|
2323
|
+
})
|
|
2324
|
+
);
|
|
2325
|
+
}
|
|
2326
|
+
/** Duplicate shapes (and their descendants) with new ids, offset by `offset` in page space. Returns the new top-level ids. */
|
|
2327
|
+
duplicateShapes(ids = this.getSelectedShapeIds(), offset = { x: 20, y: 20 }) {
|
|
2328
|
+
if (ids.length === 0) return [];
|
|
2329
|
+
const idMap = /* @__PURE__ */ new Map();
|
|
2330
|
+
const collect = (id) => {
|
|
2331
|
+
if (idMap.has(id)) return;
|
|
2332
|
+
idMap.set(id, ShapeRecordType.createId());
|
|
2333
|
+
for (const c of this.getSortedChildIdsForParent(id)) collect(c);
|
|
2334
|
+
};
|
|
2335
|
+
for (const id of ids) collect(id);
|
|
2336
|
+
const topLevel = new Set(ids);
|
|
2337
|
+
const creates = [];
|
|
2338
|
+
for (const shape of this.getCurrentPageShapesSorted()) {
|
|
2339
|
+
const newId = idMap.get(shape.id);
|
|
2340
|
+
if (!newId) continue;
|
|
2341
|
+
const parentId = isShapeId(shape.parentId) && idMap.has(shape.parentId) ? idMap.get(shape.parentId) : shape.parentId;
|
|
2342
|
+
const isTop = topLevel.has(shape.id) || !idMap.has(shape.parentId);
|
|
2343
|
+
creates.push({
|
|
2344
|
+
...shape,
|
|
2345
|
+
id: newId,
|
|
2346
|
+
parentId,
|
|
2347
|
+
x: shape.x + (isTop ? offset.x : 0),
|
|
2348
|
+
y: shape.y + (isTop ? offset.y : 0),
|
|
2349
|
+
index: void 0,
|
|
2350
|
+
props: { ...shape.props },
|
|
2351
|
+
meta: { ...shape.meta }
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
this.run(() => {
|
|
2355
|
+
this.createShapes(creates);
|
|
2356
|
+
const bindingCreates = [];
|
|
2357
|
+
for (const b of this._allBindings.get()) {
|
|
2358
|
+
const from = idMap.get(b.fromId);
|
|
2359
|
+
const to = idMap.get(b.toId);
|
|
2360
|
+
if (from && to) bindingCreates.push({ type: b.type, fromId: from, toId: to, props: { ...b.props }, meta: { ...b.meta } });
|
|
2361
|
+
}
|
|
2362
|
+
if (bindingCreates.length) this.createBindings(bindingCreates);
|
|
2363
|
+
});
|
|
2364
|
+
return ids.map((id) => idMap.get(id));
|
|
2365
|
+
}
|
|
2366
|
+
/** Serializable content for the clipboard: shapes (with descendants) and bindings between them. */
|
|
2367
|
+
getContentFromCurrentPage(ids) {
|
|
2368
|
+
if (ids.length === 0) return void 0;
|
|
2369
|
+
const set = /* @__PURE__ */ new Set();
|
|
2370
|
+
const collect = (id) => {
|
|
2371
|
+
if (set.has(id)) return;
|
|
2372
|
+
set.add(id);
|
|
2373
|
+
for (const c of this.getSortedChildIdsForParent(id)) collect(c);
|
|
2374
|
+
};
|
|
2375
|
+
for (const id of ids) collect(id);
|
|
2376
|
+
const shapes = this.getCurrentPageShapesSorted().filter((s) => set.has(s.id));
|
|
2377
|
+
const bindings = this._allBindings.get().filter((b) => set.has(b.fromId) && set.has(b.toId));
|
|
2378
|
+
return { shapes, bindings };
|
|
2379
|
+
}
|
|
2380
|
+
/** Insert clipboard content at a page point (centered), with fresh ids. Returns the new ids. */
|
|
2381
|
+
putContentOntoCurrentPage(content, opts = {}) {
|
|
2382
|
+
const { shapes } = content;
|
|
2383
|
+
if (shapes.length === 0) return [];
|
|
2384
|
+
const idMap = /* @__PURE__ */ new Map();
|
|
2385
|
+
for (const s of shapes) idMap.set(s.id, ShapeRecordType.createId());
|
|
2386
|
+
const pageId = this.getCurrentPageId();
|
|
2387
|
+
const tops = shapes.filter((s) => !idMap.has(s.parentId));
|
|
2388
|
+
let offset = new Vec(0, 0);
|
|
2389
|
+
if (opts.point) {
|
|
2390
|
+
const boxes = tops.map((s) => {
|
|
2391
|
+
const util = this.shapeUtils[s.type];
|
|
2392
|
+
const b = util ? util.getGeometry(s).bounds : new Box(0, 0, 0, 0);
|
|
2393
|
+
return new Box(s.x + b.x, s.y + b.y, b.w, b.h);
|
|
2394
|
+
});
|
|
2395
|
+
const common = Box.Common(boxes);
|
|
2396
|
+
offset = Vec.Sub(opts.point, common.center);
|
|
2397
|
+
}
|
|
2398
|
+
const creates = shapes.map((s) => {
|
|
2399
|
+
const isTop = !idMap.has(s.parentId);
|
|
2400
|
+
return {
|
|
2401
|
+
...s,
|
|
2402
|
+
id: idMap.get(s.id),
|
|
2403
|
+
parentId: isTop ? pageId : idMap.get(s.parentId),
|
|
2404
|
+
x: s.x + (isTop ? offset.x : 0),
|
|
2405
|
+
y: s.y + (isTop ? offset.y : 0),
|
|
2406
|
+
index: void 0,
|
|
2407
|
+
props: { ...s.props },
|
|
2408
|
+
meta: { ...s.meta }
|
|
2409
|
+
};
|
|
2410
|
+
});
|
|
2411
|
+
this.run(() => {
|
|
2412
|
+
this.createShapes(creates.filter((c) => this.hasShapeUtil(c.type)));
|
|
2413
|
+
const bindingCreates = [];
|
|
2414
|
+
for (const b of content.bindings ?? []) {
|
|
2415
|
+
const from = idMap.get(b.fromId);
|
|
2416
|
+
const to = idMap.get(b.toId);
|
|
2417
|
+
if (from && to && this.hasBindingUtil(b.type)) bindingCreates.push({ type: b.type, fromId: from, toId: to, props: { ...b.props }, meta: { ...b.meta } });
|
|
2418
|
+
}
|
|
2419
|
+
if (bindingCreates.length) this.createBindings(bindingCreates);
|
|
2420
|
+
if (opts.select ?? true) this.setSelectedShapes(tops.map((s) => idMap.get(s.id)));
|
|
2421
|
+
});
|
|
2422
|
+
return tops.map((s) => idMap.get(s.id));
|
|
2423
|
+
}
|
|
2424
|
+
// ---- styles ------------------------------------------------------------
|
|
2425
|
+
stylePropCache = /* @__PURE__ */ new Map();
|
|
2426
|
+
/** Style props declared by the ShapeUtil for a type, keyed by prop name. */
|
|
2427
|
+
getStylePropsForType(type) {
|
|
2428
|
+
let m = this.stylePropCache.get(type);
|
|
2429
|
+
if (!m) {
|
|
2430
|
+
const ctor = this.shapeUtils[type]?.constructor;
|
|
2431
|
+
m = getStylePropsOf(ctor?.props);
|
|
2432
|
+
this.stylePropCache.set(type, m);
|
|
2433
|
+
}
|
|
2434
|
+
return m;
|
|
2435
|
+
}
|
|
2436
|
+
getStyleForNextShape(style) {
|
|
2437
|
+
const v = this.getInstanceState().stylesForNextShape[style.id];
|
|
2438
|
+
return v === void 0 ? style.defaultValue : v;
|
|
2439
|
+
}
|
|
2440
|
+
setStyleForNextShapes(style, value) {
|
|
2441
|
+
const styles = this.getInstanceState().stylesForNextShape;
|
|
2442
|
+
if (styles[style.id] === value) return this;
|
|
2443
|
+
return this.updateInstanceState({ stylesForNextShape: { ...styles, [style.id]: value } });
|
|
2444
|
+
}
|
|
2445
|
+
/** Set a style on every selected shape that declares it. */
|
|
2446
|
+
setStyleForSelectedShapes(style, value) {
|
|
2447
|
+
const updates = [];
|
|
2448
|
+
for (const shape of this.getSelectedShapes()) {
|
|
2449
|
+
for (const [key, sp] of this.getStylePropsForType(shape.type)) {
|
|
2450
|
+
if (sp !== style) continue;
|
|
2451
|
+
if (shape.props[key] === value) continue;
|
|
2452
|
+
updates.push({ id: shape.id, type: shape.type, props: { [key]: value } });
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
if (updates.length) this.updateShapes(updates);
|
|
2456
|
+
return this;
|
|
2457
|
+
}
|
|
2458
|
+
/** Styles of the selection (or of the next shape when nothing is selected). */
|
|
2459
|
+
getSharedStyles() {
|
|
2460
|
+
const map = new SharedStyleMap();
|
|
2461
|
+
const selected = this.getSelectedShapes();
|
|
2462
|
+
if (selected.length === 0) {
|
|
2463
|
+
const styles = this.getInstanceState().stylesForNextShape;
|
|
2464
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2465
|
+
for (const type of Object.keys(this.shapeUtils)) {
|
|
2466
|
+
for (const sp of this.getStylePropsForType(type).values()) {
|
|
2467
|
+
if (seen.has(sp)) continue;
|
|
2468
|
+
seen.add(sp);
|
|
2469
|
+
map.applyValue(sp, styles[sp.id] ?? sp.defaultValue);
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
return map;
|
|
2473
|
+
}
|
|
2474
|
+
for (const shape of selected) {
|
|
2475
|
+
for (const [key, sp] of this.getStylePropsForType(shape.type)) {
|
|
2476
|
+
map.applyValue(sp, shape.props[key]);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
return map;
|
|
2480
|
+
}
|
|
2481
|
+
// ---- bulk transforms ---------------------------------------------------
|
|
2482
|
+
/** Move shapes by a page-space offset. */
|
|
2483
|
+
nudgeShapes(ids, offset) {
|
|
2484
|
+
const updates = [];
|
|
2485
|
+
for (const id of ids) {
|
|
2486
|
+
const shape = this.getShape(id);
|
|
2487
|
+
if (!shape || shape.isLocked) continue;
|
|
2488
|
+
const parent = this.getShapeParent(shape);
|
|
2489
|
+
let d = new Vec(offset.x, offset.y);
|
|
2490
|
+
if (parent) {
|
|
2491
|
+
const m = this.getShapePageTransform(parent);
|
|
2492
|
+
const det = m.a * m.d - m.b * m.c || 1;
|
|
2493
|
+
d = new Vec((m.d * d.x - m.c * d.y) / det, (-m.b * d.x + m.a * d.y) / det);
|
|
2494
|
+
}
|
|
2495
|
+
updates.push({ id, type: shape.type, x: shape.x + d.x, y: shape.y + d.y });
|
|
2496
|
+
}
|
|
2497
|
+
return this.updateShapes(updates);
|
|
2498
|
+
}
|
|
2499
|
+
/** Rotate shapes by `delta` radians around the center of their common page bounds. */
|
|
2500
|
+
rotateShapesBy(ids, delta, center) {
|
|
2501
|
+
const shapes = ids.map((id) => this.getShape(id)).filter((s) => !!s && !s.isLocked);
|
|
2502
|
+
if (shapes.length === 0) return this;
|
|
2503
|
+
const boxes = shapes.map((s) => this.getShapePageBounds(s)).filter((b) => !!b);
|
|
2504
|
+
const c = center ?? Box.Common(boxes).center;
|
|
2505
|
+
const updates = [];
|
|
2506
|
+
for (const shape of shapes) {
|
|
2507
|
+
const m = this.getShapePageTransform(shape);
|
|
2508
|
+
const newPagePos = Vec.RotWith(new Vec(m.e, m.f), c, delta);
|
|
2509
|
+
const parentPoint = this.getPointInParentSpace(shape, newPagePos);
|
|
2510
|
+
updates.push({ id: shape.id, type: shape.type, x: parentPoint.x, y: parentPoint.y, rotation: shape.rotation + delta });
|
|
2511
|
+
}
|
|
2512
|
+
return this.updateShapes(updates);
|
|
2513
|
+
}
|
|
2514
|
+
/** Mirror shapes across the center of their common bounds. Positions flip; geometry is not mirrored. */
|
|
2515
|
+
flipShapes(ids, operation) {
|
|
2516
|
+
const shapes = ids.map((id) => this.getShape(id)).filter((s) => !!s && !s.isLocked);
|
|
2517
|
+
if (shapes.length < 1) return this;
|
|
2518
|
+
const boxes = shapes.map((s) => this.getShapePageBounds(s));
|
|
2519
|
+
const common = Box.Common(boxes);
|
|
2520
|
+
const updates = [];
|
|
2521
|
+
shapes.forEach((shape, i) => {
|
|
2522
|
+
const b = boxes[i];
|
|
2523
|
+
const nb = operation === "horizontal" ? new Box(common.maxX - (b.maxX - common.x), b.y, b.w, b.h) : new Box(b.x, common.maxY - (b.maxY - common.y), b.w, b.h);
|
|
2524
|
+
const d = new Vec(nb.x - b.x, nb.y - b.y);
|
|
2525
|
+
const m = this.getShapePageTransform(shape);
|
|
2526
|
+
const parentPoint = this.getPointInParentSpace(shape, new Vec(m.e + d.x, m.f + d.y));
|
|
2527
|
+
updates.push({ id: shape.id, type: shape.type, x: parentPoint.x, y: parentPoint.y });
|
|
2528
|
+
});
|
|
2529
|
+
return this.updateShapes(updates);
|
|
2530
|
+
}
|
|
2531
|
+
/** Align shapes along an edge or center of their common bounds. */
|
|
2532
|
+
alignShapes(ids, operation) {
|
|
2533
|
+
const shapes = ids.map((id) => this.getShape(id)).filter((s) => !!s && !s.isLocked);
|
|
2534
|
+
if (shapes.length < 2) return this;
|
|
2535
|
+
const boxes = shapes.map((s) => this.getShapePageBounds(s));
|
|
2536
|
+
const common = Box.Common(boxes);
|
|
2537
|
+
const updates = [];
|
|
2538
|
+
shapes.forEach((shape, i) => {
|
|
2539
|
+
const b = boxes[i];
|
|
2540
|
+
let d = new Vec();
|
|
2541
|
+
switch (operation) {
|
|
2542
|
+
case "left":
|
|
2543
|
+
d = new Vec(common.x - b.x, 0);
|
|
2544
|
+
break;
|
|
2545
|
+
case "center-horizontal":
|
|
2546
|
+
d = new Vec(common.center.x - b.center.x, 0);
|
|
2547
|
+
break;
|
|
2548
|
+
case "right":
|
|
2549
|
+
d = new Vec(common.maxX - b.maxX, 0);
|
|
2550
|
+
break;
|
|
2551
|
+
case "top":
|
|
2552
|
+
d = new Vec(0, common.y - b.y);
|
|
2553
|
+
break;
|
|
2554
|
+
case "center-vertical":
|
|
2555
|
+
d = new Vec(0, common.center.y - b.center.y);
|
|
2556
|
+
break;
|
|
2557
|
+
case "bottom":
|
|
2558
|
+
d = new Vec(0, common.maxY - b.maxY);
|
|
2559
|
+
break;
|
|
2560
|
+
}
|
|
2561
|
+
if (d.x === 0 && d.y === 0) return;
|
|
2562
|
+
const m = this.getShapePageTransform(shape);
|
|
2563
|
+
const parentPoint = this.getPointInParentSpace(shape, new Vec(m.e + d.x, m.f + d.y));
|
|
2564
|
+
updates.push({ id: shape.id, type: shape.type, x: parentPoint.x, y: parentPoint.y });
|
|
2565
|
+
});
|
|
2566
|
+
return this.updateShapes(updates);
|
|
2567
|
+
}
|
|
2568
|
+
/** Space shapes evenly between the first and last along an axis. */
|
|
2569
|
+
distributeShapes(ids, operation) {
|
|
2570
|
+
const shapes = ids.map((id) => this.getShape(id)).filter((s) => !!s && !s.isLocked);
|
|
2571
|
+
if (shapes.length < 3) return this;
|
|
2572
|
+
const items = shapes.map((s) => ({ shape: s, b: this.getShapePageBounds(s) }));
|
|
2573
|
+
const horizontal = operation === "horizontal";
|
|
2574
|
+
items.sort((a, b) => horizontal ? a.b.center.x - b.b.center.x : a.b.center.y - b.b.center.y);
|
|
2575
|
+
const first = items[0];
|
|
2576
|
+
const last = items.at(-1);
|
|
2577
|
+
const span = horizontal ? last.b.x - first.b.maxX : last.b.y - first.b.maxY;
|
|
2578
|
+
const inner = items.slice(1, -1);
|
|
2579
|
+
const totalInner = inner.reduce((acc, i) => acc + (horizontal ? i.b.w : i.b.h), 0);
|
|
2580
|
+
const gap = (span - totalInner) / (inner.length + 1);
|
|
2581
|
+
let cursor = horizontal ? first.b.maxX + gap : first.b.maxY + gap;
|
|
2582
|
+
const updates = [];
|
|
2583
|
+
for (const it of inner) {
|
|
2584
|
+
const d = horizontal ? new Vec(cursor - it.b.x, 0) : new Vec(0, cursor - it.b.y);
|
|
2585
|
+
cursor += (horizontal ? it.b.w : it.b.h) + gap;
|
|
2586
|
+
const m = this.getShapePageTransform(it.shape);
|
|
2587
|
+
const parentPoint = this.getPointInParentSpace(it.shape, new Vec(m.e + d.x, m.f + d.y));
|
|
2588
|
+
updates.push({ id: it.shape.id, type: it.shape.type, x: parentPoint.x, y: parentPoint.y });
|
|
2589
|
+
}
|
|
2590
|
+
return this.updateShapes(updates);
|
|
2591
|
+
}
|
|
2592
|
+
/** Stack shapes edge to edge with a fixed gap along an axis (order by current position). */
|
|
2593
|
+
stackShapes(ids, operation, gap = 16) {
|
|
2594
|
+
const shapes = ids.map((id) => this.getShape(id)).filter((s) => !!s && !s.isLocked);
|
|
2595
|
+
if (shapes.length < 2) return this;
|
|
2596
|
+
const items = shapes.map((s) => ({ shape: s, b: this.getShapePageBounds(s) }));
|
|
2597
|
+
const horizontal = operation === "horizontal";
|
|
2598
|
+
items.sort((a, b) => horizontal ? a.b.x - b.b.x : a.b.y - b.b.y);
|
|
2599
|
+
let cursor = horizontal ? items[0].b.maxX + gap : items[0].b.maxY + gap;
|
|
2600
|
+
const updates = [];
|
|
2601
|
+
for (const it of items.slice(1)) {
|
|
2602
|
+
const d = horizontal ? new Vec(cursor - it.b.x, 0) : new Vec(0, cursor - it.b.y);
|
|
2603
|
+
cursor += (horizontal ? it.b.w : it.b.h) + gap;
|
|
2604
|
+
const m = this.getShapePageTransform(it.shape);
|
|
2605
|
+
const parentPoint = this.getPointInParentSpace(it.shape, new Vec(m.e + d.x, m.f + d.y));
|
|
2606
|
+
updates.push({ id: it.shape.id, type: it.shape.type, x: parentPoint.x, y: parentPoint.y });
|
|
2607
|
+
}
|
|
2608
|
+
return this.updateShapes(updates);
|
|
2609
|
+
}
|
|
2610
|
+
/** Toggle the locked state of shapes. */
|
|
2611
|
+
toggleLock(ids = this.getSelectedShapeIds()) {
|
|
2612
|
+
const shapes = ids.map((id) => this.getShape(id)).filter((s) => !!s);
|
|
2613
|
+
if (shapes.length === 0) return this;
|
|
2614
|
+
const allLocked = shapes.every((s) => s.isLocked);
|
|
2615
|
+
return this.updateShapes(shapes.map((s) => ({ id: s.id, type: s.type, isLocked: !allLocked })));
|
|
2616
|
+
}
|
|
2617
|
+
// ---- groups ------------------------------------------------------------
|
|
2618
|
+
/** Wrap shapes in a new `group` shape (requires a registered "group" ShapeUtil). Returns the group id. */
|
|
2619
|
+
groupShapes(ids = this.getSelectedShapeIds(), groupId = ShapeRecordType.createId()) {
|
|
2620
|
+
if (!this.hasShapeUtil("group")) throw new Error('No ShapeUtil registered for type "group"');
|
|
2621
|
+
const shapes = ids.map((id) => this.getShape(id)).filter((s) => !!s && !s.isLocked);
|
|
2622
|
+
if (shapes.length < 2) return void 0;
|
|
2623
|
+
const parentId = shapes[0].parentId;
|
|
2624
|
+
if (!shapes.every((s) => s.parentId === parentId)) return void 0;
|
|
2625
|
+
const sorted = sortByIndex(shapes);
|
|
2626
|
+
const bounds = Box.Common(sorted.map((s) => this.getShapePageBounds(s)));
|
|
2627
|
+
this.run(() => {
|
|
2628
|
+
this.createShape({ id: groupId, type: "group", parentId, x: bounds.x, y: bounds.y, index: getIndexAbove(sorted.at(-1).index) });
|
|
2629
|
+
this.reparentShapes(sorted.map((s) => s.id), groupId);
|
|
2630
|
+
this.setSelectedShapes([groupId]);
|
|
2631
|
+
});
|
|
2632
|
+
return groupId;
|
|
2633
|
+
}
|
|
2634
|
+
/** Dissolve groups, re-parenting their children to the group's parent. */
|
|
2635
|
+
ungroupShapes(ids = this.getSelectedShapeIds()) {
|
|
2636
|
+
const groups = ids.map((id) => this.getShape(id)).filter((s) => !!s && s.type === "group");
|
|
2637
|
+
if (groups.length === 0) return this;
|
|
2638
|
+
this.run(() => {
|
|
2639
|
+
const released = [];
|
|
2640
|
+
for (const g of groups) {
|
|
2641
|
+
const children = this.getSortedChildIdsForParent(g.id);
|
|
2642
|
+
this.reparentShapes(children, g.parentId, getIndexAbove(g.index));
|
|
2643
|
+
released.push(...children);
|
|
2644
|
+
this.store.remove([g.id]);
|
|
2645
|
+
}
|
|
2646
|
+
this.setSelectedShapes(released);
|
|
2647
|
+
});
|
|
2648
|
+
return this;
|
|
2649
|
+
}
|
|
2650
|
+
/** The outermost group containing a shape, or undefined. */
|
|
2651
|
+
getOutermostSelectableShape(shape) {
|
|
2652
|
+
let cur = typeof shape === "string" ? this.getShape(shape) : shape;
|
|
2653
|
+
if (!cur) return void 0;
|
|
2654
|
+
let result = cur;
|
|
2655
|
+
const focused = this.getCurrentPageState().focusedGroupId;
|
|
2656
|
+
while (cur && isShapeId(cur.parentId)) {
|
|
2657
|
+
const parent = this.getShape(cur.parentId);
|
|
2658
|
+
if (!parent || parent.id === focused) break;
|
|
2659
|
+
if (parent.type === "group") result = parent;
|
|
2660
|
+
cur = parent;
|
|
2661
|
+
}
|
|
2662
|
+
return result;
|
|
2663
|
+
}
|
|
2664
|
+
// ---- z-order -----------------------------------------------------------
|
|
2665
|
+
bringToFront(ids = this.getSelectedShapeIds()) {
|
|
2666
|
+
return this.reorder(ids, "toFront");
|
|
2667
|
+
}
|
|
2668
|
+
sendToBack(ids = this.getSelectedShapeIds()) {
|
|
2669
|
+
return this.reorder(ids, "toBack");
|
|
2670
|
+
}
|
|
2671
|
+
bringForward(ids = this.getSelectedShapeIds()) {
|
|
2672
|
+
return this.reorder(ids, "forward");
|
|
2673
|
+
}
|
|
2674
|
+
sendBackward(ids = this.getSelectedShapeIds()) {
|
|
2675
|
+
return this.reorder(ids, "backward");
|
|
2676
|
+
}
|
|
2677
|
+
reorder(ids, op) {
|
|
2678
|
+
if (ids.length === 0) return this;
|
|
2679
|
+
const set = new Set(ids);
|
|
2680
|
+
const byParent = /* @__PURE__ */ new Map();
|
|
2681
|
+
for (const id of ids) {
|
|
2682
|
+
const s = this.getShape(id);
|
|
2683
|
+
if (!s) continue;
|
|
2684
|
+
const arr = byParent.get(s.parentId) ?? [];
|
|
2685
|
+
arr.push(s);
|
|
2686
|
+
byParent.set(s.parentId, arr);
|
|
2687
|
+
}
|
|
2688
|
+
const updates = [];
|
|
2689
|
+
for (const [parentId, moving] of byParent) {
|
|
2690
|
+
const siblings = sortByIndex(this._allShapes.get().filter((s) => s.parentId === parentId));
|
|
2691
|
+
const movingSorted = sortByIndex(moving);
|
|
2692
|
+
const others = siblings.filter((s) => !set.has(s.id));
|
|
2693
|
+
let indices;
|
|
2694
|
+
switch (op) {
|
|
2695
|
+
case "toFront": {
|
|
2696
|
+
indices = getIndicesAbove(others.at(-1)?.index, movingSorted.length);
|
|
2697
|
+
break;
|
|
2698
|
+
}
|
|
2699
|
+
case "toBack": {
|
|
2700
|
+
const first = others[0]?.index;
|
|
2701
|
+
indices = [];
|
|
2702
|
+
let cur = first;
|
|
2703
|
+
for (let i = 0; i < movingSorted.length; i++) {
|
|
2704
|
+
cur = getIndexBelow(cur);
|
|
2705
|
+
indices.unshift(cur);
|
|
2706
|
+
}
|
|
2707
|
+
break;
|
|
2708
|
+
}
|
|
2709
|
+
case "forward": {
|
|
2710
|
+
const topMovingIdx = siblings.findIndex((s) => s.id === movingSorted.at(-1).id);
|
|
2711
|
+
const above = siblings.slice(topMovingIdx + 1).find((s) => !set.has(s.id));
|
|
2712
|
+
if (!above) return this;
|
|
2713
|
+
const aboveAbove = siblings[siblings.indexOf(above) + 1];
|
|
2714
|
+
indices = movingSorted.map(() => "");
|
|
2715
|
+
let below = above.index;
|
|
2716
|
+
for (let i = 0; i < movingSorted.length; i++) {
|
|
2717
|
+
const idx = getIndexBetween(below, aboveAbove?.index);
|
|
2718
|
+
indices[i] = idx;
|
|
2719
|
+
below = idx;
|
|
2720
|
+
}
|
|
2721
|
+
break;
|
|
2722
|
+
}
|
|
2723
|
+
case "backward": {
|
|
2724
|
+
const bottomMovingIdx = siblings.findIndex((s) => s.id === movingSorted[0].id);
|
|
2725
|
+
const below = siblings.slice(0, bottomMovingIdx).reverse().find((s) => !set.has(s.id));
|
|
2726
|
+
if (!below) return this;
|
|
2727
|
+
const belowBelow = siblings[siblings.indexOf(below) - 1];
|
|
2728
|
+
indices = movingSorted.map(() => "");
|
|
2729
|
+
let above = below.index;
|
|
2730
|
+
for (let i = movingSorted.length - 1; i >= 0; i--) {
|
|
2731
|
+
const idx = getIndexBetween(belowBelow?.index, above);
|
|
2732
|
+
indices[i] = idx;
|
|
2733
|
+
above = idx;
|
|
2734
|
+
}
|
|
2735
|
+
break;
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
movingSorted.forEach((s, i) => updates.push({ id: s.id, type: s.type, index: indices[i] }));
|
|
2739
|
+
}
|
|
2740
|
+
this.updateShapes(updates);
|
|
2741
|
+
return this;
|
|
2742
|
+
}
|
|
2743
|
+
// ---- selection ---------------------------------------------------------
|
|
2744
|
+
getSelectedShapeIds() {
|
|
2745
|
+
return this.getCurrentPageState().selectedShapeIds;
|
|
2746
|
+
}
|
|
2747
|
+
getSelectedShapes() {
|
|
2748
|
+
return this.getSelectedShapeIds().map((id) => this.getShape(id)).filter((s) => !!s);
|
|
2749
|
+
}
|
|
2750
|
+
getOnlySelectedShape() {
|
|
2751
|
+
const ids = this.getSelectedShapeIds();
|
|
2752
|
+
return ids.length === 1 ? this.getShape(ids[0]) : void 0;
|
|
2753
|
+
}
|
|
2754
|
+
setSelectedShapes(ids) {
|
|
2755
|
+
const next = ids.map((s) => typeof s === "string" ? s : s.id);
|
|
2756
|
+
const prev = this.getSelectedShapeIds();
|
|
2757
|
+
if (prev.length === next.length && prev.every((id, i) => id === next[i])) return this;
|
|
2758
|
+
this.updateCurrentPageState({ selectedShapeIds: next });
|
|
2759
|
+
return this;
|
|
2760
|
+
}
|
|
2761
|
+
select(...ids) {
|
|
2762
|
+
return this.setSelectedShapes(ids);
|
|
2763
|
+
}
|
|
2764
|
+
selectAll() {
|
|
2765
|
+
return this.setSelectedShapes(this.getSortedChildIdsForParent(this.getCurrentPageId()).filter((id) => !this.getShape(id)?.isLocked));
|
|
2766
|
+
}
|
|
2767
|
+
selectNone() {
|
|
2768
|
+
return this.setSelectedShapes([]);
|
|
2769
|
+
}
|
|
2770
|
+
isShapeOrAncestorLocked(shape) {
|
|
2771
|
+
let cur = typeof shape === "string" ? this.getShape(shape) : shape;
|
|
2772
|
+
while (cur) {
|
|
2773
|
+
if (cur.isLocked) return true;
|
|
2774
|
+
cur = this.getShapeParent(cur);
|
|
2775
|
+
}
|
|
2776
|
+
return false;
|
|
2777
|
+
}
|
|
2778
|
+
_selectionPageBounds = computed("editor.selectionPageBounds", () => {
|
|
2779
|
+
const shapes = this.getSelectedShapes();
|
|
2780
|
+
if (shapes.length === 0) return void 0;
|
|
2781
|
+
const boxes = shapes.map((s) => this.getShapePageBounds(s)).filter((b) => !!b);
|
|
2782
|
+
return boxes.length ? Box.Common(boxes) : void 0;
|
|
2783
|
+
});
|
|
2784
|
+
getSelectionPageBounds() {
|
|
2785
|
+
return this._selectionPageBounds.get();
|
|
2786
|
+
}
|
|
2787
|
+
getSelectionRotation() {
|
|
2788
|
+
const shapes = this.getSelectedShapes();
|
|
2789
|
+
if (shapes.length !== 1) return 0;
|
|
2790
|
+
const m = this.getShapePageTransform(shapes[0]);
|
|
2791
|
+
return Math.atan2(m.b, m.a);
|
|
2792
|
+
}
|
|
2793
|
+
getHoveredShapeId() {
|
|
2794
|
+
return this.getCurrentPageState().hoveredShapeId;
|
|
2795
|
+
}
|
|
2796
|
+
getHoveredShape() {
|
|
2797
|
+
const id = this.getHoveredShapeId();
|
|
2798
|
+
return id ? this.getShape(id) : void 0;
|
|
2799
|
+
}
|
|
2800
|
+
setHoveredShape(id) {
|
|
2801
|
+
const next = id === null ? null : typeof id === "string" ? id : id.id;
|
|
2802
|
+
if (this.getHoveredShapeId() === next) return this;
|
|
2803
|
+
return this.updateCurrentPageState({ hoveredShapeId: next });
|
|
2804
|
+
}
|
|
2805
|
+
getEditingShapeId() {
|
|
2806
|
+
return this.getCurrentPageState().editingShapeId;
|
|
2807
|
+
}
|
|
2808
|
+
getEditingShape() {
|
|
2809
|
+
const id = this.getEditingShapeId();
|
|
2810
|
+
return id ? this.getShape(id) : void 0;
|
|
2811
|
+
}
|
|
2812
|
+
setEditingShape(id) {
|
|
2813
|
+
const next = id === null ? null : typeof id === "string" ? id : id.id;
|
|
2814
|
+
const prev = this.getEditingShapeId();
|
|
2815
|
+
if (prev === next) return this;
|
|
2816
|
+
this.updateCurrentPageState({ editingShapeId: next });
|
|
2817
|
+
for (const sid of [prev, next]) {
|
|
2818
|
+
const shape = sid ? this.getShape(sid) : void 0;
|
|
2819
|
+
if (shape) this.writeShapeToEngine(shape, true);
|
|
2820
|
+
}
|
|
2821
|
+
this.flushEngine();
|
|
2822
|
+
this.bumpFrame();
|
|
2823
|
+
if (prev) {
|
|
2824
|
+
const shape = this.getShape(prev);
|
|
2825
|
+
if (shape) this.getShapeUtil(shape).onEditEnd?.(shape);
|
|
2826
|
+
}
|
|
2827
|
+
return this;
|
|
2828
|
+
}
|
|
2829
|
+
setErasingShapes(ids) {
|
|
2830
|
+
return this.updateCurrentPageState({ erasingShapeIds: [...ids] });
|
|
2831
|
+
}
|
|
2832
|
+
getErasingShapeIds() {
|
|
2833
|
+
return this.getCurrentPageState().erasingShapeIds;
|
|
2834
|
+
}
|
|
2835
|
+
// ---- camera / viewport -------------------------------------------------
|
|
2836
|
+
cameraId() {
|
|
2837
|
+
return CameraRecordType.createId(this.getCurrentPageId().slice("page:".length));
|
|
2838
|
+
}
|
|
2839
|
+
getCamera() {
|
|
2840
|
+
return this.store.get(this.cameraId());
|
|
2841
|
+
}
|
|
2842
|
+
getZoomLevel() {
|
|
2843
|
+
return this.getCamera().z;
|
|
2844
|
+
}
|
|
2845
|
+
setCamera(point, _opts = {}) {
|
|
2846
|
+
const cam = this.getCamera();
|
|
2847
|
+
const z = Math.min(this.options.zoomMax, Math.max(this.options.zoomMin, point.z ?? cam.z));
|
|
2848
|
+
const x = point.x ?? cam.x;
|
|
2849
|
+
const y = point.y ?? cam.y;
|
|
2850
|
+
if (cam.x === x && cam.y === y && cam.z === z) return this;
|
|
2851
|
+
this.run(
|
|
2852
|
+
() => {
|
|
2853
|
+
this.store.put([{ ...cam, x, y, z }]);
|
|
2854
|
+
},
|
|
2855
|
+
{ history: "ignore" }
|
|
2856
|
+
);
|
|
2857
|
+
return this;
|
|
2858
|
+
}
|
|
2859
|
+
getViewportScreenBounds() {
|
|
2860
|
+
const b = this.getInstanceState().screenBounds;
|
|
2861
|
+
return new Box(b.x, b.y, b.w, b.h);
|
|
2862
|
+
}
|
|
2863
|
+
getViewportScreenCenter() {
|
|
2864
|
+
const b = this.getViewportScreenBounds();
|
|
2865
|
+
return new Vec(b.w / 2, b.h / 2);
|
|
2866
|
+
}
|
|
2867
|
+
getViewportPageBounds() {
|
|
2868
|
+
const { w, h } = this.getViewportScreenBounds();
|
|
2869
|
+
const { x, y, z } = this.getCamera();
|
|
2870
|
+
return new Box(-x, -y, w / z, h / z);
|
|
2871
|
+
}
|
|
2872
|
+
getViewportPageCenter() {
|
|
2873
|
+
return this.getViewportPageBounds().center;
|
|
2874
|
+
}
|
|
2875
|
+
updateViewportScreenBounds(bounds, center = false) {
|
|
2876
|
+
const prev = this.getViewportScreenBounds();
|
|
2877
|
+
const next = Box.From(bounds);
|
|
2878
|
+
if (prev.x === next.x && prev.y === next.y && prev.w === next.w && prev.h === next.h) return this;
|
|
2879
|
+
this.run(
|
|
2880
|
+
() => {
|
|
2881
|
+
this.updateInstanceState({ screenBounds: next.toJson() });
|
|
2882
|
+
if (center) {
|
|
2883
|
+
const cam = this.getCamera();
|
|
2884
|
+
this.setCamera({ x: cam.x + (next.w - prev.w) / 2 / cam.z, y: cam.y + (next.h - prev.h) / 2 / cam.z });
|
|
2885
|
+
}
|
|
2886
|
+
},
|
|
2887
|
+
{ history: "ignore" }
|
|
2888
|
+
);
|
|
2889
|
+
return this;
|
|
2890
|
+
}
|
|
2891
|
+
/** Screen (container-relative) → page. */
|
|
2892
|
+
screenToPage(point) {
|
|
2893
|
+
const { x, y, z } = this.getCamera();
|
|
2894
|
+
return new Vec(point.x / z - x, point.y / z - y);
|
|
2895
|
+
}
|
|
2896
|
+
/** Page → screen (container-relative). */
|
|
2897
|
+
pageToScreen(point) {
|
|
2898
|
+
const { x, y, z } = this.getCamera();
|
|
2899
|
+
return new Vec((point.x + x) * z, (point.y + y) * z);
|
|
2900
|
+
}
|
|
2901
|
+
/** Zoom keeping the given screen point fixed. */
|
|
2902
|
+
zoomToPointAt(screenPoint, nextZoom) {
|
|
2903
|
+
const cam = this.getCamera();
|
|
2904
|
+
const z = Math.min(this.options.zoomMax, Math.max(this.options.zoomMin, nextZoom));
|
|
2905
|
+
const px = screenPoint.x / cam.z - cam.x;
|
|
2906
|
+
const py = screenPoint.y / cam.z - cam.y;
|
|
2907
|
+
return this.setCamera({ x: screenPoint.x / z - px, y: screenPoint.y / z - py, z });
|
|
2908
|
+
}
|
|
2909
|
+
zoomIn(point = this.getViewportScreenCenter()) {
|
|
2910
|
+
const z = this.getZoomLevel();
|
|
2911
|
+
const next = this.options.zoomSteps.find((s) => s > z + 1e-6) ?? this.options.zoomMax;
|
|
2912
|
+
return this.zoomToPointAt(point, next);
|
|
2913
|
+
}
|
|
2914
|
+
zoomOut(point = this.getViewportScreenCenter()) {
|
|
2915
|
+
const z = this.getZoomLevel();
|
|
2916
|
+
const next = [...this.options.zoomSteps].reverse().find((s) => s < z - 1e-6) ?? this.options.zoomMin;
|
|
2917
|
+
return this.zoomToPointAt(point, next);
|
|
2918
|
+
}
|
|
2919
|
+
resetZoom(point = this.getViewportScreenCenter()) {
|
|
2920
|
+
return this.zoomToPointAt(point, 1);
|
|
2921
|
+
}
|
|
2922
|
+
zoomToBounds(bounds, opts = {}) {
|
|
2923
|
+
const vp = this.getViewportScreenBounds();
|
|
2924
|
+
const inset = opts.inset ?? Math.min(256, vp.w * 0.28);
|
|
2925
|
+
let z = Math.min((vp.w - inset) / bounds.w, (vp.h - inset) / bounds.h);
|
|
2926
|
+
if (opts.targetZoom !== void 0) z = Math.min(z, opts.targetZoom);
|
|
2927
|
+
z = Math.min(this.options.zoomMax, Math.max(this.options.zoomMin, z));
|
|
2928
|
+
return this.setCamera({
|
|
2929
|
+
x: -bounds.x + (vp.w / z - bounds.w) / 2,
|
|
2930
|
+
y: -bounds.y + (vp.h / z - bounds.h) / 2,
|
|
2931
|
+
z
|
|
2932
|
+
});
|
|
2933
|
+
}
|
|
2934
|
+
zoomToFit() {
|
|
2935
|
+
const b = this.getCurrentPageBounds();
|
|
2936
|
+
return b && b.w > 0 && b.h > 0 ? this.zoomToBounds(b) : this;
|
|
2937
|
+
}
|
|
2938
|
+
zoomToSelection() {
|
|
2939
|
+
const b = this.getSelectionPageBounds();
|
|
2940
|
+
return b && b.w > 0 && b.h > 0 ? this.zoomToBounds(b, { targetZoom: Math.max(1, this.getZoomLevel()) }) : this;
|
|
2941
|
+
}
|
|
2942
|
+
centerOnPoint(point) {
|
|
2943
|
+
const vp = this.getViewportScreenBounds();
|
|
2944
|
+
const z = this.getZoomLevel();
|
|
2945
|
+
return this.setCamera({ x: -point.x + vp.w / 2 / z, y: -point.y + vp.h / 2 / z });
|
|
2946
|
+
}
|
|
2947
|
+
pan(offsetScreen) {
|
|
2948
|
+
const cam = this.getCamera();
|
|
2949
|
+
return this.setCamera({ x: cam.x + offsetScreen.x / cam.z, y: cam.y + offsetScreen.y / cam.z });
|
|
2950
|
+
}
|
|
2951
|
+
// ---- tools -------------------------------------------------------------
|
|
2952
|
+
getCurrentTool() {
|
|
2953
|
+
return this.root.getCurrent();
|
|
2954
|
+
}
|
|
2955
|
+
getCurrentToolId() {
|
|
2956
|
+
const tool = this.root.getCurrent();
|
|
2957
|
+
return tool?.id ?? "";
|
|
2958
|
+
}
|
|
2959
|
+
setCurrentTool(id, info = {}) {
|
|
2960
|
+
if (this.getCurrentToolId() === id && !info["force"]) return this;
|
|
2961
|
+
this.root.transition(id, info);
|
|
2962
|
+
return this;
|
|
2963
|
+
}
|
|
2964
|
+
getPath() {
|
|
2965
|
+
return this.root.getPath();
|
|
2966
|
+
}
|
|
2967
|
+
isIn(path) {
|
|
2968
|
+
const full = this.getPath();
|
|
2969
|
+
const target = path.startsWith("root.") ? path : `root.${path}`;
|
|
2970
|
+
return full === target || full.startsWith(`${target}.`);
|
|
2971
|
+
}
|
|
2972
|
+
isInAny(...paths) {
|
|
2973
|
+
return paths.some((p) => this.isIn(p));
|
|
2974
|
+
}
|
|
2975
|
+
getStateDescendant(path) {
|
|
2976
|
+
return this.root.getDescendant(path.replace(/^root\./, ""));
|
|
2977
|
+
}
|
|
2978
|
+
cancel() {
|
|
2979
|
+
this.dispatch({ type: "cancel", name: "cancel", ...this.modifierState() });
|
|
2980
|
+
return this;
|
|
2981
|
+
}
|
|
2982
|
+
complete() {
|
|
2983
|
+
this.dispatch({ type: "complete", name: "complete", ...this.modifierState() });
|
|
2984
|
+
return this;
|
|
2985
|
+
}
|
|
2986
|
+
interrupt() {
|
|
2987
|
+
this.dispatch({ type: "interrupt", name: "interrupt", ...this.modifierState() });
|
|
2988
|
+
return this;
|
|
2989
|
+
}
|
|
2990
|
+
modifierState() {
|
|
2991
|
+
const i = this.inputs;
|
|
2992
|
+
return { shiftKey: i.shiftKey, altKey: i.altKey, ctrlKey: i.ctrlKey, metaKey: i.metaKey, accelKey: i.accelKey };
|
|
2993
|
+
}
|
|
2994
|
+
// ---- input dispatch ----------------------------------------------------
|
|
2995
|
+
/** Route an event to the tool tree, updating `inputs` first. */
|
|
2996
|
+
dispatch(info) {
|
|
2997
|
+
if (this.getIsDisposed()) return this;
|
|
2998
|
+
const inputs = this.inputs;
|
|
2999
|
+
inputs.shiftKey = info.shiftKey;
|
|
3000
|
+
inputs.altKey = info.altKey;
|
|
3001
|
+
inputs.ctrlKey = info.ctrlKey;
|
|
3002
|
+
inputs.metaKey = info.metaKey;
|
|
3003
|
+
inputs.accelKey = info.accelKey;
|
|
3004
|
+
switch (info.type) {
|
|
3005
|
+
case "pointer": {
|
|
3006
|
+
this.updatePointer(info);
|
|
3007
|
+
break;
|
|
3008
|
+
}
|
|
3009
|
+
case "click": {
|
|
3010
|
+
const screen = new Vec(info.point.x, info.point.y);
|
|
3011
|
+
inputs.currentScreenPoint = screen;
|
|
3012
|
+
inputs.currentPagePoint = this.screenToPage(screen);
|
|
3013
|
+
break;
|
|
3014
|
+
}
|
|
3015
|
+
case "keyboard": {
|
|
3016
|
+
if (info.name === "key_down") inputs.keys.add(info.code);
|
|
3017
|
+
else if (info.name === "key_up") inputs.keys.delete(info.code);
|
|
3018
|
+
break;
|
|
3019
|
+
}
|
|
3020
|
+
case "wheel": {
|
|
3021
|
+
this.handleWheel(info);
|
|
3022
|
+
break;
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
this.root.handleEvent(info);
|
|
3026
|
+
this.emit("event", info);
|
|
3027
|
+
return this;
|
|
3028
|
+
}
|
|
3029
|
+
updatePointer(info) {
|
|
3030
|
+
const inputs = this.inputs;
|
|
3031
|
+
const screen = new Vec(info.point.x, info.point.y);
|
|
3032
|
+
const page = this.screenToPage(screen);
|
|
3033
|
+
inputs.previousScreenPoint = inputs.currentScreenPoint;
|
|
3034
|
+
inputs.previousPagePoint = inputs.currentPagePoint;
|
|
3035
|
+
inputs.currentScreenPoint = screen;
|
|
3036
|
+
inputs.currentPagePoint = page;
|
|
3037
|
+
inputs.isPen = info.isPen;
|
|
3038
|
+
switch (info.name) {
|
|
3039
|
+
case "pointer_down": {
|
|
3040
|
+
inputs.buttons.add(info.button);
|
|
3041
|
+
inputs.isPointing = true;
|
|
3042
|
+
inputs.isDragging = false;
|
|
3043
|
+
inputs.originScreenPoint = screen.clone();
|
|
3044
|
+
inputs.originPagePoint = page.clone();
|
|
3045
|
+
break;
|
|
3046
|
+
}
|
|
3047
|
+
case "pointer_move": {
|
|
3048
|
+
if (inputs.isPointing && !inputs.isDragging) {
|
|
3049
|
+
const d2 = Vec.Dist2(inputs.originScreenPoint, screen);
|
|
3050
|
+
if (d2 > this.options.dragDistanceSquared * (info.isPen ? 0.25 : 1)) inputs.isDragging = true;
|
|
3051
|
+
}
|
|
3052
|
+
break;
|
|
3053
|
+
}
|
|
3054
|
+
case "pointer_up": {
|
|
3055
|
+
inputs.buttons.delete(info.button);
|
|
3056
|
+
inputs.isPointing = false;
|
|
3057
|
+
inputs.isDragging = false;
|
|
3058
|
+
break;
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
handleWheel(info) {
|
|
3063
|
+
if (info.ctrlKey || info.metaKey) {
|
|
3064
|
+
const cam = this.getCamera();
|
|
3065
|
+
const factor = Math.exp(-info.delta.y * 25e-4);
|
|
3066
|
+
this.zoomToPointAt(info.point, cam.z * factor);
|
|
3067
|
+
} else {
|
|
3068
|
+
this.pan({ x: -info.delta.x, y: -info.delta.y });
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
// ---- engine mirror -----------------------------------------------------
|
|
3072
|
+
kindId(type) {
|
|
3073
|
+
return this.kindIds.get(type) ?? 0;
|
|
3074
|
+
}
|
|
3075
|
+
/** Apply pending engine commands. Cheap when nothing is pending. */
|
|
3076
|
+
flushEngine() {
|
|
3077
|
+
if (this.engine.cmd.pending > 0) this.engine.cmd.flush();
|
|
3078
|
+
}
|
|
3079
|
+
syncChanges(changes) {
|
|
3080
|
+
const pageId = this.getCurrentPageId();
|
|
3081
|
+
if (pageId !== this.syncedPageId) {
|
|
3082
|
+
this.syncPage(pageId);
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
let dirty = false;
|
|
3086
|
+
const rebind = /* @__PURE__ */ new Set();
|
|
3087
|
+
for (const rec of [...Object.values(changes.added), ...Object.values(changes.removed)]) {
|
|
3088
|
+
if (isBinding(rec)) rebind.add(rec.fromId);
|
|
3089
|
+
}
|
|
3090
|
+
for (const [, next] of Object.values(changes.updated)) {
|
|
3091
|
+
if (isBinding(next)) rebind.add(next.fromId);
|
|
3092
|
+
}
|
|
3093
|
+
for (const rec of Object.values(changes.removed)) {
|
|
3094
|
+
if (rec.typeName !== "shape") continue;
|
|
3095
|
+
const h = this.handles.release(rec.id);
|
|
3096
|
+
if (h !== void 0) {
|
|
3097
|
+
this.engine.cmd.remove(h);
|
|
3098
|
+
dirty = true;
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
for (const rec of Object.values(changes.added)) {
|
|
3102
|
+
if (rec.typeName !== "shape") continue;
|
|
3103
|
+
if (this.getAncestorPageId(rec) !== pageId) continue;
|
|
3104
|
+
this.writeShapeToEngine(rec, true);
|
|
3105
|
+
dirty = true;
|
|
3106
|
+
}
|
|
3107
|
+
for (const [prev, next] of Object.values(changes.updated)) {
|
|
3108
|
+
if (next.typeName !== "shape" || prev.typeName !== "shape") continue;
|
|
3109
|
+
const onPage = this.getAncestorPageId(next) === pageId;
|
|
3110
|
+
if (!onPage) {
|
|
3111
|
+
const h = this.handles.release(next.id);
|
|
3112
|
+
if (h !== void 0) {
|
|
3113
|
+
this.engine.cmd.remove(h);
|
|
3114
|
+
dirty = true;
|
|
3115
|
+
}
|
|
3116
|
+
continue;
|
|
3117
|
+
}
|
|
3118
|
+
const geometryChanged = prev.props !== next.props || prev.type !== next.type || prev.opacity !== next.opacity;
|
|
3119
|
+
this.writeShapeToEngine(next, geometryChanged || this.handles.peek(next.id) === void 0);
|
|
3120
|
+
rebind.delete(next.id);
|
|
3121
|
+
dirty = true;
|
|
3122
|
+
}
|
|
3123
|
+
const touched = [...Object.values(changes.added), ...Object.values(changes.removed), ...Object.values(changes.updated).map(([, n]) => n)];
|
|
3124
|
+
for (const rec of touched) {
|
|
3125
|
+
if (rec.typeName !== "shape") continue;
|
|
3126
|
+
let parent = isShapeId(rec.parentId) ? this.getShape(rec.parentId) : void 0;
|
|
3127
|
+
while (parent && parent.type === "group") {
|
|
3128
|
+
rebind.add(parent.id);
|
|
3129
|
+
parent = isShapeId(parent.parentId) ? this.getShape(parent.parentId) : void 0;
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
for (const id of rebind) {
|
|
3133
|
+
const shape = this.getShape(id);
|
|
3134
|
+
if (shape && this.getAncestorPageId(shape) === pageId && !(id in changes.added)) {
|
|
3135
|
+
this.writeShapeToEngine(shape, true);
|
|
3136
|
+
dirty = true;
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
if (dirty) {
|
|
3140
|
+
this.flushEngine();
|
|
3141
|
+
this.bumpFrame();
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
/** Clear the engine and upload every shape on `pageId`. */
|
|
3145
|
+
syncPage(pageId) {
|
|
3146
|
+
this.syncedPageId = pageId;
|
|
3147
|
+
this.handles.clear();
|
|
3148
|
+
this.engine.cmd.clear();
|
|
3149
|
+
for (const shape of this.getCurrentPageShapesSorted()) {
|
|
3150
|
+
this.writeShapeToEngine(shape, true);
|
|
3151
|
+
}
|
|
3152
|
+
this.flushEngine();
|
|
3153
|
+
this.bumpFrame();
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* Write one shape to the engine, isolating it from its siblings.
|
|
3157
|
+
*
|
|
3158
|
+
* A shape util can throw on a shape it does not fully understand (a prop a
|
|
3159
|
+
* file left out, a value it did not expect). That must cost that one shape,
|
|
3160
|
+
* never the rest of the page: the failure is logged once and the shape is
|
|
3161
|
+
* skipped, so a single bad record can no longer blank the canvas.
|
|
3162
|
+
*/
|
|
3163
|
+
writeShapeToEngine(shape, withGeometry) {
|
|
3164
|
+
try {
|
|
3165
|
+
this.writeShapeToEngineUnsafe(shape, withGeometry);
|
|
3166
|
+
this.brokenShapeIds.delete(shape.id);
|
|
3167
|
+
} catch (error) {
|
|
3168
|
+
if (!this.brokenShapeIds.has(shape.id)) {
|
|
3169
|
+
this.brokenShapeIds.add(shape.id);
|
|
3170
|
+
console.warn(`mocanvas: skipping shape ${shape.id} (${shape.type}); its shape util threw`, error);
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
writeShapeToEngineUnsafe(shape, withGeometry) {
|
|
3175
|
+
const util = this.shapeUtils[shape.type];
|
|
3176
|
+
const h = this.handles.handle(shape.id);
|
|
3177
|
+
const parent = isShapeId(shape.parentId) ? this.handles.handle(shape.parentId) : 0;
|
|
3178
|
+
const [zlo, zhi] = indexKeyToZKey(shape.index);
|
|
3179
|
+
let flags = 0;
|
|
3180
|
+
if (shape.isLocked) flags |= FLAG.LOCKED;
|
|
3181
|
+
let style = null;
|
|
3182
|
+
let geometry;
|
|
3183
|
+
if (util) {
|
|
3184
|
+
style = this.textures.withOwner(shape.id, () => util.getRenderStyle(shape));
|
|
3185
|
+
if (style === null || util.needsOverlay(shape)) flags |= FLAG.OVERLAY;
|
|
3186
|
+
else if (util.hasOverlayLabel(shape)) flags |= FLAG.LABEL;
|
|
3187
|
+
if (util.isClipShape(shape)) flags |= FLAG.CLIP;
|
|
3188
|
+
geometry = util.getGeometry(shape);
|
|
3189
|
+
if (geometry.isClosed && !geometry.isFilled) flags |= FLAG.NO_FILL;
|
|
3190
|
+
} else {
|
|
3191
|
+
flags |= FLAG.OVERLAY;
|
|
3192
|
+
}
|
|
3193
|
+
const b = geometry?.bounds;
|
|
3194
|
+
this.engine.cmd.upsert(h, this.kindId(shape.type), parent, zlo, zhi, flags, shape.x, shape.y, shape.rotation, b?.w ?? 0, b?.h ?? 0);
|
|
3195
|
+
if (withGeometry && geometry) {
|
|
3196
|
+
this.engine.cmd.setGeometry(h, geometry.toPathWords());
|
|
3197
|
+
if (style) {
|
|
3198
|
+
let seed = 2166136261;
|
|
3199
|
+
for (let k = 0; k < shape.id.length; k++) seed = Math.imul(seed ^ shape.id.charCodeAt(k), 16777619);
|
|
3200
|
+
this.engine.cmd.setStyle(h, { ...style, opacity: style.opacity * shape.opacity, seed: style.seed ?? seed >>> 0 });
|
|
3201
|
+
this.engine.cmd.setTexture(h, style.texture ?? 0);
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
// ---- rendering ---------------------------------------------------------
|
|
3206
|
+
/** Build and draw one frame. Called by the canvas component inside rAF. */
|
|
3207
|
+
renderFrame(backend) {
|
|
3208
|
+
const t0 = performance.now();
|
|
3209
|
+
this.textures.setBackend(backend);
|
|
3210
|
+
this.syncTextureResolution();
|
|
3211
|
+
this.flushEngine();
|
|
3212
|
+
const cam = this.getCamera();
|
|
3213
|
+
const vp = this.getViewportScreenBounds();
|
|
3214
|
+
const camState = { x: cam.x, y: cam.y, z: cam.z };
|
|
3215
|
+
const frame = this.engine.frame(camState, vp.w, vp.h);
|
|
3216
|
+
backend.draw(frame, camState, { background: this.options.backgroundColor });
|
|
3217
|
+
const overlay = EngineBridge.readOverlay(frame.overlay);
|
|
3218
|
+
const ids = [];
|
|
3219
|
+
const clips = [];
|
|
3220
|
+
for (const o of overlay) {
|
|
3221
|
+
const id = this.handles.id(o.handle);
|
|
3222
|
+
if (id) {
|
|
3223
|
+
ids.push(id);
|
|
3224
|
+
clips.push(o.clip);
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
unsafe__withoutCapture(() => {
|
|
3228
|
+
const prev = this._overlayShapeIds.get();
|
|
3229
|
+
if (prev.length !== ids.length || prev.some((id, i) => id !== ids[i])) this._overlayShapeIds.set(ids);
|
|
3230
|
+
if (!sameClips(this._overlayClips.get(), clips)) this._overlayClips.set(clips);
|
|
3231
|
+
const ms = performance.now() - t0;
|
|
3232
|
+
this._lastFrame.set({ drawn: frame.drawn, culled: frame.culled, ms });
|
|
3233
|
+
this.emit("frame", { drawn: frame.drawn, culled: frame.culled, ms });
|
|
3234
|
+
});
|
|
3235
|
+
return frame;
|
|
3236
|
+
}
|
|
3237
|
+
// ---- textures ----------------------------------------------------------
|
|
3238
|
+
/**
|
|
3239
|
+
* Device-pixel scale at which rasterized textures (text labels) should be
|
|
3240
|
+
* drawn: the device pixel ratio times the zoom, bucketed to powers of two.
|
|
3241
|
+
*/
|
|
3242
|
+
getTextureResolution() {
|
|
3243
|
+
return bucketTextureResolution(this.getZoomLevel(), this.getInstanceState().devicePixelRatio);
|
|
3244
|
+
}
|
|
3245
|
+
textureResolution = 0;
|
|
3246
|
+
/** Re-derive texture-backed styles when the resolution bucket changes. */
|
|
3247
|
+
syncTextureResolution() {
|
|
3248
|
+
const next = this.getTextureResolution();
|
|
3249
|
+
if (next === this.textureResolution) return;
|
|
3250
|
+
this.textureResolution = next;
|
|
3251
|
+
this.rewriteTextureOwners(this.textures.getAllOwners());
|
|
3252
|
+
}
|
|
3253
|
+
registerTextureSideEffects() {
|
|
3254
|
+
this.disposables.push(
|
|
3255
|
+
this.sideEffects.registerAfterDeleteHandler("shape", (shape) => {
|
|
3256
|
+
this.textures.releaseOwner(shape.id);
|
|
3257
|
+
})
|
|
3258
|
+
);
|
|
3259
|
+
}
|
|
3260
|
+
/** A texture finished (or failed) loading: re-write its shapes and redraw. */
|
|
3261
|
+
onTexturesChanged(keys) {
|
|
3262
|
+
const owners = /* @__PURE__ */ new Set();
|
|
3263
|
+
for (const key of keys) for (const owner of this.textures.getOwners(key)) owners.add(owner);
|
|
3264
|
+
this.rewriteTextureOwners(owners);
|
|
3265
|
+
this.bumpFrame();
|
|
3266
|
+
}
|
|
3267
|
+
rewriteTextureOwners(owners) {
|
|
3268
|
+
let dirty = false;
|
|
3269
|
+
for (const owner of owners) {
|
|
3270
|
+
const shape = this.getShape(owner);
|
|
3271
|
+
if (!shape || this.getAncestorPageId(shape) !== this.syncedPageId) continue;
|
|
3272
|
+
this.writeShapeToEngine(shape, true);
|
|
3273
|
+
dirty = true;
|
|
3274
|
+
}
|
|
3275
|
+
if (dirty) this.flushEngine();
|
|
3276
|
+
}
|
|
3277
|
+
// ---- misc helpers ------------------------------------------------------
|
|
3278
|
+
/** Degrees → radians. */
|
|
3279
|
+
static degToRad(d) {
|
|
3280
|
+
return d * RAD_PER_DEG;
|
|
3281
|
+
}
|
|
3282
|
+
// ---- assets ------------------------------------------------------------
|
|
3283
|
+
_allAssets = computed(
|
|
3284
|
+
"editor.allAssets",
|
|
3285
|
+
() => this.store.query.records("asset").get()
|
|
3286
|
+
);
|
|
3287
|
+
getAsset(id) {
|
|
3288
|
+
const assetId = typeof id === "string" ? id : id.id;
|
|
3289
|
+
return this.store.get(assetId);
|
|
3290
|
+
}
|
|
3291
|
+
/** Every asset in the document (assets are not per page). */
|
|
3292
|
+
getAssets() {
|
|
3293
|
+
return this._allAssets.get();
|
|
3294
|
+
}
|
|
3295
|
+
createAsset(asset) {
|
|
3296
|
+
return this.createAssets([asset]);
|
|
3297
|
+
}
|
|
3298
|
+
createAssets(assets) {
|
|
3299
|
+
if (assets.length === 0) return this;
|
|
3300
|
+
this.run(() => {
|
|
3301
|
+
const records = assets.map(
|
|
3302
|
+
(a) => AssetRecordType.create({
|
|
3303
|
+
id: a.id ?? AssetRecordType.createId(),
|
|
3304
|
+
type: a.type,
|
|
3305
|
+
props: { ...a.props },
|
|
3306
|
+
meta: { ...a.meta ?? {} }
|
|
3307
|
+
})
|
|
3308
|
+
);
|
|
3309
|
+
this.store.put(records);
|
|
3310
|
+
});
|
|
3311
|
+
return this;
|
|
3312
|
+
}
|
|
3313
|
+
updateAsset(partial) {
|
|
3314
|
+
return this.updateAssets([partial]);
|
|
3315
|
+
}
|
|
3316
|
+
updateAssets(partials) {
|
|
3317
|
+
this.run(() => {
|
|
3318
|
+
const records = [];
|
|
3319
|
+
for (const partial of partials) {
|
|
3320
|
+
const prev = this.getAsset(partial.id);
|
|
3321
|
+
if (!prev) continue;
|
|
3322
|
+
records.push({
|
|
3323
|
+
...prev,
|
|
3324
|
+
props: partial.props ? { ...prev.props, ...partial.props } : prev.props,
|
|
3325
|
+
meta: partial.meta ? { ...prev.meta, ...partial.meta } : prev.meta
|
|
3326
|
+
});
|
|
3327
|
+
}
|
|
3328
|
+
if (records.length) this.store.put(records);
|
|
3329
|
+
});
|
|
3330
|
+
return this;
|
|
3331
|
+
}
|
|
3332
|
+
deleteAsset(id) {
|
|
3333
|
+
return this.deleteAssets([id]);
|
|
3334
|
+
}
|
|
3335
|
+
deleteAssets(ids) {
|
|
3336
|
+
const assetIds = ids.map((a) => typeof a === "string" ? a : a.id).filter((id) => this.store.has(id));
|
|
3337
|
+
if (assetIds.length === 0) return this;
|
|
3338
|
+
this.run(() => this.store.remove(assetIds));
|
|
3339
|
+
return this;
|
|
3340
|
+
}
|
|
3341
|
+
// ---- external content --------------------------------------------------
|
|
3342
|
+
// Handlers are keyed by content type, so each entry only ever sees the member
|
|
3343
|
+
// of the union it registered for; they are stored under the widest handler type.
|
|
3344
|
+
externalContentHandlers = /* @__PURE__ */ new Map();
|
|
3345
|
+
externalAssetHandlers = /* @__PURE__ */ new Map();
|
|
3346
|
+
/**
|
|
3347
|
+
* Register the handler for one kind of dropped/pasted content. Replaces any
|
|
3348
|
+
* previous handler for that type; `null` removes it. Returns a function that
|
|
3349
|
+
* removes the handler again (only if it is still the registered one).
|
|
3350
|
+
*/
|
|
3351
|
+
registerExternalContentHandler(type, handler) {
|
|
3352
|
+
const entry = handler;
|
|
3353
|
+
if (entry) this.externalContentHandlers.set(type, entry);
|
|
3354
|
+
else this.externalContentHandlers.delete(type);
|
|
3355
|
+
return () => {
|
|
3356
|
+
if (entry && this.externalContentHandlers.get(type) === entry) this.externalContentHandlers.delete(type);
|
|
3357
|
+
};
|
|
3358
|
+
}
|
|
3359
|
+
/** Register how an asset record is produced from a file or url. Returns a function that removes it again. */
|
|
3360
|
+
registerExternalAssetHandler(type, handler) {
|
|
3361
|
+
const entry = handler;
|
|
3362
|
+
if (entry) this.externalAssetHandlers.set(type, entry);
|
|
3363
|
+
else this.externalAssetHandlers.delete(type);
|
|
3364
|
+
return () => {
|
|
3365
|
+
if (entry && this.externalAssetHandlers.get(type) === entry) this.externalAssetHandlers.delete(type);
|
|
3366
|
+
};
|
|
3367
|
+
}
|
|
3368
|
+
hasExternalContentHandler(type) {
|
|
3369
|
+
return this.externalContentHandlers.has(type);
|
|
3370
|
+
}
|
|
3371
|
+
hasExternalAssetHandler(type) {
|
|
3372
|
+
return this.externalAssetHandlers.has(type);
|
|
3373
|
+
}
|
|
3374
|
+
/**
|
|
3375
|
+
* Handle content dropped or pasted onto the canvas by dispatching to the
|
|
3376
|
+
* registered handler for `info.type`. Resolves once the handler is done;
|
|
3377
|
+
* resolves immediately when no handler is registered.
|
|
3378
|
+
*/
|
|
3379
|
+
async putExternalContent(info) {
|
|
3380
|
+
const handler = this.externalContentHandlers.get(info.type);
|
|
3381
|
+
if (!handler) return;
|
|
3382
|
+
await handler(info);
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
3385
|
+
* Produce (but do not store) an asset record for a file or url through the
|
|
3386
|
+
* registered asset handler. `undefined` when there is no handler or the
|
|
3387
|
+
* handler declines the content.
|
|
3388
|
+
*/
|
|
3389
|
+
async getAssetForExternalContent(info) {
|
|
3390
|
+
const handler = this.externalAssetHandlers.get(info.type);
|
|
3391
|
+
if (!handler) return void 0;
|
|
3392
|
+
return await handler(info);
|
|
3393
|
+
}
|
|
3394
|
+
// ---- presence ----------------------------------------------------------
|
|
3395
|
+
// Everything below is about other people in the same document. It is kept in
|
|
3396
|
+
// one block so the collaboration layer (`@mocanvas/sync`) has a single seam.
|
|
3397
|
+
/**
|
|
3398
|
+
* The local person's identity: a random id for this session plus a name and
|
|
3399
|
+
* a colour that can be changed at any time. Session-only, never persisted.
|
|
3400
|
+
*/
|
|
3401
|
+
user = createUserPreferences();
|
|
3402
|
+
/** Presence records of everyone else in the room, in arrival order. */
|
|
3403
|
+
getCollaborators() {
|
|
3404
|
+
const me = this.user.getId();
|
|
3405
|
+
const records = this.store.query.records("instance_presence").get();
|
|
3406
|
+
return records.filter((p) => p.userId !== me);
|
|
3407
|
+
}
|
|
3408
|
+
/** The subset of `getCollaborators()` looking at the page we are on. */
|
|
3409
|
+
getCollaboratorsOnCurrentPage() {
|
|
3410
|
+
const pageId = this.getCurrentPageId();
|
|
3411
|
+
return this.getCollaborators().filter((p) => p.currentPageId === pageId);
|
|
3412
|
+
}
|
|
3413
|
+
// ---- resizing ----------------------------------------------------------
|
|
3414
|
+
/**
|
|
3415
|
+
* Scale one shape by `scale` about a point, letting its `ShapeUtil.onResize`
|
|
3416
|
+
* produce the prop change — the same contract the select tool's resize state
|
|
3417
|
+
* uses. The shape's origin is moved by the same scale about `scaleOrigin`
|
|
3418
|
+
* (its page bounds center by default), measured in a frame rotated by
|
|
3419
|
+
* `scaleAxisRotation`.
|
|
3420
|
+
*
|
|
3421
|
+
* Locked shapes and shapes whose util says `canResize` is false are left
|
|
3422
|
+
* alone. `onResizeStart` / `onResizeEnd` bracket the change; the util is
|
|
3423
|
+
* told the resize came from the `bottom_right` handle.
|
|
3424
|
+
*/
|
|
3425
|
+
resizeShape(id, scale, options = {}) {
|
|
3426
|
+
const shape = this.getShape(id);
|
|
3427
|
+
if (!shape || shape.isLocked) return this;
|
|
3428
|
+
const util = this.getShapeUtil(shape);
|
|
3429
|
+
if (!util.canResize(shape)) return this;
|
|
3430
|
+
if (!Number.isFinite(scale.x) || !Number.isFinite(scale.y)) return this;
|
|
3431
|
+
const initialBounds = options.initialBounds ?? this.getShapeGeometryBounds(shape)?.toJson();
|
|
3432
|
+
if (!initialBounds) return this;
|
|
3433
|
+
let scaleX = scale.x;
|
|
3434
|
+
let scaleY = scale.y;
|
|
3435
|
+
if (options.isAspectRatioLocked ?? util.isAspectRatioLocked(shape)) {
|
|
3436
|
+
const s = Math.max(Math.abs(scaleX), Math.abs(scaleY));
|
|
3437
|
+
scaleX = Math.sign(scaleX || 1) * s;
|
|
3438
|
+
scaleY = Math.sign(scaleY || 1) * s;
|
|
3439
|
+
}
|
|
3440
|
+
const m = this.getShapePageTransform(shape);
|
|
3441
|
+
const pagePos = new Vec(m.e, m.f);
|
|
3442
|
+
const origin = options.scaleOrigin ?? this.getShapePageBounds(shape)?.center ?? pagePos;
|
|
3443
|
+
const axis = options.scaleAxisRotation ?? 0;
|
|
3444
|
+
const inFrame = Vec.Rot(Vec.Sub(pagePos, origin), -axis);
|
|
3445
|
+
const scaled = new Vec(inFrame.x * scaleX, inFrame.y * scaleY);
|
|
3446
|
+
const newPoint = this.getPointInParentSpace(shape, Vec.Add(origin, Vec.Rot(scaled, axis)));
|
|
3447
|
+
this.run(() => {
|
|
3448
|
+
util.onResizeStart?.(shape);
|
|
3449
|
+
const change = util.onResize?.(shape, {
|
|
3450
|
+
newPoint,
|
|
3451
|
+
handle: "bottom_right",
|
|
3452
|
+
mode: options.mode ?? "scale_shape",
|
|
3453
|
+
scaleX,
|
|
3454
|
+
scaleY,
|
|
3455
|
+
initialBounds,
|
|
3456
|
+
initialShape: shape
|
|
3457
|
+
});
|
|
3458
|
+
this.updateShapes([{ id: shape.id, type: shape.type, x: newPoint.x, y: newPoint.y, ...change ?? {} }]);
|
|
3459
|
+
const current = this.getShape(shape.id);
|
|
3460
|
+
if (current) util.onResizeEnd?.(shape, current);
|
|
3461
|
+
});
|
|
3462
|
+
return this;
|
|
3463
|
+
}
|
|
3464
|
+
/**
|
|
3465
|
+
* Scale several shapes by the same factor about one point — by default the
|
|
3466
|
+
* center of their common page bounds, so the group scales as a unit.
|
|
3467
|
+
*/
|
|
3468
|
+
resizeShapes(ids, scale, options = {}) {
|
|
3469
|
+
const shapes = ids.map((id) => this.getShape(id)).filter((s) => !!s);
|
|
3470
|
+
if (shapes.length === 0) return this;
|
|
3471
|
+
const boxes = shapes.map((s) => this.getShapePageBounds(s)).filter((b) => !!b);
|
|
3472
|
+
if (boxes.length === 0) return this;
|
|
3473
|
+
const scaleOrigin = options.scaleOrigin ?? Box.Common(boxes).center;
|
|
3474
|
+
this.run(() => {
|
|
3475
|
+
for (const shape of shapes) this.resizeShape(shape.id, scale, { ...options, scaleOrigin });
|
|
3476
|
+
});
|
|
3477
|
+
return this;
|
|
3478
|
+
}
|
|
3479
|
+
/**
|
|
3480
|
+
* Resize shapes so each one spans the common bounds of all of them on one
|
|
3481
|
+
* axis. Every shape given contributes to the common bounds, but only the
|
|
3482
|
+
* unlocked, resizable ones are stretched.
|
|
3483
|
+
*/
|
|
3484
|
+
stretchShapes(ids, operation) {
|
|
3485
|
+
const items = [];
|
|
3486
|
+
for (const id of ids) {
|
|
3487
|
+
const shape = this.getShape(id);
|
|
3488
|
+
if (!shape) continue;
|
|
3489
|
+
const b = this.getShapePageBounds(shape);
|
|
3490
|
+
if (b) items.push({ shape, b });
|
|
3491
|
+
}
|
|
3492
|
+
if (items.length < 2) return this;
|
|
3493
|
+
const common = Box.Common(items.map((it) => it.b));
|
|
3494
|
+
const horizontal = operation === "horizontal";
|
|
3495
|
+
this.run(() => {
|
|
3496
|
+
for (const { shape, b } of items) {
|
|
3497
|
+
if (shape.isLocked || !this.getShapeUtil(shape).canResize(shape)) continue;
|
|
3498
|
+
const scale = horizontal ? { x: b.w === 0 ? 1 : common.w / b.w, y: 1 } : { x: 1, y: b.h === 0 ? 1 : common.h / b.h };
|
|
3499
|
+
this.resizeShape(shape.id, scale, { scaleOrigin: { x: b.x, y: b.y }, isAspectRatioLocked: false });
|
|
3500
|
+
const after = this.getShapePageBounds(shape.id);
|
|
3501
|
+
if (!after) continue;
|
|
3502
|
+
const d = horizontal ? new Vec(common.x - after.x, 0) : new Vec(0, common.y - after.y);
|
|
3503
|
+
if (d.x !== 0 || d.y !== 0) this.nudgeShapes([shape.id], d);
|
|
3504
|
+
}
|
|
3505
|
+
});
|
|
3506
|
+
return this;
|
|
3507
|
+
}
|
|
3508
|
+
// ---- export ------------------------------------------------------------
|
|
3509
|
+
// The implementations live in `mocanvas` (which depends on this package, not
|
|
3510
|
+
// the other way round), so they are installed through a registration seam.
|
|
3511
|
+
/**
|
|
3512
|
+
* Serialize shapes to an SVG string; `undefined` when there is nothing to
|
|
3513
|
+
* export. Defaults to the selection, or the whole page when nothing is
|
|
3514
|
+
* selected. Throws until an export implementation is registered.
|
|
3515
|
+
*/
|
|
3516
|
+
getSvgString(ids, opts) {
|
|
3517
|
+
const impl = exportImplementation;
|
|
3518
|
+
if (!impl) throw new Error(missingImplementation("getSvgString", "registerExportImplementation"));
|
|
3519
|
+
return impl.getSvgString(this, ids, opts);
|
|
3520
|
+
}
|
|
3521
|
+
/**
|
|
3522
|
+
* Render shapes to an image blob (`png` by default). Same shape selection
|
|
3523
|
+
* rules as `getSvgString`. Throws until an export implementation is
|
|
3524
|
+
* registered.
|
|
3525
|
+
*/
|
|
3526
|
+
async toImage(ids, opts) {
|
|
3527
|
+
const impl = exportImplementation;
|
|
3528
|
+
if (!impl) throw new Error(missingImplementation("toImage", "registerExportImplementation"));
|
|
3529
|
+
return await impl.toImage(this, ids, opts);
|
|
3530
|
+
}
|
|
3531
|
+
// ---- text measurement --------------------------------------------------
|
|
3532
|
+
/**
|
|
3533
|
+
* Measures runs of text the way the editor renders them. Installed through
|
|
3534
|
+
* the same seam as the export functions; throws until something registers
|
|
3535
|
+
* one.
|
|
3536
|
+
*/
|
|
3537
|
+
get textMeasure() {
|
|
3538
|
+
if (!textMeasureProvider) throw new Error(missingImplementation("textMeasure", "registerTextMeasureImplementation"));
|
|
3539
|
+
return textMeasureProvider(this);
|
|
3540
|
+
}
|
|
3541
|
+
// ---- menus -------------------------------------------------------------
|
|
3542
|
+
/** Which menus are open right now. Backed by the `instance` session record. */
|
|
3543
|
+
menus = new MenuManager(this);
|
|
3544
|
+
};
|
|
3545
|
+
function sameClips(a, b) {
|
|
3546
|
+
if (a.length !== b.length) return false;
|
|
3547
|
+
for (let i = 0; i < a.length; i++) {
|
|
3548
|
+
const x = a[i];
|
|
3549
|
+
const y = b[i];
|
|
3550
|
+
if (x === y) continue;
|
|
3551
|
+
if (!x || !y) return false;
|
|
3552
|
+
if (x[0] !== y[0] || x[1] !== y[1] || x[2] !== y[2] || x[3] !== y[3]) return false;
|
|
3553
|
+
}
|
|
3554
|
+
return true;
|
|
3555
|
+
}
|
|
3556
|
+
var exportImplementation = null;
|
|
3557
|
+
function registerExportImplementation(impl) {
|
|
3558
|
+
exportImplementation = impl;
|
|
3559
|
+
return () => {
|
|
3560
|
+
if (exportImplementation === impl) exportImplementation = null;
|
|
3561
|
+
};
|
|
3562
|
+
}
|
|
3563
|
+
function getExportImplementation() {
|
|
3564
|
+
return exportImplementation;
|
|
3565
|
+
}
|
|
3566
|
+
var textMeasureProvider = null;
|
|
3567
|
+
function registerTextMeasureImplementation(provider) {
|
|
3568
|
+
textMeasureProvider = provider;
|
|
3569
|
+
return () => {
|
|
3570
|
+
if (textMeasureProvider === provider) textMeasureProvider = null;
|
|
3571
|
+
};
|
|
3572
|
+
}
|
|
3573
|
+
function getTextMeasureProvider() {
|
|
3574
|
+
return textMeasureProvider;
|
|
3575
|
+
}
|
|
3576
|
+
function missingImplementation(member, register) {
|
|
3577
|
+
return `Editor.${member} has no implementation registered. Importing \`mocanvas\` installs it \u2014 import { Mocanvas } from "mocanvas" (or "mocanvas" for its side effect) anywhere in your app. To install your own, call ${register}(...) from "@mocanvas/editor".`;
|
|
3578
|
+
}
|
|
3579
|
+
var MenuManager = class {
|
|
3580
|
+
constructor(editor) {
|
|
3581
|
+
this.editor = editor;
|
|
3582
|
+
}
|
|
3583
|
+
editor;
|
|
3584
|
+
/** The open menu ids, in the order they were opened. */
|
|
3585
|
+
getOpenMenus() {
|
|
3586
|
+
return [...this.editor.getInstanceState().openMenus];
|
|
3587
|
+
}
|
|
3588
|
+
isMenuOpen(id) {
|
|
3589
|
+
return this.editor.getInstanceState().openMenus.includes(id);
|
|
3590
|
+
}
|
|
3591
|
+
/** Mark a menu as open. Opening an already-open menu changes nothing. */
|
|
3592
|
+
addOpenMenu(id) {
|
|
3593
|
+
const open = this.editor.getInstanceState().openMenus;
|
|
3594
|
+
if (open.includes(id)) return this;
|
|
3595
|
+
this.editor.updateInstanceState({ openMenus: [...open, id] });
|
|
3596
|
+
return this;
|
|
3597
|
+
}
|
|
3598
|
+
/** Mark a menu as closed. Closing a menu that is not open changes nothing. */
|
|
3599
|
+
removeOpenMenu(id) {
|
|
3600
|
+
const open = this.editor.getInstanceState().openMenus;
|
|
3601
|
+
if (!open.includes(id)) return this;
|
|
3602
|
+
this.editor.updateInstanceState({ openMenus: open.filter((menu) => menu !== id) });
|
|
3603
|
+
return this;
|
|
3604
|
+
}
|
|
3605
|
+
/** Close every open menu. */
|
|
3606
|
+
clearOpenMenus() {
|
|
3607
|
+
if (this.editor.getInstanceState().openMenus.length === 0) return this;
|
|
3608
|
+
this.editor.updateInstanceState({ openMenus: [] });
|
|
3609
|
+
return this;
|
|
3610
|
+
}
|
|
3611
|
+
};
|
|
3612
|
+
function createSchema(migrations = []) {
|
|
3613
|
+
return StoreSchema.create(
|
|
3614
|
+
{
|
|
3615
|
+
document: DocumentRecordType,
|
|
3616
|
+
page: PageRecordType,
|
|
3617
|
+
shape: ShapeRecordType,
|
|
3618
|
+
binding: BindingRecordType,
|
|
3619
|
+
asset: AssetRecordType,
|
|
3620
|
+
camera: CameraRecordType,
|
|
3621
|
+
instance: InstanceRecordType,
|
|
3622
|
+
instance_page_state: InstancePageStateRecordType,
|
|
3623
|
+
instance_presence: InstancePresenceRecordType
|
|
3624
|
+
},
|
|
3625
|
+
{ migrations }
|
|
3626
|
+
);
|
|
3627
|
+
}
|
|
3628
|
+
function createStore(options = {}) {
|
|
3629
|
+
const schema = createSchema(options.migrations);
|
|
3630
|
+
const store = new Store({
|
|
3631
|
+
schema,
|
|
3632
|
+
...options.initialData ? { initialData: options.initialData } : {},
|
|
3633
|
+
...options.id ? { id: options.id } : {},
|
|
3634
|
+
props: { defaultName: options.defaultName ?? "" }
|
|
3635
|
+
});
|
|
3636
|
+
if (options.snapshot) store.loadStoreSnapshot(options.snapshot);
|
|
3637
|
+
return store;
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
// src/editor/selectionHandles.ts
|
|
3641
|
+
var HANDLE_HIT_RADIUS = 12;
|
|
3642
|
+
var ROTATE_HANDLE_OFFSET = 20;
|
|
3643
|
+
var MIN_EDGE_LENGTH_FOR_EDGE_HANDLE = 4 * HANDLE_HIT_RADIUS;
|
|
3644
|
+
function getHandleHitRadius(screenW, screenH) {
|
|
3645
|
+
const smallest = Math.min(screenW, screenH);
|
|
3646
|
+
if (smallest >= 6 * HANDLE_HIT_RADIUS) return HANDLE_HIT_RADIUS;
|
|
3647
|
+
return Math.max(4, Math.min(HANDLE_HIT_RADIUS, smallest / 6));
|
|
3648
|
+
}
|
|
3649
|
+
function getSelectionHandlePositions(editor) {
|
|
3650
|
+
const selected = editor.getSelectedShapes();
|
|
3651
|
+
if (selected.length === 0) return null;
|
|
3652
|
+
if (selected.length === 1) {
|
|
3653
|
+
const util = editor.getShapeUtil(selected[0]);
|
|
3654
|
+
if (util.hideResizeHandles(selected[0]) && util.hideRotateHandle(selected[0])) return null;
|
|
3655
|
+
}
|
|
3656
|
+
const single = selected.length === 1 ? selected[0] : void 0;
|
|
3657
|
+
const rotation = single ? editor.getSelectionRotation() : 0;
|
|
3658
|
+
let bounds;
|
|
3659
|
+
let toScreen;
|
|
3660
|
+
if (single) {
|
|
3661
|
+
const b = editor.getShapeGeometryBounds(single);
|
|
3662
|
+
const m = editor.getShapePageTransform(single);
|
|
3663
|
+
bounds = b;
|
|
3664
|
+
toScreen = (p) => editor.pageToScreen({ x: m.a * p.x + m.c * p.y + m.e, y: m.b * p.x + m.d * p.y + m.f });
|
|
3665
|
+
} else {
|
|
3666
|
+
bounds = editor.getSelectionPageBounds();
|
|
3667
|
+
toScreen = (p) => editor.pageToScreen(p);
|
|
3668
|
+
}
|
|
3669
|
+
const { x, y, w, h } = bounds;
|
|
3670
|
+
const cx = x + w / 2;
|
|
3671
|
+
const cy = y + h / 2;
|
|
3672
|
+
const handles = [];
|
|
3673
|
+
const hideResize = single ? editor.getShapeUtil(single).hideResizeHandles(single) : false;
|
|
3674
|
+
const hideRotate = single ? editor.getShapeUtil(single).hideRotateHandle(single) : false;
|
|
3675
|
+
const topLeft = toScreen({ x, y });
|
|
3676
|
+
const topRight = toScreen({ x: x + w, y });
|
|
3677
|
+
const bottomRight = toScreen({ x: x + w, y: y + h });
|
|
3678
|
+
const bottomLeft = toScreen({ x, y: y + h });
|
|
3679
|
+
const screenW = Vec.Dist(topLeft, topRight);
|
|
3680
|
+
const screenH = Vec.Dist(topLeft, bottomLeft);
|
|
3681
|
+
if (!hideResize) {
|
|
3682
|
+
handles.push(
|
|
3683
|
+
{ handle: "top_left", point: topLeft },
|
|
3684
|
+
{ handle: "top_right", point: topRight },
|
|
3685
|
+
{ handle: "bottom_right", point: bottomRight },
|
|
3686
|
+
{ handle: "bottom_left", point: bottomLeft }
|
|
3687
|
+
);
|
|
3688
|
+
if (screenW >= MIN_EDGE_LENGTH_FOR_EDGE_HANDLE) {
|
|
3689
|
+
handles.push(
|
|
3690
|
+
{ handle: "top", point: toScreen({ x: cx, y }) },
|
|
3691
|
+
{ handle: "bottom", point: toScreen({ x: cx, y: y + h }) }
|
|
3692
|
+
);
|
|
3693
|
+
}
|
|
3694
|
+
if (screenH >= MIN_EDGE_LENGTH_FOR_EDGE_HANDLE) {
|
|
3695
|
+
handles.push(
|
|
3696
|
+
{ handle: "right", point: toScreen({ x: x + w, y: cy }) },
|
|
3697
|
+
{ handle: "left", point: toScreen({ x, y: cy }) }
|
|
3698
|
+
);
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
if (!hideRotate) {
|
|
3702
|
+
const top = toScreen({ x: cx, y });
|
|
3703
|
+
const center = toScreen({ x: cx, y: cy });
|
|
3704
|
+
const dir = Vec.Uni(Vec.Sub(top, center));
|
|
3705
|
+
const off = Vec.Len(Vec.Sub(top, center)) === 0 ? new Vec(0, -1) : dir;
|
|
3706
|
+
handles.push({ handle: "rotate", point: Vec.Add(top, Vec.Mul(off, ROTATE_HANDLE_OFFSET)) });
|
|
3707
|
+
}
|
|
3708
|
+
return { bounds, rotation, handles, screenW, screenH };
|
|
3709
|
+
}
|
|
3710
|
+
function hitTestSelectionHandles(editor, screenPoint, radius) {
|
|
3711
|
+
const info = getSelectionHandlePositions(editor);
|
|
3712
|
+
if (!info) return void 0;
|
|
3713
|
+
const r = radius ?? getHandleHitRadius(info.screenW, info.screenH);
|
|
3714
|
+
let best;
|
|
3715
|
+
let bestD = r * r;
|
|
3716
|
+
for (const h of info.handles) {
|
|
3717
|
+
const d = Vec.Dist2(h.point, screenPoint);
|
|
3718
|
+
if (d <= bestD) {
|
|
3719
|
+
bestD = d;
|
|
3720
|
+
best = h.handle;
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
return best;
|
|
3724
|
+
}
|
|
3725
|
+
function hitTestSelectionBounds(editor, pagePoint) {
|
|
3726
|
+
const selected = editor.getSelectedShapes();
|
|
3727
|
+
if (selected.length === 0) return false;
|
|
3728
|
+
if (selected.length === 1) {
|
|
3729
|
+
const s = selected[0];
|
|
3730
|
+
const util = editor.getShapeUtil(s);
|
|
3731
|
+
if (util.hideSelectionBoundsBg(s)) return false;
|
|
3732
|
+
const b2 = editor.getShapeGeometryBounds(s);
|
|
3733
|
+
const local = editor.getPointInShapeSpace(s, pagePoint);
|
|
3734
|
+
return Box.ContainsPoint(b2, local);
|
|
3735
|
+
}
|
|
3736
|
+
const b = editor.getSelectionPageBounds();
|
|
3737
|
+
return Box.ContainsPoint(b, pagePoint);
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
// src/records/normalize.ts
|
|
3741
|
+
function isPlainObject(value) {
|
|
3742
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3743
|
+
}
|
|
3744
|
+
function richTextToPlainText(doc, unknownNodeTypes) {
|
|
3745
|
+
if (typeof doc === "string") return doc;
|
|
3746
|
+
if (!isPlainObject(doc)) return "";
|
|
3747
|
+
const blocks = Array.isArray(doc["content"]) ? doc["content"] : [];
|
|
3748
|
+
if (blocks.length === 0) return nodeToText(doc, unknownNodeTypes);
|
|
3749
|
+
return blocks.map((block) => nodeToText(block, unknownNodeTypes)).join("\n");
|
|
3750
|
+
}
|
|
3751
|
+
var TEXTLESS_NODE_TYPES = /* @__PURE__ */ new Set(["doc", "paragraph", "text", "hardBreak", "heading", "listItem", "bulletList", "orderedList"]);
|
|
3752
|
+
function nodeToText(node, unknownNodeTypes) {
|
|
3753
|
+
if (typeof node === "string") return node;
|
|
3754
|
+
if (!isPlainObject(node)) return "";
|
|
3755
|
+
const type = typeof node["type"] === "string" ? node["type"] : "";
|
|
3756
|
+
if (type === "text") return typeof node["text"] === "string" ? node["text"] : "";
|
|
3757
|
+
if (type === "hardBreak") return "\n";
|
|
3758
|
+
if (type && !TEXTLESS_NODE_TYPES.has(type)) unknownNodeTypes?.add(type);
|
|
3759
|
+
const content = node["content"];
|
|
3760
|
+
if (!Array.isArray(content)) return "";
|
|
3761
|
+
const separator = type === "paragraph" || type === "heading" ? "" : "\n";
|
|
3762
|
+
return content.map((child) => nodeToText(child, unknownNodeTypes)).join(separator);
|
|
3763
|
+
}
|
|
3764
|
+
var PATH_HEADER_BYTES = 12;
|
|
3765
|
+
var PATH_DELTA_BYTES = 6;
|
|
3766
|
+
function decodeDrawSegmentPath(path) {
|
|
3767
|
+
const bytes = base64ToBytes(path);
|
|
3768
|
+
if (!bytes) return null;
|
|
3769
|
+
if (bytes.byteLength < PATH_HEADER_BYTES) return null;
|
|
3770
|
+
if ((bytes.byteLength - PATH_HEADER_BYTES) % PATH_DELTA_BYTES !== 0) return null;
|
|
3771
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
3772
|
+
let x = view.getFloat32(0, true);
|
|
3773
|
+
let y = view.getFloat32(4, true);
|
|
3774
|
+
let z = view.getFloat32(8, true);
|
|
3775
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null;
|
|
3776
|
+
const points = [{ x, y, z }];
|
|
3777
|
+
for (let at = PATH_HEADER_BYTES; at < bytes.byteLength; at += PATH_DELTA_BYTES) {
|
|
3778
|
+
const dx = getFloat16(view, at);
|
|
3779
|
+
const dy = getFloat16(view, at + 2);
|
|
3780
|
+
const dz = getFloat16(view, at + 4);
|
|
3781
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(dz)) return null;
|
|
3782
|
+
x += dx;
|
|
3783
|
+
y += dy;
|
|
3784
|
+
z += dz;
|
|
3785
|
+
points.push({ x, y, z });
|
|
3786
|
+
}
|
|
3787
|
+
return points;
|
|
3788
|
+
}
|
|
3789
|
+
function base64ToBytes(value) {
|
|
3790
|
+
if (typeof value !== "string" || value.length === 0 || value.length % 4 !== 0) return null;
|
|
3791
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
|
|
3792
|
+
try {
|
|
3793
|
+
if (typeof atob === "function") {
|
|
3794
|
+
const binary = atob(value);
|
|
3795
|
+
const out = new Uint8Array(binary.length);
|
|
3796
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
3797
|
+
return out;
|
|
3798
|
+
}
|
|
3799
|
+
const buffer = globalThis.Buffer;
|
|
3800
|
+
return buffer ? new Uint8Array(buffer.from(value, "base64")) : null;
|
|
3801
|
+
} catch {
|
|
3802
|
+
return null;
|
|
3803
|
+
}
|
|
3804
|
+
}
|
|
3805
|
+
function getFloat16(view, offset) {
|
|
3806
|
+
const bits = view.getUint16(offset, true);
|
|
3807
|
+
const sign = bits & 32768 ? -1 : 1;
|
|
3808
|
+
const exponent = bits >> 10 & 31;
|
|
3809
|
+
const fraction = bits & 1023;
|
|
3810
|
+
if (exponent === 0) return sign * 2 ** -14 * (fraction / 1024);
|
|
3811
|
+
if (exponent === 31) return fraction ? Number.NaN : sign * Infinity;
|
|
3812
|
+
return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
|
|
3813
|
+
}
|
|
3814
|
+
var FREEHAND_TYPES = /* @__PURE__ */ new Set(["draw", "highlight"]);
|
|
3815
|
+
function isShapeRecord(record) {
|
|
3816
|
+
return record.typeName === "shape" && typeof record.type === "string";
|
|
3817
|
+
}
|
|
3818
|
+
function normalizeLoadedRecords(records, options) {
|
|
3819
|
+
const warnings = [];
|
|
3820
|
+
const out = [];
|
|
3821
|
+
const unknownTypes = /* @__PURE__ */ new Set();
|
|
3822
|
+
for (const record of records) {
|
|
3823
|
+
if (!isShapeRecord(record)) {
|
|
3824
|
+
out.push(record);
|
|
3825
|
+
continue;
|
|
3826
|
+
}
|
|
3827
|
+
const util = options.shapeUtils[record.type];
|
|
3828
|
+
if (!util) {
|
|
3829
|
+
if (!unknownTypes.has(record.type)) {
|
|
3830
|
+
unknownTypes.add(record.type);
|
|
3831
|
+
warnings.push(`shape type "${record.type}" has no registered util; its shapes are kept but not rendered`);
|
|
3832
|
+
}
|
|
3833
|
+
out.push(record);
|
|
3834
|
+
continue;
|
|
3835
|
+
}
|
|
3836
|
+
const source = isPlainObject(record.props) ? record.props : {};
|
|
3837
|
+
let props = isPlainObject(record.props) ? null : { ...source };
|
|
3838
|
+
if ("richText" in source) {
|
|
3839
|
+
const unknownNodes = /* @__PURE__ */ new Set();
|
|
3840
|
+
const text = richTextToPlainText(source["richText"], unknownNodes);
|
|
3841
|
+
for (const node of unknownNodes) {
|
|
3842
|
+
warnings.push(`${record.id}: unsupported rich text node "${node}"; its text was kept, its formatting dropped`);
|
|
3843
|
+
}
|
|
3844
|
+
props ??= { ...source };
|
|
3845
|
+
delete props["richText"];
|
|
3846
|
+
if (typeof props["text"] !== "string") props["text"] = text;
|
|
3847
|
+
}
|
|
3848
|
+
if (FREEHAND_TYPES.has(record.type) && Array.isArray(source["segments"])) {
|
|
3849
|
+
const decoded = decodeSegments(source["segments"], record.id, warnings);
|
|
3850
|
+
if (decoded) {
|
|
3851
|
+
props ??= { ...source };
|
|
3852
|
+
props["segments"] = decoded;
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
const defaults = safeDefaultProps(util, record.type, warnings);
|
|
3856
|
+
const missing = [];
|
|
3857
|
+
for (const key of Object.keys(defaults)) {
|
|
3858
|
+
const from = props ?? source;
|
|
3859
|
+
if (!(key in from) || from[key] === void 0) missing.push(key);
|
|
3860
|
+
}
|
|
3861
|
+
if (missing.length > 0) {
|
|
3862
|
+
props ??= { ...source };
|
|
3863
|
+
for (const key of missing) props[key] = defaults[key];
|
|
3864
|
+
warnings.push(`${record.id}: missing prop${missing.length > 1 ? "s" : ""} ${missing.join(", ")} filled in from defaults`);
|
|
3865
|
+
}
|
|
3866
|
+
out.push(props ? { ...record, props } : record);
|
|
3867
|
+
}
|
|
3868
|
+
return { records: out, warnings };
|
|
3869
|
+
}
|
|
3870
|
+
function safeDefaultProps(util, type, warnings) {
|
|
3871
|
+
try {
|
|
3872
|
+
const defaults = util.getDefaultProps();
|
|
3873
|
+
return isPlainObject(defaults) ? defaults : {};
|
|
3874
|
+
} catch (error) {
|
|
3875
|
+
warnings.push(`shape type "${type}": getDefaultProps() failed (${String(error)}); missing props were not filled in`);
|
|
3876
|
+
return {};
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
function decodeSegments(segments, id, warnings) {
|
|
3880
|
+
let changed = false;
|
|
3881
|
+
const out = [];
|
|
3882
|
+
for (const segment of segments) {
|
|
3883
|
+
if (!isPlainObject(segment) || Array.isArray(segment["points"]) || typeof segment["path"] !== "string") {
|
|
3884
|
+
out.push(segment);
|
|
3885
|
+
continue;
|
|
3886
|
+
}
|
|
3887
|
+
changed = true;
|
|
3888
|
+
const points = decodeDrawSegmentPath(segment["path"]);
|
|
3889
|
+
if (!points) {
|
|
3890
|
+
warnings.push(`${id}: freehand segment uses an unrecognized path encoding; the segment was dropped`);
|
|
3891
|
+
continue;
|
|
3892
|
+
}
|
|
3893
|
+
const next = { ...segment, points };
|
|
3894
|
+
delete next["path"];
|
|
3895
|
+
out.push(next);
|
|
3896
|
+
}
|
|
3897
|
+
return changed ? out : null;
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
// src/bindings/BindingUtil.ts
|
|
3901
|
+
var BindingUtil = class {
|
|
3902
|
+
constructor(editor) {
|
|
3903
|
+
this.editor = editor;
|
|
3904
|
+
}
|
|
3905
|
+
editor;
|
|
3906
|
+
static type;
|
|
3907
|
+
static props;
|
|
3908
|
+
static migrations;
|
|
3909
|
+
get type() {
|
|
3910
|
+
return this.constructor.type;
|
|
3911
|
+
}
|
|
3912
|
+
};
|
|
3913
|
+
|
|
3914
|
+
// src/shapes/ShapeUtil.ts
|
|
3915
|
+
var ShapeUtil = class {
|
|
3916
|
+
constructor(editor) {
|
|
3917
|
+
this.editor = editor;
|
|
3918
|
+
}
|
|
3919
|
+
editor;
|
|
3920
|
+
static type;
|
|
3921
|
+
static props;
|
|
3922
|
+
static migrations;
|
|
3923
|
+
get type() {
|
|
3924
|
+
return this.constructor.type;
|
|
3925
|
+
}
|
|
3926
|
+
/**
|
|
3927
|
+
* GPU style for the shape's geometry. Return `null` (the default) to render
|
|
3928
|
+
* the shape through `component` in the DOM overlay instead.
|
|
3929
|
+
*/
|
|
3930
|
+
getRenderStyle(_shape) {
|
|
3931
|
+
return null;
|
|
3932
|
+
}
|
|
3933
|
+
/** Whether the shape should be drawn by the DOM overlay even if it has a render style (e.g. while editing). */
|
|
3934
|
+
needsOverlay(shape) {
|
|
3935
|
+
return this.editor.getEditingShapeId() === shape.id;
|
|
3936
|
+
}
|
|
3937
|
+
/**
|
|
3938
|
+
* Whether `component` should be rendered in the DOM overlay *in addition to*
|
|
3939
|
+
* the GPU geometry (e.g. a text label on a filled shape).
|
|
3940
|
+
*/
|
|
3941
|
+
hasOverlayLabel(_shape) {
|
|
3942
|
+
return false;
|
|
3943
|
+
}
|
|
3944
|
+
/**
|
|
3945
|
+
* Whether the shape clips its descendants to its own geometry bounds (e.g.
|
|
3946
|
+
* frames). Children are then rendered with a scissor rect on the GPU.
|
|
3947
|
+
*/
|
|
3948
|
+
isClipShape(_shape) {
|
|
3949
|
+
return false;
|
|
3950
|
+
}
|
|
3951
|
+
canEdit(_shape) {
|
|
3952
|
+
return false;
|
|
3953
|
+
}
|
|
3954
|
+
canResize(_shape) {
|
|
3955
|
+
return true;
|
|
3956
|
+
}
|
|
3957
|
+
canBind(_opts) {
|
|
3958
|
+
return true;
|
|
3959
|
+
}
|
|
3960
|
+
canCrop(_shape) {
|
|
3961
|
+
return false;
|
|
3962
|
+
}
|
|
3963
|
+
canScroll(_shape) {
|
|
3964
|
+
return false;
|
|
3965
|
+
}
|
|
3966
|
+
canSnap(_shape) {
|
|
3967
|
+
return true;
|
|
3968
|
+
}
|
|
3969
|
+
canReceiveNewChildrenOfType(_shape, _type) {
|
|
3970
|
+
return false;
|
|
3971
|
+
}
|
|
3972
|
+
canDropShapes(_shape, _shapes) {
|
|
3973
|
+
return false;
|
|
3974
|
+
}
|
|
3975
|
+
hideRotateHandle(_shape) {
|
|
3976
|
+
return false;
|
|
3977
|
+
}
|
|
3978
|
+
hideResizeHandles(_shape) {
|
|
3979
|
+
return false;
|
|
3980
|
+
}
|
|
3981
|
+
hideSelectionBoundsBg(_shape) {
|
|
3982
|
+
return false;
|
|
3983
|
+
}
|
|
3984
|
+
hideSelectionBoundsFg(_shape) {
|
|
3985
|
+
return false;
|
|
3986
|
+
}
|
|
3987
|
+
isAspectRatioLocked(_shape) {
|
|
3988
|
+
return false;
|
|
3989
|
+
}
|
|
3990
|
+
};
|
|
3991
|
+
var BaseBoxShapeUtil = class extends ShapeUtil {
|
|
3992
|
+
onResize(shape, info) {
|
|
3993
|
+
const { scaleX, scaleY, initialShape, newPoint } = info;
|
|
3994
|
+
const w = Math.max(1, Math.abs(initialShape.props.w * scaleX));
|
|
3995
|
+
const h = Math.max(1, Math.abs(initialShape.props.h * scaleY));
|
|
3996
|
+
return { x: newPoint.x, y: newPoint.y, props: { ...shape.props, w, h } };
|
|
3997
|
+
}
|
|
3998
|
+
};
|
|
3999
|
+
function mapClipToDeviceRect(clip, camera, dpr, canvasW, canvasH) {
|
|
4000
|
+
const s = camera.z * dpr;
|
|
4001
|
+
const ox = camera.x * s;
|
|
4002
|
+
const oy = camera.y * s;
|
|
4003
|
+
const x0 = Math.max(0, Math.floor(clip[0] * s + ox));
|
|
4004
|
+
const x1 = Math.min(canvasW, Math.ceil(clip[2] * s + ox));
|
|
4005
|
+
const y0 = Math.max(0, Math.floor(clip[1] * s + oy));
|
|
4006
|
+
const y1 = Math.min(canvasH, Math.ceil(clip[3] * s + oy));
|
|
4007
|
+
if (!(x1 > x0 && y1 > y0)) return null;
|
|
4008
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
4009
|
+
}
|
|
4010
|
+
function forEachDrawBatch(batches, camera, dpr, canvasW, canvasH, visit) {
|
|
4011
|
+
const out = { first: 0, count: 0, texture: 0, scissor: null };
|
|
4012
|
+
for (let i = 0; i + BATCH_WORDS <= batches.length; i += BATCH_WORDS) {
|
|
4013
|
+
const count = batches[i + 1];
|
|
4014
|
+
if (count === 0) continue;
|
|
4015
|
+
const clip = readClip(batches, i + 3);
|
|
4016
|
+
let scissor = null;
|
|
4017
|
+
if (clip) {
|
|
4018
|
+
scissor = mapClipToDeviceRect(clip, camera, dpr, canvasW, canvasH);
|
|
4019
|
+
if (!scissor) continue;
|
|
4020
|
+
}
|
|
4021
|
+
out.first = batches[i];
|
|
4022
|
+
out.count = count;
|
|
4023
|
+
out.texture = batches[i + 2];
|
|
4024
|
+
out.scissor = scissor;
|
|
4025
|
+
visit(out);
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
|
|
4029
|
+
// src/render/webgl2.ts
|
|
4030
|
+
var VERT = `#version 300 es
|
|
4031
|
+
precision highp float;
|
|
4032
|
+
layout(location = 0) in vec2 a_pos;
|
|
4033
|
+
layout(location = 1) in vec2 a_uv;
|
|
4034
|
+
layout(location = 2) in vec4 a_color;
|
|
4035
|
+
uniform vec3 u_cam; // x, y, zoom
|
|
4036
|
+
uniform vec2 u_vp; // viewport size in CSS px
|
|
4037
|
+
out vec2 v_uv;
|
|
4038
|
+
out vec4 v_color;
|
|
4039
|
+
void main() {
|
|
4040
|
+
vec2 screen = (a_pos + u_cam.xy) * u_cam.z;
|
|
4041
|
+
vec2 clip = screen / u_vp * 2.0 - 1.0;
|
|
4042
|
+
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
|
|
4043
|
+
v_uv = a_uv;
|
|
4044
|
+
v_color = a_color;
|
|
4045
|
+
}`;
|
|
4046
|
+
var FRAG = `#version 300 es
|
|
4047
|
+
precision mediump float;
|
|
4048
|
+
in vec2 v_uv;
|
|
4049
|
+
in vec4 v_color;
|
|
4050
|
+
uniform sampler2D u_tex;
|
|
4051
|
+
out vec4 o_color;
|
|
4052
|
+
void main() {
|
|
4053
|
+
vec4 t = texture(u_tex, v_uv);
|
|
4054
|
+
o_color = vec4(v_color.rgb * v_color.a, v_color.a) * t;
|
|
4055
|
+
}`;
|
|
4056
|
+
var BYTES_PER_VERTEX = VERTEX_FLOATS * 4;
|
|
4057
|
+
function compile(gl, type, src) {
|
|
4058
|
+
const sh = gl.createShader(type);
|
|
4059
|
+
if (!sh) throw new Error("mocanvas: cannot create shader");
|
|
4060
|
+
gl.shaderSource(sh, src);
|
|
4061
|
+
gl.compileShader(sh);
|
|
4062
|
+
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS) && !gl.isContextLost()) {
|
|
4063
|
+
const log = gl.getShaderInfoLog(sh);
|
|
4064
|
+
gl.deleteShader(sh);
|
|
4065
|
+
throw new Error(`mocanvas: shader compile failed: ${log}`);
|
|
4066
|
+
}
|
|
4067
|
+
return sh;
|
|
4068
|
+
}
|
|
4069
|
+
function isPowerOfTwo(n) {
|
|
4070
|
+
return n > 0 && (n & n - 1) === 0;
|
|
4071
|
+
}
|
|
4072
|
+
function sourceSize2(source) {
|
|
4073
|
+
if (typeof VideoFrame !== "undefined" && source instanceof VideoFrame) return [source.displayWidth, source.displayHeight];
|
|
4074
|
+
if (typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement) return [source.videoWidth, source.videoHeight];
|
|
4075
|
+
const s = source;
|
|
4076
|
+
return [s.width, s.height];
|
|
4077
|
+
}
|
|
4078
|
+
var WebGL2Backend = class {
|
|
4079
|
+
constructor(canvas, options = {}) {
|
|
4080
|
+
this.canvas = canvas;
|
|
4081
|
+
const gl = canvas.getContext("webgl2", {
|
|
4082
|
+
antialias: options.antialias ?? true,
|
|
4083
|
+
premultipliedAlpha: true,
|
|
4084
|
+
alpha: true,
|
|
4085
|
+
preserveDrawingBuffer: false,
|
|
4086
|
+
powerPreference: "high-performance",
|
|
4087
|
+
desynchronized: true
|
|
4088
|
+
});
|
|
4089
|
+
if (!gl) throw new Error("mocanvas: WebGL2 is not available");
|
|
4090
|
+
this.gl = gl;
|
|
4091
|
+
const vs = compile(gl, gl.VERTEX_SHADER, VERT);
|
|
4092
|
+
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
|
|
4093
|
+
const program = gl.createProgram();
|
|
4094
|
+
if (!program) throw new Error("mocanvas: cannot create program");
|
|
4095
|
+
gl.attachShader(program, vs);
|
|
4096
|
+
gl.attachShader(program, fs);
|
|
4097
|
+
gl.linkProgram(program);
|
|
4098
|
+
gl.deleteShader(vs);
|
|
4099
|
+
gl.deleteShader(fs);
|
|
4100
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
4101
|
+
throw new Error(`mocanvas: program link failed: ${gl.getProgramInfoLog(program)}`);
|
|
4102
|
+
}
|
|
4103
|
+
this.program = program;
|
|
4104
|
+
this.uCam = gl.getUniformLocation(program, "u_cam");
|
|
4105
|
+
this.uVp = gl.getUniformLocation(program, "u_vp");
|
|
4106
|
+
gl.useProgram(program);
|
|
4107
|
+
gl.uniform1i(gl.getUniformLocation(program, "u_tex"), 0);
|
|
4108
|
+
const vao = gl.createVertexArray();
|
|
4109
|
+
const vbo = gl.createBuffer();
|
|
4110
|
+
const ibo = gl.createBuffer();
|
|
4111
|
+
if (!vao || !vbo || !ibo) throw new Error("mocanvas: cannot create buffers");
|
|
4112
|
+
this.vao = vao;
|
|
4113
|
+
this.vbo = vbo;
|
|
4114
|
+
this.ibo = ibo;
|
|
4115
|
+
gl.bindVertexArray(vao);
|
|
4116
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
4117
|
+
gl.enableVertexAttribArray(0);
|
|
4118
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, BYTES_PER_VERTEX, 0);
|
|
4119
|
+
gl.enableVertexAttribArray(1);
|
|
4120
|
+
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, BYTES_PER_VERTEX, 8);
|
|
4121
|
+
gl.enableVertexAttribArray(2);
|
|
4122
|
+
gl.vertexAttribPointer(2, 4, gl.FLOAT, false, BYTES_PER_VERTEX, 16);
|
|
4123
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
|
|
4124
|
+
gl.bindVertexArray(null);
|
|
4125
|
+
const white = gl.createTexture();
|
|
4126
|
+
if (!white) throw new Error("mocanvas: cannot create texture");
|
|
4127
|
+
this.whiteTex = white;
|
|
4128
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
4129
|
+
gl.bindTexture(gl.TEXTURE_2D, white);
|
|
4130
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]));
|
|
4131
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
4132
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
4133
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
4134
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
4135
|
+
gl.enable(gl.BLEND);
|
|
4136
|
+
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
4137
|
+
gl.disable(gl.DEPTH_TEST);
|
|
4138
|
+
gl.disable(gl.CULL_FACE);
|
|
4139
|
+
gl.disable(gl.SCISSOR_TEST);
|
|
4140
|
+
}
|
|
4141
|
+
canvas;
|
|
4142
|
+
kind = "webgl2";
|
|
4143
|
+
gl;
|
|
4144
|
+
program;
|
|
4145
|
+
vao;
|
|
4146
|
+
vbo;
|
|
4147
|
+
ibo;
|
|
4148
|
+
uCam;
|
|
4149
|
+
uVp;
|
|
4150
|
+
/** Texture 0: 1×1 opaque white, so solid batches use the same shader. */
|
|
4151
|
+
whiteTex;
|
|
4152
|
+
textures = /* @__PURE__ */ new Map();
|
|
4153
|
+
vboBytes = 0;
|
|
4154
|
+
iboBytes = 0;
|
|
4155
|
+
/**
|
|
4156
|
+
* `FrameBuffers.version` currently sitting in the VBO/IBO, or -1 when they hold
|
|
4157
|
+
* nothing usable. The engine's vertex data is page-space, so panning and zooming
|
|
4158
|
+
* change only the camera uniform: while the version is unchanged this skips the
|
|
4159
|
+
* (multi-megabyte, at scale) `bufferSubData` and just re-issues the draw calls.
|
|
4160
|
+
*/
|
|
4161
|
+
uploadedVersion = -1;
|
|
4162
|
+
width = 1;
|
|
4163
|
+
height = 1;
|
|
4164
|
+
pixelWidth = 1;
|
|
4165
|
+
pixelHeight = 1;
|
|
4166
|
+
dpr = 1;
|
|
4167
|
+
disposed = false;
|
|
4168
|
+
resize(width, height, dpr) {
|
|
4169
|
+
this.width = Math.max(1, width);
|
|
4170
|
+
this.height = Math.max(1, height);
|
|
4171
|
+
this.dpr = dpr;
|
|
4172
|
+
const pw = Math.max(1, Math.round(width * dpr));
|
|
4173
|
+
const ph = Math.max(1, Math.round(height * dpr));
|
|
4174
|
+
this.pixelWidth = pw;
|
|
4175
|
+
this.pixelHeight = ph;
|
|
4176
|
+
if (this.canvas.width !== pw || this.canvas.height !== ph) {
|
|
4177
|
+
this.canvas.width = pw;
|
|
4178
|
+
this.canvas.height = ph;
|
|
4179
|
+
}
|
|
4180
|
+
this.gl.viewport(0, 0, pw, ph);
|
|
4181
|
+
}
|
|
4182
|
+
uploadTexture(id, source, opts = {}) {
|
|
4183
|
+
if (this.disposed) return;
|
|
4184
|
+
if (id === 0) throw new Error("mocanvas: texture id 0 is reserved");
|
|
4185
|
+
const gl = this.gl;
|
|
4186
|
+
let tex = this.textures.get(id);
|
|
4187
|
+
if (!tex) {
|
|
4188
|
+
const t = gl.createTexture();
|
|
4189
|
+
if (!t) throw new Error("mocanvas: cannot create texture");
|
|
4190
|
+
tex = t;
|
|
4191
|
+
this.textures.set(id, tex);
|
|
4192
|
+
}
|
|
4193
|
+
const [w, h] = sourceSize2(source);
|
|
4194
|
+
const mip = isPowerOfTwo(w) && isPowerOfTwo(h);
|
|
4195
|
+
const smooth = opts.smooth ?? true;
|
|
4196
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
4197
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
4198
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, opts.premultiplied ? 0 : 1);
|
|
4199
|
+
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
|
|
4200
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
|
4201
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0);
|
|
4202
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
4203
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
4204
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, smooth ? gl.LINEAR : gl.NEAREST);
|
|
4205
|
+
if (mip) {
|
|
4206
|
+
gl.generateMipmap(gl.TEXTURE_2D);
|
|
4207
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, smooth ? gl.LINEAR_MIPMAP_LINEAR : gl.NEAREST_MIPMAP_NEAREST);
|
|
4208
|
+
} else {
|
|
4209
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, smooth ? gl.LINEAR : gl.NEAREST);
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
deleteTexture(id) {
|
|
4213
|
+
const tex = this.textures.get(id);
|
|
4214
|
+
if (!tex) return;
|
|
4215
|
+
this.textures.delete(id);
|
|
4216
|
+
if (!this.disposed) this.gl.deleteTexture(tex);
|
|
4217
|
+
}
|
|
4218
|
+
draw(frame, camera, options) {
|
|
4219
|
+
if (this.disposed) return;
|
|
4220
|
+
const gl = this.gl;
|
|
4221
|
+
const [r, g, b, a] = options.background;
|
|
4222
|
+
gl.disable(gl.SCISSOR_TEST);
|
|
4223
|
+
gl.clearColor(r * a, g * a, b * a, a);
|
|
4224
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
4225
|
+
if (frame.indices.length === 0) return;
|
|
4226
|
+
gl.useProgram(this.program);
|
|
4227
|
+
gl.uniform3f(this.uCam, camera.x, camera.y, camera.z);
|
|
4228
|
+
gl.uniform2f(this.uVp, this.width, this.height);
|
|
4229
|
+
gl.bindVertexArray(this.vao);
|
|
4230
|
+
if (frame.version !== this.uploadedVersion) {
|
|
4231
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo);
|
|
4232
|
+
const vBytes = frame.vertices.byteLength;
|
|
4233
|
+
if (vBytes > this.vboBytes) {
|
|
4234
|
+
this.vboBytes = Math.max(vBytes, this.vboBytes * 2);
|
|
4235
|
+
gl.bufferData(gl.ARRAY_BUFFER, this.vboBytes, gl.DYNAMIC_DRAW);
|
|
4236
|
+
}
|
|
4237
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, frame.vertices);
|
|
4238
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.ibo);
|
|
4239
|
+
const iBytes = frame.indices.byteLength;
|
|
4240
|
+
if (iBytes > this.iboBytes) {
|
|
4241
|
+
this.iboBytes = Math.max(iBytes, this.iboBytes * 2);
|
|
4242
|
+
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.iboBytes, gl.DYNAMIC_DRAW);
|
|
4243
|
+
}
|
|
4244
|
+
gl.bufferSubData(gl.ELEMENT_ARRAY_BUFFER, 0, frame.indices);
|
|
4245
|
+
this.uploadedVersion = frame.version;
|
|
4246
|
+
}
|
|
4247
|
+
const ph = this.pixelHeight;
|
|
4248
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
4249
|
+
let boundTex = null;
|
|
4250
|
+
let scissoring = false;
|
|
4251
|
+
forEachDrawBatch(frame.batches, camera, this.dpr, this.pixelWidth, ph, (b2) => {
|
|
4252
|
+
const sc = b2.scissor;
|
|
4253
|
+
if (sc) {
|
|
4254
|
+
if (!scissoring) {
|
|
4255
|
+
gl.enable(gl.SCISSOR_TEST);
|
|
4256
|
+
scissoring = true;
|
|
4257
|
+
}
|
|
4258
|
+
gl.scissor(sc.x, ph - (sc.y + sc.h), sc.w, sc.h);
|
|
4259
|
+
} else if (scissoring) {
|
|
4260
|
+
gl.disable(gl.SCISSOR_TEST);
|
|
4261
|
+
scissoring = false;
|
|
4262
|
+
}
|
|
4263
|
+
const tex = (b2.texture !== 0 ? this.textures.get(b2.texture) : void 0) ?? this.whiteTex;
|
|
4264
|
+
if (tex !== boundTex) {
|
|
4265
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
4266
|
+
boundTex = tex;
|
|
4267
|
+
}
|
|
4268
|
+
gl.drawElements(gl.TRIANGLES, b2.count, gl.UNSIGNED_INT, b2.first * 4);
|
|
4269
|
+
});
|
|
4270
|
+
if (scissoring) gl.disable(gl.SCISSOR_TEST);
|
|
4271
|
+
gl.bindVertexArray(null);
|
|
4272
|
+
}
|
|
4273
|
+
dispose() {
|
|
4274
|
+
if (this.disposed) return;
|
|
4275
|
+
this.disposed = true;
|
|
4276
|
+
const gl = this.gl;
|
|
4277
|
+
for (const tex of this.textures.values()) gl.deleteTexture(tex);
|
|
4278
|
+
this.textures.clear();
|
|
4279
|
+
gl.deleteTexture(this.whiteTex);
|
|
4280
|
+
gl.deleteBuffer(this.vbo);
|
|
4281
|
+
gl.deleteBuffer(this.ibo);
|
|
4282
|
+
gl.deleteVertexArray(this.vao);
|
|
4283
|
+
gl.deleteProgram(this.program);
|
|
4284
|
+
}
|
|
4285
|
+
};
|
|
4286
|
+
function createBackend(canvas) {
|
|
4287
|
+
return new WebGL2Backend(canvas);
|
|
4288
|
+
}
|
|
4289
|
+
var EditorContext = createContext(null);
|
|
4290
|
+
function useEditor() {
|
|
4291
|
+
const editor = useContext(EditorContext);
|
|
4292
|
+
if (!editor) throw new Error("useEditor must be used inside a mocanvas <Canvas> or <EditorProvider>");
|
|
4293
|
+
return editor;
|
|
4294
|
+
}
|
|
4295
|
+
function useMaybeEditor() {
|
|
4296
|
+
return useContext(EditorContext);
|
|
4297
|
+
}
|
|
4298
|
+
var EditorProvider = EditorContext.Provider;
|
|
4299
|
+
function modifiers(e) {
|
|
4300
|
+
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform ?? "");
|
|
4301
|
+
return {
|
|
4302
|
+
shiftKey: e.shiftKey,
|
|
4303
|
+
altKey: e.altKey,
|
|
4304
|
+
ctrlKey: e.ctrlKey,
|
|
4305
|
+
metaKey: e.metaKey,
|
|
4306
|
+
accelKey: isMac ? e.metaKey : e.ctrlKey
|
|
4307
|
+
};
|
|
4308
|
+
}
|
|
4309
|
+
function localPoint(editor, e) {
|
|
4310
|
+
const rect = editor.getContainer().getBoundingClientRect();
|
|
4311
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top, z: 0.5 };
|
|
4312
|
+
}
|
|
4313
|
+
function resolveTarget(editor, point) {
|
|
4314
|
+
const page = editor.screenToPage(point);
|
|
4315
|
+
if (editor.getCurrentToolId() === "select") {
|
|
4316
|
+
const selHandle = hitTestSelectionHandles(editor, point);
|
|
4317
|
+
if (selHandle) return { target: "selection", handle: selHandle };
|
|
4318
|
+
const only = editor.getOnlySelectedShape();
|
|
4319
|
+
if (only) {
|
|
4320
|
+
const handles = editor.getShapeUtil(only).getHandles?.(only);
|
|
4321
|
+
if (handles?.length) {
|
|
4322
|
+
const local = editor.getPointInShapeSpace(only, page);
|
|
4323
|
+
const r = HANDLE_HIT_RADIUS / editor.getZoomLevel();
|
|
4324
|
+
let best;
|
|
4325
|
+
let bestD = r * r;
|
|
4326
|
+
for (const h of handles) {
|
|
4327
|
+
const d = Vec.Dist2(h, local);
|
|
4328
|
+
if (d <= bestD) {
|
|
4329
|
+
bestD = d;
|
|
4330
|
+
best = h;
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
if (best) return { target: "handle", shape: only, handle: best };
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
const shape = editor.getShapeAtPoint(page, { hitInside: true });
|
|
4338
|
+
if (shape) return { target: "shape", shape };
|
|
4339
|
+
if (editor.getCurrentToolId() === "select" && hitTestSelectionBounds(editor, page)) {
|
|
4340
|
+
const selected = editor.getSelectedShapes();
|
|
4341
|
+
const inside = selected.find((s) => editor.getShapeGeometry(s).hitTestPoint(editor.getPointInShapeSpace(s, page), 0, true));
|
|
4342
|
+
return inside ? { target: "shape", shape: inside } : { target: "selection" };
|
|
4343
|
+
}
|
|
4344
|
+
const filled = editor.getShapeAtPoint(page, { hitInside: true });
|
|
4345
|
+
if (filled && editor.getSelectedShapeIds().includes(filled.id)) return { target: "shape", shape: filled };
|
|
4346
|
+
return { target: "canvas" };
|
|
4347
|
+
}
|
|
4348
|
+
function useCanvasEvents(editor) {
|
|
4349
|
+
return useMemo(() => {
|
|
4350
|
+
let lastDownTime = 0;
|
|
4351
|
+
let clickCount = 0;
|
|
4352
|
+
let lastDownPoint = { x: 0, y: 0 };
|
|
4353
|
+
const pointerInfo = (e, name) => {
|
|
4354
|
+
const point = localPoint(editor, e);
|
|
4355
|
+
return {
|
|
4356
|
+
type: "pointer",
|
|
4357
|
+
name,
|
|
4358
|
+
point,
|
|
4359
|
+
pointerId: e.pointerId,
|
|
4360
|
+
button: e.button,
|
|
4361
|
+
isPen: e.pointerType === "pen",
|
|
4362
|
+
...modifiers(e),
|
|
4363
|
+
...resolveTarget(editor, point)
|
|
4364
|
+
};
|
|
4365
|
+
};
|
|
4366
|
+
return {
|
|
4367
|
+
onPointerDown(e) {
|
|
4368
|
+
if (e.button === 2) {
|
|
4369
|
+
editor.dispatch(pointerInfo(e, "right_click"));
|
|
4370
|
+
return;
|
|
4371
|
+
}
|
|
4372
|
+
if (e.button === 1) {
|
|
4373
|
+
editor.dispatch(pointerInfo(e, "middle_click"));
|
|
4374
|
+
}
|
|
4375
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
4376
|
+
editor.getContainer().focus({ preventScroll: true });
|
|
4377
|
+
const now = performance.now();
|
|
4378
|
+
const p = localPoint(editor, e);
|
|
4379
|
+
if (now - lastDownTime < 400 && Math.hypot(p.x - lastDownPoint.x, p.y - lastDownPoint.y) < 8) clickCount++;
|
|
4380
|
+
else clickCount = 1;
|
|
4381
|
+
lastDownTime = now;
|
|
4382
|
+
lastDownPoint = p;
|
|
4383
|
+
editor.dispatch(pointerInfo(e, "pointer_down"));
|
|
4384
|
+
},
|
|
4385
|
+
onPointerMove(e) {
|
|
4386
|
+
if (e.pointerType === "mouse" && e.buttons === 0 && editor.inputs.isPointing) {
|
|
4387
|
+
editor.dispatch(pointerInfo(e, "pointer_up"));
|
|
4388
|
+
return;
|
|
4389
|
+
}
|
|
4390
|
+
editor.dispatch(pointerInfo(e, "pointer_move"));
|
|
4391
|
+
},
|
|
4392
|
+
onPointerUp(e) {
|
|
4393
|
+
if (e.button === 2) return;
|
|
4394
|
+
const el = e.currentTarget;
|
|
4395
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
4396
|
+
editor.dispatch(pointerInfo(e, "pointer_up"));
|
|
4397
|
+
if (clickCount >= 2) {
|
|
4398
|
+
const name = clickCount === 2 ? "double_click" : clickCount === 3 ? "triple_click" : "quadruple_click";
|
|
4399
|
+
const point = localPoint(editor, e);
|
|
4400
|
+
editor.dispatch({
|
|
4401
|
+
type: "click",
|
|
4402
|
+
name,
|
|
4403
|
+
phase: "up",
|
|
4404
|
+
point,
|
|
4405
|
+
pointerId: e.pointerId,
|
|
4406
|
+
button: e.button,
|
|
4407
|
+
isPen: e.pointerType === "pen",
|
|
4408
|
+
...modifiers(e),
|
|
4409
|
+
...resolveTarget(editor, point)
|
|
4410
|
+
});
|
|
4411
|
+
}
|
|
4412
|
+
},
|
|
4413
|
+
onPointerCancel(e) {
|
|
4414
|
+
editor.dispatch(pointerInfo(e, "pointer_up"));
|
|
4415
|
+
editor.cancel();
|
|
4416
|
+
},
|
|
4417
|
+
onWheel(e) {
|
|
4418
|
+
e.preventDefault();
|
|
4419
|
+
const point = localPoint(editor, e);
|
|
4420
|
+
const scale = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1;
|
|
4421
|
+
const info = {
|
|
4422
|
+
type: "wheel",
|
|
4423
|
+
name: "wheel",
|
|
4424
|
+
point,
|
|
4425
|
+
delta: { x: e.deltaX * scale, y: e.deltaY * scale, z: 0 },
|
|
4426
|
+
...modifiers(e)
|
|
4427
|
+
};
|
|
4428
|
+
editor.dispatch(info);
|
|
4429
|
+
},
|
|
4430
|
+
onContextMenu(e) {
|
|
4431
|
+
e.preventDefault();
|
|
4432
|
+
},
|
|
4433
|
+
onKeyDown(e) {
|
|
4434
|
+
if (isEditableTarget(e.target)) return;
|
|
4435
|
+
editor.dispatch({ type: "keyboard", name: e.repeat ? "key_repeat" : "key_down", key: e.key, code: e.code, ...modifiers(e) });
|
|
4436
|
+
},
|
|
4437
|
+
onKeyUp(e) {
|
|
4438
|
+
if (isEditableTarget(e.target)) return;
|
|
4439
|
+
editor.dispatch({ type: "keyboard", name: "key_up", key: e.key, code: e.code, ...modifiers(e) });
|
|
4440
|
+
}
|
|
4441
|
+
};
|
|
4442
|
+
}, [editor]);
|
|
4443
|
+
}
|
|
4444
|
+
function isEditableTarget(t) {
|
|
4445
|
+
if (!(t instanceof HTMLElement)) return false;
|
|
4446
|
+
const tag = t.tagName;
|
|
4447
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || t.isContentEditable;
|
|
4448
|
+
}
|
|
4449
|
+
var containerStyle = {
|
|
4450
|
+
position: "relative",
|
|
4451
|
+
width: "100%",
|
|
4452
|
+
height: "100%",
|
|
4453
|
+
overflow: "hidden",
|
|
4454
|
+
touchAction: "none",
|
|
4455
|
+
userSelect: "none",
|
|
4456
|
+
WebkitUserSelect: "none",
|
|
4457
|
+
outline: "none",
|
|
4458
|
+
cursor: "default"
|
|
4459
|
+
};
|
|
4460
|
+
var layerStyle = {
|
|
4461
|
+
position: "absolute",
|
|
4462
|
+
inset: 0,
|
|
4463
|
+
width: "100%",
|
|
4464
|
+
height: "100%"
|
|
4465
|
+
};
|
|
4466
|
+
var SELECTION = "var(--mocanvas-selection, #2f6fe4)";
|
|
4467
|
+
var HANDLE_FILL = "var(--mocanvas-selection-fg, #ffffff)";
|
|
4468
|
+
var BRUSH_FILL = "var(--mocanvas-brush-fill, rgba(47, 111, 228, 0.12))";
|
|
4469
|
+
var SNAP = "var(--mocanvas-snap, #cf3fe0)";
|
|
4470
|
+
var INDICATOR_STROKE = 1.5;
|
|
4471
|
+
var HANDLE = { corner: 9, rotate: 5.5, shape: 6, virtual: 4 };
|
|
4472
|
+
function Canvas({ editor, className, style, children, components }) {
|
|
4473
|
+
const containerRef = useRef(null);
|
|
4474
|
+
const canvasRef = useRef(null);
|
|
4475
|
+
const [backend, setBackend] = useState(null);
|
|
4476
|
+
const events = useCanvasEvents(editor);
|
|
4477
|
+
useLayoutEffect(() => {
|
|
4478
|
+
const canvas = canvasRef.current;
|
|
4479
|
+
if (!canvas) return;
|
|
4480
|
+
const b = createBackend(canvas);
|
|
4481
|
+
setBackend(b);
|
|
4482
|
+
return () => {
|
|
4483
|
+
b.dispose();
|
|
4484
|
+
setBackend(null);
|
|
4485
|
+
};
|
|
4486
|
+
}, []);
|
|
4487
|
+
useLayoutEffect(() => {
|
|
4488
|
+
const el = containerRef.current;
|
|
4489
|
+
if (!el || !backend) return;
|
|
4490
|
+
const update = () => {
|
|
4491
|
+
const rect = el.getBoundingClientRect();
|
|
4492
|
+
const dpr = window.devicePixelRatio || 1;
|
|
4493
|
+
backend.resize(rect.width, rect.height, dpr);
|
|
4494
|
+
editor.updateViewportScreenBounds({ x: rect.left, y: rect.top, w: rect.width, h: rect.height });
|
|
4495
|
+
editor.updateInstanceState({ devicePixelRatio: dpr });
|
|
4496
|
+
};
|
|
4497
|
+
update();
|
|
4498
|
+
const ro = new ResizeObserver(update);
|
|
4499
|
+
ro.observe(el);
|
|
4500
|
+
window.addEventListener("resize", update);
|
|
4501
|
+
return () => {
|
|
4502
|
+
ro.disconnect();
|
|
4503
|
+
window.removeEventListener("resize", update);
|
|
4504
|
+
};
|
|
4505
|
+
}, [editor, backend]);
|
|
4506
|
+
useEffect(() => {
|
|
4507
|
+
if (!backend) return;
|
|
4508
|
+
let raf = 0;
|
|
4509
|
+
let dirty = true;
|
|
4510
|
+
const draw = () => {
|
|
4511
|
+
raf = 0;
|
|
4512
|
+
if (!dirty) return;
|
|
4513
|
+
dirty = false;
|
|
4514
|
+
if (editor.renderFrame(backend).pending) {
|
|
4515
|
+
dirty = true;
|
|
4516
|
+
raf = requestAnimationFrame(draw);
|
|
4517
|
+
}
|
|
4518
|
+
};
|
|
4519
|
+
const stop = react(
|
|
4520
|
+
"canvas.frame",
|
|
4521
|
+
() => {
|
|
4522
|
+
editor.getFrameEpoch();
|
|
4523
|
+
editor.getCamera();
|
|
4524
|
+
editor.getViewportScreenBounds();
|
|
4525
|
+
editor.getEditingShapeId();
|
|
4526
|
+
dirty = true;
|
|
4527
|
+
if (!raf) raf = requestAnimationFrame(draw);
|
|
4528
|
+
}
|
|
4529
|
+
);
|
|
4530
|
+
return () => {
|
|
4531
|
+
stop();
|
|
4532
|
+
if (raf) cancelAnimationFrame(raf);
|
|
4533
|
+
};
|
|
4534
|
+
}, [editor, backend]);
|
|
4535
|
+
useEffect(() => {
|
|
4536
|
+
const el = containerRef.current;
|
|
4537
|
+
if (!el) return;
|
|
4538
|
+
const onWheel = (e) => events.onWheel(e);
|
|
4539
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
4540
|
+
window.addEventListener("keydown", events.onKeyDown);
|
|
4541
|
+
window.addEventListener("keyup", events.onKeyUp);
|
|
4542
|
+
return () => {
|
|
4543
|
+
el.removeEventListener("wheel", onWheel);
|
|
4544
|
+
window.removeEventListener("keydown", events.onKeyDown);
|
|
4545
|
+
window.removeEventListener("keyup", events.onKeyUp);
|
|
4546
|
+
};
|
|
4547
|
+
}, [events]);
|
|
4548
|
+
const cursor = useValue("cursor", () => cssCursor(editor.getInstanceState().cursor.type), [editor]);
|
|
4549
|
+
const Indicators = components?.Indicators ?? DefaultIndicators;
|
|
4550
|
+
const Brush = components?.Brush ?? DefaultBrush;
|
|
4551
|
+
const Background = components?.Background;
|
|
4552
|
+
return /* @__PURE__ */ jsx(EditorProvider, { value: editor, children: /* @__PURE__ */ jsxs(
|
|
4553
|
+
"div",
|
|
4554
|
+
{
|
|
4555
|
+
ref: containerRef,
|
|
4556
|
+
className: className ? `mocanvas ${className}` : "mocanvas",
|
|
4557
|
+
style: { ...containerStyle, ...style, cursor },
|
|
4558
|
+
tabIndex: 0,
|
|
4559
|
+
onPointerDown: events.onPointerDown,
|
|
4560
|
+
onPointerMove: events.onPointerMove,
|
|
4561
|
+
onPointerUp: events.onPointerUp,
|
|
4562
|
+
onPointerCancel: events.onPointerCancel,
|
|
4563
|
+
onContextMenu: events.onContextMenu,
|
|
4564
|
+
"data-testid": "mocanvas-container",
|
|
4565
|
+
children: [
|
|
4566
|
+
Background ? /* @__PURE__ */ jsx(Background, { editor }) : null,
|
|
4567
|
+
/* @__PURE__ */ jsx("canvas", { ref: canvasRef, style: { ...layerStyle, display: "block" } }),
|
|
4568
|
+
/* @__PURE__ */ jsx(OverlayLayer, { editor }),
|
|
4569
|
+
/* @__PURE__ */ jsxs("svg", { style: { ...layerStyle, pointerEvents: "none", overflow: "visible" }, children: [
|
|
4570
|
+
/* @__PURE__ */ jsx(Indicators, { editor }),
|
|
4571
|
+
/* @__PURE__ */ jsx(SnapLines, { editor }),
|
|
4572
|
+
/* @__PURE__ */ jsx(Brush, { editor })
|
|
4573
|
+
] }),
|
|
4574
|
+
children
|
|
4575
|
+
]
|
|
4576
|
+
}
|
|
4577
|
+
) });
|
|
4578
|
+
}
|
|
4579
|
+
var OverlayLayer = track(function OverlayLayer2({ editor }) {
|
|
4580
|
+
const ids = editor.getOverlayShapeIds();
|
|
4581
|
+
const clips = editor.getOverlayClips();
|
|
4582
|
+
const cam = editor.getCamera();
|
|
4583
|
+
const editingId = editor.getEditingShapeId();
|
|
4584
|
+
return /* @__PURE__ */ jsx(
|
|
4585
|
+
"div",
|
|
4586
|
+
{
|
|
4587
|
+
className: "mocanvas-overlay",
|
|
4588
|
+
style: {
|
|
4589
|
+
...layerStyle,
|
|
4590
|
+
pointerEvents: "none",
|
|
4591
|
+
transformOrigin: "0 0",
|
|
4592
|
+
transform: `scale(${cam.z}) translate(${cam.x}px, ${cam.y}px)`
|
|
4593
|
+
},
|
|
4594
|
+
children: ids.map((id, i) => {
|
|
4595
|
+
const shape = editor.getShape(id);
|
|
4596
|
+
if (!shape) return null;
|
|
4597
|
+
return /* @__PURE__ */ jsx(OverlayShape, { editor, shape, isEditing: editingId === id, clip: clips[i] }, id);
|
|
4598
|
+
})
|
|
4599
|
+
}
|
|
4600
|
+
);
|
|
4601
|
+
});
|
|
4602
|
+
var OverlayShape = track(function OverlayShape2({
|
|
4603
|
+
editor,
|
|
4604
|
+
shape,
|
|
4605
|
+
isEditing,
|
|
4606
|
+
clip
|
|
4607
|
+
}) {
|
|
4608
|
+
const util = editor.getShapeUtil(shape);
|
|
4609
|
+
const m = editor.getShapePageTransform(shape);
|
|
4610
|
+
const bounds = editor.getShapeGeometryBounds(shape);
|
|
4611
|
+
const offset = clip ? `translate(${-clip[0]}px, ${-clip[1]}px) ` : "";
|
|
4612
|
+
const el = /* @__PURE__ */ jsx(
|
|
4613
|
+
"div",
|
|
4614
|
+
{
|
|
4615
|
+
className: "mocanvas-shape",
|
|
4616
|
+
"data-shape-id": shape.id,
|
|
4617
|
+
"data-shape-type": shape.type,
|
|
4618
|
+
style: {
|
|
4619
|
+
position: "absolute",
|
|
4620
|
+
left: 0,
|
|
4621
|
+
top: 0,
|
|
4622
|
+
width: bounds?.w ?? 0,
|
|
4623
|
+
height: bounds?.h ?? 0,
|
|
4624
|
+
transformOrigin: "0 0",
|
|
4625
|
+
transform: `${offset}matrix(${m.a}, ${m.b}, ${m.c}, ${m.d}, ${m.e}, ${m.f})`,
|
|
4626
|
+
opacity: shape.opacity,
|
|
4627
|
+
pointerEvents: isEditing ? "auto" : "none"
|
|
4628
|
+
},
|
|
4629
|
+
children: util.component(shape)
|
|
4630
|
+
}
|
|
4631
|
+
);
|
|
4632
|
+
if (!clip) return el;
|
|
4633
|
+
return /* @__PURE__ */ jsx(
|
|
4634
|
+
"div",
|
|
4635
|
+
{
|
|
4636
|
+
className: "mocanvas-shape-clip",
|
|
4637
|
+
style: {
|
|
4638
|
+
position: "absolute",
|
|
4639
|
+
left: clip[0],
|
|
4640
|
+
top: clip[1],
|
|
4641
|
+
width: Math.max(0, clip[2] - clip[0]),
|
|
4642
|
+
height: Math.max(0, clip[3] - clip[1]),
|
|
4643
|
+
overflow: "hidden",
|
|
4644
|
+
pointerEvents: "none"
|
|
4645
|
+
},
|
|
4646
|
+
children: el
|
|
4647
|
+
}
|
|
4648
|
+
);
|
|
4649
|
+
});
|
|
4650
|
+
var CURSORS = {
|
|
4651
|
+
default: "default",
|
|
4652
|
+
cross: "crosshair",
|
|
4653
|
+
grab: "grab",
|
|
4654
|
+
grabbing: "grabbing",
|
|
4655
|
+
move: "move",
|
|
4656
|
+
pointer: "pointer",
|
|
4657
|
+
text: "text"
|
|
4658
|
+
};
|
|
4659
|
+
function cssCursor(type) {
|
|
4660
|
+
return CURSORS[type] ?? type;
|
|
4661
|
+
}
|
|
4662
|
+
function getShapeIndicatorNode(util, shape, bounds) {
|
|
4663
|
+
const own = util.indicator?.(shape);
|
|
4664
|
+
if (own !== null && own !== void 0 && own !== false) return own;
|
|
4665
|
+
if (!bounds) return null;
|
|
4666
|
+
return /* @__PURE__ */ jsx("rect", { x: bounds.x, y: bounds.y, width: bounds.w, height: bounds.h });
|
|
4667
|
+
}
|
|
4668
|
+
var DefaultIndicators = track(function DefaultIndicators2({ editor }) {
|
|
4669
|
+
const bounds = editor.getSelectionPageBounds();
|
|
4670
|
+
const hovered = editor.getHoveredShape();
|
|
4671
|
+
const selected = editor.getSelectedShapes();
|
|
4672
|
+
const cam = editor.getCamera();
|
|
4673
|
+
const z = cam.z;
|
|
4674
|
+
const tool = editor.getCurrentToolId();
|
|
4675
|
+
const toScreen = (x, y) => [(x + cam.x) * z, (y + cam.y) * z];
|
|
4676
|
+
const items = [];
|
|
4677
|
+
const indicatorFor = (shape, key, stroke) => {
|
|
4678
|
+
const b = editor.getShapeGeometryBounds(shape);
|
|
4679
|
+
const node = getShapeIndicatorNode(editor.getShapeUtil(shape), shape, b);
|
|
4680
|
+
if (node === null) return null;
|
|
4681
|
+
const m = editor.getShapePageTransform(shape);
|
|
4682
|
+
return /* @__PURE__ */ jsx(
|
|
4683
|
+
"g",
|
|
4684
|
+
{
|
|
4685
|
+
transform: `matrix(${z} 0 0 ${z} ${cam.x * z} ${cam.y * z}) matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`,
|
|
4686
|
+
fill: "none",
|
|
4687
|
+
stroke,
|
|
4688
|
+
strokeWidth: INDICATOR_STROKE / z,
|
|
4689
|
+
children: node
|
|
4690
|
+
},
|
|
4691
|
+
key
|
|
4692
|
+
);
|
|
4693
|
+
};
|
|
4694
|
+
if (hovered && tool === "select" && !selected.some((s) => s.id === hovered.id)) {
|
|
4695
|
+
const ind = indicatorFor(hovered, "hover", SELECTION);
|
|
4696
|
+
if (ind) items.push(ind);
|
|
4697
|
+
}
|
|
4698
|
+
if (selected.length > 1) {
|
|
4699
|
+
for (const s of selected) {
|
|
4700
|
+
const ind = indicatorFor(s, `sel-${s.id}`, SELECTION);
|
|
4701
|
+
if (ind) items.push(ind);
|
|
4702
|
+
}
|
|
4703
|
+
}
|
|
4704
|
+
if (bounds && tool === "select") {
|
|
4705
|
+
const info = getSelectionHandlePositions(editor);
|
|
4706
|
+
const stroke = SELECTION;
|
|
4707
|
+
if (selected.length === 1) {
|
|
4708
|
+
const shape = selected[0];
|
|
4709
|
+
const util = editor.getShapeUtil(shape);
|
|
4710
|
+
const m = editor.getShapePageTransform(shape);
|
|
4711
|
+
if (!util.hideSelectionBoundsFg(shape)) {
|
|
4712
|
+
const ind = indicatorFor(shape, "bounds", stroke);
|
|
4713
|
+
if (ind) items.push(ind);
|
|
4714
|
+
}
|
|
4715
|
+
const handles = util.getHandles?.(shape) ?? [];
|
|
4716
|
+
for (const hd of handles) {
|
|
4717
|
+
const p = new Vec(m.a * hd.x + m.c * hd.y + m.e, m.b * hd.x + m.d * hd.y + m.f);
|
|
4718
|
+
const sp = editor.pageToScreen(p);
|
|
4719
|
+
items.push(
|
|
4720
|
+
/* @__PURE__ */ jsx(
|
|
4721
|
+
"circle",
|
|
4722
|
+
{
|
|
4723
|
+
cx: sp.x,
|
|
4724
|
+
cy: sp.y,
|
|
4725
|
+
r: hd.type === "virtual" ? HANDLE.virtual : HANDLE.shape,
|
|
4726
|
+
fill: hd.type === "virtual" ? stroke : HANDLE_FILL,
|
|
4727
|
+
stroke,
|
|
4728
|
+
strokeWidth: INDICATOR_STROKE,
|
|
4729
|
+
opacity: hd.type === "virtual" ? 0.6 : 1
|
|
4730
|
+
},
|
|
4731
|
+
`h-${hd.id}`
|
|
4732
|
+
)
|
|
4733
|
+
);
|
|
4734
|
+
}
|
|
4735
|
+
} else {
|
|
4736
|
+
const [x0, y0] = toScreen(bounds.x, bounds.y);
|
|
4737
|
+
const [x1, y1] = toScreen(bounds.maxX, bounds.maxY);
|
|
4738
|
+
items.push(/* @__PURE__ */ jsx("rect", { x: x0, y: y0, width: x1 - x0, height: y1 - y0, fill: "none", stroke, strokeWidth: INDICATOR_STROKE }, "bounds"));
|
|
4739
|
+
}
|
|
4740
|
+
if (info) {
|
|
4741
|
+
for (const h of info.handles) {
|
|
4742
|
+
if (h.handle === "rotate") {
|
|
4743
|
+
items.push(/* @__PURE__ */ jsx("circle", { cx: h.point.x, cy: h.point.y, r: HANDLE.rotate, fill: HANDLE_FILL, stroke, strokeWidth: INDICATOR_STROKE }, "rotate"));
|
|
4744
|
+
} else if (h.handle.includes("_")) {
|
|
4745
|
+
items.push(
|
|
4746
|
+
/* @__PURE__ */ jsx(
|
|
4747
|
+
"rect",
|
|
4748
|
+
{
|
|
4749
|
+
x: h.point.x - HANDLE.corner / 2,
|
|
4750
|
+
y: h.point.y - HANDLE.corner / 2,
|
|
4751
|
+
width: HANDLE.corner,
|
|
4752
|
+
height: HANDLE.corner,
|
|
4753
|
+
rx: 2,
|
|
4754
|
+
fill: HANDLE_FILL,
|
|
4755
|
+
stroke,
|
|
4756
|
+
strokeWidth: INDICATOR_STROKE
|
|
4757
|
+
},
|
|
4758
|
+
h.handle
|
|
4759
|
+
)
|
|
4760
|
+
);
|
|
4761
|
+
}
|
|
4762
|
+
}
|
|
4763
|
+
}
|
|
4764
|
+
}
|
|
4765
|
+
return /* @__PURE__ */ jsx(Fragment, { children: items });
|
|
4766
|
+
});
|
|
4767
|
+
var SnapLines = track(function SnapLines2({ editor }) {
|
|
4768
|
+
const lines = editor.snaps.getLines();
|
|
4769
|
+
if (lines.length === 0) return null;
|
|
4770
|
+
return /* @__PURE__ */ jsx(Fragment, { children: lines.map((l) => {
|
|
4771
|
+
const pts = l.points.map((p) => editor.pageToScreen(p));
|
|
4772
|
+
return /* @__PURE__ */ jsxs("g", { children: [
|
|
4773
|
+
/* @__PURE__ */ jsx("polyline", { points: pts.map((p) => `${p.x},${p.y}`).join(" "), fill: "none", stroke: SNAP, strokeWidth: INDICATOR_STROKE }),
|
|
4774
|
+
pts.map((p, i) => /* @__PURE__ */ jsx("line", { x1: p.x - 4, y1: p.y - 4, x2: p.x + 4, y2: p.y + 4, stroke: SNAP, strokeWidth: INDICATOR_STROKE }, i)),
|
|
4775
|
+
pts.map((p, i) => /* @__PURE__ */ jsx("line", { x1: p.x - 4, y1: p.y + 4, x2: p.x + 4, y2: p.y - 4, stroke: SNAP, strokeWidth: INDICATOR_STROKE }, `b${i}`))
|
|
4776
|
+
] }, l.id);
|
|
4777
|
+
}) });
|
|
4778
|
+
});
|
|
4779
|
+
var DefaultBrush = track(function DefaultBrush2({ editor }) {
|
|
4780
|
+
const brush = editor.getInstanceState().brush;
|
|
4781
|
+
if (!brush) return null;
|
|
4782
|
+
const cam = editor.getCamera();
|
|
4783
|
+
const x = (brush.x + cam.x) * cam.z;
|
|
4784
|
+
const y = (brush.y + cam.y) * cam.z;
|
|
4785
|
+
return /* @__PURE__ */ jsx(
|
|
4786
|
+
"rect",
|
|
4787
|
+
{
|
|
4788
|
+
x,
|
|
4789
|
+
y,
|
|
4790
|
+
width: brush.w * cam.z,
|
|
4791
|
+
height: brush.h * cam.z,
|
|
4792
|
+
fill: BRUSH_FILL,
|
|
4793
|
+
stroke: SELECTION,
|
|
4794
|
+
strokeWidth: INDICATOR_STROKE
|
|
4795
|
+
}
|
|
4796
|
+
);
|
|
4797
|
+
});
|
|
4798
|
+
|
|
4799
|
+
export { AssetRecordType, BaseBoxShapeUtil, BindingRecordType, BindingUtil, Box, CameraRecordType, Canvas, Circle2d, CubicSpline2d, DEFAULT_COLORS, DEFAULT_DASHES, DEFAULT_EDITOR_CONFIG, DEFAULT_FILLS, DEFAULT_FONTS, DEFAULT_H_ALIGNS, DEFAULT_SIZES, DEFAULT_V_ALIGNS, DOCUMENT_ID, DefaultColorStyle, DefaultDashStyle, DefaultFillStyle, DefaultFontStyle, DefaultHorizontalAlignStyle, DefaultLabelColorStyle, DefaultSizeStyle, DefaultVerticalAlignStyle, DocumentRecordType, EVENT_NAME_MAP, Edge2d, Editor, EditorContext, EditorProvider, Ellipse2d, EnumStyleProp, EventEmitter, FONT_SIZES, GEO_SHAPE_KINDS, GeoShapeGeoStyle, Geometry2d, Group2d, HANDLE_HIT_RADIUS, HandleTable, HistoryManager, INSTANCE_ID, InstancePageStateRecordType, InstancePresenceRecordType, InstanceRecordType, LIGHT_THEME, MAX_TEXTURE_RESOLUTION, MAX_TEXTURE_ZOOM, MenuManager, PRESENCE_COLORS, PageRecordType, Polygon2d, Polyline2d, ROTATE_HANDLE_OFFSET, Rectangle2d, RootState, STROKE_SIZES, ShapeRecordType, ShapeUtil, SharedStyleMap, SnapManager, StateNode, StyleProp, TextureManager, Vec, WebGL2Backend, bucketTextureResolution, createAssetId, createBackend, createBindingId, createRootState, createSchema, createShapeId, createStore, createUserPreferences, decodeDrawSegmentPath, getExportImplementation, getHandleHitRadius, getSelectionHandlePositions, getShapeIndicatorNode, getStylePropsOf, getTextMeasureProvider, hexToRgba, hitTestSelectionBounds, hitTestSelectionHandles, isAsset, isAssetId, isBinding, isBindingId, isInstancePresenceId, isPageId, isShape, isShapeId, normalizeLoadedRecords, pointInPolygon, randomPresenceColor, registerExportImplementation, registerTextMeasureImplementation, richTextToPlainText, useEditor, useMaybeEditor };
|
|
4800
|
+
//# sourceMappingURL=index.js.map
|
|
4801
|
+
//# sourceMappingURL=index.js.map
|