@domternal/angular 0.6.2 → 0.7.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.
@@ -1,9 +1,10 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { viewChild, input, output, signal, inject, NgZone, afterNextRender, effect, untracked, forwardRef, ViewEncapsulation, ChangeDetectionStrategy, Component, ElementRef, computed } from '@angular/core';
3
3
  import { NG_VALUE_ACCESSOR } from '@angular/forms';
4
- import { Document, Paragraph, Text, BaseKeymap, History, Editor, positionFloatingOnce, defaultIcons, ToolbarController, PluginKey, createBubbleMenuPlugin, createFloatingMenuPlugin } from '@domternal/core';
4
+ import { Document, Paragraph, Text, BaseKeymap, History, Editor, positionFloatingOnce, defaultIcons, ToolbarController, defaultBubbleContexts, PluginKey, createBubbleMenuPlugin, FloatingMenuController, positionFloating } from '@domternal/core';
5
5
  export { Editor } from '@domternal/core';
6
6
  import { DomSanitizer } from '@angular/platform-browser';
7
+ import { createFloatingMenuPlugin } from '@domternal/extension-block-menu';
7
8
 
8
9
  const DEFAULT_EXTENSIONS = [Document, Paragraph, Text, BaseKeymap, History];
9
10
  class DomternalEditorComponent {
@@ -53,8 +54,9 @@ class DomternalEditorComponent {
53
54
  const editable = this.editable();
54
55
  if (!this._editor || this._editor.isDestroyed)
55
56
  return;
57
+ const ed = this._editor;
56
58
  untracked(() => {
57
- this._editor.setEditable(editable);
59
+ ed.setEditable(editable);
58
60
  this._isEditable.set(editable);
59
61
  });
60
62
  });
@@ -64,15 +66,16 @@ class DomternalEditorComponent {
64
66
  const format = this.outputFormat();
65
67
  if (!this._editor || this._editor.isDestroyed)
66
68
  return;
69
+ const ed = this._editor;
67
70
  untracked(() => {
68
71
  const current = format === 'html'
69
- ? this._editor.getHTML()
70
- : JSON.stringify(this._editor.getJSON());
72
+ ? ed.getHTML()
73
+ : JSON.stringify(ed.getJSON());
71
74
  const incoming = format === 'html'
72
75
  ? content
73
76
  : JSON.stringify(content);
74
77
  if (incoming !== current) {
75
- this._editor.setContent(content, false);
78
+ ed.setContent(content, false);
76
79
  }
77
80
  });
78
81
  });
@@ -148,9 +151,10 @@ class DomternalEditorComponent {
148
151
  this._htmlContent.set(this._editor.getHTML());
149
152
  this._jsonContent.set(this._editor.getJSON());
150
153
  this._isEmpty.set(this._editor.isEmpty);
151
- this._editor.on('transaction', ({ transaction }) => {
154
+ const editor = this._editor;
155
+ editor.on('transaction', ({ transaction }) => {
152
156
  this.ngZone.run(() => {
153
- const ed = this._editor;
157
+ const ed = editor;
154
158
  if (transaction.docChanged) {
155
159
  const html = ed.getHTML();
156
160
  this._htmlContent.set(html);
@@ -165,22 +169,22 @@ class DomternalEditorComponent {
165
169
  }
166
170
  });
167
171
  });
168
- this._editor.on('focus', ({ event }) => {
172
+ editor.on('focus', ({ event }) => {
169
173
  this.ngZone.run(() => {
170
174
  this._isFocused.set(true);
171
- this.focusChanged.emit({ editor: this._editor, event });
175
+ this.focusChanged.emit({ editor, event });
172
176
  });
173
177
  });
174
- this._editor.on('blur', ({ event }) => {
178
+ editor.on('blur', ({ event }) => {
175
179
  this.ngZone.run(() => {
176
180
  this._isFocused.set(false);
177
- this.blurChanged.emit({ editor: this._editor, event });
181
+ this.blurChanged.emit({ editor, event });
178
182
  this.onTouched();
179
183
  });
180
184
  });
181
185
  // Emit editor created
182
186
  this.ngZone.run(() => {
183
- this.editorCreated.emit(this._editor);
187
+ this.editorCreated.emit(editor);
184
188
  });
185
189
  }
186
190
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: DomternalEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
@@ -222,13 +226,13 @@ class DomternalToolbarComponent {
222
226
  ngZone = inject(NgZone);
223
227
  elRef = inject(ElementRef);
224
228
  sanitizer = inject(DomSanitizer);
225
- /** SafeHtml cache same reference returned for same key, prevents DOM churn */
229
+ /** SafeHtml cache - same reference returned for same key, prevents DOM churn */
226
230
  htmlCache = new Map();
227
231
  dropdownCaret = '<svg class="dm-dropdown-caret" width="10" height="10" viewBox="0 0 10 10"><path d="M2 4l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
228
232
  constructor() {
229
233
  effect(() => {
230
234
  const editor = this.editor();
231
- untracked(() => this.setupController(editor));
235
+ untracked(() => { this.setupController(editor); });
232
236
  });
233
237
  }
234
238
  ngOnDestroy() {
@@ -269,7 +273,7 @@ class DomternalToolbarComponent {
269
273
  }
270
274
  return cached;
271
275
  }
272
- // Non-grid dropdown show active sub-item's label as text
276
+ // Non-grid dropdown - show active sub-item's label as text
273
277
  if (dropdown.dynamicLabel) {
274
278
  if (activeItem)
275
279
  return this.getCachedTriggerLabel(activeItem.label);
@@ -281,7 +285,7 @@ class DomternalToolbarComponent {
281
285
  computed = this.getInlineStyleAtCursor(dropdown.computedStyleProperty);
282
286
  if (computed) {
283
287
  const first = computed.split(',')[0]?.replace(/['"]+/g, '').trim();
284
- computed = first || null;
288
+ computed = first ?? null;
285
289
  }
286
290
  }
287
291
  else {
@@ -300,7 +304,7 @@ class DomternalToolbarComponent {
300
304
  }
301
305
  /**
302
306
  * Returns 'true' when an emitEvent button's panel is open, null otherwise.
303
- * Maps to [attr.aria-expanded] null removes the attribute entirely.
307
+ * Maps to [attr.aria-expanded] - null removes the attribute entirely.
304
308
  */
305
309
  getAriaExpanded(item) {
306
310
  if (!item.emitEvent)
@@ -408,7 +412,7 @@ class DomternalToolbarComponent {
408
412
  // Always refocus editor after executing a command via toolbar button.
409
413
  // Mouse clicks already keep focus via mousedown.preventDefault();
410
414
  // keyboard activations (Enter/Space) need explicit refocus.
411
- requestAnimationFrame(() => this.editor().view.focus());
415
+ requestAnimationFrame(() => { this.editor().view.focus(); });
412
416
  }
413
417
  onDropdownToggle(dropdown) {
414
418
  this.cleanupFloating?.();
@@ -448,7 +452,7 @@ class DomternalToolbarComponent {
448
452
  this.controller?.executeCommand(item);
449
453
  }
450
454
  // Refocus editor so ::selection highlight stays visible
451
- requestAnimationFrame(() => this.editor().view.focus());
455
+ requestAnimationFrame(() => { this.editor().view.focus(); });
452
456
  }
453
457
  onButtonFocus(name) {
454
458
  const index = this.controller?.getFlatIndex(name) ?? -1;
@@ -489,7 +493,7 @@ class DomternalToolbarComponent {
489
493
  const btn = document.activeElement;
490
494
  if (btn?.getAttribute('aria-haspopup') && btn.closest('.dm-toolbar')) {
491
495
  btn.click();
492
- requestAnimationFrame(() => this.focusDropdownItem(0, true));
496
+ requestAnimationFrame(() => { this.focusDropdownItem(0, true); });
493
497
  }
494
498
  }
495
499
  break;
@@ -541,7 +545,7 @@ class DomternalToolbarComponent {
541
545
  }
542
546
  setupController(editor) {
543
547
  this.destroyController();
544
- this.controller = new ToolbarController(editor, () => this.ngZone.run(() => this.syncState()), this.layout());
548
+ this.controller = new ToolbarController(editor, () => { this.ngZone.run(() => { this.syncState(); }); }, this.layout());
545
549
  this.controller.subscribe();
546
550
  this.syncState();
547
551
  // Click outside to close dropdown
@@ -550,7 +554,7 @@ class DomternalToolbarComponent {
550
554
  this.cleanupFloating?.();
551
555
  this.cleanupFloating = null;
552
556
  this.controller?.closeDropdown();
553
- this.ngZone.run(() => this.syncState());
557
+ this.ngZone.run(() => { this.syncState(); });
554
558
  }
555
559
  };
556
560
  document.addEventListener('mousedown', this.clickOutsideHandler);
@@ -562,7 +566,7 @@ class DomternalToolbarComponent {
562
566
  this.cleanupFloating?.();
563
567
  this.cleanupFloating = null;
564
568
  this.controller?.closeDropdown();
565
- this.ngZone.run(() => this.syncState());
569
+ this.ngZone.run(() => { this.syncState(); });
566
570
  }
567
571
  };
568
572
  this.editorEl.addEventListener('dm:dismiss-overlays', this.dismissOverlayHandler);
@@ -616,7 +620,7 @@ class DomternalToolbarComponent {
616
620
  return null;
617
621
  }
618
622
  }
619
- /** Read only inline style no computed fallback (used for font-family). */
623
+ /** Read only inline style - no computed fallback (used for font-family). */
620
624
  getInlineStyleAtCursor(prop) {
621
625
  try {
622
626
  const { from } = this.editor().state.selection;
@@ -873,27 +877,82 @@ class DomternalBubbleMenuComponent {
873
877
  offset = input(8, ...(ngDevMode ? [{ debugName: "offset" }] : /* istanbul ignore next */ []));
874
878
  updateDelay = input(0, ...(ngDevMode ? [{ debugName: "updateDelay" }] : /* istanbul ignore next */ []));
875
879
  /** Fixed item names (e.g. ['bold', 'italic', 'code']). Omit for auto mode (all format items). */
876
- items = input(...(ngDevMode ? [undefined, { debugName: "items" }] : /* istanbul ignore next */ []));
880
+ items = input(undefined, ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
877
881
  /** Context-aware: map context names to item arrays, `true` for all valid items, or `null` to disable */
878
- contexts = input(...(ngDevMode ? [undefined, { debugName: "contexts" }] : /* istanbul ignore next */ []));
879
- /** Internal — updated on transactions. Not meant to be set from outside. */
882
+ contexts = input(undefined, ...(ngDevMode ? [{ debugName: "contexts" }] : /* istanbul ignore next */ []));
883
+ /**
884
+ * Custom icon overrides. Falls back to default Phosphor icons for unmapped keys.
885
+ * For nullable bindings, use `iconsSignal() ?? {}` to satisfy strict template checks.
886
+ */
887
+ icons = input(undefined, ...(ngDevMode ? [{ debugName: "icons" }] : /* istanbul ignore next */ []));
888
+ /**
889
+ * Returns the effective contexts map: the explicit `contexts` input
890
+ * when provided, the standard default when neither `contexts` nor
891
+ * `items` is set, or `undefined` (items-mode) when only `items` is set.
892
+ * The standard default is richer when the editor sits inside
893
+ * `.dm-notion-mode`.
894
+ */
895
+ resolveContexts(editor) {
896
+ const explicit = this.contexts();
897
+ if (explicit !== undefined)
898
+ return explicit;
899
+ if (this.items() !== undefined)
900
+ return undefined;
901
+ return defaultBubbleContexts(editor);
902
+ }
903
+ /** Internal - updated on transactions. Not meant to be set from outside. */
880
904
  resolvedItems = signal([], ...(ngDevMode ? [{ debugName: "resolvedItems" }] : /* istanbul ignore next */ []));
905
+ /** Internal - true when the BlockContextMenu extension is loaded; toggles the "..." trailing button. */
906
+ showBlockMenuButton = signal(false, ...(ngDevMode ? [{ debugName: "showBlockMenuButton" }] : /* istanbul ignore next */ []));
907
+ /** Internal - true when the selection spans more than one top-level block; the "..." trigger is disabled in that case because block-level commands have no unambiguous target. */
908
+ blockMenuButtonDisabled = signal(false, ...(ngDevMode ? [{ debugName: "blockMenuButtonDisabled" }] : /* istanbul ignore next */ []));
909
+ /** Internal - true when the current selection is a NodeSelection (image, HR). The text-color and block-context triggers are hidden in that case: a node has no inline text to color and its bubble menu already exposes node-specific actions. */
910
+ isNodeSelection = signal(false, ...(ngDevMode ? [{ debugName: "isNodeSelection" }] : /* istanbul ignore next */ []));
911
+ /** Internal - true when the NotionColorPicker extension is loaded; toggles the "A" color trigger. */
912
+ showColorPickerButton = signal(false, ...(ngDevMode ? [{ debugName: "showColorPickerButton" }] : /* istanbul ignore next */ []));
913
+ /** Current text color CSS variable expression for the trigger glyph (null = default). */
914
+ currentTextColorVar = signal(null, ...(ngDevMode ? [{ debugName: "currentTextColorVar" }] : /* istanbul ignore next */ []));
915
+ /** Current background color CSS variable expression for the trigger underline (null = transparent). */
916
+ currentBgColorVar = signal(null, ...(ngDevMode ? [{ debugName: "currentBgColorVar" }] : /* istanbul ignore next */ []));
917
+ /** Internal - true when the selection has any token-based text or background color applied. */
918
+ hasAnyColor = signal(false, ...(ngDevMode ? [{ debugName: "hasAnyColor" }] : /* istanbul ignore next */ []));
881
919
  menuEl = viewChild.required('menuEl');
882
920
  pluginKey;
883
921
  sanitizer = inject(DomSanitizer);
884
922
  ngZone = inject(NgZone);
885
923
  activeVersion = signal(0, ...(ngDevMode ? [{ debugName: "activeVersion" }] : /* istanbul ignore next */ []));
886
924
  itemMap = new Map();
925
+ dropdownMap = new Map();
887
926
  activeMap = new Map();
888
927
  disabledMap = new Map();
889
928
  htmlCache = new Map();
890
929
  bubbleDefaults = new Map();
891
930
  transactionHandler = null;
931
+ // Dropdown state for bubble-menu-embedded dropdowns (e.g. text-align).
932
+ openDropdown = signal(null, ...(ngDevMode ? [{ debugName: "openDropdown" }] : /* istanbul ignore next */ []));
933
+ dropdownCleanupFloating = null;
934
+ dropdownOutsideHandler = null;
935
+ dropdownKeydownHandler = null;
936
+ dropdownDismissHandler = null;
937
+ dropdownCaret = '<svg class="dm-dropdown-caret" width="10" height="10" viewBox="0 0 10 10">' +
938
+ '<path d="M2 4l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
892
939
  constructor() {
893
- this.pluginKey = new PluginKey('angularBubbleMenu-' + Math.random().toString(36).slice(2, 8));
940
+ // Each component instance needs a unique plugin key so that multiple
941
+ // bubble-menus mounted in the same editor do not collide. Prefer
942
+ // crypto.randomUUID over Math.random() for collision-free uniqueness
943
+ // (Math.random across two simultaneous mounts has a small but real
944
+ // collision probability, and SSR may share the random seed).
945
+ const crypto = globalThis.crypto;
946
+ const suffix = crypto?.randomUUID?.().slice(0, 8) ?? Math.random().toString(36).slice(2, 8);
947
+ this.pluginKey = new PluginKey('angularBubbleMenu-' + suffix);
948
+ // Cache keys do not include icon-set identity, so flush on swap.
949
+ effect(() => {
950
+ this.icons();
951
+ this.htmlCache.clear();
952
+ });
894
953
  afterNextRender(() => {
895
954
  const editor = this.editor();
896
- const ctxs = this.contexts();
955
+ const ctxs = this.resolveContexts(editor);
897
956
  let shouldShowFn = this.shouldShow();
898
957
  if (!shouldShowFn) {
899
958
  if (ctxs) {
@@ -913,8 +972,10 @@ class DomternalBubbleMenuComponent {
913
972
  else {
914
973
  // Auto/items mode: show when any endpoint's parent allows marks
915
974
  shouldShowFn = ({ state }) => {
916
- if (state.selection.empty || state.selection.node)
975
+ if (state.selection.empty)
917
976
  return false;
977
+ if (state.selection.node)
978
+ return this.bubbleDefaults.has(state.selection.node.type.name);
918
979
  if (isInsideTableCell(state.selection.$from))
919
980
  return false;
920
981
  return state.selection.$from.parent.type.spec.marks !== ''
@@ -956,27 +1017,66 @@ class DomternalBubbleMenuComponent {
956
1017
  getCachedIcon(name) {
957
1018
  let cached = this.htmlCache.get(name);
958
1019
  if (!cached) {
959
- cached = this.sanitizer.bypassSecurityTrustHtml(defaultIcons[name] ?? '');
1020
+ const custom = this.icons();
1021
+ cached = this.sanitizer.bypassSecurityTrustHtml(custom?.[name] ?? defaultIcons[name] ?? '');
960
1022
  this.htmlCache.set(name, cached);
961
1023
  }
962
1024
  return cached;
963
1025
  }
964
- executeCommand(item) {
1026
+ executeCommand(item, event) {
965
1027
  if (item.emitEvent) {
1028
+ // Forward the clicked button as the anchor so listeners (LinkPopover,
1029
+ // image popover, emoji picker) can position their panels against it
1030
+ // - matches `openColorPicker(anchor)` and the toolbar's executeItem.
1031
+ const anchor = event?.currentTarget ??
1032
+ event?.target ?? null;
966
1033
  // emitEvent is a dynamic string; cast needed to bypass strict EventEmitter<EditorEvents> typing
967
- this.editor().emit(item.emitEvent, {});
1034
+ this.editor().emit(item.emitEvent, { anchorElement: anchor });
968
1035
  return;
969
1036
  }
970
1037
  ToolbarController.executeItem(this.editor(), item);
971
1038
  }
1039
+ /**
1040
+ * Emit the `notionColorOpen` event with the trigger button as the anchor.
1041
+ * The framework popover component (DomternalNotionColorPickerComponent)
1042
+ * listens for this event and positions itself against the anchor.
1043
+ */
1044
+ openColorPicker(anchor) {
1045
+ this.editor().emit('notionColorOpen', { anchorElement: anchor });
1046
+ }
1047
+ /**
1048
+ * Open the BlockContextMenu against the cursor's containing block. Skips
1049
+ * the textblock for nested cases (cursor in `listItem > paragraph` targets
1050
+ * the listItem, not the paragraph) so Delete / Turn into operate on the
1051
+ * "visual block" the user is editing.
1052
+ */
1053
+ openBlockContextMenu(anchor) {
1054
+ const editor = this.editor();
1055
+ const $from = editor.state.selection.$from;
1056
+ if ($from.depth < 1)
1057
+ return;
1058
+ // Walk one level up to use the container when the cursor's textblock
1059
+ // isn't a direct doc child; otherwise use the textblock itself.
1060
+ const depth = $from.depth > 1 && $from.node($from.depth - 1).type.name !== 'doc'
1061
+ ? $from.depth - 1
1062
+ : $from.depth;
1063
+ const blockPos = $from.before(depth);
1064
+ const editorEl = editor.view.dom.closest('.dm-editor');
1065
+ editorEl?.dispatchEvent(new CustomEvent('dm:block-context-menu-open', {
1066
+ bubbles: false,
1067
+ detail: { blockPos, anchorElement: anchor },
1068
+ }));
1069
+ }
972
1070
  // === Internal ===
973
1071
  buildItemMap(editor) {
974
1072
  this.itemMap.clear();
1073
+ this.dropdownMap.clear();
975
1074
  for (const item of editor.toolbarItems) {
976
1075
  if (item.type === 'button') {
977
1076
  this.itemMap.set(item.name, item);
978
1077
  }
979
1078
  else if (item.type === 'dropdown') {
1079
+ this.dropdownMap.set(item.name, item);
980
1080
  for (const sub of item.items) {
981
1081
  this.itemMap.set(sub.name, sub);
982
1082
  }
@@ -988,9 +1088,14 @@ class DomternalBubbleMenuComponent {
988
1088
  let sepIdx = 0;
989
1089
  for (const name of names) {
990
1090
  if (name === '|') {
991
- result.push({ type: 'separator', name: `sep-${sepIdx++}` });
1091
+ result.push({ type: 'separator', name: `sep-${String(sepIdx++)}` });
992
1092
  }
993
1093
  else {
1094
+ const dropdown = this.dropdownMap.get(name);
1095
+ if (dropdown) {
1096
+ result.push(dropdown);
1097
+ continue;
1098
+ }
994
1099
  const item = this.itemMap.get(name);
995
1100
  if (item)
996
1101
  result.push(item);
@@ -1004,7 +1109,7 @@ class DomternalBubbleMenuComponent {
1004
1109
  .sort((a, b) => (b.priority ?? 100) - (a.priority ?? 100));
1005
1110
  }
1006
1111
  detectContext(selection, ctxs) {
1007
- // CellSelection (duck-type: has $anchorCell) never show bubble menu
1112
+ // CellSelection (duck-type: has $anchorCell) - never show bubble menu
1008
1113
  if ('$anchorCell' in selection)
1009
1114
  return null;
1010
1115
  if (selection.node)
@@ -1056,7 +1161,7 @@ class DomternalBubbleMenuComponent {
1056
1161
  const markName = typeof item.isActive === 'string' ? item.isActive : null;
1057
1162
  if (!markName)
1058
1163
  return true;
1059
- const markType = schema.marks?.[markName];
1164
+ const markType = schema.marks[markName];
1060
1165
  if (!markType)
1061
1166
  return true;
1062
1167
  return nodeType.allowsMarkType(markType);
@@ -1091,7 +1196,7 @@ class DomternalBubbleMenuComponent {
1091
1196
  let sepIdx = 0;
1092
1197
  for (const item of items) {
1093
1198
  if (lastGroup !== undefined && item.group !== lastGroup) {
1094
- result.push({ type: 'separator', name: `bsep-${sepIdx++}` });
1199
+ result.push({ type: 'separator', name: `bsep-${String(sepIdx++)}` });
1095
1200
  }
1096
1201
  result.push(item);
1097
1202
  lastGroup = item.group;
@@ -1102,29 +1207,85 @@ class DomternalBubbleMenuComponent {
1102
1207
  setupItemTracking(editor) {
1103
1208
  this.buildItemMap(editor);
1104
1209
  this.buildBubbleDefaults(editor);
1105
- if (this.contexts()) {
1210
+ this.showBlockMenuButton.set(editor.extensionManager.extensions.some((e) => e.name === 'blockContextMenu'));
1211
+ this.showColorPickerButton.set(editor.extensionManager.extensions.some((e) => e.name === 'notionColorPicker'));
1212
+ const items = this.items();
1213
+ const defaultItems = this.resolveNames(items ?? ['bold', 'italic', 'underline']);
1214
+ if (this.resolveContexts(editor)) {
1106
1215
  this.updateContextItems(editor);
1107
1216
  }
1108
- else if (this.items()) {
1109
- this.resolvedItems.set(this.resolveNames(this.items()));
1110
- }
1111
1217
  else {
1112
- this.resolvedItems.set(this.resolveNames(['bold', 'italic', 'underline']));
1218
+ this.resolvedItems.set(defaultItems);
1113
1219
  }
1114
1220
  this.transactionHandler = () => {
1115
1221
  this.ngZone.run(() => {
1116
- if (this.contexts()) {
1222
+ const sel = editor.state.selection;
1223
+ this.isNodeSelection.set(!!sel.node);
1224
+ if (this.resolveContexts(editor)) {
1117
1225
  this.updateContextItems(editor);
1118
1226
  }
1227
+ else if (sel.node && this.bubbleDefaults.has(sel.node.type.name)) {
1228
+ this.resolvedItems.set(this.bubbleDefaults.get(sel.node.type.name) ?? []);
1229
+ }
1230
+ else {
1231
+ this.resolvedItems.set(defaultItems);
1232
+ }
1119
1233
  this.updateStates(editor);
1120
- this.activeVersion.update(v => v + 1);
1234
+ this.syncTrailingButtonsState(editor);
1235
+ this.activeVersion.update((v) => v + 1);
1121
1236
  });
1122
1237
  };
1123
1238
  editor.on('transaction', this.transactionHandler);
1124
1239
  this.updateStates(editor);
1240
+ this.syncTrailingButtonsState(editor);
1241
+ }
1242
+ /**
1243
+ * Refresh state for the optional trailing buttons (A color trigger,
1244
+ * "..." block menu) and the isNodeSelection flag. Called from both
1245
+ * setupItemTracking init and the per-transaction handler so the two
1246
+ * paths stay in sync.
1247
+ */
1248
+ syncTrailingButtonsState(editor) {
1249
+ this.isNodeSelection.set(!!editor.state.selection.node);
1250
+ if (this.showColorPickerButton())
1251
+ this.syncColorTriggerState(editor);
1252
+ if (this.showBlockMenuButton())
1253
+ this.syncBlockMenuButtonState(editor);
1254
+ }
1255
+ /**
1256
+ * Disable the "..." trigger when the selection spans more than one
1257
+ * top-level block. The block context menu targets the block at $from, so
1258
+ * a multi-block selection has no unambiguous target.
1259
+ */
1260
+ syncBlockMenuButtonState(editor) {
1261
+ const { $from, $to } = editor.state.selection;
1262
+ // depth(1) addresses the top-level child of doc; if either end is
1263
+ // shallower we can't compare blocks meaningfully.
1264
+ if ($from.depth < 1 || $to.depth < 1) {
1265
+ this.blockMenuButtonDisabled.set(true);
1266
+ return;
1267
+ }
1268
+ this.blockMenuButtonDisabled.set($from.before(1) !== $to.before(1));
1269
+ }
1270
+ /**
1271
+ * Update the trigger's indicator colors to mirror the current selection.
1272
+ * Used by the "A" button so the underline matches whatever the user has
1273
+ * applied at the cursor (Notion-style live preview).
1274
+ */
1275
+ syncColorTriggerState(editor) {
1276
+ const $from = editor.state.selection.$from;
1277
+ const mark = $from.marks().find((m) => m.type.name === 'textStyle');
1278
+ const attrs = (mark?.attrs ?? {});
1279
+ const textToken = attrs.colorToken ?? null;
1280
+ const bgToken = attrs.backgroundColorToken ?? null;
1281
+ this.currentTextColorVar.set(textToken ? `var(--dm-block-text-${textToken})` : null);
1282
+ this.currentBgColorVar.set(bgToken ? `var(--dm-block-bg-${bgToken})` : null);
1283
+ this.hasAnyColor.set(textToken !== null || bgToken !== null);
1125
1284
  }
1126
1285
  updateContextItems(editor) {
1127
- const ctxs = this.contexts();
1286
+ const ctxs = this.resolveContexts(editor);
1287
+ if (!ctxs)
1288
+ return;
1128
1289
  const ctx = this.detectContext(editor.state.selection, ctxs);
1129
1290
  if (!ctx) {
1130
1291
  this.resolvedItems.set([]);
@@ -1155,40 +1316,237 @@ class DomternalBubbleMenuComponent {
1155
1316
  try {
1156
1317
  canProxy = editor.can();
1157
1318
  }
1158
- catch { }
1159
- for (const item of this.resolvedItems()) {
1160
- if (item.type === 'separator')
1161
- continue;
1162
- this.activeMap.set(item.name, ToolbarController.resolveActive(editor, item));
1319
+ catch { /* ignore */ }
1320
+ const trackButton = (btn) => {
1321
+ this.activeMap.set(btn.name, ToolbarController.resolveActive(editor, btn));
1163
1322
  try {
1164
- const canCmd = canProxy?.[item.command];
1165
- this.disabledMap.set(item.name, canCmd
1166
- ? !(item.commandArgs?.length ? canCmd(...item.commandArgs) : canCmd())
1323
+ const canCmd = typeof btn.command === 'string' ? canProxy?.[btn.command] : undefined;
1324
+ this.disabledMap.set(btn.name, canCmd
1325
+ ? !(btn.commandArgs?.length ? canCmd(...btn.commandArgs) : canCmd())
1167
1326
  : false);
1168
1327
  }
1169
1328
  catch {
1170
- this.disabledMap.set(item.name, false);
1329
+ this.disabledMap.set(btn.name, false);
1171
1330
  }
1331
+ };
1332
+ for (const item of this.resolvedItems()) {
1333
+ if (item.type === 'separator')
1334
+ continue;
1335
+ if (item.type === 'dropdown') {
1336
+ // Track each child so dynamicIcon + isActive lookups work.
1337
+ for (const sub of item.items)
1338
+ trackButton(sub);
1339
+ continue;
1340
+ }
1341
+ trackButton(item);
1172
1342
  }
1173
1343
  }
1344
+ // === Dropdown helpers (shared shape with toolbar.component.ts) ===
1345
+ asButton(item) {
1346
+ return item;
1347
+ }
1348
+ asDropdown(item) {
1349
+ return item;
1350
+ }
1351
+ isDropdownActive(dropdown) {
1352
+ this.activeVersion();
1353
+ return dropdown.items.some((sub) => this.activeMap.get(sub.name) ?? false);
1354
+ }
1355
+ /** Trigger HTML: swap icon to the active child when `dynamicIcon` is set. */
1356
+ getDropdownTriggerHtml(dropdown) {
1357
+ this.activeVersion();
1358
+ const activeItem = dropdown.dynamicIcon
1359
+ ? dropdown.items.find((sub) => this.activeMap.get(sub.name))
1360
+ : undefined;
1361
+ const iconName = activeItem?.icon ?? dropdown.icon;
1362
+ const key = `t:${iconName}`;
1363
+ let cached = this.htmlCache.get(key);
1364
+ if (!cached) {
1365
+ const custom = this.icons();
1366
+ const svg = custom?.[iconName] ?? defaultIcons[iconName] ?? '';
1367
+ cached = this.sanitizer.bypassSecurityTrustHtml(svg + this.dropdownCaret);
1368
+ this.htmlCache.set(key, cached);
1369
+ }
1370
+ return cached;
1371
+ }
1372
+ getCachedItemContent(iconName, label) {
1373
+ const key = `dc:${iconName}:${label}`;
1374
+ let cached = this.htmlCache.get(key);
1375
+ if (!cached) {
1376
+ const custom = this.icons();
1377
+ const svg = custom?.[iconName] ?? defaultIcons[iconName] ?? '';
1378
+ cached = this.sanitizer.bypassSecurityTrustHtml(`${svg} ${label}`);
1379
+ this.htmlCache.set(key, cached);
1380
+ }
1381
+ return cached;
1382
+ }
1383
+ isSubItemActive(name) {
1384
+ this.activeVersion();
1385
+ return this.activeMap.get(name) ?? false;
1386
+ }
1387
+ onDropdownToggle(dropdown, event) {
1388
+ const isOpening = this.openDropdown() !== dropdown.name;
1389
+ this.cleanupDropdown();
1390
+ if (!isOpening)
1391
+ return;
1392
+ this.openDropdown.set(dropdown.name);
1393
+ // NOTE: we deliberately do NOT broadcast `dm:dismiss-overlays` here.
1394
+ // The bubble menu plugin in core listens for that event and would
1395
+ // hide itself - taking our just-opened dropdown's trigger with it.
1396
+ // Other overlays (color picker, link popover) close themselves via
1397
+ // their own click-outside handlers when the user clicks our trigger,
1398
+ // so cooperative dismissal still works.
1399
+ const editorElBroadcast = this.menuEl().nativeElement.closest('.dm-editor');
1400
+ // Position the panel against the trigger after Angular renders it.
1401
+ // We deliberately keep the panel INSIDE `.dm-bubble-menu` so it
1402
+ // inherits the bubble-menu-scoped button tokens (--dm-button-active-bg
1403
+ // / --dm-button-active-color). Reparenting into `.dm-editor` was
1404
+ // tempting for consistency with the color picker, but that dropped
1405
+ // the active-state colors because those tokens aren't defined at
1406
+ // editor scope. floating-ui handles cross-element positioning fine
1407
+ // without reparenting.
1408
+ requestAnimationFrame(() => {
1409
+ const trigger = event?.currentTarget
1410
+ ?? this.menuEl().nativeElement.querySelector(`[data-dropdown="${dropdown.name}"]`);
1411
+ const panel = this.menuEl().nativeElement.querySelector(`[data-dropdown-panel="${dropdown.name}"]`);
1412
+ if (!trigger || !panel)
1413
+ return;
1414
+ this.dropdownCleanupFloating?.();
1415
+ this.dropdownCleanupFloating = positionFloatingOnce(trigger, panel, {
1416
+ placement: 'bottom-start',
1417
+ offsetValue: 4,
1418
+ });
1419
+ });
1420
+ // Click-outside dismissal. mousedown is used so the dropdown closes
1421
+ // BEFORE the bubble menu's blur handler runs.
1422
+ const onOutside = (e) => {
1423
+ const target = e.target;
1424
+ if (!target)
1425
+ return;
1426
+ const panel = document.querySelector(`[data-dropdown-panel="${dropdown.name}"]`);
1427
+ const trigger = this.menuEl().nativeElement.querySelector(`[data-dropdown="${dropdown.name}"]`);
1428
+ if (panel?.contains(target))
1429
+ return;
1430
+ if (trigger?.contains(target))
1431
+ return;
1432
+ this.cleanupDropdown();
1433
+ };
1434
+ document.addEventListener('mousedown', onOutside);
1435
+ this.dropdownOutsideHandler = onOutside;
1436
+ // Escape closes the dropdown.
1437
+ const onKeydown = (e) => {
1438
+ if (e.key === 'Escape') {
1439
+ e.preventDefault();
1440
+ this.cleanupDropdown();
1441
+ }
1442
+ };
1443
+ document.addEventListener('keydown', onKeydown);
1444
+ this.dropdownKeydownHandler = onKeydown;
1445
+ // Listen for cooperative dismissals from other overlays opening.
1446
+ const onDismiss = () => { this.cleanupDropdown(); };
1447
+ if (editorElBroadcast) {
1448
+ editorElBroadcast.addEventListener('dm:dismiss-overlays', onDismiss);
1449
+ this.dropdownDismissHandler = () => {
1450
+ editorElBroadcast.removeEventListener('dm:dismiss-overlays', onDismiss);
1451
+ };
1452
+ }
1453
+ }
1454
+ onDropdownItemClick(item) {
1455
+ this.cleanupDropdown();
1456
+ ToolbarController.executeItem(this.editor(), item);
1457
+ requestAnimationFrame(() => { this.editor().view.focus(); });
1458
+ }
1459
+ cleanupDropdown() {
1460
+ if (this.dropdownOutsideHandler) {
1461
+ document.removeEventListener('mousedown', this.dropdownOutsideHandler);
1462
+ this.dropdownOutsideHandler = null;
1463
+ }
1464
+ if (this.dropdownKeydownHandler) {
1465
+ document.removeEventListener('keydown', this.dropdownKeydownHandler);
1466
+ this.dropdownKeydownHandler = null;
1467
+ }
1468
+ if (this.dropdownDismissHandler) {
1469
+ this.dropdownDismissHandler();
1470
+ this.dropdownDismissHandler = null;
1471
+ }
1472
+ this.dropdownCleanupFloating?.();
1473
+ this.dropdownCleanupFloating = null;
1474
+ this.openDropdown.set(null);
1475
+ }
1174
1476
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: DomternalBubbleMenuComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1175
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: DomternalBubbleMenuComponent, isStandalone: true, selector: "domternal-bubble-menu", inputs: { editor: { classPropertyName: "editor", publicName: "editor", isSignal: true, isRequired: true, transformFunction: null }, shouldShow: { classPropertyName: "shouldShow", publicName: "shouldShow", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "offset", isSignal: true, isRequired: false, transformFunction: null }, updateDelay: { classPropertyName: "updateDelay", publicName: "updateDelay", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, contexts: { classPropertyName: "contexts", publicName: "contexts", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "menuEl", first: true, predicate: ["menuEl"], descendants: true, isSignal: true }], ngImport: i0, template: `
1477
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: DomternalBubbleMenuComponent, isStandalone: true, selector: "domternal-bubble-menu", inputs: { editor: { classPropertyName: "editor", publicName: "editor", isSignal: true, isRequired: true, transformFunction: null }, shouldShow: { classPropertyName: "shouldShow", publicName: "shouldShow", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "offset", isSignal: true, isRequired: false, transformFunction: null }, updateDelay: { classPropertyName: "updateDelay", publicName: "updateDelay", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, contexts: { classPropertyName: "contexts", publicName: "contexts", isSignal: true, isRequired: false, transformFunction: null }, icons: { classPropertyName: "icons", publicName: "icons", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "menuEl", first: true, predicate: ["menuEl"], descendants: true, isSignal: true }], ngImport: i0, template: `
1176
1478
  <div #menuEl class="dm-bubble-menu" role="toolbar" aria-label="Text formatting">
1177
1479
  @for (item of resolvedItems(); track item.name) {
1178
1480
  @if (item.type === 'separator') {
1179
1481
  <span class="dm-toolbar-separator" role="separator"></span>
1482
+ } @else if (item.type === 'dropdown') {
1483
+ <div class="dm-toolbar-dropdown-wrapper" [attr.data-dropdown-wrapper]="asDropdown(item).name">
1484
+ <button type="button"
1485
+ class="dm-toolbar-button dm-toolbar-dropdown-trigger"
1486
+ [class.dm-toolbar-button--active]="isDropdownActive(asDropdown(item))"
1487
+ [attr.aria-expanded]="openDropdown() === asDropdown(item).name"
1488
+ [attr.aria-haspopup]="'true'"
1489
+ [attr.aria-label]="asDropdown(item).label"
1490
+ [title]="asDropdown(item).label"
1491
+ [attr.data-dropdown]="asDropdown(item).name"
1492
+ [innerHTML]="getDropdownTriggerHtml(asDropdown(item))"
1493
+ (mousedown)="$event.preventDefault()"
1494
+ (click)="onDropdownToggle(asDropdown(item), $event)"></button>
1495
+ @if (openDropdown() === asDropdown(item).name) {
1496
+ <div class="dm-toolbar-dropdown-panel" role="menu"
1497
+ data-dm-editor-ui
1498
+ [attr.data-dropdown-panel]="asDropdown(item).name">
1499
+ @for (sub of asDropdown(item).items; track sub.name) {
1500
+ <button type="button"
1501
+ class="dm-toolbar-dropdown-item"
1502
+ [class.dm-toolbar-dropdown-item--active]="isSubItemActive(sub.name)"
1503
+ role="menuitem"
1504
+ [attr.aria-label]="sub.label"
1505
+ [innerHTML]="getCachedItemContent(sub.icon, sub.label)"
1506
+ (mousedown)="$event.preventDefault()"
1507
+ (click)="onDropdownItemClick(sub)"></button>
1508
+ }
1509
+ </div>
1510
+ }
1511
+ </div>
1180
1512
  } @else {
1181
1513
  <button type="button" class="dm-toolbar-button"
1182
- [class.dm-toolbar-button--active]="isItemActive(item)"
1183
- [attr.aria-pressed]="isItemActive(item)"
1184
- [disabled]="isItemDisabled(item)"
1185
- [title]="item.label"
1186
- [attr.aria-label]="item.label"
1187
- [innerHTML]="getCachedIcon(item.icon)"
1514
+ [class.dm-toolbar-button--active]="isItemActive(asButton(item))"
1515
+ [attr.aria-pressed]="isItemActive(asButton(item))"
1516
+ [disabled]="isItemDisabled(asButton(item))"
1517
+ [title]="asButton(item).label"
1518
+ [attr.aria-label]="asButton(item).label"
1519
+ [innerHTML]="getCachedIcon(asButton(item).icon)"
1188
1520
  (mousedown)="$event.preventDefault()"
1189
- (click)="executeCommand(item)"></button>
1521
+ (click)="executeCommand(asButton(item), $event)"></button>
1190
1522
  }
1191
1523
  }
1524
+ @if (showColorPickerButton() && !isNodeSelection()) {
1525
+ <span class="dm-toolbar-separator" role="separator"></span>
1526
+ <button #colorBtn type="button" class="dm-toolbar-button dm-ncp-trigger"
1527
+ [class.dm-toolbar-button--active]="hasAnyColor()"
1528
+ title="Text and background color"
1529
+ aria-label="Text and background color"
1530
+ aria-haspopup="dialog"
1531
+ (mousedown)="$event.preventDefault()"
1532
+ (click)="openColorPicker(colorBtn)">
1533
+ <span class="dm-ncp-trigger-glyph"
1534
+ [style.color]="currentTextColorVar()">A</span>
1535
+ <span class="dm-ncp-trigger-underline"
1536
+ [style.background-color]="currentBgColorVar()"></span>
1537
+ </button>
1538
+ }
1539
+ @if (showBlockMenuButton() && !isNodeSelection()) {
1540
+ <span class="dm-toolbar-separator" role="separator"></span>
1541
+ <button #blockMenuBtn type="button" class="dm-toolbar-button"
1542
+ [disabled]="blockMenuButtonDisabled()"
1543
+ [title]="blockMenuButtonDisabled() ? 'Block actions (select within a single block)' : 'More options'"
1544
+ aria-label="More options"
1545
+ aria-haspopup="menu"
1546
+ [innerHTML]="getCachedIcon('dotsThree')"
1547
+ (mousedown)="$event.preventDefault()"
1548
+ (click)="openBlockContextMenu(blockMenuBtn)"></button>
1549
+ }
1192
1550
  <ng-content />
1193
1551
  </div>
1194
1552
  `, isInline: true, styles: [":host{display:contents}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
@@ -1200,57 +1558,350 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImpor
1200
1558
  @for (item of resolvedItems(); track item.name) {
1201
1559
  @if (item.type === 'separator') {
1202
1560
  <span class="dm-toolbar-separator" role="separator"></span>
1561
+ } @else if (item.type === 'dropdown') {
1562
+ <div class="dm-toolbar-dropdown-wrapper" [attr.data-dropdown-wrapper]="asDropdown(item).name">
1563
+ <button type="button"
1564
+ class="dm-toolbar-button dm-toolbar-dropdown-trigger"
1565
+ [class.dm-toolbar-button--active]="isDropdownActive(asDropdown(item))"
1566
+ [attr.aria-expanded]="openDropdown() === asDropdown(item).name"
1567
+ [attr.aria-haspopup]="'true'"
1568
+ [attr.aria-label]="asDropdown(item).label"
1569
+ [title]="asDropdown(item).label"
1570
+ [attr.data-dropdown]="asDropdown(item).name"
1571
+ [innerHTML]="getDropdownTriggerHtml(asDropdown(item))"
1572
+ (mousedown)="$event.preventDefault()"
1573
+ (click)="onDropdownToggle(asDropdown(item), $event)"></button>
1574
+ @if (openDropdown() === asDropdown(item).name) {
1575
+ <div class="dm-toolbar-dropdown-panel" role="menu"
1576
+ data-dm-editor-ui
1577
+ [attr.data-dropdown-panel]="asDropdown(item).name">
1578
+ @for (sub of asDropdown(item).items; track sub.name) {
1579
+ <button type="button"
1580
+ class="dm-toolbar-dropdown-item"
1581
+ [class.dm-toolbar-dropdown-item--active]="isSubItemActive(sub.name)"
1582
+ role="menuitem"
1583
+ [attr.aria-label]="sub.label"
1584
+ [innerHTML]="getCachedItemContent(sub.icon, sub.label)"
1585
+ (mousedown)="$event.preventDefault()"
1586
+ (click)="onDropdownItemClick(sub)"></button>
1587
+ }
1588
+ </div>
1589
+ }
1590
+ </div>
1203
1591
  } @else {
1204
1592
  <button type="button" class="dm-toolbar-button"
1205
- [class.dm-toolbar-button--active]="isItemActive(item)"
1206
- [attr.aria-pressed]="isItemActive(item)"
1207
- [disabled]="isItemDisabled(item)"
1208
- [title]="item.label"
1209
- [attr.aria-label]="item.label"
1210
- [innerHTML]="getCachedIcon(item.icon)"
1593
+ [class.dm-toolbar-button--active]="isItemActive(asButton(item))"
1594
+ [attr.aria-pressed]="isItemActive(asButton(item))"
1595
+ [disabled]="isItemDisabled(asButton(item))"
1596
+ [title]="asButton(item).label"
1597
+ [attr.aria-label]="asButton(item).label"
1598
+ [innerHTML]="getCachedIcon(asButton(item).icon)"
1211
1599
  (mousedown)="$event.preventDefault()"
1212
- (click)="executeCommand(item)"></button>
1600
+ (click)="executeCommand(asButton(item), $event)"></button>
1213
1601
  }
1214
1602
  }
1603
+ @if (showColorPickerButton() && !isNodeSelection()) {
1604
+ <span class="dm-toolbar-separator" role="separator"></span>
1605
+ <button #colorBtn type="button" class="dm-toolbar-button dm-ncp-trigger"
1606
+ [class.dm-toolbar-button--active]="hasAnyColor()"
1607
+ title="Text and background color"
1608
+ aria-label="Text and background color"
1609
+ aria-haspopup="dialog"
1610
+ (mousedown)="$event.preventDefault()"
1611
+ (click)="openColorPicker(colorBtn)">
1612
+ <span class="dm-ncp-trigger-glyph"
1613
+ [style.color]="currentTextColorVar()">A</span>
1614
+ <span class="dm-ncp-trigger-underline"
1615
+ [style.background-color]="currentBgColorVar()"></span>
1616
+ </button>
1617
+ }
1618
+ @if (showBlockMenuButton() && !isNodeSelection()) {
1619
+ <span class="dm-toolbar-separator" role="separator"></span>
1620
+ <button #blockMenuBtn type="button" class="dm-toolbar-button"
1621
+ [disabled]="blockMenuButtonDisabled()"
1622
+ [title]="blockMenuButtonDisabled() ? 'Block actions (select within a single block)' : 'More options'"
1623
+ aria-label="More options"
1624
+ aria-haspopup="menu"
1625
+ [innerHTML]="getCachedIcon('dotsThree')"
1626
+ (mousedown)="$event.preventDefault()"
1627
+ (click)="openBlockContextMenu(blockMenuBtn)"></button>
1628
+ }
1215
1629
  <ng-content />
1216
1630
  </div>
1217
1631
  `, styles: [":host{display:contents}\n"] }]
1218
- }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "editor", required: true }] }], shouldShow: [{ type: i0.Input, args: [{ isSignal: true, alias: "shouldShow", required: false }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], offset: [{ type: i0.Input, args: [{ isSignal: true, alias: "offset", required: false }] }], updateDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "updateDelay", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], contexts: [{ type: i0.Input, args: [{ isSignal: true, alias: "contexts", required: false }] }], menuEl: [{ type: i0.ViewChild, args: ['menuEl', { isSignal: true }] }] } });
1632
+ }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "editor", required: true }] }], shouldShow: [{ type: i0.Input, args: [{ isSignal: true, alias: "shouldShow", required: false }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], offset: [{ type: i0.Input, args: [{ isSignal: true, alias: "offset", required: false }] }], updateDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "updateDelay", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], contexts: [{ type: i0.Input, args: [{ isSignal: true, alias: "contexts", required: false }] }], icons: [{ type: i0.Input, args: [{ isSignal: true, alias: "icons", required: false }] }], menuEl: [{ type: i0.ViewChild, args: ['menuEl', { isSignal: true }] }] } });
1219
1633
 
1634
+ /**
1635
+ * Block-insert floating menu for Angular.
1636
+ *
1637
+ * Renders defaults collected from the editor's extensions via
1638
+ * `addFloatingMenuItems()`; use `items` to override the list and `icons`
1639
+ * to swap the icon set.
1640
+ */
1220
1641
  class DomternalFloatingMenuComponent {
1221
1642
  editor = input.required(...(ngDevMode ? [{ debugName: "editor" }] : /* istanbul ignore next */ []));
1222
1643
  shouldShow = input(...(ngDevMode ? [undefined, { debugName: "shouldShow" }] : /* istanbul ignore next */ []));
1223
1644
  offset = input(0, ...(ngDevMode ? [{ debugName: "offset" }] : /* istanbul ignore next */ []));
1645
+ items = input(undefined, ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
1646
+ keymap = input(undefined, ...(ngDevMode ? [{ debugName: "keymap" }] : /* istanbul ignore next */ []));
1647
+ icons = input(undefined, ...(ngDevMode ? [{ debugName: "icons" }] : /* istanbul ignore next */ []));
1648
+ /**
1649
+ * When true, the menu does NOT auto-show on every empty paragraph;
1650
+ * it only opens when the BlockHandle `+` button (or any caller of
1651
+ * `showFloatingMenu`) explicitly triggers it. Notion-style behaviour
1652
+ * - empty rows show a placeholder, the slash menu is the keyboard
1653
+ * trigger, the `+` button is the gutter trigger.
1654
+ * @default false
1655
+ */
1656
+ requireExplicitTrigger = input(false, ...(ngDevMode ? [{ debugName: "requireExplicitTrigger" }] : /* istanbul ignore next */ []));
1224
1657
  menuEl = viewChild.required('menuEl');
1658
+ ngZone = inject(NgZone);
1659
+ sanitizer = inject(DomSanitizer);
1225
1660
  pluginKey;
1661
+ controller = null;
1662
+ // Signal that bumps on every controller change - templates read it to
1663
+ // re-run @for tracking. OnPush components need explicit change triggers.
1664
+ version = signal(0, ...(ngDevMode ? [{ debugName: "version" }] : /* istanbul ignore next */ []));
1665
+ iconCache = new Map();
1666
+ groups = computed(() => {
1667
+ // Read version to subscribe to controller updates.
1668
+ void this.version();
1669
+ return this.controller?.groups ?? [];
1670
+ }, ...(ngDevMode ? [{ debugName: "groups" }] : /* istanbul ignore next */ []));
1671
+ flatNames = computed(() => {
1672
+ void this.version();
1673
+ return this.groups().flatMap((g) => g.items.map((i) => i.name));
1674
+ }, ...(ngDevMode ? [{ debugName: "flatNames" }] : /* istanbul ignore next */ []));
1675
+ focusedIndex = computed(() => {
1676
+ void this.version();
1677
+ return this.controller?.focusedIndex ?? -1;
1678
+ }, ...(ngDevMode ? [{ debugName: "focusedIndex" }] : /* istanbul ignore next */ []));
1226
1679
  constructor() {
1227
- // Unique key per instance multiple floating menus on same page
1228
- this.pluginKey = new PluginKey('angularFloatingMenu-' + Math.random().toString(36).slice(2, 8));
1680
+ // Prefer crypto.randomUUID for collision-free uniqueness (Math.random
1681
+ // across simultaneous mounts may collide; SSR can share the seed).
1682
+ // Matches the bubble-menu component's pattern.
1683
+ const cryptoRef = globalThis.crypto;
1684
+ const suffix = cryptoRef?.randomUUID?.().slice(0, 8) ?? Math.random().toString(36).slice(2, 8);
1685
+ this.pluginKey = new PluginKey('angularFloatingMenu-' + suffix);
1229
1686
  afterNextRender(() => {
1687
+ const editor = this.editor();
1230
1688
  const shouldShow = this.shouldShow();
1689
+ const keymap = this.keymap();
1231
1690
  const plugin = createFloatingMenuPlugin({
1232
1691
  pluginKey: this.pluginKey,
1233
- editor: this.editor(),
1692
+ editor,
1234
1693
  element: this.menuEl().nativeElement,
1235
1694
  ...(shouldShow && { shouldShow }),
1236
1695
  offset: this.offset(),
1696
+ ...(keymap && { keymap }),
1697
+ requireExplicitTrigger: this.requireExplicitTrigger(),
1698
+ });
1699
+ editor.registerPlugin(plugin);
1700
+ // Create controller for default rendering. ProseMirror transactions
1701
+ // fire outside Angular zone, so signal updates inside the onChange
1702
+ // callback must be wrapped to trigger OnPush change detection.
1703
+ const controller = new FloatingMenuController(editor, () => {
1704
+ this.ngZone.run(() => { this.version.update((v) => v + 1); });
1705
+ }, this.items());
1706
+ controller.subscribe();
1707
+ this.controller = controller;
1708
+ // Controller is a plain field, not a signal - bumping `version` is
1709
+ // what tells the `groups`/`focusedIndex` computeds to re-evaluate
1710
+ // and pick up the freshly assigned controller instance.
1711
+ this.version.update((v) => v + 1);
1712
+ });
1713
+ // Imperatively focus the active menuitem on focusedIndex change.
1714
+ effect(() => {
1715
+ const idx = this.focusedIndex();
1716
+ if (idx < 0)
1717
+ return;
1718
+ queueMicrotask(() => {
1719
+ const target = this.menuEl().nativeElement.querySelector(`[data-floating-menu-index="${String(idx)}"]`);
1720
+ target?.focus();
1237
1721
  });
1238
- this.editor().registerPlugin(plugin);
1239
1722
  });
1240
1723
  }
1241
1724
  ngOnDestroy() {
1725
+ this.controller?.destroy();
1726
+ this.controller = null;
1242
1727
  const editor = this.editor();
1243
1728
  if (!editor.isDestroyed) {
1244
1729
  editor.unregisterPlugin(this.pluginKey);
1245
1730
  }
1246
1731
  }
1732
+ // === Template helpers ===
1733
+ flatIndexOf(name) {
1734
+ return this.flatNames().indexOf(name);
1735
+ }
1736
+ tabIndexFor(name) {
1737
+ const flat = this.flatIndexOf(name);
1738
+ const focused = this.focusedIndex();
1739
+ if (flat === focused)
1740
+ return 0;
1741
+ // When menu not yet entered, first item is tabbable so external focus
1742
+ // (e.g. Tab traversal) lands naturally.
1743
+ if (focused < 0 && flat === 0)
1744
+ return 0;
1745
+ return -1;
1746
+ }
1747
+ isItemDisabled(item) {
1748
+ return this.controller?.isDisabled(item) ?? false;
1749
+ }
1750
+ iconHtml(name) {
1751
+ if (!name)
1752
+ return null;
1753
+ const existing = this.iconCache.get(name);
1754
+ if (existing)
1755
+ return existing;
1756
+ const raw = this.icons()?.[name] ?? defaultIcons[name] ?? '';
1757
+ if (!raw)
1758
+ return null;
1759
+ const sanitized = this.sanitizer.bypassSecurityTrustHtml(raw);
1760
+ this.iconCache.set(name, sanitized);
1761
+ return sanitized;
1762
+ }
1763
+ onItemClick(item) {
1764
+ const editor = this.editor();
1765
+ const ctl = this.controller;
1766
+ if (!ctl)
1767
+ return;
1768
+ ctl.execute(item);
1769
+ requestAnimationFrame(() => { editor.view.focus(); });
1770
+ }
1771
+ onKeyDown(e) {
1772
+ const ctl = this.controller;
1773
+ if (!ctl)
1774
+ return;
1775
+ const focused = ctl.focusedItem();
1776
+ switch (e.key) {
1777
+ case 'ArrowDown':
1778
+ e.preventDefault();
1779
+ ctl.next();
1780
+ return;
1781
+ case 'ArrowUp':
1782
+ e.preventDefault();
1783
+ ctl.prev();
1784
+ return;
1785
+ case 'Home':
1786
+ e.preventDefault();
1787
+ ctl.first();
1788
+ return;
1789
+ case 'End':
1790
+ e.preventDefault();
1791
+ ctl.last();
1792
+ return;
1793
+ case 'Escape':
1794
+ e.preventDefault();
1795
+ e.stopPropagation();
1796
+ ctl.leaveMenu();
1797
+ this.editor().view.focus();
1798
+ return;
1799
+ case 'Enter':
1800
+ case ' ':
1801
+ if (focused) {
1802
+ e.preventDefault();
1803
+ this.onItemClick(focused);
1804
+ }
1805
+ return;
1806
+ default:
1807
+ return;
1808
+ }
1809
+ }
1247
1810
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: DomternalFloatingMenuComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1248
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.5", type: DomternalFloatingMenuComponent, isStandalone: true, selector: "domternal-floating-menu", inputs: { editor: { classPropertyName: "editor", publicName: "editor", isSignal: true, isRequired: true, transformFunction: null }, shouldShow: { classPropertyName: "shouldShow", publicName: "shouldShow", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "offset", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "menuEl", first: true, predicate: ["menuEl"], descendants: true, isSignal: true }], ngImport: i0, template: '<div #menuEl class="dm-floating-menu"><ng-content /></div>', isInline: true, styles: [":host{display:contents}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1811
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: DomternalFloatingMenuComponent, isStandalone: true, selector: "domternal-floating-menu", inputs: { editor: { classPropertyName: "editor", publicName: "editor", isSignal: true, isRequired: true, transformFunction: null }, shouldShow: { classPropertyName: "shouldShow", publicName: "shouldShow", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "offset", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, keymap: { classPropertyName: "keymap", publicName: "keymap", isSignal: true, isRequired: false, transformFunction: null }, icons: { classPropertyName: "icons", publicName: "icons", isSignal: true, isRequired: false, transformFunction: null }, requireExplicitTrigger: { classPropertyName: "requireExplicitTrigger", publicName: "requireExplicitTrigger", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "menuEl", first: true, predicate: ["menuEl"], descendants: true, isSignal: true }], ngImport: i0, template: `
1812
+ <div
1813
+ #menuEl
1814
+ class="dm-floating-menu"
1815
+ role="menu"
1816
+ aria-label="Insert block"
1817
+ data-dm-editor-ui=""
1818
+ (keydown)="onKeyDown($event)"
1819
+ >
1820
+ @for (group of groups(); track group.name || $index; let gi = $index) {
1821
+ @if (group.name) {
1822
+ <div class="dm-floating-menu-group-label" [id]="'dm-fm-g' + gi">{{ group.name }}</div>
1823
+ }
1824
+ <div
1825
+ class="dm-floating-menu-group"
1826
+ role="group"
1827
+ [attr.aria-labelledby]="group.name ? 'dm-fm-g' + gi : null"
1828
+ >
1829
+ @for (item of group.items; track item.name) {
1830
+ <button
1831
+ type="button"
1832
+ role="menuitem"
1833
+ class="dm-floating-menu-item"
1834
+ [attr.data-floating-menu-item]="item.name"
1835
+ [attr.data-floating-menu-index]="flatIndexOf(item.name)"
1836
+ [attr.tabindex]="tabIndexFor(item.name)"
1837
+ [attr.aria-disabled]="isItemDisabled(item) ? 'true' : null"
1838
+ [attr.aria-keyshortcuts]="item.shortcut"
1839
+ [disabled]="isItemDisabled(item)"
1840
+ (mousedown)="$event.preventDefault()"
1841
+ (click)="onItemClick(item)"
1842
+ >
1843
+ @if (iconHtml(item.icon); as html) {
1844
+ <span class="dm-floating-menu-item-icon" aria-hidden="true" [innerHTML]="html"></span>
1845
+ }
1846
+ <span class="dm-floating-menu-item-label">{{ item.label }}</span>
1847
+ @if (item.shortcut) {
1848
+ <span class="dm-floating-menu-item-shortcut" aria-hidden="true">{{ item.shortcut }}</span>
1849
+ }
1850
+ </button>
1851
+ }
1852
+ </div>
1853
+ }
1854
+ </div>
1855
+ `, isInline: true, styles: [":host{display:contents}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1249
1856
  }
1250
1857
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: DomternalFloatingMenuComponent, decorators: [{
1251
1858
  type: Component,
1252
- args: [{ selector: 'domternal-floating-menu', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: '<div #menuEl class="dm-floating-menu"><ng-content /></div>', styles: [":host{display:contents}\n"] }]
1253
- }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "editor", required: true }] }], shouldShow: [{ type: i0.Input, args: [{ isSignal: true, alias: "shouldShow", required: false }] }], offset: [{ type: i0.Input, args: [{ isSignal: true, alias: "offset", required: false }] }], menuEl: [{ type: i0.ViewChild, args: ['menuEl', { isSignal: true }] }] } });
1859
+ args: [{ selector: 'domternal-floating-menu', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: `
1860
+ <div
1861
+ #menuEl
1862
+ class="dm-floating-menu"
1863
+ role="menu"
1864
+ aria-label="Insert block"
1865
+ data-dm-editor-ui=""
1866
+ (keydown)="onKeyDown($event)"
1867
+ >
1868
+ @for (group of groups(); track group.name || $index; let gi = $index) {
1869
+ @if (group.name) {
1870
+ <div class="dm-floating-menu-group-label" [id]="'dm-fm-g' + gi">{{ group.name }}</div>
1871
+ }
1872
+ <div
1873
+ class="dm-floating-menu-group"
1874
+ role="group"
1875
+ [attr.aria-labelledby]="group.name ? 'dm-fm-g' + gi : null"
1876
+ >
1877
+ @for (item of group.items; track item.name) {
1878
+ <button
1879
+ type="button"
1880
+ role="menuitem"
1881
+ class="dm-floating-menu-item"
1882
+ [attr.data-floating-menu-item]="item.name"
1883
+ [attr.data-floating-menu-index]="flatIndexOf(item.name)"
1884
+ [attr.tabindex]="tabIndexFor(item.name)"
1885
+ [attr.aria-disabled]="isItemDisabled(item) ? 'true' : null"
1886
+ [attr.aria-keyshortcuts]="item.shortcut"
1887
+ [disabled]="isItemDisabled(item)"
1888
+ (mousedown)="$event.preventDefault()"
1889
+ (click)="onItemClick(item)"
1890
+ >
1891
+ @if (iconHtml(item.icon); as html) {
1892
+ <span class="dm-floating-menu-item-icon" aria-hidden="true" [innerHTML]="html"></span>
1893
+ }
1894
+ <span class="dm-floating-menu-item-label">{{ item.label }}</span>
1895
+ @if (item.shortcut) {
1896
+ <span class="dm-floating-menu-item-shortcut" aria-hidden="true">{{ item.shortcut }}</span>
1897
+ }
1898
+ </button>
1899
+ }
1900
+ </div>
1901
+ }
1902
+ </div>
1903
+ `, styles: [":host{display:contents}\n"] }]
1904
+ }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "editor", required: true }] }], shouldShow: [{ type: i0.Input, args: [{ isSignal: true, alias: "shouldShow", required: false }] }], offset: [{ type: i0.Input, args: [{ isSignal: true, alias: "offset", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], keymap: [{ type: i0.Input, args: [{ isSignal: true, alias: "keymap", required: false }] }], icons: [{ type: i0.Input, args: [{ isSignal: true, alias: "icons", required: false }] }], requireExplicitTrigger: [{ type: i0.Input, args: [{ isSignal: true, alias: "requireExplicitTrigger", required: false }] }], menuEl: [{ type: i0.ViewChild, args: ['menuEl', { isSignal: true }] }] } });
1254
1905
 
1255
1906
  const CATEGORY_ICONS = {
1256
1907
  'Smileys & Emotion': '\u{1F600}',
@@ -1305,7 +1956,9 @@ class DomternalEmojiPickerComponent {
1305
1956
  // Re-evaluate when panel opens (isOpen changes)
1306
1957
  this.isOpen();
1307
1958
  const storage = this.getEmojiStorage();
1308
- const getFreq = storage?.['getFrequentlyUsed'];
1959
+ if (!storage)
1960
+ return [];
1961
+ const getFreq = storage['getFrequentlyUsed'];
1309
1962
  if (!getFreq)
1310
1963
  return [];
1311
1964
  const names = getFreq();
@@ -1319,7 +1972,7 @@ class DomternalEmojiPickerComponent {
1319
1972
  constructor() {
1320
1973
  effect(() => {
1321
1974
  const editor = this.editor();
1322
- untracked(() => this.setupEventListener(editor));
1975
+ untracked(() => { this.setupEventListener(editor); });
1323
1976
  });
1324
1977
  }
1325
1978
  ngOnDestroy() {
@@ -1376,9 +2029,9 @@ class DomternalEmojiPickerComponent {
1376
2029
  if (!swatches.length)
1377
2030
  return;
1378
2031
  const current = document.activeElement;
1379
- let idx = swatches.indexOf(current);
2032
+ const idx = swatches.indexOf(current);
1380
2033
  if (idx === -1) {
1381
- // Focus is on grid container, not a swatch enter the grid
2034
+ // Focus is on grid container, not a swatch - enter the grid
1382
2035
  if (['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp'].includes(event.key)) {
1383
2036
  event.preventDefault();
1384
2037
  swatches[0]?.focus();
@@ -1481,14 +2134,14 @@ class DomternalEmojiPickerComponent {
1481
2134
  !this.elRef.nativeElement.contains(target) &&
1482
2135
  target !== this.anchorEl &&
1483
2136
  !this.anchorEl?.contains(target)) {
1484
- this.ngZone.run(() => this.close());
2137
+ this.ngZone.run(() => { this.close(); });
1485
2138
  }
1486
2139
  };
1487
2140
  document.addEventListener('mousedown', this.clickOutsideHandler);
1488
2141
  this.keydownHandler = (e) => {
1489
2142
  if (e.key === 'Escape' && this.isOpen()) {
1490
2143
  e.preventDefault();
1491
- this.ngZone.run(() => this.close());
2144
+ this.ngZone.run(() => { this.close(); });
1492
2145
  }
1493
2146
  };
1494
2147
  document.addEventListener('keydown', this.keydownHandler);
@@ -1698,9 +2351,462 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImpor
1698
2351
  }]
1699
2352
  }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "editor", required: true }] }], emojis: [{ type: i0.Input, args: [{ isSignal: true, alias: "emojis", required: true }] }] } });
1700
2353
 
2354
+ /**
2355
+ * Display labels for the named-token palette. Used in tooltips / aria labels;
2356
+ * unknown tokens fall back to a title-cased version of the raw key.
2357
+ */
2358
+ const TOKEN_LABELS = {
2359
+ gray: 'Gray',
2360
+ brown: 'Brown',
2361
+ orange: 'Orange',
2362
+ yellow: 'Yellow',
2363
+ green: 'Green',
2364
+ blue: 'Blue',
2365
+ purple: 'Purple',
2366
+ pink: 'Pink',
2367
+ red: 'Red',
2368
+ };
2369
+ class DomternalNotionColorPickerComponent {
2370
+ editor = input.required(...(ngDevMode ? [{ debugName: "editor" }] : /* istanbul ignore next */ []));
2371
+ isOpen = signal(false, ...(ngDevMode ? [{ debugName: "isOpen" }] : /* istanbul ignore next */ []));
2372
+ currentTextToken = signal(null, ...(ngDevMode ? [{ debugName: "currentTextToken" }] : /* istanbul ignore next */ []));
2373
+ currentBgToken = signal(null, ...(ngDevMode ? [{ debugName: "currentBgToken" }] : /* istanbul ignore next */ []));
2374
+ palette = signal([], ...(ngDevMode ? [{ debugName: "palette" }] : /* istanbul ignore next */ []));
2375
+ anchorEl = null;
2376
+ // Reference to the picker panel after it has been reparented into
2377
+ // `.dm-editor`. After reparenting `elRef.nativeElement.contains(panel)`
2378
+ // returns false, so any inside-click detection must consult `panelEl`
2379
+ // directly rather than walking up from the component host.
2380
+ panelEl = null;
2381
+ ngZone = inject(NgZone);
2382
+ elRef = inject(ElementRef);
2383
+ eventHandler = null;
2384
+ clickOutsideHandler = null;
2385
+ keydownHandler = null;
2386
+ selectionHandler = null;
2387
+ cleanupFloating = null;
2388
+ constructor() {
2389
+ effect(() => {
2390
+ const editor = this.editor();
2391
+ untracked(() => { this.setupEventListener(editor); });
2392
+ });
2393
+ }
2394
+ ngOnDestroy() {
2395
+ this.cleanup();
2396
+ }
2397
+ tokenLabel(token) {
2398
+ return TOKEN_LABELS[token] ?? token.charAt(0).toUpperCase() + token.slice(1);
2399
+ }
2400
+ applyText(token) {
2401
+ const editor = this.editor();
2402
+ editor.commands.setTextColorToken(token);
2403
+ // Picker stays open so the user can chain picks; close mirrors the bubble
2404
+ // menu (outside-click / Escape / empty selection).
2405
+ this.syncFromSelection();
2406
+ }
2407
+ applyBg(token) {
2408
+ const editor = this.editor();
2409
+ editor.commands.setBackgroundColorToken(token);
2410
+ this.syncFromSelection();
2411
+ }
2412
+ /**
2413
+ * Arrow-key navigation across the 5-column swatch grids. Arrows move
2414
+ * within and across rows, Home/End jump to grid boundaries, Tab leaves
2415
+ * the picker (browser default), Enter/Space activates the focused swatch.
2416
+ * Focus moves sequentially through Text color → Background color so
2417
+ * keyboard users can reach any swatch without re-grabbing Tab.
2418
+ */
2419
+ onPanelKeydown(event) {
2420
+ const cols = 5;
2421
+ // Query off the tracked panel; after reparenting into `.dm-editor` the
2422
+ // swatches are no longer descendants of the component host element.
2423
+ const root = this.panelEl ?? this.elRef.nativeElement;
2424
+ const swatches = Array.from(root.querySelectorAll('.dm-ncp-swatch'));
2425
+ if (!swatches.length)
2426
+ return;
2427
+ const active = document.activeElement;
2428
+ const idx = active ? swatches.indexOf(active) : -1;
2429
+ if (idx === -1)
2430
+ return;
2431
+ let next = idx;
2432
+ switch (event.key) {
2433
+ case 'ArrowRight':
2434
+ event.preventDefault();
2435
+ next = Math.min(idx + 1, swatches.length - 1);
2436
+ break;
2437
+ case 'ArrowLeft':
2438
+ event.preventDefault();
2439
+ next = Math.max(idx - 1, 0);
2440
+ break;
2441
+ case 'ArrowDown':
2442
+ event.preventDefault();
2443
+ next = Math.min(idx + cols, swatches.length - 1);
2444
+ break;
2445
+ case 'ArrowUp':
2446
+ event.preventDefault();
2447
+ next = Math.max(idx - cols, 0);
2448
+ break;
2449
+ case 'Home':
2450
+ event.preventDefault();
2451
+ next = 0;
2452
+ break;
2453
+ case 'End':
2454
+ event.preventDefault();
2455
+ next = swatches.length - 1;
2456
+ break;
2457
+ default:
2458
+ return;
2459
+ }
2460
+ swatches[next]?.focus();
2461
+ }
2462
+ close(opts = {}) {
2463
+ if (!this.isOpen())
2464
+ return;
2465
+ this.cleanupFloating?.();
2466
+ this.cleanupFloating = null;
2467
+ this.isOpen.set(false);
2468
+ this.setStorageOpen(false);
2469
+ this.anchorEl = null;
2470
+ this.panelEl = null;
2471
+ this.removeGlobalListeners();
2472
+ if (opts.refocus) {
2473
+ this.editor().view.focus();
2474
+ }
2475
+ }
2476
+ setupEventListener(editor) {
2477
+ this.cleanup();
2478
+ this.eventHandler = (...args) => {
2479
+ const data = args[0];
2480
+ this.ngZone.run(() => {
2481
+ const anchor = data?.anchorElement;
2482
+ if (!anchor)
2483
+ return;
2484
+ // Toggle: clicking the same anchor while open closes the picker.
2485
+ if (this.isOpen() && this.anchorEl === anchor) {
2486
+ this.close({ refocus: true });
2487
+ return;
2488
+ }
2489
+ this.anchorEl = anchor;
2490
+ this.palette.set(this.readPalette());
2491
+ this.syncFromSelection();
2492
+ this.isOpen.set(true);
2493
+ this.setStorageOpen(true);
2494
+ this.addGlobalListeners();
2495
+ // Position panel against the trigger after Angular renders the DOM.
2496
+ // Focus management (move into the first/active swatch) runs on a
2497
+ // second rAF so positioning + paint has flushed; focusing during the
2498
+ // same frame as mount can race with the bubble-menu's blur handler
2499
+ // and momentarily hide the floating layer Playwright observes.
2500
+ requestAnimationFrame(() => {
2501
+ const panel = this.elRef.nativeElement.querySelector('.dm-notion-color-picker');
2502
+ if (panel && this.anchorEl) {
2503
+ // Reparent into `.dm-editor` so the panel inherits the editor's
2504
+ // CSS custom properties (`--dm-block-bg-*`, `--dm-block-text-*`,
2505
+ // surface/border tokens). All variables in @domternal/theme are
2506
+ // scoped to `.dm-editor`; without reparenting the picker would
2507
+ // sit as a sibling and render with bare fallbacks.
2508
+ const editorEl = this.anchorEl.closest('.dm-editor');
2509
+ if (editorEl && panel.parentElement !== editorEl) {
2510
+ editorEl.appendChild(panel);
2511
+ }
2512
+ this.panelEl = panel;
2513
+ this.cleanupFloating?.();
2514
+ this.cleanupFloating = positionFloating(this.anchorEl, panel, {
2515
+ placement: 'bottom-start',
2516
+ offsetValue: 4,
2517
+ });
2518
+ }
2519
+ requestAnimationFrame(() => {
2520
+ if (!this.isOpen())
2521
+ return; // bailed out between frames
2522
+ // Use the tracked panel reference: after reparenting into
2523
+ // `.dm-editor` the panel is no longer a descendant of the
2524
+ // component host, so `elRef.nativeElement.querySelector` returns
2525
+ // null and focus never lands.
2526
+ const p = this.panelEl;
2527
+ if (!p)
2528
+ return;
2529
+ const active = p.querySelector('.dm-ncp-swatch.dm-ncp-active');
2530
+ const fallback = p.querySelector('.dm-ncp-swatch--text[data-color="null"]');
2531
+ (active ?? fallback)?.focus({ preventScroll: true });
2532
+ });
2533
+ });
2534
+ });
2535
+ };
2536
+ editor.on('notionColorOpen', this.eventHandler);
2537
+ }
2538
+ addGlobalListeners() {
2539
+ this.clickOutsideHandler = (e) => {
2540
+ if (!this.isOpen())
2541
+ return;
2542
+ const target = e.target;
2543
+ // Clicks inside the reparented panel or on the anchor (which toggles
2544
+ // via the event handler) must not trigger outside-close. The panel
2545
+ // has been moved out of `elRef.nativeElement` to inherit editor CSS
2546
+ // vars, so we cannot rely on the host element for containment.
2547
+ if (this.panelEl?.contains(target))
2548
+ return;
2549
+ if (this.elRef.nativeElement.contains(target))
2550
+ return;
2551
+ if (this.anchorEl?.contains(target))
2552
+ return;
2553
+ this.ngZone.run(() => { this.close({ refocus: false }); });
2554
+ };
2555
+ document.addEventListener('mousedown', this.clickOutsideHandler);
2556
+ this.keydownHandler = (e) => {
2557
+ if (e.key === 'Escape' && this.isOpen()) {
2558
+ e.preventDefault();
2559
+ this.ngZone.run(() => { this.close({ refocus: true }); });
2560
+ }
2561
+ };
2562
+ document.addEventListener('keydown', this.keydownHandler);
2563
+ // Empty selection (user clicked elsewhere) closes the picker since the
2564
+ // bubble-menu anchor it sits on is about to vanish anyway.
2565
+ this.selectionHandler = () => {
2566
+ if (!this.isOpen())
2567
+ return;
2568
+ // Defensive: bubble menu may have vanished between transactions
2569
+ // (hideout, route change). Positioning against a detached anchor is
2570
+ // a no-op; close instead. Matches React/Vue behaviour.
2571
+ if (this.anchorEl && !this.anchorEl.isConnected) {
2572
+ this.ngZone.run(() => { this.close({ refocus: false }); });
2573
+ return;
2574
+ }
2575
+ if (this.editor().state.selection.empty) {
2576
+ this.ngZone.run(() => { this.close({ refocus: false }); });
2577
+ }
2578
+ else {
2579
+ // Selection changed but stays non-empty (e.g. shift+arrow): refresh
2580
+ // the active-state indicators against the new mark set.
2581
+ this.syncFromSelection();
2582
+ }
2583
+ };
2584
+ this.editor().on('selectionUpdate', this.selectionHandler);
2585
+ }
2586
+ removeGlobalListeners() {
2587
+ if (this.clickOutsideHandler) {
2588
+ document.removeEventListener('mousedown', this.clickOutsideHandler);
2589
+ this.clickOutsideHandler = null;
2590
+ }
2591
+ if (this.keydownHandler) {
2592
+ document.removeEventListener('keydown', this.keydownHandler);
2593
+ this.keydownHandler = null;
2594
+ }
2595
+ if (this.selectionHandler) {
2596
+ this.editor().off('selectionUpdate', this.selectionHandler);
2597
+ this.selectionHandler = null;
2598
+ }
2599
+ }
2600
+ syncFromSelection() {
2601
+ const editor = this.editor();
2602
+ const { selection } = editor.state;
2603
+ // Empty cursor: stored-marks semantics (what newly-typed text inherits).
2604
+ // Non-empty: scan the range for the first text node carrying a textStyle
2605
+ // mark. `$from.nodeAfter` can land on a block (paragraph) when the
2606
+ // selection starts at a block boundary (e.g. AllSelection / selectAll),
2607
+ // so walking text nodes is necessary for correct active-state detection.
2608
+ let mark = null;
2609
+ if (selection.empty) {
2610
+ mark = selection.$from.marks().find((m) => m.type.name === 'textStyle') ?? null;
2611
+ }
2612
+ else {
2613
+ editor.state.doc.nodesBetween(selection.from, selection.to, (node) => {
2614
+ if (mark)
2615
+ return false;
2616
+ if (node.isText) {
2617
+ const found = node.marks.find((m) => m.type.name === 'textStyle');
2618
+ if (found)
2619
+ mark = found;
2620
+ }
2621
+ return true;
2622
+ });
2623
+ }
2624
+ const attrs = (mark?.attrs ?? {});
2625
+ this.currentTextToken.set(attrs.colorToken ?? null);
2626
+ this.currentBgToken.set(attrs.backgroundColorToken ?? null);
2627
+ }
2628
+ readPalette() {
2629
+ const ext = this.editor().extensionManager.extensions.find((e) => e.name === 'notionColorPicker');
2630
+ const options = (ext?.options ?? null);
2631
+ return options?.palette ? [...options.palette] : [];
2632
+ }
2633
+ getStorage() {
2634
+ const storage = this.editor().storage;
2635
+ const slot = storage['notionColorPicker'];
2636
+ if (!slot || typeof slot !== 'object')
2637
+ return null;
2638
+ return slot;
2639
+ }
2640
+ setStorageOpen(open) {
2641
+ const storage = this.getStorage();
2642
+ if (storage)
2643
+ storage.isOpen = open;
2644
+ }
2645
+ cleanup() {
2646
+ this.removeGlobalListeners();
2647
+ this.cleanupFloating?.();
2648
+ this.cleanupFloating = null;
2649
+ if (this.eventHandler) {
2650
+ const editor = this.editor();
2651
+ editor.off('notionColorOpen', this.eventHandler);
2652
+ this.eventHandler = null;
2653
+ }
2654
+ }
2655
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: DomternalNotionColorPickerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2656
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: DomternalNotionColorPickerComponent, isStandalone: true, selector: "domternal-notion-color-picker", inputs: { editor: { classPropertyName: "editor", publicName: "editor", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
2657
+ @if (isOpen()) {
2658
+ <div
2659
+ class="dm-notion-color-picker"
2660
+ data-show
2661
+ data-dm-editor-ui
2662
+ role="dialog"
2663
+ aria-label="Text and background color"
2664
+ aria-modal="false"
2665
+ (keydown)="onPanelKeydown($event)"
2666
+ >
2667
+ <div class="dm-ncp-section">
2668
+ <div class="dm-ncp-label">Text color</div>
2669
+ <div class="dm-ncp-grid">
2670
+ <button
2671
+ type="button"
2672
+ class="dm-ncp-swatch dm-ncp-swatch--text"
2673
+ [class.dm-ncp-active]="currentTextToken() === null"
2674
+ [attr.aria-pressed]="currentTextToken() === null"
2675
+ data-color="null"
2676
+ title="Default text color"
2677
+ aria-label="Default text color"
2678
+ (mousedown)="$event.preventDefault()"
2679
+ (click)="applyText(null)"
2680
+ ></button>
2681
+ @for (t of palette(); track t) {
2682
+ <button
2683
+ type="button"
2684
+ class="dm-ncp-swatch dm-ncp-swatch--text"
2685
+ [class.dm-ncp-active]="currentTextToken() === t"
2686
+ [attr.aria-pressed]="currentTextToken() === t"
2687
+ [attr.data-color]="t"
2688
+ [title]="tokenLabel(t)"
2689
+ [attr.aria-label]="tokenLabel(t) + ' text'"
2690
+ (mousedown)="$event.preventDefault()"
2691
+ (click)="applyText(t)"
2692
+ ></button>
2693
+ }
2694
+ </div>
2695
+ </div>
2696
+
2697
+ <div class="dm-ncp-section">
2698
+ <div class="dm-ncp-label">Background color</div>
2699
+ <div class="dm-ncp-grid">
2700
+ <button
2701
+ type="button"
2702
+ class="dm-ncp-swatch dm-ncp-swatch--bg"
2703
+ [class.dm-ncp-active]="currentBgToken() === null"
2704
+ [attr.aria-pressed]="currentBgToken() === null"
2705
+ data-color="null"
2706
+ title="Default background"
2707
+ aria-label="Default background"
2708
+ (mousedown)="$event.preventDefault()"
2709
+ (click)="applyBg(null)"
2710
+ ></button>
2711
+ @for (t of palette(); track t) {
2712
+ <button
2713
+ type="button"
2714
+ class="dm-ncp-swatch dm-ncp-swatch--bg"
2715
+ [class.dm-ncp-active]="currentBgToken() === t"
2716
+ [attr.aria-pressed]="currentBgToken() === t"
2717
+ [attr.data-color]="t"
2718
+ [title]="tokenLabel(t) + ' background'"
2719
+ [attr.aria-label]="tokenLabel(t) + ' background'"
2720
+ (mousedown)="$event.preventDefault()"
2721
+ (click)="applyBg(t)"
2722
+ ></button>
2723
+ }
2724
+ </div>
2725
+ </div>
2726
+ </div>
2727
+ }
2728
+ `, isInline: true, styles: [":host{display:contents}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
2729
+ }
2730
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: DomternalNotionColorPickerComponent, decorators: [{
2731
+ type: Component,
2732
+ args: [{ selector: 'domternal-notion-color-picker', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: `
2733
+ @if (isOpen()) {
2734
+ <div
2735
+ class="dm-notion-color-picker"
2736
+ data-show
2737
+ data-dm-editor-ui
2738
+ role="dialog"
2739
+ aria-label="Text and background color"
2740
+ aria-modal="false"
2741
+ (keydown)="onPanelKeydown($event)"
2742
+ >
2743
+ <div class="dm-ncp-section">
2744
+ <div class="dm-ncp-label">Text color</div>
2745
+ <div class="dm-ncp-grid">
2746
+ <button
2747
+ type="button"
2748
+ class="dm-ncp-swatch dm-ncp-swatch--text"
2749
+ [class.dm-ncp-active]="currentTextToken() === null"
2750
+ [attr.aria-pressed]="currentTextToken() === null"
2751
+ data-color="null"
2752
+ title="Default text color"
2753
+ aria-label="Default text color"
2754
+ (mousedown)="$event.preventDefault()"
2755
+ (click)="applyText(null)"
2756
+ ></button>
2757
+ @for (t of palette(); track t) {
2758
+ <button
2759
+ type="button"
2760
+ class="dm-ncp-swatch dm-ncp-swatch--text"
2761
+ [class.dm-ncp-active]="currentTextToken() === t"
2762
+ [attr.aria-pressed]="currentTextToken() === t"
2763
+ [attr.data-color]="t"
2764
+ [title]="tokenLabel(t)"
2765
+ [attr.aria-label]="tokenLabel(t) + ' text'"
2766
+ (mousedown)="$event.preventDefault()"
2767
+ (click)="applyText(t)"
2768
+ ></button>
2769
+ }
2770
+ </div>
2771
+ </div>
2772
+
2773
+ <div class="dm-ncp-section">
2774
+ <div class="dm-ncp-label">Background color</div>
2775
+ <div class="dm-ncp-grid">
2776
+ <button
2777
+ type="button"
2778
+ class="dm-ncp-swatch dm-ncp-swatch--bg"
2779
+ [class.dm-ncp-active]="currentBgToken() === null"
2780
+ [attr.aria-pressed]="currentBgToken() === null"
2781
+ data-color="null"
2782
+ title="Default background"
2783
+ aria-label="Default background"
2784
+ (mousedown)="$event.preventDefault()"
2785
+ (click)="applyBg(null)"
2786
+ ></button>
2787
+ @for (t of palette(); track t) {
2788
+ <button
2789
+ type="button"
2790
+ class="dm-ncp-swatch dm-ncp-swatch--bg"
2791
+ [class.dm-ncp-active]="currentBgToken() === t"
2792
+ [attr.aria-pressed]="currentBgToken() === t"
2793
+ [attr.data-color]="t"
2794
+ [title]="tokenLabel(t) + ' background'"
2795
+ [attr.aria-label]="tokenLabel(t) + ' background'"
2796
+ (mousedown)="$event.preventDefault()"
2797
+ (click)="applyBg(t)"
2798
+ ></button>
2799
+ }
2800
+ </div>
2801
+ </div>
2802
+ </div>
2803
+ }
2804
+ `, styles: [":host{display:contents}\n"] }]
2805
+ }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "editor", required: true }] }] } });
2806
+
1701
2807
  /**
1702
2808
  * Generated bundle index. Do not edit.
1703
2809
  */
1704
2810
 
1705
- export { DEFAULT_EXTENSIONS, DomternalBubbleMenuComponent, DomternalEditorComponent, DomternalEmojiPickerComponent, DomternalFloatingMenuComponent, DomternalToolbarComponent };
2811
+ export { DEFAULT_EXTENSIONS, DomternalBubbleMenuComponent, DomternalEditorComponent, DomternalEmojiPickerComponent, DomternalFloatingMenuComponent, DomternalNotionColorPickerComponent, DomternalToolbarComponent };
1706
2812
  //# sourceMappingURL=domternal-angular.mjs.map