autumnnote 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +874 -0
- package/dist/autumnnote.css +1 -0
- package/dist/autumnnote.es.js +5888 -0
- package/dist/autumnnote.es.js.map +1 -0
- package/dist/autumnnote.umd.js +74 -0
- package/dist/autumnnote.umd.js.map +1 -0
- package/package.json +55 -0
- package/src/js/Context.js +497 -0
- package/src/js/core/dom.js +315 -0
- package/src/js/core/env.js +25 -0
- package/src/js/core/func.js +153 -0
- package/src/js/core/key.js +66 -0
- package/src/js/core/lists.js +121 -0
- package/src/js/core/markdown.js +294 -0
- package/src/js/core/range.js +194 -0
- package/src/js/core/sanitise.js +78 -0
- package/src/js/editing/History.js +205 -0
- package/src/js/editing/Style.js +329 -0
- package/src/js/editing/Table.js +59 -0
- package/src/js/editing/Typing.js +142 -0
- package/src/js/index.js +126 -0
- package/src/js/module/Buttons.js +300 -0
- package/src/js/module/Clipboard.js +460 -0
- package/src/js/module/CodeTooltip.js +428 -0
- package/src/js/module/Codeview.js +122 -0
- package/src/js/module/ContextMenu.js +470 -0
- package/src/js/module/Editor.js +528 -0
- package/src/js/module/EmojiDialog.js +726 -0
- package/src/js/module/FindReplace.js +440 -0
- package/src/js/module/Fullscreen.js +80 -0
- package/src/js/module/IconDialog.js +620 -0
- package/src/js/module/ImageDialog.js +208 -0
- package/src/js/module/ImageResizer.js +216 -0
- package/src/js/module/ImageTooltip.js +286 -0
- package/src/js/module/LinkDialog.js +204 -0
- package/src/js/module/LinkTooltip.js +242 -0
- package/src/js/module/Placeholder.js +44 -0
- package/src/js/module/ShortcutsDialog.js +141 -0
- package/src/js/module/Statusbar.js +238 -0
- package/src/js/module/TableTooltip.js +568 -0
- package/src/js/module/Toolbar.js +562 -0
- package/src/js/module/VideoDialog.js +263 -0
- package/src/js/module/VideoResizer.js +227 -0
- package/src/js/module/VideoTooltip.js +252 -0
- package/src/js/renderer.js +107 -0
- package/src/js/settings.js +134 -0
- package/src/styles/_variables.scss +48 -0
- package/src/styles/autumnnote.scss +1740 -0
- package/types/index.d.ts +324 -0
|
@@ -0,0 +1,470 @@
|
|
|
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
|
+
};
|
|
34
|
+
|
|
35
|
+
const defaultItems = [
|
|
36
|
+
{ name: 'undo', label: 'Undo', icon: ICONS.undo, action: (ctx) => ctx.invoke('editor.undo') },
|
|
37
|
+
{ name: 'redo', label: 'Redo', icon: ICONS.redo, action: (ctx) => ctx.invoke('editor.redo') },
|
|
38
|
+
{ separator: true },
|
|
39
|
+
{ name: 'cut', label: 'Cut', icon: ICONS.cut, action: () => document.execCommand('cut') },
|
|
40
|
+
{ name: 'copy', label: 'Copy', icon: ICONS.copy, action: () => document.execCommand('copy') },
|
|
41
|
+
{ name: 'paste', label: 'Paste', icon: ICONS.paste, action: (ctx) => {
|
|
42
|
+
if (!navigator.clipboard) return;
|
|
43
|
+
const editable = ctx.layoutInfo && ctx.layoutInfo.editable;
|
|
44
|
+
if (!editable) return;
|
|
45
|
+
// Prefer read() to get rich HTML; fall back to readText() for plain text
|
|
46
|
+
const doInsert = (html, text) => {
|
|
47
|
+
editable.focus();
|
|
48
|
+
if (html) {
|
|
49
|
+
const clean = sanitiseHTML(html);
|
|
50
|
+
document.execCommand('insertHTML', false, clean);
|
|
51
|
+
} else if (text) {
|
|
52
|
+
document.execCommand('insertText', false, text);
|
|
53
|
+
}
|
|
54
|
+
ctx.invoke('editor.afterCommand');
|
|
55
|
+
};
|
|
56
|
+
if (typeof navigator.clipboard.read === 'function') {
|
|
57
|
+
navigator.clipboard.read().then((items) => {
|
|
58
|
+
for (const item of items) {
|
|
59
|
+
if (item.types.includes('text/html')) {
|
|
60
|
+
item.getType('text/html').then((blob) => blob.text()).then((html) => doInsert(html, null));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// No HTML item — fall through to plain text
|
|
65
|
+
navigator.clipboard.readText().then((text) => doInsert(null, text)).catch(() => {});
|
|
66
|
+
}).catch(() => {
|
|
67
|
+
// read() denied → try readText() as fallback
|
|
68
|
+
navigator.clipboard.readText().then((text) => doInsert(null, text)).catch(() => {});
|
|
69
|
+
});
|
|
70
|
+
} else if (typeof navigator.clipboard.readText === 'function') {
|
|
71
|
+
navigator.clipboard.readText().then((text) => doInsert(null, text)).catch(() => {});
|
|
72
|
+
}
|
|
73
|
+
} },
|
|
74
|
+
{ separator: true },
|
|
75
|
+
{ name: 'bold', label: 'Bold', icon: ICONS.bold, action: (ctx) => ctx.invoke('editor.bold') },
|
|
76
|
+
{ name: 'italic', label: 'Italic', icon: ICONS.italic, action: (ctx) => ctx.invoke('editor.italic') },
|
|
77
|
+
{ name: 'underline', label: 'Underline', icon: ICONS.underline, action: (ctx) => ctx.invoke('editor.underline') },
|
|
78
|
+
{ separator: true },
|
|
79
|
+
{ name: 'copyFormat', label: 'Copy Format', icon: ICONS.copyFormat, action: (ctx) => ctx.invoke('contextMenu.copyFormat') },
|
|
80
|
+
{ name: 'pasteFormat', label: 'Paste Format', icon: ICONS.pasteFormat, action: (ctx) => ctx.invoke('contextMenu.pasteFormat'), disabled: (ctx) => !ctx.invoke('contextMenu.hasCopiedFormat') },
|
|
81
|
+
{ name: 'removeFormat', label: 'Remove Format', icon: ICONS.removeFormat, action: (ctx) => ctx.invoke('contextMenu.removeFormat') },
|
|
82
|
+
{ separator: true },
|
|
83
|
+
{ name: 'link', label: 'Insert Link', icon: ICONS.link, action: (ctx) => ctx.invoke('linkDialog.show') },
|
|
84
|
+
{ name: 'image', label: 'Insert Image', icon: ICONS.image, action: (ctx) => ctx.invoke('imageDialog.show') },
|
|
85
|
+
{ name: 'video', label: 'Insert Video', icon: ICONS.video, action: (ctx) => ctx.invoke('videoDialog.show') },
|
|
86
|
+
{ name: 'table', label: 'Insert Table', icon: ICONS.table, tableGrid: true },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export class ContextMenu {
|
|
90
|
+
/** @param {import('../Context.js').Context} context */
|
|
91
|
+
constructor(context) {
|
|
92
|
+
this.context = context;
|
|
93
|
+
this.options = context.options || {};
|
|
94
|
+
this._items = (this.options.contextMenu && this.options.contextMenu.items) || defaultItems;
|
|
95
|
+
this.el = null;
|
|
96
|
+
this._disposers = [];
|
|
97
|
+
this._menuDisposers = []; // disposers for dynamically-rendered menu buttons
|
|
98
|
+
this._lastX = 0;
|
|
99
|
+
this._lastY = 0;
|
|
100
|
+
this._copiedFormat = null;
|
|
101
|
+
this._savedRange = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
initialize() {
|
|
105
|
+
this.el = createElement('div', { class: 'an-contextmenu', role: 'menu', 'aria-hidden': 'true' });
|
|
106
|
+
this.el.style.display = 'none';
|
|
107
|
+
document.body.appendChild(this.el);
|
|
108
|
+
|
|
109
|
+
this._renderItems(this._items);
|
|
110
|
+
|
|
111
|
+
const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
|
|
112
|
+
if (editable) {
|
|
113
|
+
this._disposers.push(on(editable, 'contextmenu', (e) => this._onContextMenu(e)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this._disposers.push(on(document, 'click', (e) => this._maybeHide(e)));
|
|
117
|
+
this._disposers.push(on(document, 'keydown', (e) => { if (e.key === 'Escape') this.hide(); }));
|
|
118
|
+
this._disposers.push(on(window, 'scroll', () => this.hide(), { passive: true }));
|
|
119
|
+
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
destroy() {
|
|
124
|
+
this._menuDisposers.forEach((d) => { try { d(); } catch (e) {} });
|
|
125
|
+
this._menuDisposers = [];
|
|
126
|
+
this._disposers.forEach((d) => { try { d(); } catch (e) {} });
|
|
127
|
+
this._disposers = [];
|
|
128
|
+
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
|
|
129
|
+
this.el = null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_renderItems(items) {
|
|
133
|
+
// Clean up previously registered per-item listeners
|
|
134
|
+
this._menuDisposers.forEach((d) => d());
|
|
135
|
+
this._menuDisposers = [];
|
|
136
|
+
|
|
137
|
+
if (!this.el) return;
|
|
138
|
+
this.el.innerHTML = '';
|
|
139
|
+
|
|
140
|
+
items.forEach((it) => {
|
|
141
|
+
if (it.separator || it.sep) {
|
|
142
|
+
this.el.appendChild(createElement('div', { class: 'an-context-sep' }));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Back-navigation header
|
|
147
|
+
if (it.back) {
|
|
148
|
+
const backBtn = createElement('button', { type: 'button', class: 'an-context-back' });
|
|
149
|
+
const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
|
|
150
|
+
iconSpan.innerHTML = ICONS.back;
|
|
151
|
+
backBtn.appendChild(iconSpan);
|
|
152
|
+
backBtn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || 'Back']));
|
|
153
|
+
const off = on(backBtn, 'click', (e) => {
|
|
154
|
+
e.stopPropagation();
|
|
155
|
+
this._renderItems(it.navigate());
|
|
156
|
+
this._reposition();
|
|
157
|
+
});
|
|
158
|
+
this._menuDisposers.push(off);
|
|
159
|
+
this.el.appendChild(backBtn);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Submenu-navigate item (shows ▶ chevron, re-renders without closing)
|
|
164
|
+
if (it.navigate) {
|
|
165
|
+
const btn = createElement('button', { type: 'button', class: 'an-context-item an-context-submenu', 'data-name': it.name || '' });
|
|
166
|
+
if (it.icon) {
|
|
167
|
+
const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
|
|
168
|
+
iconSpan.innerHTML = it.icon;
|
|
169
|
+
btn.appendChild(iconSpan);
|
|
170
|
+
}
|
|
171
|
+
btn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || it.name]));
|
|
172
|
+
const chevron = createElement('span', { class: 'an-context-chevron', 'aria-hidden': 'true' });
|
|
173
|
+
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>`;
|
|
174
|
+
btn.appendChild(chevron);
|
|
175
|
+
const off = on(btn, 'click', (e) => {
|
|
176
|
+
e.stopPropagation();
|
|
177
|
+
this._renderItems(it.navigate());
|
|
178
|
+
this._reposition();
|
|
179
|
+
});
|
|
180
|
+
this._menuDisposers.push(off);
|
|
181
|
+
this.el.appendChild(btn);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Table grid picker item — expands an inline grid panel when clicked
|
|
186
|
+
if (it.tableGrid) {
|
|
187
|
+
const GRID_ROWS = 8, GRID_COLS = 8;
|
|
188
|
+
const wrapper = createElement('div', { class: 'an-context-table-wrap' });
|
|
189
|
+
|
|
190
|
+
const headerBtn = createElement('button', { type: 'button', class: 'an-context-item an-context-submenu', 'data-name': it.name || 'table' });
|
|
191
|
+
if (it.icon) {
|
|
192
|
+
const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
|
|
193
|
+
iconSpan.innerHTML = it.icon;
|
|
194
|
+
headerBtn.appendChild(iconSpan);
|
|
195
|
+
}
|
|
196
|
+
headerBtn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || 'Insert Table']));
|
|
197
|
+
const chevron = createElement('span', { class: 'an-context-chevron', 'aria-hidden': 'true' });
|
|
198
|
+
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>`;
|
|
199
|
+
headerBtn.appendChild(chevron);
|
|
200
|
+
|
|
201
|
+
const panel = createElement('div', { class: 'an-context-table-grid-panel' });
|
|
202
|
+
panel.style.display = 'none';
|
|
203
|
+
|
|
204
|
+
const gridEl = createElement('div', { class: 'an-table-grid' });
|
|
205
|
+
gridEl.style.gridTemplateColumns = `repeat(${GRID_COLS}, 16px)`;
|
|
206
|
+
const labelEl = createElement('div', { class: 'an-table-label' });
|
|
207
|
+
labelEl.textContent = 'Insert Table';
|
|
208
|
+
|
|
209
|
+
const cells = [];
|
|
210
|
+
for (let r = 1; r <= GRID_ROWS; r++) {
|
|
211
|
+
for (let c = 1; c <= GRID_COLS; c++) {
|
|
212
|
+
const cell = createElement('div', { class: 'an-table-cell', 'data-row': String(r), 'data-col': String(c) });
|
|
213
|
+
cell.style.width = '16px';
|
|
214
|
+
cell.style.height = '16px';
|
|
215
|
+
cells.push(cell);
|
|
216
|
+
gridEl.appendChild(cell);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const setHighlight = (rows, cols) => {
|
|
221
|
+
cells.forEach((cell) => {
|
|
222
|
+
cell.classList.toggle('active', +cell.dataset.row <= rows && +cell.dataset.col <= cols);
|
|
223
|
+
});
|
|
224
|
+
labelEl.textContent = (rows && cols) ? `${cols} × ${rows}` : 'Insert Table';
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
panel.appendChild(gridEl);
|
|
228
|
+
panel.appendChild(labelEl);
|
|
229
|
+
|
|
230
|
+
let expanded = false;
|
|
231
|
+
const offHeader = on(headerBtn, 'click', (e) => {
|
|
232
|
+
e.stopPropagation();
|
|
233
|
+
expanded = !expanded;
|
|
234
|
+
panel.style.display = expanded ? '' : 'none';
|
|
235
|
+
chevron.style.transform = expanded ? 'rotate(90deg)' : '';
|
|
236
|
+
this._reposition();
|
|
237
|
+
});
|
|
238
|
+
this._menuDisposers.push(offHeader);
|
|
239
|
+
|
|
240
|
+
const offMove = on(gridEl, 'mousemove', (e) => {
|
|
241
|
+
const cell = e.target.closest('[data-row]');
|
|
242
|
+
if (!cell) return;
|
|
243
|
+
setHighlight(+cell.dataset.row, +cell.dataset.col);
|
|
244
|
+
});
|
|
245
|
+
const offLeave = on(gridEl, 'mouseleave', () => setHighlight(0, 0));
|
|
246
|
+
const offClick = on(gridEl, 'click', (e) => {
|
|
247
|
+
const cell = e.target.closest('[data-row]');
|
|
248
|
+
if (!cell) return;
|
|
249
|
+
const rows = +cell.dataset.row;
|
|
250
|
+
const cols = +cell.dataset.col;
|
|
251
|
+
const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
|
|
252
|
+
if (editable && this._savedRange) {
|
|
253
|
+
editable.focus();
|
|
254
|
+
const sel = window.getSelection();
|
|
255
|
+
sel.removeAllRanges();
|
|
256
|
+
sel.addRange(this._savedRange.cloneRange());
|
|
257
|
+
}
|
|
258
|
+
this.hide();
|
|
259
|
+
this.context.invoke('editor.insertTable', cols, rows);
|
|
260
|
+
});
|
|
261
|
+
this._menuDisposers.push(offMove, offLeave, offClick);
|
|
262
|
+
|
|
263
|
+
wrapper.appendChild(headerBtn);
|
|
264
|
+
wrapper.appendChild(panel);
|
|
265
|
+
this.el.appendChild(wrapper);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Regular item
|
|
270
|
+
const btn = createElement('button', { type: 'button', class: 'an-context-item', 'data-name': it.name || '' });
|
|
271
|
+
if (typeof it.disabled === 'function' ? it.disabled(this.context) : !!it.disabled) btn.disabled = true;
|
|
272
|
+
if (it.icon) {
|
|
273
|
+
const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
|
|
274
|
+
iconSpan.innerHTML = it.icon;
|
|
275
|
+
btn.appendChild(iconSpan);
|
|
276
|
+
}
|
|
277
|
+
btn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || it.name]));
|
|
278
|
+
const off = on(btn, 'click', (e) => {
|
|
279
|
+
e.stopPropagation();
|
|
280
|
+
this.hide();
|
|
281
|
+
try { it.action(this.context); } catch (err) { console.error(err); }
|
|
282
|
+
});
|
|
283
|
+
this._menuDisposers.push(off);
|
|
284
|
+
this.el.appendChild(btn);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
_onContextMenu(event) {
|
|
289
|
+
const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
|
|
290
|
+
if (!editable) return;
|
|
291
|
+
if (!editable.contains(event.target)) return;
|
|
292
|
+
event.preventDefault();
|
|
293
|
+
this._lastX = event.clientX;
|
|
294
|
+
this._lastY = event.clientY;
|
|
295
|
+
const winSel = window.getSelection();
|
|
296
|
+
this._savedRange = (winSel && winSel.rangeCount > 0) ? winSel.getRangeAt(0).cloneRange() : null;
|
|
297
|
+
this._renderItems(this._items);
|
|
298
|
+
this.showAt(event.clientX, event.clientY);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_maybeHide(event) {
|
|
302
|
+
if (!this.el) return;
|
|
303
|
+
if (!this.el.contains(event.target)) this.hide();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
showAt(x, y) {
|
|
307
|
+
if (!this.el) return;
|
|
308
|
+
this.el.style.display = 'block';
|
|
309
|
+
this._reposition(x, y);
|
|
310
|
+
this.el.setAttribute('aria-hidden', 'false');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
_reposition(x, y) {
|
|
314
|
+
if (!this.el) return;
|
|
315
|
+
const rx = x !== undefined ? x : this._lastX;
|
|
316
|
+
const ry = y !== undefined ? y : this._lastY;
|
|
317
|
+
const rect = this.el.getBoundingClientRect();
|
|
318
|
+
let left = rx;
|
|
319
|
+
let top = ry;
|
|
320
|
+
if (left + rect.width > window.innerWidth) left = window.innerWidth - rect.width - 8;
|
|
321
|
+
if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 8;
|
|
322
|
+
this.el.style.left = `${left}px`;
|
|
323
|
+
this.el.style.top = `${top}px`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
hide() {
|
|
327
|
+
if (!this.el) return;
|
|
328
|
+
this.el.style.display = 'none';
|
|
329
|
+
this.el.setAttribute('aria-hidden', 'true');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Format operations (Copy Format / Paste Format / Remove Format)
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/** Returns true if a format has been copied — used to disable Paste Format. */
|
|
337
|
+
hasCopiedFormat() { return !!this._copiedFormat; }
|
|
338
|
+
|
|
339
|
+
/** Snapshot inline styles at the selection anchor node. */
|
|
340
|
+
copyFormat() {
|
|
341
|
+
const range = this._savedRange;
|
|
342
|
+
if (!range) return;
|
|
343
|
+
const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
|
|
344
|
+
let node = range.startContainer;
|
|
345
|
+
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
|
346
|
+
if (!node || !editable || !editable.contains(node)) return;
|
|
347
|
+
|
|
348
|
+
// Walk up to collect explicitly-set inline properties from the nearest styled ancestor
|
|
349
|
+
const cs = window.getComputedStyle(node);
|
|
350
|
+
|
|
351
|
+
// Detect if an explicit font-family / font-size is set on an element (not just inherited)
|
|
352
|
+
const explicitFontFamily = this._findExplicitStyle(node, editable, 'fontFamily');
|
|
353
|
+
const explicitFontSize = this._findExplicitStyle(node, editable, 'fontSize');
|
|
354
|
+
|
|
355
|
+
this._copiedFormat = {
|
|
356
|
+
bold: parseInt(cs.fontWeight, 10) >= 700,
|
|
357
|
+
italic: cs.fontStyle === 'italic' || cs.fontStyle === 'oblique',
|
|
358
|
+
underline: (cs.textDecorationLine || '').includes('underline'),
|
|
359
|
+
strikethrough: (cs.textDecorationLine || '').includes('line-through'),
|
|
360
|
+
fontFamily: explicitFontFamily,
|
|
361
|
+
fontSize: explicitFontSize,
|
|
362
|
+
color: this._isDefaultColor(cs.color) ? null : cs.color,
|
|
363
|
+
backgroundColor: cs.backgroundColor,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Walk up the tree looking for a property explicitly set in inline style. Returns null if only inherited. */
|
|
368
|
+
_findExplicitStyle(node, boundary, prop) {
|
|
369
|
+
let el = node;
|
|
370
|
+
while (el && el !== boundary && el !== document.body) {
|
|
371
|
+
if (el.style && el.style[prop]) return el.style[prop];
|
|
372
|
+
// also check font element attributes
|
|
373
|
+
if (el.nodeName === 'FONT') {
|
|
374
|
+
if (prop === 'fontFamily' && el.getAttribute('face')) return el.getAttribute('face');
|
|
375
|
+
if (prop === 'fontSize' && el.getAttribute('size')) {
|
|
376
|
+
// font size attribute is 1-7; skip these since we need px
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
el = el.parentElement;
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Checks if a computed color looks like the default browser text color (black). */
|
|
386
|
+
_isDefaultColor(color) {
|
|
387
|
+
return !color || color === 'rgb(0, 0, 0)' || color === 'rgba(0, 0, 0, 0)' || color === 'transparent';
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Apply the most-recently copied format to the saved selection. */
|
|
391
|
+
pasteFormat() {
|
|
392
|
+
if (!this._copiedFormat || !this._savedRange) return;
|
|
393
|
+
const fmt = this._copiedFormat;
|
|
394
|
+
const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
|
|
395
|
+
if (!editable) return;
|
|
396
|
+
|
|
397
|
+
// CRITICAL: focus the editable FIRST, then restore selection.
|
|
398
|
+
// Calling focus() after addRange() can wipe the selection in Chrome/Firefox.
|
|
399
|
+
editable.focus();
|
|
400
|
+
const sel = window.getSelection();
|
|
401
|
+
sel.removeAllRanges();
|
|
402
|
+
sel.addRange(this._savedRange.cloneRange());
|
|
403
|
+
|
|
404
|
+
// Strip existing inline formatting so we start from a clean state.
|
|
405
|
+
// After removeFormat, bold/italic/underline/strikethrough are guaranteed off.
|
|
406
|
+
document.execCommand('removeFormat');
|
|
407
|
+
|
|
408
|
+
// Apply each format positively — no toggle-check needed after removeFormat.
|
|
409
|
+
if (fmt.bold) document.execCommand('bold');
|
|
410
|
+
if (fmt.italic) document.execCommand('italic');
|
|
411
|
+
if (fmt.underline) document.execCommand('underline');
|
|
412
|
+
if (fmt.strikethrough) document.execCommand('strikeThrough');
|
|
413
|
+
|
|
414
|
+
if (fmt.color) document.execCommand('foreColor', false, fmt.color);
|
|
415
|
+
|
|
416
|
+
const isTransparent = (c) => !c || c === 'rgba(0, 0, 0, 0)' || c === 'transparent';
|
|
417
|
+
if (!isTransparent(fmt.backgroundColor)) {
|
|
418
|
+
document.execCommand('hiliteColor', false, fmt.backgroundColor);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Font size — use a unique data-marker to avoid touching pre-existing font[size="7"] nodes.
|
|
422
|
+
if (fmt.fontSize) {
|
|
423
|
+
const marker = `fs-${Date.now()}`;
|
|
424
|
+
document.execCommand('fontSize', false, '7');
|
|
425
|
+
editable.querySelectorAll('font[size="7"]').forEach((el) => el.setAttribute('data-an-tmp', marker));
|
|
426
|
+
editable.querySelectorAll(`[data-an-tmp="${marker}"]`).forEach((el) => {
|
|
427
|
+
const span = document.createElement('span');
|
|
428
|
+
span.style.fontSize = fmt.fontSize;
|
|
429
|
+
el.parentNode.insertBefore(span, el);
|
|
430
|
+
while (el.firstChild) span.appendChild(el.firstChild);
|
|
431
|
+
el.parentNode.removeChild(el);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Font family — only apply if it was explicitly set (not just inherited default).
|
|
436
|
+
if (fmt.fontFamily) {
|
|
437
|
+
document.execCommand('fontName', false, fmt.fontFamily);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this.context.invoke('editor.afterCommand');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Strip all inline formatting from the saved selection. */
|
|
444
|
+
removeFormat() {
|
|
445
|
+
if (!this._savedRange) return;
|
|
446
|
+
const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
|
|
447
|
+
if (!editable) return;
|
|
448
|
+
|
|
449
|
+
editable.focus();
|
|
450
|
+
const sel = window.getSelection();
|
|
451
|
+
sel.removeAllRanges();
|
|
452
|
+
sel.addRange(this._savedRange.cloneRange());
|
|
453
|
+
|
|
454
|
+
document.execCommand('removeFormat');
|
|
455
|
+
|
|
456
|
+
const range = sel.getRangeAt(0);
|
|
457
|
+
const root = range.commonAncestorContainer;
|
|
458
|
+
const rootEl = root.nodeType === Node.TEXT_NODE ? root.parentElement : root;
|
|
459
|
+
const iter = document.createNodeIterator(rootEl, NodeFilter.SHOW_ELEMENT);
|
|
460
|
+
let el;
|
|
461
|
+
while ((el = iter.nextNode())) {
|
|
462
|
+
if (!editable.contains(el) || el === editable) continue;
|
|
463
|
+
try { if (range.intersectsNode(el)) el.removeAttribute('style'); } catch { /* ignore */ }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
this.context.invoke('editor.afterCommand');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export default ContextMenu;
|