@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/react.js CHANGED
@@ -126,7 +126,11 @@ function validateStrokes(raw) {
126
126
  for (let index = 0; index < raw.length; index += 1) {
127
127
  const value = raw[index];
128
128
  const path = `strokes[${index}]`;
129
- 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);
129
+ if (!isRecord(value) || !nonempty(value.id) || !nonempty(value.color) || !positive(value.baseWidth) || !optionalEnum(value.tool, [
130
+ "marker",
131
+ "highlighter",
132
+ "shape"
133
+ ]) || !validLock(value) || !optionalString(value.clusterId) || !validMatrix(value.matrix) || !Array.isArray(value.points)) return validation("invalid stroke", path);
130
134
  const points = [];
131
135
  for (let pointIndex = 0; pointIndex < value.points.length; pointIndex += 1) {
132
136
  const point = normalizePoint(value.points[pointIndex]);
@@ -407,9 +411,21 @@ var HIGHLIGHT_COLORS = {
407
411
  };
408
412
  var NOTE_COLORS = {
409
413
  yellow: "#FDE68A",
414
+ amber: "#FCD34D",
415
+ orange: "#FDBA74",
416
+ peach: "#FED7AA",
417
+ red: "#FCA5A5",
418
+ rose: "#FECACA",
410
419
  pink: "#FBCFE8",
420
+ magenta: "#F9A8D4",
421
+ purple: "#D8B4FE",
422
+ lavender: "#E9D5FF",
411
423
  blue: "#BFDBFE",
412
- green: "#BBF7D0"
424
+ cornflower: "#93C5FD",
425
+ sky: "#7DD3FC",
426
+ cyan: "#BAE6FD",
427
+ green: "#BBF7D0",
428
+ mint: "#86EFAC"
413
429
  };
414
430
  var TEXT_DEFAULT_SIZE = 1.8;
415
431
  function cloneText(block) {
@@ -36362,13 +36378,26 @@ var INK_Z = .02;
36362
36378
  var HIGHLIGHT_Z = .016;
36363
36379
  /** Ghosts sit under everything so live strokes always read on top. */
36364
36380
  var GHOST_Z = .012;
36381
+ /**
36382
+ * A miter join at each centerline point — averaging the incoming and
36383
+ * outgoing segment directions into one width offset — under-covers the
36384
+ * outside of a sharp turn: the two segments' edges diverge there, leaving a
36385
+ * real geometric gap (the board shows through, not just a shader artifact).
36386
+ * Barely visible on opaque ink, but glaring on the highlighter's lower,
36387
+ * multiply-blended opacity, which is why this reads as "goes transparent
36388
+ * at corners" specifically there even though the underlying strip geometry
36389
+ * is shared with ink. Below this turn angle a miter join is a fine
36390
+ * approximation and not worth the extra geometry.
36391
+ */
36392
+ var JOIN_ANGLE_THRESHOLD = .35;
36393
+ var JOIN_SEGMENTS = 10;
36365
36394
  function buildRibbonGeometry(points, baseWidth, z = INK_Z, handDrawn = true) {
36366
36395
  const edges = ribbonEdges(points, baseWidth, handDrawn);
36367
36396
  const n = edges.length;
36368
- const positions = new Float32Array(n * 2 * 3);
36369
- const uvs = new Float32Array(n * 2 * 2);
36370
- const alphas = new Float32Array(n * 2);
36371
- const indices = new Uint32Array((n - 1) * 6);
36397
+ const positions = new Array(n * 2 * 3);
36398
+ const uvs = new Array(n * 2 * 2);
36399
+ const alphas = new Array(n * 2);
36400
+ const indices = new Array((n - 1) * 6);
36372
36401
  for (let i = 0; i < n; i++) {
36373
36402
  const e = edges[i];
36374
36403
  const vi = i * 6;
@@ -36397,11 +36426,45 @@ function buildRibbonGeometry(points, baseWidth, z = INK_Z, handDrawn = true) {
36397
36426
  indices[ii + 5] = a + 2;
36398
36427
  }
36399
36428
  }
36429
+ for (let i = 1; i < n - 1; i++) {
36430
+ const prev = points[Math.max(0, i - 1)];
36431
+ const point = points[i];
36432
+ const next = points[Math.min(n - 1, i + 1)];
36433
+ const inX = point.x - prev.x, inY = point.y - prev.y;
36434
+ const outX = next.x - point.x, outY = next.y - point.y;
36435
+ const inLen = Math.hypot(inX, inY) || 1;
36436
+ const outLen = Math.hypot(outX, outY) || 1;
36437
+ const inDx = inX / inLen, inDy = inY / inLen;
36438
+ const outDx = outX / outLen, outDy = outY / outLen;
36439
+ const cos = inDx * outDx + inDy * outDy;
36440
+ if (Math.acos(Math.min(1, Math.max(-1, cos))) < JOIN_ANGLE_THRESHOLD) continue;
36441
+ const radius = (handDrawn ? baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) : baseWidth) / 2;
36442
+ const t = i / (n - 1);
36443
+ const alpha = edges[i].alpha;
36444
+ const side = inDx * outDy - inDy * outDx > 0 ? -1 : 1;
36445
+ const inEdgeX = point.x + side * -inDy * radius, inEdgeY = point.y + side * inDx * radius;
36446
+ const outEdgeX = point.x + side * -outDy * radius, outEdgeY = point.y + side * outDx * radius;
36447
+ const angle1 = Math.atan2(inEdgeY - point.y, inEdgeX - point.x);
36448
+ let sweep = Math.atan2(outEdgeY - point.y, outEdgeX - point.x) - angle1;
36449
+ while (sweep > Math.PI) sweep -= Math.PI * 2;
36450
+ while (sweep < -Math.PI) sweep += Math.PI * 2;
36451
+ const center = positions.length / 3;
36452
+ positions.push(point.x, point.y, z);
36453
+ uvs.push(t, .5);
36454
+ alphas.push(alpha);
36455
+ for (let s = 0; s <= JOIN_SEGMENTS; s++) {
36456
+ const theta = angle1 + sweep * (s / JOIN_SEGMENTS);
36457
+ positions.push(point.x + Math.cos(theta) * radius, point.y + Math.sin(theta) * radius, z);
36458
+ uvs.push(t, .5);
36459
+ alphas.push(alpha);
36460
+ if (s > 0) indices.push(center, center + s, center + s + 1);
36461
+ }
36462
+ }
36400
36463
  const geometry = new BufferGeometry();
36401
- geometry.setAttribute("position", new BufferAttribute(positions, 3));
36402
- geometry.setAttribute("uv", new BufferAttribute(uvs, 2));
36403
- geometry.setAttribute("aAlpha", new BufferAttribute(alphas, 1));
36404
- geometry.setIndex(new BufferAttribute(indices, 1));
36464
+ geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
36465
+ geometry.setAttribute("uv", new BufferAttribute(new Float32Array(uvs), 2));
36466
+ geometry.setAttribute("aAlpha", new BufferAttribute(new Float32Array(alphas), 1));
36467
+ geometry.setIndex(indices);
36405
36468
  geometry.computeBoundingSphere();
36406
36469
  return geometry;
36407
36470
  }
@@ -36512,6 +36575,7 @@ var StrokeRenderer = class {
36512
36575
  if (mesh) {
36513
36576
  mesh.geometry.dispose();
36514
36577
  mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36578
+ mesh.material = stroke.tool === "highlighter" ? this.materials.highlight(stroke.color) : this.materials.get(stroke.color);
36515
36579
  }
36516
36580
  }
36517
36581
  for (const stroke of change.transformed) {
@@ -36542,9 +36606,9 @@ function syncMatrix(mesh, stroke) {
36542
36606
  }
36543
36607
  //#endregion
36544
36608
  //#region src/renderer/shapes/markerProp.ts
36545
- var BODY_LENGTH = 11;
36546
- var BODY_RADIUS = .85;
36547
- var TIP_LENGTH = 1.6;
36609
+ var BODY_LENGTH = 7;
36610
+ var BODY_RADIUS = .55;
36611
+ var TIP_LENGTH = 1.05;
36548
36612
  var BASE_TILT = .42;
36549
36613
  var VELOCITY_TILT = .22;
36550
36614
  var MAX_EXTRA_TILT = .35;
@@ -36575,12 +36639,12 @@ var MarkerProp = class {
36575
36639
  const tip = new Mesh(new ConeGeometry(BODY_RADIUS * .55, TIP_LENGTH, 14), tipMaterial);
36576
36640
  tip.rotation.x = Math.PI;
36577
36641
  tip.position.y = TIP_LENGTH / 2;
36578
- this.body.position.y = 7.1;
36642
+ this.body.position.y = 4.55;
36579
36643
  const pen = new Group();
36580
36644
  pen.add(this.body, tip);
36581
36645
  pen.rotation.x = Math.PI / 2;
36582
36646
  this.group.add(pen);
36583
- this.shadow = new Mesh(new CircleGeometry(1.4, 24), new MeshBasicMaterial({
36647
+ this.shadow = new Mesh(new CircleGeometry(.9, 24), new MeshBasicMaterial({
36584
36648
  color: 0,
36585
36649
  transparent: true,
36586
36650
  opacity: .13,
@@ -44691,7 +44755,7 @@ function distToSegmentSq(p, a, b) {
44691
44755
  }
44692
44756
  //#endregion
44693
44757
  //#region src/interaction/tools/eraserTool.ts
44694
- var DECAY_PER_PASS = .4;
44758
+ var DECAY_PER_PASS = 1;
44695
44759
  /** A point can take another decay pass after this long — rubbing works. */
44696
44760
  var REARM_MS = 250;
44697
44761
  var EraserTool = class {
@@ -44785,19 +44849,11 @@ var EraserTool = class {
44785
44849
  const runs = partitionByErasure(current.points);
44786
44850
  const survives = runs.filter((r) => r.survives && r.points.length >= 2);
44787
44851
  const keepId = survives.length === 1 && survives[0].points.length === current.points.length;
44788
- const m = current.matrix;
44789
44852
  for (const run of runs) if (run.survives && run.points.length >= 2) after.push({
44790
44853
  ...current,
44791
44854
  id: keepId ? current.id : crypto.randomUUID(),
44792
44855
  points: run.points
44793
44856
  });
44794
- else if (!run.survives) {
44795
- const world = m ? run.points.map((p) => ({
44796
- ...p,
44797
- ...apply(m, p)
44798
- })) : run.points;
44799
- this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1), current.tool !== "shape");
44800
- }
44801
44857
  }
44802
44858
  doc.removeStrokes(before.map((s) => s.id));
44803
44859
  doc.addStrokes(after.map(cloneStroke));
@@ -45005,10 +45061,10 @@ function buildShape(kind, a, b, step, constrain) {
45005
45061
  case "ellipse": return [ellipse(a, targetB, step)];
45006
45062
  case "line": return [sampleSegment(a, constrain ? snappedEnd(a, b) : b, step)];
45007
45063
  case "arrow": return arrow(a, constrain ? snappedEnd(a, b) : b, step);
45008
- case "triangle": return [polygon(3, a, targetB, step, -Math.PI / 2)];
45064
+ case "triangle": return [polygon(3, a, targetB, step, Math.PI / 2)];
45009
45065
  case "diamond": return [polygon(4, a, targetB, step, 0)];
45010
- case "pentagon": return [polygon(5, a, targetB, step, -Math.PI / 2)];
45011
- case "hexagon": return [polygon(6, a, targetB, step, -Math.PI / 2)];
45066
+ case "pentagon": return [polygon(5, a, targetB, step, Math.PI / 2)];
45067
+ case "hexagon": return [polygon(6, a, targetB, step, Math.PI / 2)];
45012
45068
  case "octagon": return [polygon(8, a, targetB, step, Math.PI / 8)];
45013
45069
  case "star": return [star(5, a, targetB, step)];
45014
45070
  case "heart": return [heart(a, targetB, step)];
@@ -45207,6 +45263,7 @@ var ShapeTool = class {
45207
45263
  this.ctx.clusters.assign(strokes[0]);
45208
45264
  for (const s of strokes) s.clusterId = strokes[0].clusterId;
45209
45265
  this.ctx.history.execute(new AddStrokesCommand(strokes));
45266
+ this.ctx.selection.set(strokes.map((s) => s.id));
45210
45267
  this.ctx.setTool?.("select");
45211
45268
  }
45212
45269
  this.ctx.transition("idle");
@@ -45788,6 +45845,13 @@ var HANDLE_HIT_PX = 12;
45788
45845
  var ROTATE_OFFSET_PX = 26;
45789
45846
  var ROTATE_HIT_PX = 14;
45790
45847
  var COLOR = 2450411;
45848
+ /**
45849
+ * Visual-only outset between the selected object and the gizmo's outline/
45850
+ * handles — the object's actual bounding box (used for resize math, see
45851
+ * `anchorPoint`/`handlePoint`'s callers in `selectTool.ts`) never changes;
45852
+ * only where the outline and handle meshes are *drawn* and hit-tested does.
45853
+ */
45854
+ var GAP_PX = 8;
45791
45855
  var SelectionGizmo = class {
45792
45856
  constructor(scene) {
45793
45857
  this.group = new Group();
@@ -45802,7 +45866,7 @@ var SelectionGizmo = class {
45802
45866
  this.outline = new LineLoop(new BufferGeometry(), this.lineMaterial);
45803
45867
  this.outline.position.z = GIZMO_Z;
45804
45868
  this.outline.frustumCulled = false;
45805
- const handleGeometry = new PlaneGeometry(1, 1);
45869
+ const handleGeometry = new CircleGeometry(.5, 20);
45806
45870
  const handleMaterial = new MeshBasicMaterial({ color: COLOR });
45807
45871
  const makeHandle = () => new Mesh(handleGeometry, handleMaterial);
45808
45872
  this.handles = {
@@ -45830,18 +45894,19 @@ var SelectionGizmo = class {
45830
45894
  if (!box) return;
45831
45895
  this.lineMaterial.color.setHex(locked ? 16096779 : COLOR);
45832
45896
  this.rotateHandle.visible = !locked;
45897
+ const visual = this.visualBox(box, wpp);
45833
45898
  const positions = new Float32Array([
45834
- box.minX,
45835
- box.minY,
45899
+ visual.minX,
45900
+ visual.minY,
45836
45901
  GIZMO_Z,
45837
- box.maxX,
45838
- box.minY,
45902
+ visual.maxX,
45903
+ visual.minY,
45839
45904
  GIZMO_Z,
45840
- box.maxX,
45841
- box.maxY,
45905
+ visual.maxX,
45906
+ visual.maxY,
45842
45907
  GIZMO_Z,
45843
- box.minX,
45844
- box.maxY,
45908
+ visual.minX,
45909
+ visual.maxY,
45845
45910
  GIZMO_Z
45846
45911
  ]);
45847
45912
  this.outline.geometry.dispose();
@@ -45853,17 +45918,27 @@ var SelectionGizmo = class {
45853
45918
  for (const [handle, mesh] of Object.entries(this.handles)) {
45854
45919
  mesh.visible = !locked;
45855
45920
  if (!locked) {
45856
- const p = this.handlePoint(handle, box);
45921
+ const p = this.handlePoint(handle, visual);
45857
45922
  mesh.position.set(p.x, p.y, GIZMO_Z);
45858
45923
  mesh.scale.set(size, size, 1);
45859
45924
  }
45860
45925
  }
45861
45926
  if (!locked) {
45862
- const rotate = this.rotatePoint(box, wpp);
45927
+ const rotate = this.rotatePoint(visual, wpp);
45863
45928
  this.rotateHandle.position.set(rotate.x, rotate.y, GIZMO_Z);
45864
45929
  this.rotateHandle.scale.set(size, size, 1);
45865
45930
  }
45866
45931
  }
45932
+ /** 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`). */
45933
+ visualBox(box, wpp) {
45934
+ const gap = GAP_PX * wpp;
45935
+ return {
45936
+ minX: box.minX - gap,
45937
+ minY: box.minY - gap,
45938
+ maxX: box.maxX + gap,
45939
+ maxY: box.maxY + gap
45940
+ };
45941
+ }
45867
45942
  handlePoint(handle, box) {
45868
45943
  const midX = (box.minX + box.maxX) / 2;
45869
45944
  const midY = (box.minY + box.maxY) / 2;
@@ -45951,7 +46026,8 @@ var SelectionGizmo = class {
45951
46026
  if (!this.box || this.isLocked) return null;
45952
46027
  const handleR = HANDLE_HIT_PX * this.wpp;
45953
46028
  const edgeSlop = 6 * this.wpp;
45954
- const rotate = this.rotatePoint(this.box, this.wpp);
46029
+ const visual = this.visualBox(this.box, this.wpp);
46030
+ const rotate = this.rotatePoint(visual, this.wpp);
45955
46031
  if (Math.hypot(p.x - rotate.x, p.y - rotate.y) < ROTATE_HIT_PX * this.wpp) return { kind: "rotate" };
45956
46032
  for (const handle of [
45957
46033
  "nw",
@@ -45963,13 +46039,13 @@ var SelectionGizmo = class {
45963
46039
  "e",
45964
46040
  "w"
45965
46041
  ]) {
45966
- const hp = this.handlePoint(handle, this.box);
46042
+ const hp = this.handlePoint(handle, visual);
45967
46043
  if (Math.abs(p.x - hp.x) < handleR && Math.abs(p.y - hp.y) < handleR) return {
45968
46044
  kind: "scale",
45969
46045
  handle
45970
46046
  };
45971
46047
  }
45972
- const { minX, maxX, minY, maxY } = this.box;
46048
+ const { minX, maxX, minY, maxY } = visual;
45973
46049
  const inX = p.x >= minX - edgeSlop && p.x <= maxX + edgeSlop;
45974
46050
  const inY = p.y >= minY - edgeSlop && p.y <= maxY + edgeSlop;
45975
46051
  if (inX && Math.abs(p.y - maxY) < edgeSlop) return {
@@ -45989,7 +46065,7 @@ var SelectionGizmo = class {
45989
46065
  handle: "w"
45990
46066
  };
45991
46067
  const pad = 2 * this.wpp;
45992
- if (p.x >= minX - pad && p.x <= maxX + pad && p.y >= minY - pad && p.y <= maxY + pad) return { kind: "inside" };
46068
+ 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" };
45993
46069
  return null;
45994
46070
  }
45995
46071
  rotatePoint(box, wpp) {
@@ -48222,6 +48298,112 @@ function createBoardControllerWithEngine(options, existingEngine) {
48222
48298
  })),
48223
48299
  ...[...document.allCustomObjects()].map((value) => clone(toBoardObject(value)))
48224
48300
  ];
48301
+ const projectToScreen = (point) => engine ? engine.boardToScreen(point) : {
48302
+ x: (point.x - view.x) * view.zoom,
48303
+ y: (view.y - point.y) * view.zoom
48304
+ };
48305
+ const lockFields = (item) => ({
48306
+ locked: !!item.locked,
48307
+ ...item.lockedBy ? { lockedBy: item.lockedBy } : {},
48308
+ ...item.lockedByName ? { lockedByName: item.lockedByName } : {}
48309
+ });
48310
+ const lockFieldsMany = (items) => {
48311
+ const locked = items.filter((i) => i.locked);
48312
+ if (locked.length === 0) return { locked: false };
48313
+ const lockedBys = [...new Set(locked.map((i) => i.lockedBy).filter((v) => !!v))];
48314
+ const names = [...new Set(locked.map((i) => i.lockedByName).filter((v) => !!v))];
48315
+ return {
48316
+ locked: true,
48317
+ ...lockedBys.length === 1 ? { lockedBy: lockedBys[0] } : {},
48318
+ ...names.length === 1 ? { lockedByName: names[0] } : {}
48319
+ };
48320
+ };
48321
+ const strokeClusterAnchor = (strokes) => {
48322
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
48323
+ for (const s of strokes) for (const p of s.points) {
48324
+ const world = s.matrix ? apply(s.matrix, p) : p;
48325
+ minX = Math.min(minX, world.x);
48326
+ maxX = Math.max(maxX, world.x);
48327
+ minY = Math.min(minY, world.y);
48328
+ maxY = Math.max(maxY, world.y);
48329
+ }
48330
+ if (!Number.isFinite(minX)) return projectToScreen({
48331
+ x: 0,
48332
+ y: 0
48333
+ });
48334
+ return projectToScreen({
48335
+ x: (minX + maxX) / 2,
48336
+ y: maxY + 1.4
48337
+ });
48338
+ };
48339
+ /** Per-type anchor formulas mirror `ScrawlEngine.getSelectedItemInfo` exactly, so a Host-built toolbar lands in the same spot the SDK's own would. */
48340
+ const anchorFor = (obj) => {
48341
+ switch (obj.type) {
48342
+ case "note": return projectToScreen({
48343
+ x: obj.x,
48344
+ y: obj.y + obj.size / 2 + 1
48345
+ });
48346
+ case "text": {
48347
+ const { width } = measureTextBlock(obj.text, obj.fontSize);
48348
+ return projectToScreen({
48349
+ x: obj.x + width / 2,
48350
+ y: obj.y + 1
48351
+ });
48352
+ }
48353
+ case "table": {
48354
+ const { width } = measureTable(obj);
48355
+ return projectToScreen({
48356
+ x: obj.x + width / 2,
48357
+ y: obj.y + 1.2
48358
+ });
48359
+ }
48360
+ case "image": return projectToScreen({
48361
+ x: obj.x,
48362
+ y: obj.y + obj.height / 2 + 1.2
48363
+ });
48364
+ case "timer": return projectToScreen({
48365
+ x: obj.x,
48366
+ y: obj.y + obj.size / 2 + 1.2
48367
+ });
48368
+ case "custom": {
48369
+ const b = obj.fallback.bounds;
48370
+ return projectToScreen(apply(obj.transform, {
48371
+ x: b.x + b.width / 2,
48372
+ y: b.y + b.height + 1
48373
+ }));
48374
+ }
48375
+ case "stroke": return strokeClusterAnchor([obj]);
48376
+ }
48377
+ };
48378
+ const computeFocusedItem = () => {
48379
+ if (selection.length === 0) return null;
48380
+ if (selection.length === 1) {
48381
+ const obj = readObject(selection[0]);
48382
+ if (!obj) return null;
48383
+ if (obj.type === "custom") return {
48384
+ type: "custom",
48385
+ id: obj.id,
48386
+ locked: false,
48387
+ screenPosition: anchorFor(obj)
48388
+ };
48389
+ return {
48390
+ type: obj.type,
48391
+ id: obj.id,
48392
+ ...lockFields(obj),
48393
+ screenPosition: anchorFor(obj)
48394
+ };
48395
+ }
48396
+ const objects = selection.map(readObject);
48397
+ if (!objects.every((o) => o?.type === "stroke")) return null;
48398
+ const clusterId = objects[0].clusterId;
48399
+ if (!clusterId || !objects.every((o) => o.clusterId === clusterId)) return null;
48400
+ return {
48401
+ type: "stroke",
48402
+ id: objects[0].id,
48403
+ ...lockFieldsMany(objects),
48404
+ screenPosition: strokeClusterAnchor(objects)
48405
+ };
48406
+ };
48225
48407
  const updateObject = (id, patch) => {
48226
48408
  assertMutable();
48227
48409
  const before = readObject(id);
@@ -48263,6 +48445,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48263
48445
  zoom: view.zoom,
48264
48446
  readOnly,
48265
48447
  selection: Object.freeze([...selection]),
48448
+ focusedItem: computeFocusedItem(),
48266
48449
  strokeCount: [...document.all()].length,
48267
48450
  objectCount: allObjects().length,
48268
48451
  canUndo: history.canUndo,
@@ -48677,10 +48860,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48677
48860
  changed();
48678
48861
  },
48679
48862
  get: () => ({ ...view }),
48680
- boardToScreen: (point) => engine ? engine.boardToScreen(point) : {
48681
- x: (point.x - view.x) * view.zoom,
48682
- y: (view.y - point.y) * view.zoom
48683
- },
48863
+ boardToScreen: (point) => projectToScreen(point),
48684
48864
  screenToBoard: (point) => engine ? engine.screenToBoard(point.x, point.y) ?? {
48685
48865
  x: point.x,
48686
48866
  y: point.y
@@ -48717,6 +48897,81 @@ function createBoardControllerWithEngine(options, existingEngine) {
48717
48897
  update(id, patch) {
48718
48898
  updateObject(id, patch);
48719
48899
  },
48900
+ duplicate(ids) {
48901
+ assertMutable();
48902
+ const objects = [...new Set(ids)].map(readObject).filter((o) => !!o);
48903
+ if (objects.length === 0) return [];
48904
+ const DX = 1.5;
48905
+ const DY = -1.5;
48906
+ const newClusterIds = /* @__PURE__ */ new Map();
48907
+ const clusterIdFor = (oldClusterId) => {
48908
+ let next = newClusterIds.get(oldClusterId);
48909
+ if (!next) {
48910
+ next = crypto.randomUUID();
48911
+ newClusterIds.set(oldClusterId, next);
48912
+ }
48913
+ return next;
48914
+ };
48915
+ const commands = [];
48916
+ const newIds = [];
48917
+ for (const obj of objects) {
48918
+ const id = createId();
48919
+ newIds.push(id);
48920
+ const rest = { ...obj };
48921
+ delete rest.locked;
48922
+ delete rest.lockedBy;
48923
+ delete rest.lockedByName;
48924
+ let duplicated;
48925
+ if (obj.type === "stroke") {
48926
+ const matrix = obj.matrix ?? [
48927
+ 1,
48928
+ 0,
48929
+ 0,
48930
+ 1,
48931
+ 0,
48932
+ 0
48933
+ ];
48934
+ duplicated = {
48935
+ ...rest,
48936
+ id,
48937
+ matrix: [
48938
+ matrix[0],
48939
+ matrix[1],
48940
+ matrix[2],
48941
+ matrix[3],
48942
+ matrix[4] + DX,
48943
+ matrix[5] + DY
48944
+ ],
48945
+ ...obj.clusterId ? { clusterId: clusterIdFor(obj.clusterId) } : {}
48946
+ };
48947
+ } else if (obj.type === "custom") {
48948
+ const t = obj.transform;
48949
+ duplicated = {
48950
+ ...rest,
48951
+ id,
48952
+ transform: [
48953
+ t[0],
48954
+ t[1],
48955
+ t[2],
48956
+ t[3],
48957
+ t[4] + DX,
48958
+ t[5] + DY
48959
+ ]
48960
+ };
48961
+ delete duplicated.lock;
48962
+ } else duplicated = {
48963
+ ...rest,
48964
+ id,
48965
+ x: obj.x + DX,
48966
+ y: obj.y + DY
48967
+ };
48968
+ validateBoardObject(duplicated.type, duplicated);
48969
+ commands.push(addCommand(duplicated.type, duplicated));
48970
+ }
48971
+ executeHistory(() => history.execute(new CommandBatch("duplicate content", commands)));
48972
+ changed();
48973
+ return newIds;
48974
+ },
48720
48975
  table: {
48721
48976
  addRow(tableId) {
48722
48977
  const table = document.getTable(tableId);
@@ -49650,6 +49905,24 @@ function InlineEditors({ controller, renderPortal }) {
49650
49905
  };
49651
49906
  useEffect(() => controller.on("edit-request", (event) => {
49652
49907
  const offset = rootOffset();
49908
+ if (event.kind === "text" && event.id === null) {
49909
+ const newId = controller.content.add({
49910
+ type: "text",
49911
+ x: event.boardX,
49912
+ y: event.boardY,
49913
+ text: "",
49914
+ color: event.color,
49915
+ fontSize: TEXT_DEFAULT_SIZE
49916
+ });
49917
+ controller.content.select([newId]);
49918
+ setEditor({
49919
+ ...event,
49920
+ id: newId,
49921
+ left: event.screenRect.left - offset.left,
49922
+ top: event.screenRect.top - offset.top
49923
+ });
49924
+ return;
49925
+ }
49653
49926
  setEditor({
49654
49927
  ...event,
49655
49928
  left: event.screenRect.left - offset.left,
@@ -49661,19 +49934,11 @@ function InlineEditors({ controller, renderPortal }) {
49661
49934
  setEditor(null);
49662
49935
  };
49663
49936
  const commitText = () => {
49664
- if (editor?.kind === "text") {
49937
+ if (editor?.kind === "text" && editor.id !== null) {
49665
49938
  const text = editor.text;
49666
- if (editor.id === null) {
49667
- if (text.trim()) controller.content.add({
49668
- type: "text",
49669
- x: editor.boardX,
49670
- y: editor.boardY,
49671
- text,
49672
- color: editor.color,
49673
- fontSize: TEXT_DEFAULT_SIZE
49674
- });
49675
- } else if (!text.trim()) controller.content.remove([editor.id]);
49676
- else controller.content.update(editor.id, { text });
49939
+ const existing = controller.query.get(editor.id);
49940
+ if (!text.trim()) controller.content.remove([editor.id]);
49941
+ else if (existing?.type !== "text" || existing.text !== text) controller.content.update(editor.id, { text });
49677
49942
  }
49678
49943
  setEditor(null);
49679
49944
  };
@@ -49866,6 +50131,366 @@ function InlineEditors({ controller, renderPortal }) {
49866
50131
  }))] });
49867
50132
  }
49868
50133
  //#endregion
50134
+ //#region src/ui/toolbar/focused-item-toolbar.tsx
50135
+ var NOTE_SIZES = [
50136
+ ["Small", 7],
50137
+ ["Medium", 10],
50138
+ ["Large", 14]
50139
+ ];
50140
+ var TEXT_SIZES = [
50141
+ ["Small", 1.2],
50142
+ ["Medium", 1.8],
50143
+ ["Large", 2.6]
50144
+ ];
50145
+ var SHAPE_WIDTHS = [
50146
+ ["Thin", .1],
50147
+ ["Medium", .15],
50148
+ ["Thick", .3]
50149
+ ];
50150
+ /** Matches the native selection gizmo's own outset (renderer/selection/gizmo.ts's GAP_PX), for a consistent look. */
50151
+ var HANDLE_GAP_PX = 8;
50152
+ var MIN_SIZE = 4;
50153
+ var MAX_SIZE = 30;
50154
+ var HANDLE_CORNERS = [
50155
+ "nw",
50156
+ "ne",
50157
+ "sw",
50158
+ "se"
50159
+ ];
50160
+ /**
50161
+ * Floating toolbar above the focused note, shape, or text block — Colour,
50162
+ * Size (note/text) or Width (shape), Lock/Unlock, Duplicate, Delete.
50163
+ * Table/image/timer/custom objects and plain (non-shape) ink strokes never
50164
+ * get a toolbar here — out of scope for this destination.
50165
+ *
50166
+ * Lock/Unlock carries no per-user ownership gating: nothing else in this
50167
+ * SDK enforces lock ownership either (`content.update` never checks
50168
+ * `locked`, and the engine's own internal unlock check has no way for a
50169
+ * Host to ever supply a real user id) — locking is advisory UI state
50170
+ * throughout, and this toolbar matches that rather than inventing an
50171
+ * enforcement story alone.
50172
+ *
50173
+ * A shape can be several strokes sharing one `clusterId` (e.g. an arrow's
50174
+ * shaft + head) — every action here applies to the whole cluster. Resolved
50175
+ * via `query.all()` + `clusterId`, not `snapshot.selection`: a canvas click
50176
+ * on one member selects every member internally, but the public selection
50177
+ * bridge only ever reports one id (`FocusedItem.id` is singular by design),
50178
+ * so reconstructing the cluster from the document is the reliable path
50179
+ * regardless of how the selection was made.
50180
+ *
50181
+ * A focused note also gets drag-to-resize corner handles — notes have no
50182
+ * gizmo of their own (the native selection gizmo is ink-stroke/shape-only),
50183
+ * so this is that capability's default-ui-owned equivalent. Proportional
50184
+ * (always-square) resize from the note's own centre, clamped to
50185
+ * `[MIN_SIZE, MAX_SIZE]`, independent of which corner is grabbed.
50186
+ */
50187
+ function FocusedItemToolbar({ controller, snapshot }) {
50188
+ const focused = snapshot.focusedItem;
50189
+ if (!focused) return null;
50190
+ const obj = controller.query.get(focused.id);
50191
+ if (!obj) return null;
50192
+ if (obj.type === "note") return /* @__PURE__ */ jsx(NoteToolbar, {
50193
+ controller,
50194
+ snapshot,
50195
+ note: obj,
50196
+ locked: focused.locked,
50197
+ anchor: focused.screenPosition
50198
+ });
50199
+ if (obj.type === "text") return /* @__PURE__ */ jsx(TextToolbar, {
50200
+ controller,
50201
+ snapshot,
50202
+ text: obj,
50203
+ locked: focused.locked,
50204
+ anchor: focused.screenPosition
50205
+ });
50206
+ if (obj.type === "stroke" && obj.tool === "shape") return /* @__PURE__ */ jsx(ShapeToolbar, {
50207
+ controller,
50208
+ snapshot,
50209
+ stroke: obj,
50210
+ locked: focused.locked,
50211
+ anchor: focused.screenPosition
50212
+ });
50213
+ return null;
50214
+ }
50215
+ function ToolbarShell({ anchor, children }) {
50216
+ if (!anchor) return null;
50217
+ return /* @__PURE__ */ jsx("div", {
50218
+ "aria-label": "Selected object",
50219
+ className: "scrawl-board__focus-toolbar",
50220
+ role: "toolbar",
50221
+ style: {
50222
+ left: anchor.x,
50223
+ top: anchor.y
50224
+ },
50225
+ children
50226
+ });
50227
+ }
50228
+ function NoteToolbar({ controller, snapshot, note, locked, anchor }) {
50229
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50230
+ const locked_ = !mutable || locked;
50231
+ const half = note.size / 2;
50232
+ const cornerA = controller.view.boardToScreen({
50233
+ x: note.x - half,
50234
+ y: note.y + half
50235
+ });
50236
+ const cornerB = controller.view.boardToScreen({
50237
+ x: note.x + half,
50238
+ y: note.y - half
50239
+ });
50240
+ const outline = {
50241
+ left: Math.min(cornerA.x, cornerB.x) - HANDLE_GAP_PX,
50242
+ top: Math.min(cornerA.y, cornerB.y) - HANDLE_GAP_PX,
50243
+ right: Math.max(cornerA.x, cornerB.x) + HANDLE_GAP_PX,
50244
+ bottom: Math.max(cornerA.y, cornerB.y) + HANDLE_GAP_PX
50245
+ };
50246
+ const handlePoints = {
50247
+ nw: {
50248
+ x: outline.left,
50249
+ y: outline.top
50250
+ },
50251
+ ne: {
50252
+ x: outline.right,
50253
+ y: outline.top
50254
+ },
50255
+ sw: {
50256
+ x: outline.left,
50257
+ y: outline.bottom
50258
+ },
50259
+ se: {
50260
+ x: outline.right,
50261
+ y: outline.bottom
50262
+ }
50263
+ };
50264
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
50265
+ /* @__PURE__ */ jsx("div", {
50266
+ "aria-hidden": "true",
50267
+ className: "scrawl-board__focus-outline",
50268
+ "data-locked": locked,
50269
+ style: {
50270
+ left: outline.left,
50271
+ top: outline.top,
50272
+ width: outline.right - outline.left,
50273
+ height: outline.bottom - outline.top
50274
+ }
50275
+ }),
50276
+ mutable && !locked && HANDLE_CORNERS.map((corner) => /* @__PURE__ */ jsx(ResizeHandle, {
50277
+ controller,
50278
+ note,
50279
+ corner,
50280
+ x: handlePoints[corner].x,
50281
+ y: handlePoints[corner].y
50282
+ }, corner)),
50283
+ /* @__PURE__ */ jsxs(ToolbarShell, {
50284
+ anchor,
50285
+ children: [
50286
+ /* @__PURE__ */ jsxs("div", {
50287
+ className: "scrawl-board__style-group",
50288
+ children: [/* @__PURE__ */ jsx("span", { children: "Size" }), NOTE_SIZES.map(([label, size]) => /* @__PURE__ */ jsx("button", {
50289
+ "aria-label": `${label} note`,
50290
+ "aria-pressed": note.size === size,
50291
+ disabled: locked_,
50292
+ onClick: () => controller.content.update(note.id, { size }),
50293
+ type: "button",
50294
+ children: label[0]
50295
+ }, label))]
50296
+ }),
50297
+ /* @__PURE__ */ jsxs("div", {
50298
+ className: "scrawl-board__style-group",
50299
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(NOTE_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50300
+ "aria-label": `${name} note`,
50301
+ "aria-pressed": note.color.toLowerCase() === value.toLowerCase(),
50302
+ disabled: locked_,
50303
+ onClick: () => controller.content.update(note.id, { color: value }),
50304
+ type: "button",
50305
+ children: /* @__PURE__ */ jsx("span", {
50306
+ "aria-hidden": "true",
50307
+ style: { backgroundColor: value }
50308
+ })
50309
+ }, value))]
50310
+ }),
50311
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50312
+ controller,
50313
+ mutable,
50314
+ locked,
50315
+ ids: [note.id]
50316
+ })
50317
+ ]
50318
+ })
50319
+ ] });
50320
+ }
50321
+ /**
50322
+ * One corner handle — drag distance from the note's own centre (its `x`/`y`
50323
+ * already *is* the centre) drives a proportional, always-square resize,
50324
+ * independent of which corner is grabbed. `note` is fresh every render (a
50325
+ * new `content.update` during drag triggers a new snapshot), so the
50326
+ * closure never goes stale.
50327
+ */
50328
+ function ResizeHandle({ controller, note, corner, x, y }) {
50329
+ const [dragging, setDragging] = useState(false);
50330
+ const nudge = (delta) => {
50331
+ const half = Math.max(MIN_SIZE / 2, Math.min(MAX_SIZE / 2, note.size / 2 + delta));
50332
+ controller.content.update(note.id, { size: half * 2 });
50333
+ };
50334
+ return /* @__PURE__ */ jsx("span", {
50335
+ "aria-label": `Resize note (${corner})`,
50336
+ "aria-valuemax": MAX_SIZE,
50337
+ "aria-valuemin": MIN_SIZE,
50338
+ "aria-valuenow": note.size,
50339
+ role: "slider",
50340
+ tabIndex: 0,
50341
+ className: "scrawl-board__focus-handle",
50342
+ "data-orientation": corner === "nw" || corner === "se" ? "nwse" : "nesw",
50343
+ style: {
50344
+ left: x,
50345
+ top: y
50346
+ },
50347
+ onKeyDown: (event) => {
50348
+ if (event.key === "ArrowUp" || event.key === "ArrowRight") {
50349
+ event.preventDefault();
50350
+ nudge(.5);
50351
+ } else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
50352
+ event.preventDefault();
50353
+ nudge(-.5);
50354
+ }
50355
+ },
50356
+ onPointerDown: (event) => {
50357
+ event.stopPropagation();
50358
+ event.currentTarget.setPointerCapture(event.pointerId);
50359
+ setDragging(true);
50360
+ },
50361
+ onPointerMove: (event) => {
50362
+ if (!dragging) return;
50363
+ const board = controller.view.screenToBoard({
50364
+ x: event.clientX,
50365
+ y: event.clientY
50366
+ });
50367
+ 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))));
50368
+ controller.content.update(note.id, { size: half * 2 });
50369
+ },
50370
+ onPointerUp: (event) => {
50371
+ event.currentTarget.releasePointerCapture(event.pointerId);
50372
+ setDragging(false);
50373
+ }
50374
+ });
50375
+ }
50376
+ function TextToolbar({ controller, snapshot, text, locked, anchor }) {
50377
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50378
+ const locked_ = !mutable || locked;
50379
+ return /* @__PURE__ */ jsxs(ToolbarShell, {
50380
+ anchor,
50381
+ children: [
50382
+ /* @__PURE__ */ jsxs("div", {
50383
+ className: "scrawl-board__style-group",
50384
+ children: [/* @__PURE__ */ jsx("span", { children: "Size" }), TEXT_SIZES.map(([label, fontSize]) => /* @__PURE__ */ jsx("button", {
50385
+ "aria-label": `${label} text`,
50386
+ "aria-pressed": Math.abs(text.fontSize - fontSize) < .001,
50387
+ disabled: locked_,
50388
+ onClick: () => controller.content.update(text.id, { fontSize }),
50389
+ type: "button",
50390
+ children: label[0]
50391
+ }, label))]
50392
+ }),
50393
+ /* @__PURE__ */ jsxs("div", {
50394
+ className: "scrawl-board__style-group",
50395
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(INK_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50396
+ "aria-label": `${name} text`,
50397
+ "aria-pressed": text.color.toLowerCase() === value.toLowerCase(),
50398
+ disabled: locked_,
50399
+ onClick: () => controller.content.update(text.id, { color: value }),
50400
+ type: "button",
50401
+ children: /* @__PURE__ */ jsx("span", {
50402
+ "aria-hidden": "true",
50403
+ style: { backgroundColor: value }
50404
+ })
50405
+ }, value))]
50406
+ }),
50407
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50408
+ controller,
50409
+ mutable,
50410
+ locked,
50411
+ ids: [text.id]
50412
+ })
50413
+ ]
50414
+ });
50415
+ }
50416
+ function ShapeToolbar({ controller, snapshot, stroke, locked, anchor }) {
50417
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50418
+ const locked_ = !mutable || locked;
50419
+ const ids = (stroke.clusterId ? controller.query.all().filter((o) => o.type === "stroke" && o.clusterId === stroke.clusterId) : [stroke]).map((m) => m.id);
50420
+ const applyToAll = (patch) => {
50421
+ for (const id of ids) controller.content.update(id, patch);
50422
+ };
50423
+ return /* @__PURE__ */ jsxs(ToolbarShell, {
50424
+ anchor,
50425
+ children: [
50426
+ /* @__PURE__ */ jsxs("div", {
50427
+ className: "scrawl-board__style-group",
50428
+ children: [/* @__PURE__ */ jsx("span", { children: "Width" }), SHAPE_WIDTHS.map(([label, width]) => /* @__PURE__ */ jsx("button", {
50429
+ "aria-label": `${label} stroke`,
50430
+ "aria-pressed": Math.abs(stroke.baseWidth - width) < .001,
50431
+ disabled: locked_,
50432
+ onClick: () => applyToAll({ baseWidth: width }),
50433
+ type: "button",
50434
+ children: label
50435
+ }, label))]
50436
+ }),
50437
+ /* @__PURE__ */ jsxs("div", {
50438
+ className: "scrawl-board__style-group",
50439
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(INK_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50440
+ "aria-label": `${name} shape`,
50441
+ "aria-pressed": stroke.color.toLowerCase() === value.toLowerCase(),
50442
+ disabled: locked_,
50443
+ onClick: () => applyToAll({ color: value }),
50444
+ type: "button",
50445
+ children: /* @__PURE__ */ jsx("span", {
50446
+ "aria-hidden": "true",
50447
+ style: { backgroundColor: value }
50448
+ })
50449
+ }, value))]
50450
+ }),
50451
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50452
+ controller,
50453
+ mutable,
50454
+ locked,
50455
+ ids
50456
+ })
50457
+ ]
50458
+ });
50459
+ }
50460
+ function LockDuplicateDelete({ controller, mutable, locked, ids }) {
50461
+ return /* @__PURE__ */ jsxs("div", {
50462
+ className: "scrawl-board__style-group",
50463
+ children: [
50464
+ /* @__PURE__ */ jsx("button", {
50465
+ "aria-label": locked ? "Unlock" : "Lock",
50466
+ disabled: !mutable,
50467
+ onClick: () => {
50468
+ for (const id of ids) controller.content.update(id, locked ? { locked: false } : { locked: true });
50469
+ },
50470
+ type: "button",
50471
+ children: locked ? "Unlock" : "Lock"
50472
+ }),
50473
+ /* @__PURE__ */ jsx("button", {
50474
+ "aria-label": "Duplicate",
50475
+ disabled: !mutable || locked,
50476
+ onClick: () => {
50477
+ const newIds = controller.content.duplicate(ids);
50478
+ if (newIds.length) controller.content.select(newIds);
50479
+ },
50480
+ type: "button",
50481
+ children: "Duplicate"
50482
+ }),
50483
+ /* @__PURE__ */ jsx("button", {
50484
+ "aria-label": "Delete",
50485
+ disabled: !mutable || locked,
50486
+ onClick: () => controller.content.remove(ids),
50487
+ type: "button",
50488
+ children: "Delete"
50489
+ })
50490
+ ]
50491
+ });
50492
+ }
50493
+ //#endregion
49869
50494
  //#region src/ui/toolbar/style-shelf.tsx
49870
50495
  var ERASER_RADII = [
49871
50496
  ["Fine", 1],
@@ -49904,7 +50529,7 @@ var SHELF_TOOLS = /* @__PURE__ */ new Set([
49904
50529
  "timer"
49905
50530
  ]);
49906
50531
  /** Contextual per-tool style controls — visible while a styleable tool is active. */
49907
- function StyleShelf({ controller, snapshot }) {
50532
+ function StyleShelf({ controller, snapshot, anchor }) {
49908
50533
  if (!SHELF_TOOLS.has(snapshot.tool)) return null;
49909
50534
  const mutable = snapshot.status === "ready" && !snapshot.readOnly;
49910
50535
  const { style } = snapshot;
@@ -49912,7 +50537,12 @@ function StyleShelf({ controller, snapshot }) {
49912
50537
  return /* @__PURE__ */ jsxs("div", {
49913
50538
  "aria-label": "Board style",
49914
50539
  className: "scrawl-board__style-shelf",
50540
+ "data-anchored": !!anchor,
49915
50541
  role: "toolbar",
50542
+ style: anchor ? {
50543
+ left: anchor.x,
50544
+ top: anchor.y
50545
+ } : void 0,
49916
50546
  children: [
49917
50547
  snapshot.tool === "highlighter" && /* @__PURE__ */ jsxs("div", {
49918
50548
  className: "scrawl-board__style-group",
@@ -50142,6 +50772,7 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50142
50772
  const [panel, setPanel] = useState(null);
50143
50773
  const [query, setQuery] = useState("");
50144
50774
  const [announcement, setAnnouncement] = useState("Board controls ready");
50775
+ const [styleShelfAnchor, setStyleShelfAnchor] = useState(void 0);
50145
50776
  const importRef = useRef(null);
50146
50777
  const openerRef = useRef(null);
50147
50778
  const searchRef = useRef(null);
@@ -50152,6 +50783,25 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50152
50783
  useEffect(() => {
50153
50784
  if (panel === "search") searchRef.current?.focus();
50154
50785
  }, [panel]);
50786
+ useEffect(() => {
50787
+ const recompute = () => {
50788
+ const root = chromeRef.current;
50789
+ const button = root?.querySelector(".scrawl-board__tools button[aria-pressed=\"true\"]");
50790
+ if (!root || !button) {
50791
+ setStyleShelfAnchor(void 0);
50792
+ return;
50793
+ }
50794
+ const buttonRect = button.getBoundingClientRect();
50795
+ const rootRect = root.getBoundingClientRect();
50796
+ setStyleShelfAnchor({
50797
+ x: buttonRect.left + buttonRect.width / 2 - rootRect.left,
50798
+ y: buttonRect.top - rootRect.top
50799
+ });
50800
+ };
50801
+ recompute();
50802
+ window.addEventListener("resize", recompute);
50803
+ return () => window.removeEventListener("resize", recompute);
50804
+ }, [snapshot.tool]);
50155
50805
  const open = (next, opener) => {
50156
50806
  openerRef.current = opener;
50157
50807
  setPanel(next);
@@ -50380,9 +51030,14 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50380
51030
  ]
50381
51031
  }),
50382
51032
  renderSlot(slots?.stylePanel, enabled("styleShelf") && /* @__PURE__ */ jsx(StyleShelf, {
51033
+ anchor: styleShelfAnchor,
50383
51034
  controller,
50384
51035
  snapshot
50385
51036
  })),
51037
+ enabled("focusedItemToolbar") && /* @__PURE__ */ jsx(FocusedItemToolbar, {
51038
+ controller,
51039
+ snapshot
51040
+ }),
50386
51041
  ContextMenu && /* @__PURE__ */ jsx(ContextMenu, {
50387
51042
  controller,
50388
51043
  snapshot
@@ -51025,4 +51680,4 @@ function ScrawlBoard({ documentId, initialDocument, onReady, className, style })
51025
51680
  });
51026
51681
  }
51027
51682
  //#endregion
51028
- export { AssetResolutionError, DefaultBoardChrome, InlineEditors, MultiplayerCursors, SUPPORTED_ASSET_MEDIA_TYPES, Scrawl, ScrawlBoard, ScrawlCanvas, ScrawlDefaultUI, ScrawlPortal, ScrawlProvider, StyleShelf, assetRef, clampAssetCacheBytes, cloneCustomObject, isAssetRef, resolveScrawlTheme, scrawlThemePresets, useScrawlController, useScrawlSnapshot, useScrawlTheme, validateScrawlTheme };
51683
+ export { AssetResolutionError, DefaultBoardChrome, FocusedItemToolbar, InlineEditors, MultiplayerCursors, SUPPORTED_ASSET_MEDIA_TYPES, Scrawl, ScrawlBoard, ScrawlCanvas, ScrawlDefaultUI, ScrawlPortal, ScrawlProvider, StyleShelf, assetRef, clampAssetCacheBytes, cloneCustomObject, isAssetRef, resolveScrawlTheme, scrawlThemePresets, useScrawlController, useScrawlSnapshot, useScrawlTheme, validateScrawlTheme };