@domternal/vanilla 0.14.0 → 0.15.0

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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PluginKey, IconSet, Editor, AnyExtension, Content, FocusPosition, JSONContent, ToolbarController, ToolbarLayoutEntry, ToolbarButton, ToolbarDropdown, BubbleMenuOptions, FloatingMenuController, FloatingMenuOptions, FloatingMenuItemsOverride, FloatingMenuKeymap } from '@domternal/core';
1
+ import { PluginKey, IconSet, Editor, AnyExtension, Content, EditorPreset, FocusPosition, JSONContent, ToolbarController, ToolbarLayoutEntry, ToolbarButton, ToolbarDropdown, BubbleMenuOptions, FloatingMenuController, FloatingMenuOptions, FloatingMenuItemsOverride, FloatingMenuKeymap } from '@domternal/core';
2
2
 
3
3
  /**
4
4
  * SSR-safe environment check.
@@ -200,6 +200,12 @@ interface DomternalEditorOptions {
200
200
  content?: Content;
201
201
  /** Whether the editor is editable. @default true */
202
202
  editable?: boolean;
203
+ /**
204
+ * Editing experience preset. `'notion'` paints `dm-notion-mode` on the
205
+ * `.dm-editor` host and switches preset-aware extensions to their Notion
206
+ * behavior, replacing the hand-written class. Create-time only.
207
+ */
208
+ preset?: EditorPreset;
203
209
  /** Where to autofocus on mount. @default false */
204
210
  autofocus?: FocusPosition;
205
211
  /**
@@ -584,14 +590,13 @@ interface DomternalBubbleMenuOptions extends CustomContentOption {
584
590
  * "..." for block context menu) are rendered automatically when the
585
591
  * corresponding extensions are loaded.
586
592
  *
587
- * Re-renders are batched via `requestAnimationFrame`. DOM is rebuilt on every
588
- * transaction because the item list changes with context (selection moves
589
- * between text and code-block, for example).
593
+ * Re-renders are batched via `requestAnimationFrame`. The structure is rebuilt
594
+ * only when the item list changes; a state-only render updates the nodes on
595
+ * screen, so a press that outlives a transaction still produces a click.
590
596
  *
591
- * **Stable identity convention.** Trigger buttons (including the "A" and "..."
592
- * trailing triggers and dropdown triggers) are DESTROYED and RECREATED on
593
- * every transaction. Consumers that store a reference to a trigger element
594
- * (e.g. as a popover anchor) should:
597
+ * **Stable identity convention.** A trigger survives a state-only render but
598
+ * NOT an item-list change, so consumers holding a reference to one (e.g. as a
599
+ * popover anchor) should still:
595
600
  * - Either listen for `selectionUpdate` / `transaction` and re-resolve the
596
601
  * anchor via `host.querySelector('.dm-ncp-trigger')` on demand
597
602
  * - Or match the trigger by class + container (`closest('.dm-bubble-menu')`)
package/dist/index.js CHANGED
@@ -82,7 +82,8 @@ var DomternalEditor = class extends EventTarget {
82
82
  extensions: [...defaults, ...options.extensions ?? []],
83
83
  content: options.content ?? "",
84
84
  editable: options.editable ?? true,
85
- autofocus: options.autofocus ?? false
85
+ autofocus: options.autofocus ?? false,
86
+ ...options.preset ? { preset: options.preset } : {}
86
87
  });
87
88
  this.#wireEditorEvents();
88
89
  this.#onCreate?.(this.editor);
@@ -359,6 +360,8 @@ var DomternalToolbar = class extends EventTarget {
359
360
  #lastGroupsRef = null;
360
361
  /** Maps top-level button/dropdown name -> rendered trigger element. */
361
362
  #buttonEls = /* @__PURE__ */ new Map();
363
+ /** Trigger markup last written, so a re-render does not rewrite it blindly. */
364
+ #triggerHtml = /* @__PURE__ */ new WeakMap();
362
365
  /** Rendered dropdown panel elements (created lazily when opened). */
363
366
  #dropdownPanelEl = null;
364
367
  #cleanupFloating = null;
@@ -452,6 +455,17 @@ var DomternalToolbar = class extends EventTarget {
452
455
  },
453
456
  { signal }
454
457
  );
458
+ document.addEventListener(
459
+ "keydown",
460
+ (e) => {
461
+ if (!this.#controller.openDropdown) return;
462
+ if (e.key !== "Escape") return;
463
+ if (this.host.contains(document.activeElement)) return;
464
+ this.closeDropdown();
465
+ e.preventDefault();
466
+ },
467
+ { signal }
468
+ );
455
469
  this.host.addEventListener(
456
470
  "keydown",
457
471
  (e) => {
@@ -549,7 +563,9 @@ var DomternalToolbar = class extends EventTarget {
549
563
  btn.setAttribute("aria-label", dd.label);
550
564
  btn.title = dd.label;
551
565
  btn.setAttribute("data-dropdown", dd.name);
552
- btn.innerHTML = this.#resolveDropdownTriggerHtml(dd);
566
+ const triggerHtml = this.#resolveDropdownTriggerHtml(dd);
567
+ this.#triggerHtml.set(btn, triggerHtml);
568
+ btn.innerHTML = triggerHtml;
553
569
  btn.addEventListener("mousedown", (e) => {
554
570
  e.preventDefault();
555
571
  });
@@ -626,7 +642,8 @@ var DomternalToolbar = class extends EventTarget {
626
642
  btn.setAttribute("aria-expanded", String(isOpen));
627
643
  btn.tabIndex = flat === focusedIndex ? 0 : -1;
628
644
  const newHtml = this.#resolveDropdownTriggerHtml(dd);
629
- if (btn.innerHTML !== newHtml) {
645
+ if (this.#triggerHtml.get(btn) !== newHtml) {
646
+ this.#triggerHtml.set(btn, newHtml);
630
647
  btn.innerHTML = newHtml;
631
648
  }
632
649
  }
@@ -1031,7 +1048,8 @@ function computeTrailingState(editor, opts) {
1031
1048
  }
1032
1049
  return {
1033
1050
  isNodeSelection: isNode,
1034
- showColorPickerButton: opts.hasNotionColorPicker,
1051
+ // Hidden without a live notionColorOpen listener: the trigger would be dead.
1052
+ showColorPickerButton: opts.hasNotionColorPicker && editor.listenerCount("notionColorOpen") > 0,
1035
1053
  showBlockMenuButton: opts.hasBlockContextMenu,
1036
1054
  blockMenuButtonDisabled: blockMenuDisabled,
1037
1055
  currentTextColorVar: textVar,
@@ -1080,6 +1098,15 @@ var DomternalBubbleMenu = class extends EventTarget {
1080
1098
  #activeMap = /* @__PURE__ */ new Map();
1081
1099
  #disabledMap = /* @__PURE__ */ new Map();
1082
1100
  #trailing = { ...INITIAL_TRAILING_STATE };
1101
+ // Structure rebuilt only on an item-list change, so a pressed button lives.
1102
+ #structureKey = null;
1103
+ #buttonEls = /* @__PURE__ */ new Map();
1104
+ // Icon markup last WRITTEN. `btn.innerHTML` returns the browser's
1105
+ // re-serialisation, so comparing against it rewrites every render and
1106
+ // replaces the glyph under the pointer. Keyed by element: names repeat.
1107
+ #iconHtml = /* @__PURE__ */ new WeakMap();
1108
+ #colorTriggerEl = null;
1109
+ #blockMenuTriggerEl = null;
1083
1110
  // Dropdown state (text-align dropdown inside bubble menu)
1084
1111
  #openDropdown = null;
1085
1112
  #dropdownPanelEl = null;
@@ -1186,6 +1213,7 @@ var DomternalBubbleMenu = class extends EventTarget {
1186
1213
  setIcons(icons) {
1187
1214
  if (this.#destroyed) return;
1188
1215
  this.#icons = icons;
1216
+ this.#structureKey = null;
1189
1217
  this.#scheduleRender();
1190
1218
  }
1191
1219
  /** Close any open dropdown (text-align). No-op if nothing is open. */
@@ -1362,19 +1390,51 @@ var DomternalBubbleMenu = class extends EventTarget {
1362
1390
  this.#render();
1363
1391
  });
1364
1392
  }
1393
+ /** Signature of what decides which nodes exist; equal keys reuse the DOM. */
1394
+ #computeStructureKey() {
1395
+ const items = this.#resolvedItems.map(
1396
+ (item) => item.type === "dropdown" ? `${item.type}:${item.name}:${item.items.map((sub) => sub.name).join(",")}` : `${item.type}:${item.name}`
1397
+ ).join("|");
1398
+ const t = this.#trailing;
1399
+ return [
1400
+ items,
1401
+ t.showColorPickerButton && !t.isNodeSelection ? "color" : "",
1402
+ t.showBlockMenuButton && !t.isNodeSelection ? "block" : "",
1403
+ this.#customContent ? "custom" : ""
1404
+ ].join("#");
1405
+ }
1406
+ /**
1407
+ * Runs on every transaction, and pointer motion alone dispatches those. A
1408
+ * render lands a frame late, so rebuilding wholesale can replace a button
1409
+ * between mousedown and mouseup: the two events share no element and the
1410
+ * browser fires no click at all. Structure only on an item-list change,
1411
+ * state in place otherwise, as the toolbar does.
1412
+ */
1365
1413
  #render() {
1366
- this.host.replaceChildren();
1367
- this.#cleanupDropdownFloating?.();
1368
- this.#cleanupDropdownFloating = null;
1369
- this.#dropdownAbortCtl?.abort();
1370
- this.#dropdownAbortCtl = null;
1371
- this.#dropdownPanelEl = null;
1372
1414
  if (this.#openDropdown !== null) {
1373
1415
  const stillExists = this.#resolvedItems.some(
1374
1416
  (item) => item.type === "dropdown" && item.name === this.#openDropdown
1375
1417
  );
1376
- if (!stillExists) this.#openDropdown = null;
1418
+ if (!stillExists) {
1419
+ this.#openDropdown = null;
1420
+ this.#detachDropdown();
1421
+ }
1377
1422
  }
1423
+ const key = this.#computeStructureKey();
1424
+ if (key === this.#structureKey) {
1425
+ this.#updateButtons();
1426
+ } else {
1427
+ this.#renderStructure();
1428
+ this.#structureKey = key;
1429
+ }
1430
+ this.#syncDropdownPanel();
1431
+ }
1432
+ #renderStructure() {
1433
+ this.host.replaceChildren();
1434
+ this.#buttonEls.clear();
1435
+ this.#colorTriggerEl = null;
1436
+ this.#blockMenuTriggerEl = null;
1437
+ this.#detachDropdown();
1378
1438
  for (const item of this.#resolvedItems) {
1379
1439
  if (item.type === "separator") {
1380
1440
  this.host.appendChild(this.#createSeparator(item.name));
@@ -1415,13 +1475,14 @@ var DomternalBubbleMenu = class extends EventTarget {
1415
1475
  btn.setAttribute("aria-label", item.label);
1416
1476
  btn.setAttribute("aria-pressed", String(isActive));
1417
1477
  btn.title = item.label;
1418
- btn.innerHTML = resolveIcon(item.icon, this.#icons);
1478
+ this.#setIconHtml(btn, resolveIcon(item.icon, this.#icons));
1419
1479
  btn.addEventListener("mousedown", (e) => {
1420
1480
  e.preventDefault();
1421
1481
  });
1422
1482
  btn.addEventListener("click", (e) => {
1423
1483
  this.#onButtonClick(item, e);
1424
1484
  });
1485
+ this.#buttonEls.set(item.name, btn);
1425
1486
  return btn;
1426
1487
  }
1427
1488
  #createDropdownTrigger(dd) {
@@ -1441,7 +1502,7 @@ var DomternalBubbleMenu = class extends EventTarget {
1441
1502
  trigger.setAttribute("aria-label", dd.label);
1442
1503
  trigger.title = dd.label;
1443
1504
  trigger.dataset["dropdown"] = dd.name;
1444
- trigger.innerHTML = triggerHtml;
1505
+ this.#setIconHtml(trigger, triggerHtml);
1445
1506
  trigger.addEventListener("mousedown", (e) => {
1446
1507
  e.preventDefault();
1447
1508
  });
@@ -1449,12 +1510,7 @@ var DomternalBubbleMenu = class extends EventTarget {
1449
1510
  this.#onDropdownToggle(dd);
1450
1511
  });
1451
1512
  wrapper.appendChild(trigger);
1452
- if (this.#openDropdown === dd.name) {
1453
- const panel = this.#createDropdownPanel(dd);
1454
- wrapper.appendChild(panel);
1455
- this.#dropdownPanelEl = panel;
1456
- this.#attachDropdownListeners(trigger, panel);
1457
- }
1513
+ this.#buttonEls.set(dd.name, trigger);
1458
1514
  return wrapper;
1459
1515
  }
1460
1516
  #createDropdownPanel(dd) {
@@ -1472,6 +1528,7 @@ var DomternalBubbleMenu = class extends EventTarget {
1472
1528
  if (subActive) subBtn.classList.add("dm-toolbar-dropdown-item--active");
1473
1529
  subBtn.setAttribute("role", "menuitem");
1474
1530
  subBtn.setAttribute("aria-label", sub.label);
1531
+ subBtn.dataset["dropdownItem"] = sub.name;
1475
1532
  subBtn.innerHTML = subHtml;
1476
1533
  subBtn.addEventListener("mousedown", (e) => {
1477
1534
  e.preventDefault();
@@ -1507,6 +1564,7 @@ var DomternalBubbleMenu = class extends EventTarget {
1507
1564
  btn.addEventListener("click", () => {
1508
1565
  this.openColorPicker(btn);
1509
1566
  });
1567
+ this.#colorTriggerEl = btn;
1510
1568
  return btn;
1511
1569
  }
1512
1570
  #createBlockMenuTrigger() {
@@ -1525,8 +1583,83 @@ var DomternalBubbleMenu = class extends EventTarget {
1525
1583
  btn.addEventListener("click", () => {
1526
1584
  this.openBlockContextMenu(btn);
1527
1585
  });
1586
+ this.#blockMenuTriggerEl = btn;
1528
1587
  return btn;
1529
1588
  }
1589
+ // === In-place updates (the common render) ===
1590
+ #updateButtons() {
1591
+ for (const item of this.#resolvedItems) {
1592
+ if (item.type === "separator") continue;
1593
+ const el = this.#buttonEls.get(item.name);
1594
+ if (!el) continue;
1595
+ if (item.type === "dropdown") this.#updateDropdownTrigger(item, el);
1596
+ else this.#updateButton(item, el);
1597
+ }
1598
+ const t = this.#trailing;
1599
+ if (this.#colorTriggerEl) {
1600
+ this.#colorTriggerEl.classList.toggle("dm-toolbar-button--active", t.hasAnyColor);
1601
+ const glyph = this.#colorTriggerEl.querySelector(".dm-ncp-trigger-glyph");
1602
+ if (glyph) glyph.style.color = t.currentTextColorVar ?? "";
1603
+ const underline = this.#colorTriggerEl.querySelector(".dm-ncp-trigger-underline");
1604
+ if (underline) underline.style.backgroundColor = t.currentBgColorVar ?? "";
1605
+ }
1606
+ if (this.#blockMenuTriggerEl) {
1607
+ this.#blockMenuTriggerEl.disabled = t.blockMenuButtonDisabled;
1608
+ this.#blockMenuTriggerEl.title = t.blockMenuButtonDisabled ? "Block actions (select within a single block)" : "More options";
1609
+ }
1610
+ }
1611
+ #updateButton(item, btn) {
1612
+ const isActive = this.#activeMap.get(item.name) ?? false;
1613
+ const isDisabled = this.#disabledMap.get(item.name) ?? false;
1614
+ btn.classList.toggle("dm-toolbar-button--active", isActive);
1615
+ btn.disabled = isDisabled;
1616
+ btn.setAttribute("aria-pressed", String(isActive));
1617
+ this.#setIconHtml(btn, resolveIcon(item.icon, this.#icons));
1618
+ }
1619
+ /** Writes icon markup only when it differs from what was written last. */
1620
+ #setIconHtml(el, html) {
1621
+ if (this.#iconHtml.get(el) === html) return;
1622
+ this.#iconHtml.set(el, html);
1623
+ el.innerHTML = html;
1624
+ }
1625
+ #updateDropdownTrigger(dd, trigger) {
1626
+ const dropdownActive = dd.items.some((sub) => this.#activeMap.get(sub.name) ?? false);
1627
+ const activeChild = dd.dynamicIcon ? dd.items.find((sub) => this.#activeMap.get(sub.name) ?? false) : void 0;
1628
+ const html = resolveIcon(activeChild?.icon ?? dd.icon, this.#icons) + DROPDOWN_CARET2;
1629
+ trigger.classList.toggle("dm-toolbar-button--active", dropdownActive);
1630
+ trigger.setAttribute("aria-expanded", String(this.#openDropdown === dd.name));
1631
+ this.#setIconHtml(trigger, html);
1632
+ }
1633
+ /** Builds the open dropdown's panel, or refreshes the marks on an open one. */
1634
+ #syncDropdownPanel() {
1635
+ const open = this.#openDropdown;
1636
+ if (open === null) {
1637
+ if (this.#dropdownPanelEl) this.#detachDropdown();
1638
+ return;
1639
+ }
1640
+ const dd = this.#resolvedItems.find(
1641
+ (item) => item.type === "dropdown" && item.name === open
1642
+ );
1643
+ if (!dd) return;
1644
+ const panel = this.#dropdownPanelEl;
1645
+ if (panel?.isConnected === true) {
1646
+ for (const sub of dd.items) {
1647
+ const subBtn = panel.querySelector(`[data-dropdown-item="${sub.name}"]`);
1648
+ subBtn?.classList.toggle(
1649
+ "dm-toolbar-dropdown-item--active",
1650
+ this.#activeMap.get(sub.name) ?? false
1651
+ );
1652
+ }
1653
+ return;
1654
+ }
1655
+ const trigger = this.#buttonEls.get(open);
1656
+ const wrapper = trigger?.parentElement;
1657
+ if (!trigger || !wrapper) return;
1658
+ const fresh = this.#createDropdownPanel(dd);
1659
+ wrapper.appendChild(fresh);
1660
+ this.#dropdownPanelEl = fresh;
1661
+ this.#attachDropdownListeners(trigger, fresh);
1662
+ }
1530
1663
  // === Event handlers ===
1531
1664
  #onButtonClick(item, event) {
1532
1665
  if (this.#openDropdown) this.closeDropdown();