@scrawl-board/board 0.1.0-beta.4 → 0.1.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -151,7 +151,11 @@ function validateStrokes(raw) {
151
151
  for (let index = 0; index < raw.length; index += 1) {
152
152
  const value = raw[index];
153
153
  const path = `strokes[${index}]`;
154
- if (!isRecord(value) || !nonempty(value.id) || !nonempty(value.color) || !positive(value.baseWidth) || !optionalEnum(value.tool, ["marker", "highlighter"]) || !validLock(value) || !optionalString(value.clusterId) || !validMatrix(value.matrix) || !Array.isArray(value.points)) return validation("invalid stroke", path);
154
+ if (!isRecord(value) || !nonempty(value.id) || !nonempty(value.color) || !positive(value.baseWidth) || !optionalEnum(value.tool, [
155
+ "marker",
156
+ "highlighter",
157
+ "shape"
158
+ ]) || !validLock(value) || !optionalString(value.clusterId) || !validMatrix(value.matrix) || !Array.isArray(value.points)) return validation("invalid stroke", path);
155
159
  const points = [];
156
160
  for (let pointIndex = 0; pointIndex < value.points.length; pointIndex += 1) {
157
161
  const point = normalizePoint(value.points[pointIndex]);
@@ -432,9 +436,21 @@ var HIGHLIGHT_COLORS = {
432
436
  };
433
437
  var NOTE_COLORS = {
434
438
  yellow: "#FDE68A",
439
+ amber: "#FCD34D",
440
+ orange: "#FDBA74",
441
+ peach: "#FED7AA",
442
+ red: "#FCA5A5",
443
+ rose: "#FECACA",
435
444
  pink: "#FBCFE8",
445
+ magenta: "#F9A8D4",
446
+ purple: "#D8B4FE",
447
+ lavender: "#E9D5FF",
436
448
  blue: "#BFDBFE",
437
- green: "#BBF7D0"
449
+ cornflower: "#93C5FD",
450
+ sky: "#7DD3FC",
451
+ cyan: "#BAE6FD",
452
+ green: "#BBF7D0",
453
+ mint: "#86EFAC"
438
454
  };
439
455
  var TEXT_DEFAULT_SIZE = 1.8;
440
456
  function cloneText(block) {
@@ -36400,13 +36416,26 @@ var INK_Z = .02;
36400
36416
  var HIGHLIGHT_Z = .016;
36401
36417
  /** Ghosts sit under everything so live strokes always read on top. */
36402
36418
  var GHOST_Z = .012;
36419
+ /**
36420
+ * A miter join at each centerline point — averaging the incoming and
36421
+ * outgoing segment directions into one width offset — under-covers the
36422
+ * outside of a sharp turn: the two segments' edges diverge there, leaving a
36423
+ * real geometric gap (the board shows through, not just a shader artifact).
36424
+ * Barely visible on opaque ink, but glaring on the highlighter's lower,
36425
+ * multiply-blended opacity, which is why this reads as "goes transparent
36426
+ * at corners" specifically there even though the underlying strip geometry
36427
+ * is shared with ink. Below this turn angle a miter join is a fine
36428
+ * approximation and not worth the extra geometry.
36429
+ */
36430
+ var JOIN_ANGLE_THRESHOLD = .35;
36431
+ var JOIN_SEGMENTS = 10;
36403
36432
  function buildRibbonGeometry(points, baseWidth, z = INK_Z, handDrawn = true) {
36404
36433
  const edges = ribbonEdges(points, baseWidth, handDrawn);
36405
36434
  const n = edges.length;
36406
- const positions = new Float32Array(n * 2 * 3);
36407
- const uvs = new Float32Array(n * 2 * 2);
36408
- const alphas = new Float32Array(n * 2);
36409
- const indices = new Uint32Array((n - 1) * 6);
36435
+ const positions = new Array(n * 2 * 3);
36436
+ const uvs = new Array(n * 2 * 2);
36437
+ const alphas = new Array(n * 2);
36438
+ const indices = new Array((n - 1) * 6);
36410
36439
  for (let i = 0; i < n; i++) {
36411
36440
  const e = edges[i];
36412
36441
  const vi = i * 6;
@@ -36435,11 +36464,45 @@ function buildRibbonGeometry(points, baseWidth, z = INK_Z, handDrawn = true) {
36435
36464
  indices[ii + 5] = a + 2;
36436
36465
  }
36437
36466
  }
36467
+ for (let i = 1; i < n - 1; i++) {
36468
+ const prev = points[Math.max(0, i - 1)];
36469
+ const point = points[i];
36470
+ const next = points[Math.min(n - 1, i + 1)];
36471
+ const inX = point.x - prev.x, inY = point.y - prev.y;
36472
+ const outX = next.x - point.x, outY = next.y - point.y;
36473
+ const inLen = Math.hypot(inX, inY) || 1;
36474
+ const outLen = Math.hypot(outX, outY) || 1;
36475
+ const inDx = inX / inLen, inDy = inY / inLen;
36476
+ const outDx = outX / outLen, outDy = outY / outLen;
36477
+ const cos = inDx * outDx + inDy * outDy;
36478
+ if (Math.acos(Math.min(1, Math.max(-1, cos))) < JOIN_ANGLE_THRESHOLD) continue;
36479
+ const radius = (handDrawn ? baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) : baseWidth) / 2;
36480
+ const t = i / (n - 1);
36481
+ const alpha = edges[i].alpha;
36482
+ const side = inDx * outDy - inDy * outDx > 0 ? -1 : 1;
36483
+ const inEdgeX = point.x + side * -inDy * radius, inEdgeY = point.y + side * inDx * radius;
36484
+ const outEdgeX = point.x + side * -outDy * radius, outEdgeY = point.y + side * outDx * radius;
36485
+ const angle1 = Math.atan2(inEdgeY - point.y, inEdgeX - point.x);
36486
+ let sweep = Math.atan2(outEdgeY - point.y, outEdgeX - point.x) - angle1;
36487
+ while (sweep > Math.PI) sweep -= Math.PI * 2;
36488
+ while (sweep < -Math.PI) sweep += Math.PI * 2;
36489
+ const center = positions.length / 3;
36490
+ positions.push(point.x, point.y, z);
36491
+ uvs.push(t, .5);
36492
+ alphas.push(alpha);
36493
+ for (let s = 0; s <= JOIN_SEGMENTS; s++) {
36494
+ const theta = angle1 + sweep * (s / JOIN_SEGMENTS);
36495
+ positions.push(point.x + Math.cos(theta) * radius, point.y + Math.sin(theta) * radius, z);
36496
+ uvs.push(t, .5);
36497
+ alphas.push(alpha);
36498
+ if (s > 0) indices.push(center, center + s, center + s + 1);
36499
+ }
36500
+ }
36438
36501
  const geometry = new BufferGeometry();
36439
- geometry.setAttribute("position", new BufferAttribute(positions, 3));
36440
- geometry.setAttribute("uv", new BufferAttribute(uvs, 2));
36441
- geometry.setAttribute("aAlpha", new BufferAttribute(alphas, 1));
36442
- geometry.setIndex(new BufferAttribute(indices, 1));
36502
+ geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
36503
+ geometry.setAttribute("uv", new BufferAttribute(new Float32Array(uvs), 2));
36504
+ geometry.setAttribute("aAlpha", new BufferAttribute(new Float32Array(alphas), 1));
36505
+ geometry.setIndex(indices);
36443
36506
  geometry.computeBoundingSphere();
36444
36507
  return geometry;
36445
36508
  }
@@ -36550,6 +36613,7 @@ var StrokeRenderer = class {
36550
36613
  if (mesh) {
36551
36614
  mesh.geometry.dispose();
36552
36615
  mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36616
+ mesh.material = stroke.tool === "highlighter" ? this.materials.highlight(stroke.color) : this.materials.get(stroke.color);
36553
36617
  }
36554
36618
  }
36555
36619
  for (const stroke of change.transformed) {
@@ -36580,9 +36644,9 @@ function syncMatrix(mesh, stroke) {
36580
36644
  }
36581
36645
  //#endregion
36582
36646
  //#region src/renderer/shapes/markerProp.ts
36583
- var BODY_LENGTH = 11;
36584
- var BODY_RADIUS = .85;
36585
- var TIP_LENGTH = 1.6;
36647
+ var BODY_LENGTH = 7;
36648
+ var BODY_RADIUS = .55;
36649
+ var TIP_LENGTH = 1.05;
36586
36650
  var BASE_TILT = .42;
36587
36651
  var VELOCITY_TILT = .22;
36588
36652
  var MAX_EXTRA_TILT = .35;
@@ -36613,12 +36677,12 @@ var MarkerProp = class {
36613
36677
  const tip = new Mesh(new ConeGeometry(BODY_RADIUS * .55, TIP_LENGTH, 14), tipMaterial);
36614
36678
  tip.rotation.x = Math.PI;
36615
36679
  tip.position.y = TIP_LENGTH / 2;
36616
- this.body.position.y = 7.1;
36680
+ this.body.position.y = 4.55;
36617
36681
  const pen = new Group();
36618
36682
  pen.add(this.body, tip);
36619
36683
  pen.rotation.x = Math.PI / 2;
36620
36684
  this.group.add(pen);
36621
- this.shadow = new Mesh(new CircleGeometry(1.4, 24), new MeshBasicMaterial({
36685
+ this.shadow = new Mesh(new CircleGeometry(.9, 24), new MeshBasicMaterial({
36622
36686
  color: 0,
36623
36687
  transparent: true,
36624
36688
  opacity: .13,
@@ -44729,7 +44793,7 @@ function distToSegmentSq(p, a, b) {
44729
44793
  }
44730
44794
  //#endregion
44731
44795
  //#region src/interaction/tools/eraserTool.ts
44732
- var DECAY_PER_PASS = .4;
44796
+ var DECAY_PER_PASS = 1;
44733
44797
  /** A point can take another decay pass after this long — rubbing works. */
44734
44798
  var REARM_MS = 250;
44735
44799
  var EraserTool = class {
@@ -44823,19 +44887,11 @@ var EraserTool = class {
44823
44887
  const runs = partitionByErasure(current.points);
44824
44888
  const survives = runs.filter((r) => r.survives && r.points.length >= 2);
44825
44889
  const keepId = survives.length === 1 && survives[0].points.length === current.points.length;
44826
- const m = current.matrix;
44827
44890
  for (const run of runs) if (run.survives && run.points.length >= 2) after.push({
44828
44891
  ...current,
44829
44892
  id: keepId ? current.id : crypto.randomUUID(),
44830
44893
  points: run.points
44831
44894
  });
44832
- else if (!run.survives) {
44833
- const world = m ? run.points.map((p) => ({
44834
- ...p,
44835
- ...apply(m, p)
44836
- })) : run.points;
44837
- this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1), current.tool !== "shape");
44838
- }
44839
44895
  }
44840
44896
  doc.removeStrokes(before.map((s) => s.id));
44841
44897
  doc.addStrokes(after.map(cloneStroke));
@@ -45043,10 +45099,10 @@ function buildShape(kind, a, b, step, constrain) {
45043
45099
  case "ellipse": return [ellipse(a, targetB, step)];
45044
45100
  case "line": return [sampleSegment(a, constrain ? snappedEnd(a, b) : b, step)];
45045
45101
  case "arrow": return arrow(a, constrain ? snappedEnd(a, b) : b, step);
45046
- case "triangle": return [polygon(3, a, targetB, step, -Math.PI / 2)];
45102
+ case "triangle": return [polygon(3, a, targetB, step, Math.PI / 2)];
45047
45103
  case "diamond": return [polygon(4, a, targetB, step, 0)];
45048
- case "pentagon": return [polygon(5, a, targetB, step, -Math.PI / 2)];
45049
- case "hexagon": return [polygon(6, a, targetB, step, -Math.PI / 2)];
45104
+ case "pentagon": return [polygon(5, a, targetB, step, Math.PI / 2)];
45105
+ case "hexagon": return [polygon(6, a, targetB, step, Math.PI / 2)];
45050
45106
  case "octagon": return [polygon(8, a, targetB, step, Math.PI / 8)];
45051
45107
  case "star": return [star(5, a, targetB, step)];
45052
45108
  case "heart": return [heart(a, targetB, step)];
@@ -45245,6 +45301,7 @@ var ShapeTool = class {
45245
45301
  this.ctx.clusters.assign(strokes[0]);
45246
45302
  for (const s of strokes) s.clusterId = strokes[0].clusterId;
45247
45303
  this.ctx.history.execute(new AddStrokesCommand(strokes));
45304
+ this.ctx.selection.set(strokes.map((s) => s.id));
45248
45305
  this.ctx.setTool?.("select");
45249
45306
  }
45250
45307
  this.ctx.transition("idle");
@@ -45826,6 +45883,13 @@ var HANDLE_HIT_PX = 12;
45826
45883
  var ROTATE_OFFSET_PX = 26;
45827
45884
  var ROTATE_HIT_PX = 14;
45828
45885
  var COLOR = 2450411;
45886
+ /**
45887
+ * Visual-only outset between the selected object and the gizmo's outline/
45888
+ * handles — the object's actual bounding box (used for resize math, see
45889
+ * `anchorPoint`/`handlePoint`'s callers in `selectTool.ts`) never changes;
45890
+ * only where the outline and handle meshes are *drawn* and hit-tested does.
45891
+ */
45892
+ var GAP_PX = 8;
45829
45893
  var SelectionGizmo = class {
45830
45894
  constructor(scene) {
45831
45895
  this.group = new Group();
@@ -45840,7 +45904,7 @@ var SelectionGizmo = class {
45840
45904
  this.outline = new LineLoop(new BufferGeometry(), this.lineMaterial);
45841
45905
  this.outline.position.z = GIZMO_Z;
45842
45906
  this.outline.frustumCulled = false;
45843
- const handleGeometry = new PlaneGeometry(1, 1);
45907
+ const handleGeometry = new CircleGeometry(.5, 20);
45844
45908
  const handleMaterial = new MeshBasicMaterial({ color: COLOR });
45845
45909
  const makeHandle = () => new Mesh(handleGeometry, handleMaterial);
45846
45910
  this.handles = {
@@ -45868,18 +45932,19 @@ var SelectionGizmo = class {
45868
45932
  if (!box) return;
45869
45933
  this.lineMaterial.color.setHex(locked ? 16096779 : COLOR);
45870
45934
  this.rotateHandle.visible = !locked;
45935
+ const visual = this.visualBox(box, wpp);
45871
45936
  const positions = new Float32Array([
45872
- box.minX,
45873
- box.minY,
45937
+ visual.minX,
45938
+ visual.minY,
45874
45939
  GIZMO_Z,
45875
- box.maxX,
45876
- box.minY,
45940
+ visual.maxX,
45941
+ visual.minY,
45877
45942
  GIZMO_Z,
45878
- box.maxX,
45879
- box.maxY,
45943
+ visual.maxX,
45944
+ visual.maxY,
45880
45945
  GIZMO_Z,
45881
- box.minX,
45882
- box.maxY,
45946
+ visual.minX,
45947
+ visual.maxY,
45883
45948
  GIZMO_Z
45884
45949
  ]);
45885
45950
  this.outline.geometry.dispose();
@@ -45891,17 +45956,27 @@ var SelectionGizmo = class {
45891
45956
  for (const [handle, mesh] of Object.entries(this.handles)) {
45892
45957
  mesh.visible = !locked;
45893
45958
  if (!locked) {
45894
- const p = this.handlePoint(handle, box);
45959
+ const p = this.handlePoint(handle, visual);
45895
45960
  mesh.position.set(p.x, p.y, GIZMO_Z);
45896
45961
  mesh.scale.set(size, size, 1);
45897
45962
  }
45898
45963
  }
45899
45964
  if (!locked) {
45900
- const rotate = this.rotatePoint(box, wpp);
45965
+ const rotate = this.rotatePoint(visual, wpp);
45901
45966
  this.rotateHandle.position.set(rotate.x, rotate.y, GIZMO_Z);
45902
45967
  this.rotateHandle.scale.set(size, size, 1);
45903
45968
  }
45904
45969
  }
45970
+ /** The object's real bbox outset by `GAP_PX`, screen-constant — where the outline/handles are drawn and hit-tested. Never used for resize math (see `anchorPoint`). */
45971
+ visualBox(box, wpp) {
45972
+ const gap = GAP_PX * wpp;
45973
+ return {
45974
+ minX: box.minX - gap,
45975
+ minY: box.minY - gap,
45976
+ maxX: box.maxX + gap,
45977
+ maxY: box.maxY + gap
45978
+ };
45979
+ }
45905
45980
  handlePoint(handle, box) {
45906
45981
  const midX = (box.minX + box.maxX) / 2;
45907
45982
  const midY = (box.minY + box.maxY) / 2;
@@ -45989,7 +46064,8 @@ var SelectionGizmo = class {
45989
46064
  if (!this.box || this.isLocked) return null;
45990
46065
  const handleR = HANDLE_HIT_PX * this.wpp;
45991
46066
  const edgeSlop = 6 * this.wpp;
45992
- const rotate = this.rotatePoint(this.box, this.wpp);
46067
+ const visual = this.visualBox(this.box, this.wpp);
46068
+ const rotate = this.rotatePoint(visual, this.wpp);
45993
46069
  if (Math.hypot(p.x - rotate.x, p.y - rotate.y) < ROTATE_HIT_PX * this.wpp) return { kind: "rotate" };
45994
46070
  for (const handle of [
45995
46071
  "nw",
@@ -46001,13 +46077,13 @@ var SelectionGizmo = class {
46001
46077
  "e",
46002
46078
  "w"
46003
46079
  ]) {
46004
- const hp = this.handlePoint(handle, this.box);
46080
+ const hp = this.handlePoint(handle, visual);
46005
46081
  if (Math.abs(p.x - hp.x) < handleR && Math.abs(p.y - hp.y) < handleR) return {
46006
46082
  kind: "scale",
46007
46083
  handle
46008
46084
  };
46009
46085
  }
46010
- const { minX, maxX, minY, maxY } = this.box;
46086
+ const { minX, maxX, minY, maxY } = visual;
46011
46087
  const inX = p.x >= minX - edgeSlop && p.x <= maxX + edgeSlop;
46012
46088
  const inY = p.y >= minY - edgeSlop && p.y <= maxY + edgeSlop;
46013
46089
  if (inX && Math.abs(p.y - maxY) < edgeSlop) return {
@@ -46027,7 +46103,7 @@ var SelectionGizmo = class {
46027
46103
  handle: "w"
46028
46104
  };
46029
46105
  const pad = 2 * this.wpp;
46030
- if (p.x >= minX - pad && p.x <= maxX + pad && p.y >= minY - pad && p.y <= maxY + pad) return { kind: "inside" };
46106
+ if (p.x >= this.box.minX - pad && p.x <= this.box.maxX + pad && p.y >= this.box.minY - pad && p.y <= this.box.maxY + pad) return { kind: "inside" };
46031
46107
  return null;
46032
46108
  }
46033
46109
  rotatePoint(box, wpp) {
@@ -48260,6 +48336,112 @@ function createBoardControllerWithEngine(options, existingEngine) {
48260
48336
  })),
48261
48337
  ...[...document.allCustomObjects()].map((value) => clone(toBoardObject(value)))
48262
48338
  ];
48339
+ const projectToScreen = (point) => engine ? engine.boardToScreen(point) : {
48340
+ x: (point.x - view.x) * view.zoom,
48341
+ y: (view.y - point.y) * view.zoom
48342
+ };
48343
+ const lockFields = (item) => ({
48344
+ locked: !!item.locked,
48345
+ ...item.lockedBy ? { lockedBy: item.lockedBy } : {},
48346
+ ...item.lockedByName ? { lockedByName: item.lockedByName } : {}
48347
+ });
48348
+ const lockFieldsMany = (items) => {
48349
+ const locked = items.filter((i) => i.locked);
48350
+ if (locked.length === 0) return { locked: false };
48351
+ const lockedBys = [...new Set(locked.map((i) => i.lockedBy).filter((v) => !!v))];
48352
+ const names = [...new Set(locked.map((i) => i.lockedByName).filter((v) => !!v))];
48353
+ return {
48354
+ locked: true,
48355
+ ...lockedBys.length === 1 ? { lockedBy: lockedBys[0] } : {},
48356
+ ...names.length === 1 ? { lockedByName: names[0] } : {}
48357
+ };
48358
+ };
48359
+ const strokeClusterAnchor = (strokes) => {
48360
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
48361
+ for (const s of strokes) for (const p of s.points) {
48362
+ const world = s.matrix ? apply(s.matrix, p) : p;
48363
+ minX = Math.min(minX, world.x);
48364
+ maxX = Math.max(maxX, world.x);
48365
+ minY = Math.min(minY, world.y);
48366
+ maxY = Math.max(maxY, world.y);
48367
+ }
48368
+ if (!Number.isFinite(minX)) return projectToScreen({
48369
+ x: 0,
48370
+ y: 0
48371
+ });
48372
+ return projectToScreen({
48373
+ x: (minX + maxX) / 2,
48374
+ y: maxY + 1.4
48375
+ });
48376
+ };
48377
+ /** Per-type anchor formulas mirror `ScrawlEngine.getSelectedItemInfo` exactly, so a Host-built toolbar lands in the same spot the SDK's own would. */
48378
+ const anchorFor = (obj) => {
48379
+ switch (obj.type) {
48380
+ case "note": return projectToScreen({
48381
+ x: obj.x,
48382
+ y: obj.y + obj.size / 2 + 1
48383
+ });
48384
+ case "text": {
48385
+ const { width } = measureTextBlock(obj.text, obj.fontSize);
48386
+ return projectToScreen({
48387
+ x: obj.x + width / 2,
48388
+ y: obj.y + 1
48389
+ });
48390
+ }
48391
+ case "table": {
48392
+ const { width } = measureTable(obj);
48393
+ return projectToScreen({
48394
+ x: obj.x + width / 2,
48395
+ y: obj.y + 1.2
48396
+ });
48397
+ }
48398
+ case "image": return projectToScreen({
48399
+ x: obj.x,
48400
+ y: obj.y + obj.height / 2 + 1.2
48401
+ });
48402
+ case "timer": return projectToScreen({
48403
+ x: obj.x,
48404
+ y: obj.y + obj.size / 2 + 1.2
48405
+ });
48406
+ case "custom": {
48407
+ const b = obj.fallback.bounds;
48408
+ return projectToScreen(apply(obj.transform, {
48409
+ x: b.x + b.width / 2,
48410
+ y: b.y + b.height + 1
48411
+ }));
48412
+ }
48413
+ case "stroke": return strokeClusterAnchor([obj]);
48414
+ }
48415
+ };
48416
+ const computeFocusedItem = () => {
48417
+ if (selection.length === 0) return null;
48418
+ if (selection.length === 1) {
48419
+ const obj = readObject(selection[0]);
48420
+ if (!obj) return null;
48421
+ if (obj.type === "custom") return {
48422
+ type: "custom",
48423
+ id: obj.id,
48424
+ locked: false,
48425
+ screenPosition: anchorFor(obj)
48426
+ };
48427
+ return {
48428
+ type: obj.type,
48429
+ id: obj.id,
48430
+ ...lockFields(obj),
48431
+ screenPosition: anchorFor(obj)
48432
+ };
48433
+ }
48434
+ const objects = selection.map(readObject);
48435
+ if (!objects.every((o) => o?.type === "stroke")) return null;
48436
+ const clusterId = objects[0].clusterId;
48437
+ if (!clusterId || !objects.every((o) => o.clusterId === clusterId)) return null;
48438
+ return {
48439
+ type: "stroke",
48440
+ id: objects[0].id,
48441
+ ...lockFieldsMany(objects),
48442
+ screenPosition: strokeClusterAnchor(objects)
48443
+ };
48444
+ };
48263
48445
  const updateObject = (id, patch) => {
48264
48446
  assertMutable();
48265
48447
  const before = readObject(id);
@@ -48301,6 +48483,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48301
48483
  zoom: view.zoom,
48302
48484
  readOnly,
48303
48485
  selection: Object.freeze([...selection]),
48486
+ focusedItem: computeFocusedItem(),
48304
48487
  strokeCount: [...document.all()].length,
48305
48488
  objectCount: allObjects().length,
48306
48489
  canUndo: history.canUndo,
@@ -48715,10 +48898,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48715
48898
  changed();
48716
48899
  },
48717
48900
  get: () => ({ ...view }),
48718
- boardToScreen: (point) => engine ? engine.boardToScreen(point) : {
48719
- x: (point.x - view.x) * view.zoom,
48720
- y: (view.y - point.y) * view.zoom
48721
- },
48901
+ boardToScreen: (point) => projectToScreen(point),
48722
48902
  screenToBoard: (point) => engine ? engine.screenToBoard(point.x, point.y) ?? {
48723
48903
  x: point.x,
48724
48904
  y: point.y
@@ -48755,6 +48935,81 @@ function createBoardControllerWithEngine(options, existingEngine) {
48755
48935
  update(id, patch) {
48756
48936
  updateObject(id, patch);
48757
48937
  },
48938
+ duplicate(ids) {
48939
+ assertMutable();
48940
+ const objects = [...new Set(ids)].map(readObject).filter((o) => !!o);
48941
+ if (objects.length === 0) return [];
48942
+ const DX = 1.5;
48943
+ const DY = -1.5;
48944
+ const newClusterIds = /* @__PURE__ */ new Map();
48945
+ const clusterIdFor = (oldClusterId) => {
48946
+ let next = newClusterIds.get(oldClusterId);
48947
+ if (!next) {
48948
+ next = crypto.randomUUID();
48949
+ newClusterIds.set(oldClusterId, next);
48950
+ }
48951
+ return next;
48952
+ };
48953
+ const commands = [];
48954
+ const newIds = [];
48955
+ for (const obj of objects) {
48956
+ const id = createId();
48957
+ newIds.push(id);
48958
+ const rest = { ...obj };
48959
+ delete rest.locked;
48960
+ delete rest.lockedBy;
48961
+ delete rest.lockedByName;
48962
+ let duplicated;
48963
+ if (obj.type === "stroke") {
48964
+ const matrix = obj.matrix ?? [
48965
+ 1,
48966
+ 0,
48967
+ 0,
48968
+ 1,
48969
+ 0,
48970
+ 0
48971
+ ];
48972
+ duplicated = {
48973
+ ...rest,
48974
+ id,
48975
+ matrix: [
48976
+ matrix[0],
48977
+ matrix[1],
48978
+ matrix[2],
48979
+ matrix[3],
48980
+ matrix[4] + DX,
48981
+ matrix[5] + DY
48982
+ ],
48983
+ ...obj.clusterId ? { clusterId: clusterIdFor(obj.clusterId) } : {}
48984
+ };
48985
+ } else if (obj.type === "custom") {
48986
+ const t = obj.transform;
48987
+ duplicated = {
48988
+ ...rest,
48989
+ id,
48990
+ transform: [
48991
+ t[0],
48992
+ t[1],
48993
+ t[2],
48994
+ t[3],
48995
+ t[4] + DX,
48996
+ t[5] + DY
48997
+ ]
48998
+ };
48999
+ delete duplicated.lock;
49000
+ } else duplicated = {
49001
+ ...rest,
49002
+ id,
49003
+ x: obj.x + DX,
49004
+ y: obj.y + DY
49005
+ };
49006
+ validateBoardObject(duplicated.type, duplicated);
49007
+ commands.push(addCommand(duplicated.type, duplicated));
49008
+ }
49009
+ executeHistory(() => history.execute(new CommandBatch("duplicate content", commands)));
49010
+ changed();
49011
+ return newIds;
49012
+ },
48758
49013
  table: {
48759
49014
  addRow(tableId) {
48760
49015
  const table = document.getTable(tableId);
@@ -49688,6 +49943,24 @@ function InlineEditors({ controller, renderPortal }) {
49688
49943
  };
49689
49944
  useEffect(() => controller.on("edit-request", (event) => {
49690
49945
  const offset = rootOffset();
49946
+ if (event.kind === "text" && event.id === null) {
49947
+ const newId = controller.content.add({
49948
+ type: "text",
49949
+ x: event.boardX,
49950
+ y: event.boardY,
49951
+ text: "",
49952
+ color: event.color,
49953
+ fontSize: TEXT_DEFAULT_SIZE
49954
+ });
49955
+ controller.content.select([newId]);
49956
+ setEditor({
49957
+ ...event,
49958
+ id: newId,
49959
+ left: event.screenRect.left - offset.left,
49960
+ top: event.screenRect.top - offset.top
49961
+ });
49962
+ return;
49963
+ }
49691
49964
  setEditor({
49692
49965
  ...event,
49693
49966
  left: event.screenRect.left - offset.left,
@@ -49699,19 +49972,11 @@ function InlineEditors({ controller, renderPortal }) {
49699
49972
  setEditor(null);
49700
49973
  };
49701
49974
  const commitText = () => {
49702
- if (editor?.kind === "text") {
49975
+ if (editor?.kind === "text" && editor.id !== null) {
49703
49976
  const text = editor.text;
49704
- if (editor.id === null) {
49705
- if (text.trim()) controller.content.add({
49706
- type: "text",
49707
- x: editor.boardX,
49708
- y: editor.boardY,
49709
- text,
49710
- color: editor.color,
49711
- fontSize: TEXT_DEFAULT_SIZE
49712
- });
49713
- } else if (!text.trim()) controller.content.remove([editor.id]);
49714
- else controller.content.update(editor.id, { text });
49977
+ const existing = controller.query.get(editor.id);
49978
+ if (!text.trim()) controller.content.remove([editor.id]);
49979
+ else if (existing?.type !== "text" || existing.text !== text) controller.content.update(editor.id, { text });
49715
49980
  }
49716
49981
  setEditor(null);
49717
49982
  };
@@ -49904,6 +50169,366 @@ function InlineEditors({ controller, renderPortal }) {
49904
50169
  }))] });
49905
50170
  }
49906
50171
  //#endregion
50172
+ //#region src/ui/toolbar/focused-item-toolbar.tsx
50173
+ var NOTE_SIZES = [
50174
+ ["Small", 7],
50175
+ ["Medium", 10],
50176
+ ["Large", 14]
50177
+ ];
50178
+ var TEXT_SIZES = [
50179
+ ["Small", 1.2],
50180
+ ["Medium", 1.8],
50181
+ ["Large", 2.6]
50182
+ ];
50183
+ var SHAPE_WIDTHS = [
50184
+ ["Thin", .1],
50185
+ ["Medium", .15],
50186
+ ["Thick", .3]
50187
+ ];
50188
+ /** Matches the native selection gizmo's own outset (renderer/selection/gizmo.ts's GAP_PX), for a consistent look. */
50189
+ var HANDLE_GAP_PX = 8;
50190
+ var MIN_SIZE = 4;
50191
+ var MAX_SIZE = 30;
50192
+ var HANDLE_CORNERS = [
50193
+ "nw",
50194
+ "ne",
50195
+ "sw",
50196
+ "se"
50197
+ ];
50198
+ /**
50199
+ * Floating toolbar above the focused note, shape, or text block — Colour,
50200
+ * Size (note/text) or Width (shape), Lock/Unlock, Duplicate, Delete.
50201
+ * Table/image/timer/custom objects and plain (non-shape) ink strokes never
50202
+ * get a toolbar here — out of scope for this destination.
50203
+ *
50204
+ * Lock/Unlock carries no per-user ownership gating: nothing else in this
50205
+ * SDK enforces lock ownership either (`content.update` never checks
50206
+ * `locked`, and the engine's own internal unlock check has no way for a
50207
+ * Host to ever supply a real user id) — locking is advisory UI state
50208
+ * throughout, and this toolbar matches that rather than inventing an
50209
+ * enforcement story alone.
50210
+ *
50211
+ * A shape can be several strokes sharing one `clusterId` (e.g. an arrow's
50212
+ * shaft + head) — every action here applies to the whole cluster. Resolved
50213
+ * via `query.all()` + `clusterId`, not `snapshot.selection`: a canvas click
50214
+ * on one member selects every member internally, but the public selection
50215
+ * bridge only ever reports one id (`FocusedItem.id` is singular by design),
50216
+ * so reconstructing the cluster from the document is the reliable path
50217
+ * regardless of how the selection was made.
50218
+ *
50219
+ * A focused note also gets drag-to-resize corner handles — notes have no
50220
+ * gizmo of their own (the native selection gizmo is ink-stroke/shape-only),
50221
+ * so this is that capability's default-ui-owned equivalent. Proportional
50222
+ * (always-square) resize from the note's own centre, clamped to
50223
+ * `[MIN_SIZE, MAX_SIZE]`, independent of which corner is grabbed.
50224
+ */
50225
+ function FocusedItemToolbar({ controller, snapshot }) {
50226
+ const focused = snapshot.focusedItem;
50227
+ if (!focused) return null;
50228
+ const obj = controller.query.get(focused.id);
50229
+ if (!obj) return null;
50230
+ if (obj.type === "note") return /* @__PURE__ */ jsx(NoteToolbar, {
50231
+ controller,
50232
+ snapshot,
50233
+ note: obj,
50234
+ locked: focused.locked,
50235
+ anchor: focused.screenPosition
50236
+ });
50237
+ if (obj.type === "text") return /* @__PURE__ */ jsx(TextToolbar, {
50238
+ controller,
50239
+ snapshot,
50240
+ text: obj,
50241
+ locked: focused.locked,
50242
+ anchor: focused.screenPosition
50243
+ });
50244
+ if (obj.type === "stroke" && obj.tool === "shape") return /* @__PURE__ */ jsx(ShapeToolbar, {
50245
+ controller,
50246
+ snapshot,
50247
+ stroke: obj,
50248
+ locked: focused.locked,
50249
+ anchor: focused.screenPosition
50250
+ });
50251
+ return null;
50252
+ }
50253
+ function ToolbarShell({ anchor, children }) {
50254
+ if (!anchor) return null;
50255
+ return /* @__PURE__ */ jsx("div", {
50256
+ "aria-label": "Selected object",
50257
+ className: "scrawl-board__focus-toolbar",
50258
+ role: "toolbar",
50259
+ style: {
50260
+ left: anchor.x,
50261
+ top: anchor.y
50262
+ },
50263
+ children
50264
+ });
50265
+ }
50266
+ function NoteToolbar({ controller, snapshot, note, locked, anchor }) {
50267
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50268
+ const locked_ = !mutable || locked;
50269
+ const half = note.size / 2;
50270
+ const cornerA = controller.view.boardToScreen({
50271
+ x: note.x - half,
50272
+ y: note.y + half
50273
+ });
50274
+ const cornerB = controller.view.boardToScreen({
50275
+ x: note.x + half,
50276
+ y: note.y - half
50277
+ });
50278
+ const outline = {
50279
+ left: Math.min(cornerA.x, cornerB.x) - HANDLE_GAP_PX,
50280
+ top: Math.min(cornerA.y, cornerB.y) - HANDLE_GAP_PX,
50281
+ right: Math.max(cornerA.x, cornerB.x) + HANDLE_GAP_PX,
50282
+ bottom: Math.max(cornerA.y, cornerB.y) + HANDLE_GAP_PX
50283
+ };
50284
+ const handlePoints = {
50285
+ nw: {
50286
+ x: outline.left,
50287
+ y: outline.top
50288
+ },
50289
+ ne: {
50290
+ x: outline.right,
50291
+ y: outline.top
50292
+ },
50293
+ sw: {
50294
+ x: outline.left,
50295
+ y: outline.bottom
50296
+ },
50297
+ se: {
50298
+ x: outline.right,
50299
+ y: outline.bottom
50300
+ }
50301
+ };
50302
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
50303
+ /* @__PURE__ */ jsx("div", {
50304
+ "aria-hidden": "true",
50305
+ className: "scrawl-board__focus-outline",
50306
+ "data-locked": locked,
50307
+ style: {
50308
+ left: outline.left,
50309
+ top: outline.top,
50310
+ width: outline.right - outline.left,
50311
+ height: outline.bottom - outline.top
50312
+ }
50313
+ }),
50314
+ mutable && !locked && HANDLE_CORNERS.map((corner) => /* @__PURE__ */ jsx(ResizeHandle, {
50315
+ controller,
50316
+ note,
50317
+ corner,
50318
+ x: handlePoints[corner].x,
50319
+ y: handlePoints[corner].y
50320
+ }, corner)),
50321
+ /* @__PURE__ */ jsxs(ToolbarShell, {
50322
+ anchor,
50323
+ children: [
50324
+ /* @__PURE__ */ jsxs("div", {
50325
+ className: "scrawl-board__style-group",
50326
+ children: [/* @__PURE__ */ jsx("span", { children: "Size" }), NOTE_SIZES.map(([label, size]) => /* @__PURE__ */ jsx("button", {
50327
+ "aria-label": `${label} note`,
50328
+ "aria-pressed": note.size === size,
50329
+ disabled: locked_,
50330
+ onClick: () => controller.content.update(note.id, { size }),
50331
+ type: "button",
50332
+ children: label[0]
50333
+ }, label))]
50334
+ }),
50335
+ /* @__PURE__ */ jsxs("div", {
50336
+ className: "scrawl-board__style-group",
50337
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(NOTE_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50338
+ "aria-label": `${name} note`,
50339
+ "aria-pressed": note.color.toLowerCase() === value.toLowerCase(),
50340
+ disabled: locked_,
50341
+ onClick: () => controller.content.update(note.id, { color: value }),
50342
+ type: "button",
50343
+ children: /* @__PURE__ */ jsx("span", {
50344
+ "aria-hidden": "true",
50345
+ style: { backgroundColor: value }
50346
+ })
50347
+ }, value))]
50348
+ }),
50349
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50350
+ controller,
50351
+ mutable,
50352
+ locked,
50353
+ ids: [note.id]
50354
+ })
50355
+ ]
50356
+ })
50357
+ ] });
50358
+ }
50359
+ /**
50360
+ * One corner handle — drag distance from the note's own centre (its `x`/`y`
50361
+ * already *is* the centre) drives a proportional, always-square resize,
50362
+ * independent of which corner is grabbed. `note` is fresh every render (a
50363
+ * new `content.update` during drag triggers a new snapshot), so the
50364
+ * closure never goes stale.
50365
+ */
50366
+ function ResizeHandle({ controller, note, corner, x, y }) {
50367
+ const [dragging, setDragging] = useState(false);
50368
+ const nudge = (delta) => {
50369
+ const half = Math.max(MIN_SIZE / 2, Math.min(MAX_SIZE / 2, note.size / 2 + delta));
50370
+ controller.content.update(note.id, { size: half * 2 });
50371
+ };
50372
+ return /* @__PURE__ */ jsx("span", {
50373
+ "aria-label": `Resize note (${corner})`,
50374
+ "aria-valuemax": MAX_SIZE,
50375
+ "aria-valuemin": MIN_SIZE,
50376
+ "aria-valuenow": note.size,
50377
+ role: "slider",
50378
+ tabIndex: 0,
50379
+ className: "scrawl-board__focus-handle",
50380
+ "data-orientation": corner === "nw" || corner === "se" ? "nwse" : "nesw",
50381
+ style: {
50382
+ left: x,
50383
+ top: y
50384
+ },
50385
+ onKeyDown: (event) => {
50386
+ if (event.key === "ArrowUp" || event.key === "ArrowRight") {
50387
+ event.preventDefault();
50388
+ nudge(.5);
50389
+ } else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
50390
+ event.preventDefault();
50391
+ nudge(-.5);
50392
+ }
50393
+ },
50394
+ onPointerDown: (event) => {
50395
+ event.stopPropagation();
50396
+ event.currentTarget.setPointerCapture(event.pointerId);
50397
+ setDragging(true);
50398
+ },
50399
+ onPointerMove: (event) => {
50400
+ if (!dragging) return;
50401
+ const board = controller.view.screenToBoard({
50402
+ x: event.clientX,
50403
+ y: event.clientY
50404
+ });
50405
+ const half = Math.max(MIN_SIZE / 2, Math.min(MAX_SIZE / 2, Math.max(Math.abs(board.x - note.x), Math.abs(board.y - note.y))));
50406
+ controller.content.update(note.id, { size: half * 2 });
50407
+ },
50408
+ onPointerUp: (event) => {
50409
+ event.currentTarget.releasePointerCapture(event.pointerId);
50410
+ setDragging(false);
50411
+ }
50412
+ });
50413
+ }
50414
+ function TextToolbar({ controller, snapshot, text, locked, anchor }) {
50415
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50416
+ const locked_ = !mutable || locked;
50417
+ return /* @__PURE__ */ jsxs(ToolbarShell, {
50418
+ anchor,
50419
+ children: [
50420
+ /* @__PURE__ */ jsxs("div", {
50421
+ className: "scrawl-board__style-group",
50422
+ children: [/* @__PURE__ */ jsx("span", { children: "Size" }), TEXT_SIZES.map(([label, fontSize]) => /* @__PURE__ */ jsx("button", {
50423
+ "aria-label": `${label} text`,
50424
+ "aria-pressed": Math.abs(text.fontSize - fontSize) < .001,
50425
+ disabled: locked_,
50426
+ onClick: () => controller.content.update(text.id, { fontSize }),
50427
+ type: "button",
50428
+ children: label[0]
50429
+ }, label))]
50430
+ }),
50431
+ /* @__PURE__ */ jsxs("div", {
50432
+ className: "scrawl-board__style-group",
50433
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(INK_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50434
+ "aria-label": `${name} text`,
50435
+ "aria-pressed": text.color.toLowerCase() === value.toLowerCase(),
50436
+ disabled: locked_,
50437
+ onClick: () => controller.content.update(text.id, { color: value }),
50438
+ type: "button",
50439
+ children: /* @__PURE__ */ jsx("span", {
50440
+ "aria-hidden": "true",
50441
+ style: { backgroundColor: value }
50442
+ })
50443
+ }, value))]
50444
+ }),
50445
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50446
+ controller,
50447
+ mutable,
50448
+ locked,
50449
+ ids: [text.id]
50450
+ })
50451
+ ]
50452
+ });
50453
+ }
50454
+ function ShapeToolbar({ controller, snapshot, stroke, locked, anchor }) {
50455
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50456
+ const locked_ = !mutable || locked;
50457
+ const ids = (stroke.clusterId ? controller.query.all().filter((o) => o.type === "stroke" && o.clusterId === stroke.clusterId) : [stroke]).map((m) => m.id);
50458
+ const applyToAll = (patch) => {
50459
+ for (const id of ids) controller.content.update(id, patch);
50460
+ };
50461
+ return /* @__PURE__ */ jsxs(ToolbarShell, {
50462
+ anchor,
50463
+ children: [
50464
+ /* @__PURE__ */ jsxs("div", {
50465
+ className: "scrawl-board__style-group",
50466
+ children: [/* @__PURE__ */ jsx("span", { children: "Width" }), SHAPE_WIDTHS.map(([label, width]) => /* @__PURE__ */ jsx("button", {
50467
+ "aria-label": `${label} stroke`,
50468
+ "aria-pressed": Math.abs(stroke.baseWidth - width) < .001,
50469
+ disabled: locked_,
50470
+ onClick: () => applyToAll({ baseWidth: width }),
50471
+ type: "button",
50472
+ children: label
50473
+ }, label))]
50474
+ }),
50475
+ /* @__PURE__ */ jsxs("div", {
50476
+ className: "scrawl-board__style-group",
50477
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(INK_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50478
+ "aria-label": `${name} shape`,
50479
+ "aria-pressed": stroke.color.toLowerCase() === value.toLowerCase(),
50480
+ disabled: locked_,
50481
+ onClick: () => applyToAll({ color: value }),
50482
+ type: "button",
50483
+ children: /* @__PURE__ */ jsx("span", {
50484
+ "aria-hidden": "true",
50485
+ style: { backgroundColor: value }
50486
+ })
50487
+ }, value))]
50488
+ }),
50489
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50490
+ controller,
50491
+ mutable,
50492
+ locked,
50493
+ ids
50494
+ })
50495
+ ]
50496
+ });
50497
+ }
50498
+ function LockDuplicateDelete({ controller, mutable, locked, ids }) {
50499
+ return /* @__PURE__ */ jsxs("div", {
50500
+ className: "scrawl-board__style-group",
50501
+ children: [
50502
+ /* @__PURE__ */ jsx("button", {
50503
+ "aria-label": locked ? "Unlock" : "Lock",
50504
+ disabled: !mutable,
50505
+ onClick: () => {
50506
+ for (const id of ids) controller.content.update(id, locked ? { locked: false } : { locked: true });
50507
+ },
50508
+ type: "button",
50509
+ children: locked ? "Unlock" : "Lock"
50510
+ }),
50511
+ /* @__PURE__ */ jsx("button", {
50512
+ "aria-label": "Duplicate",
50513
+ disabled: !mutable || locked,
50514
+ onClick: () => {
50515
+ const newIds = controller.content.duplicate(ids);
50516
+ if (newIds.length) controller.content.select(newIds);
50517
+ },
50518
+ type: "button",
50519
+ children: "Duplicate"
50520
+ }),
50521
+ /* @__PURE__ */ jsx("button", {
50522
+ "aria-label": "Delete",
50523
+ disabled: !mutable || locked,
50524
+ onClick: () => controller.content.remove(ids),
50525
+ type: "button",
50526
+ children: "Delete"
50527
+ })
50528
+ ]
50529
+ });
50530
+ }
50531
+ //#endregion
49907
50532
  //#region src/ui/toolbar/style-shelf.tsx
49908
50533
  var ERASER_RADII = [
49909
50534
  ["Fine", 1],
@@ -49942,7 +50567,7 @@ var SHELF_TOOLS = /* @__PURE__ */ new Set([
49942
50567
  "timer"
49943
50568
  ]);
49944
50569
  /** Contextual per-tool style controls — visible while a styleable tool is active. */
49945
- function StyleShelf({ controller, snapshot }) {
50570
+ function StyleShelf({ controller, snapshot, anchor }) {
49946
50571
  if (!SHELF_TOOLS.has(snapshot.tool)) return null;
49947
50572
  const mutable = snapshot.status === "ready" && !snapshot.readOnly;
49948
50573
  const { style } = snapshot;
@@ -49950,7 +50575,12 @@ function StyleShelf({ controller, snapshot }) {
49950
50575
  return /* @__PURE__ */ jsxs("div", {
49951
50576
  "aria-label": "Board style",
49952
50577
  className: "scrawl-board__style-shelf",
50578
+ "data-anchored": !!anchor,
49953
50579
  role: "toolbar",
50580
+ style: anchor ? {
50581
+ left: anchor.x,
50582
+ top: anchor.y
50583
+ } : void 0,
49954
50584
  children: [
49955
50585
  snapshot.tool === "highlighter" && /* @__PURE__ */ jsxs("div", {
49956
50586
  className: "scrawl-board__style-group",
@@ -50180,6 +50810,7 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50180
50810
  const [panel, setPanel] = useState(null);
50181
50811
  const [query, setQuery] = useState("");
50182
50812
  const [announcement, setAnnouncement] = useState("Board controls ready");
50813
+ const [styleShelfAnchor, setStyleShelfAnchor] = useState(void 0);
50183
50814
  const importRef = useRef(null);
50184
50815
  const openerRef = useRef(null);
50185
50816
  const searchRef = useRef(null);
@@ -50190,6 +50821,25 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50190
50821
  useEffect(() => {
50191
50822
  if (panel === "search") searchRef.current?.focus();
50192
50823
  }, [panel]);
50824
+ useEffect(() => {
50825
+ const recompute = () => {
50826
+ const root = chromeRef.current;
50827
+ const button = root?.querySelector(".scrawl-board__tools button[aria-pressed=\"true\"]");
50828
+ if (!root || !button) {
50829
+ setStyleShelfAnchor(void 0);
50830
+ return;
50831
+ }
50832
+ const buttonRect = button.getBoundingClientRect();
50833
+ const rootRect = root.getBoundingClientRect();
50834
+ setStyleShelfAnchor({
50835
+ x: buttonRect.left + buttonRect.width / 2 - rootRect.left,
50836
+ y: buttonRect.top - rootRect.top
50837
+ });
50838
+ };
50839
+ recompute();
50840
+ window.addEventListener("resize", recompute);
50841
+ return () => window.removeEventListener("resize", recompute);
50842
+ }, [snapshot.tool]);
50193
50843
  const open = (next, opener) => {
50194
50844
  openerRef.current = opener;
50195
50845
  setPanel(next);
@@ -50418,9 +51068,14 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50418
51068
  ]
50419
51069
  }),
50420
51070
  renderSlot(slots?.stylePanel, enabled("styleShelf") && /* @__PURE__ */ jsx(StyleShelf, {
51071
+ anchor: styleShelfAnchor,
50421
51072
  controller,
50422
51073
  snapshot
50423
51074
  })),
51075
+ enabled("focusedItemToolbar") && /* @__PURE__ */ jsx(FocusedItemToolbar, {
51076
+ controller,
51077
+ snapshot
51078
+ }),
50424
51079
  ContextMenu && /* @__PURE__ */ jsx(ContextMenu, {
50425
51080
  controller,
50426
51081
  snapshot
@@ -51063,4 +51718,4 @@ function ScrawlBoard({ documentId, initialDocument, onReady, className, style })
51063
51718
  });
51064
51719
  }
51065
51720
  //#endregion
51066
- export { ASSET_CACHE_BYTES_DEFAULT, ASSET_CACHE_BYTES_MAX, ASSET_CACHE_BYTES_MIN, ASSET_EXPORT_MAX_DECODED_MEGAPIXELS, ASSET_EXPORT_MAX_ENCODED_BYTES, ASSET_MAX_CONCURRENT_RESOLUTIONS, ASSET_MAX_DECODED_MEGAPIXELS, ASSET_MAX_DIMENSION_PX, ASSET_MAX_ENCODED_BYTES, ASSET_REF_MAX_BYTES, ASSET_REF_PATTERN, AddImageCommand, AddNoteCommand, AddStrokesCommand, AddTableCommand, AddTextCommand, AddTimerCommand, AssetResolutionError, BEACON_INSET, BoardDocument, CURRENT_DOCUMENT_SCHEMA_VERSION, ClusterStore, DefaultBoardChrome, DeleteImageCommand, DeleteNoteCommand, DeleteStrokesCommand, DeleteTableCommand, DeleteTextCommand, DeleteTimerCommand, DocumentRecoveryError, END_TAPER, ERASE_THRESHOLD, EraseCommand, FOG_COLOR, HIGHLIGHT_COLORS, History, IDENTITY, INK_COLORS, InlineEditors, LockItemsCommand, MIN_WIDTH_FACTOR, MultiplayerCursors, NOTE_COLORS, NOTE_DEFAULT_SIZE, NOTE_DEFAULT_Z, NOTE_MAX_Z, NOTE_MIN_Z, NOTE_PEEL_STEP, SDK_DEVELOPMENT_VERSION, SDK_PACKAGE_NAME, STAMPS, STAMP_SIZE, SUPPORTED_ASSET_MEDIA_TYPES, Scrawl, ScrawlBoard, ScrawlCanvas, ScrawlDefaultUI, ScrawlPortal, ScrawlProvider, SpatialIndex, StyleShelf, TABLE_DEFAULT_CELL_HEIGHT, TABLE_DEFAULT_CELL_WIDTH, TABLE_DEFAULT_FONT_SIZE, TEXT_DEFAULT_SIZE, TIMER_DEFAULT_DURATION_MS, TIMER_DEFAULT_SIZE, TIMER_PRESETS_MS, TransformCommand, UpdateImageCommand, UpdateNoteCommand, UpdateTableCommand, UpdateTextCommand, UpdateTimerCommand, apply, applyItemLock, assetRef, avgScale, canUnlockItem, changeToOps, clampAssetCacheBytes, cloneCustomObject, cloneImage, cloneNote, cloneStroke, cloneTable, cloneText, cloneTimer, createBoardController, createLocalBoard, documentId, documentToSVG, formatTimer, invert, isAssetRef, isIdentity, isStampKind, loadDocumentBytes, measureTable, measureTextBlock, migrateDocument, mul, pauseTimer, placePresenceBeacon, resolveScrawlTheme, ribbonEdges, rotationAbout, scalingAbout, scrawlThemePresets, searchBoard, serializeDocument, serializeLock, serializeStroke, setTimerDuration, stampDataUrl, startTimer, strokeId, timerExpired, timerRemaining, toggleTimer, translation, useScrawlController, useScrawlSnapshot, useScrawlTheme, validateScrawlTheme };
51721
+ export { ASSET_CACHE_BYTES_DEFAULT, ASSET_CACHE_BYTES_MAX, ASSET_CACHE_BYTES_MIN, ASSET_EXPORT_MAX_DECODED_MEGAPIXELS, ASSET_EXPORT_MAX_ENCODED_BYTES, ASSET_MAX_CONCURRENT_RESOLUTIONS, ASSET_MAX_DECODED_MEGAPIXELS, ASSET_MAX_DIMENSION_PX, ASSET_MAX_ENCODED_BYTES, ASSET_REF_MAX_BYTES, ASSET_REF_PATTERN, AddImageCommand, AddNoteCommand, AddStrokesCommand, AddTableCommand, AddTextCommand, AddTimerCommand, AssetResolutionError, BEACON_INSET, BoardDocument, CURRENT_DOCUMENT_SCHEMA_VERSION, ClusterStore, DefaultBoardChrome, DeleteImageCommand, DeleteNoteCommand, DeleteStrokesCommand, DeleteTableCommand, DeleteTextCommand, DeleteTimerCommand, DocumentRecoveryError, END_TAPER, ERASE_THRESHOLD, EraseCommand, FOG_COLOR, FocusedItemToolbar, HIGHLIGHT_COLORS, History, IDENTITY, INK_COLORS, InlineEditors, LockItemsCommand, MIN_WIDTH_FACTOR, MultiplayerCursors, NOTE_COLORS, NOTE_DEFAULT_SIZE, NOTE_DEFAULT_Z, NOTE_MAX_Z, NOTE_MIN_Z, NOTE_PEEL_STEP, SDK_DEVELOPMENT_VERSION, SDK_PACKAGE_NAME, STAMPS, STAMP_SIZE, SUPPORTED_ASSET_MEDIA_TYPES, Scrawl, ScrawlBoard, ScrawlCanvas, ScrawlDefaultUI, ScrawlPortal, ScrawlProvider, SpatialIndex, StyleShelf, TABLE_DEFAULT_CELL_HEIGHT, TABLE_DEFAULT_CELL_WIDTH, TABLE_DEFAULT_FONT_SIZE, TEXT_DEFAULT_SIZE, TIMER_DEFAULT_DURATION_MS, TIMER_DEFAULT_SIZE, TIMER_PRESETS_MS, TransformCommand, UpdateImageCommand, UpdateNoteCommand, UpdateTableCommand, UpdateTextCommand, UpdateTimerCommand, apply, applyItemLock, assetRef, avgScale, canUnlockItem, changeToOps, clampAssetCacheBytes, cloneCustomObject, cloneImage, cloneNote, cloneStroke, cloneTable, cloneText, cloneTimer, createBoardController, createLocalBoard, documentId, documentToSVG, formatTimer, invert, isAssetRef, isIdentity, isStampKind, loadDocumentBytes, measureTable, measureTextBlock, migrateDocument, mul, pauseTimer, placePresenceBeacon, resolveScrawlTheme, ribbonEdges, rotationAbout, scalingAbout, scrawlThemePresets, searchBoard, serializeDocument, serializeLock, serializeStroke, setTimerDuration, stampDataUrl, startTimer, strokeId, timerExpired, timerRemaining, toggleTimer, translation, useScrawlController, useScrawlSnapshot, useScrawlTheme, validateScrawlTheme };