@scrawl-board/board 0.1.0-beta.3 → 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) {
@@ -35926,12 +35942,16 @@ var FRAGMENT = `
35926
35942
  return max(lineAlpha2.x, lineAlpha2.y);
35927
35943
  }
35928
35944
 
35929
- // Distance from p's nearest grid intersection, in world units. Dot radius
35930
- // matches the configured line width in pixels, same anti-aliasing.
35945
+ // Distance from p's nearest grid intersection, in world units. A line's
35946
+ // width reads as visible even at 1px because it spans the whole screen;
35947
+ // an isolated dot at that same 1px radius gets almost entirely eaten by
35948
+ // anti-aliasing and all but disappears. Scaling the radius up keeps a
35949
+ // dot's on-screen weight comparable to a line drawn with the same
35950
+ // uGridLineWidthPx, rather than using it literally as a radius.
35931
35951
  float gridDotAlpha(vec2 p) {
35932
35952
  vec2 cell = mod(p, uGridSpacing) - uGridSpacing * 0.5;
35933
35953
  float dist = length(cell);
35934
- float radius = uGridLineWidthPx * uWorldPerPixel;
35954
+ float radius = uGridLineWidthPx * uWorldPerPixel * 1.75;
35935
35955
  float aa = uWorldPerPixel;
35936
35956
  return 1.0 - smoothstep(radius - aa * 0.5, radius + aa * 0.5, dist);
35937
35957
  }
@@ -36508,6 +36528,7 @@ var StrokeRenderer = class {
36508
36528
  if (mesh) {
36509
36529
  mesh.geometry.dispose();
36510
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);
36511
36532
  }
36512
36533
  }
36513
36534
  for (const stroke of change.transformed) {
@@ -44687,7 +44708,7 @@ function distToSegmentSq(p, a, b) {
44687
44708
  }
44688
44709
  //#endregion
44689
44710
  //#region src/interaction/tools/eraserTool.ts
44690
- var DECAY_PER_PASS = .4;
44711
+ var DECAY_PER_PASS = 1;
44691
44712
  /** A point can take another decay pass after this long — rubbing works. */
44692
44713
  var REARM_MS = 250;
44693
44714
  var EraserTool = class {
@@ -44781,19 +44802,11 @@ var EraserTool = class {
44781
44802
  const runs = partitionByErasure(current.points);
44782
44803
  const survives = runs.filter((r) => r.survives && r.points.length >= 2);
44783
44804
  const keepId = survives.length === 1 && survives[0].points.length === current.points.length;
44784
- const m = current.matrix;
44785
44805
  for (const run of runs) if (run.survives && run.points.length >= 2) after.push({
44786
44806
  ...current,
44787
44807
  id: keepId ? current.id : crypto.randomUUID(),
44788
44808
  points: run.points
44789
44809
  });
44790
- else if (!run.survives) {
44791
- const world = m ? run.points.map((p) => ({
44792
- ...p,
44793
- ...apply(m, p)
44794
- })) : run.points;
44795
- this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1), current.tool !== "shape");
44796
- }
44797
44810
  }
44798
44811
  doc.removeStrokes(before.map((s) => s.id));
44799
44812
  doc.addStrokes(after.map(cloneStroke));
@@ -45001,10 +45014,10 @@ function buildShape(kind, a, b, step, constrain) {
45001
45014
  case "ellipse": return [ellipse(a, targetB, step)];
45002
45015
  case "line": return [sampleSegment(a, constrain ? snappedEnd(a, b) : b, step)];
45003
45016
  case "arrow": return arrow(a, constrain ? snappedEnd(a, b) : b, step);
45004
- case "triangle": return [polygon(3, a, targetB, step, -Math.PI / 2)];
45017
+ case "triangle": return [polygon(3, a, targetB, step, Math.PI / 2)];
45005
45018
  case "diamond": return [polygon(4, a, targetB, step, 0)];
45006
- case "pentagon": return [polygon(5, a, targetB, step, -Math.PI / 2)];
45007
- 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)];
45008
45021
  case "octagon": return [polygon(8, a, targetB, step, Math.PI / 8)];
45009
45022
  case "star": return [star(5, a, targetB, step)];
45010
45023
  case "heart": return [heart(a, targetB, step)];
@@ -45203,6 +45216,7 @@ var ShapeTool = class {
45203
45216
  this.ctx.clusters.assign(strokes[0]);
45204
45217
  for (const s of strokes) s.clusterId = strokes[0].clusterId;
45205
45218
  this.ctx.history.execute(new AddStrokesCommand(strokes));
45219
+ this.ctx.selection.set(strokes.map((s) => s.id));
45206
45220
  this.ctx.setTool?.("select");
45207
45221
  }
45208
45222
  this.ctx.transition("idle");
@@ -45784,6 +45798,13 @@ var HANDLE_HIT_PX = 12;
45784
45798
  var ROTATE_OFFSET_PX = 26;
45785
45799
  var ROTATE_HIT_PX = 14;
45786
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;
45787
45808
  var SelectionGizmo = class {
45788
45809
  constructor(scene) {
45789
45810
  this.group = new Group();
@@ -45798,7 +45819,7 @@ var SelectionGizmo = class {
45798
45819
  this.outline = new LineLoop(new BufferGeometry(), this.lineMaterial);
45799
45820
  this.outline.position.z = GIZMO_Z;
45800
45821
  this.outline.frustumCulled = false;
45801
- const handleGeometry = new PlaneGeometry(1, 1);
45822
+ const handleGeometry = new CircleGeometry(.5, 20);
45802
45823
  const handleMaterial = new MeshBasicMaterial({ color: COLOR });
45803
45824
  const makeHandle = () => new Mesh(handleGeometry, handleMaterial);
45804
45825
  this.handles = {
@@ -45826,18 +45847,19 @@ var SelectionGizmo = class {
45826
45847
  if (!box) return;
45827
45848
  this.lineMaterial.color.setHex(locked ? 16096779 : COLOR);
45828
45849
  this.rotateHandle.visible = !locked;
45850
+ const visual = this.visualBox(box, wpp);
45829
45851
  const positions = new Float32Array([
45830
- box.minX,
45831
- box.minY,
45852
+ visual.minX,
45853
+ visual.minY,
45832
45854
  GIZMO_Z,
45833
- box.maxX,
45834
- box.minY,
45855
+ visual.maxX,
45856
+ visual.minY,
45835
45857
  GIZMO_Z,
45836
- box.maxX,
45837
- box.maxY,
45858
+ visual.maxX,
45859
+ visual.maxY,
45838
45860
  GIZMO_Z,
45839
- box.minX,
45840
- box.maxY,
45861
+ visual.minX,
45862
+ visual.maxY,
45841
45863
  GIZMO_Z
45842
45864
  ]);
45843
45865
  this.outline.geometry.dispose();
@@ -45849,17 +45871,27 @@ var SelectionGizmo = class {
45849
45871
  for (const [handle, mesh] of Object.entries(this.handles)) {
45850
45872
  mesh.visible = !locked;
45851
45873
  if (!locked) {
45852
- const p = this.handlePoint(handle, box);
45874
+ const p = this.handlePoint(handle, visual);
45853
45875
  mesh.position.set(p.x, p.y, GIZMO_Z);
45854
45876
  mesh.scale.set(size, size, 1);
45855
45877
  }
45856
45878
  }
45857
45879
  if (!locked) {
45858
- const rotate = this.rotatePoint(box, wpp);
45880
+ const rotate = this.rotatePoint(visual, wpp);
45859
45881
  this.rotateHandle.position.set(rotate.x, rotate.y, GIZMO_Z);
45860
45882
  this.rotateHandle.scale.set(size, size, 1);
45861
45883
  }
45862
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
+ }
45863
45895
  handlePoint(handle, box) {
45864
45896
  const midX = (box.minX + box.maxX) / 2;
45865
45897
  const midY = (box.minY + box.maxY) / 2;
@@ -45947,7 +45979,8 @@ var SelectionGizmo = class {
45947
45979
  if (!this.box || this.isLocked) return null;
45948
45980
  const handleR = HANDLE_HIT_PX * this.wpp;
45949
45981
  const edgeSlop = 6 * this.wpp;
45950
- 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);
45951
45984
  if (Math.hypot(p.x - rotate.x, p.y - rotate.y) < ROTATE_HIT_PX * this.wpp) return { kind: "rotate" };
45952
45985
  for (const handle of [
45953
45986
  "nw",
@@ -45959,13 +45992,13 @@ var SelectionGizmo = class {
45959
45992
  "e",
45960
45993
  "w"
45961
45994
  ]) {
45962
- const hp = this.handlePoint(handle, this.box);
45995
+ const hp = this.handlePoint(handle, visual);
45963
45996
  if (Math.abs(p.x - hp.x) < handleR && Math.abs(p.y - hp.y) < handleR) return {
45964
45997
  kind: "scale",
45965
45998
  handle
45966
45999
  };
45967
46000
  }
45968
- const { minX, maxX, minY, maxY } = this.box;
46001
+ const { minX, maxX, minY, maxY } = visual;
45969
46002
  const inX = p.x >= minX - edgeSlop && p.x <= maxX + edgeSlop;
45970
46003
  const inY = p.y >= minY - edgeSlop && p.y <= maxY + edgeSlop;
45971
46004
  if (inX && Math.abs(p.y - maxY) < edgeSlop) return {
@@ -45985,7 +46018,7 @@ var SelectionGizmo = class {
45985
46018
  handle: "w"
45986
46019
  };
45987
46020
  const pad = 2 * this.wpp;
45988
- 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" };
45989
46022
  return null;
45990
46023
  }
45991
46024
  rotatePoint(box, wpp) {
@@ -48218,6 +48251,112 @@ function createBoardControllerWithEngine(options, existingEngine) {
48218
48251
  })),
48219
48252
  ...[...document.allCustomObjects()].map((value) => clone(toBoardObject(value)))
48220
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
+ };
48221
48360
  const updateObject = (id, patch) => {
48222
48361
  assertMutable();
48223
48362
  const before = readObject(id);
@@ -48259,6 +48398,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48259
48398
  zoom: view.zoom,
48260
48399
  readOnly,
48261
48400
  selection: Object.freeze([...selection]),
48401
+ focusedItem: computeFocusedItem(),
48262
48402
  strokeCount: [...document.all()].length,
48263
48403
  objectCount: allObjects().length,
48264
48404
  canUndo: history.canUndo,
@@ -48673,10 +48813,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48673
48813
  changed();
48674
48814
  },
48675
48815
  get: () => ({ ...view }),
48676
- boardToScreen: (point) => engine ? engine.boardToScreen(point) : {
48677
- x: (point.x - view.x) * view.zoom,
48678
- y: (view.y - point.y) * view.zoom
48679
- },
48816
+ boardToScreen: (point) => projectToScreen(point),
48680
48817
  screenToBoard: (point) => engine ? engine.screenToBoard(point.x, point.y) ?? {
48681
48818
  x: point.x,
48682
48819
  y: point.y
@@ -48713,6 +48850,81 @@ function createBoardControllerWithEngine(options, existingEngine) {
48713
48850
  update(id, patch) {
48714
48851
  updateObject(id, patch);
48715
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
+ },
48716
48928
  table: {
48717
48929
  addRow(tableId) {
48718
48930
  const table = document.getTable(tableId);
@@ -49660,14 +49872,17 @@ function InlineEditors({ controller, renderPortal }) {
49660
49872
  if (editor?.kind === "text") {
49661
49873
  const text = editor.text;
49662
49874
  if (editor.id === null) {
49663
- if (text.trim()) controller.content.add({
49664
- type: "text",
49665
- x: editor.boardX,
49666
- y: editor.boardY,
49667
- text,
49668
- color: editor.color,
49669
- fontSize: TEXT_DEFAULT_SIZE
49670
- });
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
+ }
49671
49886
  } else if (!text.trim()) controller.content.remove([editor.id]);
49672
49887
  else controller.content.update(editor.id, { text });
49673
49888
  }
@@ -49862,6 +50077,366 @@ function InlineEditors({ controller, renderPortal }) {
49862
50077
  }))] });
49863
50078
  }
49864
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
49865
50440
  //#region src/ui/toolbar/style-shelf.tsx
49866
50441
  var ERASER_RADII = [
49867
50442
  ["Fine", 1],
@@ -49900,7 +50475,7 @@ var SHELF_TOOLS = /* @__PURE__ */ new Set([
49900
50475
  "timer"
49901
50476
  ]);
49902
50477
  /** Contextual per-tool style controls — visible while a styleable tool is active. */
49903
- function StyleShelf({ controller, snapshot }) {
50478
+ function StyleShelf({ controller, snapshot, anchor }) {
49904
50479
  if (!SHELF_TOOLS.has(snapshot.tool)) return null;
49905
50480
  const mutable = snapshot.status === "ready" && !snapshot.readOnly;
49906
50481
  const { style } = snapshot;
@@ -49908,7 +50483,12 @@ function StyleShelf({ controller, snapshot }) {
49908
50483
  return /* @__PURE__ */ jsxs("div", {
49909
50484
  "aria-label": "Board style",
49910
50485
  className: "scrawl-board__style-shelf",
50486
+ "data-anchored": !!anchor,
49911
50487
  role: "toolbar",
50488
+ style: anchor ? {
50489
+ left: anchor.x,
50490
+ top: anchor.y
50491
+ } : void 0,
49912
50492
  children: [
49913
50493
  snapshot.tool === "highlighter" && /* @__PURE__ */ jsxs("div", {
49914
50494
  className: "scrawl-board__style-group",
@@ -50138,6 +50718,7 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50138
50718
  const [panel, setPanel] = useState(null);
50139
50719
  const [query, setQuery] = useState("");
50140
50720
  const [announcement, setAnnouncement] = useState("Board controls ready");
50721
+ const [styleShelfAnchor, setStyleShelfAnchor] = useState(void 0);
50141
50722
  const importRef = useRef(null);
50142
50723
  const openerRef = useRef(null);
50143
50724
  const searchRef = useRef(null);
@@ -50148,6 +50729,25 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50148
50729
  useEffect(() => {
50149
50730
  if (panel === "search") searchRef.current?.focus();
50150
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]);
50151
50751
  const open = (next, opener) => {
50152
50752
  openerRef.current = opener;
50153
50753
  setPanel(next);
@@ -50376,9 +50976,14 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50376
50976
  ]
50377
50977
  }),
50378
50978
  renderSlot(slots?.stylePanel, enabled("styleShelf") && /* @__PURE__ */ jsx(StyleShelf, {
50979
+ anchor: styleShelfAnchor,
50379
50980
  controller,
50380
50981
  snapshot
50381
50982
  })),
50983
+ enabled("focusedItemToolbar") && /* @__PURE__ */ jsx(FocusedItemToolbar, {
50984
+ controller,
50985
+ snapshot
50986
+ }),
50382
50987
  ContextMenu && /* @__PURE__ */ jsx(ContextMenu, {
50383
50988
  controller,
50384
50989
  snapshot
@@ -51021,4 +51626,4 @@ function ScrawlBoard({ documentId, initialDocument, onReady, className, style })
51021
51626
  });
51022
51627
  }
51023
51628
  //#endregion
51024
- 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 };