@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/browser.d.ts CHANGED
@@ -591,6 +591,7 @@ interface BoardSnapshot {
591
591
  readonly zoom: number;
592
592
  readonly readOnly: boolean;
593
593
  readonly selection: readonly string[];
594
+ readonly focusedItem: FocusedItem | null;
594
595
  readonly strokeCount: number;
595
596
  readonly objectCount: number;
596
597
  readonly canUndo: boolean;
@@ -601,6 +602,27 @@ interface BoardSnapshot {
601
602
  readonly collaboration: CollaborationSnapshot;
602
603
  };
603
604
  }
605
+ /**
606
+ * `selection` collapsed into the one thing a selected-object toolbar needs:
607
+ * what's selected, its lock state, and where to anchor above it. `null`
608
+ * when nothing is selected, or when the selection mixes types/objects that
609
+ * don't resolve to a single focus (anything but one object, or several
610
+ * strokes sharing a `clusterId` — a multi-stroke shape).
611
+ *
612
+ * Raw lock fields, not a derived `canUnlock` — this API has no notion of
613
+ * "the local user" to judge that against (see `canUnlockItem` in `../core`,
614
+ * which takes a `userId` the Host already owns). A Custom object's lock
615
+ * shape doesn't carry a holder name, so it always reports `locked: false`
616
+ * here, matching the engine's own internal selection-badge behavior.
617
+ */
618
+ interface FocusedItem {
619
+ readonly type: "stroke" | "note" | "text" | "table" | "image" | "timer" | "custom";
620
+ readonly id: string;
621
+ readonly locked: boolean;
622
+ readonly lockedBy?: string;
623
+ readonly lockedByName?: string;
624
+ readonly screenPosition?: ScreenPoint;
625
+ }
604
626
  interface BoardControllerError {
605
627
  source: "controller" | "renderer" | "persistence" | "collaboration";
606
628
  code: string;
@@ -967,6 +989,17 @@ interface BoardController {
967
989
  add(input: BoardObjectInput): string;
968
990
  update(id: string, patch: BoardObjectPatch): void;
969
991
  remove(ids: readonly string[]): void;
992
+ /**
993
+ * Clone each given object as a new, unlocked copy offset by a small
994
+ * fixed cascade (matching the Duplicate affordance's established Host
995
+ * convention), as one undoable step. Order-preserving: `result[i]` is
996
+ * the clone of `ids[i]`. Strokes that share a `clusterId` among the
997
+ * given ids get a single fresh shared `clusterId` in the result, so
998
+ * duplicating a whole multi-stroke shape (e.g. an arrow's shaft + head)
999
+ * keeps it one shape — pass every member's id together, not just one.
1000
+ * Unknown ids are silently skipped, matching `remove`'s convention.
1001
+ */
1002
+ duplicate(ids: readonly string[]): readonly string[];
970
1003
  table: {
971
1004
  addRow(tableId: string): void;
972
1005
  addCol(tableId: string): void;
@@ -1056,4 +1089,4 @@ type LocalBoard = {
1056
1089
  declare function createLocalBoard(options: LocalBoardOptions): LocalBoard;
1057
1090
 
1058
1091
  export { AssetResolutionError, STAMPS, SUPPORTED_ASSET_MEDIA_TYPES, assetRef, clampAssetCacheBytes, cloneCustomObject, createBoardController, createLocalBoard, isAssetRef, isStampKind, stampDataUrl };
1059
- export type { ApplyOpsResult, AssetDiagnostic, AssetIngestRequest, AssetIngestResult, AssetIngestor, AssetKind, AssetPurpose, AssetRef, AssetResolutionErrorCode, AssetResolveRequest, AssetResolveResult, AssetResolver, BoardController, BoardControllerError, BoardEventMap, BoardKeyInput, BoardObject, BoardObjectInput, BoardObjectPatch, BoardPointerInput, BoardScene, BoardSnapshot, BoardStyle, BoardThemeOptions, BoardView, BuiltInTool, CollaborationAdapter, CollaborationReceiver, CollaborationSession, CollaborationSnapshot, CollaboratorIdentity, CommentMarker, ControllerOp, CreateBoardControllerOptions, CustomBoardObject, CustomObjectAddInput, CustomObjectDefinition, CustomTool, CustomToolDefinition, DeepReadonly, DocumentContext, ExtensionCommand, ExtensionDiagnostic, ExtensionHitResult, ExtensionId, ExtensionRequirement, InputModifiers, JsonObject, JsonValue, LoadResult, LocalBoard, LocalBoardOptions, LocalBoardSnapshot, Mat2x3, ObjectDescribeContext, ObjectIntent, ObjectType, PersistenceAdapter, PersistenceDiagnostic, PersistenceSnapshot, PresenceCursor, PresenceUser, PresenceView, QueryableBoardObject, ReadonlyBoardDocument, ReadonlyCustomObject, ReadonlyDocumentChange, SceneEllipse, SceneGroup, SceneImage, ScenePath, SceneRect, SceneText, ScrawlExtension, ScreenPoint, ScreenRect, StampKind, SupportedAssetMediaType, ToolCancelReason, ToolCapabilities, ToolCursor, ToolId };
1092
+ export type { ApplyOpsResult, AssetDiagnostic, AssetIngestRequest, AssetIngestResult, AssetIngestor, AssetKind, AssetPurpose, AssetRef, AssetResolutionErrorCode, AssetResolveRequest, AssetResolveResult, AssetResolver, BoardController, BoardControllerError, BoardEventMap, BoardKeyInput, BoardObject, BoardObjectInput, BoardObjectPatch, BoardPointerInput, BoardScene, BoardSnapshot, BoardStyle, BoardThemeOptions, BoardView, BuiltInTool, CollaborationAdapter, CollaborationReceiver, CollaborationSession, CollaborationSnapshot, CollaboratorIdentity, CommentMarker, ControllerOp, CreateBoardControllerOptions, CustomBoardObject, CustomObjectAddInput, CustomObjectDefinition, CustomTool, CustomToolDefinition, DeepReadonly, DocumentContext, ExtensionCommand, ExtensionDiagnostic, ExtensionHitResult, ExtensionId, ExtensionRequirement, FocusedItem, InputModifiers, JsonObject, JsonValue, LoadResult, LocalBoard, LocalBoardOptions, LocalBoardSnapshot, Mat2x3, ObjectDescribeContext, ObjectIntent, ObjectType, PersistenceAdapter, PersistenceDiagnostic, PersistenceSnapshot, PresenceCursor, PresenceUser, PresenceView, QueryableBoardObject, ReadonlyBoardDocument, ReadonlyCustomObject, ReadonlyDocumentChange, SceneEllipse, SceneGroup, SceneImage, ScenePath, SceneRect, SceneText, ScrawlExtension, ScreenPoint, ScreenRect, StampKind, SupportedAssetMediaType, ToolCancelReason, ToolCapabilities, ToolCursor, ToolId };
package/dist/browser.js CHANGED
@@ -123,7 +123,11 @@ function validateStrokes(raw) {
123
123
  for (let index = 0; index < raw.length; index += 1) {
124
124
  const value = raw[index];
125
125
  const path = `strokes[${index}]`;
126
- 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);
126
+ if (!isRecord(value) || !nonempty(value.id) || !nonempty(value.color) || !positive(value.baseWidth) || !optionalEnum(value.tool, [
127
+ "marker",
128
+ "highlighter",
129
+ "shape"
130
+ ]) || !validLock(value) || !optionalString(value.clusterId) || !validMatrix(value.matrix) || !Array.isArray(value.points)) return validation("invalid stroke", path);
127
131
  const points = [];
128
132
  for (let pointIndex = 0; pointIndex < value.points.length; pointIndex += 1) {
129
133
  const point = normalizePoint(value.points[pointIndex]);
@@ -404,9 +408,21 @@ var HIGHLIGHT_COLORS = {
404
408
  };
405
409
  var NOTE_COLORS = {
406
410
  yellow: "#FDE68A",
411
+ amber: "#FCD34D",
412
+ orange: "#FDBA74",
413
+ peach: "#FED7AA",
414
+ red: "#FCA5A5",
415
+ rose: "#FECACA",
407
416
  pink: "#FBCFE8",
417
+ magenta: "#F9A8D4",
418
+ purple: "#D8B4FE",
419
+ lavender: "#E9D5FF",
408
420
  blue: "#BFDBFE",
409
- green: "#BBF7D0"
421
+ cornflower: "#93C5FD",
422
+ sky: "#7DD3FC",
423
+ cyan: "#BAE6FD",
424
+ green: "#BBF7D0",
425
+ mint: "#86EFAC"
410
426
  };
411
427
  var TEXT_DEFAULT_SIZE = 1.8;
412
428
  function cloneText(block) {
@@ -35884,12 +35900,16 @@ var FRAGMENT = `
35884
35900
  return max(lineAlpha2.x, lineAlpha2.y);
35885
35901
  }
35886
35902
 
35887
- // Distance from p's nearest grid intersection, in world units. Dot radius
35888
- // matches the configured line width in pixels, same anti-aliasing.
35903
+ // Distance from p's nearest grid intersection, in world units. A line's
35904
+ // width reads as visible even at 1px because it spans the whole screen;
35905
+ // an isolated dot at that same 1px radius gets almost entirely eaten by
35906
+ // anti-aliasing and all but disappears. Scaling the radius up keeps a
35907
+ // dot's on-screen weight comparable to a line drawn with the same
35908
+ // uGridLineWidthPx, rather than using it literally as a radius.
35889
35909
  float gridDotAlpha(vec2 p) {
35890
35910
  vec2 cell = mod(p, uGridSpacing) - uGridSpacing * 0.5;
35891
35911
  float dist = length(cell);
35892
- float radius = uGridLineWidthPx * uWorldPerPixel;
35912
+ float radius = uGridLineWidthPx * uWorldPerPixel * 1.75;
35893
35913
  float aa = uWorldPerPixel;
35894
35914
  return 1.0 - smoothstep(radius - aa * 0.5, radius + aa * 0.5, dist);
35895
35915
  }
@@ -36466,6 +36486,7 @@ var StrokeRenderer = class {
36466
36486
  if (mesh) {
36467
36487
  mesh.geometry.dispose();
36468
36488
  mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36489
+ mesh.material = stroke.tool === "highlighter" ? this.materials.highlight(stroke.color) : this.materials.get(stroke.color);
36469
36490
  }
36470
36491
  }
36471
36492
  for (const stroke of change.transformed) {
@@ -44645,7 +44666,7 @@ function distToSegmentSq(p, a, b) {
44645
44666
  }
44646
44667
  //#endregion
44647
44668
  //#region src/interaction/tools/eraserTool.ts
44648
- var DECAY_PER_PASS = .4;
44669
+ var DECAY_PER_PASS = 1;
44649
44670
  /** A point can take another decay pass after this long — rubbing works. */
44650
44671
  var REARM_MS = 250;
44651
44672
  var EraserTool = class {
@@ -44739,19 +44760,11 @@ var EraserTool = class {
44739
44760
  const runs = partitionByErasure(current.points);
44740
44761
  const survives = runs.filter((r) => r.survives && r.points.length >= 2);
44741
44762
  const keepId = survives.length === 1 && survives[0].points.length === current.points.length;
44742
- const m = current.matrix;
44743
44763
  for (const run of runs) if (run.survives && run.points.length >= 2) after.push({
44744
44764
  ...current,
44745
44765
  id: keepId ? current.id : crypto.randomUUID(),
44746
44766
  points: run.points
44747
44767
  });
44748
- else if (!run.survives) {
44749
- const world = m ? run.points.map((p) => ({
44750
- ...p,
44751
- ...apply(m, p)
44752
- })) : run.points;
44753
- this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1), current.tool !== "shape");
44754
- }
44755
44768
  }
44756
44769
  doc.removeStrokes(before.map((s) => s.id));
44757
44770
  doc.addStrokes(after.map(cloneStroke));
@@ -44959,10 +44972,10 @@ function buildShape(kind, a, b, step, constrain) {
44959
44972
  case "ellipse": return [ellipse(a, targetB, step)];
44960
44973
  case "line": return [sampleSegment(a, constrain ? snappedEnd(a, b) : b, step)];
44961
44974
  case "arrow": return arrow(a, constrain ? snappedEnd(a, b) : b, step);
44962
- case "triangle": return [polygon(3, a, targetB, step, -Math.PI / 2)];
44975
+ case "triangle": return [polygon(3, a, targetB, step, Math.PI / 2)];
44963
44976
  case "diamond": return [polygon(4, a, targetB, step, 0)];
44964
- case "pentagon": return [polygon(5, a, targetB, step, -Math.PI / 2)];
44965
- case "hexagon": return [polygon(6, a, targetB, step, -Math.PI / 2)];
44977
+ case "pentagon": return [polygon(5, a, targetB, step, Math.PI / 2)];
44978
+ case "hexagon": return [polygon(6, a, targetB, step, Math.PI / 2)];
44966
44979
  case "octagon": return [polygon(8, a, targetB, step, Math.PI / 8)];
44967
44980
  case "star": return [star(5, a, targetB, step)];
44968
44981
  case "heart": return [heart(a, targetB, step)];
@@ -45161,6 +45174,7 @@ var ShapeTool = class {
45161
45174
  this.ctx.clusters.assign(strokes[0]);
45162
45175
  for (const s of strokes) s.clusterId = strokes[0].clusterId;
45163
45176
  this.ctx.history.execute(new AddStrokesCommand(strokes));
45177
+ this.ctx.selection.set(strokes.map((s) => s.id));
45164
45178
  this.ctx.setTool?.("select");
45165
45179
  }
45166
45180
  this.ctx.transition("idle");
@@ -45742,6 +45756,13 @@ var HANDLE_HIT_PX = 12;
45742
45756
  var ROTATE_OFFSET_PX = 26;
45743
45757
  var ROTATE_HIT_PX = 14;
45744
45758
  var COLOR = 2450411;
45759
+ /**
45760
+ * Visual-only outset between the selected object and the gizmo's outline/
45761
+ * handles — the object's actual bounding box (used for resize math, see
45762
+ * `anchorPoint`/`handlePoint`'s callers in `selectTool.ts`) never changes;
45763
+ * only where the outline and handle meshes are *drawn* and hit-tested does.
45764
+ */
45765
+ var GAP_PX = 8;
45745
45766
  var SelectionGizmo = class {
45746
45767
  constructor(scene) {
45747
45768
  this.group = new Group();
@@ -45756,7 +45777,7 @@ var SelectionGizmo = class {
45756
45777
  this.outline = new LineLoop(new BufferGeometry(), this.lineMaterial);
45757
45778
  this.outline.position.z = GIZMO_Z;
45758
45779
  this.outline.frustumCulled = false;
45759
- const handleGeometry = new PlaneGeometry(1, 1);
45780
+ const handleGeometry = new CircleGeometry(.5, 20);
45760
45781
  const handleMaterial = new MeshBasicMaterial({ color: COLOR });
45761
45782
  const makeHandle = () => new Mesh(handleGeometry, handleMaterial);
45762
45783
  this.handles = {
@@ -45784,18 +45805,19 @@ var SelectionGizmo = class {
45784
45805
  if (!box) return;
45785
45806
  this.lineMaterial.color.setHex(locked ? 16096779 : COLOR);
45786
45807
  this.rotateHandle.visible = !locked;
45808
+ const visual = this.visualBox(box, wpp);
45787
45809
  const positions = new Float32Array([
45788
- box.minX,
45789
- box.minY,
45810
+ visual.minX,
45811
+ visual.minY,
45790
45812
  GIZMO_Z,
45791
- box.maxX,
45792
- box.minY,
45813
+ visual.maxX,
45814
+ visual.minY,
45793
45815
  GIZMO_Z,
45794
- box.maxX,
45795
- box.maxY,
45816
+ visual.maxX,
45817
+ visual.maxY,
45796
45818
  GIZMO_Z,
45797
- box.minX,
45798
- box.maxY,
45819
+ visual.minX,
45820
+ visual.maxY,
45799
45821
  GIZMO_Z
45800
45822
  ]);
45801
45823
  this.outline.geometry.dispose();
@@ -45807,17 +45829,27 @@ var SelectionGizmo = class {
45807
45829
  for (const [handle, mesh] of Object.entries(this.handles)) {
45808
45830
  mesh.visible = !locked;
45809
45831
  if (!locked) {
45810
- const p = this.handlePoint(handle, box);
45832
+ const p = this.handlePoint(handle, visual);
45811
45833
  mesh.position.set(p.x, p.y, GIZMO_Z);
45812
45834
  mesh.scale.set(size, size, 1);
45813
45835
  }
45814
45836
  }
45815
45837
  if (!locked) {
45816
- const rotate = this.rotatePoint(box, wpp);
45838
+ const rotate = this.rotatePoint(visual, wpp);
45817
45839
  this.rotateHandle.position.set(rotate.x, rotate.y, GIZMO_Z);
45818
45840
  this.rotateHandle.scale.set(size, size, 1);
45819
45841
  }
45820
45842
  }
45843
+ /** 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`). */
45844
+ visualBox(box, wpp) {
45845
+ const gap = GAP_PX * wpp;
45846
+ return {
45847
+ minX: box.minX - gap,
45848
+ minY: box.minY - gap,
45849
+ maxX: box.maxX + gap,
45850
+ maxY: box.maxY + gap
45851
+ };
45852
+ }
45821
45853
  handlePoint(handle, box) {
45822
45854
  const midX = (box.minX + box.maxX) / 2;
45823
45855
  const midY = (box.minY + box.maxY) / 2;
@@ -45905,7 +45937,8 @@ var SelectionGizmo = class {
45905
45937
  if (!this.box || this.isLocked) return null;
45906
45938
  const handleR = HANDLE_HIT_PX * this.wpp;
45907
45939
  const edgeSlop = 6 * this.wpp;
45908
- const rotate = this.rotatePoint(this.box, this.wpp);
45940
+ const visual = this.visualBox(this.box, this.wpp);
45941
+ const rotate = this.rotatePoint(visual, this.wpp);
45909
45942
  if (Math.hypot(p.x - rotate.x, p.y - rotate.y) < ROTATE_HIT_PX * this.wpp) return { kind: "rotate" };
45910
45943
  for (const handle of [
45911
45944
  "nw",
@@ -45917,13 +45950,13 @@ var SelectionGizmo = class {
45917
45950
  "e",
45918
45951
  "w"
45919
45952
  ]) {
45920
- const hp = this.handlePoint(handle, this.box);
45953
+ const hp = this.handlePoint(handle, visual);
45921
45954
  if (Math.abs(p.x - hp.x) < handleR && Math.abs(p.y - hp.y) < handleR) return {
45922
45955
  kind: "scale",
45923
45956
  handle
45924
45957
  };
45925
45958
  }
45926
- const { minX, maxX, minY, maxY } = this.box;
45959
+ const { minX, maxX, minY, maxY } = visual;
45927
45960
  const inX = p.x >= minX - edgeSlop && p.x <= maxX + edgeSlop;
45928
45961
  const inY = p.y >= minY - edgeSlop && p.y <= maxY + edgeSlop;
45929
45962
  if (inX && Math.abs(p.y - maxY) < edgeSlop) return {
@@ -45943,7 +45976,7 @@ var SelectionGizmo = class {
45943
45976
  handle: "w"
45944
45977
  };
45945
45978
  const pad = 2 * this.wpp;
45946
- if (p.x >= minX - pad && p.x <= maxX + pad && p.y >= minY - pad && p.y <= maxY + pad) return { kind: "inside" };
45979
+ 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" };
45947
45980
  return null;
45948
45981
  }
45949
45982
  rotatePoint(box, wpp) {
@@ -48176,6 +48209,112 @@ function createBoardControllerWithEngine(options, existingEngine) {
48176
48209
  })),
48177
48210
  ...[...document.allCustomObjects()].map((value) => clone(toBoardObject(value)))
48178
48211
  ];
48212
+ const projectToScreen = (point) => engine ? engine.boardToScreen(point) : {
48213
+ x: (point.x - view.x) * view.zoom,
48214
+ y: (view.y - point.y) * view.zoom
48215
+ };
48216
+ const lockFields = (item) => ({
48217
+ locked: !!item.locked,
48218
+ ...item.lockedBy ? { lockedBy: item.lockedBy } : {},
48219
+ ...item.lockedByName ? { lockedByName: item.lockedByName } : {}
48220
+ });
48221
+ const lockFieldsMany = (items) => {
48222
+ const locked = items.filter((i) => i.locked);
48223
+ if (locked.length === 0) return { locked: false };
48224
+ const lockedBys = [...new Set(locked.map((i) => i.lockedBy).filter((v) => !!v))];
48225
+ const names = [...new Set(locked.map((i) => i.lockedByName).filter((v) => !!v))];
48226
+ return {
48227
+ locked: true,
48228
+ ...lockedBys.length === 1 ? { lockedBy: lockedBys[0] } : {},
48229
+ ...names.length === 1 ? { lockedByName: names[0] } : {}
48230
+ };
48231
+ };
48232
+ const strokeClusterAnchor = (strokes) => {
48233
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
48234
+ for (const s of strokes) for (const p of s.points) {
48235
+ const world = s.matrix ? apply(s.matrix, p) : p;
48236
+ minX = Math.min(minX, world.x);
48237
+ maxX = Math.max(maxX, world.x);
48238
+ minY = Math.min(minY, world.y);
48239
+ maxY = Math.max(maxY, world.y);
48240
+ }
48241
+ if (!Number.isFinite(minX)) return projectToScreen({
48242
+ x: 0,
48243
+ y: 0
48244
+ });
48245
+ return projectToScreen({
48246
+ x: (minX + maxX) / 2,
48247
+ y: maxY + 1.4
48248
+ });
48249
+ };
48250
+ /** Per-type anchor formulas mirror `ScrawlEngine.getSelectedItemInfo` exactly, so a Host-built toolbar lands in the same spot the SDK's own would. */
48251
+ const anchorFor = (obj) => {
48252
+ switch (obj.type) {
48253
+ case "note": return projectToScreen({
48254
+ x: obj.x,
48255
+ y: obj.y + obj.size / 2 + 1
48256
+ });
48257
+ case "text": {
48258
+ const { width } = measureTextBlock(obj.text, obj.fontSize);
48259
+ return projectToScreen({
48260
+ x: obj.x + width / 2,
48261
+ y: obj.y + 1
48262
+ });
48263
+ }
48264
+ case "table": {
48265
+ const { width } = measureTable(obj);
48266
+ return projectToScreen({
48267
+ x: obj.x + width / 2,
48268
+ y: obj.y + 1.2
48269
+ });
48270
+ }
48271
+ case "image": return projectToScreen({
48272
+ x: obj.x,
48273
+ y: obj.y + obj.height / 2 + 1.2
48274
+ });
48275
+ case "timer": return projectToScreen({
48276
+ x: obj.x,
48277
+ y: obj.y + obj.size / 2 + 1.2
48278
+ });
48279
+ case "custom": {
48280
+ const b = obj.fallback.bounds;
48281
+ return projectToScreen(apply(obj.transform, {
48282
+ x: b.x + b.width / 2,
48283
+ y: b.y + b.height + 1
48284
+ }));
48285
+ }
48286
+ case "stroke": return strokeClusterAnchor([obj]);
48287
+ }
48288
+ };
48289
+ const computeFocusedItem = () => {
48290
+ if (selection.length === 0) return null;
48291
+ if (selection.length === 1) {
48292
+ const obj = readObject(selection[0]);
48293
+ if (!obj) return null;
48294
+ if (obj.type === "custom") return {
48295
+ type: "custom",
48296
+ id: obj.id,
48297
+ locked: false,
48298
+ screenPosition: anchorFor(obj)
48299
+ };
48300
+ return {
48301
+ type: obj.type,
48302
+ id: obj.id,
48303
+ ...lockFields(obj),
48304
+ screenPosition: anchorFor(obj)
48305
+ };
48306
+ }
48307
+ const objects = selection.map(readObject);
48308
+ if (!objects.every((o) => o?.type === "stroke")) return null;
48309
+ const clusterId = objects[0].clusterId;
48310
+ if (!clusterId || !objects.every((o) => o.clusterId === clusterId)) return null;
48311
+ return {
48312
+ type: "stroke",
48313
+ id: objects[0].id,
48314
+ ...lockFieldsMany(objects),
48315
+ screenPosition: strokeClusterAnchor(objects)
48316
+ };
48317
+ };
48179
48318
  const updateObject = (id, patch) => {
48180
48319
  assertMutable();
48181
48320
  const before = readObject(id);
@@ -48217,6 +48356,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48217
48356
  zoom: view.zoom,
48218
48357
  readOnly,
48219
48358
  selection: Object.freeze([...selection]),
48359
+ focusedItem: computeFocusedItem(),
48220
48360
  strokeCount: [...document.all()].length,
48221
48361
  objectCount: allObjects().length,
48222
48362
  canUndo: history.canUndo,
@@ -48631,10 +48771,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
48631
48771
  changed();
48632
48772
  },
48633
48773
  get: () => ({ ...view }),
48634
- boardToScreen: (point) => engine ? engine.boardToScreen(point) : {
48635
- x: (point.x - view.x) * view.zoom,
48636
- y: (view.y - point.y) * view.zoom
48637
- },
48774
+ boardToScreen: (point) => projectToScreen(point),
48638
48775
  screenToBoard: (point) => engine ? engine.screenToBoard(point.x, point.y) ?? {
48639
48776
  x: point.x,
48640
48777
  y: point.y
@@ -48671,6 +48808,81 @@ function createBoardControllerWithEngine(options, existingEngine) {
48671
48808
  update(id, patch) {
48672
48809
  updateObject(id, patch);
48673
48810
  },
48811
+ duplicate(ids) {
48812
+ assertMutable();
48813
+ const objects = [...new Set(ids)].map(readObject).filter((o) => !!o);
48814
+ if (objects.length === 0) return [];
48815
+ const DX = 1.5;
48816
+ const DY = -1.5;
48817
+ const newClusterIds = /* @__PURE__ */ new Map();
48818
+ const clusterIdFor = (oldClusterId) => {
48819
+ let next = newClusterIds.get(oldClusterId);
48820
+ if (!next) {
48821
+ next = crypto.randomUUID();
48822
+ newClusterIds.set(oldClusterId, next);
48823
+ }
48824
+ return next;
48825
+ };
48826
+ const commands = [];
48827
+ const newIds = [];
48828
+ for (const obj of objects) {
48829
+ const id = createId();
48830
+ newIds.push(id);
48831
+ const rest = { ...obj };
48832
+ delete rest.locked;
48833
+ delete rest.lockedBy;
48834
+ delete rest.lockedByName;
48835
+ let duplicated;
48836
+ if (obj.type === "stroke") {
48837
+ const matrix = obj.matrix ?? [
48838
+ 1,
48839
+ 0,
48840
+ 0,
48841
+ 1,
48842
+ 0,
48843
+ 0
48844
+ ];
48845
+ duplicated = {
48846
+ ...rest,
48847
+ id,
48848
+ matrix: [
48849
+ matrix[0],
48850
+ matrix[1],
48851
+ matrix[2],
48852
+ matrix[3],
48853
+ matrix[4] + DX,
48854
+ matrix[5] + DY
48855
+ ],
48856
+ ...obj.clusterId ? { clusterId: clusterIdFor(obj.clusterId) } : {}
48857
+ };
48858
+ } else if (obj.type === "custom") {
48859
+ const t = obj.transform;
48860
+ duplicated = {
48861
+ ...rest,
48862
+ id,
48863
+ transform: [
48864
+ t[0],
48865
+ t[1],
48866
+ t[2],
48867
+ t[3],
48868
+ t[4] + DX,
48869
+ t[5] + DY
48870
+ ]
48871
+ };
48872
+ delete duplicated.lock;
48873
+ } else duplicated = {
48874
+ ...rest,
48875
+ id,
48876
+ x: obj.x + DX,
48877
+ y: obj.y + DY
48878
+ };
48879
+ validateBoardObject(duplicated.type, duplicated);
48880
+ commands.push(addCommand(duplicated.type, duplicated));
48881
+ }
48882
+ executeHistory(() => history.execute(new CommandBatch("duplicate content", commands)));
48883
+ changed();
48884
+ return newIds;
48885
+ },
48674
48886
  table: {
48675
48887
  addRow(tableId) {
48676
48888
  const table = document.getTable(tableId);
package/dist/core.d.ts CHANGED
@@ -492,9 +492,21 @@ declare const HIGHLIGHT_COLORS: {
492
492
  };
493
493
  declare const NOTE_COLORS: {
494
494
  readonly yellow: "#FDE68A";
495
+ readonly amber: "#FCD34D";
496
+ readonly orange: "#FDBA74";
497
+ readonly peach: "#FED7AA";
498
+ readonly red: "#FCA5A5";
499
+ readonly rose: "#FECACA";
495
500
  readonly pink: "#FBCFE8";
501
+ readonly magenta: "#F9A8D4";
502
+ readonly purple: "#D8B4FE";
503
+ readonly lavender: "#E9D5FF";
496
504
  readonly blue: "#BFDBFE";
505
+ readonly cornflower: "#93C5FD";
506
+ readonly sky: "#7DD3FC";
507
+ readonly cyan: "#BAE6FD";
497
508
  readonly green: "#BBF7D0";
509
+ readonly mint: "#86EFAC";
498
510
  };
499
511
  /**
500
512
  * One collaborator's vote on a note. One per person; toggling removes it.
package/dist/core.js CHANGED
@@ -148,7 +148,11 @@ function validateStrokes(raw) {
148
148
  for (let index = 0; index < raw.length; index += 1) {
149
149
  const value = raw[index];
150
150
  const path = `strokes[${index}]`;
151
- 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);
151
+ if (!isRecord(value) || !nonempty(value.id) || !nonempty(value.color) || !positive(value.baseWidth) || !optionalEnum(value.tool, [
152
+ "marker",
153
+ "highlighter",
154
+ "shape"
155
+ ]) || !validLock(value) || !optionalString(value.clusterId) || !validMatrix(value.matrix) || !Array.isArray(value.points)) return validation("invalid stroke", path);
152
156
  const points = [];
153
157
  for (let pointIndex = 0; pointIndex < value.points.length; pointIndex += 1) {
154
158
  const point = normalizePoint(value.points[pointIndex]);
@@ -429,9 +433,21 @@ var HIGHLIGHT_COLORS = {
429
433
  };
430
434
  var NOTE_COLORS = {
431
435
  yellow: "#FDE68A",
436
+ amber: "#FCD34D",
437
+ orange: "#FDBA74",
438
+ peach: "#FED7AA",
439
+ red: "#FCA5A5",
440
+ rose: "#FECACA",
432
441
  pink: "#FBCFE8",
442
+ magenta: "#F9A8D4",
443
+ purple: "#D8B4FE",
444
+ lavender: "#E9D5FF",
433
445
  blue: "#BFDBFE",
434
- green: "#BBF7D0"
446
+ cornflower: "#93C5FD",
447
+ sky: "#7DD3FC",
448
+ cyan: "#BAE6FD",
449
+ green: "#BBF7D0",
450
+ mint: "#86EFAC"
435
451
  };
436
452
  var TEXT_DEFAULT_SIZE = 1.8;
437
453
  function cloneText(block) {