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

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) {
@@ -36512,6 +36528,7 @@ var StrokeRenderer = class {
36512
36528
  if (mesh) {
36513
36529
  mesh.geometry.dispose();
36514
36530
  mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36531
+ mesh.material = stroke.tool === "highlighter" ? this.materials.highlight(stroke.color) : this.materials.get(stroke.color);
36515
36532
  }
36516
36533
  }
36517
36534
  for (const stroke of change.transformed) {
@@ -44691,7 +44708,7 @@ function distToSegmentSq(p, a, b) {
44691
44708
  }
44692
44709
  //#endregion
44693
44710
  //#region src/interaction/tools/eraserTool.ts
44694
- var DECAY_PER_PASS = .4;
44711
+ var DECAY_PER_PASS = 1;
44695
44712
  /** A point can take another decay pass after this long — rubbing works. */
44696
44713
  var REARM_MS = 250;
44697
44714
  var EraserTool = class {
@@ -44785,19 +44802,11 @@ var EraserTool = class {
44785
44802
  const runs = partitionByErasure(current.points);
44786
44803
  const survives = runs.filter((r) => r.survives && r.points.length >= 2);
44787
44804
  const keepId = survives.length === 1 && survives[0].points.length === current.points.length;
44788
- const m = current.matrix;
44789
44805
  for (const run of runs) if (run.survives && run.points.length >= 2) after.push({
44790
44806
  ...current,
44791
44807
  id: keepId ? current.id : crypto.randomUUID(),
44792
44808
  points: run.points
44793
44809
  });
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
44810
  }
44802
44811
  doc.removeStrokes(before.map((s) => s.id));
44803
44812
  doc.addStrokes(after.map(cloneStroke));
@@ -45005,10 +45014,10 @@ function buildShape(kind, a, b, step, constrain) {
45005
45014
  case "ellipse": return [ellipse(a, targetB, step)];
45006
45015
  case "line": return [sampleSegment(a, constrain ? snappedEnd(a, b) : b, step)];
45007
45016
  case "arrow": return arrow(a, constrain ? snappedEnd(a, b) : b, step);
45008
- case "triangle": return [polygon(3, a, targetB, step, -Math.PI / 2)];
45017
+ case "triangle": return [polygon(3, a, targetB, step, Math.PI / 2)];
45009
45018
  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)];
45019
+ case "pentagon": return [polygon(5, a, targetB, step, Math.PI / 2)];
45020
+ case "hexagon": return [polygon(6, a, targetB, step, Math.PI / 2)];
45012
45021
  case "octagon": return [polygon(8, a, targetB, step, Math.PI / 8)];
45013
45022
  case "star": return [star(5, a, targetB, step)];
45014
45023
  case "heart": return [heart(a, targetB, step)];
@@ -45207,6 +45216,7 @@ var ShapeTool = class {
45207
45216
  this.ctx.clusters.assign(strokes[0]);
45208
45217
  for (const s of strokes) s.clusterId = strokes[0].clusterId;
45209
45218
  this.ctx.history.execute(new AddStrokesCommand(strokes));
45219
+ this.ctx.selection.set(strokes.map((s) => s.id));
45210
45220
  this.ctx.setTool?.("select");
45211
45221
  }
45212
45222
  this.ctx.transition("idle");
@@ -45788,6 +45798,13 @@ var HANDLE_HIT_PX = 12;
45788
45798
  var ROTATE_OFFSET_PX = 26;
45789
45799
  var ROTATE_HIT_PX = 14;
45790
45800
  var COLOR = 2450411;
45801
+ /**
45802
+ * Visual-only outset between the selected object and the gizmo's outline/
45803
+ * handles — the object's actual bounding box (used for resize math, see
45804
+ * `anchorPoint`/`handlePoint`'s callers in `selectTool.ts`) never changes;
45805
+ * only where the outline and handle meshes are *drawn* and hit-tested does.
45806
+ */
45807
+ var GAP_PX = 8;
45791
45808
  var SelectionGizmo = class {
45792
45809
  constructor(scene) {
45793
45810
  this.group = new Group();
@@ -45802,7 +45819,7 @@ var SelectionGizmo = class {
45802
45819
  this.outline = new LineLoop(new BufferGeometry(), this.lineMaterial);
45803
45820
  this.outline.position.z = GIZMO_Z;
45804
45821
  this.outline.frustumCulled = false;
45805
- const handleGeometry = new PlaneGeometry(1, 1);
45822
+ const handleGeometry = new CircleGeometry(.5, 20);
45806
45823
  const handleMaterial = new MeshBasicMaterial({ color: COLOR });
45807
45824
  const makeHandle = () => new Mesh(handleGeometry, handleMaterial);
45808
45825
  this.handles = {
@@ -45830,18 +45847,19 @@ var SelectionGizmo = class {
45830
45847
  if (!box) return;
45831
45848
  this.lineMaterial.color.setHex(locked ? 16096779 : COLOR);
45832
45849
  this.rotateHandle.visible = !locked;
45850
+ const visual = this.visualBox(box, wpp);
45833
45851
  const positions = new Float32Array([
45834
- box.minX,
45835
- box.minY,
45852
+ visual.minX,
45853
+ visual.minY,
45836
45854
  GIZMO_Z,
45837
- box.maxX,
45838
- box.minY,
45855
+ visual.maxX,
45856
+ visual.minY,
45839
45857
  GIZMO_Z,
45840
- box.maxX,
45841
- box.maxY,
45858
+ visual.maxX,
45859
+ visual.maxY,
45842
45860
  GIZMO_Z,
45843
- box.minX,
45844
- box.maxY,
45861
+ visual.minX,
45862
+ visual.maxY,
45845
45863
  GIZMO_Z
45846
45864
  ]);
45847
45865
  this.outline.geometry.dispose();
@@ -45853,17 +45871,27 @@ var SelectionGizmo = class {
45853
45871
  for (const [handle, mesh] of Object.entries(this.handles)) {
45854
45872
  mesh.visible = !locked;
45855
45873
  if (!locked) {
45856
- const p = this.handlePoint(handle, box);
45874
+ const p = this.handlePoint(handle, visual);
45857
45875
  mesh.position.set(p.x, p.y, GIZMO_Z);
45858
45876
  mesh.scale.set(size, size, 1);
45859
45877
  }
45860
45878
  }
45861
45879
  if (!locked) {
45862
- const rotate = this.rotatePoint(box, wpp);
45880
+ const rotate = this.rotatePoint(visual, wpp);
45863
45881
  this.rotateHandle.position.set(rotate.x, rotate.y, GIZMO_Z);
45864
45882
  this.rotateHandle.scale.set(size, size, 1);
45865
45883
  }
45866
45884
  }
45885
+ /** 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`). */
45886
+ visualBox(box, wpp) {
45887
+ const gap = GAP_PX * wpp;
45888
+ return {
45889
+ minX: box.minX - gap,
45890
+ minY: box.minY - gap,
45891
+ maxX: box.maxX + gap,
45892
+ maxY: box.maxY + gap
45893
+ };
45894
+ }
45867
45895
  handlePoint(handle, box) {
45868
45896
  const midX = (box.minX + box.maxX) / 2;
45869
45897
  const midY = (box.minY + box.maxY) / 2;
@@ -45951,7 +45979,8 @@ var SelectionGizmo = class {
45951
45979
  if (!this.box || this.isLocked) return null;
45952
45980
  const handleR = HANDLE_HIT_PX * this.wpp;
45953
45981
  const edgeSlop = 6 * this.wpp;
45954
- const rotate = this.rotatePoint(this.box, this.wpp);
45982
+ const visual = this.visualBox(this.box, this.wpp);
45983
+ const rotate = this.rotatePoint(visual, this.wpp);
45955
45984
  if (Math.hypot(p.x - rotate.x, p.y - rotate.y) < ROTATE_HIT_PX * this.wpp) return { kind: "rotate" };
45956
45985
  for (const handle of [
45957
45986
  "nw",
@@ -45963,13 +45992,13 @@ var SelectionGizmo = class {
45963
45992
  "e",
45964
45993
  "w"
45965
45994
  ]) {
45966
- const hp = this.handlePoint(handle, this.box);
45995
+ const hp = this.handlePoint(handle, visual);
45967
45996
  if (Math.abs(p.x - hp.x) < handleR && Math.abs(p.y - hp.y) < handleR) return {
45968
45997
  kind: "scale",
45969
45998
  handle
45970
45999
  };
45971
46000
  }
45972
- const { minX, maxX, minY, maxY } = this.box;
46001
+ const { minX, maxX, minY, maxY } = visual;
45973
46002
  const inX = p.x >= minX - edgeSlop && p.x <= maxX + edgeSlop;
45974
46003
  const inY = p.y >= minY - edgeSlop && p.y <= maxY + edgeSlop;
45975
46004
  if (inX && Math.abs(p.y - maxY) < edgeSlop) return {
@@ -45989,7 +46018,7 @@ var SelectionGizmo = class {
45989
46018
  handle: "w"
45990
46019
  };
45991
46020
  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" };
46021
+ 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
46022
  return null;
45994
46023
  }
45995
46024
  rotatePoint(box, wpp) {
@@ -48222,6 +48251,112 @@ function createBoardControllerWithEngine(options, existingEngine) {
48222
48251
  })),
48223
48252
  ...[...document.allCustomObjects()].map((value) => clone(toBoardObject(value)))
48224
48253
  ];
48254
+ const projectToScreen = (point) => engine ? engine.boardToScreen(point) : {
48255
+ x: (point.x - view.x) * view.zoom,
48256
+ y: (view.y - point.y) * view.zoom
48257
+ };
48258
+ const lockFields = (item) => ({
48259
+ locked: !!item.locked,
48260
+ ...item.lockedBy ? { lockedBy: item.lockedBy } : {},
48261
+ ...item.lockedByName ? { lockedByName: item.lockedByName } : {}
48262
+ });
48263
+ const lockFieldsMany = (items) => {
48264
+ const locked = items.filter((i) => i.locked);
48265
+ if (locked.length === 0) return { locked: false };
48266
+ const lockedBys = [...new Set(locked.map((i) => i.lockedBy).filter((v) => !!v))];
48267
+ const names = [...new Set(locked.map((i) => i.lockedByName).filter((v) => !!v))];
48268
+ return {
48269
+ locked: true,
48270
+ ...lockedBys.length === 1 ? { lockedBy: lockedBys[0] } : {},
48271
+ ...names.length === 1 ? { lockedByName: names[0] } : {}
48272
+ };
48273
+ };
48274
+ const strokeClusterAnchor = (strokes) => {
48275
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
48276
+ for (const s of strokes) for (const p of s.points) {
48277
+ const world = s.matrix ? apply(s.matrix, p) : p;
48278
+ minX = Math.min(minX, world.x);
48279
+ maxX = Math.max(maxX, world.x);
48280
+ minY = Math.min(minY, world.y);
48281
+ maxY = Math.max(maxY, world.y);
48282
+ }
48283
+ if (!Number.isFinite(minX)) return projectToScreen({
48284
+ x: 0,
48285
+ y: 0
48286
+ });
48287
+ return projectToScreen({
48288
+ x: (minX + maxX) / 2,
48289
+ y: maxY + 1.4
48290
+ });
48291
+ };
48292
+ /** Per-type anchor formulas mirror `ScrawlEngine.getSelectedItemInfo` exactly, so a Host-built toolbar lands in the same spot the SDK's own would. */
48293
+ const anchorFor = (obj) => {
48294
+ switch (obj.type) {
48295
+ case "note": return projectToScreen({
48296
+ x: obj.x,
48297
+ y: obj.y + obj.size / 2 + 1
48298
+ });
48299
+ case "text": {
48300
+ const { width } = measureTextBlock(obj.text, obj.fontSize);
48301
+ return projectToScreen({
48302
+ x: obj.x + width / 2,
48303
+ y: obj.y + 1
48304
+ });
48305
+ }
48306
+ case "table": {
48307
+ const { width } = measureTable(obj);
48308
+ return projectToScreen({
48309
+ x: obj.x + width / 2,
48310
+ y: obj.y + 1.2
48311
+ });
48312
+ }
48313
+ case "image": return projectToScreen({
48314
+ x: obj.x,
48315
+ y: obj.y + obj.height / 2 + 1.2
48316
+ });
48317
+ case "timer": return projectToScreen({
48318
+ x: obj.x,
48319
+ y: obj.y + obj.size / 2 + 1.2
48320
+ });
48321
+ case "custom": {
48322
+ const b = obj.fallback.bounds;
48323
+ return projectToScreen(apply(obj.transform, {
48324
+ x: b.x + b.width / 2,
48325
+ y: b.y + b.height + 1
48326
+ }));
48327
+ }
48328
+ case "stroke": return strokeClusterAnchor([obj]);
48329
+ }
48330
+ };
48331
+ const computeFocusedItem = () => {
48332
+ if (selection.length === 0) return null;
48333
+ if (selection.length === 1) {
48334
+ const obj = readObject(selection[0]);
48335
+ if (!obj) return null;
48336
+ if (obj.type === "custom") return {
48337
+ type: "custom",
48338
+ id: obj.id,
48339
+ locked: false,
48340
+ screenPosition: anchorFor(obj)
48341
+ };
48342
+ return {
48343
+ type: obj.type,
48344
+ id: obj.id,
48345
+ ...lockFields(obj),
48346
+ screenPosition: anchorFor(obj)
48347
+ };
48348
+ }
48349
+ const objects = selection.map(readObject);
48350
+ if (!objects.every((o) => o?.type === "stroke")) return null;
48351
+ const clusterId = objects[0].clusterId;
48352
+ if (!clusterId || !objects.every((o) => o.clusterId === clusterId)) return null;
48353
+ return {
48354
+ type: "stroke",
48355
+ id: objects[0].id,
48356
+ ...lockFieldsMany(objects),
48357
+ screenPosition: strokeClusterAnchor(objects)
48358
+ };
48359
+ };
48225
48360
  const updateObject = (id, patch) => {
48226
48361
  assertMutable();
48227
48362
  const before = readObject(id);
@@ -48263,6 +48398,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48263
48398
  zoom: view.zoom,
48264
48399
  readOnly,
48265
48400
  selection: Object.freeze([...selection]),
48401
+ focusedItem: computeFocusedItem(),
48266
48402
  strokeCount: [...document.all()].length,
48267
48403
  objectCount: allObjects().length,
48268
48404
  canUndo: history.canUndo,
@@ -48677,10 +48813,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48677
48813
  changed();
48678
48814
  },
48679
48815
  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
- },
48816
+ boardToScreen: (point) => projectToScreen(point),
48684
48817
  screenToBoard: (point) => engine ? engine.screenToBoard(point.x, point.y) ?? {
48685
48818
  x: point.x,
48686
48819
  y: point.y
@@ -48717,6 +48850,81 @@ function createBoardControllerWithEngine(options, existingEngine) {
48717
48850
  update(id, patch) {
48718
48851
  updateObject(id, patch);
48719
48852
  },
48853
+ duplicate(ids) {
48854
+ assertMutable();
48855
+ const objects = [...new Set(ids)].map(readObject).filter((o) => !!o);
48856
+ if (objects.length === 0) return [];
48857
+ const DX = 1.5;
48858
+ const DY = -1.5;
48859
+ const newClusterIds = /* @__PURE__ */ new Map();
48860
+ const clusterIdFor = (oldClusterId) => {
48861
+ let next = newClusterIds.get(oldClusterId);
48862
+ if (!next) {
48863
+ next = crypto.randomUUID();
48864
+ newClusterIds.set(oldClusterId, next);
48865
+ }
48866
+ return next;
48867
+ };
48868
+ const commands = [];
48869
+ const newIds = [];
48870
+ for (const obj of objects) {
48871
+ const id = createId();
48872
+ newIds.push(id);
48873
+ const rest = { ...obj };
48874
+ delete rest.locked;
48875
+ delete rest.lockedBy;
48876
+ delete rest.lockedByName;
48877
+ let duplicated;
48878
+ if (obj.type === "stroke") {
48879
+ const matrix = obj.matrix ?? [
48880
+ 1,
48881
+ 0,
48882
+ 0,
48883
+ 1,
48884
+ 0,
48885
+ 0
48886
+ ];
48887
+ duplicated = {
48888
+ ...rest,
48889
+ id,
48890
+ matrix: [
48891
+ matrix[0],
48892
+ matrix[1],
48893
+ matrix[2],
48894
+ matrix[3],
48895
+ matrix[4] + DX,
48896
+ matrix[5] + DY
48897
+ ],
48898
+ ...obj.clusterId ? { clusterId: clusterIdFor(obj.clusterId) } : {}
48899
+ };
48900
+ } else if (obj.type === "custom") {
48901
+ const t = obj.transform;
48902
+ duplicated = {
48903
+ ...rest,
48904
+ id,
48905
+ transform: [
48906
+ t[0],
48907
+ t[1],
48908
+ t[2],
48909
+ t[3],
48910
+ t[4] + DX,
48911
+ t[5] + DY
48912
+ ]
48913
+ };
48914
+ delete duplicated.lock;
48915
+ } else duplicated = {
48916
+ ...rest,
48917
+ id,
48918
+ x: obj.x + DX,
48919
+ y: obj.y + DY
48920
+ };
48921
+ validateBoardObject(duplicated.type, duplicated);
48922
+ commands.push(addCommand(duplicated.type, duplicated));
48923
+ }
48924
+ executeHistory(() => history.execute(new CommandBatch("duplicate content", commands)));
48925
+ changed();
48926
+ return newIds;
48927
+ },
48720
48928
  table: {
48721
48929
  addRow(tableId) {
48722
48930
  const table = document.getTable(tableId);
@@ -49664,14 +49872,17 @@ function InlineEditors({ controller, renderPortal }) {
49664
49872
  if (editor?.kind === "text") {
49665
49873
  const text = editor.text;
49666
49874
  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
- });
49875
+ if (text.trim()) {
49876
+ const newId = controller.content.add({
49877
+ type: "text",
49878
+ x: editor.boardX,
49879
+ y: editor.boardY,
49880
+ text,
49881
+ color: editor.color,
49882
+ fontSize: TEXT_DEFAULT_SIZE
49883
+ });
49884
+ controller.content.select([newId]);
49885
+ }
49675
49886
  } else if (!text.trim()) controller.content.remove([editor.id]);
49676
49887
  else controller.content.update(editor.id, { text });
49677
49888
  }
@@ -49866,6 +50077,366 @@ function InlineEditors({ controller, renderPortal }) {
49866
50077
  }))] });
49867
50078
  }
49868
50079
  //#endregion
50080
+ //#region src/ui/toolbar/focused-item-toolbar.tsx
50081
+ var NOTE_SIZES = [
50082
+ ["Small", 7],
50083
+ ["Medium", 10],
50084
+ ["Large", 14]
50085
+ ];
50086
+ var TEXT_SIZES = [
50087
+ ["Small", 1.2],
50088
+ ["Medium", 1.8],
50089
+ ["Large", 2.6]
50090
+ ];
50091
+ var SHAPE_WIDTHS = [
50092
+ ["Thin", .1],
50093
+ ["Medium", .15],
50094
+ ["Thick", .3]
50095
+ ];
50096
+ /** Matches the native selection gizmo's own outset (renderer/selection/gizmo.ts's GAP_PX), for a consistent look. */
50097
+ var HANDLE_GAP_PX = 8;
50098
+ var MIN_SIZE = 4;
50099
+ var MAX_SIZE = 30;
50100
+ var HANDLE_CORNERS = [
50101
+ "nw",
50102
+ "ne",
50103
+ "sw",
50104
+ "se"
50105
+ ];
50106
+ /**
50107
+ * Floating toolbar above the focused note, shape, or text block — Colour,
50108
+ * Size (note/text) or Width (shape), Lock/Unlock, Duplicate, Delete.
50109
+ * Table/image/timer/custom objects and plain (non-shape) ink strokes never
50110
+ * get a toolbar here — out of scope for this destination.
50111
+ *
50112
+ * Lock/Unlock carries no per-user ownership gating: nothing else in this
50113
+ * SDK enforces lock ownership either (`content.update` never checks
50114
+ * `locked`, and the engine's own internal unlock check has no way for a
50115
+ * Host to ever supply a real user id) — locking is advisory UI state
50116
+ * throughout, and this toolbar matches that rather than inventing an
50117
+ * enforcement story alone.
50118
+ *
50119
+ * A shape can be several strokes sharing one `clusterId` (e.g. an arrow's
50120
+ * shaft + head) — every action here applies to the whole cluster. Resolved
50121
+ * via `query.all()` + `clusterId`, not `snapshot.selection`: a canvas click
50122
+ * on one member selects every member internally, but the public selection
50123
+ * bridge only ever reports one id (`FocusedItem.id` is singular by design),
50124
+ * so reconstructing the cluster from the document is the reliable path
50125
+ * regardless of how the selection was made.
50126
+ *
50127
+ * A focused note also gets drag-to-resize corner handles — notes have no
50128
+ * gizmo of their own (the native selection gizmo is ink-stroke/shape-only),
50129
+ * so this is that capability's default-ui-owned equivalent. Proportional
50130
+ * (always-square) resize from the note's own centre, clamped to
50131
+ * `[MIN_SIZE, MAX_SIZE]`, independent of which corner is grabbed.
50132
+ */
50133
+ function FocusedItemToolbar({ controller, snapshot }) {
50134
+ const focused = snapshot.focusedItem;
50135
+ if (!focused) return null;
50136
+ const obj = controller.query.get(focused.id);
50137
+ if (!obj) return null;
50138
+ if (obj.type === "note") return /* @__PURE__ */ jsx(NoteToolbar, {
50139
+ controller,
50140
+ snapshot,
50141
+ note: obj,
50142
+ locked: focused.locked,
50143
+ anchor: focused.screenPosition
50144
+ });
50145
+ if (obj.type === "text") return /* @__PURE__ */ jsx(TextToolbar, {
50146
+ controller,
50147
+ snapshot,
50148
+ text: obj,
50149
+ locked: focused.locked,
50150
+ anchor: focused.screenPosition
50151
+ });
50152
+ if (obj.type === "stroke" && obj.tool === "shape") return /* @__PURE__ */ jsx(ShapeToolbar, {
50153
+ controller,
50154
+ snapshot,
50155
+ stroke: obj,
50156
+ locked: focused.locked,
50157
+ anchor: focused.screenPosition
50158
+ });
50159
+ return null;
50160
+ }
50161
+ function ToolbarShell({ anchor, children }) {
50162
+ if (!anchor) return null;
50163
+ return /* @__PURE__ */ jsx("div", {
50164
+ "aria-label": "Selected object",
50165
+ className: "scrawl-board__focus-toolbar",
50166
+ role: "toolbar",
50167
+ style: {
50168
+ left: anchor.x,
50169
+ top: anchor.y
50170
+ },
50171
+ children
50172
+ });
50173
+ }
50174
+ function NoteToolbar({ controller, snapshot, note, locked, anchor }) {
50175
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50176
+ const locked_ = !mutable || locked;
50177
+ const half = note.size / 2;
50178
+ const cornerA = controller.view.boardToScreen({
50179
+ x: note.x - half,
50180
+ y: note.y + half
50181
+ });
50182
+ const cornerB = controller.view.boardToScreen({
50183
+ x: note.x + half,
50184
+ y: note.y - half
50185
+ });
50186
+ const outline = {
50187
+ left: Math.min(cornerA.x, cornerB.x) - HANDLE_GAP_PX,
50188
+ top: Math.min(cornerA.y, cornerB.y) - HANDLE_GAP_PX,
50189
+ right: Math.max(cornerA.x, cornerB.x) + HANDLE_GAP_PX,
50190
+ bottom: Math.max(cornerA.y, cornerB.y) + HANDLE_GAP_PX
50191
+ };
50192
+ const handlePoints = {
50193
+ nw: {
50194
+ x: outline.left,
50195
+ y: outline.top
50196
+ },
50197
+ ne: {
50198
+ x: outline.right,
50199
+ y: outline.top
50200
+ },
50201
+ sw: {
50202
+ x: outline.left,
50203
+ y: outline.bottom
50204
+ },
50205
+ se: {
50206
+ x: outline.right,
50207
+ y: outline.bottom
50208
+ }
50209
+ };
50210
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
50211
+ /* @__PURE__ */ jsx("div", {
50212
+ "aria-hidden": "true",
50213
+ className: "scrawl-board__focus-outline",
50214
+ "data-locked": locked,
50215
+ style: {
50216
+ left: outline.left,
50217
+ top: outline.top,
50218
+ width: outline.right - outline.left,
50219
+ height: outline.bottom - outline.top
50220
+ }
50221
+ }),
50222
+ mutable && !locked && HANDLE_CORNERS.map((corner) => /* @__PURE__ */ jsx(ResizeHandle, {
50223
+ controller,
50224
+ note,
50225
+ corner,
50226
+ x: handlePoints[corner].x,
50227
+ y: handlePoints[corner].y
50228
+ }, corner)),
50229
+ /* @__PURE__ */ jsxs(ToolbarShell, {
50230
+ anchor,
50231
+ children: [
50232
+ /* @__PURE__ */ jsxs("div", {
50233
+ className: "scrawl-board__style-group",
50234
+ children: [/* @__PURE__ */ jsx("span", { children: "Size" }), NOTE_SIZES.map(([label, size]) => /* @__PURE__ */ jsx("button", {
50235
+ "aria-label": `${label} note`,
50236
+ "aria-pressed": note.size === size,
50237
+ disabled: locked_,
50238
+ onClick: () => controller.content.update(note.id, { size }),
50239
+ type: "button",
50240
+ children: label[0]
50241
+ }, label))]
50242
+ }),
50243
+ /* @__PURE__ */ jsxs("div", {
50244
+ className: "scrawl-board__style-group",
50245
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(NOTE_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50246
+ "aria-label": `${name} note`,
50247
+ "aria-pressed": note.color.toLowerCase() === value.toLowerCase(),
50248
+ disabled: locked_,
50249
+ onClick: () => controller.content.update(note.id, { color: value }),
50250
+ type: "button",
50251
+ children: /* @__PURE__ */ jsx("span", {
50252
+ "aria-hidden": "true",
50253
+ style: { backgroundColor: value }
50254
+ })
50255
+ }, value))]
50256
+ }),
50257
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50258
+ controller,
50259
+ mutable,
50260
+ locked,
50261
+ ids: [note.id]
50262
+ })
50263
+ ]
50264
+ })
50265
+ ] });
50266
+ }
50267
+ /**
50268
+ * One corner handle — drag distance from the note's own centre (its `x`/`y`
50269
+ * already *is* the centre) drives a proportional, always-square resize,
50270
+ * independent of which corner is grabbed. `note` is fresh every render (a
50271
+ * new `content.update` during drag triggers a new snapshot), so the
50272
+ * closure never goes stale.
50273
+ */
50274
+ function ResizeHandle({ controller, note, corner, x, y }) {
50275
+ const [dragging, setDragging] = useState(false);
50276
+ const nudge = (delta) => {
50277
+ const half = Math.max(MIN_SIZE / 2, Math.min(MAX_SIZE / 2, note.size / 2 + delta));
50278
+ controller.content.update(note.id, { size: half * 2 });
50279
+ };
50280
+ return /* @__PURE__ */ jsx("span", {
50281
+ "aria-label": `Resize note (${corner})`,
50282
+ "aria-valuemax": MAX_SIZE,
50283
+ "aria-valuemin": MIN_SIZE,
50284
+ "aria-valuenow": note.size,
50285
+ role: "slider",
50286
+ tabIndex: 0,
50287
+ className: "scrawl-board__focus-handle",
50288
+ "data-orientation": corner === "nw" || corner === "se" ? "nwse" : "nesw",
50289
+ style: {
50290
+ left: x,
50291
+ top: y
50292
+ },
50293
+ onKeyDown: (event) => {
50294
+ if (event.key === "ArrowUp" || event.key === "ArrowRight") {
50295
+ event.preventDefault();
50296
+ nudge(.5);
50297
+ } else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
50298
+ event.preventDefault();
50299
+ nudge(-.5);
50300
+ }
50301
+ },
50302
+ onPointerDown: (event) => {
50303
+ event.stopPropagation();
50304
+ event.currentTarget.setPointerCapture(event.pointerId);
50305
+ setDragging(true);
50306
+ },
50307
+ onPointerMove: (event) => {
50308
+ if (!dragging) return;
50309
+ const board = controller.view.screenToBoard({
50310
+ x: event.clientX,
50311
+ y: event.clientY
50312
+ });
50313
+ 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))));
50314
+ controller.content.update(note.id, { size: half * 2 });
50315
+ },
50316
+ onPointerUp: (event) => {
50317
+ event.currentTarget.releasePointerCapture(event.pointerId);
50318
+ setDragging(false);
50319
+ }
50320
+ });
50321
+ }
50322
+ function TextToolbar({ controller, snapshot, text, locked, anchor }) {
50323
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50324
+ const locked_ = !mutable || locked;
50325
+ return /* @__PURE__ */ jsxs(ToolbarShell, {
50326
+ anchor,
50327
+ children: [
50328
+ /* @__PURE__ */ jsxs("div", {
50329
+ className: "scrawl-board__style-group",
50330
+ children: [/* @__PURE__ */ jsx("span", { children: "Size" }), TEXT_SIZES.map(([label, fontSize]) => /* @__PURE__ */ jsx("button", {
50331
+ "aria-label": `${label} text`,
50332
+ "aria-pressed": Math.abs(text.fontSize - fontSize) < .001,
50333
+ disabled: locked_,
50334
+ onClick: () => controller.content.update(text.id, { fontSize }),
50335
+ type: "button",
50336
+ children: label[0]
50337
+ }, label))]
50338
+ }),
50339
+ /* @__PURE__ */ jsxs("div", {
50340
+ className: "scrawl-board__style-group",
50341
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(INK_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50342
+ "aria-label": `${name} text`,
50343
+ "aria-pressed": text.color.toLowerCase() === value.toLowerCase(),
50344
+ disabled: locked_,
50345
+ onClick: () => controller.content.update(text.id, { color: value }),
50346
+ type: "button",
50347
+ children: /* @__PURE__ */ jsx("span", {
50348
+ "aria-hidden": "true",
50349
+ style: { backgroundColor: value }
50350
+ })
50351
+ }, value))]
50352
+ }),
50353
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50354
+ controller,
50355
+ mutable,
50356
+ locked,
50357
+ ids: [text.id]
50358
+ })
50359
+ ]
50360
+ });
50361
+ }
50362
+ function ShapeToolbar({ controller, snapshot, stroke, locked, anchor }) {
50363
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50364
+ const locked_ = !mutable || locked;
50365
+ const ids = (stroke.clusterId ? controller.query.all().filter((o) => o.type === "stroke" && o.clusterId === stroke.clusterId) : [stroke]).map((m) => m.id);
50366
+ const applyToAll = (patch) => {
50367
+ for (const id of ids) controller.content.update(id, patch);
50368
+ };
50369
+ return /* @__PURE__ */ jsxs(ToolbarShell, {
50370
+ anchor,
50371
+ children: [
50372
+ /* @__PURE__ */ jsxs("div", {
50373
+ className: "scrawl-board__style-group",
50374
+ children: [/* @__PURE__ */ jsx("span", { children: "Width" }), SHAPE_WIDTHS.map(([label, width]) => /* @__PURE__ */ jsx("button", {
50375
+ "aria-label": `${label} stroke`,
50376
+ "aria-pressed": Math.abs(stroke.baseWidth - width) < .001,
50377
+ disabled: locked_,
50378
+ onClick: () => applyToAll({ baseWidth: width }),
50379
+ type: "button",
50380
+ children: label
50381
+ }, label))]
50382
+ }),
50383
+ /* @__PURE__ */ jsxs("div", {
50384
+ className: "scrawl-board__style-group",
50385
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(INK_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50386
+ "aria-label": `${name} shape`,
50387
+ "aria-pressed": stroke.color.toLowerCase() === value.toLowerCase(),
50388
+ disabled: locked_,
50389
+ onClick: () => applyToAll({ color: value }),
50390
+ type: "button",
50391
+ children: /* @__PURE__ */ jsx("span", {
50392
+ "aria-hidden": "true",
50393
+ style: { backgroundColor: value }
50394
+ })
50395
+ }, value))]
50396
+ }),
50397
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50398
+ controller,
50399
+ mutable,
50400
+ locked,
50401
+ ids
50402
+ })
50403
+ ]
50404
+ });
50405
+ }
50406
+ function LockDuplicateDelete({ controller, mutable, locked, ids }) {
50407
+ return /* @__PURE__ */ jsxs("div", {
50408
+ className: "scrawl-board__style-group",
50409
+ children: [
50410
+ /* @__PURE__ */ jsx("button", {
50411
+ "aria-label": locked ? "Unlock" : "Lock",
50412
+ disabled: !mutable,
50413
+ onClick: () => {
50414
+ for (const id of ids) controller.content.update(id, locked ? { locked: false } : { locked: true });
50415
+ },
50416
+ type: "button",
50417
+ children: locked ? "Unlock" : "Lock"
50418
+ }),
50419
+ /* @__PURE__ */ jsx("button", {
50420
+ "aria-label": "Duplicate",
50421
+ disabled: !mutable || locked,
50422
+ onClick: () => {
50423
+ const newIds = controller.content.duplicate(ids);
50424
+ if (newIds.length) controller.content.select(newIds);
50425
+ },
50426
+ type: "button",
50427
+ children: "Duplicate"
50428
+ }),
50429
+ /* @__PURE__ */ jsx("button", {
50430
+ "aria-label": "Delete",
50431
+ disabled: !mutable || locked,
50432
+ onClick: () => controller.content.remove(ids),
50433
+ type: "button",
50434
+ children: "Delete"
50435
+ })
50436
+ ]
50437
+ });
50438
+ }
50439
+ //#endregion
49869
50440
  //#region src/ui/toolbar/style-shelf.tsx
49870
50441
  var ERASER_RADII = [
49871
50442
  ["Fine", 1],
@@ -49904,7 +50475,7 @@ var SHELF_TOOLS = /* @__PURE__ */ new Set([
49904
50475
  "timer"
49905
50476
  ]);
49906
50477
  /** Contextual per-tool style controls — visible while a styleable tool is active. */
49907
- function StyleShelf({ controller, snapshot }) {
50478
+ function StyleShelf({ controller, snapshot, anchor }) {
49908
50479
  if (!SHELF_TOOLS.has(snapshot.tool)) return null;
49909
50480
  const mutable = snapshot.status === "ready" && !snapshot.readOnly;
49910
50481
  const { style } = snapshot;
@@ -49912,7 +50483,12 @@ function StyleShelf({ controller, snapshot }) {
49912
50483
  return /* @__PURE__ */ jsxs("div", {
49913
50484
  "aria-label": "Board style",
49914
50485
  className: "scrawl-board__style-shelf",
50486
+ "data-anchored": !!anchor,
49915
50487
  role: "toolbar",
50488
+ style: anchor ? {
50489
+ left: anchor.x,
50490
+ top: anchor.y
50491
+ } : void 0,
49916
50492
  children: [
49917
50493
  snapshot.tool === "highlighter" && /* @__PURE__ */ jsxs("div", {
49918
50494
  className: "scrawl-board__style-group",
@@ -50142,6 +50718,7 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50142
50718
  const [panel, setPanel] = useState(null);
50143
50719
  const [query, setQuery] = useState("");
50144
50720
  const [announcement, setAnnouncement] = useState("Board controls ready");
50721
+ const [styleShelfAnchor, setStyleShelfAnchor] = useState(void 0);
50145
50722
  const importRef = useRef(null);
50146
50723
  const openerRef = useRef(null);
50147
50724
  const searchRef = useRef(null);
@@ -50152,6 +50729,25 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50152
50729
  useEffect(() => {
50153
50730
  if (panel === "search") searchRef.current?.focus();
50154
50731
  }, [panel]);
50732
+ useEffect(() => {
50733
+ const recompute = () => {
50734
+ const root = chromeRef.current;
50735
+ const button = root?.querySelector(".scrawl-board__tools button[aria-pressed=\"true\"]");
50736
+ if (!root || !button) {
50737
+ setStyleShelfAnchor(void 0);
50738
+ return;
50739
+ }
50740
+ const buttonRect = button.getBoundingClientRect();
50741
+ const rootRect = root.getBoundingClientRect();
50742
+ setStyleShelfAnchor({
50743
+ x: buttonRect.left + buttonRect.width / 2 - rootRect.left,
50744
+ y: buttonRect.top - rootRect.top
50745
+ });
50746
+ };
50747
+ recompute();
50748
+ window.addEventListener("resize", recompute);
50749
+ return () => window.removeEventListener("resize", recompute);
50750
+ }, [snapshot.tool]);
50155
50751
  const open = (next, opener) => {
50156
50752
  openerRef.current = opener;
50157
50753
  setPanel(next);
@@ -50380,9 +50976,14 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50380
50976
  ]
50381
50977
  }),
50382
50978
  renderSlot(slots?.stylePanel, enabled("styleShelf") && /* @__PURE__ */ jsx(StyleShelf, {
50979
+ anchor: styleShelfAnchor,
50383
50980
  controller,
50384
50981
  snapshot
50385
50982
  })),
50983
+ enabled("focusedItemToolbar") && /* @__PURE__ */ jsx(FocusedItemToolbar, {
50984
+ controller,
50985
+ snapshot
50986
+ }),
50386
50987
  ContextMenu && /* @__PURE__ */ jsx(ContextMenu, {
50387
50988
  controller,
50388
50989
  snapshot
@@ -51025,4 +51626,4 @@ function ScrawlBoard({ documentId, initialDocument, onReady, className, style })
51025
51626
  });
51026
51627
  }
51027
51628
  //#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 };
51629
+ 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 };