autumnnote 2.1.0 → 2.2.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.
Files changed (65) hide show
  1. package/README.md +72 -5
  2. package/dist/autumnnote.cjs +20 -20
  3. package/dist/autumnnote.es.js +385 -619
  4. package/dist/autumnnote.es.js.map +1 -1
  5. package/dist/autumnnote.min.js +20 -20
  6. package/dist/autumnnote.umd.js +20 -20
  7. package/dist/autumnnote.umd.js.map +1 -1
  8. package/dist/icon-data-V0Xqv-wX.js +255 -0
  9. package/dist/icon-data-V0Xqv-wX.js.map +1 -0
  10. package/package.json +12 -3
  11. package/types/index.d.ts +8 -0
  12. package/src/js/Context.js +0 -854
  13. package/src/js/core/detectLang.js +0 -98
  14. package/src/js/core/dom.js +0 -372
  15. package/src/js/core/env.js +0 -38
  16. package/src/js/core/key.js +0 -66
  17. package/src/js/core/lists.js +0 -121
  18. package/src/js/core/markdown.js +0 -695
  19. package/src/js/core/range.js +0 -194
  20. package/src/js/core/sanitise.js +0 -304
  21. package/src/js/editing/History.js +0 -266
  22. package/src/js/editing/Style.js +0 -812
  23. package/src/js/editing/Table.js +0 -105
  24. package/src/js/editing/Typing.js +0 -397
  25. package/src/js/index.js +0 -193
  26. package/src/js/index.umd.js +0 -17
  27. package/src/js/module/AutoSaveRestore.js +0 -125
  28. package/src/js/module/BaseDialog.js +0 -133
  29. package/src/js/module/BaseMediaTooltip.js +0 -142
  30. package/src/js/module/BaseResizer.js +0 -322
  31. package/src/js/module/BubbleToolbar.js +0 -483
  32. package/src/js/module/Buttons.js +0 -399
  33. package/src/js/module/Clipboard.js +0 -579
  34. package/src/js/module/CodeTooltip.js +0 -493
  35. package/src/js/module/Codeview.js +0 -125
  36. package/src/js/module/ContextMenu.js +0 -621
  37. package/src/js/module/Editor.js +0 -747
  38. package/src/js/module/EmojiDialog.js +0 -254
  39. package/src/js/module/FindReplace.js +0 -512
  40. package/src/js/module/Fullscreen.js +0 -80
  41. package/src/js/module/IconDialog.js +0 -618
  42. package/src/js/module/ImageCropOverlay.js +0 -586
  43. package/src/js/module/ImageDialog.js +0 -193
  44. package/src/js/module/ImageResizer.js +0 -42
  45. package/src/js/module/ImageTooltip.js +0 -285
  46. package/src/js/module/LinkDialog.js +0 -145
  47. package/src/js/module/LinkTooltip.js +0 -250
  48. package/src/js/module/MarkdownShortcuts.js +0 -250
  49. package/src/js/module/Mention.js +0 -365
  50. package/src/js/module/Placeholder.js +0 -51
  51. package/src/js/module/ShortcutsDialog.js +0 -111
  52. package/src/js/module/SlashMenu.js +0 -376
  53. package/src/js/module/Statusbar.js +0 -246
  54. package/src/js/module/TableTooltip.js +0 -1392
  55. package/src/js/module/Toolbar.js +0 -855
  56. package/src/js/module/VideoDialog.js +0 -193
  57. package/src/js/module/VideoResizer.js +0 -66
  58. package/src/js/module/VideoTooltip.js +0 -248
  59. package/src/js/module/emoji-data.js +0 -496
  60. package/src/js/module/table-grid.js +0 -102
  61. package/src/js/module/table-icons.js +0 -33
  62. package/src/js/renderer.js +0 -120
  63. package/src/js/settings.js +0 -214
  64. package/src/styles/_variables.scss +0 -48
  65. package/src/styles/autumnnote.scss +0 -2877
@@ -1,621 +0,0 @@
1
- // ContextMenu.js - Right-click context menu for editor actions
2
- import { createElement, on } from '../core/dom.js';
3
- import { sanitiseHTML } from '../core/sanitise.js';
4
-
5
- // SVG icon map — 16×16 Heroicons-style paths
6
- const ICONS = {
7
- undo: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>`,
8
- redo: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/></svg>`,
9
- cut: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="20" r="3"/><circle cx="6" cy="4" r="3"/><line x1="19" y1="5" x2="6" y2="19"/><line x1="19" y1="19" x2="13.5" y2="13.5"/></svg>`,
10
- copy: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
11
- paste: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>`,
12
- bold: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>`,
13
- italic: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>`,
14
- underline: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>`,
15
- link: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
16
- image: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
17
- video: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>`,
18
- // Image format operations
19
- floatLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="8" height="8" rx="1"/><line x1="12" y1="6" x2="22" y2="6"/><line x1="12" y1="9" x2="22" y2="9"/><line x1="12" y1="12" x2="22" y2="12"/><line x1="2" y1="16" x2="22" y2="16"/><line x1="2" y1="20" x2="18" y2="20"/></svg>`,
20
- floatRight: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="4" width="8" height="8" rx="1"/><line x1="2" y1="6" x2="12" y2="6"/><line x1="2" y1="9" x2="12" y2="9"/><line x1="2" y1="12" x2="12" y2="12"/><line x1="2" y1="16" x2="22" y2="16"/><line x1="2" y1="20" x2="18" y2="20"/></svg>`,
21
- floatNone: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8" rx="1"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="3" y1="19" x2="17" y2="19"/></svg>`,
22
- originalSize:`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>`,
23
- deleteImg: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
24
- // Video format operations
25
- videoAlign: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>`,
26
- deleteVideo: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
27
- // Format painter operations
28
- copyFormat: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
29
- pasteFormat: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/><path d="m9 14 2 2 4-4"/></svg>`,
30
- removeFormat:`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>`,
31
- // Table
32
- table: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>`,
33
- // Color
34
- textColor: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20L12 4L20 20"/><line x1="7.5" y1="14" x2="16.5" y2="14"/></svg>`,
35
- highlightColor: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21v-4l9-9 4 4-9 9z"/><path d="M12 8l4 4"/><line x1="3" y1="21" x2="21" y2="21"/></svg>`,
36
- noColor: `<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="4" y1="4" x2="20" y2="20"/><line x1="20" y1="4" x2="4" y2="20"/></svg>`,
37
- back: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>`,
38
- };
39
-
40
- const COLOR_PRESETS = [
41
- // Grayscale
42
- '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#efefef', '#ffffff',
43
- // Saturated
44
- '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#9900ff', '#ff00ff',
45
- // Pastel
46
- '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#d9d2e9', '#ead1dc',
47
- ];
48
-
49
- function makeColorSubItems(colorType) {
50
- const label = colorType === 'foreColor' ? 'Text Color' : 'Highlight Color';
51
- const localeKey = colorType === 'foreColor' ? 'textColor' : 'highlightColor';
52
- return () => [
53
- { back: true, label, localeKey, navigate: () => defaultItems },
54
- { colorPalette: true, colorType },
55
- ];
56
- }
57
-
58
- const defaultItems = [
59
- { name: 'cut', label: 'Cut', icon: ICONS.cut, action: () => document.execCommand('cut') },
60
- { name: 'copy', label: 'Copy', icon: ICONS.copy, action: () => document.execCommand('copy') },
61
- { name: 'paste', label: 'Paste', icon: ICONS.paste, action: (ctx) => {
62
- if (!navigator.clipboard) return;
63
- const editable = ctx.layoutInfo?.editable;
64
- if (!editable) return;
65
- // Prefer read() to get rich HTML; fall back to readText() for plain text
66
- const doInsert = (html, text) => {
67
- editable.focus();
68
- if (html) {
69
- const clean = sanitiseHTML(html);
70
- document.execCommand('insertHTML', false, clean);
71
- } else if (text) {
72
- document.execCommand('insertText', false, text);
73
- }
74
- ctx.invoke('editor.afterCommand');
75
- };
76
- if (typeof navigator.clipboard.read === 'function') {
77
- navigator.clipboard.read().then((items) => {
78
- for (const item of items) {
79
- if (item.types.includes('text/html')) {
80
- item.getType('text/html').then((blob) => blob.text()).then((html) => doInsert(html, null)).catch(() => {});
81
- return;
82
- }
83
- }
84
- // No HTML item — fall through to plain text
85
- navigator.clipboard.readText().then((text) => doInsert(null, text)).catch(() => {});
86
- }).catch(() => {
87
- // read() denied → try readText() as fallback
88
- navigator.clipboard.readText().then((text) => doInsert(null, text)).catch(() => {});
89
- });
90
- } else if (typeof navigator.clipboard.readText === 'function') {
91
- navigator.clipboard.readText().then((text) => doInsert(null, text)).catch(() => {});
92
- }
93
- } },
94
- { separator: true },
95
- { name: 'bold', label: 'Bold', icon: ICONS.bold, action: (ctx) => ctx.invoke('editor.bold') },
96
- { name: 'italic', label: 'Italic', icon: ICONS.italic, action: (ctx) => ctx.invoke('editor.italic') },
97
- { name: 'underline', label: 'Underline', icon: ICONS.underline, action: (ctx) => ctx.invoke('editor.underline') },
98
- { separator: true },
99
- { name: 'textColor', label: 'Text Color', icon: ICONS.textColor, colorStrip: 'foreColor', navigate: makeColorSubItems('foreColor') },
100
- { name: 'highlightColor', label: 'Highlight Color', icon: ICONS.highlightColor, colorStrip: 'hiliteColor', navigate: makeColorSubItems('hiliteColor') },
101
- { separator: true },
102
- { name: 'copyFormat', label: 'Copy Format', icon: ICONS.copyFormat, action: (ctx) => ctx.invoke('contextMenu.copyFormat') },
103
- { name: 'pasteFormat', label: 'Paste Format', icon: ICONS.pasteFormat, action: (ctx) => ctx.invoke('contextMenu.pasteFormat'), disabled: (ctx) => !ctx.invoke('contextMenu.hasCopiedFormat') },
104
- { name: 'removeFormat', label: 'Remove Format', icon: ICONS.removeFormat, action: (ctx) => ctx.invoke('contextMenu.removeFormat') },
105
- { separator: true },
106
- { name: 'link', label: 'Insert Link', icon: ICONS.link, action: (ctx) => ctx.invoke('linkDialog.show') },
107
- { name: 'image', label: 'Insert Image', icon: ICONS.image, action: (ctx) => ctx.invoke('imageDialog.show') },
108
- { name: 'video', label: 'Insert Video', icon: ICONS.video, action: (ctx) => ctx.invoke('videoDialog.show') },
109
- { name: 'table', label: 'Insert Table', icon: ICONS.table, tableGrid: true },
110
- ];
111
-
112
- export class ContextMenu {
113
- /** @param {import('../Context.js').Context} context */
114
- constructor(context) {
115
- this.context = context;
116
- this.options = context.options || {};
117
- this._items = this.options.contextMenu?.items || defaultItems;
118
- this.el = null;
119
- this._disposers = [];
120
- this._menuDisposers = []; // disposers for dynamically-rendered menu buttons
121
- this._lastX = 0;
122
- this._lastY = 0;
123
- this._copiedFormat = null;
124
- this._savedRange = null;
125
- }
126
-
127
- initialize() {
128
- this.el = createElement('div', { class: 'an-contextmenu', role: 'menu', 'aria-hidden': 'true' });
129
- this.el.style.display = 'none';
130
- document.body.appendChild(this.el);
131
-
132
- this._renderItems(this._items);
133
-
134
- const editable = this.context.layoutInfo?.editable;
135
- if (editable) {
136
- this._disposers.push(on(editable, 'contextmenu', (e) => this._onContextMenu(e)));
137
- }
138
-
139
- this._disposers.push(
140
- on(document, 'click', (e) => this._maybeHide(e)),
141
- on(document, 'keydown', (e) => { if (/** @type {KeyboardEvent} */ (e).key === 'Escape') this.hide(); }),
142
- on(globalThis, 'scroll', () => this.hide(), { passive: true }),
143
- );
144
-
145
- return this;
146
- }
147
-
148
- destroy() {
149
- this._menuDisposers.forEach((d) => { try { d(); } catch (_e) { void _e; } });
150
- this._menuDisposers = [];
151
- this._disposers.forEach((d) => { try { d(); } catch (_e) { void _e; } });
152
- this._disposers = [];
153
- if (this.el) this.el.remove();
154
- this.el = null;
155
- }
156
-
157
- _renderItems(items) {
158
- // Clean up previously registered per-item listeners
159
- this._menuDisposers.forEach((d) => d());
160
- this._menuDisposers = [];
161
-
162
- if (!this.el) return;
163
- this.el.innerHTML = '';
164
-
165
- items.forEach((it) => {
166
- if (it.separator || it.sep) {
167
- this.el.appendChild(createElement('div', { class: 'an-context-sep' }));
168
- return;
169
- }
170
-
171
- // Back-navigation header
172
- if (it.back) {
173
- const backBtn = createElement('button', { type: 'button', class: 'an-context-back' });
174
- const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
175
- iconSpan.innerHTML = ICONS.back;
176
- backBtn.appendChild(iconSpan);
177
- const backLabel = (it.localeKey && this.context.locale.contextMenu[it.localeKey]) || it.label || this.context.locale.contextMenu.back || 'Back';
178
- backBtn.appendChild(createElement('span', { class: 'an-context-label' }, [backLabel]));
179
- const off = on(backBtn, 'click', (e) => {
180
- e.stopPropagation();
181
- const curLeft = Number.parseFloat(this.el.style.left);
182
- const curTop = Number.parseFloat(this.el.style.top);
183
- this._renderItems(it.navigate());
184
- this._reposition(curLeft, curTop);
185
- });
186
- this._menuDisposers.push(off);
187
- this.el.appendChild(backBtn);
188
- return;
189
- }
190
-
191
- // Submenu-navigate item (shows ▶ chevron, re-renders without closing)
192
- if (it.navigate) {
193
- const btn = createElement('button', { type: 'button', class: 'an-context-item an-context-submenu', 'data-name': it.name || '' });
194
- if (it.icon) {
195
- if (it.colorStrip) {
196
- // Toolbar-style icon: SVG stacked above a colored strip
197
- const iconWrap = createElement('span', { class: 'an-context-icon an-context-icon--color', 'aria-hidden': 'true' });
198
- const svgSpan = createElement('span', { class: 'an-context-icon-svg' });
199
- svgSpan.innerHTML = it.icon;
200
- const strip = createElement('span', { class: 'an-context-color-strip' });
201
- strip.style.background = this._getSelectionColor(it.colorStrip);
202
- iconWrap.appendChild(svgSpan);
203
- iconWrap.appendChild(strip);
204
- btn.appendChild(iconWrap);
205
- } else {
206
- const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
207
- iconSpan.innerHTML = it.icon;
208
- btn.appendChild(iconSpan);
209
- }
210
- }
211
- btn.appendChild(createElement('span', { class: 'an-context-label' }, [(this.context.locale.contextMenu[it.name] || it.label || it.name)]));
212
- const chevron = createElement('span', { class: 'an-context-chevron', 'aria-hidden': 'true' });
213
- chevron.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`;
214
- btn.appendChild(chevron);
215
- const off = on(btn, 'click', (e) => {
216
- e.stopPropagation();
217
- const curLeft = Number.parseFloat(this.el.style.left);
218
- const curTop = Number.parseFloat(this.el.style.top);
219
- this._renderItems(it.navigate());
220
- this._reposition(curLeft, curTop);
221
- });
222
- this._menuDisposers.push(off);
223
- this.el.appendChild(btn);
224
- return;
225
- }
226
-
227
- // Color palette item — renders inline color swatches
228
- if (it.colorPalette) {
229
- const palette = createElement('div', { class: 'an-context-color-palette' });
230
- COLOR_PRESETS.forEach((color) => {
231
- const sw = createElement('div', {
232
- class: 'an-context-color-swatch',
233
- title: color,
234
- role: 'button',
235
- 'aria-label': color,
236
- });
237
- sw.style.background = color;
238
- const offSw = on(sw, 'click', (e) => { e.stopPropagation(); this._applyColor(it.colorType, color); });
239
- this._menuDisposers.push(offSw);
240
- palette.appendChild(sw);
241
- });
242
- if (it.colorType === 'hiliteColor') {
243
- const noColor = createElement('div', {
244
- class: 'an-context-color-swatch an-context-color-none',
245
- title: this.context.locale.contextMenu.noHighlight || 'No highlight',
246
- role: 'button',
247
- 'aria-label': this.context.locale.contextMenu.noHighlight || 'No highlight',
248
- });
249
- noColor.innerHTML = ICONS.noColor;
250
- const offNo = on(noColor, 'click', (e) => { e.stopPropagation(); this._applyColor('hiliteColor', 'transparent'); });
251
- this._menuDisposers.push(offNo);
252
- palette.appendChild(noColor);
253
- }
254
- this.el.appendChild(palette);
255
-
256
- // Custom color row
257
- const customRow = createElement('div', { class: 'an-context-color-custom' });
258
- const colorInput = /** @type {HTMLInputElement} */ (createElement('input', {
259
- type: 'color',
260
- value: it.colorType === 'foreColor' ? '#000000' : '#ffff00',
261
- title: this.context.locale.contextMenu.customColor || 'Custom color',
262
- 'aria-label': this.context.locale.contextMenu.customColor || 'Custom color',
263
- }));
264
- const customLabel = createElement('span', {}, [this.context.locale.contextMenu.customColorLabel || 'Custom…']);
265
- const offCustom = on(colorInput, 'change', () => this._applyColor(it.colorType, colorInput.value));
266
- this._menuDisposers.push(offCustom);
267
- customRow.appendChild(colorInput);
268
- customRow.appendChild(customLabel);
269
- this.el.appendChild(customRow);
270
- return;
271
- }
272
-
273
- // Table grid picker item — expands an inline grid panel when clicked
274
- if (it.tableGrid) {
275
- const GRID_ROWS = 8, GRID_COLS = 8;
276
- const wrapper = createElement('div', { class: 'an-context-table-wrap' });
277
-
278
- const headerBtn = createElement('button', { type: 'button', class: 'an-context-item an-context-submenu', 'data-name': it.name || 'table' });
279
- if (it.icon) {
280
- const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
281
- iconSpan.innerHTML = it.icon;
282
- headerBtn.appendChild(iconSpan);
283
- }
284
- headerBtn.appendChild(createElement('span', { class: 'an-context-label' }, [this.context.locale.contextMenu.table || it.label || 'Insert Table']));
285
- const chevron = createElement('span', { class: 'an-context-chevron', 'aria-hidden': 'true' });
286
- chevron.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`;
287
- headerBtn.appendChild(chevron);
288
-
289
- const panel = createElement('div', { class: 'an-context-table-grid-panel' });
290
- panel.style.display = 'none';
291
-
292
- const gridEl = createElement('div', { class: 'an-table-grid' });
293
- gridEl.style.gridTemplateColumns = `repeat(${GRID_COLS}, 16px)`;
294
- const labelEl = createElement('div', { class: 'an-table-label' });
295
- labelEl.textContent = 'Insert Table';
296
-
297
- const cells = [];
298
- for (let r = 1; r <= GRID_ROWS; r++) {
299
- for (let c = 1; c <= GRID_COLS; c++) {
300
- const cell = createElement('div', { class: 'an-table-cell', 'data-row': String(r), 'data-col': String(c) });
301
- cell.style.width = '16px';
302
- cell.style.height = '16px';
303
- cells.push(cell);
304
- gridEl.appendChild(cell);
305
- }
306
- }
307
-
308
- const setHighlight = (rows, cols) => {
309
- cells.forEach((cell) => {
310
- cell.classList.toggle('active', +cell.dataset.row <= rows && +cell.dataset.col <= cols);
311
- });
312
- labelEl.textContent = (rows && cols) ? `${rows} × ${cols}` : (this.context.locale.contextMenu.table || 'Insert Table');
313
- };
314
-
315
- panel.appendChild(gridEl);
316
- panel.appendChild(labelEl);
317
-
318
- let expanded = false;
319
- const offHeader = on(headerBtn, 'click', (e) => {
320
- e.stopPropagation();
321
- expanded = !expanded;
322
- panel.style.display = expanded ? '' : 'none';
323
- chevron.style.transform = expanded ? 'rotate(90deg)' : '';
324
- this._reposition();
325
- });
326
- this._menuDisposers.push(offHeader);
327
-
328
- const offMove = on(gridEl, 'mousemove', (e) => {
329
- const cell = /** @type {HTMLElement} */ (/** @type {Element} */ (e.target)?.closest('[data-row]'));
330
- if (!cell) return;
331
- setHighlight(+cell.dataset.row, +cell.dataset.col);
332
- });
333
- const offLeave = on(gridEl, 'mouseleave', () => setHighlight(0, 0));
334
- const offClick = on(gridEl, 'click', (e) => {
335
- const cell = /** @type {HTMLElement} */ (/** @type {Element} */ (e.target)?.closest('[data-row]'));
336
- if (!cell) return;
337
- const rows = +cell.dataset.row;
338
- const cols = +cell.dataset.col;
339
- const editable = this.context.layoutInfo?.editable;
340
- if (editable && this._savedRange) {
341
- editable.focus();
342
- const sel = globalThis.getSelection();
343
- sel.removeAllRanges();
344
- sel.addRange(this._savedRange.cloneRange());
345
- }
346
- this.hide();
347
- this.context.invoke('editor.insertTable', cols, rows);
348
- });
349
- this._menuDisposers.push(offMove, offLeave, offClick);
350
-
351
- wrapper.appendChild(headerBtn);
352
- wrapper.appendChild(panel);
353
- this.el.appendChild(wrapper);
354
- return;
355
- }
356
-
357
- // Regular item
358
- const btn = createElement('button', { type: 'button', class: 'an-context-item', 'data-name': it.name || '' });
359
- if (typeof it.disabled === 'function' ? it.disabled(this.context) : !!it.disabled) /** @type {HTMLButtonElement} */ (btn).disabled = true;
360
- if (it.icon) {
361
- const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
362
- iconSpan.innerHTML = it.icon;
363
- btn.appendChild(iconSpan);
364
- }
365
- btn.appendChild(createElement('span', { class: 'an-context-label' }, [(this.context.locale.contextMenu[it.name] || it.label || it.name)]));
366
- const off = on(btn, 'click', (e) => {
367
- e.stopPropagation();
368
- this.hide();
369
- try { it.action(this.context); } catch (err) { console.error(err); }
370
- });
371
- this._menuDisposers.push(off);
372
- this.el.appendChild(btn);
373
- });
374
- }
375
-
376
- _onContextMenu(event) {
377
- const editable = this.context.layoutInfo?.editable;
378
- if (!editable) return;
379
- if (!editable.contains(event.target)) return;
380
- if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
381
- event.preventDefault();
382
-
383
- const winSel = globalThis.getSelection();
384
- this._savedRange = (winSel?.rangeCount > 0) ? winSel.getRangeAt(0).cloneRange() : null;
385
- this._renderItems(this._items);
386
-
387
- // Open below the selected text so the selection stays visible.
388
- // Only apply when there is a real (non-collapsed) selection — a collapsed range
389
- // (cursor only) also has height > 0, which would misplace the menu relative
390
- // to the actual click point.
391
- const openX = event.clientX;
392
- let openY = event.clientY;
393
- if (this._savedRange && !this._savedRange.collapsed) {
394
- try {
395
- const selRect = this._savedRange.getBoundingClientRect();
396
- if (selRect.width > 0 && selRect.height > 0) {
397
- // Keep X at click position (feels natural); Y just below the selection.
398
- openY = selRect.bottom + 4;
399
- }
400
- } catch (_) { void _; }
401
- }
402
-
403
- this._lastX = openX;
404
- this._lastY = openY;
405
- this.showAt(openX, openY);
406
- }
407
-
408
- _maybeHide(event) {
409
- if (!this.el) return;
410
- if (!this.el.contains(event.target)) this.hide();
411
- }
412
-
413
- showAt(x, y) {
414
- if (!this.el) return;
415
- this.el.style.display = 'block';
416
- this._reposition(x, y);
417
- this.el.setAttribute('aria-hidden', 'false');
418
- this.context.triggerEvent('contextMenu:show');
419
- }
420
-
421
- _reposition(x, y) {
422
- if (!this.el) return;
423
- const rx = x === undefined ? this._lastX : x;
424
- const ry = y === undefined ? this._lastY : y;
425
- const w = this.el.offsetWidth;
426
- const h = this.el.offsetHeight;
427
- let left = rx;
428
- let top = ry;
429
- // Clamp to viewport so the menu never overflows the screen edge
430
- if (left + w > globalThis.innerWidth - 8) left = globalThis.innerWidth - w - 8;
431
- if (left < 8) left = 8;
432
- if (top + h > globalThis.innerHeight - 8) top = globalThis.innerHeight - h - 8;
433
- if (top < 8) top = 8;
434
- this.el.style.left = `${left}px`;
435
- this.el.style.top = `${top}px`;
436
- }
437
-
438
- hide() {
439
- if (!this.el) return;
440
- this.el.style.display = 'none';
441
- this.el.setAttribute('aria-hidden', 'true');
442
- this.context.triggerEvent('contextMenu:hide');
443
- }
444
-
445
- // ---------------------------------------------------------------------------
446
- // Format operations (Copy Format / Paste Format / Remove Format)
447
- // ---------------------------------------------------------------------------
448
-
449
- /** Read the current selection's text or highlight color for the strip.
450
- * @param {'foreColor'|'hiliteColor'} type
451
- * @returns {string} CSS color string
452
- */
453
- _getSelectionColor(type) {
454
- const range = this._savedRange;
455
- if (!range) return type === 'foreColor' ? '#000000' : 'transparent';
456
- let node = range.startContainer;
457
- if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
458
- if (!node) return type === 'foreColor' ? '#000000' : 'transparent';
459
- const cs = globalThis.getComputedStyle(/** @type {Element} */ (node));
460
- if (type === 'foreColor') {
461
- return cs.color || '#000000';
462
- }
463
- const bg = cs.backgroundColor;
464
- return (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') ? 'transparent' : bg;
465
- }
466
-
467
- /** Restore selection, apply a color command, then hide the menu. */
468
- _applyColor(type, color) {
469
- const editable = this.context.layoutInfo?.editable;
470
- if (!editable || !this._savedRange) return;
471
- editable.focus();
472
- const sel = globalThis.getSelection();
473
- sel.removeAllRanges();
474
- sel.addRange(this._savedRange.cloneRange());
475
- document.execCommand(type, false, color);
476
- this.context.invoke('editor.afterCommand');
477
- this.hide();
478
- }
479
-
480
- /** Returns true if a format has been copied — used to disable Paste Format. */
481
- hasCopiedFormat() { return !!this._copiedFormat; }
482
-
483
- /** Snapshot inline styles at the selection anchor node. */
484
- copyFormat() {
485
- const range = this._savedRange;
486
- if (!range) return;
487
- const editable = this.context.layoutInfo?.editable;
488
- let node = range.startContainer;
489
- if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
490
- if (!node || !editable?.contains(node)) return;
491
-
492
- // Walk up to collect explicitly-set inline properties from the nearest styled ancestor
493
- const cs = globalThis.getComputedStyle(/** @type {Element} */ (node));
494
-
495
- // Detect if an explicit font-family / font-size is set on an element (not just inherited)
496
- const explicitFontFamily = this._findExplicitStyle(node, editable, 'fontFamily');
497
- const explicitFontSize = this._findExplicitStyle(node, editable, 'fontSize');
498
-
499
- this._copiedFormat = {
500
- bold: Number.parseInt(cs.fontWeight, 10) >= 700,
501
- italic: cs.fontStyle === 'italic' || cs.fontStyle === 'oblique',
502
- underline: (cs.textDecorationLine || '').includes('underline'),
503
- strikethrough: (cs.textDecorationLine || '').includes('line-through'),
504
- fontFamily: explicitFontFamily,
505
- fontSize: explicitFontSize,
506
- color: this._isDefaultColor(cs.color) ? null : cs.color,
507
- backgroundColor: cs.backgroundColor,
508
- };
509
- }
510
-
511
- /** Walk up the tree looking for a property explicitly set in inline style. Returns null if only inherited. */
512
- _findExplicitStyle(node, boundary, prop) {
513
- let el = node;
514
- while (el && el !== boundary && el !== document.body) {
515
- if (el.style?.[prop]) return el.style[prop];
516
- // also check font element attributes
517
- if (el.nodeName === 'FONT') {
518
- if (prop === 'fontFamily' && el.getAttribute('face')) return el.getAttribute('face');
519
- if (prop === 'fontSize' && el.getAttribute('size')) {
520
- // font size attribute is 1-7; skip these since we need px
521
- return null;
522
- }
523
- }
524
- el = el.parentElement;
525
- }
526
- return null;
527
- }
528
-
529
- /** Checks if a computed color looks like the default browser text color (black). */
530
- _isDefaultColor(color) {
531
- return !color || color === 'rgb(0, 0, 0)' || color === 'rgba(0, 0, 0, 0)' || color === 'transparent';
532
- }
533
-
534
- /** Apply the most-recently copied format to the saved selection. */
535
- pasteFormat() {
536
- if (!this._copiedFormat || !this._savedRange) return;
537
- const fmt = this._copiedFormat;
538
- const editable = this.context.layoutInfo?.editable;
539
- if (!editable) return;
540
-
541
- // CRITICAL: focus the editable FIRST, then restore selection.
542
- // Calling focus() after addRange() can wipe the selection in Chrome/Firefox.
543
- editable.focus();
544
- const sel = globalThis.getSelection();
545
- sel.removeAllRanges();
546
- sel.addRange(this._savedRange.cloneRange());
547
-
548
- // Strip existing inline formatting so we start from a clean state.
549
- // After removeFormat, bold/italic/underline/strikethrough are guaranteed off.
550
- document.execCommand('removeFormat');
551
-
552
- // Apply each format positively — no toggle-check needed after removeFormat.
553
- if (fmt.bold) document.execCommand('bold');
554
- if (fmt.italic) document.execCommand('italic');
555
- if (fmt.underline) document.execCommand('underline');
556
- if (fmt.strikethrough) document.execCommand('strikeThrough');
557
-
558
- if (fmt.color) document.execCommand('foreColor', false, fmt.color);
559
-
560
- const isTransparent = (c) => !c || c === 'rgba(0, 0, 0, 0)' || c === 'transparent';
561
- if (!isTransparent(fmt.backgroundColor)) {
562
- document.execCommand('hiliteColor', false, fmt.backgroundColor);
563
- }
564
-
565
- // Font family — apply BEFORE font-size DOM manipulation so the selection is still intact.
566
- if (fmt.fontFamily) {
567
- document.execCommand('fontName', false, fmt.fontFamily);
568
- }
569
-
570
- // Font size — use a unique data-marker to avoid touching pre-existing font[size="7"] nodes.
571
- if (fmt.fontSize) {
572
- const marker = `fs-${Date.now()}`;
573
- // Snapshot pre-existing font[size="7"] BEFORE execCommand so we don't accidentally
574
- // replace nodes that were already in the document (the comment below was aspirational
575
- // but the original code never actually excluded them — this Set approach is the fix).
576
- const preExisting = new Set(editable.querySelectorAll('font[size="7"]'));
577
- document.execCommand('fontSize', false, '7');
578
- // Mark only the NEWLY created font[size="7"] elements.
579
- editable.querySelectorAll('font[size="7"]').forEach((el) => {
580
- if (!preExisting.has(el)) /** @type {HTMLElement} */ (el).dataset.anTmp = marker;
581
- });
582
- editable.querySelectorAll(`[data-an-tmp="${marker}"]`).forEach((el) => {
583
- const span = document.createElement('span');
584
- span.style.fontSize = fmt.fontSize;
585
- el.parentNode.insertBefore(span, el);
586
- while (el.firstChild) span.appendChild(el.firstChild);
587
- el.remove();
588
- });
589
- }
590
-
591
- this.context.invoke('editor.afterCommand');
592
- }
593
-
594
- /** Strip all inline formatting from the saved selection. */
595
- removeFormat() {
596
- if (!this._savedRange) return;
597
- const editable = this.context.layoutInfo?.editable;
598
- if (!editable) return;
599
-
600
- editable.focus();
601
- const sel = globalThis.getSelection();
602
- sel.removeAllRanges();
603
- sel.addRange(this._savedRange.cloneRange());
604
-
605
- document.execCommand('removeFormat');
606
-
607
- const range = sel.getRangeAt(0);
608
- const root = range.commonAncestorContainer;
609
- const rootEl = root.nodeType === Node.TEXT_NODE ? root.parentElement : root;
610
- const iter = document.createNodeIterator(rootEl, NodeFilter.SHOW_ELEMENT);
611
- let el;
612
- while ((el = iter.nextNode())) {
613
- if (!editable.contains(el) || el === editable) continue;
614
- try { if (range.intersectsNode(el)) /** @type {Element} */ (el).removeAttribute('style'); } catch { /* ignore */ }
615
- }
616
-
617
- this.context.invoke('editor.afterCommand');
618
- }
619
- }
620
-
621
- export default ContextMenu;