@scrawl-board/board 0.1.0-beta.10 → 0.1.0-beta.11

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
@@ -5939,12 +5939,35 @@ function toDataUri(bytes, mediaType) {
5939
5939
  return `data:${mediaType};base64,${btoa(binary)}`;
5940
5940
  }
5941
5941
  //#endregion
5942
+ //#region src/persistence/adapters/imageDecode.ts
5943
+ var ORIENTED = { imageOrientation: "from-image" };
5944
+ /**
5945
+ * Decodes image bytes the way a browser displays them: EXIF orientation
5946
+ * applied, so a phone photo comes out upright and its width/height are the
5947
+ * displayed ones.
5948
+ *
5949
+ * `"from-image"` is already the default in every browser this SDK supports
5950
+ * (Chrome/Edge 112+, Firefox 111+, Safari 16+); passing it just makes that
5951
+ * explicit. Engines from before `"none"` was renamed to `"from-image"` reject
5952
+ * the value with a TypeError — they get their own default decode instead of
5953
+ * no image at all.
5954
+ */
5955
+ async function decodeImageBitmap(blob) {
5956
+ try {
5957
+ return await createImageBitmap(blob, ORIENTED);
5958
+ } catch (error) {
5959
+ if (error instanceof TypeError) return createImageBitmap(blob);
5960
+ throw error;
5961
+ }
5962
+ }
5963
+ //#endregion
5942
5964
  //#region src/persistence/adapters/assetIngestion.ts
5965
+ /** Displayed dimensions — EXIF orientation applied, so a portrait phone photo measures as portrait. */
5943
5966
  async function decodeDimensionsWithImageBitmap(bytes, mediaType) {
5944
5967
  const blob = new Blob([bytes], { type: mediaType });
5945
5968
  let bitmap;
5946
5969
  try {
5947
- bitmap = await createImageBitmap(blob);
5970
+ bitmap = await decodeImageBitmap(blob);
5948
5971
  } catch (cause) {
5949
5972
  throw new AssetResolutionError("decode-failed", false, cause instanceof Error ? cause.message : String(cause));
5950
5973
  }
@@ -6134,10 +6157,7 @@ var AssetTextureCache = class {
6134
6157
  }
6135
6158
  let texture;
6136
6159
  try {
6137
- const bitmap = await createImageBitmap(new Blob([outcome.bytes], { type: outcome.mediaType }));
6138
- texture = new THREE.Texture(bitmap);
6139
- texture.colorSpace = THREE.SRGBColorSpace;
6140
- texture.needsUpdate = true;
6160
+ texture = uprightBitmapTexture(await decodeImageBitmap(new Blob([outcome.bytes], { type: outcome.mediaType })));
6141
6161
  } catch (cause) {
6142
6162
  onError(toDecodeError(cause, ref));
6143
6163
  return;
@@ -6192,6 +6212,23 @@ var AssetTextureCache = class {
6192
6212
  function toDecodeError(cause, ref) {
6193
6213
  return new AssetResolutionError("decode-failed", false, cause instanceof Error ? cause.message : String(cause), ref);
6194
6214
  }
6215
+ /**
6216
+ * A texture that shows `bitmap` the right way up. Three.js normally flips an
6217
+ * image vertically on upload (`flipY`) to match WebGL's bottom-up texture
6218
+ * rows, but WebGL ignores that flag for ImageBitmap sources — so every
6219
+ * bitmap-backed image drew upside-down. The flip happens in the texture's UV
6220
+ * transform instead, which every material using this shared texture (built-in
6221
+ * images and Custom SceneImages alike) picks up.
6222
+ */
6223
+ function uprightBitmapTexture(bitmap) {
6224
+ const texture = new THREE.Texture(bitmap);
6225
+ texture.colorSpace = THREE.SRGBColorSpace;
6226
+ texture.flipY = false;
6227
+ texture.repeat.set(1, -1);
6228
+ texture.offset.set(0, 1);
6229
+ texture.needsUpdate = true;
6230
+ return texture;
6231
+ }
6195
6232
  //#endregion
6196
6233
  //#region src/renderer/canvas/raycast.ts
6197
6234
  var origin$1 = new THREE.Vector3();
@@ -19469,7 +19506,7 @@ var ScrawlEngine = class {
19469
19506
  this.eraserRadius = 2;
19470
19507
  this.tableRows = 3;
19471
19508
  this.tableCols = 3;
19472
- this.tool = "marker";
19509
+ this.tool = "select";
19473
19510
  this.voter = null;
19474
19511
  this.stampKind = "emoji:grinning";
19475
19512
  this.timerDurationMs = TIMER_DEFAULT_DURATION_MS;
@@ -21302,7 +21339,8 @@ function createBoardControllerWithEngine(options, existingEngine) {
21302
21339
  const storeListeners = /* @__PURE__ */ new Set();
21303
21340
  const eventListeners = /* @__PURE__ */ new Map();
21304
21341
  const documentListeners = /* @__PURE__ */ new Set();
21305
- let tool = "marker";
21342
+ let tool = "select";
21343
+ let inlineEditing = null;
21306
21344
  let style = Object.freeze({
21307
21345
  inkColor: "#1C1C1E",
21308
21346
  highlightColor: HIGHLIGHT_COLORS.yellow,
@@ -21877,6 +21915,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
21877
21915
  readOnly,
21878
21916
  selection: Object.freeze([...selection]),
21879
21917
  focusedItem: computeFocusedItem(),
21918
+ inlineEditing,
21880
21919
  strokeCount: [...document.all()].length,
21881
21920
  objectCount: allObjects().length,
21882
21921
  canUndo: history.canUndo,
@@ -22503,6 +22542,8 @@ function createBoardControllerWithEngine(options, existingEngine) {
22503
22542
  setInlineEditing(target) {
22504
22543
  assertActive();
22505
22544
  engine?.setInlineEditing(target);
22545
+ inlineEditing = target ? { ...target } : null;
22546
+ changed();
22506
22547
  },
22507
22548
  centerOn(point) {
22508
22549
  assertActive();
@@ -22948,6 +22989,15 @@ function createBoardControllerWithEngine(options, existingEngine) {
22948
22989
  query: {
22949
22990
  get: readObject,
22950
22991
  all: () => allObjects(),
22992
+ bounds: (id) => {
22993
+ const box = document.bbox(id);
22994
+ return box ? {
22995
+ minX: box.minX,
22996
+ minY: box.minY,
22997
+ maxX: box.maxX,
22998
+ maxY: box.maxY
22999
+ } : void 0;
23000
+ },
22951
23001
  search: (query) => searchBoard(query, {
22952
23002
  notes: document.allNotes(),
22953
23003
  texts: document.allTexts(),
@@ -22958,6 +23008,7 @@ function createBoardControllerWithEngine(options, existingEngine) {
22958
23008
  })
22959
23009
  },
22960
23010
  comments: {
23011
+ list: () => comments,
22961
23012
  sync(next) {
22962
23013
  comments = Object.freeze([...next]);
22963
23014
  if (engine) engine.setComments(comments);
@@ -24402,9 +24453,17 @@ var PATHS = {
24402
24453
  "shape:curved-connector": "M5 18C5 8 19 16 19 6M3 18h4v4H3ZM17 2h4v4h-4Z",
24403
24454
  "shape:polyline-connector": "m5 12 7-7 7 5-4 9-10-7M3 10h4v4H3ZM10 3h4v4h-4ZM17 8h4v4h-4ZM13 17h4v4h-4Z",
24404
24455
  chevronDown: "m6 9 6 6 6-6",
24456
+ chevronRight: "m9 6 6 6-6 6",
24457
+ notebook: "M4 4.5A1.5 1.5 0 0 1 5.5 3H18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H6a2 2 0 0 1-2-2ZM4 19a2 2 0 0 1 2-2h13",
24458
+ section: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z",
24459
+ page: "M7 3h7l4 4v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1ZM14 3v4h4",
24405
24460
  pages: "M7 3h13v16H7ZM4 7H2v15h13v-1M10 7h7M10 11h7M10 15h4",
24406
24461
  close: "M5 5l14 14M19 5 5 19",
24407
24462
  check: "m5 12 5 5L20 7",
24463
+ activity: "M3 12h4l3-8 4 16 3-8h4",
24464
+ shield: "M12 3 4 6v6c0 5 3.5 8 8 9 4.5-1 8-4 8-9V6Z",
24465
+ moreVertical: "M12 5h.01M12 12h.01M12 19h.01",
24466
+ share: "M18 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM6 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM18 16a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM8.6 10.6l6.8-3.2M8.6 13.4l6.8 3.2",
24408
24467
  lock: "M5 10h14v11H5ZM8 10V6a4 4 0 0 1 8 0v4",
24409
24468
  unlock: "M5 10h14v11H5ZM8 10V6a4 4 0 0 1 8 0",
24410
24469
  duplicate: "M8 8h13v13H8ZM16 8V3H3v13h5",
@@ -24414,8 +24473,8 @@ var PATHS = {
24414
24473
  color: "M12 3C9 8 5 11 5 15a7 7 0 0 0 14 0c0-4-4-7-7-12Z",
24415
24474
  none: "M4 4l16 16M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z",
24416
24475
  search: "M10 3a7 7 0 1 0 0 14 7 7 0 0 0 0-14ZM15 15l6 6",
24417
- import: "M12 3v12M7 10l5 5 5-5M4 16v5h16v-5",
24418
- export: "M12 16V3M7 8l5-5 5 5M4 16v5h16v-5",
24476
+ import: "M12 16V3M7 8l5-5 5 5M4 16v5h16v-5",
24477
+ export: "M12 3v12M7 10l5 5 5-5M4 16v5h16v-5",
24419
24478
  fit: "M3 9V3h6M15 3h6v6M21 15v6h-6M9 21H3v-6M8 8h8v8H8Z",
24420
24479
  select: "m5 3 14 9-7 1-3 7Z",
24421
24480
  marker: "m4 20 2-6L16 4a2 2 0 0 1 3 3L9 17Zm2-6 3 3M14 6l3 3",
@@ -24444,7 +24503,8 @@ var PATHS = {
24444
24503
  undo: "M9 5 4 10l5 5M4 10h10a6 6 0 0 1 6 6v3",
24445
24504
  redo: "m15 5 5 5-5 5M20 10H10a6 6 0 0 0-6 6v3",
24446
24505
  addRow: "M4 6h16M4 12h16M4 18h10M17 16v5M14.5 18.5h5",
24447
- addColumn: "M6 4v16M12 4v16M18 4v10M16 16.5h5M18.5 14v5"
24506
+ addColumn: "M6 4v16M12 4v16M18 4v10M16 16.5h5M18.5 14v5",
24507
+ deleteRow: "M4 6h16M4 12h16M4 18h10M15 16.5l5 5M20 16.5l-5 5"
24448
24508
  };
24449
24509
  function ToolIcon({ tool }) {
24450
24510
  return /* @__PURE__ */ jsx("svg", {
@@ -24501,127 +24561,197 @@ function NavigatorName({ name, kind, onRename }) {
24501
24561
  children: /* @__PURE__ */ jsx(ToolIcon, { tool: "marker" })
24502
24562
  });
24503
24563
  }
24504
- /**
24505
- * The notebook header across the top of the board (notebook switcher on the
24506
- * left) and a breadcrumb on the canvas, just past the sidebar and tool rail.
24507
- * Both stay visible whether or not the sidebar is open.
24508
- */
24509
- function NotebookNavigation({ controller, snapshot, onOpen }) {
24564
+ /** A crumb (or the switcher) with its menu: pick an item, rename one, or add one. */
24565
+ function NavigatorMenu({ label, items, activeId, kind, mutable, onSelect, onAdd, onRename, children, className, triggerLabel, triggerProps }) {
24510
24566
  const [open, setOpen] = useState(false);
24511
24567
  const root = useRef(null);
24512
24568
  const trigger = useRef(null);
24513
24569
  const menuId = useId();
24514
- const notebook = snapshot.notebooks.find((item) => item.id === snapshot.activeNotebookId);
24515
- const section = snapshot.sections.find((item) => item.id === snapshot.activeSectionId);
24516
- const page = snapshot.pages.find((item) => item.id === snapshot.activePageId);
24517
- const mutable = snapshot.status === "ready" && !snapshot.readOnly;
24518
24570
  useEffect(() => {
24519
24571
  if (!open) return;
24520
24572
  const outside = (event) => {
24521
24573
  if (!root.current?.contains(event.target)) setOpen(false);
24522
24574
  };
24575
+ const escape = (event) => {
24576
+ if (event.key !== "Escape") return;
24577
+ setOpen(false);
24578
+ trigger.current?.focus();
24579
+ };
24523
24580
  document.addEventListener("pointerdown", outside);
24524
- return () => document.removeEventListener("pointerdown", outside);
24581
+ document.addEventListener("keydown", escape);
24582
+ return () => {
24583
+ document.removeEventListener("pointerdown", outside);
24584
+ document.removeEventListener("keydown", escape);
24585
+ };
24525
24586
  }, [open]);
24526
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
24527
- className: "scrawl-board__header",
24528
- children: /* @__PURE__ */ jsxs("div", {
24529
- className: "scrawl-board__notebook-switcher",
24530
- ref: root,
24531
- onKeyDown: (event) => {
24532
- event.stopPropagation();
24533
- if (event.key === "Escape") {
24534
- event.preventDefault();
24535
- setOpen(false);
24536
- trigger.current?.focus();
24537
- }
24538
- },
24539
- children: [/* @__PURE__ */ jsxs("button", {
24540
- className: "scrawl-board__notebook-trigger",
24541
- ref: trigger,
24542
- type: "button",
24543
- "aria-label": "Choose notebook",
24544
- "aria-expanded": open,
24545
- "aria-controls": menuId,
24546
- onClick: () => setOpen((value) => !value),
24547
- children: [
24548
- /* @__PURE__ */ jsx("span", {
24549
- className: "scrawl-board__notebook-color",
24550
- style: { background: notebook?.color }
24551
- }),
24552
- /* @__PURE__ */ jsx("span", { children: notebook?.name ?? "Notebooks" }),
24553
- /* @__PURE__ */ jsx(ToolIcon, { tool: "chevronDown" })
24554
- ]
24555
- }), open && /* @__PURE__ */ jsxs("div", {
24556
- className: "scrawl-board__notebook-dropdown",
24557
- id: menuId,
24558
- role: "group",
24559
- "aria-label": "Notebooks",
24560
- children: [/* @__PURE__ */ jsx("div", {
24561
- className: "scrawl-board__notebook-options",
24562
- children: snapshot.notebooks.map((item) => /* @__PURE__ */ jsxs("div", {
24563
- className: "scrawl-board__page-row",
24564
- "data-active": item.id === snapshot.activeNotebookId,
24565
- children: [/* @__PURE__ */ jsxs("button", {
24566
- className: "scrawl-board__page-select",
24567
- type: "button",
24568
- "aria-pressed": item.id === snapshot.activeNotebookId,
24569
- onClick: () => {
24570
- controller.notebooks.select(item.id);
24571
- setOpen(false);
24572
- onOpen();
24573
- trigger.current?.focus();
24574
- },
24575
- children: [/* @__PURE__ */ jsx("span", {
24587
+ const close = () => {
24588
+ setOpen(false);
24589
+ trigger.current?.focus();
24590
+ };
24591
+ return /* @__PURE__ */ jsxs("div", {
24592
+ className,
24593
+ "data-open": open ? "" : void 0,
24594
+ ref: root,
24595
+ onKeyDown: (event) => {
24596
+ event.stopPropagation();
24597
+ if (event.key === "Escape") {
24598
+ event.preventDefault();
24599
+ close();
24600
+ }
24601
+ },
24602
+ children: [/* @__PURE__ */ jsx("button", {
24603
+ ...triggerProps,
24604
+ ref: trigger,
24605
+ type: "button",
24606
+ "aria-label": triggerLabel,
24607
+ "aria-expanded": open,
24608
+ "aria-controls": menuId,
24609
+ "aria-haspopup": "true",
24610
+ onClick: () => setOpen((value) => !value),
24611
+ children
24612
+ }), open && /* @__PURE__ */ jsxs("div", {
24613
+ className: "scrawl-board__navigator-menu",
24614
+ id: menuId,
24615
+ role: "group",
24616
+ "aria-label": label,
24617
+ children: [/* @__PURE__ */ jsx("div", {
24618
+ className: "scrawl-board__navigator-options",
24619
+ children: items.map((item) => /* @__PURE__ */ jsxs("div", {
24620
+ className: "scrawl-board__page-row",
24621
+ "data-active": item.id === activeId,
24622
+ children: [/* @__PURE__ */ jsxs("button", {
24623
+ className: "scrawl-board__page-select",
24624
+ type: "button",
24625
+ "aria-pressed": item.id === activeId,
24626
+ onClick: () => {
24627
+ onSelect(item.id);
24628
+ close();
24629
+ },
24630
+ children: [
24631
+ item.color !== void 0 ? /* @__PURE__ */ jsx("span", {
24576
24632
  className: "scrawl-board__notebook-color",
24577
24633
  style: { background: item.color }
24578
- }), /* @__PURE__ */ jsx("span", { children: item.name })]
24579
- }), mutable && /* @__PURE__ */ jsx(NavigatorName, {
24580
- name: item.name,
24581
- kind: "Notebook",
24582
- onRename: (name) => controller.notebooks.rename(item.id, name)
24583
- })]
24584
- }, item.id))
24585
- }), /* @__PURE__ */ jsxs("button", {
24586
- className: "scrawl-board__add-notebook",
24587
- type: "button",
24588
- disabled: !mutable,
24589
- onClick: () => {
24590
- controller.notebooks.add();
24591
- setOpen(false);
24592
- onOpen();
24593
- trigger.current?.focus();
24594
- },
24595
- children: [/* @__PURE__ */ jsx(ToolIcon, { tool: "more" }), /* @__PURE__ */ jsx("span", { children: "New notebook" })]
24596
- })]
24634
+ }) : /* @__PURE__ */ jsx(ToolIcon, { tool: kind === "Section" ? "section" : "page" }),
24635
+ /* @__PURE__ */ jsx("span", {
24636
+ className: "scrawl-board__navigator-label",
24637
+ children: item.name
24638
+ }),
24639
+ item.id === activeId && /* @__PURE__ */ jsx(ToolIcon, { tool: "check" })
24640
+ ]
24641
+ }), mutable && /* @__PURE__ */ jsx(NavigatorName, {
24642
+ name: item.name,
24643
+ kind,
24644
+ onRename: (name) => onRename(item.id, name)
24645
+ })]
24646
+ }, item.id))
24647
+ }), /* @__PURE__ */ jsxs("button", {
24648
+ className: "scrawl-board__navigator-add",
24649
+ type: "button",
24650
+ disabled: !mutable,
24651
+ onClick: () => {
24652
+ onAdd();
24653
+ close();
24654
+ },
24655
+ children: [/* @__PURE__ */ jsx(ToolIcon, { tool: "more" }), /* @__PURE__ */ jsxs("span", { children: ["New ", kind.toLowerCase()] })]
24597
24656
  })]
24598
- })
24657
+ })]
24658
+ });
24659
+ }
24660
+ /**
24661
+ * The floating navigator: a notebook switcher in the top-left corner, and a
24662
+ * breadcrumb across the top whose notebook, section and page crumbs each open
24663
+ * their own menu (pick, rename, or add one).
24664
+ */
24665
+ function NotebookNavigation({ controller, snapshot }) {
24666
+ const notebook = snapshot.notebooks.find((item) => item.id === snapshot.activeNotebookId);
24667
+ const section = snapshot.sections.find((item) => item.id === snapshot.activeSectionId);
24668
+ const page = snapshot.pages.find((item) => item.id === snapshot.activePageId);
24669
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
24670
+ const sections = snapshot.sections.filter((item) => item.notebookId === snapshot.activeNotebookId);
24671
+ const pages = snapshot.pages.filter((item) => item.sectionId === snapshot.activeSectionId);
24672
+ const notebookMenu = {
24673
+ items: snapshot.notebooks,
24674
+ activeId: snapshot.activeNotebookId,
24675
+ kind: "Notebook",
24676
+ mutable,
24677
+ onSelect: (id) => controller.notebooks.select(id),
24678
+ onAdd: () => controller.notebooks.add(),
24679
+ onRename: (id, name) => controller.notebooks.rename(id, name)
24680
+ };
24681
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(NavigatorMenu, {
24682
+ ...notebookMenu,
24683
+ label: "Notebooks",
24684
+ className: "scrawl-board__notebook-switcher",
24685
+ triggerLabel: "Choose notebook",
24686
+ triggerProps: { className: "scrawl-board__notebook-trigger" },
24687
+ children: [
24688
+ /* @__PURE__ */ jsx("span", {
24689
+ className: "scrawl-board__notebook-color",
24690
+ style: { background: notebook?.color }
24691
+ }),
24692
+ /* @__PURE__ */ jsx("span", {
24693
+ className: "scrawl-board__navigator-label",
24694
+ children: notebook?.name ?? "Notebooks"
24695
+ }),
24696
+ /* @__PURE__ */ jsx(ToolIcon, { tool: "chevronDown" })
24697
+ ]
24599
24698
  }), /* @__PURE__ */ jsx("nav", {
24600
24699
  className: "scrawl-board__breadcrumb",
24601
24700
  "aria-label": "Breadcrumb",
24602
24701
  children: /* @__PURE__ */ jsxs("ol", { children: [
24603
- /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
24604
- type: "button",
24605
- onClick: () => {
24606
- setOpen(true);
24607
- trigger.current?.focus();
24608
- },
24609
- children: notebook?.name ?? "Notebook"
24702
+ /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(NavigatorMenu, {
24703
+ ...notebookMenu,
24704
+ label: "Notebooks",
24705
+ className: "scrawl-board__crumb-group",
24706
+ triggerLabel: `Notebook: ${notebook?.name ?? "Notebook"}`,
24707
+ triggerProps: { className: "scrawl-board__crumb" },
24708
+ children: [/* @__PURE__ */ jsx(ToolIcon, { tool: "notebook" }), /* @__PURE__ */ jsx("span", {
24709
+ className: "scrawl-board__navigator-label",
24710
+ children: notebook?.name ?? "Notebook"
24711
+ })]
24610
24712
  }) }),
24611
- /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx("span", {
24612
- "aria-hidden": "true",
24613
- children: "›"
24614
- }), /* @__PURE__ */ jsx("button", {
24615
- type: "button",
24616
- onClick: onOpen,
24617
- children: section?.name ?? "Section"
24713
+ /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx(ToolIcon, { tool: "chevronRight" }), /* @__PURE__ */ jsxs(NavigatorMenu, {
24714
+ label: "Sections",
24715
+ items: sections,
24716
+ activeId: snapshot.activeSectionId,
24717
+ kind: "Section",
24718
+ mutable: true,
24719
+ onSelect: (id) => controller.sections.select(id),
24720
+ onAdd: () => controller.sections.add(),
24721
+ onRename: (id, name) => controller.sections.rename(id, name),
24722
+ className: "scrawl-board__crumb-group",
24723
+ triggerLabel: `Section: ${section?.name ?? "Section"}`,
24724
+ triggerProps: { className: "scrawl-board__crumb" },
24725
+ children: [
24726
+ /* @__PURE__ */ jsx(ToolIcon, { tool: "section" }),
24727
+ /* @__PURE__ */ jsx("span", {
24728
+ className: "scrawl-board__navigator-label",
24729
+ children: section?.name ?? "Section"
24730
+ }),
24731
+ /* @__PURE__ */ jsx(ToolIcon, { tool: "chevronDown" })
24732
+ ]
24618
24733
  })] }),
24619
- /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx("span", {
24620
- "aria-hidden": "true",
24621
- children: "›"
24622
- }), /* @__PURE__ */ jsx("span", {
24623
- "aria-current": "page",
24624
- children: page?.name ?? "Page"
24734
+ /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx(ToolIcon, { tool: "chevronRight" }), /* @__PURE__ */ jsxs(NavigatorMenu, {
24735
+ label: "Pages",
24736
+ items: pages,
24737
+ activeId: snapshot.activePageId,
24738
+ kind: "Page",
24739
+ mutable: true,
24740
+ onSelect: (id) => controller.pages.select(id),
24741
+ onAdd: () => controller.pages.add(),
24742
+ onRename: (id, name) => controller.pages.rename(id, name),
24743
+ className: "scrawl-board__crumb-group",
24744
+ triggerLabel: `Page: ${page?.name ?? "Page"}`,
24745
+ triggerProps: { className: "scrawl-board__crumb scrawl-board__crumb--current" },
24746
+ children: [
24747
+ /* @__PURE__ */ jsx(ToolIcon, { tool: "page" }),
24748
+ /* @__PURE__ */ jsx("span", {
24749
+ className: "scrawl-board__navigator-label",
24750
+ "aria-current": "page",
24751
+ children: page?.name ?? "Page"
24752
+ }),
24753
+ /* @__PURE__ */ jsx(ToolIcon, { tool: "chevronDown" })
24754
+ ]
24625
24755
  })] })
24626
24756
  ] })
24627
24757
  })] });
@@ -24710,14 +24840,6 @@ function PagesPanel({ controller, snapshot, open, onClose }) {
24710
24840
  onClick: () => controller.sections.add(),
24711
24841
  children: /* @__PURE__ */ jsx(ToolIcon, { tool: "more" })
24712
24842
  }),
24713
- /* @__PURE__ */ jsx("button", {
24714
- "aria-label": "New page",
24715
- "data-tooltip": "New page",
24716
- type: "button",
24717
- disabled: !mutable,
24718
- onClick: addPage,
24719
- children: /* @__PURE__ */ jsx(ToolIcon, { tool: "more" })
24720
- }),
24721
24843
  /* @__PURE__ */ jsx("button", {
24722
24844
  "aria-label": "Close pages",
24723
24845
  "data-tooltip": "Close pages",
@@ -25138,298 +25260,425 @@ async function insertImageFile(controller, file) {
25138
25260
  return id;
25139
25261
  }
25140
25262
  //#endregion
25141
- //#region src/ui/dialogs/inline-editing.tsx
25142
- /** Inline note, text, and table-cell editors — driven entirely by public controller events and commands. */
25143
- function InlineEditors({ controller, renderPortal }) {
25144
- const [editor, setEditor] = useState(null);
25145
- const anchorRef = useRef(null);
25146
- const textAreaRef = useRef(null);
25147
- const measureRef = useRef(null);
25148
- const coveredRow = editor?.kind === "table-cell" ? editor.row : void 0;
25149
- const coveredCol = editor?.kind === "table-cell" ? editor.col : void 0;
25150
- const coveredId = editor && editor.kind !== "note" ? editor.id : null;
25263
+ //#region src/ui/panels/board-panels.tsx
25264
+ /** A floating drawer under the header (a bottom sheet on narrow boards). */
25265
+ function BoardDrawer({ label, icon, subtitle, onClose, onMarkAllRead, markAllDisabled, footer, children }) {
25266
+ const headingId = useId();
25267
+ const ref = useRef(null);
25268
+ const closeRef = useRef(onClose);
25151
25269
  useEffect(() => {
25152
- if (!coveredId) return;
25153
- controller.view.setInlineEditing(coveredRow === void 0 ? { id: coveredId } : {
25154
- id: coveredId,
25155
- row: coveredRow,
25156
- col: coveredCol
25157
- });
25158
- return () => {
25159
- if (controller.getSnapshot().status !== "disposed") controller.view.setInlineEditing(null);
25270
+ closeRef.current = onClose;
25271
+ });
25272
+ useEffect(() => {
25273
+ ref.current?.querySelector("button:not(:disabled), [tabindex='0']")?.focus({ preventScroll: true });
25274
+ const outside = (event) => {
25275
+ const target = event.target;
25276
+ if (!ref.current?.contains(target) && !target.closest?.(".scrawl-board__actions")) closeRef.current();
25160
25277
  };
25161
- }, [
25162
- controller,
25163
- coveredId,
25164
- coveredRow,
25165
- coveredCol
25166
- ]);
25167
- const textValue = editor?.kind === "text" ? editor.text : null;
25168
- useLayoutEffect(() => {
25169
- const el = textAreaRef.current;
25170
- if (textValue === null || !el) return;
25171
- const cs = getComputedStyle(el);
25172
- measureRef.current ??= document.createElement("canvas").getContext("2d");
25173
- const measure = measureRef.current;
25174
- if (measure) {
25175
- measure.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
25176
- const widest = Math.max(...textValue.split("\n").map((line) => measure.measureText(line || " ").width));
25177
- const chrome = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) + parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth);
25178
- const cap = Math.min(window.innerWidth * .6, 640);
25179
- el.style.width = `${Math.ceil(Math.min(cap, widest + chrome + 4))}px`;
25180
- }
25181
- el.style.height = "auto";
25182
- el.style.height = `${el.scrollHeight + parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)}px`;
25183
- }, [textValue, editor?.kind === "text" ? editor.fontSizePx : null]);
25184
- /** Portal children are positioned relative to the Board root, not the viewport. */
25185
- const rootOffset = () => {
25186
- const rootRect = (anchorRef.current?.closest("[data-scrawl-root]"))?.getBoundingClientRect();
25187
- return {
25188
- left: rootRect?.left ?? 0,
25189
- top: rootRect?.top ?? 0
25278
+ const escape = (event) => {
25279
+ if (event.key !== "Escape" || event.defaultPrevented || document.activeElement && document.activeElement !== document.body) return;
25280
+ event.preventDefault();
25281
+ closeRef.current();
25282
+ };
25283
+ document.addEventListener("pointerdown", outside);
25284
+ document.addEventListener("keydown", escape);
25285
+ return () => {
25286
+ document.removeEventListener("pointerdown", outside);
25287
+ document.removeEventListener("keydown", escape);
25190
25288
  };
25289
+ }, []);
25290
+ return /* @__PURE__ */ jsxs("section", {
25291
+ "aria-labelledby": headingId,
25292
+ className: "scrawl-board__drawer",
25293
+ ref,
25294
+ role: "dialog",
25295
+ tabIndex: -1,
25296
+ onKeyDown: (event) => {
25297
+ event.stopPropagation();
25298
+ if (event.key === "Escape") {
25299
+ event.preventDefault();
25300
+ onClose();
25301
+ }
25302
+ },
25303
+ children: [
25304
+ /* @__PURE__ */ jsxs("header", {
25305
+ className: "scrawl-board__drawer-header",
25306
+ children: [
25307
+ icon && /* @__PURE__ */ jsx("span", {
25308
+ "aria-hidden": "true",
25309
+ className: "scrawl-board__drawer-icon",
25310
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: icon })
25311
+ }),
25312
+ /* @__PURE__ */ jsxs("div", {
25313
+ className: "scrawl-board__drawer-titles",
25314
+ children: [/* @__PURE__ */ jsx("h2", {
25315
+ id: headingId,
25316
+ children: label
25317
+ }), subtitle && /* @__PURE__ */ jsx("p", { children: subtitle })]
25318
+ }),
25319
+ onMarkAllRead && /* @__PURE__ */ jsx("button", {
25320
+ className: "scrawl-board__drawer-link",
25321
+ disabled: markAllDisabled,
25322
+ onClick: () => {
25323
+ onMarkAllRead();
25324
+ ref.current?.focus();
25325
+ },
25326
+ type: "button",
25327
+ children: "Mark all as read"
25328
+ }),
25329
+ /* @__PURE__ */ jsx("button", {
25330
+ "aria-label": `Close ${label}`,
25331
+ className: "scrawl-board__drawer-close",
25332
+ onClick: onClose,
25333
+ type: "button",
25334
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: "close" })
25335
+ })
25336
+ ]
25337
+ }),
25338
+ /* @__PURE__ */ jsx("div", {
25339
+ className: "scrawl-board__drawer-body",
25340
+ children
25341
+ }),
25342
+ footer && /* @__PURE__ */ jsx("footer", {
25343
+ className: "scrawl-board__drawer-footer",
25344
+ children: footer
25345
+ })
25346
+ ]
25347
+ });
25348
+ }
25349
+ function DrawerEmpty({ icon, title, hint }) {
25350
+ return /* @__PURE__ */ jsxs("div", {
25351
+ className: "scrawl-board__drawer-empty",
25352
+ children: [
25353
+ /* @__PURE__ */ jsx("span", {
25354
+ "aria-hidden": "true",
25355
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: icon })
25356
+ }),
25357
+ /* @__PURE__ */ jsx("p", {
25358
+ className: "scrawl-board__drawer-empty-title",
25359
+ children: title
25360
+ }),
25361
+ /* @__PURE__ */ jsx("p", { children: hint })
25362
+ ]
25363
+ });
25364
+ }
25365
+ function CommentsDrawer({ controller, comments, unread, onMarkAllRead, onClose }) {
25366
+ return /* @__PURE__ */ jsx(BoardDrawer, {
25367
+ icon: "comment",
25368
+ label: "Comments",
25369
+ markAllDisabled: unread === 0,
25370
+ onClose,
25371
+ onMarkAllRead,
25372
+ children: comments.length === 0 ? /* @__PURE__ */ jsx(DrawerEmpty, {
25373
+ hint: "Pick the Comment tool and click the board to start a conversation.",
25374
+ icon: "comment",
25375
+ title: "No comments yet"
25376
+ }) : /* @__PURE__ */ jsx("ul", {
25377
+ className: "scrawl-board__drawer-list",
25378
+ children: comments.map((comment) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
25379
+ "aria-label": `Open comment by ${comment.authorName}: ${comment.text}`,
25380
+ className: "scrawl-board__drawer-row",
25381
+ type: "button",
25382
+ onClick: () => {
25383
+ onClose();
25384
+ controller.view.centerOn({
25385
+ x: comment.x,
25386
+ y: comment.y
25387
+ });
25388
+ controller.comments.open(comment.id);
25389
+ },
25390
+ children: [/* @__PURE__ */ jsx("span", {
25391
+ "aria-hidden": "true",
25392
+ className: "scrawl-board__comment-avatar",
25393
+ style: { background: comment.authorColor },
25394
+ children: comment.authorName.slice(0, 1).toUpperCase()
25395
+ }), /* @__PURE__ */ jsxs("span", {
25396
+ className: "scrawl-board__drawer-row-text",
25397
+ children: [
25398
+ /* @__PURE__ */ jsxs("strong", { children: [comment.authorName, comment.resolved ? " · Resolved" : ""] }),
25399
+ /* @__PURE__ */ jsx("span", { children: comment.text }),
25400
+ comment.replies.length > 0 && /* @__PURE__ */ jsxs("small", { children: [
25401
+ comment.replies.length,
25402
+ " ",
25403
+ comment.replies.length === 1 ? "reply" : "replies"
25404
+ ] })
25405
+ ]
25406
+ })]
25407
+ }) }, comment.id))
25408
+ })
25409
+ });
25410
+ }
25411
+ var KIND_ICON = {
25412
+ added: "more",
25413
+ removed: "delete",
25414
+ updated: "marker",
25415
+ commented: "comment",
25416
+ shared: "share",
25417
+ exported: "export"
25418
+ };
25419
+ function timeLabels(at, now) {
25420
+ const date = new Date(at);
25421
+ const seconds = Math.max(0, Math.round((now - date.getTime()) / 1e3));
25422
+ return {
25423
+ relative: seconds < 45 ? "just now" : seconds < 3600 ? `${Math.round(seconds / 60)} min ago` : seconds < 86400 ? `${Math.round(seconds / 3600)} h ago` : `${Math.round(seconds / 86400)} d ago`,
25424
+ absolute: date.toLocaleString(void 0, {
25425
+ dateStyle: "medium",
25426
+ timeStyle: "short"
25427
+ })
25191
25428
  };
25192
- useEffect(() => controller.on("edit-request", (event) => {
25193
- const offset = rootOffset();
25194
- if (event.kind === "text" && event.id === null) {
25195
- const newId = controller.content.add({
25196
- type: "text",
25197
- x: event.boardX,
25198
- y: event.boardY,
25199
- text: "",
25200
- color: event.color,
25201
- fontSize: TEXT_DEFAULT_SIZE
25202
- });
25203
- controller.content.select([newId]);
25204
- setEditor({
25205
- ...event,
25206
- id: newId,
25207
- left: event.screenRect.left - offset.left,
25208
- top: event.screenRect.top - offset.top
25209
- });
25210
- return;
25211
- }
25212
- setEditor({
25213
- ...event,
25214
- left: event.screenRect.left - offset.left,
25215
- top: event.screenRect.top - offset.top
25216
- });
25217
- }), [controller]);
25218
- const commitNote = () => {
25219
- if (editor?.kind === "note") controller.content.update(editor.id, { text: editor.text });
25220
- setEditor(null);
25221
- };
25222
- const commitText = () => {
25223
- if (editor?.kind === "text" && editor.id !== null) {
25224
- const text = editor.text;
25225
- const existing = controller.query.get(editor.id);
25226
- if (!text.trim()) controller.content.remove([editor.id]);
25227
- else if (existing?.type !== "text" || existing.text !== text) controller.content.update(editor.id, { text });
25228
- }
25229
- setEditor(null);
25230
- };
25231
- const commitTableCell = () => {
25232
- if (editor?.kind === "table-cell") controller.content.table.commitCell(editor.id, editor.row, editor.col, editor.text);
25233
- setEditor(null);
25234
- };
25235
- useEffect(() => controller.on("page-changing", () => {
25236
- if (editor?.kind === "note") commitNote();
25237
- else if (editor?.kind === "text") commitText();
25238
- else if (editor?.kind === "table-cell") commitTableCell();
25429
+ }
25430
+ function ActivityDrawer({ entries, unread, onMarkAllRead, onJump, onClose }) {
25431
+ const [now] = useState(() => Date.now());
25432
+ return /* @__PURE__ */ jsx(BoardDrawer, {
25433
+ footer: /* @__PURE__ */ jsxs("span", { children: [
25434
+ entries.length,
25435
+ " ",
25436
+ entries.length === 1 ? "entry" : "entries"
25437
+ ] }),
25438
+ icon: "activity",
25439
+ label: "Recent Activities",
25440
+ markAllDisabled: unread === 0,
25441
+ onClose,
25442
+ onMarkAllRead,
25443
+ subtitle: "Track board actions",
25444
+ children: entries.length === 0 ? /* @__PURE__ */ jsx(DrawerEmpty, {
25445
+ hint: "Changes to this board will show up here.",
25446
+ icon: "activity",
25447
+ title: "No activity yet"
25448
+ }) : /* @__PURE__ */ jsx("ol", {
25449
+ className: "scrawl-board__drawer-list scrawl-board__timeline",
25450
+ children: entries.map((entry) => {
25451
+ const time = timeLabels(entry.at, now);
25452
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
25453
+ "aria-hidden": "true",
25454
+ className: "scrawl-board__timeline-icon",
25455
+ "data-kind": entry.kind,
25456
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: KIND_ICON[entry.kind] })
25457
+ }), /* @__PURE__ */ jsxs("span", {
25458
+ className: "scrawl-board__drawer-row-text",
25459
+ children: [
25460
+ /* @__PURE__ */ jsx("strong", { children: entry.title }),
25461
+ entry.actorName && /* @__PURE__ */ jsxs("span", { children: ["by ", entry.actorName] }),
25462
+ /* @__PURE__ */ jsx("small", { children: /* @__PURE__ */ jsxs("time", {
25463
+ dateTime: new Date(entry.at).toISOString(),
25464
+ children: [
25465
+ time.relative,
25466
+ " · ",
25467
+ time.absolute
25468
+ ]
25469
+ }) })
25470
+ ]
25471
+ })] });
25472
+ return /* @__PURE__ */ jsx("li", { children: entry.location ? /* @__PURE__ */ jsx("button", {
25473
+ "aria-label": `${entry.title}${entry.actorName ? ` by ${entry.actorName}` : ""} — show on board`,
25474
+ className: "scrawl-board__drawer-row",
25475
+ type: "button",
25476
+ onClick: () => {
25477
+ onClose();
25478
+ onJump(entry.location);
25479
+ },
25480
+ children: content
25481
+ }) : /* @__PURE__ */ jsx("div", {
25482
+ className: "scrawl-board__drawer-row",
25483
+ children: content
25484
+ }) }, entry.id);
25485
+ })
25486
+ })
25487
+ });
25488
+ }
25489
+ var ROLE_LABEL = {
25490
+ viewer: "View",
25491
+ editor: "Edit",
25492
+ admin: "Admin"
25493
+ };
25494
+ var ROLES = [
25495
+ "viewer",
25496
+ "editor",
25497
+ "admin"
25498
+ ];
25499
+ function PermissionsDrawer({ permissions, onClose }) {
25500
+ const [pending, setPending] = useState({});
25501
+ const saveRef = useRef(null);
25502
+ const [status, setStatus] = useState({ kind: "idle" });
25503
+ const changes = permissions.members.filter((member) => pending[member.id] && pending[member.id] !== member.role).map((member) => ({
25504
+ memberId: member.id,
25505
+ role: pending[member.id]
25239
25506
  }));
25240
- const navigateTableCell = (dRow, dCol) => {
25241
- if (editor?.kind !== "table-cell") return;
25242
- controller.content.table.commitCell(editor.id, editor.row, editor.col, editor.text);
25243
- const table = controller.query.get(editor.id);
25244
- if (!table || table.type !== "table") {
25245
- setEditor(null);
25246
- return;
25247
- }
25248
- let nextCol = editor.col + dCol;
25249
- let nextRow = editor.row + dRow;
25250
- if (nextCol >= table.cols) {
25251
- nextCol = 0;
25252
- nextRow += 1;
25253
- } else if (nextCol < 0) {
25254
- nextCol = table.cols - 1;
25255
- nextRow -= 1;
25507
+ useEffect(() => {
25508
+ if (status.kind !== "saved") return;
25509
+ const timer = setTimeout(() => setStatus({ kind: "idle" }), 1800);
25510
+ return () => clearTimeout(timer);
25511
+ }, [status]);
25512
+ const save = async () => {
25513
+ setStatus({ kind: "saving" });
25514
+ try {
25515
+ await permissions.onSave(changes);
25516
+ setPending({});
25517
+ setStatus({ kind: "saved" });
25518
+ saveRef.current?.closest(".scrawl-board__drawer")?.focus();
25519
+ } catch (error) {
25520
+ setStatus({
25521
+ kind: "error",
25522
+ message: error instanceof Error ? error.message : "Your changes weren't saved."
25523
+ });
25256
25524
  }
25257
- if (nextRow >= table.rows) nextRow = 0;
25258
- if (nextRow < 0) nextRow = table.rows - 1;
25259
- let leftBoard = table.x;
25260
- for (let c = 0; c < nextCol; c++) leftBoard += table.colWidths[c] ?? 8;
25261
- let topBoard = table.y;
25262
- for (let r = 0; r < nextRow; r++) topBoard -= table.rowHeights[r] ?? 3.6;
25263
- const colWidth = table.colWidths[nextCol] ?? 8;
25264
- const rowHeight = table.rowHeights[nextRow] ?? 3.6;
25265
- const topLeft = controller.view.boardToScreen({
25266
- x: leftBoard,
25267
- y: topBoard
25268
- });
25269
- const bottomRight = controller.view.boardToScreen({
25270
- x: leftBoard + colWidth,
25271
- y: topBoard - rowHeight
25272
- });
25273
- const offset = rootOffset();
25274
- setEditor({
25275
- kind: "table-cell",
25276
- id: editor.id,
25277
- row: nextRow,
25278
- col: nextCol,
25279
- text: table.cells[`${nextRow},${nextCol}`] ?? "",
25280
- fontSizePx: editor.fontSizePx,
25281
- screenRect: {
25282
- left: topLeft.x,
25283
- top: topLeft.y,
25284
- width: Math.max(40, bottomRight.x - topLeft.x),
25285
- height: Math.max(24, bottomRight.y - topLeft.y)
25286
- },
25287
- left: topLeft.x - offset.left,
25288
- top: topLeft.y - offset.top
25289
- });
25290
25525
  };
25291
- const focusEditor = useCallback((el) => {
25292
- textAreaRef.current = el;
25293
- if (!el) return;
25294
- const doFocus = () => {
25295
- try {
25296
- el.focus({ preventScroll: true });
25297
- el.setSelectionRange(el.value.length, el.value.length);
25298
- } catch {}
25526
+ return /* @__PURE__ */ jsxs(BoardDrawer, {
25527
+ icon: "shield",
25528
+ label: "Pod Member Permissions",
25529
+ onClose,
25530
+ subtitle: "Manage whiteboard access",
25531
+ footer: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
25532
+ "aria-live": "polite",
25533
+ className: "scrawl-board__drawer-status",
25534
+ "data-kind": status.kind,
25535
+ children: status.kind === "saved" ? "Saved" : status.kind === "error" ? status.message : status.kind === "saving" ? "Saving…" : ""
25536
+ }), /* @__PURE__ */ jsx("button", {
25537
+ className: "scrawl-board__drawer-primary",
25538
+ ref: saveRef,
25539
+ disabled: changes.length === 0 || status.kind === "saving",
25540
+ onClick: () => void save(),
25541
+ type: "button",
25542
+ children: "Save Permissions"
25543
+ })] }),
25544
+ children: [/* @__PURE__ */ jsxs("dl", {
25545
+ className: "scrawl-board__role-legend",
25546
+ children: [
25547
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", { children: "View Only" }), /* @__PURE__ */ jsx("dd", { children: "Can see the board but not change it." })] }),
25548
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", { children: "Edit Access" }), /* @__PURE__ */ jsx("dd", { children: "Can draw, add and change content." })] }),
25549
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", { children: "Admin Access" }), /* @__PURE__ */ jsx("dd", { children: "Can edit and manage who has access." })] })
25550
+ ]
25551
+ }), /* @__PURE__ */ jsx("ul", {
25552
+ className: "scrawl-board__member-list",
25553
+ children: permissions.members.map((member) => {
25554
+ const role = pending[member.id] ?? member.role;
25555
+ const isYou = member.id === permissions.currentUserId;
25556
+ return /* @__PURE__ */ jsxs("li", {
25557
+ className: "scrawl-board__member",
25558
+ children: [
25559
+ member.avatarUrl ? /* @__PURE__ */ jsx("img", {
25560
+ alt: "",
25561
+ className: "scrawl-board__member-avatar",
25562
+ src: member.avatarUrl
25563
+ }) : /* @__PURE__ */ jsx("span", {
25564
+ "aria-hidden": "true",
25565
+ className: "scrawl-board__member-avatar",
25566
+ children: initials(member.name)
25567
+ }),
25568
+ /* @__PURE__ */ jsxs("div", {
25569
+ className: "scrawl-board__member-info",
25570
+ children: [/* @__PURE__ */ jsxs("p", { children: [
25571
+ /* @__PURE__ */ jsx("strong", { children: member.name }),
25572
+ isYou && /* @__PURE__ */ jsx("span", {
25573
+ className: "scrawl-board__member-you",
25574
+ children: " (you)"
25575
+ }),
25576
+ member.online !== void 0 && /* @__PURE__ */ jsx("span", {
25577
+ className: "scrawl-board__member-status",
25578
+ "data-online": member.online,
25579
+ children: member.online ? "Online" : "Offline"
25580
+ })
25581
+ ] }), member.email && /* @__PURE__ */ jsx("p", {
25582
+ className: "scrawl-board__member-email",
25583
+ children: member.email
25584
+ })]
25585
+ }),
25586
+ /* @__PURE__ */ jsx("div", {
25587
+ "aria-label": `Access for ${member.name}`,
25588
+ className: "scrawl-board__segmented",
25589
+ role: "radiogroup",
25590
+ children: ROLES.map((option) => /* @__PURE__ */ jsx("button", {
25591
+ "aria-checked": role === option,
25592
+ disabled: isYou,
25593
+ role: "radio",
25594
+ type: "button",
25595
+ onClick: () => {
25596
+ setStatus({ kind: "idle" });
25597
+ setPending((current) => ({
25598
+ ...current,
25599
+ [member.id]: option
25600
+ }));
25601
+ },
25602
+ children: ROLE_LABEL[option]
25603
+ }, option))
25604
+ })
25605
+ ]
25606
+ }, member.id);
25607
+ })
25608
+ })]
25609
+ });
25610
+ }
25611
+ function initials(name) {
25612
+ return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0].toUpperCase()).join("");
25613
+ }
25614
+ /** The ⋮ overflow menu in the board actions. */
25615
+ function BoardMoreMenu({ items, onSelect, onClose }) {
25616
+ const ref = useRef(null);
25617
+ const closeRef = useRef(onClose);
25618
+ useEffect(() => {
25619
+ closeRef.current = onClose;
25620
+ });
25621
+ useEffect(() => {
25622
+ ref.current?.querySelector("button")?.focus({ preventScroll: true });
25623
+ const outside = (event) => {
25624
+ const target = event.target;
25625
+ if (!ref.current?.contains(target) && !target.closest?.("[aria-label=\"More board actions\"]")) closeRef.current();
25299
25626
  };
25300
- doFocus();
25301
- requestAnimationFrame(doFocus);
25627
+ document.addEventListener("pointerdown", outside);
25628
+ return () => document.removeEventListener("pointerdown", outside);
25302
25629
  }, []);
25303
- if (!editor) return /* @__PURE__ */ jsx("div", {
25304
- ref: anchorRef,
25305
- style: { display: "none" }
25306
- });
25307
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
25308
- ref: anchorRef,
25309
- style: { display: "none" }
25310
- }), renderPortal(editor.kind === "note" ? /* @__PURE__ */ jsx("textarea", {
25311
- "aria-label": "Edit note",
25312
- autoFocus: true,
25313
- className: "scrawl-board__inline-editor scrawl-board__inline-editor--note",
25314
- onBlur: commitNote,
25315
- onChange: (event) => setEditor({
25316
- ...editor,
25317
- text: event.currentTarget.value
25318
- }),
25319
- onKeyDown: (event) => {
25320
- event.stopPropagation();
25321
- if (event.key === "Escape") commitNote();
25322
- if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) commitNote();
25323
- },
25324
- ref: focusEditor,
25325
- style: {
25326
- left: editor.left,
25327
- top: editor.top,
25328
- width: editor.screenRect.width,
25329
- height: editor.screenRect.height,
25330
- backgroundColor: editor.color,
25331
- fontSize: Math.max(12, editor.screenRect.width * 42 / 512),
25332
- lineHeight: 1.3,
25333
- padding: editor.screenRect.width * 40 / 512
25334
- },
25335
- value: editor.text
25336
- }) : editor.kind === "text" ? /* @__PURE__ */ jsx("textarea", {
25337
- "aria-label": "Edit text",
25338
- autoFocus: true,
25339
- className: "scrawl-board__inline-editor scrawl-board__inline-editor--text",
25340
- onBlur: commitText,
25341
- onChange: (event) => setEditor({
25342
- ...editor,
25343
- text: event.currentTarget.value
25344
- }),
25630
+ return /* @__PURE__ */ jsx("div", {
25631
+ "aria-label": "More board actions",
25632
+ className: "scrawl-board__more-menu",
25633
+ ref,
25634
+ role: "menu",
25345
25635
  onKeyDown: (event) => {
25346
25636
  event.stopPropagation();
25347
- if (event.key === "Escape") commitText();
25348
- if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) commitText();
25349
- },
25350
- ref: focusEditor,
25351
- rows: Math.max(1, editor.text.split("\n").length),
25352
- style: {
25353
- left: editor.left,
25354
- top: editor.top,
25355
- color: editor.color,
25356
- fontSize: Math.max(12, editor.fontSizePx)
25357
- },
25358
- value: editor.text
25359
- }) : /* @__PURE__ */ jsxs("div", {
25360
- className: "scrawl-board__inline-editor--table-cell",
25361
- style: {
25362
- left: editor.left,
25363
- top: editor.top,
25364
- width: editor.screenRect.width,
25365
- minHeight: editor.screenRect.height
25637
+ if (event.key === "Escape") {
25638
+ event.preventDefault();
25639
+ onClose();
25640
+ }
25641
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
25642
+ event.preventDefault();
25643
+ const buttons = [...ref.current?.querySelectorAll("button") ?? []];
25644
+ buttons[(buttons.indexOf(document.activeElement) + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus();
25645
+ }
25366
25646
  },
25367
- children: [/* @__PURE__ */ jsx("textarea", {
25368
- "aria-label": `Edit table cell ${editor.row + 1}, ${editor.col + 1}`,
25369
- autoFocus: true,
25370
- className: "scrawl-board__inline-editor",
25371
- onBlur: commitTableCell,
25372
- onChange: (event) => setEditor({
25373
- ...editor,
25374
- text: event.currentTarget.value
25375
- }),
25376
- onKeyDown: (event) => {
25377
- event.stopPropagation();
25378
- if (event.key === "Tab") {
25379
- event.preventDefault();
25380
- navigateTableCell(0, event.shiftKey ? -1 : 1);
25381
- } else if (event.key === "Enter" && !event.shiftKey) {
25382
- event.preventDefault();
25383
- navigateTableCell(1, 0);
25384
- } else if (event.key === "Escape") commitTableCell();
25647
+ children: items.map((item) => /* @__PURE__ */ jsxs("button", {
25648
+ onClick: () => {
25649
+ onClose();
25650
+ onSelect(item.id);
25385
25651
  },
25386
- ref: focusEditor,
25387
- style: { fontSize: Math.max(11, editor.fontSizePx) },
25388
- value: editor.text
25389
- }), /* @__PURE__ */ jsxs("div", {
25390
- className: "scrawl-board__inline-editor-actions",
25391
- children: [
25392
- /* @__PURE__ */ jsxs("span", { children: [
25393
- "Cell (",
25394
- editor.row + 1,
25395
- ", ",
25396
- editor.col + 1,
25397
- ")"
25398
- ] }),
25399
- /* @__PURE__ */ jsx("button", {
25400
- "aria-label": "Add row",
25401
- onMouseDown: (event) => {
25402
- event.preventDefault();
25403
- controller.content.table.addRow(editor.id);
25404
- },
25405
- "data-tooltip": "Add row",
25406
- type: "button",
25407
- children: /* @__PURE__ */ jsx(ToolIcon, { tool: "addRow" })
25408
- }),
25409
- /* @__PURE__ */ jsx("button", {
25410
- "aria-label": "Add column",
25411
- onMouseDown: (event) => {
25412
- event.preventDefault();
25413
- controller.content.table.addCol(editor.id);
25414
- },
25415
- "data-tooltip": "Add column",
25416
- type: "button",
25417
- children: /* @__PURE__ */ jsx(ToolIcon, { tool: "addColumn" })
25418
- }),
25419
- /* @__PURE__ */ jsx("button", {
25420
- "aria-label": "Delete row",
25421
- onMouseDown: (event) => {
25422
- event.preventDefault();
25423
- controller.content.table.deleteRow(editor.id, editor.row);
25424
- setEditor(null);
25425
- },
25426
- "data-tooltip": "Delete row",
25427
- type: "button",
25428
- children: /* @__PURE__ */ jsx(ToolIcon, { tool: "delete" })
25429
- })
25430
- ]
25431
- })]
25432
- }))] });
25652
+ role: "menuitem",
25653
+ type: "button",
25654
+ children: [item.icon ?? /* @__PURE__ */ jsx("span", {
25655
+ "aria-hidden": "true",
25656
+ className: "scrawl-board__menu-icon-space"
25657
+ }), /* @__PURE__ */ jsx("span", { children: item.label })]
25658
+ }, item.id))
25659
+ });
25660
+ }
25661
+ /** A persistent pin where the last activity jump landed, with a pulse on arrival. */
25662
+ function JumpPin({ controller, location, arrival }) {
25663
+ const ref = useRef(null);
25664
+ useLayoutEffect(() => {
25665
+ const place = () => {
25666
+ const pin = ref.current;
25667
+ if (!pin) return;
25668
+ const root = pin.closest("[data-scrawl-root]")?.getBoundingClientRect();
25669
+ const screen = controller.view.boardToScreen(location);
25670
+ pin.style.left = `${screen.x - (root?.left ?? 0)}px`;
25671
+ pin.style.top = `${screen.y - (root?.top ?? 0)}px`;
25672
+ };
25673
+ place();
25674
+ return controller.on("view-change", place);
25675
+ }, [controller, location]);
25676
+ return /* @__PURE__ */ jsx("span", {
25677
+ "aria-hidden": "true",
25678
+ className: "scrawl-board__jump-pin",
25679
+ ref,
25680
+ children: /* @__PURE__ */ jsx("span", { className: "scrawl-board__jump-pulse" }, arrival)
25681
+ });
25433
25682
  }
25434
25683
  //#endregion
25435
25684
  //#region src/ui/toolbar/compact-picker.tsx
@@ -25570,9 +25819,11 @@ var HANDLE_CORNERS = [
25570
25819
  * Floating toolbar above the focused note, shape, text block, or semantic
25571
25820
  * Rectangle/Ellipse — Colour, Size (note/text) or Width (shape), Fill/Stroke
25572
25821
  * (Rectangle/Ellipse), Lock/Unlock, Duplicate, Delete. Images (photos and
25573
- * placed emoji/icons) get Lock/Duplicate/Delete only — the native gizmo
25574
- * already resizes them. Table/timer/custom objects and plain (non-shape) ink
25575
- * strokes never get a toolbar here — out of scope for this destination.
25822
+ * placed emoji/icons) and tables get Lock/Duplicate/Delete only — each
25823
+ * already has its own resize affordance (the native gizmo for images, edge
25824
+ * handles for tables), and a table's row/column actions live in its cell
25825
+ * editor. Timer/custom objects and plain (non-shape) ink strokes never get a
25826
+ * toolbar here — out of scope for this destination.
25576
25827
  *
25577
25828
  * Rectangle/Ellipse (Phase 2) are a second, parallel "shape" concept from
25578
25829
  * the legacy ink-stroke shape below: both draw from the same toolbar
@@ -25636,7 +25887,8 @@ function FocusedItemToolbar({ controller, snapshot }) {
25636
25887
  locked: focused.locked,
25637
25888
  anchor: focused.screenPosition
25638
25889
  }, obj.id);
25639
- if (obj.type === "image") return /* @__PURE__ */ jsx(ToolbarShell, {
25890
+ if (obj.type === "table" && snapshot.inlineEditing?.id === obj.id) return null;
25891
+ if (obj.type === "image" || obj.type === "table") return /* @__PURE__ */ jsx(ToolbarShell, {
25640
25892
  anchor: focused.screenPosition,
25641
25893
  children: /* @__PURE__ */ jsx(LockDuplicateDelete, {
25642
25894
  controller,
@@ -25657,8 +25909,8 @@ function ToolbarShell({ anchor, children }) {
25657
25909
  const board = root?.getBoundingClientRect();
25658
25910
  const left = Math.max(8, board?.left ?? 0);
25659
25911
  const right = Math.min(window.innerWidth, board?.right ?? window.innerWidth) - 8;
25660
- const header = root?.querySelector(".scrawl-board__header")?.getBoundingClientRect();
25661
- const top = Math.max(8, header ? header.bottom + 8 : board?.top ?? 0);
25912
+ const navigator = root?.querySelector(".scrawl-board__breadcrumb ol")?.getBoundingClientRect();
25913
+ const top = Math.max(8, navigator ? navigator.bottom + 8 : board?.top ?? 0);
25662
25914
  toolbar.style.left = `${Math.max(left, Math.min(anchor.x - toolbar.offsetWidth / 2, right - toolbar.offsetWidth))}px`;
25663
25915
  toolbar.style.top = `${Math.max(top, anchor.y - toolbar.offsetHeight - 12)}px`;
25664
25916
  };
@@ -26017,140 +26269,451 @@ function ShapeObjectToolbar({ controller, snapshot, shape, locked, anchor }) {
26017
26269
  /* @__PURE__ */ jsxs(ToolbarShell, {
26018
26270
  anchor,
26019
26271
  children: [
26020
- /* @__PURE__ */ jsx(ColorPicker, {
26021
- label: "Shape fill",
26022
- color: shape.fill,
26023
- colors: INK_COLORS,
26024
- disabled: locked_,
26025
- itemLabel: "fill",
26026
- allowNone: true,
26027
- onChange: (value) => controller.content.update(shape.id, { fill: value })
26272
+ /* @__PURE__ */ jsx(ColorPicker, {
26273
+ label: "Shape fill",
26274
+ color: shape.fill,
26275
+ colors: INK_COLORS,
26276
+ disabled: locked_,
26277
+ itemLabel: "fill",
26278
+ allowNone: true,
26279
+ onChange: (value) => controller.content.update(shape.id, { fill: value })
26280
+ }),
26281
+ /* @__PURE__ */ jsx(ColorPicker, {
26282
+ label: "Shape stroke",
26283
+ color: shape.stroke,
26284
+ colors: INK_COLORS,
26285
+ disabled: locked_,
26286
+ itemLabel: "stroke",
26287
+ onChange: (value) => controller.content.update(shape.id, { stroke: value })
26288
+ }),
26289
+ /* @__PURE__ */ jsx(LockDuplicateDelete, {
26290
+ controller,
26291
+ mutable,
26292
+ locked,
26293
+ ids: [shape.id]
26294
+ })
26295
+ ]
26296
+ })
26297
+ ] });
26298
+ }
26299
+ /** One corner: drags the board point directly under the pointer, keeping the opposite corner fixed — independent width/height, no proportional lock. */
26300
+ function ShapeResizeHandle({ controller, shape, corner, x, y }) {
26301
+ const [dragging, setDragging] = useState(false);
26302
+ const fixedCorner = () => {
26303
+ return {
26304
+ x: corner === "nw" || corner === "sw" ? shape.x + shape.width : shape.x,
26305
+ y: corner === "nw" || corner === "ne" ? shape.y - shape.height : shape.y
26306
+ };
26307
+ };
26308
+ const resizeTo = (boardX, boardY) => {
26309
+ const fixed = fixedCorner();
26310
+ const width = Math.max(SHAPE_MIN_SIZE, Math.abs(boardX - fixed.x));
26311
+ const height = Math.max(SHAPE_MIN_SIZE, Math.abs(boardY - fixed.y));
26312
+ const nextX = Math.min(boardX, fixed.x);
26313
+ const nextY = Math.max(boardY, fixed.y);
26314
+ controller.content.update(shape.id, {
26315
+ x: nextX,
26316
+ y: nextY,
26317
+ width,
26318
+ height
26319
+ });
26320
+ };
26321
+ const nudge = (dx, dy) => {
26322
+ resizeTo((corner === "ne" || corner === "se" ? shape.x + shape.width : shape.x) + dx, (corner === "nw" || corner === "ne" ? shape.y : shape.y - shape.height) + dy);
26323
+ };
26324
+ return /* @__PURE__ */ jsx("span", {
26325
+ "aria-label": `Resize shape (${corner})`,
26326
+ role: "slider",
26327
+ "aria-valuemin": SHAPE_MIN_SIZE,
26328
+ "aria-valuemax": 9999,
26329
+ "aria-valuenow": Math.round(shape.width),
26330
+ tabIndex: 0,
26331
+ className: "scrawl-board__focus-handle",
26332
+ "data-orientation": corner === "nw" || corner === "se" ? "nwse" : "nesw",
26333
+ style: {
26334
+ left: x,
26335
+ top: y
26336
+ },
26337
+ onKeyDown: (event) => {
26338
+ if (event.key === "ArrowUp") {
26339
+ event.preventDefault();
26340
+ nudge(0, .5);
26341
+ } else if (event.key === "ArrowDown") {
26342
+ event.preventDefault();
26343
+ nudge(0, -.5);
26344
+ } else if (event.key === "ArrowRight") {
26345
+ event.preventDefault();
26346
+ nudge(.5, 0);
26347
+ } else if (event.key === "ArrowLeft") {
26348
+ event.preventDefault();
26349
+ nudge(-.5, 0);
26350
+ }
26351
+ },
26352
+ onPointerDown: (event) => {
26353
+ event.stopPropagation();
26354
+ event.currentTarget.setPointerCapture(event.pointerId);
26355
+ setDragging(true);
26356
+ },
26357
+ onPointerMove: (event) => {
26358
+ if (!dragging) return;
26359
+ const board = controller.view.screenToBoard({
26360
+ x: event.clientX,
26361
+ y: event.clientY
26362
+ });
26363
+ resizeTo(board.x, board.y);
26364
+ },
26365
+ onPointerUp: (event) => {
26366
+ event.currentTarget.releasePointerCapture(event.pointerId);
26367
+ setDragging(false);
26368
+ }
26369
+ });
26370
+ }
26371
+ function LockDuplicateDelete({ controller, mutable, locked, ids }) {
26372
+ return /* @__PURE__ */ jsxs("div", {
26373
+ className: "scrawl-board__style-group",
26374
+ children: [
26375
+ /* @__PURE__ */ jsx("button", {
26376
+ "aria-label": locked ? "Unlock" : "Lock",
26377
+ disabled: !mutable,
26378
+ onClick: () => {
26379
+ for (const id of ids) controller.content.update(id, locked ? { locked: false } : { locked: true });
26380
+ },
26381
+ "data-tooltip": locked ? "Unlock" : "Lock",
26382
+ type: "button",
26383
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: locked ? "lock" : "unlock" })
26384
+ }),
26385
+ /* @__PURE__ */ jsx("button", {
26386
+ "aria-label": "Duplicate",
26387
+ disabled: !mutable || locked,
26388
+ onClick: () => {
26389
+ const newIds = controller.content.duplicate(ids);
26390
+ if (newIds.length) controller.content.select(newIds);
26391
+ },
26392
+ "data-tooltip": "Duplicate",
26393
+ type: "button",
26394
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: "duplicate" })
26395
+ }),
26396
+ /* @__PURE__ */ jsx("button", {
26397
+ "aria-label": "Delete",
26398
+ disabled: !mutable || locked,
26399
+ onClick: () => controller.content.remove(ids),
26400
+ "data-tooltip": "Delete",
26401
+ type: "button",
26402
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: "delete" })
26403
+ })
26404
+ ]
26405
+ });
26406
+ }
26407
+ //#endregion
26408
+ //#region src/ui/dialogs/inline-editing.tsx
26409
+ /** Inline note, text, and table-cell editors — driven entirely by public controller events and commands. */
26410
+ function InlineEditors({ controller, renderPortal }) {
26411
+ const [editor, setEditor] = useState(null);
26412
+ const [snapshot, setSnapshot] = useState(() => controller.getSnapshot());
26413
+ useEffect(() => controller.subscribe(() => setSnapshot(controller.getSnapshot())), [controller]);
26414
+ const mutable = snapshot.status === "ready" && !snapshot.readOnly;
26415
+ const editedTable = editor?.kind === "table-cell" ? controller.query.get(editor.id) : void 0;
26416
+ const locked = editedTable?.type === "table" && Boolean(editedTable.locked);
26417
+ const anchorRef = useRef(null);
26418
+ const textAreaRef = useRef(null);
26419
+ const measureRef = useRef(null);
26420
+ const coveredRow = editor?.kind === "table-cell" ? editor.row : void 0;
26421
+ const coveredCol = editor?.kind === "table-cell" ? editor.col : void 0;
26422
+ const coveredId = editor && editor.kind !== "note" ? editor.id : null;
26423
+ useEffect(() => {
26424
+ if (!coveredId) return;
26425
+ controller.view.setInlineEditing(coveredRow === void 0 ? { id: coveredId } : {
26426
+ id: coveredId,
26427
+ row: coveredRow,
26428
+ col: coveredCol
26429
+ });
26430
+ return () => {
26431
+ if (controller.getSnapshot().status !== "disposed") controller.view.setInlineEditing(null);
26432
+ };
26433
+ }, [
26434
+ controller,
26435
+ coveredId,
26436
+ coveredRow,
26437
+ coveredCol
26438
+ ]);
26439
+ const textValue = editor?.kind === "text" ? editor.text : null;
26440
+ useLayoutEffect(() => {
26441
+ const el = textAreaRef.current;
26442
+ if (textValue === null || !el) return;
26443
+ const cs = getComputedStyle(el);
26444
+ measureRef.current ??= document.createElement("canvas").getContext("2d");
26445
+ const measure = measureRef.current;
26446
+ if (measure) {
26447
+ measure.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
26448
+ const widest = Math.max(...textValue.split("\n").map((line) => measure.measureText(line || " ").width));
26449
+ const chrome = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) + parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth);
26450
+ const cap = Math.min(window.innerWidth * .6, 640);
26451
+ el.style.width = `${Math.ceil(Math.min(cap, widest + chrome + 4))}px`;
26452
+ }
26453
+ el.style.height = "auto";
26454
+ el.style.height = `${el.scrollHeight + parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)}px`;
26455
+ }, [textValue, editor?.kind === "text" ? editor.fontSizePx : null]);
26456
+ /** Portal children are positioned relative to the Board root, not the viewport. */
26457
+ const rootOffset = () => {
26458
+ const rootRect = (anchorRef.current?.closest("[data-scrawl-root]"))?.getBoundingClientRect();
26459
+ return {
26460
+ left: rootRect?.left ?? 0,
26461
+ top: rootRect?.top ?? 0
26462
+ };
26463
+ };
26464
+ useEffect(() => controller.on("edit-request", (event) => {
26465
+ const offset = rootOffset();
26466
+ if (event.kind === "text" && event.id === null) {
26467
+ const newId = controller.content.add({
26468
+ type: "text",
26469
+ x: event.boardX,
26470
+ y: event.boardY,
26471
+ text: "",
26472
+ color: event.color,
26473
+ fontSize: TEXT_DEFAULT_SIZE
26474
+ });
26475
+ controller.content.select([newId]);
26476
+ setEditor({
26477
+ ...event,
26478
+ id: newId,
26479
+ left: event.screenRect.left - offset.left,
26480
+ top: event.screenRect.top - offset.top
26481
+ });
26482
+ return;
26483
+ }
26484
+ setEditor({
26485
+ ...event,
26486
+ left: event.screenRect.left - offset.left,
26487
+ top: event.screenRect.top - offset.top
26488
+ });
26489
+ }), [controller]);
26490
+ const commitNote = () => {
26491
+ if (editor?.kind === "note") controller.content.update(editor.id, { text: editor.text });
26492
+ setEditor(null);
26493
+ };
26494
+ const commitText = () => {
26495
+ if (editor?.kind === "text" && editor.id !== null) {
26496
+ const text = editor.text;
26497
+ const existing = controller.query.get(editor.id);
26498
+ if (!text.trim()) controller.content.remove([editor.id]);
26499
+ else if (existing?.type !== "text" || existing.text !== text) controller.content.update(editor.id, { text });
26500
+ }
26501
+ setEditor(null);
26502
+ };
26503
+ const commitTableCell = () => {
26504
+ if (editor?.kind === "table-cell") controller.content.table.commitCell(editor.id, editor.row, editor.col, editor.text);
26505
+ setEditor(null);
26506
+ };
26507
+ useEffect(() => controller.on("page-changing", () => {
26508
+ if (editor?.kind === "note") commitNote();
26509
+ else if (editor?.kind === "text") commitText();
26510
+ else if (editor?.kind === "table-cell") commitTableCell();
26511
+ }));
26512
+ const navigateTableCell = (dRow, dCol) => {
26513
+ if (editor?.kind !== "table-cell") return;
26514
+ controller.content.table.commitCell(editor.id, editor.row, editor.col, editor.text);
26515
+ const table = controller.query.get(editor.id);
26516
+ if (!table || table.type !== "table") {
26517
+ setEditor(null);
26518
+ return;
26519
+ }
26520
+ let nextCol = editor.col + dCol;
26521
+ let nextRow = editor.row + dRow;
26522
+ if (nextCol >= table.cols) {
26523
+ nextCol = 0;
26524
+ nextRow += 1;
26525
+ } else if (nextCol < 0) {
26526
+ nextCol = table.cols - 1;
26527
+ nextRow -= 1;
26528
+ }
26529
+ if (nextRow >= table.rows) nextRow = 0;
26530
+ if (nextRow < 0) nextRow = table.rows - 1;
26531
+ let leftBoard = table.x;
26532
+ for (let c = 0; c < nextCol; c++) leftBoard += table.colWidths[c] ?? 8;
26533
+ let topBoard = table.y;
26534
+ for (let r = 0; r < nextRow; r++) topBoard -= table.rowHeights[r] ?? 3.6;
26535
+ const colWidth = table.colWidths[nextCol] ?? 8;
26536
+ const rowHeight = table.rowHeights[nextRow] ?? 3.6;
26537
+ const topLeft = controller.view.boardToScreen({
26538
+ x: leftBoard,
26539
+ y: topBoard
26540
+ });
26541
+ const bottomRight = controller.view.boardToScreen({
26542
+ x: leftBoard + colWidth,
26543
+ y: topBoard - rowHeight
26544
+ });
26545
+ const offset = rootOffset();
26546
+ setEditor({
26547
+ kind: "table-cell",
26548
+ id: editor.id,
26549
+ row: nextRow,
26550
+ col: nextCol,
26551
+ text: table.cells[`${nextRow},${nextCol}`] ?? "",
26552
+ fontSizePx: editor.fontSizePx,
26553
+ screenRect: {
26554
+ left: topLeft.x,
26555
+ top: topLeft.y,
26556
+ width: Math.max(40, bottomRight.x - topLeft.x),
26557
+ height: Math.max(24, bottomRight.y - topLeft.y)
26558
+ },
26559
+ left: topLeft.x - offset.left,
26560
+ top: topLeft.y - offset.top
26561
+ });
26562
+ };
26563
+ const focusEditor = useCallback((el) => {
26564
+ textAreaRef.current = el;
26565
+ if (!el) return;
26566
+ const doFocus = () => {
26567
+ try {
26568
+ el.focus({ preventScroll: true });
26569
+ el.setSelectionRange(el.value.length, el.value.length);
26570
+ } catch {}
26571
+ };
26572
+ doFocus();
26573
+ requestAnimationFrame(doFocus);
26574
+ }, []);
26575
+ if (!editor) return /* @__PURE__ */ jsx("div", {
26576
+ ref: anchorRef,
26577
+ style: { display: "none" }
26578
+ });
26579
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
26580
+ ref: anchorRef,
26581
+ style: { display: "none" }
26582
+ }), renderPortal(editor.kind === "note" ? /* @__PURE__ */ jsx("textarea", {
26583
+ "aria-label": "Edit note",
26584
+ autoFocus: true,
26585
+ className: "scrawl-board__inline-editor scrawl-board__inline-editor--note",
26586
+ onBlur: commitNote,
26587
+ onChange: (event) => setEditor({
26588
+ ...editor,
26589
+ text: event.currentTarget.value
26590
+ }),
26591
+ onKeyDown: (event) => {
26592
+ event.stopPropagation();
26593
+ if (event.key === "Escape") commitNote();
26594
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) commitNote();
26595
+ },
26596
+ ref: focusEditor,
26597
+ style: {
26598
+ left: editor.left,
26599
+ top: editor.top,
26600
+ width: editor.screenRect.width,
26601
+ height: editor.screenRect.height,
26602
+ backgroundColor: editor.color,
26603
+ fontSize: Math.max(12, editor.screenRect.width * 42 / 512),
26604
+ lineHeight: 1.3,
26605
+ padding: editor.screenRect.width * 40 / 512
26606
+ },
26607
+ value: editor.text
26608
+ }) : editor.kind === "text" ? /* @__PURE__ */ jsx("textarea", {
26609
+ "aria-label": "Edit text",
26610
+ autoFocus: true,
26611
+ className: "scrawl-board__inline-editor scrawl-board__inline-editor--text",
26612
+ onBlur: commitText,
26613
+ onChange: (event) => setEditor({
26614
+ ...editor,
26615
+ text: event.currentTarget.value
26616
+ }),
26617
+ onKeyDown: (event) => {
26618
+ event.stopPropagation();
26619
+ if (event.key === "Escape") commitText();
26620
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) commitText();
26621
+ },
26622
+ ref: focusEditor,
26623
+ rows: Math.max(1, editor.text.split("\n").length),
26624
+ style: {
26625
+ left: editor.left,
26626
+ top: editor.top,
26627
+ color: editor.color,
26628
+ fontSize: Math.max(12, editor.fontSizePx)
26629
+ },
26630
+ value: editor.text
26631
+ }) : /* @__PURE__ */ jsxs("div", {
26632
+ className: "scrawl-board__inline-editor--table-cell",
26633
+ style: {
26634
+ left: editor.left,
26635
+ top: editor.top,
26636
+ width: editor.screenRect.width,
26637
+ minHeight: editor.screenRect.height
26638
+ },
26639
+ children: [/* @__PURE__ */ jsx("textarea", {
26640
+ "aria-label": `Edit table cell ${editor.row + 1}, ${editor.col + 1}`,
26641
+ autoFocus: true,
26642
+ className: "scrawl-board__inline-editor",
26643
+ onBlur: commitTableCell,
26644
+ onChange: (event) => setEditor({
26645
+ ...editor,
26646
+ text: event.currentTarget.value
26647
+ }),
26648
+ onKeyDown: (event) => {
26649
+ event.stopPropagation();
26650
+ if (event.key === "Tab") {
26651
+ event.preventDefault();
26652
+ navigateTableCell(0, event.shiftKey ? -1 : 1);
26653
+ } else if (event.key === "Enter" && !event.shiftKey) {
26654
+ event.preventDefault();
26655
+ navigateTableCell(1, 0);
26656
+ } else if (event.key === "Escape") commitTableCell();
26657
+ },
26658
+ ref: focusEditor,
26659
+ style: { fontSize: Math.max(11, editor.fontSizePx) },
26660
+ value: editor.text
26661
+ }), /* @__PURE__ */ jsxs("div", {
26662
+ "aria-label": "Selected object",
26663
+ className: "scrawl-board__inline-editor-actions",
26664
+ role: "toolbar",
26665
+ children: [
26666
+ /* @__PURE__ */ jsxs("span", { children: [
26667
+ "Cell (",
26668
+ editor.row + 1,
26669
+ ", ",
26670
+ editor.col + 1,
26671
+ ")"
26672
+ ] }),
26673
+ /* @__PURE__ */ jsx("button", {
26674
+ "aria-label": "Add row",
26675
+ onMouseDown: (event) => {
26676
+ event.preventDefault();
26677
+ controller.content.table.addRow(editor.id);
26678
+ },
26679
+ "data-tooltip": "Add row",
26680
+ type: "button",
26681
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: "addRow" })
26028
26682
  }),
26029
- /* @__PURE__ */ jsx(ColorPicker, {
26030
- label: "Shape stroke",
26031
- color: shape.stroke,
26032
- colors: INK_COLORS,
26033
- disabled: locked_,
26034
- itemLabel: "stroke",
26035
- onChange: (value) => controller.content.update(shape.id, { stroke: value })
26683
+ /* @__PURE__ */ jsx("button", {
26684
+ "aria-label": "Add column",
26685
+ onMouseDown: (event) => {
26686
+ event.preventDefault();
26687
+ controller.content.table.addCol(editor.id);
26688
+ },
26689
+ "data-tooltip": "Add column",
26690
+ type: "button",
26691
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: "addColumn" })
26692
+ }),
26693
+ /* @__PURE__ */ jsx("button", {
26694
+ "aria-label": "Delete row",
26695
+ onMouseDown: (event) => {
26696
+ event.preventDefault();
26697
+ controller.content.table.deleteRow(editor.id, editor.row);
26698
+ setEditor(null);
26699
+ },
26700
+ "data-tooltip": "Delete row",
26701
+ type: "button",
26702
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: "deleteRow" })
26703
+ }),
26704
+ /* @__PURE__ */ jsx("span", {
26705
+ "aria-hidden": "true",
26706
+ className: "scrawl-board__toolbar-divider"
26036
26707
  }),
26037
26708
  /* @__PURE__ */ jsx(LockDuplicateDelete, {
26038
26709
  controller,
26039
- mutable,
26710
+ ids: [editor.id],
26040
26711
  locked,
26041
- ids: [shape.id]
26712
+ mutable
26042
26713
  })
26043
26714
  ]
26044
- })
26045
- ] });
26046
- }
26047
- /** One corner: drags the board point directly under the pointer, keeping the opposite corner fixed — independent width/height, no proportional lock. */
26048
- function ShapeResizeHandle({ controller, shape, corner, x, y }) {
26049
- const [dragging, setDragging] = useState(false);
26050
- const fixedCorner = () => {
26051
- return {
26052
- x: corner === "nw" || corner === "sw" ? shape.x + shape.width : shape.x,
26053
- y: corner === "nw" || corner === "ne" ? shape.y - shape.height : shape.y
26054
- };
26055
- };
26056
- const resizeTo = (boardX, boardY) => {
26057
- const fixed = fixedCorner();
26058
- const width = Math.max(SHAPE_MIN_SIZE, Math.abs(boardX - fixed.x));
26059
- const height = Math.max(SHAPE_MIN_SIZE, Math.abs(boardY - fixed.y));
26060
- const nextX = Math.min(boardX, fixed.x);
26061
- const nextY = Math.max(boardY, fixed.y);
26062
- controller.content.update(shape.id, {
26063
- x: nextX,
26064
- y: nextY,
26065
- width,
26066
- height
26067
- });
26068
- };
26069
- const nudge = (dx, dy) => {
26070
- resizeTo((corner === "ne" || corner === "se" ? shape.x + shape.width : shape.x) + dx, (corner === "nw" || corner === "ne" ? shape.y : shape.y - shape.height) + dy);
26071
- };
26072
- return /* @__PURE__ */ jsx("span", {
26073
- "aria-label": `Resize shape (${corner})`,
26074
- role: "slider",
26075
- "aria-valuemin": SHAPE_MIN_SIZE,
26076
- "aria-valuemax": 9999,
26077
- "aria-valuenow": Math.round(shape.width),
26078
- tabIndex: 0,
26079
- className: "scrawl-board__focus-handle",
26080
- "data-orientation": corner === "nw" || corner === "se" ? "nwse" : "nesw",
26081
- style: {
26082
- left: x,
26083
- top: y
26084
- },
26085
- onKeyDown: (event) => {
26086
- if (event.key === "ArrowUp") {
26087
- event.preventDefault();
26088
- nudge(0, .5);
26089
- } else if (event.key === "ArrowDown") {
26090
- event.preventDefault();
26091
- nudge(0, -.5);
26092
- } else if (event.key === "ArrowRight") {
26093
- event.preventDefault();
26094
- nudge(.5, 0);
26095
- } else if (event.key === "ArrowLeft") {
26096
- event.preventDefault();
26097
- nudge(-.5, 0);
26098
- }
26099
- },
26100
- onPointerDown: (event) => {
26101
- event.stopPropagation();
26102
- event.currentTarget.setPointerCapture(event.pointerId);
26103
- setDragging(true);
26104
- },
26105
- onPointerMove: (event) => {
26106
- if (!dragging) return;
26107
- const board = controller.view.screenToBoard({
26108
- x: event.clientX,
26109
- y: event.clientY
26110
- });
26111
- resizeTo(board.x, board.y);
26112
- },
26113
- onPointerUp: (event) => {
26114
- event.currentTarget.releasePointerCapture(event.pointerId);
26115
- setDragging(false);
26116
- }
26117
- });
26118
- }
26119
- function LockDuplicateDelete({ controller, mutable, locked, ids }) {
26120
- return /* @__PURE__ */ jsxs("div", {
26121
- className: "scrawl-board__style-group",
26122
- children: [
26123
- /* @__PURE__ */ jsx("button", {
26124
- "aria-label": locked ? "Unlock" : "Lock",
26125
- disabled: !mutable,
26126
- onClick: () => {
26127
- for (const id of ids) controller.content.update(id, locked ? { locked: false } : { locked: true });
26128
- },
26129
- "data-tooltip": locked ? "Unlock" : "Lock",
26130
- type: "button",
26131
- children: /* @__PURE__ */ jsx(ToolIcon, { tool: locked ? "lock" : "unlock" })
26132
- }),
26133
- /* @__PURE__ */ jsx("button", {
26134
- "aria-label": "Duplicate",
26135
- disabled: !mutable || locked,
26136
- onClick: () => {
26137
- const newIds = controller.content.duplicate(ids);
26138
- if (newIds.length) controller.content.select(newIds);
26139
- },
26140
- "data-tooltip": "Duplicate",
26141
- type: "button",
26142
- children: /* @__PURE__ */ jsx(ToolIcon, { tool: "duplicate" })
26143
- }),
26144
- /* @__PURE__ */ jsx("button", {
26145
- "aria-label": "Delete",
26146
- disabled: !mutable || locked,
26147
- onClick: () => controller.content.remove(ids),
26148
- "data-tooltip": "Delete",
26149
- type: "button",
26150
- children: /* @__PURE__ */ jsx(ToolIcon, { tool: "delete" })
26151
- })
26152
- ]
26153
- });
26715
+ })]
26716
+ }))] });
26154
26717
  }
26155
26718
  //#endregion
26156
26719
  //#region src/ui/toolbar/board-tooltips.tsx
@@ -26212,7 +26775,7 @@ function BoardTooltips({ renderPortal }) {
26212
26775
  document.addEventListener("focusin", show);
26213
26776
  document.addEventListener("focusout", leave);
26214
26777
  document.addEventListener("pointerdown", hide);
26215
- document.addEventListener("keydown", key);
26778
+ document.addEventListener("keydown", key, true);
26216
26779
  window.addEventListener("scroll", hide, true);
26217
26780
  window.addEventListener("resize", hide);
26218
26781
  return () => {
@@ -26222,11 +26785,22 @@ function BoardTooltips({ renderPortal }) {
26222
26785
  document.removeEventListener("focusin", show);
26223
26786
  document.removeEventListener("focusout", leave);
26224
26787
  document.removeEventListener("pointerdown", hide);
26225
- document.removeEventListener("keydown", key);
26788
+ document.removeEventListener("keydown", key, true);
26226
26789
  window.removeEventListener("scroll", hide, true);
26227
26790
  window.removeEventListener("resize", hide);
26228
26791
  };
26229
26792
  }, []);
26793
+ useEffect(() => {
26794
+ if (!active) return;
26795
+ const observer = new MutationObserver(() => {
26796
+ if (!active.element.isConnected) setActive(null);
26797
+ });
26798
+ observer.observe(document.body, {
26799
+ childList: true,
26800
+ subtree: true
26801
+ });
26802
+ return () => observer.disconnect();
26803
+ }, [active]);
26230
26804
  useLayoutEffect(() => {
26231
26805
  if (!active || !tooltip.current) return;
26232
26806
  const box = active.element.getBoundingClientRect();
@@ -26745,9 +27319,18 @@ var INKS = [
26745
27319
  ["Forest", "#315d46"],
26746
27320
  ["Oxblood", "#773f3b"]
26747
27321
  ];
26748
- function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots, icons, commentAuthor }) {
27322
+ var ACTIVITY_KIND = {
27323
+ create: "added",
27324
+ update: "updated",
27325
+ delete: "removed",
27326
+ comment: "commented",
27327
+ share: "shared",
27328
+ export: "exported"
27329
+ };
27330
+ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots, icons, commentAuthor, onShare, activity, commentFeed, permissions, menuItems }) {
26749
27331
  const [welcomeDismissed, setWelcomeDismissed] = useState(false);
26750
27332
  const [panel, setPanel] = useState(null);
27333
+ const [searchOpen, setSearchOpen] = useState(false);
26751
27334
  const [pagesOpen, setPagesOpen] = useState(false);
26752
27335
  const [moreOpen, setMoreOpen] = useState(false);
26753
27336
  const [query, setQuery] = useState("");
@@ -26768,8 +27351,17 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
26768
27351
  const enabled = (region) => regions?.[region] !== false;
26769
27352
  const mutable = snapshot.status === "ready" && !snapshot.readOnly;
26770
27353
  useEffect(() => {
26771
- if (panel === "search") searchRef.current?.focus();
26772
- }, [panel]);
27354
+ if (searchOpen) searchRef.current?.focus();
27355
+ }, [searchOpen]);
27356
+ useEffect(() => {
27357
+ if (!searchOpen) return;
27358
+ const dismiss = (event) => {
27359
+ if (searchRef.current?.value) return;
27360
+ if (!event.target.closest?.(".scrawl-board__actions")) setSearchOpen(false);
27361
+ };
27362
+ document.addEventListener("pointerdown", dismiss);
27363
+ return () => document.removeEventListener("pointerdown", dismiss);
27364
+ }, [searchOpen]);
26773
27365
  useEffect(() => {
26774
27366
  const root = chromeRef.current;
26775
27367
  if (!root) return;
@@ -26866,10 +27458,90 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
26866
27458
  root.removeEventListener("drop", drop);
26867
27459
  };
26868
27460
  }, [imagesEnabled]);
27461
+ const [localActivity, setLocalActivity] = useState([]);
27462
+ const [localActivityUnread, setLocalActivityUnread] = useState(0);
27463
+ const hostActivity = activity?.entries;
27464
+ const actorName = commentAuthor?.name ?? "You";
27465
+ useEffect(() => {
27466
+ if (hostActivity) return;
27467
+ let sequence = 0;
27468
+ return controller.on("audit", (payload) => {
27469
+ const event = payload;
27470
+ queueMicrotask(() => {
27471
+ const selected = controller.getSnapshot().selection[0];
27472
+ const box = selected && event.action !== "delete" ? controller.query.bounds(selected) : void 0;
27473
+ const entry = {
27474
+ id: `session-${Date.now()}-${sequence++}`,
27475
+ kind: ACTIVITY_KIND[event.action] ?? "updated",
27476
+ title: event.summary.charAt(0).toUpperCase() + event.summary.slice(1),
27477
+ actorName,
27478
+ at: Date.now(),
27479
+ ...box ? { location: {
27480
+ x: (box.minX + box.maxX) / 2,
27481
+ y: (box.minY + box.maxY) / 2
27482
+ } } : {}
27483
+ };
27484
+ setLocalActivity((list) => [entry, ...list].slice(0, 200));
27485
+ setLocalActivityUnread((count) => count + 1);
27486
+ });
27487
+ });
27488
+ }, [
27489
+ controller,
27490
+ hostActivity,
27491
+ actorName
27492
+ ]);
27493
+ const activityEntries = hostActivity ?? localActivity;
27494
+ const activityUnread = activity?.unreadCount ?? (hostActivity ? 0 : localActivityUnread);
27495
+ const commentsUnread = commentFeed?.unreadCount ?? 0;
27496
+ const markActivityRead = () => {
27497
+ setLocalActivityUnread(0);
27498
+ if (activityUnread > 0) activity?.onMarkAllRead?.();
27499
+ };
27500
+ const markCommentsRead = () => {
27501
+ if (commentsUnread > 0) commentFeed?.onMarkAllRead?.();
27502
+ };
27503
+ const showComments = enabled("comments");
27504
+ const showActivity = enabled("activity");
27505
+ const [jumpPin, setJumpPin] = useState(null);
27506
+ const jumpTo = (location) => {
27507
+ controller.view.centerOn(location);
27508
+ setJumpPin({
27509
+ location,
27510
+ arrival: Date.now()
27511
+ });
27512
+ };
27513
+ const moreItems = [
27514
+ ...enabled("import") && mutable ? [{
27515
+ id: "scrawl:import",
27516
+ label: "Import board JSON",
27517
+ icon: /* @__PURE__ */ jsx(ToolIcon, { tool: "import" })
27518
+ }] : [],
27519
+ ...permissions?.canManage ? [{
27520
+ id: "scrawl:permissions",
27521
+ label: "Manage permissions",
27522
+ icon: /* @__PURE__ */ jsx(ToolIcon, { tool: "shield" })
27523
+ }] : [],
27524
+ ...menuItems ?? []
27525
+ ];
27526
+ const selectMoreItem = (id) => {
27527
+ if (id === "scrawl:import") importRef.current?.click();
27528
+ else if (id === "scrawl:permissions") {
27529
+ const more = chromeRef.current?.querySelector("[aria-label=\"More board actions\"]");
27530
+ if (more) open("permissions", more);
27531
+ } else menuItems?.find((item) => item.id === id)?.onSelect();
27532
+ };
26869
27533
  const open = (next, opener) => {
26870
27534
  setMoreOpen(false);
27535
+ if (!searchRef.current?.value) setSearchOpen(false);
26871
27536
  openerRef.current = opener;
26872
27537
  setPanel(next);
27538
+ if (next === "comments") markCommentsRead();
27539
+ if (next === "activity") markActivityRead();
27540
+ };
27541
+ /** A header button opens its own panel, or closes it if it's already open. */
27542
+ const toggle = (next, opener) => {
27543
+ if (panel === next) close();
27544
+ else open(next, opener);
26873
27545
  };
26874
27546
  const close = useCallback(() => {
26875
27547
  setPanel(null);
@@ -26914,12 +27586,9 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
26914
27586
  return;
26915
27587
  }
26916
27588
  if (modifier && event.key.toLowerCase() === "f" && enabled("search")) {
26917
- const opener = chromeRef.current?.querySelector("[aria-label=\"Search Board\"]");
26918
- if (opener) {
26919
- event.preventDefault();
26920
- event.stopPropagation();
26921
- open("search", opener);
26922
- }
27589
+ event.preventDefault();
27590
+ event.stopPropagation();
27591
+ setSearchOpen(true);
26923
27592
  return;
26924
27593
  }
26925
27594
  if (editing) return;
@@ -26981,14 +27650,149 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
26981
27650
  ref: chromeRef,
26982
27651
  style,
26983
27652
  children: [
26984
- enabled("pages") && /* @__PURE__ */ jsx(NotebookNavigation, {
26985
- controller,
26986
- snapshot,
26987
- onOpen: () => {
26988
- setPagesOpen(true);
26989
- setMoreOpen(false);
26990
- setPanel(null);
26991
- }
27653
+ /* @__PURE__ */ jsxs("div", {
27654
+ className: "scrawl-board__topbar",
27655
+ children: [enabled("pages") && /* @__PURE__ */ jsx("div", {
27656
+ className: "scrawl-board__navigator",
27657
+ children: /* @__PURE__ */ jsx(NotebookNavigation, {
27658
+ controller,
27659
+ snapshot
27660
+ })
27661
+ }), renderSlot(slots?.topBar, (enabled("search") || enabled("export") || showComments || showActivity || moreItems.length > 0 || onShare) && /* @__PURE__ */ jsxs("div", {
27662
+ className: "scrawl-board__header-actions",
27663
+ children: [
27664
+ /* @__PURE__ */ jsxs("div", {
27665
+ "aria-label": "Board actions",
27666
+ className: "scrawl-board__actions",
27667
+ role: "toolbar",
27668
+ children: [
27669
+ enabled("search") && /* @__PURE__ */ jsxs("div", {
27670
+ className: "scrawl-board__search",
27671
+ "data-open": searchOpen ? "" : void 0,
27672
+ "data-scrawl-search": "",
27673
+ children: [
27674
+ /* @__PURE__ */ jsx("button", {
27675
+ "aria-label": "Search Board",
27676
+ "aria-expanded": searchOpen,
27677
+ "data-tooltip": "Search",
27678
+ type: "button",
27679
+ onClick: () => {
27680
+ if (searchOpen && !query) setSearchOpen(false);
27681
+ else setSearchOpen(true);
27682
+ searchRef.current?.focus();
27683
+ },
27684
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: "search" })
27685
+ }),
27686
+ /* @__PURE__ */ jsx("input", {
27687
+ "aria-label": "Search Board",
27688
+ className: "scrawl-board__search-input",
27689
+ placeholder: "Search this board…",
27690
+ ref: searchRef,
27691
+ tabIndex: searchOpen ? void 0 : -1,
27692
+ type: "search",
27693
+ value: query,
27694
+ onChange: (event) => setQuery(event.currentTarget.value),
27695
+ onKeyDown: (event) => {
27696
+ event.stopPropagation();
27697
+ if (event.key !== "Escape") return;
27698
+ event.preventDefault();
27699
+ setQuery("");
27700
+ setSearchOpen(false);
27701
+ chromeRef.current?.querySelector("[aria-label=\"Search Board\"]")?.focus();
27702
+ }
27703
+ }),
27704
+ searchOpen && query && /* @__PURE__ */ jsx("div", {
27705
+ "aria-live": "polite",
27706
+ className: "scrawl-board__search-results",
27707
+ "data-scrawl-search-results": "",
27708
+ children: results.length === 0 ? /* @__PURE__ */ jsx("p", { children: "No matching Board content." }) : results.map((result) => /* @__PURE__ */ jsxs("button", {
27709
+ type: "button",
27710
+ onClick: () => {
27711
+ if (result.kind === "comment") controller.comments.open(result.id);
27712
+ else controller.content.select([result.id]);
27713
+ controller.view.centerOn({
27714
+ x: result.x,
27715
+ y: result.y
27716
+ });
27717
+ setAnnouncement(`${result.title} selected`);
27718
+ setQuery("");
27719
+ setSearchOpen(false);
27720
+ },
27721
+ children: [/* @__PURE__ */ jsx("strong", { children: result.title }), /* @__PURE__ */ jsx("span", { children: result.snippet })]
27722
+ }, `${result.kind}:${result.id}`))
27723
+ })
27724
+ ]
27725
+ }),
27726
+ enabled("export") && /* @__PURE__ */ jsx("button", {
27727
+ "aria-label": "Export Board",
27728
+ "aria-expanded": panel === "export",
27729
+ onClick: (event) => toggle("export", event.currentTarget),
27730
+ "data-tooltip": "Export",
27731
+ type: "button",
27732
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: "export" })
27733
+ }),
27734
+ showComments && /* @__PURE__ */ jsxs("button", {
27735
+ "aria-expanded": panel === "comments",
27736
+ "aria-label": commentsUnread > 0 ? `Comments (${commentsUnread} unread)` : "Comments",
27737
+ "data-tooltip": "Comments",
27738
+ type: "button",
27739
+ onClick: (event) => toggle("comments", event.currentTarget),
27740
+ children: [/* @__PURE__ */ jsx(ToolIcon, { tool: "comment" }), commentsUnread > 0 && /* @__PURE__ */ jsx("span", {
27741
+ "aria-hidden": "true",
27742
+ className: "scrawl-board__badge",
27743
+ children: commentsUnread > 99 ? "99+" : commentsUnread
27744
+ })]
27745
+ }),
27746
+ showActivity && /* @__PURE__ */ jsxs("button", {
27747
+ "aria-expanded": panel === "activity",
27748
+ "aria-label": activityUnread > 0 ? `Recent activity (${activityUnread} unread)` : "Recent activity",
27749
+ "data-tooltip": "Recent activity",
27750
+ type: "button",
27751
+ onClick: (event) => toggle("activity", event.currentTarget),
27752
+ children: [/* @__PURE__ */ jsx(ToolIcon, { tool: "activity" }), activityUnread > 0 && /* @__PURE__ */ jsx("span", {
27753
+ "aria-hidden": "true",
27754
+ className: "scrawl-board__badge",
27755
+ children: activityUnread > 99 ? "99+" : activityUnread
27756
+ })]
27757
+ }),
27758
+ moreItems.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
27759
+ "aria-hidden": "true",
27760
+ className: "scrawl-board__actions-divider"
27761
+ }), /* @__PURE__ */ jsx("button", {
27762
+ "aria-expanded": panel === "more",
27763
+ "aria-haspopup": "menu",
27764
+ "aria-label": "More board actions",
27765
+ "data-tooltip": "More",
27766
+ type: "button",
27767
+ onClick: (event) => toggle("more", event.currentTarget),
27768
+ children: /* @__PURE__ */ jsx(ToolIcon, { tool: "moreVertical" })
27769
+ })] })
27770
+ ]
27771
+ }),
27772
+ onShare && /* @__PURE__ */ jsxs("button", {
27773
+ className: "scrawl-board__share",
27774
+ onClick: onShare,
27775
+ type: "button",
27776
+ children: [/* @__PURE__ */ jsx(ToolIcon, { tool: "share" }), /* @__PURE__ */ jsx("span", { children: "Share" })]
27777
+ }),
27778
+ enabled("import") && /* @__PURE__ */ jsx("input", {
27779
+ accept: "application/json,.json",
27780
+ "aria-label": "Choose Board JSON",
27781
+ className: "scrawl-board__file-input",
27782
+ onChange: (event) => {
27783
+ importDocument(event.currentTarget.files?.[0]);
27784
+ event.currentTarget.value = "";
27785
+ },
27786
+ ref: importRef,
27787
+ type: "file"
27788
+ }),
27789
+ panel === "more" && /* @__PURE__ */ jsx(BoardMoreMenu, {
27790
+ items: moreItems,
27791
+ onClose: close,
27792
+ onSelect: selectMoreItem
27793
+ })
27794
+ ]
27795
+ }))]
26992
27796
  }),
26993
27797
  enabled("pages") && /* @__PURE__ */ jsx(PagesPanel, {
26994
27798
  controller,
@@ -27090,45 +27894,29 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
27090
27894
  })]
27091
27895
  })]
27092
27896
  }),
27093
- renderSlot(slots?.topBar, (enabled("search") || enabled("import") || enabled("export")) && /* @__PURE__ */ jsxs("div", {
27094
- "aria-label": "Board actions",
27095
- className: "scrawl-board__actions",
27096
- role: "toolbar",
27097
- children: [
27098
- enabled("search") && /* @__PURE__ */ jsx("button", {
27099
- "aria-label": "Search Board",
27100
- onClick: (event) => open("search", event.currentTarget),
27101
- "data-tooltip": "Search",
27102
- type: "button",
27103
- children: /* @__PURE__ */ jsx(ToolIcon, { tool: "search" })
27104
- }),
27105
- enabled("import") && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
27106
- "aria-label": "Import Board JSON",
27107
- disabled: !mutable,
27108
- onClick: () => importRef.current?.click(),
27109
- "data-tooltip": "Import",
27110
- type: "button",
27111
- children: /* @__PURE__ */ jsx(ToolIcon, { tool: "import" })
27112
- }), /* @__PURE__ */ jsx("input", {
27113
- accept: "application/json,.json",
27114
- "aria-label": "Choose Board JSON",
27115
- className: "scrawl-board__file-input",
27116
- onChange: (event) => {
27117
- importDocument(event.currentTarget.files?.[0]);
27118
- event.currentTarget.value = "";
27119
- },
27120
- ref: importRef,
27121
- type: "file"
27122
- })] }),
27123
- enabled("export") && /* @__PURE__ */ jsx("button", {
27124
- "aria-label": "Export Board",
27125
- onClick: (event) => open("export", event.currentTarget),
27126
- "data-tooltip": "Export",
27127
- type: "button",
27128
- children: /* @__PURE__ */ jsx(ToolIcon, { tool: "export" })
27129
- })
27130
- ]
27131
- })),
27897
+ panel === "comments" && /* @__PURE__ */ jsx(CommentsDrawer, {
27898
+ comments: controller.comments.list(),
27899
+ controller,
27900
+ onClose: close,
27901
+ onMarkAllRead: markCommentsRead,
27902
+ unread: commentsUnread
27903
+ }),
27904
+ panel === "activity" && /* @__PURE__ */ jsx(ActivityDrawer, {
27905
+ entries: activityEntries,
27906
+ onClose: close,
27907
+ onJump: jumpTo,
27908
+ onMarkAllRead: markActivityRead,
27909
+ unread: activityUnread
27910
+ }),
27911
+ panel === "permissions" && permissions && /* @__PURE__ */ jsx(PermissionsDrawer, {
27912
+ onClose: close,
27913
+ permissions
27914
+ }),
27915
+ jumpPin && /* @__PURE__ */ jsx(JumpPin, {
27916
+ arrival: jumpPin.arrival,
27917
+ controller,
27918
+ location: jumpPin.location
27919
+ }),
27132
27920
  renderSlot(slots?.toolbar, (enabled("tools") || enabled("style") || enabled("history") || enabled("pages")) && /* @__PURE__ */ jsxs("div", {
27133
27921
  "aria-label": "Board tools",
27134
27922
  className: "scrawl-board__tools",
@@ -27334,47 +28122,16 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
27334
28122
  }, color))
27335
28123
  })
27336
28124
  })),
27337
- panel === "search" && renderPortal(/* @__PURE__ */ jsx(ChromePanel, {
27338
- anchor: "actions",
27339
- label: "Search Board",
27340
- onClose: close,
27341
- slot: slots?.dialogs,
27342
- children: /* @__PURE__ */ jsxs("div", {
27343
- "data-scrawl-search": "",
27344
- children: [/* @__PURE__ */ jsxs("label", { children: ["Search Board", /* @__PURE__ */ jsx("input", {
27345
- onChange: (event) => setQuery(event.currentTarget.value),
27346
- ref: searchRef,
27347
- type: "search",
27348
- value: query
27349
- })] }), /* @__PURE__ */ jsx("div", {
27350
- "aria-live": "polite",
27351
- "data-scrawl-search-results": "",
27352
- children: query && results.length === 0 ? /* @__PURE__ */ jsx("p", { children: "No matching Board content." }) : results.map((result) => /* @__PURE__ */ jsxs("button", {
27353
- onClick: () => {
27354
- if (result.kind === "comment") controller.comments.open(result.id);
27355
- else controller.content.select([result.id]);
27356
- controller.view.centerOn({
27357
- x: result.x,
27358
- y: result.y
27359
- });
27360
- setAnnouncement(`${result.title} selected`);
27361
- close();
27362
- },
27363
- type: "button",
27364
- children: [/* @__PURE__ */ jsx("strong", { children: result.title }), /* @__PURE__ */ jsx("span", { children: result.snippet })]
27365
- }, `${result.kind}:${result.id}`))
27366
- })]
27367
- })
27368
- })),
27369
28125
  panel === "export" && renderPortal(/* @__PURE__ */ jsx(ChromePanel, {
27370
28126
  anchor: "actions",
28127
+ headless: true,
27371
28128
  label: "Export Board",
27372
28129
  onClose: close,
27373
28130
  slot: slots?.dialogs,
27374
28131
  children: /* @__PURE__ */ jsxs("div", {
27375
28132
  className: "scrawl-board__export-options",
27376
28133
  children: [
27377
- /* @__PURE__ */ jsx("button", {
28134
+ /* @__PURE__ */ jsxs("button", {
27378
28135
  "aria-label": "Export PNG",
27379
28136
  onClick: () => {
27380
28137
  controller.export.png().then((blob) => blob && download(blob, `${snapshot.documentId}.png`));
@@ -27382,9 +28139,9 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
27382
28139
  close();
27383
28140
  },
27384
28141
  type: "button",
27385
- children: "PNG image"
28142
+ children: [/* @__PURE__ */ jsx(ToolIcon, { tool: "image" }), /* @__PURE__ */ jsx("span", { children: "Export as PNG" })]
27386
28143
  }),
27387
- /* @__PURE__ */ jsx("button", {
28144
+ /* @__PURE__ */ jsxs("button", {
27388
28145
  "aria-label": "Export SVG",
27389
28146
  onClick: () => {
27390
28147
  download(new Blob([controller.export.svg()], { type: "image/svg+xml" }), `${snapshot.documentId}.svg`);
@@ -27392,9 +28149,9 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
27392
28149
  close();
27393
28150
  },
27394
28151
  type: "button",
27395
- children: "SVG vector"
28152
+ children: [/* @__PURE__ */ jsx(ToolIcon, { tool: "shape:triangle" }), /* @__PURE__ */ jsx("span", { children: "Export as SVG" })]
27396
28153
  }),
27397
- /* @__PURE__ */ jsx("button", {
28154
+ /* @__PURE__ */ jsxs("button", {
27398
28155
  "aria-label": "Export JSON",
27399
28156
  onClick: () => {
27400
28157
  download(new Blob([JSON.stringify(controller.export.json(), null, 2)], { type: "application/json" }), `${snapshot.documentId}.json`);
@@ -27402,7 +28159,7 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
27402
28159
  close();
27403
28160
  },
27404
28161
  type: "button",
27405
- children: "Board JSON"
28162
+ children: [/* @__PURE__ */ jsx(ToolIcon, { tool: "page" }), /* @__PURE__ */ jsx("span", { children: "Export as JSON" })]
27406
28163
  })
27407
28164
  ]
27408
28165
  })
@@ -27719,7 +28476,7 @@ function ScrawlProvider({ controller, children, preset = "light", theme, portalC
27719
28476
  * field except `canvas` (pass `canvas={null}` for a headless board, or an
27720
28477
  * existing `<canvas>` element to control its identity yourself).
27721
28478
  */
27722
- function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, icons, ...options }) {
28479
+ function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, icons, onShare, activity, commentFeed, permissions, menuItems, ...options }) {
27723
28480
  const [runtime, setRuntime] = useState(null);
27724
28481
  const runtimeRef = useRef(null);
27725
28482
  const ownedCanvasRef = useRef(null);
@@ -27816,8 +28573,13 @@ function Scrawl({ children, preset, theme, portalContainer, className, style, on
27816
28573
  className,
27817
28574
  style,
27818
28575
  children: [runtime.canvas && /* @__PURE__ */ jsx(ScrawlCanvas, { element: runtime.canvas }), children ?? /* @__PURE__ */ jsx(ScrawlDefaultUI, {
28576
+ activity,
27819
28577
  commentAuthor: options.identity,
27820
- icons
28578
+ commentFeed,
28579
+ icons,
28580
+ menuItems,
28581
+ onShare,
28582
+ permissions
27821
28583
  })]
27822
28584
  });
27823
28585
  }
@@ -27854,12 +28616,17 @@ function ScrawlCanvas({ element, className, style, "aria-label": ariaLabel = "Sc
27854
28616
  style
27855
28617
  });
27856
28618
  }
27857
- function ScrawlDefaultUI({ className, style, regions, slots, icons, commentAuthor }) {
28619
+ function ScrawlDefaultUI({ className, style, regions, slots, icons, commentAuthor, onShare, activity, commentFeed, permissions, menuItems }) {
27858
28620
  return /* @__PURE__ */ jsx(DefaultBoardChrome, {
28621
+ activity,
27859
28622
  className,
27860
28623
  commentAuthor,
28624
+ commentFeed,
27861
28625
  controller: useScrawlController(),
27862
28626
  icons,
28627
+ menuItems,
28628
+ onShare,
28629
+ permissions,
27863
28630
  regions,
27864
28631
  renderPortal: (children) => /* @__PURE__ */ jsx(ScrawlPortal, { children }),
27865
28632
  slots,