build: 提交 dist 构建产物(v0.4.3 完整 5 格式 + sourcemap + 类型声明)
CI / test-parser (push) Successful in 9m25s
CI / test-core (push) Successful in 9m33s
CI / test-rest (push) Successful in 9m27s
CI / e2e (push) Successful in 9m43s
CI / verify (18.x) (push) Successful in 9m52s
CI / verify (20.x) (push) Successful in 9m52s
CI / verify (24.x) (push) Successful in 9m45s

This commit is contained in:
2026-08-20 20:43:07 +08:00
parent d6a618af5d
commit 67f234676c
8 changed files with 831 additions and 362 deletions
+271 -119
View File
@@ -109,6 +109,7 @@ const LOCALES = {
outline: '大纲', outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选', cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB', regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB',
formatToolbar: '格式化选区', pasteBlocked: '无法直接粘贴,请使用 Ctrl+V',
}, },
'en-US': { 'en-US': {
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
@@ -136,6 +137,7 @@ const LOCALES = {
outline: 'Outline', outline: 'Outline',
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All', cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)', regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)',
formatToolbar: 'Format selection', pasteBlocked: 'Cannot paste directly, use Ctrl+V',
}, },
ja: { ja: {
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線', bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
@@ -163,6 +165,7 @@ const LOCALES = {
outline: 'アウトライン', outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択', cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB', regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
}, },
ko: { ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -190,6 +193,7 @@ const LOCALES = {
outline: '개요', outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택', cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)', regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
formatToolbar: '선택 영역 서식', pasteBlocked: '직접 붙여넣을 수 없습니다. Ctrl+V를 사용하세요',
}, },
fr: { fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -217,6 +221,7 @@ const LOCALES = {
outline: 'Plan', outline: 'Plan',
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner', cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)', regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)',
formatToolbar: 'Formater la sélection', pasteBlocked: 'Impossible de coller directement, utilisez Ctrl+V',
}, },
de: { de: {
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen', bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
@@ -244,6 +249,7 @@ const LOCALES = {
outline: 'Gliederung', outline: 'Gliederung',
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen', cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)', regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)',
formatToolbar: 'Auswahl formatieren', pasteBlocked: 'Direktes Einfügen nicht möglich, verwenden Sie Strg+V',
}, },
}; };
@@ -2154,15 +2160,21 @@ const searchReplacePlugin = {
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
ta.setSelectionRange(s, s + r.length); ta.setSelectionRange(s, s + r.length);
} }
const oldV = editor._value || '';
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._pushHistory === 'function') if (typeof editor._afterProgrammaticEdit === 'function') {
editor._pushHistory(); editor._afterProgrammaticEdit(oldV);
if (typeof editor._render === 'function') }
editor._render(); else {
if (typeof editor._updateWordCount === 'function') if (typeof editor._pushHistory === 'function')
editor._updateWordCount(); editor._pushHistory();
if (typeof editor._emit === 'function') if (typeof editor._render === 'function')
editor._emit('change', editor._value); editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
}
findNext(); findNext();
}; };
const replaceAll = () => { const replaceAll = () => {
@@ -2173,17 +2185,23 @@ const searchReplacePlugin = {
const re = buildPattern(q); const re = buildPattern(q);
if (!re) if (!re)
return; return;
const oldV = editor._value || '';
ta.value = ta.value.replace(re, () => r); ta.value = ta.value.replace(re, () => r);
ta.setSelectionRange(0, 0); ta.setSelectionRange(0, 0);
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._pushHistory === 'function') if (typeof editor._afterProgrammaticEdit === 'function') {
editor._pushHistory(); editor._afterProgrammaticEdit(oldV);
if (typeof editor._render === 'function') }
editor._render(); else {
if (typeof editor._updateWordCount === 'function') if (typeof editor._pushHistory === 'function')
editor._updateWordCount(); editor._pushHistory();
if (typeof editor._emit === 'function') if (typeof editor._render === 'function')
editor._emit('change', editor._value); editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
}
findAll(); findAll();
}; };
fi.addEventListener('input', () => { lastIdxs = findAll(); }); fi.addEventListener('input', () => { lastIdxs = findAll(); });
@@ -2849,11 +2867,10 @@ function wrapSelection(before, after) {
else { else {
ta.selectionStart = ta.selectionEnd = start + before.length; ta.selectionStart = ta.selectionEnd = start + before.length;
} }
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Line prefix toggling ============ // ============ Line prefix toggling ============
function toggleLinePrefix(prefix) { function toggleLinePrefix(prefix) {
@@ -2874,11 +2891,10 @@ function toggleLinePrefix(prefix) {
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Block insertion ============ // ============ Block insertion ============
function insertBlock(text) { function insertBlock(text) {
@@ -2891,11 +2907,10 @@ function insertBlock(text) {
ta.focus(); ta.focus();
const pos = start + insert.length; const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos; ta.selectionStart = ta.selectionEnd = pos;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
function insertLink() { function insertLink() {
const ta = this.textarea; const ta = this.textarea;
@@ -2908,10 +2923,10 @@ function insertLink() {
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 3; ta.selectionStart = start + sel.length + 3;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertImage() { function insertImage() {
const ta = this.textarea; const ta = this.textarea;
@@ -2924,10 +2939,10 @@ function insertImage() {
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 4; ta.selectionStart = start + sel.length + 4;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertTable(rows = 3, cols = 3) { function insertTable(rows = 3, cols = 3) {
const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | '); const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | ');
@@ -2969,10 +2984,10 @@ function formatTable() {
return '| ' + padded.join(' | ') + ' |'; return '| ' + padded.join(' | ') + ' |';
}); });
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
// ============ Prototype installation ============ // ============ Prototype installation ============
const installCommands = (proto) => { const installCommands = (proto) => {
@@ -2988,8 +3003,17 @@ const installCommands = (proto) => {
/** /**
* MetonaEditor Floating Toolbar — selection format toolbar * MetonaEditor Floating Toolbar — selection format toolbar
* @module floating-toolbar * @module floating-toolbar
* @version 0.4.0 * @version 0.4.3
*
* 定位策略(v0.4.3 重做):mirror 镜像测量。
* 创建与 textarea 排版样式一致的隐藏镜像层,在选区锚点(文档序中点)处
* 插入零宽字符 marker,读取其 offsetLeft/offsetTop 得到像素级精确坐标,
* 彻底替代旧版按字符宽度估算的方案(CJK、软换行、tab 宽度、字体
* fallback 的误差全部消除)。锚点滚出可视区自动隐藏;textarea 滚动时
* rAF 节流重定位;divider 拖拽结束 / 窗口 resize 后隐藏待重新触发。
*/ */
/** 浮动工具栏与选区锚点之间的间距(px) */
const ANCHOR_GAP = 6;
function initFloatingToolbar() { function initFloatingToolbar() {
if (typeof document === 'undefined') if (typeof document === 'undefined')
return; return;
@@ -2997,11 +3021,9 @@ function initFloatingToolbar() {
if (!document.getElementById('me-float-style')) { if (!document.getElementById('me-float-style')) {
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'me-float-style'; style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`; style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;visibility:hidden;transform:translateY(4px);transition:opacity .15s,transform .15s,visibility .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}.me-float-mirror{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;z-index:-1;border:0;margin:0}`;
document.head.appendChild(style); document.head.appendChild(style);
} }
if (!this._floatingEnabled)
return;
const ta = this.textarea; const ta = this.textarea;
const show = () => { const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) if (this._destroyed || this.config.readOnly || !this._floatingEnabled)
@@ -3014,58 +3036,130 @@ function initFloatingToolbar() {
} }
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) if (!this._floatingToolbar || !this._floatingToolbar.parentNode)
this._buildFloatingToolbar(); this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect(); const anchor = this._measureSelectionAnchor();
const paneRect = this.editorPane.getBoundingClientRect(); if (!anchor) {
this._hideFloatingToolbar();
return;
}
// 选区锚点行已滚出 textarea 可视区时隐藏(跟随滚动的重定位会再次显示)
if (anchor.y < -parseFloat(getComputedStyle(ta).lineHeight || '22') || anchor.y > ta.clientHeight) {
this._hideFloatingToolbar();
return;
}
const bar = this._floatingToolbar;
// 实测尺寸定位(替代旧版 -80 / 36 / 200 等魔法数字)
const barRect = bar.getBoundingClientRect();
const barW = barRect.width || 170;
const barH = barRect.height || 38;
const taStyle = getComputedStyle(ta); const taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22; const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16; const paneW = this.editorPane.getBoundingClientRect().width;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18; let top = anchor.y - barH - ANCHOR_GAP;
const fontSize = parseFloat(taStyle.fontSize) || 13.5; if (top < ANCHOR_GAP)
const textBefore = ta.value.substring(0, start); top = anchor.y + lineHeight + ANCHOR_GAP;
const lines = textBefore.split('\n'); let left = anchor.x - barW / 2;
const currentLine = lines.length - 1; left = Math.max(4, Math.min(left, paneW - barW - 4));
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1; if (left < 4)
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1)); left = 4; // 极窄面板兜底
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop; bar.style.top = top + 'px';
const lineLocalTop = lineViewportTop - paneRect.top; bar.style.left = left + 'px';
const toolbarHeight = 36; bar.classList.add('me-visible');
const gap = 4;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap)
top = lineLocalTop + lineHeight + gap;
// 逐字符估算列偏移:CJK 全角字符宽约 1em,其余按 0.6em,
// 纯 ASCII 选区回退到原 0.6em 估算,中文选区不再整体偏左。
const CJK_RE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/;
const lineText = lines[lines.length - 1] || '';
const measureX = (col) => {
let w = 0;
const n = Math.max(0, Math.min(col, lineText.length));
for (let i = 0; i < n; i++)
w += CJK_RE.test(lineText[i]) ? fontSize : fontSize * 0.6;
return w;
};
const colX = taRect.left + paddingLeft + measureX(midCol) - ta.scrollLeft;
let left = colX - paneRect.left - 80;
left = Math.max(4, Math.min(left, paneRect.width - 200));
this._floatingToolbar.style.top = top + 'px';
this._floatingToolbar.style.left = left + 'px';
this._floatingToolbar.classList.add('me-visible');
}; };
const hide = () => { this._hideFloatingToolbar(); }; const hide = () => { this._hideFloatingToolbar(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0)); // 滚动跟随:rAF 节流重定位(保持纯事件驱动,不做轮询)
ta.addEventListener('keyup', () => { let rafPending = false;
const reposition = () => {
if (rafPending)
return;
rafPending = true;
requestAnimationFrame(() => { rafPending = false; show(); });
};
const onMouseUp = () => setTimeout(show, 0);
const onKeyup = () => {
// selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射 // selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
if (ta.selectionStart !== ta.selectionEnd) if (ta.selectionStart !== ta.selectionEnd)
setTimeout(show, 0); setTimeout(show, 0);
else else
setTimeout(hide, 0); setTimeout(hide, 0);
}); };
ta.addEventListener('blur', () => setTimeout(hide, 300)); const onBlur = () => setTimeout(() => {
ta.addEventListener('click', () => { // 焦点快速抖动场景(如自动化 fill 后还原焦点再 focus 回来、
// 宿主脚本焦点切换):延迟到期时焦点已回到 textarea 则不隐藏
if (document.activeElement === ta)
return;
hide();
}, 300);
const onClick = () => {
if (ta.selectionStart === ta.selectionEnd) if (ta.selectionStart === ta.selectionEnd)
hide(); hide();
};
const onScroll = reposition;
// 布局变化后旧坐标失效,直接隐藏(下次选区交互重新显示)
const onResize = () => hide();
ta.addEventListener('mouseup', onMouseUp);
ta.addEventListener('keyup', onKeyup);
ta.addEventListener('blur', onBlur);
ta.addEventListener('click', onClick);
ta.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);
// 事件绑定与启用状态解耦:floatingToolbar:false 构造的实例,
// toggleFloatingToolbar() 打开后仍可正常显示(修复“死开关”)。
this._cleanups.push(() => {
ta.removeEventListener('mouseup', onMouseUp);
ta.removeEventListener('keyup', onKeyup);
ta.removeEventListener('blur', onBlur);
ta.removeEventListener('click', onClick);
ta.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
}); });
} }
/**
* 镜像测量选区锚点坐标(相对 editorPane 的局部坐标)。
* 锚点 = 选区文档序中点:单行选区即视觉中部,跨行选区约在选区垂直中心。
*/
function measureSelectionAnchor() {
const ta = this.textarea;
if (!ta || !this.editorPane)
return null;
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (start === end)
return null;
const anchor = Math.floor((start + end) / 2);
// 懒创建镜像层并复制 textarea 排版样式(font/行高/padding/换行/tab
let mirror = this._floatMirror;
if (!mirror || !mirror.parentNode) {
mirror = document.createElement('div');
mirror.className = 'me-float-mirror';
this.editorPane.appendChild(mirror);
this._floatMirror = mirror;
}
const cs = getComputedStyle(ta);
const props = [
['fontFamily', cs.fontFamily], ['fontSize', cs.fontSize], ['fontWeight', cs.fontWeight],
['fontStyle', cs.fontStyle], ['letterSpacing', cs.letterSpacing], ['lineHeight', cs.lineHeight],
['paddingTop', cs.paddingTop], ['paddingRight', cs.paddingRight],
['paddingBottom', cs.paddingBottom], ['paddingLeft', cs.paddingLeft],
['whiteSpace', cs.whiteSpace], ['overflowWrap', cs.overflowWrap],
['wordBreak', cs.wordBreak], ['tabSize', cs.tabSize], ['boxSizing', 'border-box'],
];
props.forEach(([k, v]) => { mirror.style[k] = v; });
// 宽度对齐 textarea 文本排版区(clientWidth 含 padding 不含滚动条)
mirror.style.width = ta.clientWidth + 'px';
// 仅需锚点前缀文本:其后内容不影响锚点位置(折行由前缀决定)
mirror.textContent = '';
mirror.appendChild(document.createTextNode(ta.value.slice(0, anchor)));
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
// marker 坐标相对 mirrorposition:absolute 的 offsetParentborder 0),
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
const x = marker.offsetLeft - ta.scrollLeft;
const y = marker.offsetTop - ta.scrollTop;
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
}
function toggleFloatingToolbar() { function toggleFloatingToolbar() {
this._floatingEnabled = !this._floatingEnabled; this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) { if (!this._floatingEnabled) {
@@ -3081,15 +3175,23 @@ function isFloatingToolbar() { return this._floatingEnabled; }
function buildFloatingToolbar() { function buildFloatingToolbar() {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.className = 'me-float-toolbar'; bar.className = 'me-float-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', this.t('formatToolbar') || '格式化选区');
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough']; const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => { actions.forEach((action) => {
const btn = this._createBtn(action); const btn = this._createBtn(action);
// mousedown 阻止夺焦(textarea 选区与焦点保持);命令执行统一走 click,
// 键盘 Enter/Space 触发的 click 同样生效(可访问性)
btn.addEventListener('mousedown', (e) => { btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
});
btn.addEventListener('click', () => {
this.exec(action); this.exec(action);
// Keep selection after exec this._hideFloatingToolbar();
setTimeout(() => this.textarea.focus(), 0); // Keep selection after exec(延迟回调时实例可能已销毁)
setTimeout(() => { if (!this._destroyed && this.textarea)
this.textarea.focus(); }, 0);
}); });
bar.appendChild(btn); bar.appendChild(btn);
}); });
@@ -3108,6 +3210,7 @@ const installFloatingToolbar = (proto) => {
proto.isFloatingToolbar = isFloatingToolbar; proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar; proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar; proto._hideFloatingToolbar = hideFloatingToolbar;
proto._measureSelectionAnchor = measureSelectionAnchor;
}; };
/** /**
@@ -3172,7 +3275,8 @@ function showContextMenu(e) {
el.className = 'me-context-menu-item'; el.className = 'me-context-menu-item';
if (item.disabled) if (item.disabled)
el.classList.add('me-disabled'); el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`; // 自定义菜单项的 label / shortcut 经转义后拼接(防 HTML 注入)
el.innerHTML = `<span>${escapeHTML(item.label)}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${escapeHTML(item.shortcut)}</span>` : ''}`;
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
if (item.disabled) if (item.disabled)
@@ -3218,9 +3322,13 @@ function execContextAction(action) {
case 'copy': case 'copy':
document.execCommand('copy'); document.execCommand('copy');
break; break;
case 'paste': case 'paste': {
document.execCommand('paste'); // 现代浏览器出于安全限制,execCommand('paste') 通常静默失败 —— 提示改用 Ctrl+V
const ok = document.execCommand('paste');
if (!ok)
this.toast(this.t('pasteBlocked') || '无法直接粘贴,请使用 Ctrl+V', { type: 'info' });
break; break;
}
case 'selectAll': case 'selectAll':
ta.focus(); ta.focus();
ta.select(); ta.select();
@@ -3475,6 +3583,7 @@ class MarkdownEditor {
this._zenMouseHandler = null; this._zenMouseHandler = null;
this._floatingToolbar = null; this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false; this._floatingEnabled = this.config.floatingToolbar !== false;
this._floatMirror = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown; this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null; this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
MarkdownEditor.trigger('beforeCreate', this); MarkdownEditor.trigger('beforeCreate', this);
@@ -3496,8 +3605,9 @@ class MarkdownEditor {
this._bindEvents(); this._bindEvents();
this._bindContextMenu(); this._bindContextMenu();
this._trackOutlineScroll(); this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) // 无条件绑定事件(show 内部检查 _floatingEnabled):
this._initFloatingToolbar(); // floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
this._initFloatingToolbar();
this.textarea.value = this._value; this.textarea.value = this._value;
if (!this._wordWrap) if (!this._wordWrap)
this.textarea.style.whiteSpace = 'pre'; this.textarea.style.whiteSpace = 'pre';
@@ -3777,6 +3887,12 @@ class MarkdownEditor {
this.toolbarEl.addEventListener('click', onToolbarClick); this.toolbarEl.addEventListener('click', onToolbarClick);
const onDividerDown = (e) => this._bindDividerDrag(e); const onDividerDown = (e) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown); this.dividerEl.addEventListener('pointerdown', onDividerDown);
// 全屏模式按 Esc 退出(与 shortcutHelp 面板的 Esc 关闭互不冲突:条件互斥)
const onDocKeydown = (e) => {
if (e.key === 'Escape' && this._fullscreen)
this.exitFullscreen();
};
document.addEventListener('keydown', onDocKeydown);
this._bindDragDrop(); this._bindDragDrop();
this._cleanups.push(() => { this._cleanups.push(() => {
ta.removeEventListener('input', onInput); ta.removeEventListener('input', onInput);
@@ -3790,6 +3906,7 @@ class MarkdownEditor {
ta.removeEventListener('blur', onBlur); ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick); this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown); this.dividerEl.removeEventListener('pointerdown', onDividerDown);
document.removeEventListener('keydown', onDocKeydown);
}); });
} }
_handleKeydown(e) { _handleKeydown(e) {
@@ -3874,10 +3991,10 @@ class MarkdownEditor {
ta.selectionStart = lineStart; ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + newBlock.length; ta.selectionEnd = lineStart + newBlock.length;
} }
const old = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(old);
this._emit('change', this._value);
} }
_bindDividerDrag(e) { _bindDividerDrag(e) {
if (this._mode !== 'split') if (this._mode !== 'split')
@@ -3903,6 +4020,8 @@ class MarkdownEditor {
window.removeEventListener('pointermove', onMove); window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp); window.removeEventListener('pointerup', onUp);
this._saveDividerPosition(); this._saveDividerPosition();
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
this._hideFloatingToolbar();
}; };
window.addEventListener('pointermove', onMove); window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp); window.addEventListener('pointerup', onUp);
@@ -4018,12 +4137,12 @@ class MarkdownEditor {
const content = listMatch[3]; const content = listMatch[3];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
let nextMarker = marker; let nextMarker = marker;
@@ -4036,10 +4155,10 @@ class MarkdownEditor {
const insert = '\n' + indent + nextMarker + ' '; const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/); const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
@@ -4048,22 +4167,22 @@ class MarkdownEditor {
const content = quoteMatch[2]; const content = quoteMatch[2];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
e.preventDefault(); e.preventDefault();
const insert = '\n' + prefix; const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
} }
_handleBracketAutoClose(e) { _handleBracketAutoClose(e) {
@@ -4078,6 +4197,7 @@ class MarkdownEditor {
const end = ta.selectionEnd; const end = ta.selectionEnd;
if (start !== end) { if (start !== end) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const selected = ta.value.slice(start, end); const selected = ta.value.slice(start, end);
const wrap = key + selected + close; const wrap = key + selected + close;
ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end);
@@ -4085,7 +4205,7 @@ class MarkdownEditor {
ta.selectionEnd = start + 1 + selected.length; ta.selectionEnd = start + 1 + selected.length;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
return; return;
} }
const nextChar = ta.value[start] || ''; const nextChar = ta.value[start] || '';
@@ -4099,12 +4219,13 @@ class MarkdownEditor {
return; return;
} }
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const pair = key + close; const pair = key + close;
ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 1; ta.selectionStart = ta.selectionEnd = start + 1;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
} }
_bindDragDrop() { _bindDragDrop() {
const ta = this.textarea; const ta = this.textarea;
@@ -4146,6 +4267,39 @@ class MarkdownEditor {
} }
_scheduleHistory() { if (this._historyTimer) _scheduleHistory() { if (this._historyTimer)
clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
/**
* 程序化编辑路径(命令 / 快捷键 / 插件 / API 直改 textarea)的统一刷新管线。
* 此前 8 条路径的 预览/行号/字数/大纲 刷新与事件发射参差不齐,
* 现统一为:渲染 + 行号 + 字数 + 大纲 + input/change 事件 + 回调 + before/afterChange 钩子。
*/
_afterProgrammaticEdit(oldValue) {
MarkdownEditor.trigger('beforeChange', this);
this._emit('beforeChange', oldValue, this._value);
this._render();
this._renderGutter();
this._updateWordCount();
this._updateOutline();
this._emit('input', this._value);
this._emit('change', this._value);
if (typeof this.config.onInput === 'function') {
try {
this.config.onInput(this._value, this);
}
catch (e) {
console.error(e);
}
}
if (typeof this.config.onChange === 'function') {
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
}
this._emit('afterChange', this._value);
MarkdownEditor.trigger('afterChange', this);
}
_pushHistory() { _pushHistory() {
// Enforce maxLength as a last-resort guard for programmatic edit paths // Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write // (tab/indent, smart-enter, bracket auto-close, exec commands) that write
@@ -4176,11 +4330,10 @@ class MarkdownEditor {
const pv = this.previewPane; const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0; const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null; const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
const oldV = this._value;
this._value = this._history[this._historyIndex]; this._value = this._history[this._historyIndex];
this.textarea.value = this._value; this.textarea.value = this._value;
this._render(); this._afterProgrammaticEdit(oldV);
this._renderGutter();
this._updateWordCount();
if (this.gutter) if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop; this.gutter.scrollTop = this.textarea.scrollTop;
if (pv && pvRatio != null) { if (pv && pvRatio != null) {
@@ -4188,15 +4341,6 @@ class MarkdownEditor {
if (max > 0) if (max > 0)
pv.scrollTop = pvRatio * max; pv.scrollTop = pvRatio * max;
} }
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
}
} }
_captureCursor() { _captureCursor() {
if (!this.textarea) if (!this.textarea)
@@ -4562,12 +4706,11 @@ class MarkdownEditor {
if (!matches) if (!matches)
return 0; return 0;
// 函数式替换:替换文本按字面处理,$ 无特殊语义 // 函数式替换:替换文本按字面处理,$ 无特殊语义
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, () => replace)); this._value = this._limitLength(this._value.replace(re, () => replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
replaceAllRegex(pattern, replace) { replaceAllRegex(pattern, replace) {
@@ -4577,12 +4720,11 @@ class MarkdownEditor {
const matches = this._value.match(re); const matches = this._value.match(re);
if (!matches) if (!matches)
return 0; return 0;
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, replace)); this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
lineCount() { lineCount() {
@@ -4650,9 +4792,17 @@ class MarkdownEditor {
copyAsHTML() { copyAsHTML() {
const html = this.getHTML(); const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) { if (typeof navigator !== 'undefined' && navigator.clipboard) {
const blob = new Blob([html], { type: 'text/html' }); // ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) }); if (typeof ClipboardItem !== 'undefined') {
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { }); try {
const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
return this;
}
catch (_) { /* fallthrough 降级 */ }
}
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
} }
return this; return this;
} }
@@ -4664,11 +4814,10 @@ class MarkdownEditor {
ta.value = inserted; ta.value = inserted;
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length); ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return this; return this;
} }
wrap(before, after) { this._wrapSelection(before, after || before); return this; } wrap(before, after) { this._wrapSelection(before, after || before); return this; }
@@ -4854,6 +5003,9 @@ class MarkdownEditor {
if (this._floatingToolbar && this._floatingToolbar.parentNode) if (this._floatingToolbar && this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null; this._floatingToolbar = null;
if (this._floatMirror && this._floatMirror.parentNode)
this._floatMirror.parentNode.removeChild(this._floatMirror);
this._floatMirror = null;
if (this.el) if (this.el)
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove()); this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { this._plugins.forEach((p) => { if (typeof p.destroy === 'function') {
@@ -5143,9 +5295,9 @@ const getSupportedLanguages = () => Object.keys(LANGUAGES);
/** /**
* MetonaEditor — Type-safe, lightweight Markdown Editor * MetonaEditor — Type-safe, lightweight Markdown Editor
* @module metona-editor * @module metona-editor
* @version 0.4.2 * @version 0.4.3
*/ */
const VERSION = '0.4.2'; const VERSION = '0.4.3';
const globalPlugins = []; const globalPlugins = [];
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件 // 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。 // searchReplace / shortcutHelp / imagePaste)才能正常生效。
+1 -1
View File
File diff suppressed because one or more lines are too long
+14 -1
View File
@@ -19,6 +19,8 @@ interface EditorLike {
_pushHistory?: () => void; _pushHistory?: () => void;
_render?: () => void; _render?: () => void;
_updateWordCount?: () => void; _updateWordCount?: () => void;
/** 程序化编辑统一刷新管线(渲染+行号+字数+大纲+事件+钩子) */
_afterProgrammaticEdit?: (oldValue: string) => void;
insert?: (text: string, opts?: { insert?: (text: string, opts?: {
replace?: boolean; replace?: boolean;
}) => unknown; }) => unknown;
@@ -359,6 +361,7 @@ declare class MarkdownEditor {
_i18nCtx: InstanceI18n; _i18nCtx: InstanceI18n;
_floatingToolbar: HTMLElement | null; _floatingToolbar: HTMLElement | null;
_floatingEnabled: boolean; _floatingEnabled: boolean;
_floatMirror: HTMLElement | null;
_wrapSelection: (before: string, after: string) => void; _wrapSelection: (before: string, after: string) => void;
_toggleLinePrefix: (prefix: string) => void; _toggleLinePrefix: (prefix: string) => void;
_insertBlock: (text: string) => void; _insertBlock: (text: string) => void;
@@ -371,6 +374,10 @@ declare class MarkdownEditor {
isFloatingToolbar: () => boolean; isFloatingToolbar: () => boolean;
_buildFloatingToolbar: () => void; _buildFloatingToolbar: () => void;
_hideFloatingToolbar: () => void; _hideFloatingToolbar: () => void;
_measureSelectionAnchor: () => {
x: number;
y: number;
} | null;
registerContextMenu: (items: any[]) => this; registerContextMenu: (items: any[]) => this;
_bindContextMenu: () => void; _bindContextMenu: () => void;
_showContextMenu: (e: MouseEvent) => void; _showContextMenu: (e: MouseEvent) => void;
@@ -399,6 +406,12 @@ declare class MarkdownEditor {
_handleBracketAutoClose(e: KeyboardEvent): void; _handleBracketAutoClose(e: KeyboardEvent): void;
_bindDragDrop(): void; _bindDragDrop(): void;
_scheduleHistory(): void; _scheduleHistory(): void;
/**
* 程序化编辑路径(命令 / 快捷键 / 插件 / API 直改 textarea)的统一刷新管线。
* 此前 8 条路径的 预览/行号/字数/大纲 刷新与事件发射参差不齐,
* 现统一为:渲染 + 行号 + 字数 + 大纲 + input/change 事件 + 回调 + before/afterChange 钩子。
*/
_afterProgrammaticEdit(oldValue: string): void;
_pushHistory(): void; _pushHistory(): void;
undo(): this; undo(): this;
redo(): this; redo(): this;
@@ -585,7 +598,7 @@ declare const animationUtils: {
destroy(): void; destroy(): void;
}; };
declare const VERSION = "0.4.2"; declare const VERSION = "0.4.3";
declare function create(container: string | HTMLElement, options?: EditorOptions): MarkdownEditor; declare function create(container: string | HTMLElement, options?: EditorOptions): MarkdownEditor;
declare function use(plugin: string | any, options?: any): typeof api; declare function use(plugin: string | any, options?: any): typeof api;
declare function on(name: string, fn: (editor: MarkdownEditor) => void): () => void; declare function on(name: string, fn: (editor: MarkdownEditor) => void): () => void;
+271 -119
View File
@@ -111,6 +111,7 @@
outline: '大纲', outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选', cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB', regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB',
formatToolbar: '格式化选区', pasteBlocked: '无法直接粘贴,请使用 Ctrl+V',
}, },
'en-US': { 'en-US': {
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
@@ -138,6 +139,7 @@
outline: 'Outline', outline: 'Outline',
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All', cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)', regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)',
formatToolbar: 'Format selection', pasteBlocked: 'Cannot paste directly, use Ctrl+V',
}, },
ja: { ja: {
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線', bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
@@ -165,6 +167,7 @@
outline: 'アウトライン', outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択', cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB', regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
}, },
ko: { ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -192,6 +195,7 @@
outline: '개요', outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택', cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)', regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
formatToolbar: '선택 영역 서식', pasteBlocked: '직접 붙여넣을 수 없습니다. Ctrl+V를 사용하세요',
}, },
fr: { fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -219,6 +223,7 @@
outline: 'Plan', outline: 'Plan',
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner', cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)', regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)',
formatToolbar: 'Formater la sélection', pasteBlocked: 'Impossible de coller directement, utilisez Ctrl+V',
}, },
de: { de: {
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen', bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
@@ -246,6 +251,7 @@
outline: 'Gliederung', outline: 'Gliederung',
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen', cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)', regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)',
formatToolbar: 'Auswahl formatieren', pasteBlocked: 'Direktes Einfügen nicht möglich, verwenden Sie Strg+V',
}, },
}; };
@@ -2156,15 +2162,21 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
ta.setSelectionRange(s, s + r.length); ta.setSelectionRange(s, s + r.length);
} }
const oldV = editor._value || '';
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._pushHistory === 'function') if (typeof editor._afterProgrammaticEdit === 'function') {
editor._pushHistory(); editor._afterProgrammaticEdit(oldV);
if (typeof editor._render === 'function') }
editor._render(); else {
if (typeof editor._updateWordCount === 'function') if (typeof editor._pushHistory === 'function')
editor._updateWordCount(); editor._pushHistory();
if (typeof editor._emit === 'function') if (typeof editor._render === 'function')
editor._emit('change', editor._value); editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
}
findNext(); findNext();
}; };
const replaceAll = () => { const replaceAll = () => {
@@ -2175,17 +2187,23 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const re = buildPattern(q); const re = buildPattern(q);
if (!re) if (!re)
return; return;
const oldV = editor._value || '';
ta.value = ta.value.replace(re, () => r); ta.value = ta.value.replace(re, () => r);
ta.setSelectionRange(0, 0); ta.setSelectionRange(0, 0);
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._pushHistory === 'function') if (typeof editor._afterProgrammaticEdit === 'function') {
editor._pushHistory(); editor._afterProgrammaticEdit(oldV);
if (typeof editor._render === 'function') }
editor._render(); else {
if (typeof editor._updateWordCount === 'function') if (typeof editor._pushHistory === 'function')
editor._updateWordCount(); editor._pushHistory();
if (typeof editor._emit === 'function') if (typeof editor._render === 'function')
editor._emit('change', editor._value); editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
}
findAll(); findAll();
}; };
fi.addEventListener('input', () => { lastIdxs = findAll(); }); fi.addEventListener('input', () => { lastIdxs = findAll(); });
@@ -2851,11 +2869,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
else { else {
ta.selectionStart = ta.selectionEnd = start + before.length; ta.selectionStart = ta.selectionEnd = start + before.length;
} }
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Line prefix toggling ============ // ============ Line prefix toggling ============
function toggleLinePrefix(prefix) { function toggleLinePrefix(prefix) {
@@ -2876,11 +2893,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Block insertion ============ // ============ Block insertion ============
function insertBlock(text) { function insertBlock(text) {
@@ -2893,11 +2909,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.focus(); ta.focus();
const pos = start + insert.length; const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos; ta.selectionStart = ta.selectionEnd = pos;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
function insertLink() { function insertLink() {
const ta = this.textarea; const ta = this.textarea;
@@ -2910,10 +2925,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 3; ta.selectionStart = start + sel.length + 3;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertImage() { function insertImage() {
const ta = this.textarea; const ta = this.textarea;
@@ -2926,10 +2941,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 4; ta.selectionStart = start + sel.length + 4;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertTable(rows = 3, cols = 3) { function insertTable(rows = 3, cols = 3) {
const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | '); const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | ');
@@ -2971,10 +2986,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
return '| ' + padded.join(' | ') + ' |'; return '| ' + padded.join(' | ') + ' |';
}); });
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
// ============ Prototype installation ============ // ============ Prototype installation ============
const installCommands = (proto) => { const installCommands = (proto) => {
@@ -2990,8 +3005,17 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
/** /**
* MetonaEditor Floating Toolbar selection format toolbar * MetonaEditor Floating Toolbar selection format toolbar
* @module floating-toolbar * @module floating-toolbar
* @version 0.4.0 * @version 0.4.3
*
* 定位策略v0.4.3 重做mirror 镜像测量
* 创建与 textarea 排版样式一致的隐藏镜像层在选区锚点文档序中点
* 插入零宽字符 marker读取其 offsetLeft/offsetTop 得到像素级精确坐标
* 彻底替代旧版按字符宽度估算的方案CJK软换行tab 宽度字体
* fallback 的误差全部消除锚点滚出可视区自动隐藏textarea 滚动时
* rAF 节流重定位divider 拖拽结束 / 窗口 resize 后隐藏待重新触发
*/ */
/** 浮动工具栏与选区锚点之间的间距(px) */
const ANCHOR_GAP = 6;
function initFloatingToolbar() { function initFloatingToolbar() {
if (typeof document === 'undefined') if (typeof document === 'undefined')
return; return;
@@ -2999,11 +3023,9 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
if (!document.getElementById('me-float-style')) { if (!document.getElementById('me-float-style')) {
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'me-float-style'; style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`; style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;visibility:hidden;transform:translateY(4px);transition:opacity .15s,transform .15s,visibility .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}.me-float-mirror{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;z-index:-1;border:0;margin:0}`;
document.head.appendChild(style); document.head.appendChild(style);
} }
if (!this._floatingEnabled)
return;
const ta = this.textarea; const ta = this.textarea;
const show = () => { const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) if (this._destroyed || this.config.readOnly || !this._floatingEnabled)
@@ -3016,58 +3038,130 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
} }
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) if (!this._floatingToolbar || !this._floatingToolbar.parentNode)
this._buildFloatingToolbar(); this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect(); const anchor = this._measureSelectionAnchor();
const paneRect = this.editorPane.getBoundingClientRect(); if (!anchor) {
this._hideFloatingToolbar();
return;
}
// 选区锚点行已滚出 textarea 可视区时隐藏(跟随滚动的重定位会再次显示)
if (anchor.y < -parseFloat(getComputedStyle(ta).lineHeight || '22') || anchor.y > ta.clientHeight) {
this._hideFloatingToolbar();
return;
}
const bar = this._floatingToolbar;
// 实测尺寸定位(替代旧版 -80 / 36 / 200 等魔法数字)
const barRect = bar.getBoundingClientRect();
const barW = barRect.width || 170;
const barH = barRect.height || 38;
const taStyle = getComputedStyle(ta); const taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22; const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16; const paneW = this.editorPane.getBoundingClientRect().width;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18; let top = anchor.y - barH - ANCHOR_GAP;
const fontSize = parseFloat(taStyle.fontSize) || 13.5; if (top < ANCHOR_GAP)
const textBefore = ta.value.substring(0, start); top = anchor.y + lineHeight + ANCHOR_GAP;
const lines = textBefore.split('\n'); let left = anchor.x - barW / 2;
const currentLine = lines.length - 1; left = Math.max(4, Math.min(left, paneW - barW - 4));
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1; if (left < 4)
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1)); left = 4; // 极窄面板兜底
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop; bar.style.top = top + 'px';
const lineLocalTop = lineViewportTop - paneRect.top; bar.style.left = left + 'px';
const toolbarHeight = 36; bar.classList.add('me-visible');
const gap = 4;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap)
top = lineLocalTop + lineHeight + gap;
// 逐字符估算列偏移:CJK 全角字符宽约 1em,其余按 0.6em,
// 纯 ASCII 选区回退到原 0.6em 估算,中文选区不再整体偏左。
const CJK_RE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/;
const lineText = lines[lines.length - 1] || '';
const measureX = (col) => {
let w = 0;
const n = Math.max(0, Math.min(col, lineText.length));
for (let i = 0; i < n; i++)
w += CJK_RE.test(lineText[i]) ? fontSize : fontSize * 0.6;
return w;
};
const colX = taRect.left + paddingLeft + measureX(midCol) - ta.scrollLeft;
let left = colX - paneRect.left - 80;
left = Math.max(4, Math.min(left, paneRect.width - 200));
this._floatingToolbar.style.top = top + 'px';
this._floatingToolbar.style.left = left + 'px';
this._floatingToolbar.classList.add('me-visible');
}; };
const hide = () => { this._hideFloatingToolbar(); }; const hide = () => { this._hideFloatingToolbar(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0)); // 滚动跟随:rAF 节流重定位(保持纯事件驱动,不做轮询)
ta.addEventListener('keyup', () => { let rafPending = false;
const reposition = () => {
if (rafPending)
return;
rafPending = true;
requestAnimationFrame(() => { rafPending = false; show(); });
};
const onMouseUp = () => setTimeout(show, 0);
const onKeyup = () => {
// selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射 // selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
if (ta.selectionStart !== ta.selectionEnd) if (ta.selectionStart !== ta.selectionEnd)
setTimeout(show, 0); setTimeout(show, 0);
else else
setTimeout(hide, 0); setTimeout(hide, 0);
}); };
ta.addEventListener('blur', () => setTimeout(hide, 300)); const onBlur = () => setTimeout(() => {
ta.addEventListener('click', () => { // 焦点快速抖动场景(如自动化 fill 后还原焦点再 focus 回来、
// 宿主脚本焦点切换):延迟到期时焦点已回到 textarea 则不隐藏
if (document.activeElement === ta)
return;
hide();
}, 300);
const onClick = () => {
if (ta.selectionStart === ta.selectionEnd) if (ta.selectionStart === ta.selectionEnd)
hide(); hide();
};
const onScroll = reposition;
// 布局变化后旧坐标失效,直接隐藏(下次选区交互重新显示)
const onResize = () => hide();
ta.addEventListener('mouseup', onMouseUp);
ta.addEventListener('keyup', onKeyup);
ta.addEventListener('blur', onBlur);
ta.addEventListener('click', onClick);
ta.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);
// 事件绑定与启用状态解耦:floatingToolbar:false 构造的实例,
// toggleFloatingToolbar() 打开后仍可正常显示(修复“死开关”)。
this._cleanups.push(() => {
ta.removeEventListener('mouseup', onMouseUp);
ta.removeEventListener('keyup', onKeyup);
ta.removeEventListener('blur', onBlur);
ta.removeEventListener('click', onClick);
ta.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
}); });
} }
/**
* 镜像测量选区锚点坐标相对 editorPane 的局部坐标
* 锚点 = 选区文档序中点单行选区即视觉中部跨行选区约在选区垂直中心
*/
function measureSelectionAnchor() {
const ta = this.textarea;
if (!ta || !this.editorPane)
return null;
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (start === end)
return null;
const anchor = Math.floor((start + end) / 2);
// 懒创建镜像层并复制 textarea 排版样式(font/行高/padding/换行/tab
let mirror = this._floatMirror;
if (!mirror || !mirror.parentNode) {
mirror = document.createElement('div');
mirror.className = 'me-float-mirror';
this.editorPane.appendChild(mirror);
this._floatMirror = mirror;
}
const cs = getComputedStyle(ta);
const props = [
['fontFamily', cs.fontFamily], ['fontSize', cs.fontSize], ['fontWeight', cs.fontWeight],
['fontStyle', cs.fontStyle], ['letterSpacing', cs.letterSpacing], ['lineHeight', cs.lineHeight],
['paddingTop', cs.paddingTop], ['paddingRight', cs.paddingRight],
['paddingBottom', cs.paddingBottom], ['paddingLeft', cs.paddingLeft],
['whiteSpace', cs.whiteSpace], ['overflowWrap', cs.overflowWrap],
['wordBreak', cs.wordBreak], ['tabSize', cs.tabSize], ['boxSizing', 'border-box'],
];
props.forEach(([k, v]) => { mirror.style[k] = v; });
// 宽度对齐 textarea 文本排版区(clientWidth 含 padding 不含滚动条)
mirror.style.width = ta.clientWidth + 'px';
// 仅需锚点前缀文本:其后内容不影响锚点位置(折行由前缀决定)
mirror.textContent = '';
mirror.appendChild(document.createTextNode(ta.value.slice(0, anchor)));
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
// marker 坐标相对 mirrorposition:absolute 的 offsetParentborder 0),
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
const x = marker.offsetLeft - ta.scrollLeft;
const y = marker.offsetTop - ta.scrollTop;
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
}
function toggleFloatingToolbar() { function toggleFloatingToolbar() {
this._floatingEnabled = !this._floatingEnabled; this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) { if (!this._floatingEnabled) {
@@ -3083,15 +3177,23 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
function buildFloatingToolbar() { function buildFloatingToolbar() {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.className = 'me-float-toolbar'; bar.className = 'me-float-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', this.t('formatToolbar') || '格式化选区');
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough']; const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => { actions.forEach((action) => {
const btn = this._createBtn(action); const btn = this._createBtn(action);
// mousedown 阻止夺焦(textarea 选区与焦点保持);命令执行统一走 click,
// 键盘 Enter/Space 触发的 click 同样生效(可访问性)
btn.addEventListener('mousedown', (e) => { btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
});
btn.addEventListener('click', () => {
this.exec(action); this.exec(action);
// Keep selection after exec this._hideFloatingToolbar();
setTimeout(() => this.textarea.focus(), 0); // Keep selection after exec(延迟回调时实例可能已销毁)
setTimeout(() => { if (!this._destroyed && this.textarea)
this.textarea.focus(); }, 0);
}); });
bar.appendChild(btn); bar.appendChild(btn);
}); });
@@ -3110,6 +3212,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
proto.isFloatingToolbar = isFloatingToolbar; proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar; proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar; proto._hideFloatingToolbar = hideFloatingToolbar;
proto._measureSelectionAnchor = measureSelectionAnchor;
}; };
/** /**
@@ -3174,7 +3277,8 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
el.className = 'me-context-menu-item'; el.className = 'me-context-menu-item';
if (item.disabled) if (item.disabled)
el.classList.add('me-disabled'); el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`; // 自定义菜单项的 label / shortcut 经转义后拼接(防 HTML 注入)
el.innerHTML = `<span>${escapeHTML(item.label)}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${escapeHTML(item.shortcut)}</span>` : ''}`;
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
if (item.disabled) if (item.disabled)
@@ -3220,9 +3324,13 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
case 'copy': case 'copy':
document.execCommand('copy'); document.execCommand('copy');
break; break;
case 'paste': case 'paste': {
document.execCommand('paste'); // 现代浏览器出于安全限制,execCommand('paste') 通常静默失败 —— 提示改用 Ctrl+V
const ok = document.execCommand('paste');
if (!ok)
this.toast(this.t('pasteBlocked') || '无法直接粘贴,请使用 Ctrl+V', { type: 'info' });
break; break;
}
case 'selectAll': case 'selectAll':
ta.focus(); ta.focus();
ta.select(); ta.select();
@@ -3477,6 +3585,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
this._zenMouseHandler = null; this._zenMouseHandler = null;
this._floatingToolbar = null; this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false; this._floatingEnabled = this.config.floatingToolbar !== false;
this._floatMirror = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown; this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null; this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
MarkdownEditor.trigger('beforeCreate', this); MarkdownEditor.trigger('beforeCreate', this);
@@ -3498,8 +3607,9 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
this._bindEvents(); this._bindEvents();
this._bindContextMenu(); this._bindContextMenu();
this._trackOutlineScroll(); this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) // 无条件绑定事件(show 内部检查 _floatingEnabled):
this._initFloatingToolbar(); // floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
this._initFloatingToolbar();
this.textarea.value = this._value; this.textarea.value = this._value;
if (!this._wordWrap) if (!this._wordWrap)
this.textarea.style.whiteSpace = 'pre'; this.textarea.style.whiteSpace = 'pre';
@@ -3779,6 +3889,12 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
this.toolbarEl.addEventListener('click', onToolbarClick); this.toolbarEl.addEventListener('click', onToolbarClick);
const onDividerDown = (e) => this._bindDividerDrag(e); const onDividerDown = (e) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown); this.dividerEl.addEventListener('pointerdown', onDividerDown);
// 全屏模式按 Esc 退出(与 shortcutHelp 面板的 Esc 关闭互不冲突:条件互斥)
const onDocKeydown = (e) => {
if (e.key === 'Escape' && this._fullscreen)
this.exitFullscreen();
};
document.addEventListener('keydown', onDocKeydown);
this._bindDragDrop(); this._bindDragDrop();
this._cleanups.push(() => { this._cleanups.push(() => {
ta.removeEventListener('input', onInput); ta.removeEventListener('input', onInput);
@@ -3792,6 +3908,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.removeEventListener('blur', onBlur); ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick); this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown); this.dividerEl.removeEventListener('pointerdown', onDividerDown);
document.removeEventListener('keydown', onDocKeydown);
}); });
} }
_handleKeydown(e) { _handleKeydown(e) {
@@ -3876,10 +3993,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.selectionStart = lineStart; ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + newBlock.length; ta.selectionEnd = lineStart + newBlock.length;
} }
const old = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(old);
this._emit('change', this._value);
} }
_bindDividerDrag(e) { _bindDividerDrag(e) {
if (this._mode !== 'split') if (this._mode !== 'split')
@@ -3905,6 +4022,8 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
window.removeEventListener('pointermove', onMove); window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp); window.removeEventListener('pointerup', onUp);
this._saveDividerPosition(); this._saveDividerPosition();
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
this._hideFloatingToolbar();
}; };
window.addEventListener('pointermove', onMove); window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp); window.addEventListener('pointerup', onUp);
@@ -4020,12 +4139,12 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const content = listMatch[3]; const content = listMatch[3];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
let nextMarker = marker; let nextMarker = marker;
@@ -4038,10 +4157,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const insert = '\n' + indent + nextMarker + ' '; const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/); const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
@@ -4050,22 +4169,22 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const content = quoteMatch[2]; const content = quoteMatch[2];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
e.preventDefault(); e.preventDefault();
const insert = '\n' + prefix; const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
} }
_handleBracketAutoClose(e) { _handleBracketAutoClose(e) {
@@ -4080,6 +4199,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const end = ta.selectionEnd; const end = ta.selectionEnd;
if (start !== end) { if (start !== end) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const selected = ta.value.slice(start, end); const selected = ta.value.slice(start, end);
const wrap = key + selected + close; const wrap = key + selected + close;
ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end);
@@ -4087,7 +4207,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.selectionEnd = start + 1 + selected.length; ta.selectionEnd = start + 1 + selected.length;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
return; return;
} }
const nextChar = ta.value[start] || ''; const nextChar = ta.value[start] || '';
@@ -4101,12 +4221,13 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
return; return;
} }
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const pair = key + close; const pair = key + close;
ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 1; ta.selectionStart = ta.selectionEnd = start + 1;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
} }
_bindDragDrop() { _bindDragDrop() {
const ta = this.textarea; const ta = this.textarea;
@@ -4148,6 +4269,39 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
} }
_scheduleHistory() { if (this._historyTimer) _scheduleHistory() { if (this._historyTimer)
clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
/**
* 程序化编辑路径命令 / 快捷键 / 插件 / API 直改 textarea的统一刷新管线
* 此前 8 条路径的 预览/行号/字数/大纲 刷新与事件发射参差不齐
* 现统一为渲染 + 行号 + 字数 + 大纲 + input/change 事件 + 回调 + before/afterChange 钩子
*/
_afterProgrammaticEdit(oldValue) {
MarkdownEditor.trigger('beforeChange', this);
this._emit('beforeChange', oldValue, this._value);
this._render();
this._renderGutter();
this._updateWordCount();
this._updateOutline();
this._emit('input', this._value);
this._emit('change', this._value);
if (typeof this.config.onInput === 'function') {
try {
this.config.onInput(this._value, this);
}
catch (e) {
console.error(e);
}
}
if (typeof this.config.onChange === 'function') {
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
}
this._emit('afterChange', this._value);
MarkdownEditor.trigger('afterChange', this);
}
_pushHistory() { _pushHistory() {
// Enforce maxLength as a last-resort guard for programmatic edit paths // Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write // (tab/indent, smart-enter, bracket auto-close, exec commands) that write
@@ -4178,11 +4332,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const pv = this.previewPane; const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0; const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null; const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
const oldV = this._value;
this._value = this._history[this._historyIndex]; this._value = this._history[this._historyIndex];
this.textarea.value = this._value; this.textarea.value = this._value;
this._render(); this._afterProgrammaticEdit(oldV);
this._renderGutter();
this._updateWordCount();
if (this.gutter) if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop; this.gutter.scrollTop = this.textarea.scrollTop;
if (pv && pvRatio != null) { if (pv && pvRatio != null) {
@@ -4190,15 +4343,6 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
if (max > 0) if (max > 0)
pv.scrollTop = pvRatio * max; pv.scrollTop = pvRatio * max;
} }
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
}
} }
_captureCursor() { _captureCursor() {
if (!this.textarea) if (!this.textarea)
@@ -4564,12 +4708,11 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
if (!matches) if (!matches)
return 0; return 0;
// 函数式替换:替换文本按字面处理,$ 无特殊语义 // 函数式替换:替换文本按字面处理,$ 无特殊语义
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, () => replace)); this._value = this._limitLength(this._value.replace(re, () => replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
replaceAllRegex(pattern, replace) { replaceAllRegex(pattern, replace) {
@@ -4579,12 +4722,11 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const matches = this._value.match(re); const matches = this._value.match(re);
if (!matches) if (!matches)
return 0; return 0;
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, replace)); this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
lineCount() { lineCount() {
@@ -4652,9 +4794,17 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
copyAsHTML() { copyAsHTML() {
const html = this.getHTML(); const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) { if (typeof navigator !== 'undefined' && navigator.clipboard) {
const blob = new Blob([html], { type: 'text/html' }); // ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) }); if (typeof ClipboardItem !== 'undefined') {
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { }); try {
const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
return this;
}
catch (_) { /* fallthrough 降级 */ }
}
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
} }
return this; return this;
} }
@@ -4666,11 +4816,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.value = inserted; ta.value = inserted;
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length); ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return this; return this;
} }
wrap(before, after) { this._wrapSelection(before, after || before); return this; } wrap(before, after) { this._wrapSelection(before, after || before); return this; }
@@ -4856,6 +5005,9 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
if (this._floatingToolbar && this._floatingToolbar.parentNode) if (this._floatingToolbar && this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null; this._floatingToolbar = null;
if (this._floatMirror && this._floatMirror.parentNode)
this._floatMirror.parentNode.removeChild(this._floatMirror);
this._floatMirror = null;
if (this.el) if (this.el)
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove()); this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { this._plugins.forEach((p) => { if (typeof p.destroy === 'function') {
@@ -5145,9 +5297,9 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
/** /**
* MetonaEditor Type-safe, lightweight Markdown Editor * MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor * @module metona-editor
* @version 0.4.2 * @version 0.4.3
*/ */
const VERSION = '0.4.2'; const VERSION = '0.4.3';
const globalPlugins = []; const globalPlugins = [];
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件 // 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。 // searchReplace / shortcutHelp / imagePaste)才能正常生效。
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+271 -119
View File
@@ -105,6 +105,7 @@ const LOCALES = {
outline: '大纲', outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选', cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB', regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB',
formatToolbar: '格式化选区', pasteBlocked: '无法直接粘贴,请使用 Ctrl+V',
}, },
'en-US': { 'en-US': {
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
@@ -132,6 +133,7 @@ const LOCALES = {
outline: 'Outline', outline: 'Outline',
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All', cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)', regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)',
formatToolbar: 'Format selection', pasteBlocked: 'Cannot paste directly, use Ctrl+V',
}, },
ja: { ja: {
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線', bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
@@ -159,6 +161,7 @@ const LOCALES = {
outline: 'アウトライン', outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択', cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB', regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
}, },
ko: { ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -186,6 +189,7 @@ const LOCALES = {
outline: '개요', outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택', cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)', regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
formatToolbar: '선택 영역 서식', pasteBlocked: '직접 붙여넣을 수 없습니다. Ctrl+V를 사용하세요',
}, },
fr: { fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -213,6 +217,7 @@ const LOCALES = {
outline: 'Plan', outline: 'Plan',
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner', cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)', regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)',
formatToolbar: 'Formater la sélection', pasteBlocked: 'Impossible de coller directement, utilisez Ctrl+V',
}, },
de: { de: {
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen', bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
@@ -240,6 +245,7 @@ const LOCALES = {
outline: 'Gliederung', outline: 'Gliederung',
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen', cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)', regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)',
formatToolbar: 'Auswahl formatieren', pasteBlocked: 'Direktes Einfügen nicht möglich, verwenden Sie Strg+V',
}, },
}; };
@@ -2150,15 +2156,21 @@ const searchReplacePlugin = {
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
ta.setSelectionRange(s, s + r.length); ta.setSelectionRange(s, s + r.length);
} }
const oldV = editor._value || '';
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._pushHistory === 'function') if (typeof editor._afterProgrammaticEdit === 'function') {
editor._pushHistory(); editor._afterProgrammaticEdit(oldV);
if (typeof editor._render === 'function') }
editor._render(); else {
if (typeof editor._updateWordCount === 'function') if (typeof editor._pushHistory === 'function')
editor._updateWordCount(); editor._pushHistory();
if (typeof editor._emit === 'function') if (typeof editor._render === 'function')
editor._emit('change', editor._value); editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
}
findNext(); findNext();
}; };
const replaceAll = () => { const replaceAll = () => {
@@ -2169,17 +2181,23 @@ const searchReplacePlugin = {
const re = buildPattern(q); const re = buildPattern(q);
if (!re) if (!re)
return; return;
const oldV = editor._value || '';
ta.value = ta.value.replace(re, () => r); ta.value = ta.value.replace(re, () => r);
ta.setSelectionRange(0, 0); ta.setSelectionRange(0, 0);
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._pushHistory === 'function') if (typeof editor._afterProgrammaticEdit === 'function') {
editor._pushHistory(); editor._afterProgrammaticEdit(oldV);
if (typeof editor._render === 'function') }
editor._render(); else {
if (typeof editor._updateWordCount === 'function') if (typeof editor._pushHistory === 'function')
editor._updateWordCount(); editor._pushHistory();
if (typeof editor._emit === 'function') if (typeof editor._render === 'function')
editor._emit('change', editor._value); editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
}
findAll(); findAll();
}; };
fi.addEventListener('input', () => { lastIdxs = findAll(); }); fi.addEventListener('input', () => { lastIdxs = findAll(); });
@@ -2845,11 +2863,10 @@ function wrapSelection(before, after) {
else { else {
ta.selectionStart = ta.selectionEnd = start + before.length; ta.selectionStart = ta.selectionEnd = start + before.length;
} }
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Line prefix toggling ============ // ============ Line prefix toggling ============
function toggleLinePrefix(prefix) { function toggleLinePrefix(prefix) {
@@ -2870,11 +2887,10 @@ function toggleLinePrefix(prefix) {
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Block insertion ============ // ============ Block insertion ============
function insertBlock(text) { function insertBlock(text) {
@@ -2887,11 +2903,10 @@ function insertBlock(text) {
ta.focus(); ta.focus();
const pos = start + insert.length; const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos; ta.selectionStart = ta.selectionEnd = pos;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
function insertLink() { function insertLink() {
const ta = this.textarea; const ta = this.textarea;
@@ -2904,10 +2919,10 @@ function insertLink() {
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 3; ta.selectionStart = start + sel.length + 3;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertImage() { function insertImage() {
const ta = this.textarea; const ta = this.textarea;
@@ -2920,10 +2935,10 @@ function insertImage() {
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 4; ta.selectionStart = start + sel.length + 4;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertTable(rows = 3, cols = 3) { function insertTable(rows = 3, cols = 3) {
const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | '); const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | ');
@@ -2965,10 +2980,10 @@ function formatTable() {
return '| ' + padded.join(' | ') + ' |'; return '| ' + padded.join(' | ') + ' |';
}); });
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
// ============ Prototype installation ============ // ============ Prototype installation ============
const installCommands = (proto) => { const installCommands = (proto) => {
@@ -2984,8 +2999,17 @@ const installCommands = (proto) => {
/** /**
* MetonaEditor Floating Toolbar selection format toolbar * MetonaEditor Floating Toolbar selection format toolbar
* @module floating-toolbar * @module floating-toolbar
* @version 0.4.0 * @version 0.4.3
*
* 定位策略v0.4.3 重做mirror 镜像测量
* 创建与 textarea 排版样式一致的隐藏镜像层在选区锚点文档序中点
* 插入零宽字符 marker读取其 offsetLeft/offsetTop 得到像素级精确坐标
* 彻底替代旧版按字符宽度估算的方案CJK软换行tab 宽度字体
* fallback 的误差全部消除锚点滚出可视区自动隐藏textarea 滚动时
* rAF 节流重定位divider 拖拽结束 / 窗口 resize 后隐藏待重新触发
*/ */
/** 浮动工具栏与选区锚点之间的间距(px) */
const ANCHOR_GAP = 6;
function initFloatingToolbar() { function initFloatingToolbar() {
if (typeof document === 'undefined') if (typeof document === 'undefined')
return; return;
@@ -2993,11 +3017,9 @@ function initFloatingToolbar() {
if (!document.getElementById('me-float-style')) { if (!document.getElementById('me-float-style')) {
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'me-float-style'; style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`; style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;visibility:hidden;transform:translateY(4px);transition:opacity .15s,transform .15s,visibility .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}.me-float-mirror{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;z-index:-1;border:0;margin:0}`;
document.head.appendChild(style); document.head.appendChild(style);
} }
if (!this._floatingEnabled)
return;
const ta = this.textarea; const ta = this.textarea;
const show = () => { const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) if (this._destroyed || this.config.readOnly || !this._floatingEnabled)
@@ -3010,58 +3032,130 @@ function initFloatingToolbar() {
} }
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) if (!this._floatingToolbar || !this._floatingToolbar.parentNode)
this._buildFloatingToolbar(); this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect(); const anchor = this._measureSelectionAnchor();
const paneRect = this.editorPane.getBoundingClientRect(); if (!anchor) {
this._hideFloatingToolbar();
return;
}
// 选区锚点行已滚出 textarea 可视区时隐藏(跟随滚动的重定位会再次显示)
if (anchor.y < -parseFloat(getComputedStyle(ta).lineHeight || '22') || anchor.y > ta.clientHeight) {
this._hideFloatingToolbar();
return;
}
const bar = this._floatingToolbar;
// 实测尺寸定位(替代旧版 -80 / 36 / 200 等魔法数字)
const barRect = bar.getBoundingClientRect();
const barW = barRect.width || 170;
const barH = barRect.height || 38;
const taStyle = getComputedStyle(ta); const taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22; const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16; const paneW = this.editorPane.getBoundingClientRect().width;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18; let top = anchor.y - barH - ANCHOR_GAP;
const fontSize = parseFloat(taStyle.fontSize) || 13.5; if (top < ANCHOR_GAP)
const textBefore = ta.value.substring(0, start); top = anchor.y + lineHeight + ANCHOR_GAP;
const lines = textBefore.split('\n'); let left = anchor.x - barW / 2;
const currentLine = lines.length - 1; left = Math.max(4, Math.min(left, paneW - barW - 4));
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1; if (left < 4)
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1)); left = 4; // 极窄面板兜底
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop; bar.style.top = top + 'px';
const lineLocalTop = lineViewportTop - paneRect.top; bar.style.left = left + 'px';
const toolbarHeight = 36; bar.classList.add('me-visible');
const gap = 4;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap)
top = lineLocalTop + lineHeight + gap;
// 逐字符估算列偏移:CJK 全角字符宽约 1em,其余按 0.6em,
// 纯 ASCII 选区回退到原 0.6em 估算,中文选区不再整体偏左。
const CJK_RE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/;
const lineText = lines[lines.length - 1] || '';
const measureX = (col) => {
let w = 0;
const n = Math.max(0, Math.min(col, lineText.length));
for (let i = 0; i < n; i++)
w += CJK_RE.test(lineText[i]) ? fontSize : fontSize * 0.6;
return w;
};
const colX = taRect.left + paddingLeft + measureX(midCol) - ta.scrollLeft;
let left = colX - paneRect.left - 80;
left = Math.max(4, Math.min(left, paneRect.width - 200));
this._floatingToolbar.style.top = top + 'px';
this._floatingToolbar.style.left = left + 'px';
this._floatingToolbar.classList.add('me-visible');
}; };
const hide = () => { this._hideFloatingToolbar(); }; const hide = () => { this._hideFloatingToolbar(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0)); // 滚动跟随:rAF 节流重定位(保持纯事件驱动,不做轮询)
ta.addEventListener('keyup', () => { let rafPending = false;
const reposition = () => {
if (rafPending)
return;
rafPending = true;
requestAnimationFrame(() => { rafPending = false; show(); });
};
const onMouseUp = () => setTimeout(show, 0);
const onKeyup = () => {
// selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射 // selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
if (ta.selectionStart !== ta.selectionEnd) if (ta.selectionStart !== ta.selectionEnd)
setTimeout(show, 0); setTimeout(show, 0);
else else
setTimeout(hide, 0); setTimeout(hide, 0);
}); };
ta.addEventListener('blur', () => setTimeout(hide, 300)); const onBlur = () => setTimeout(() => {
ta.addEventListener('click', () => { // 焦点快速抖动场景(如自动化 fill 后还原焦点再 focus 回来、
// 宿主脚本焦点切换):延迟到期时焦点已回到 textarea 则不隐藏
if (document.activeElement === ta)
return;
hide();
}, 300);
const onClick = () => {
if (ta.selectionStart === ta.selectionEnd) if (ta.selectionStart === ta.selectionEnd)
hide(); hide();
};
const onScroll = reposition;
// 布局变化后旧坐标失效,直接隐藏(下次选区交互重新显示)
const onResize = () => hide();
ta.addEventListener('mouseup', onMouseUp);
ta.addEventListener('keyup', onKeyup);
ta.addEventListener('blur', onBlur);
ta.addEventListener('click', onClick);
ta.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);
// 事件绑定与启用状态解耦:floatingToolbar:false 构造的实例,
// toggleFloatingToolbar() 打开后仍可正常显示(修复“死开关”)。
this._cleanups.push(() => {
ta.removeEventListener('mouseup', onMouseUp);
ta.removeEventListener('keyup', onKeyup);
ta.removeEventListener('blur', onBlur);
ta.removeEventListener('click', onClick);
ta.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
}); });
} }
/**
* 镜像测量选区锚点坐标相对 editorPane 的局部坐标
* 锚点 = 选区文档序中点单行选区即视觉中部跨行选区约在选区垂直中心
*/
function measureSelectionAnchor() {
const ta = this.textarea;
if (!ta || !this.editorPane)
return null;
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (start === end)
return null;
const anchor = Math.floor((start + end) / 2);
// 懒创建镜像层并复制 textarea 排版样式(font/行高/padding/换行/tab
let mirror = this._floatMirror;
if (!mirror || !mirror.parentNode) {
mirror = document.createElement('div');
mirror.className = 'me-float-mirror';
this.editorPane.appendChild(mirror);
this._floatMirror = mirror;
}
const cs = getComputedStyle(ta);
const props = [
['fontFamily', cs.fontFamily], ['fontSize', cs.fontSize], ['fontWeight', cs.fontWeight],
['fontStyle', cs.fontStyle], ['letterSpacing', cs.letterSpacing], ['lineHeight', cs.lineHeight],
['paddingTop', cs.paddingTop], ['paddingRight', cs.paddingRight],
['paddingBottom', cs.paddingBottom], ['paddingLeft', cs.paddingLeft],
['whiteSpace', cs.whiteSpace], ['overflowWrap', cs.overflowWrap],
['wordBreak', cs.wordBreak], ['tabSize', cs.tabSize], ['boxSizing', 'border-box'],
];
props.forEach(([k, v]) => { mirror.style[k] = v; });
// 宽度对齐 textarea 文本排版区(clientWidth 含 padding 不含滚动条)
mirror.style.width = ta.clientWidth + 'px';
// 仅需锚点前缀文本:其后内容不影响锚点位置(折行由前缀决定)
mirror.textContent = '';
mirror.appendChild(document.createTextNode(ta.value.slice(0, anchor)));
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
// marker 坐标相对 mirrorposition:absolute 的 offsetParentborder 0),
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
const x = marker.offsetLeft - ta.scrollLeft;
const y = marker.offsetTop - ta.scrollTop;
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
}
function toggleFloatingToolbar() { function toggleFloatingToolbar() {
this._floatingEnabled = !this._floatingEnabled; this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) { if (!this._floatingEnabled) {
@@ -3077,15 +3171,23 @@ function isFloatingToolbar() { return this._floatingEnabled; }
function buildFloatingToolbar() { function buildFloatingToolbar() {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.className = 'me-float-toolbar'; bar.className = 'me-float-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', this.t('formatToolbar') || '格式化选区');
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough']; const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => { actions.forEach((action) => {
const btn = this._createBtn(action); const btn = this._createBtn(action);
// mousedown 阻止夺焦(textarea 选区与焦点保持);命令执行统一走 click,
// 键盘 Enter/Space 触发的 click 同样生效(可访问性)
btn.addEventListener('mousedown', (e) => { btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
});
btn.addEventListener('click', () => {
this.exec(action); this.exec(action);
// Keep selection after exec this._hideFloatingToolbar();
setTimeout(() => this.textarea.focus(), 0); // Keep selection after exec(延迟回调时实例可能已销毁)
setTimeout(() => { if (!this._destroyed && this.textarea)
this.textarea.focus(); }, 0);
}); });
bar.appendChild(btn); bar.appendChild(btn);
}); });
@@ -3104,6 +3206,7 @@ const installFloatingToolbar = (proto) => {
proto.isFloatingToolbar = isFloatingToolbar; proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar; proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar; proto._hideFloatingToolbar = hideFloatingToolbar;
proto._measureSelectionAnchor = measureSelectionAnchor;
}; };
/** /**
@@ -3168,7 +3271,8 @@ function showContextMenu(e) {
el.className = 'me-context-menu-item'; el.className = 'me-context-menu-item';
if (item.disabled) if (item.disabled)
el.classList.add('me-disabled'); el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`; // 自定义菜单项的 label / shortcut 经转义后拼接(防 HTML 注入)
el.innerHTML = `<span>${escapeHTML(item.label)}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${escapeHTML(item.shortcut)}</span>` : ''}`;
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
if (item.disabled) if (item.disabled)
@@ -3214,9 +3318,13 @@ function execContextAction(action) {
case 'copy': case 'copy':
document.execCommand('copy'); document.execCommand('copy');
break; break;
case 'paste': case 'paste': {
document.execCommand('paste'); // 现代浏览器出于安全限制,execCommand('paste') 通常静默失败 —— 提示改用 Ctrl+V
const ok = document.execCommand('paste');
if (!ok)
this.toast(this.t('pasteBlocked') || '无法直接粘贴,请使用 Ctrl+V', { type: 'info' });
break; break;
}
case 'selectAll': case 'selectAll':
ta.focus(); ta.focus();
ta.select(); ta.select();
@@ -3471,6 +3579,7 @@ class MarkdownEditor {
this._zenMouseHandler = null; this._zenMouseHandler = null;
this._floatingToolbar = null; this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false; this._floatingEnabled = this.config.floatingToolbar !== false;
this._floatMirror = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown; this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null; this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
MarkdownEditor.trigger('beforeCreate', this); MarkdownEditor.trigger('beforeCreate', this);
@@ -3492,8 +3601,9 @@ class MarkdownEditor {
this._bindEvents(); this._bindEvents();
this._bindContextMenu(); this._bindContextMenu();
this._trackOutlineScroll(); this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) // 无条件绑定事件(show 内部检查 _floatingEnabled):
this._initFloatingToolbar(); // floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
this._initFloatingToolbar();
this.textarea.value = this._value; this.textarea.value = this._value;
if (!this._wordWrap) if (!this._wordWrap)
this.textarea.style.whiteSpace = 'pre'; this.textarea.style.whiteSpace = 'pre';
@@ -3773,6 +3883,12 @@ class MarkdownEditor {
this.toolbarEl.addEventListener('click', onToolbarClick); this.toolbarEl.addEventListener('click', onToolbarClick);
const onDividerDown = (e) => this._bindDividerDrag(e); const onDividerDown = (e) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown); this.dividerEl.addEventListener('pointerdown', onDividerDown);
// 全屏模式按 Esc 退出(与 shortcutHelp 面板的 Esc 关闭互不冲突:条件互斥)
const onDocKeydown = (e) => {
if (e.key === 'Escape' && this._fullscreen)
this.exitFullscreen();
};
document.addEventListener('keydown', onDocKeydown);
this._bindDragDrop(); this._bindDragDrop();
this._cleanups.push(() => { this._cleanups.push(() => {
ta.removeEventListener('input', onInput); ta.removeEventListener('input', onInput);
@@ -3786,6 +3902,7 @@ class MarkdownEditor {
ta.removeEventListener('blur', onBlur); ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick); this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown); this.dividerEl.removeEventListener('pointerdown', onDividerDown);
document.removeEventListener('keydown', onDocKeydown);
}); });
} }
_handleKeydown(e) { _handleKeydown(e) {
@@ -3870,10 +3987,10 @@ class MarkdownEditor {
ta.selectionStart = lineStart; ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + newBlock.length; ta.selectionEnd = lineStart + newBlock.length;
} }
const old = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(old);
this._emit('change', this._value);
} }
_bindDividerDrag(e) { _bindDividerDrag(e) {
if (this._mode !== 'split') if (this._mode !== 'split')
@@ -3899,6 +4016,8 @@ class MarkdownEditor {
window.removeEventListener('pointermove', onMove); window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp); window.removeEventListener('pointerup', onUp);
this._saveDividerPosition(); this._saveDividerPosition();
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
this._hideFloatingToolbar();
}; };
window.addEventListener('pointermove', onMove); window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp); window.addEventListener('pointerup', onUp);
@@ -4014,12 +4133,12 @@ class MarkdownEditor {
const content = listMatch[3]; const content = listMatch[3];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
let nextMarker = marker; let nextMarker = marker;
@@ -4032,10 +4151,10 @@ class MarkdownEditor {
const insert = '\n' + indent + nextMarker + ' '; const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/); const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
@@ -4044,22 +4163,22 @@ class MarkdownEditor {
const content = quoteMatch[2]; const content = quoteMatch[2];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
e.preventDefault(); e.preventDefault();
const insert = '\n' + prefix; const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
} }
_handleBracketAutoClose(e) { _handleBracketAutoClose(e) {
@@ -4074,6 +4193,7 @@ class MarkdownEditor {
const end = ta.selectionEnd; const end = ta.selectionEnd;
if (start !== end) { if (start !== end) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const selected = ta.value.slice(start, end); const selected = ta.value.slice(start, end);
const wrap = key + selected + close; const wrap = key + selected + close;
ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end);
@@ -4081,7 +4201,7 @@ class MarkdownEditor {
ta.selectionEnd = start + 1 + selected.length; ta.selectionEnd = start + 1 + selected.length;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
return; return;
} }
const nextChar = ta.value[start] || ''; const nextChar = ta.value[start] || '';
@@ -4095,12 +4215,13 @@ class MarkdownEditor {
return; return;
} }
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const pair = key + close; const pair = key + close;
ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 1; ta.selectionStart = ta.selectionEnd = start + 1;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
} }
_bindDragDrop() { _bindDragDrop() {
const ta = this.textarea; const ta = this.textarea;
@@ -4142,6 +4263,39 @@ class MarkdownEditor {
} }
_scheduleHistory() { if (this._historyTimer) _scheduleHistory() { if (this._historyTimer)
clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
/**
* 程序化编辑路径命令 / 快捷键 / 插件 / API 直改 textarea的统一刷新管线
* 此前 8 条路径的 预览/行号/字数/大纲 刷新与事件发射参差不齐
* 现统一为渲染 + 行号 + 字数 + 大纲 + input/change 事件 + 回调 + before/afterChange 钩子
*/
_afterProgrammaticEdit(oldValue) {
MarkdownEditor.trigger('beforeChange', this);
this._emit('beforeChange', oldValue, this._value);
this._render();
this._renderGutter();
this._updateWordCount();
this._updateOutline();
this._emit('input', this._value);
this._emit('change', this._value);
if (typeof this.config.onInput === 'function') {
try {
this.config.onInput(this._value, this);
}
catch (e) {
console.error(e);
}
}
if (typeof this.config.onChange === 'function') {
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
}
this._emit('afterChange', this._value);
MarkdownEditor.trigger('afterChange', this);
}
_pushHistory() { _pushHistory() {
// Enforce maxLength as a last-resort guard for programmatic edit paths // Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write // (tab/indent, smart-enter, bracket auto-close, exec commands) that write
@@ -4172,11 +4326,10 @@ class MarkdownEditor {
const pv = this.previewPane; const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0; const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null; const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
const oldV = this._value;
this._value = this._history[this._historyIndex]; this._value = this._history[this._historyIndex];
this.textarea.value = this._value; this.textarea.value = this._value;
this._render(); this._afterProgrammaticEdit(oldV);
this._renderGutter();
this._updateWordCount();
if (this.gutter) if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop; this.gutter.scrollTop = this.textarea.scrollTop;
if (pv && pvRatio != null) { if (pv && pvRatio != null) {
@@ -4184,15 +4337,6 @@ class MarkdownEditor {
if (max > 0) if (max > 0)
pv.scrollTop = pvRatio * max; pv.scrollTop = pvRatio * max;
} }
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
}
} }
_captureCursor() { _captureCursor() {
if (!this.textarea) if (!this.textarea)
@@ -4558,12 +4702,11 @@ class MarkdownEditor {
if (!matches) if (!matches)
return 0; return 0;
// 函数式替换:替换文本按字面处理,$ 无特殊语义 // 函数式替换:替换文本按字面处理,$ 无特殊语义
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, () => replace)); this._value = this._limitLength(this._value.replace(re, () => replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
replaceAllRegex(pattern, replace) { replaceAllRegex(pattern, replace) {
@@ -4573,12 +4716,11 @@ class MarkdownEditor {
const matches = this._value.match(re); const matches = this._value.match(re);
if (!matches) if (!matches)
return 0; return 0;
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, replace)); this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
lineCount() { lineCount() {
@@ -4646,9 +4788,17 @@ class MarkdownEditor {
copyAsHTML() { copyAsHTML() {
const html = this.getHTML(); const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) { if (typeof navigator !== 'undefined' && navigator.clipboard) {
const blob = new Blob([html], { type: 'text/html' }); // ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) }); if (typeof ClipboardItem !== 'undefined') {
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { }); try {
const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
return this;
}
catch (_) { /* fallthrough 降级 */ }
}
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
} }
return this; return this;
} }
@@ -4660,11 +4810,10 @@ class MarkdownEditor {
ta.value = inserted; ta.value = inserted;
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length); ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return this; return this;
} }
wrap(before, after) { this._wrapSelection(before, after || before); return this; } wrap(before, after) { this._wrapSelection(before, after || before); return this; }
@@ -4850,6 +4999,9 @@ class MarkdownEditor {
if (this._floatingToolbar && this._floatingToolbar.parentNode) if (this._floatingToolbar && this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null; this._floatingToolbar = null;
if (this._floatMirror && this._floatMirror.parentNode)
this._floatMirror.parentNode.removeChild(this._floatMirror);
this._floatMirror = null;
if (this.el) if (this.el)
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove()); this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { this._plugins.forEach((p) => { if (typeof p.destroy === 'function') {
@@ -5139,9 +5291,9 @@ const getSupportedLanguages = () => Object.keys(LANGUAGES);
/** /**
* MetonaEditor Type-safe, lightweight Markdown Editor * MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor * @module metona-editor
* @version 0.4.2 * @version 0.4.3
*/ */
const VERSION = '0.4.2'; const VERSION = '0.4.3';
const globalPlugins = []; const globalPlugins = [];
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件 // 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。 // searchReplace / shortcutHelp / imagePaste)才能正常生效。
+1 -1
View File
File diff suppressed because one or more lines are too long