@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/index.js CHANGED
@@ -151,7 +151,11 @@ function validateStrokes(raw) {
151
151
  for (let index = 0; index < raw.length; index += 1) {
152
152
  const value = raw[index];
153
153
  const path = `strokes[${index}]`;
154
- if (!isRecord(value) || !nonempty(value.id) || !nonempty(value.color) || !positive(value.baseWidth) || !optionalEnum(value.tool, ["marker", "highlighter"]) || !validLock(value) || !optionalString(value.clusterId) || !validMatrix(value.matrix) || !Array.isArray(value.points)) return validation("invalid stroke", path);
154
+ if (!isRecord(value) || !nonempty(value.id) || !nonempty(value.color) || !positive(value.baseWidth) || !optionalEnum(value.tool, [
155
+ "marker",
156
+ "highlighter",
157
+ "shape"
158
+ ]) || !validLock(value) || !optionalString(value.clusterId) || !validMatrix(value.matrix) || !Array.isArray(value.points)) return validation("invalid stroke", path);
155
159
  const points = [];
156
160
  for (let pointIndex = 0; pointIndex < value.points.length; pointIndex += 1) {
157
161
  const point = normalizePoint(value.points[pointIndex]);
@@ -432,9 +436,21 @@ var HIGHLIGHT_COLORS = {
432
436
  };
433
437
  var NOTE_COLORS = {
434
438
  yellow: "#FDE68A",
439
+ amber: "#FCD34D",
440
+ orange: "#FDBA74",
441
+ peach: "#FED7AA",
442
+ red: "#FCA5A5",
443
+ rose: "#FECACA",
435
444
  pink: "#FBCFE8",
445
+ magenta: "#F9A8D4",
446
+ purple: "#D8B4FE",
447
+ lavender: "#E9D5FF",
436
448
  blue: "#BFDBFE",
437
- green: "#BBF7D0"
449
+ cornflower: "#93C5FD",
450
+ sky: "#7DD3FC",
451
+ cyan: "#BAE6FD",
452
+ green: "#BBF7D0",
453
+ mint: "#86EFAC"
438
454
  };
439
455
  var TEXT_DEFAULT_SIZE = 1.8;
440
456
  function cloneText(block) {
@@ -36550,6 +36566,7 @@ var StrokeRenderer = class {
36550
36566
  if (mesh) {
36551
36567
  mesh.geometry.dispose();
36552
36568
  mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36569
+ mesh.material = stroke.tool === "highlighter" ? this.materials.highlight(stroke.color) : this.materials.get(stroke.color);
36553
36570
  }
36554
36571
  }
36555
36572
  for (const stroke of change.transformed) {
@@ -44729,7 +44746,7 @@ function distToSegmentSq(p, a, b) {
44729
44746
  }
44730
44747
  //#endregion
44731
44748
  //#region src/interaction/tools/eraserTool.ts
44732
- var DECAY_PER_PASS = .4;
44749
+ var DECAY_PER_PASS = 1;
44733
44750
  /** A point can take another decay pass after this long — rubbing works. */
44734
44751
  var REARM_MS = 250;
44735
44752
  var EraserTool = class {
@@ -44823,19 +44840,11 @@ var EraserTool = class {
44823
44840
  const runs = partitionByErasure(current.points);
44824
44841
  const survives = runs.filter((r) => r.survives && r.points.length >= 2);
44825
44842
  const keepId = survives.length === 1 && survives[0].points.length === current.points.length;
44826
- const m = current.matrix;
44827
44843
  for (const run of runs) if (run.survives && run.points.length >= 2) after.push({
44828
44844
  ...current,
44829
44845
  id: keepId ? current.id : crypto.randomUUID(),
44830
44846
  points: run.points
44831
44847
  });
44832
- else if (!run.survives) {
44833
- const world = m ? run.points.map((p) => ({
44834
- ...p,
44835
- ...apply(m, p)
44836
- })) : run.points;
44837
- this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1), current.tool !== "shape");
44838
- }
44839
44848
  }
44840
44849
  doc.removeStrokes(before.map((s) => s.id));
44841
44850
  doc.addStrokes(after.map(cloneStroke));
@@ -45043,10 +45052,10 @@ function buildShape(kind, a, b, step, constrain) {
45043
45052
  case "ellipse": return [ellipse(a, targetB, step)];
45044
45053
  case "line": return [sampleSegment(a, constrain ? snappedEnd(a, b) : b, step)];
45045
45054
  case "arrow": return arrow(a, constrain ? snappedEnd(a, b) : b, step);
45046
- case "triangle": return [polygon(3, a, targetB, step, -Math.PI / 2)];
45055
+ case "triangle": return [polygon(3, a, targetB, step, Math.PI / 2)];
45047
45056
  case "diamond": return [polygon(4, a, targetB, step, 0)];
45048
- case "pentagon": return [polygon(5, a, targetB, step, -Math.PI / 2)];
45049
- case "hexagon": return [polygon(6, a, targetB, step, -Math.PI / 2)];
45057
+ case "pentagon": return [polygon(5, a, targetB, step, Math.PI / 2)];
45058
+ case "hexagon": return [polygon(6, a, targetB, step, Math.PI / 2)];
45050
45059
  case "octagon": return [polygon(8, a, targetB, step, Math.PI / 8)];
45051
45060
  case "star": return [star(5, a, targetB, step)];
45052
45061
  case "heart": return [heart(a, targetB, step)];
@@ -45245,6 +45254,7 @@ var ShapeTool = class {
45245
45254
  this.ctx.clusters.assign(strokes[0]);
45246
45255
  for (const s of strokes) s.clusterId = strokes[0].clusterId;
45247
45256
  this.ctx.history.execute(new AddStrokesCommand(strokes));
45257
+ this.ctx.selection.set(strokes.map((s) => s.id));
45248
45258
  this.ctx.setTool?.("select");
45249
45259
  }
45250
45260
  this.ctx.transition("idle");
@@ -45826,6 +45836,13 @@ var HANDLE_HIT_PX = 12;
45826
45836
  var ROTATE_OFFSET_PX = 26;
45827
45837
  var ROTATE_HIT_PX = 14;
45828
45838
  var COLOR = 2450411;
45839
+ /**
45840
+ * Visual-only outset between the selected object and the gizmo's outline/
45841
+ * handles — the object's actual bounding box (used for resize math, see
45842
+ * `anchorPoint`/`handlePoint`'s callers in `selectTool.ts`) never changes;
45843
+ * only where the outline and handle meshes are *drawn* and hit-tested does.
45844
+ */
45845
+ var GAP_PX = 8;
45829
45846
  var SelectionGizmo = class {
45830
45847
  constructor(scene) {
45831
45848
  this.group = new Group();
@@ -45840,7 +45857,7 @@ var SelectionGizmo = class {
45840
45857
  this.outline = new LineLoop(new BufferGeometry(), this.lineMaterial);
45841
45858
  this.outline.position.z = GIZMO_Z;
45842
45859
  this.outline.frustumCulled = false;
45843
- const handleGeometry = new PlaneGeometry(1, 1);
45860
+ const handleGeometry = new CircleGeometry(.5, 20);
45844
45861
  const handleMaterial = new MeshBasicMaterial({ color: COLOR });
45845
45862
  const makeHandle = () => new Mesh(handleGeometry, handleMaterial);
45846
45863
  this.handles = {
@@ -45868,18 +45885,19 @@ var SelectionGizmo = class {
45868
45885
  if (!box) return;
45869
45886
  this.lineMaterial.color.setHex(locked ? 16096779 : COLOR);
45870
45887
  this.rotateHandle.visible = !locked;
45888
+ const visual = this.visualBox(box, wpp);
45871
45889
  const positions = new Float32Array([
45872
- box.minX,
45873
- box.minY,
45890
+ visual.minX,
45891
+ visual.minY,
45874
45892
  GIZMO_Z,
45875
- box.maxX,
45876
- box.minY,
45893
+ visual.maxX,
45894
+ visual.minY,
45877
45895
  GIZMO_Z,
45878
- box.maxX,
45879
- box.maxY,
45896
+ visual.maxX,
45897
+ visual.maxY,
45880
45898
  GIZMO_Z,
45881
- box.minX,
45882
- box.maxY,
45899
+ visual.minX,
45900
+ visual.maxY,
45883
45901
  GIZMO_Z
45884
45902
  ]);
45885
45903
  this.outline.geometry.dispose();
@@ -45891,17 +45909,27 @@ var SelectionGizmo = class {
45891
45909
  for (const [handle, mesh] of Object.entries(this.handles)) {
45892
45910
  mesh.visible = !locked;
45893
45911
  if (!locked) {
45894
- const p = this.handlePoint(handle, box);
45912
+ const p = this.handlePoint(handle, visual);
45895
45913
  mesh.position.set(p.x, p.y, GIZMO_Z);
45896
45914
  mesh.scale.set(size, size, 1);
45897
45915
  }
45898
45916
  }
45899
45917
  if (!locked) {
45900
- const rotate = this.rotatePoint(box, wpp);
45918
+ const rotate = this.rotatePoint(visual, wpp);
45901
45919
  this.rotateHandle.position.set(rotate.x, rotate.y, GIZMO_Z);
45902
45920
  this.rotateHandle.scale.set(size, size, 1);
45903
45921
  }
45904
45922
  }
45923
+ /** 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`). */
45924
+ visualBox(box, wpp) {
45925
+ const gap = GAP_PX * wpp;
45926
+ return {
45927
+ minX: box.minX - gap,
45928
+ minY: box.minY - gap,
45929
+ maxX: box.maxX + gap,
45930
+ maxY: box.maxY + gap
45931
+ };
45932
+ }
45905
45933
  handlePoint(handle, box) {
45906
45934
  const midX = (box.minX + box.maxX) / 2;
45907
45935
  const midY = (box.minY + box.maxY) / 2;
@@ -45989,7 +46017,8 @@ var SelectionGizmo = class {
45989
46017
  if (!this.box || this.isLocked) return null;
45990
46018
  const handleR = HANDLE_HIT_PX * this.wpp;
45991
46019
  const edgeSlop = 6 * this.wpp;
45992
- const rotate = this.rotatePoint(this.box, this.wpp);
46020
+ const visual = this.visualBox(this.box, this.wpp);
46021
+ const rotate = this.rotatePoint(visual, this.wpp);
45993
46022
  if (Math.hypot(p.x - rotate.x, p.y - rotate.y) < ROTATE_HIT_PX * this.wpp) return { kind: "rotate" };
45994
46023
  for (const handle of [
45995
46024
  "nw",
@@ -46001,13 +46030,13 @@ var SelectionGizmo = class {
46001
46030
  "e",
46002
46031
  "w"
46003
46032
  ]) {
46004
- const hp = this.handlePoint(handle, this.box);
46033
+ const hp = this.handlePoint(handle, visual);
46005
46034
  if (Math.abs(p.x - hp.x) < handleR && Math.abs(p.y - hp.y) < handleR) return {
46006
46035
  kind: "scale",
46007
46036
  handle
46008
46037
  };
46009
46038
  }
46010
- const { minX, maxX, minY, maxY } = this.box;
46039
+ const { minX, maxX, minY, maxY } = visual;
46011
46040
  const inX = p.x >= minX - edgeSlop && p.x <= maxX + edgeSlop;
46012
46041
  const inY = p.y >= minY - edgeSlop && p.y <= maxY + edgeSlop;
46013
46042
  if (inX && Math.abs(p.y - maxY) < edgeSlop) return {
@@ -46027,7 +46056,7 @@ var SelectionGizmo = class {
46027
46056
  handle: "w"
46028
46057
  };
46029
46058
  const pad = 2 * this.wpp;
46030
- if (p.x >= minX - pad && p.x <= maxX + pad && p.y >= minY - pad && p.y <= maxY + pad) return { kind: "inside" };
46059
+ if (p.x >= this.box.minX - pad && p.x <= this.box.maxX + pad && p.y >= this.box.minY - pad && p.y <= this.box.maxY + pad) return { kind: "inside" };
46031
46060
  return null;
46032
46061
  }
46033
46062
  rotatePoint(box, wpp) {
@@ -48260,6 +48289,112 @@ function createBoardControllerWithEngine(options, existingEngine) {
48260
48289
  })),
48261
48290
  ...[...document.allCustomObjects()].map((value) => clone(toBoardObject(value)))
48262
48291
  ];
48292
+ const projectToScreen = (point) => engine ? engine.boardToScreen(point) : {
48293
+ x: (point.x - view.x) * view.zoom,
48294
+ y: (view.y - point.y) * view.zoom
48295
+ };
48296
+ const lockFields = (item) => ({
48297
+ locked: !!item.locked,
48298
+ ...item.lockedBy ? { lockedBy: item.lockedBy } : {},
48299
+ ...item.lockedByName ? { lockedByName: item.lockedByName } : {}
48300
+ });
48301
+ const lockFieldsMany = (items) => {
48302
+ const locked = items.filter((i) => i.locked);
48303
+ if (locked.length === 0) return { locked: false };
48304
+ const lockedBys = [...new Set(locked.map((i) => i.lockedBy).filter((v) => !!v))];
48305
+ const names = [...new Set(locked.map((i) => i.lockedByName).filter((v) => !!v))];
48306
+ return {
48307
+ locked: true,
48308
+ ...lockedBys.length === 1 ? { lockedBy: lockedBys[0] } : {},
48309
+ ...names.length === 1 ? { lockedByName: names[0] } : {}
48310
+ };
48311
+ };
48312
+ const strokeClusterAnchor = (strokes) => {
48313
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
48314
+ for (const s of strokes) for (const p of s.points) {
48315
+ const world = s.matrix ? apply(s.matrix, p) : p;
48316
+ minX = Math.min(minX, world.x);
48317
+ maxX = Math.max(maxX, world.x);
48318
+ minY = Math.min(minY, world.y);
48319
+ maxY = Math.max(maxY, world.y);
48320
+ }
48321
+ if (!Number.isFinite(minX)) return projectToScreen({
48322
+ x: 0,
48323
+ y: 0
48324
+ });
48325
+ return projectToScreen({
48326
+ x: (minX + maxX) / 2,
48327
+ y: maxY + 1.4
48328
+ });
48329
+ };
48330
+ /** Per-type anchor formulas mirror `ScrawlEngine.getSelectedItemInfo` exactly, so a Host-built toolbar lands in the same spot the SDK's own would. */
48331
+ const anchorFor = (obj) => {
48332
+ switch (obj.type) {
48333
+ case "note": return projectToScreen({
48334
+ x: obj.x,
48335
+ y: obj.y + obj.size / 2 + 1
48336
+ });
48337
+ case "text": {
48338
+ const { width } = measureTextBlock(obj.text, obj.fontSize);
48339
+ return projectToScreen({
48340
+ x: obj.x + width / 2,
48341
+ y: obj.y + 1
48342
+ });
48343
+ }
48344
+ case "table": {
48345
+ const { width } = measureTable(obj);
48346
+ return projectToScreen({
48347
+ x: obj.x + width / 2,
48348
+ y: obj.y + 1.2
48349
+ });
48350
+ }
48351
+ case "image": return projectToScreen({
48352
+ x: obj.x,
48353
+ y: obj.y + obj.height / 2 + 1.2
48354
+ });
48355
+ case "timer": return projectToScreen({
48356
+ x: obj.x,
48357
+ y: obj.y + obj.size / 2 + 1.2
48358
+ });
48359
+ case "custom": {
48360
+ const b = obj.fallback.bounds;
48361
+ return projectToScreen(apply(obj.transform, {
48362
+ x: b.x + b.width / 2,
48363
+ y: b.y + b.height + 1
48364
+ }));
48365
+ }
48366
+ case "stroke": return strokeClusterAnchor([obj]);
48367
+ }
48368
+ };
48369
+ const computeFocusedItem = () => {
48370
+ if (selection.length === 0) return null;
48371
+ if (selection.length === 1) {
48372
+ const obj = readObject(selection[0]);
48373
+ if (!obj) return null;
48374
+ if (obj.type === "custom") return {
48375
+ type: "custom",
48376
+ id: obj.id,
48377
+ locked: false,
48378
+ screenPosition: anchorFor(obj)
48379
+ };
48380
+ return {
48381
+ type: obj.type,
48382
+ id: obj.id,
48383
+ ...lockFields(obj),
48384
+ screenPosition: anchorFor(obj)
48385
+ };
48386
+ }
48387
+ const objects = selection.map(readObject);
48388
+ if (!objects.every((o) => o?.type === "stroke")) return null;
48389
+ const clusterId = objects[0].clusterId;
48390
+ if (!clusterId || !objects.every((o) => o.clusterId === clusterId)) return null;
48391
+ return {
48392
+ type: "stroke",
48393
+ id: objects[0].id,
48394
+ ...lockFieldsMany(objects),
48395
+ screenPosition: strokeClusterAnchor(objects)
48396
+ };
48397
+ };
48263
48398
  const updateObject = (id, patch) => {
48264
48399
  assertMutable();
48265
48400
  const before = readObject(id);
@@ -48301,6 +48436,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48301
48436
  zoom: view.zoom,
48302
48437
  readOnly,
48303
48438
  selection: Object.freeze([...selection]),
48439
+ focusedItem: computeFocusedItem(),
48304
48440
  strokeCount: [...document.all()].length,
48305
48441
  objectCount: allObjects().length,
48306
48442
  canUndo: history.canUndo,
@@ -48715,10 +48851,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48715
48851
  changed();
48716
48852
  },
48717
48853
  get: () => ({ ...view }),
48718
- boardToScreen: (point) => engine ? engine.boardToScreen(point) : {
48719
- x: (point.x - view.x) * view.zoom,
48720
- y: (view.y - point.y) * view.zoom
48721
- },
48854
+ boardToScreen: (point) => projectToScreen(point),
48722
48855
  screenToBoard: (point) => engine ? engine.screenToBoard(point.x, point.y) ?? {
48723
48856
  x: point.x,
48724
48857
  y: point.y
@@ -48755,6 +48888,81 @@ function createBoardControllerWithEngine(options, existingEngine) {
48755
48888
  update(id, patch) {
48756
48889
  updateObject(id, patch);
48757
48890
  },
48891
+ duplicate(ids) {
48892
+ assertMutable();
48893
+ const objects = [...new Set(ids)].map(readObject).filter((o) => !!o);
48894
+ if (objects.length === 0) return [];
48895
+ const DX = 1.5;
48896
+ const DY = -1.5;
48897
+ const newClusterIds = /* @__PURE__ */ new Map();
48898
+ const clusterIdFor = (oldClusterId) => {
48899
+ let next = newClusterIds.get(oldClusterId);
48900
+ if (!next) {
48901
+ next = crypto.randomUUID();
48902
+ newClusterIds.set(oldClusterId, next);
48903
+ }
48904
+ return next;
48905
+ };
48906
+ const commands = [];
48907
+ const newIds = [];
48908
+ for (const obj of objects) {
48909
+ const id = createId();
48910
+ newIds.push(id);
48911
+ const rest = { ...obj };
48912
+ delete rest.locked;
48913
+ delete rest.lockedBy;
48914
+ delete rest.lockedByName;
48915
+ let duplicated;
48916
+ if (obj.type === "stroke") {
48917
+ const matrix = obj.matrix ?? [
48918
+ 1,
48919
+ 0,
48920
+ 0,
48921
+ 1,
48922
+ 0,
48923
+ 0
48924
+ ];
48925
+ duplicated = {
48926
+ ...rest,
48927
+ id,
48928
+ matrix: [
48929
+ matrix[0],
48930
+ matrix[1],
48931
+ matrix[2],
48932
+ matrix[3],
48933
+ matrix[4] + DX,
48934
+ matrix[5] + DY
48935
+ ],
48936
+ ...obj.clusterId ? { clusterId: clusterIdFor(obj.clusterId) } : {}
48937
+ };
48938
+ } else if (obj.type === "custom") {
48939
+ const t = obj.transform;
48940
+ duplicated = {
48941
+ ...rest,
48942
+ id,
48943
+ transform: [
48944
+ t[0],
48945
+ t[1],
48946
+ t[2],
48947
+ t[3],
48948
+ t[4] + DX,
48949
+ t[5] + DY
48950
+ ]
48951
+ };
48952
+ delete duplicated.lock;
48953
+ } else duplicated = {
48954
+ ...rest,
48955
+ id,
48956
+ x: obj.x + DX,
48957
+ y: obj.y + DY
48958
+ };
48959
+ validateBoardObject(duplicated.type, duplicated);
48960
+ commands.push(addCommand(duplicated.type, duplicated));
48961
+ }
48962
+ executeHistory(() => history.execute(new CommandBatch("duplicate content", commands)));
48963
+ changed();
48964
+ return newIds;
48965
+ },
48758
48966
  table: {
48759
48967
  addRow(tableId) {
48760
48968
  const table = document.getTable(tableId);
@@ -49702,14 +49910,17 @@ function InlineEditors({ controller, renderPortal }) {
49702
49910
  if (editor?.kind === "text") {
49703
49911
  const text = editor.text;
49704
49912
  if (editor.id === null) {
49705
- if (text.trim()) controller.content.add({
49706
- type: "text",
49707
- x: editor.boardX,
49708
- y: editor.boardY,
49709
- text,
49710
- color: editor.color,
49711
- fontSize: TEXT_DEFAULT_SIZE
49712
- });
49913
+ if (text.trim()) {
49914
+ const newId = controller.content.add({
49915
+ type: "text",
49916
+ x: editor.boardX,
49917
+ y: editor.boardY,
49918
+ text,
49919
+ color: editor.color,
49920
+ fontSize: TEXT_DEFAULT_SIZE
49921
+ });
49922
+ controller.content.select([newId]);
49923
+ }
49713
49924
  } else if (!text.trim()) controller.content.remove([editor.id]);
49714
49925
  else controller.content.update(editor.id, { text });
49715
49926
  }
@@ -49904,6 +50115,366 @@ function InlineEditors({ controller, renderPortal }) {
49904
50115
  }))] });
49905
50116
  }
49906
50117
  //#endregion
50118
+ //#region src/ui/toolbar/focused-item-toolbar.tsx
50119
+ var NOTE_SIZES = [
50120
+ ["Small", 7],
50121
+ ["Medium", 10],
50122
+ ["Large", 14]
50123
+ ];
50124
+ var TEXT_SIZES = [
50125
+ ["Small", 1.2],
50126
+ ["Medium", 1.8],
50127
+ ["Large", 2.6]
50128
+ ];
50129
+ var SHAPE_WIDTHS = [
50130
+ ["Thin", .1],
50131
+ ["Medium", .15],
50132
+ ["Thick", .3]
50133
+ ];
50134
+ /** Matches the native selection gizmo's own outset (renderer/selection/gizmo.ts's GAP_PX), for a consistent look. */
50135
+ var HANDLE_GAP_PX = 8;
50136
+ var MIN_SIZE = 4;
50137
+ var MAX_SIZE = 30;
50138
+ var HANDLE_CORNERS = [
50139
+ "nw",
50140
+ "ne",
50141
+ "sw",
50142
+ "se"
50143
+ ];
50144
+ /**
50145
+ * Floating toolbar above the focused note, shape, or text block — Colour,
50146
+ * Size (note/text) or Width (shape), Lock/Unlock, Duplicate, Delete.
50147
+ * Table/image/timer/custom objects and plain (non-shape) ink strokes never
50148
+ * get a toolbar here — out of scope for this destination.
50149
+ *
50150
+ * Lock/Unlock carries no per-user ownership gating: nothing else in this
50151
+ * SDK enforces lock ownership either (`content.update` never checks
50152
+ * `locked`, and the engine's own internal unlock check has no way for a
50153
+ * Host to ever supply a real user id) — locking is advisory UI state
50154
+ * throughout, and this toolbar matches that rather than inventing an
50155
+ * enforcement story alone.
50156
+ *
50157
+ * A shape can be several strokes sharing one `clusterId` (e.g. an arrow's
50158
+ * shaft + head) — every action here applies to the whole cluster. Resolved
50159
+ * via `query.all()` + `clusterId`, not `snapshot.selection`: a canvas click
50160
+ * on one member selects every member internally, but the public selection
50161
+ * bridge only ever reports one id (`FocusedItem.id` is singular by design),
50162
+ * so reconstructing the cluster from the document is the reliable path
50163
+ * regardless of how the selection was made.
50164
+ *
50165
+ * A focused note also gets drag-to-resize corner handles — notes have no
50166
+ * gizmo of their own (the native selection gizmo is ink-stroke/shape-only),
50167
+ * so this is that capability's default-ui-owned equivalent. Proportional
50168
+ * (always-square) resize from the note's own centre, clamped to
50169
+ * `[MIN_SIZE, MAX_SIZE]`, independent of which corner is grabbed.
50170
+ */
50171
+ function FocusedItemToolbar({ controller, snapshot }) {
50172
+ const focused = snapshot.focusedItem;
50173
+ if (!focused) return null;
50174
+ const obj = controller.query.get(focused.id);
50175
+ if (!obj) return null;
50176
+ if (obj.type === "note") return /* @__PURE__ */ jsx(NoteToolbar, {
50177
+ controller,
50178
+ snapshot,
50179
+ note: obj,
50180
+ locked: focused.locked,
50181
+ anchor: focused.screenPosition
50182
+ });
50183
+ if (obj.type === "text") return /* @__PURE__ */ jsx(TextToolbar, {
50184
+ controller,
50185
+ snapshot,
50186
+ text: obj,
50187
+ locked: focused.locked,
50188
+ anchor: focused.screenPosition
50189
+ });
50190
+ if (obj.type === "stroke" && obj.tool === "shape") return /* @__PURE__ */ jsx(ShapeToolbar, {
50191
+ controller,
50192
+ snapshot,
50193
+ stroke: obj,
50194
+ locked: focused.locked,
50195
+ anchor: focused.screenPosition
50196
+ });
50197
+ return null;
50198
+ }
50199
+ function ToolbarShell({ anchor, children }) {
50200
+ if (!anchor) return null;
50201
+ return /* @__PURE__ */ jsx("div", {
50202
+ "aria-label": "Selected object",
50203
+ className: "scrawl-board__focus-toolbar",
50204
+ role: "toolbar",
50205
+ style: {
50206
+ left: anchor.x,
50207
+ top: anchor.y
50208
+ },
50209
+ children
50210
+ });
50211
+ }
50212
+ function NoteToolbar({ controller, snapshot, note, locked, anchor }) {
50213
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50214
+ const locked_ = !mutable || locked;
50215
+ const half = note.size / 2;
50216
+ const cornerA = controller.view.boardToScreen({
50217
+ x: note.x - half,
50218
+ y: note.y + half
50219
+ });
50220
+ const cornerB = controller.view.boardToScreen({
50221
+ x: note.x + half,
50222
+ y: note.y - half
50223
+ });
50224
+ const outline = {
50225
+ left: Math.min(cornerA.x, cornerB.x) - HANDLE_GAP_PX,
50226
+ top: Math.min(cornerA.y, cornerB.y) - HANDLE_GAP_PX,
50227
+ right: Math.max(cornerA.x, cornerB.x) + HANDLE_GAP_PX,
50228
+ bottom: Math.max(cornerA.y, cornerB.y) + HANDLE_GAP_PX
50229
+ };
50230
+ const handlePoints = {
50231
+ nw: {
50232
+ x: outline.left,
50233
+ y: outline.top
50234
+ },
50235
+ ne: {
50236
+ x: outline.right,
50237
+ y: outline.top
50238
+ },
50239
+ sw: {
50240
+ x: outline.left,
50241
+ y: outline.bottom
50242
+ },
50243
+ se: {
50244
+ x: outline.right,
50245
+ y: outline.bottom
50246
+ }
50247
+ };
50248
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
50249
+ /* @__PURE__ */ jsx("div", {
50250
+ "aria-hidden": "true",
50251
+ className: "scrawl-board__focus-outline",
50252
+ "data-locked": locked,
50253
+ style: {
50254
+ left: outline.left,
50255
+ top: outline.top,
50256
+ width: outline.right - outline.left,
50257
+ height: outline.bottom - outline.top
50258
+ }
50259
+ }),
50260
+ mutable && !locked && HANDLE_CORNERS.map((corner) => /* @__PURE__ */ jsx(ResizeHandle, {
50261
+ controller,
50262
+ note,
50263
+ corner,
50264
+ x: handlePoints[corner].x,
50265
+ y: handlePoints[corner].y
50266
+ }, corner)),
50267
+ /* @__PURE__ */ jsxs(ToolbarShell, {
50268
+ anchor,
50269
+ children: [
50270
+ /* @__PURE__ */ jsxs("div", {
50271
+ className: "scrawl-board__style-group",
50272
+ children: [/* @__PURE__ */ jsx("span", { children: "Size" }), NOTE_SIZES.map(([label, size]) => /* @__PURE__ */ jsx("button", {
50273
+ "aria-label": `${label} note`,
50274
+ "aria-pressed": note.size === size,
50275
+ disabled: locked_,
50276
+ onClick: () => controller.content.update(note.id, { size }),
50277
+ type: "button",
50278
+ children: label[0]
50279
+ }, label))]
50280
+ }),
50281
+ /* @__PURE__ */ jsxs("div", {
50282
+ className: "scrawl-board__style-group",
50283
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(NOTE_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50284
+ "aria-label": `${name} note`,
50285
+ "aria-pressed": note.color.toLowerCase() === value.toLowerCase(),
50286
+ disabled: locked_,
50287
+ onClick: () => controller.content.update(note.id, { color: value }),
50288
+ type: "button",
50289
+ children: /* @__PURE__ */ jsx("span", {
50290
+ "aria-hidden": "true",
50291
+ style: { backgroundColor: value }
50292
+ })
50293
+ }, value))]
50294
+ }),
50295
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50296
+ controller,
50297
+ mutable,
50298
+ locked,
50299
+ ids: [note.id]
50300
+ })
50301
+ ]
50302
+ })
50303
+ ] });
50304
+ }
50305
+ /**
50306
+ * One corner handle — drag distance from the note's own centre (its `x`/`y`
50307
+ * already *is* the centre) drives a proportional, always-square resize,
50308
+ * independent of which corner is grabbed. `note` is fresh every render (a
50309
+ * new `content.update` during drag triggers a new snapshot), so the
50310
+ * closure never goes stale.
50311
+ */
50312
+ function ResizeHandle({ controller, note, corner, x, y }) {
50313
+ const [dragging, setDragging] = useState(false);
50314
+ const nudge = (delta) => {
50315
+ const half = Math.max(MIN_SIZE / 2, Math.min(MAX_SIZE / 2, note.size / 2 + delta));
50316
+ controller.content.update(note.id, { size: half * 2 });
50317
+ };
50318
+ return /* @__PURE__ */ jsx("span", {
50319
+ "aria-label": `Resize note (${corner})`,
50320
+ "aria-valuemax": MAX_SIZE,
50321
+ "aria-valuemin": MIN_SIZE,
50322
+ "aria-valuenow": note.size,
50323
+ role: "slider",
50324
+ tabIndex: 0,
50325
+ className: "scrawl-board__focus-handle",
50326
+ "data-orientation": corner === "nw" || corner === "se" ? "nwse" : "nesw",
50327
+ style: {
50328
+ left: x,
50329
+ top: y
50330
+ },
50331
+ onKeyDown: (event) => {
50332
+ if (event.key === "ArrowUp" || event.key === "ArrowRight") {
50333
+ event.preventDefault();
50334
+ nudge(.5);
50335
+ } else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
50336
+ event.preventDefault();
50337
+ nudge(-.5);
50338
+ }
50339
+ },
50340
+ onPointerDown: (event) => {
50341
+ event.stopPropagation();
50342
+ event.currentTarget.setPointerCapture(event.pointerId);
50343
+ setDragging(true);
50344
+ },
50345
+ onPointerMove: (event) => {
50346
+ if (!dragging) return;
50347
+ const board = controller.view.screenToBoard({
50348
+ x: event.clientX,
50349
+ y: event.clientY
50350
+ });
50351
+ 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))));
50352
+ controller.content.update(note.id, { size: half * 2 });
50353
+ },
50354
+ onPointerUp: (event) => {
50355
+ event.currentTarget.releasePointerCapture(event.pointerId);
50356
+ setDragging(false);
50357
+ }
50358
+ });
50359
+ }
50360
+ function TextToolbar({ controller, snapshot, text, locked, anchor }) {
50361
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50362
+ const locked_ = !mutable || locked;
50363
+ return /* @__PURE__ */ jsxs(ToolbarShell, {
50364
+ anchor,
50365
+ children: [
50366
+ /* @__PURE__ */ jsxs("div", {
50367
+ className: "scrawl-board__style-group",
50368
+ children: [/* @__PURE__ */ jsx("span", { children: "Size" }), TEXT_SIZES.map(([label, fontSize]) => /* @__PURE__ */ jsx("button", {
50369
+ "aria-label": `${label} text`,
50370
+ "aria-pressed": Math.abs(text.fontSize - fontSize) < .001,
50371
+ disabled: locked_,
50372
+ onClick: () => controller.content.update(text.id, { fontSize }),
50373
+ type: "button",
50374
+ children: label[0]
50375
+ }, label))]
50376
+ }),
50377
+ /* @__PURE__ */ jsxs("div", {
50378
+ className: "scrawl-board__style-group",
50379
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(INK_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50380
+ "aria-label": `${name} text`,
50381
+ "aria-pressed": text.color.toLowerCase() === value.toLowerCase(),
50382
+ disabled: locked_,
50383
+ onClick: () => controller.content.update(text.id, { color: value }),
50384
+ type: "button",
50385
+ children: /* @__PURE__ */ jsx("span", {
50386
+ "aria-hidden": "true",
50387
+ style: { backgroundColor: value }
50388
+ })
50389
+ }, value))]
50390
+ }),
50391
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50392
+ controller,
50393
+ mutable,
50394
+ locked,
50395
+ ids: [text.id]
50396
+ })
50397
+ ]
50398
+ });
50399
+ }
50400
+ function ShapeToolbar({ controller, snapshot, stroke, locked, anchor }) {
50401
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
50402
+ const locked_ = !mutable || locked;
50403
+ const ids = (stroke.clusterId ? controller.query.all().filter((o) => o.type === "stroke" && o.clusterId === stroke.clusterId) : [stroke]).map((m) => m.id);
50404
+ const applyToAll = (patch) => {
50405
+ for (const id of ids) controller.content.update(id, patch);
50406
+ };
50407
+ return /* @__PURE__ */ jsxs(ToolbarShell, {
50408
+ anchor,
50409
+ children: [
50410
+ /* @__PURE__ */ jsxs("div", {
50411
+ className: "scrawl-board__style-group",
50412
+ children: [/* @__PURE__ */ jsx("span", { children: "Width" }), SHAPE_WIDTHS.map(([label, width]) => /* @__PURE__ */ jsx("button", {
50413
+ "aria-label": `${label} stroke`,
50414
+ "aria-pressed": Math.abs(stroke.baseWidth - width) < .001,
50415
+ disabled: locked_,
50416
+ onClick: () => applyToAll({ baseWidth: width }),
50417
+ type: "button",
50418
+ children: label
50419
+ }, label))]
50420
+ }),
50421
+ /* @__PURE__ */ jsxs("div", {
50422
+ className: "scrawl-board__style-group",
50423
+ children: [/* @__PURE__ */ jsx("span", { children: "Colour" }), Object.entries(INK_COLORS).map(([name, value]) => /* @__PURE__ */ jsx("button", {
50424
+ "aria-label": `${name} shape`,
50425
+ "aria-pressed": stroke.color.toLowerCase() === value.toLowerCase(),
50426
+ disabled: locked_,
50427
+ onClick: () => applyToAll({ color: value }),
50428
+ type: "button",
50429
+ children: /* @__PURE__ */ jsx("span", {
50430
+ "aria-hidden": "true",
50431
+ style: { backgroundColor: value }
50432
+ })
50433
+ }, value))]
50434
+ }),
50435
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
50436
+ controller,
50437
+ mutable,
50438
+ locked,
50439
+ ids
50440
+ })
50441
+ ]
50442
+ });
50443
+ }
50444
+ function LockDuplicateDelete({ controller, mutable, locked, ids }) {
50445
+ return /* @__PURE__ */ jsxs("div", {
50446
+ className: "scrawl-board__style-group",
50447
+ children: [
50448
+ /* @__PURE__ */ jsx("button", {
50449
+ "aria-label": locked ? "Unlock" : "Lock",
50450
+ disabled: !mutable,
50451
+ onClick: () => {
50452
+ for (const id of ids) controller.content.update(id, locked ? { locked: false } : { locked: true });
50453
+ },
50454
+ type: "button",
50455
+ children: locked ? "Unlock" : "Lock"
50456
+ }),
50457
+ /* @__PURE__ */ jsx("button", {
50458
+ "aria-label": "Duplicate",
50459
+ disabled: !mutable || locked,
50460
+ onClick: () => {
50461
+ const newIds = controller.content.duplicate(ids);
50462
+ if (newIds.length) controller.content.select(newIds);
50463
+ },
50464
+ type: "button",
50465
+ children: "Duplicate"
50466
+ }),
50467
+ /* @__PURE__ */ jsx("button", {
50468
+ "aria-label": "Delete",
50469
+ disabled: !mutable || locked,
50470
+ onClick: () => controller.content.remove(ids),
50471
+ type: "button",
50472
+ children: "Delete"
50473
+ })
50474
+ ]
50475
+ });
50476
+ }
50477
+ //#endregion
49907
50478
  //#region src/ui/toolbar/style-shelf.tsx
49908
50479
  var ERASER_RADII = [
49909
50480
  ["Fine", 1],
@@ -49942,7 +50513,7 @@ var SHELF_TOOLS = /* @__PURE__ */ new Set([
49942
50513
  "timer"
49943
50514
  ]);
49944
50515
  /** Contextual per-tool style controls — visible while a styleable tool is active. */
49945
- function StyleShelf({ controller, snapshot }) {
50516
+ function StyleShelf({ controller, snapshot, anchor }) {
49946
50517
  if (!SHELF_TOOLS.has(snapshot.tool)) return null;
49947
50518
  const mutable = snapshot.status === "ready" && !snapshot.readOnly;
49948
50519
  const { style } = snapshot;
@@ -49950,7 +50521,12 @@ function StyleShelf({ controller, snapshot }) {
49950
50521
  return /* @__PURE__ */ jsxs("div", {
49951
50522
  "aria-label": "Board style",
49952
50523
  className: "scrawl-board__style-shelf",
50524
+ "data-anchored": !!anchor,
49953
50525
  role: "toolbar",
50526
+ style: anchor ? {
50527
+ left: anchor.x,
50528
+ top: anchor.y
50529
+ } : void 0,
49954
50530
  children: [
49955
50531
  snapshot.tool === "highlighter" && /* @__PURE__ */ jsxs("div", {
49956
50532
  className: "scrawl-board__style-group",
@@ -50180,6 +50756,7 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50180
50756
  const [panel, setPanel] = useState(null);
50181
50757
  const [query, setQuery] = useState("");
50182
50758
  const [announcement, setAnnouncement] = useState("Board controls ready");
50759
+ const [styleShelfAnchor, setStyleShelfAnchor] = useState(void 0);
50183
50760
  const importRef = useRef(null);
50184
50761
  const openerRef = useRef(null);
50185
50762
  const searchRef = useRef(null);
@@ -50190,6 +50767,25 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50190
50767
  useEffect(() => {
50191
50768
  if (panel === "search") searchRef.current?.focus();
50192
50769
  }, [panel]);
50770
+ useEffect(() => {
50771
+ const recompute = () => {
50772
+ const root = chromeRef.current;
50773
+ const button = root?.querySelector(".scrawl-board__tools button[aria-pressed=\"true\"]");
50774
+ if (!root || !button) {
50775
+ setStyleShelfAnchor(void 0);
50776
+ return;
50777
+ }
50778
+ const buttonRect = button.getBoundingClientRect();
50779
+ const rootRect = root.getBoundingClientRect();
50780
+ setStyleShelfAnchor({
50781
+ x: buttonRect.left + buttonRect.width / 2 - rootRect.left,
50782
+ y: buttonRect.top - rootRect.top
50783
+ });
50784
+ };
50785
+ recompute();
50786
+ window.addEventListener("resize", recompute);
50787
+ return () => window.removeEventListener("resize", recompute);
50788
+ }, [snapshot.tool]);
50193
50789
  const open = (next, opener) => {
50194
50790
  openerRef.current = opener;
50195
50791
  setPanel(next);
@@ -50418,9 +51014,14 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50418
51014
  ]
50419
51015
  }),
50420
51016
  renderSlot(slots?.stylePanel, enabled("styleShelf") && /* @__PURE__ */ jsx(StyleShelf, {
51017
+ anchor: styleShelfAnchor,
50421
51018
  controller,
50422
51019
  snapshot
50423
51020
  })),
51021
+ enabled("focusedItemToolbar") && /* @__PURE__ */ jsx(FocusedItemToolbar, {
51022
+ controller,
51023
+ snapshot
51024
+ }),
50424
51025
  ContextMenu && /* @__PURE__ */ jsx(ContextMenu, {
50425
51026
  controller,
50426
51027
  snapshot
@@ -51063,4 +51664,4 @@ function ScrawlBoard({ documentId, initialDocument, onReady, className, style })
51063
51664
  });
51064
51665
  }
51065
51666
  //#endregion
51066
- export { ASSET_CACHE_BYTES_DEFAULT, ASSET_CACHE_BYTES_MAX, ASSET_CACHE_BYTES_MIN, ASSET_EXPORT_MAX_DECODED_MEGAPIXELS, ASSET_EXPORT_MAX_ENCODED_BYTES, ASSET_MAX_CONCURRENT_RESOLUTIONS, ASSET_MAX_DECODED_MEGAPIXELS, ASSET_MAX_DIMENSION_PX, ASSET_MAX_ENCODED_BYTES, ASSET_REF_MAX_BYTES, ASSET_REF_PATTERN, AddImageCommand, AddNoteCommand, AddStrokesCommand, AddTableCommand, AddTextCommand, AddTimerCommand, AssetResolutionError, BEACON_INSET, BoardDocument, CURRENT_DOCUMENT_SCHEMA_VERSION, ClusterStore, DefaultBoardChrome, DeleteImageCommand, DeleteNoteCommand, DeleteStrokesCommand, DeleteTableCommand, DeleteTextCommand, DeleteTimerCommand, DocumentRecoveryError, END_TAPER, ERASE_THRESHOLD, EraseCommand, FOG_COLOR, HIGHLIGHT_COLORS, History, IDENTITY, INK_COLORS, InlineEditors, LockItemsCommand, MIN_WIDTH_FACTOR, MultiplayerCursors, NOTE_COLORS, NOTE_DEFAULT_SIZE, NOTE_DEFAULT_Z, NOTE_MAX_Z, NOTE_MIN_Z, NOTE_PEEL_STEP, SDK_DEVELOPMENT_VERSION, SDK_PACKAGE_NAME, STAMPS, STAMP_SIZE, SUPPORTED_ASSET_MEDIA_TYPES, Scrawl, ScrawlBoard, ScrawlCanvas, ScrawlDefaultUI, ScrawlPortal, ScrawlProvider, SpatialIndex, StyleShelf, TABLE_DEFAULT_CELL_HEIGHT, TABLE_DEFAULT_CELL_WIDTH, TABLE_DEFAULT_FONT_SIZE, TEXT_DEFAULT_SIZE, TIMER_DEFAULT_DURATION_MS, TIMER_DEFAULT_SIZE, TIMER_PRESETS_MS, TransformCommand, UpdateImageCommand, UpdateNoteCommand, UpdateTableCommand, UpdateTextCommand, UpdateTimerCommand, apply, applyItemLock, assetRef, avgScale, canUnlockItem, changeToOps, clampAssetCacheBytes, cloneCustomObject, cloneImage, cloneNote, cloneStroke, cloneTable, cloneText, cloneTimer, createBoardController, createLocalBoard, documentId, documentToSVG, formatTimer, invert, isAssetRef, isIdentity, isStampKind, loadDocumentBytes, measureTable, measureTextBlock, migrateDocument, mul, pauseTimer, placePresenceBeacon, resolveScrawlTheme, ribbonEdges, rotationAbout, scalingAbout, scrawlThemePresets, searchBoard, serializeDocument, serializeLock, serializeStroke, setTimerDuration, stampDataUrl, startTimer, strokeId, timerExpired, timerRemaining, toggleTimer, translation, useScrawlController, useScrawlSnapshot, useScrawlTheme, validateScrawlTheme };
51667
+ export { ASSET_CACHE_BYTES_DEFAULT, ASSET_CACHE_BYTES_MAX, ASSET_CACHE_BYTES_MIN, ASSET_EXPORT_MAX_DECODED_MEGAPIXELS, ASSET_EXPORT_MAX_ENCODED_BYTES, ASSET_MAX_CONCURRENT_RESOLUTIONS, ASSET_MAX_DECODED_MEGAPIXELS, ASSET_MAX_DIMENSION_PX, ASSET_MAX_ENCODED_BYTES, ASSET_REF_MAX_BYTES, ASSET_REF_PATTERN, AddImageCommand, AddNoteCommand, AddStrokesCommand, AddTableCommand, AddTextCommand, AddTimerCommand, AssetResolutionError, BEACON_INSET, BoardDocument, CURRENT_DOCUMENT_SCHEMA_VERSION, ClusterStore, DefaultBoardChrome, DeleteImageCommand, DeleteNoteCommand, DeleteStrokesCommand, DeleteTableCommand, DeleteTextCommand, DeleteTimerCommand, DocumentRecoveryError, END_TAPER, ERASE_THRESHOLD, EraseCommand, FOG_COLOR, FocusedItemToolbar, HIGHLIGHT_COLORS, History, IDENTITY, INK_COLORS, InlineEditors, LockItemsCommand, MIN_WIDTH_FACTOR, MultiplayerCursors, NOTE_COLORS, NOTE_DEFAULT_SIZE, NOTE_DEFAULT_Z, NOTE_MAX_Z, NOTE_MIN_Z, NOTE_PEEL_STEP, SDK_DEVELOPMENT_VERSION, SDK_PACKAGE_NAME, STAMPS, STAMP_SIZE, SUPPORTED_ASSET_MEDIA_TYPES, Scrawl, ScrawlBoard, ScrawlCanvas, ScrawlDefaultUI, ScrawlPortal, ScrawlProvider, SpatialIndex, StyleShelf, TABLE_DEFAULT_CELL_HEIGHT, TABLE_DEFAULT_CELL_WIDTH, TABLE_DEFAULT_FONT_SIZE, TEXT_DEFAULT_SIZE, TIMER_DEFAULT_DURATION_MS, TIMER_DEFAULT_SIZE, TIMER_PRESETS_MS, TransformCommand, UpdateImageCommand, UpdateNoteCommand, UpdateTableCommand, UpdateTextCommand, UpdateTimerCommand, apply, applyItemLock, assetRef, avgScale, canUnlockItem, changeToOps, clampAssetCacheBytes, cloneCustomObject, cloneImage, cloneNote, cloneStroke, cloneTable, cloneText, cloneTimer, createBoardController, createLocalBoard, documentId, documentToSVG, formatTimer, invert, isAssetRef, isIdentity, isStampKind, loadDocumentBytes, measureTable, measureTextBlock, migrateDocument, mul, pauseTimer, placePresenceBeacon, resolveScrawlTheme, ribbonEdges, rotationAbout, scalingAbout, scrawlThemePresets, searchBoard, serializeDocument, serializeLock, serializeStroke, setTimerDuration, stampDataUrl, startTimer, strokeId, timerExpired, timerRemaining, toggleTimer, translation, useScrawlController, useScrawlSnapshot, useScrawlTheme, validateScrawlTheme };