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: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB',
formatToolbar: '格式化选区', pasteBlocked: '无法直接粘贴,请使用 Ctrl+V',
},
'en-US': {
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
@@ -136,6 +137,7 @@ const LOCALES = {
outline: 'Outline',
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)',
formatToolbar: 'Format selection', pasteBlocked: 'Cannot paste directly, use Ctrl+V',
},
ja: {
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
@@ -163,6 +165,7 @@ const LOCALES = {
outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
},
ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -190,6 +193,7 @@ const LOCALES = {
outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
formatToolbar: '선택 영역 서식', pasteBlocked: '직접 붙여넣을 수 없습니다. Ctrl+V를 사용하세요',
},
fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -217,6 +221,7 @@ const LOCALES = {
outline: 'Plan',
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
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: {
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
@@ -244,6 +249,7 @@ const LOCALES = {
outline: 'Gliederung',
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
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.setSelectionRange(s, s + r.length);
}
const oldV = editor._value || '';
editor._value = ta.value;
if (typeof editor._pushHistory === 'function')
editor._pushHistory();
if (typeof editor._render === 'function')
editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
}
else {
if (typeof editor._pushHistory === 'function')
editor._pushHistory();
if (typeof editor._render === 'function')
editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
}
findNext();
};
const replaceAll = () => {
@@ -2173,17 +2185,23 @@ const searchReplacePlugin = {
const re = buildPattern(q);
if (!re)
return;
const oldV = editor._value || '';
ta.value = ta.value.replace(re, () => r);
ta.setSelectionRange(0, 0);
editor._value = ta.value;
if (typeof editor._pushHistory === 'function')
editor._pushHistory();
if (typeof editor._render === 'function')
editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
}
else {
if (typeof editor._pushHistory === 'function')
editor._pushHistory();
if (typeof editor._render === 'function')
editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
}
findAll();
};
fi.addEventListener('input', () => { lastIdxs = findAll(); });
@@ -2849,11 +2867,10 @@ function wrapSelection(before, after) {
else {
ta.selectionStart = ta.selectionEnd = start + before.length;
}
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
}
// ============ Line prefix toggling ============
function toggleLinePrefix(prefix) {
@@ -2874,11 +2891,10 @@ function toggleLinePrefix(prefix) {
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
}
// ============ Block insertion ============
function insertBlock(text) {
@@ -2891,11 +2907,10 @@ function insertBlock(text) {
ta.focus();
const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
}
function insertLink() {
const ta = this.textarea;
@@ -2908,10 +2923,10 @@ function insertLink() {
ta.focus();
ta.selectionStart = start + sel.length + 3;
ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
}
function insertImage() {
const ta = this.textarea;
@@ -2924,10 +2939,10 @@ function insertImage() {
ta.focus();
ta.selectionStart = start + sel.length + 4;
ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
}
function insertTable(rows = 3, cols = 3) {
const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | ');
@@ -2969,10 +2984,10 @@ function formatTable() {
return '| ' + padded.join(' | ') + ' |';
});
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
}
// ============ Prototype installation ============
const installCommands = (proto) => {
@@ -2988,8 +3003,17 @@ const installCommands = (proto) => {
/**
* MetonaEditor Floating Toolbar — selection format 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() {
if (typeof document === 'undefined')
return;
@@ -2997,11 +3021,9 @@ function initFloatingToolbar() {
if (!document.getElementById('me-float-style')) {
const style = document.createElement('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);
}
if (!this._floatingEnabled)
return;
const ta = this.textarea;
const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled)
@@ -3014,58 +3036,130 @@ function initFloatingToolbar() {
}
if (!this._floatingToolbar || !this._floatingToolbar.parentNode)
this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
const anchor = this._measureSelectionAnchor();
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 lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
const fontSize = parseFloat(taStyle.fontSize) || 13.5;
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1;
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1));
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop;
const lineLocalTop = lineViewportTop - paneRect.top;
const toolbarHeight = 36;
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 paneW = this.editorPane.getBoundingClientRect().width;
let top = anchor.y - barH - ANCHOR_GAP;
if (top < ANCHOR_GAP)
top = anchor.y + lineHeight + ANCHOR_GAP;
let left = anchor.x - barW / 2;
left = Math.max(4, Math.min(left, paneW - barW - 4));
if (left < 4)
left = 4; // 极窄面板兜底
bar.style.top = top + 'px';
bar.style.left = left + 'px';
bar.classList.add('me-visible');
};
const hide = () => { this._hideFloatingToolbar(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0));
ta.addEventListener('keyup', () => {
// 滚动跟随:rAF 节流重定位(保持纯事件驱动,不做轮询)
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 统一发射
if (ta.selectionStart !== ta.selectionEnd)
setTimeout(show, 0);
else
setTimeout(hide, 0);
});
ta.addEventListener('blur', () => setTimeout(hide, 300));
ta.addEventListener('click', () => {
};
const onBlur = () => setTimeout(() => {
// 焦点快速抖动场景(如自动化 fill 后还原焦点再 focus 回来、
// 宿主脚本焦点切换):延迟到期时焦点已回到 textarea 则不隐藏
if (document.activeElement === ta)
return;
hide();
}, 300);
const onClick = () => {
if (ta.selectionStart === ta.selectionEnd)
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() {
this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) {
@@ -3081,15 +3175,23 @@ function isFloatingToolbar() { return this._floatingEnabled; }
function buildFloatingToolbar() {
const bar = document.createElement('div');
bar.className = 'me-float-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', this.t('formatToolbar') || '格式化选区');
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => {
const btn = this._createBtn(action);
// mousedown 阻止夺焦(textarea 选区与焦点保持);命令执行统一走 click,
// 键盘 Enter/Space 触发的 click 同样生效(可访问性)
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
});
btn.addEventListener('click', () => {
this.exec(action);
// Keep selection after exec
setTimeout(() => this.textarea.focus(), 0);
this._hideFloatingToolbar();
// Keep selection after exec(延迟回调时实例可能已销毁)
setTimeout(() => { if (!this._destroyed && this.textarea)
this.textarea.focus(); }, 0);
});
bar.appendChild(btn);
});
@@ -3108,6 +3210,7 @@ const installFloatingToolbar = (proto) => {
proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar;
proto._measureSelectionAnchor = measureSelectionAnchor;
};
/**
@@ -3172,7 +3275,8 @@ function showContextMenu(e) {
el.className = 'me-context-menu-item';
if (item.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) => {
ev.stopPropagation();
if (item.disabled)
@@ -3218,9 +3322,13 @@ function execContextAction(action) {
case 'copy':
document.execCommand('copy');
break;
case 'paste':
document.execCommand('paste');
case 'paste': {
// 现代浏览器出于安全限制,execCommand('paste') 通常静默失败 —— 提示改用 Ctrl+V
const ok = document.execCommand('paste');
if (!ok)
this.toast(this.t('pasteBlocked') || '无法直接粘贴,请使用 Ctrl+V', { type: 'info' });
break;
}
case 'selectAll':
ta.focus();
ta.select();
@@ -3475,6 +3583,7 @@ class MarkdownEditor {
this._zenMouseHandler = null;
this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false;
this._floatMirror = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
MarkdownEditor.trigger('beforeCreate', this);
@@ -3496,8 +3605,9 @@ class MarkdownEditor {
this._bindEvents();
this._bindContextMenu();
this._trackOutlineScroll();
if (this.config.floatingToolbar !== false)
this._initFloatingToolbar();
// 无条件绑定事件(show 内部检查 _floatingEnabled):
// floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
this._initFloatingToolbar();
this.textarea.value = this._value;
if (!this._wordWrap)
this.textarea.style.whiteSpace = 'pre';
@@ -3777,6 +3887,12 @@ class MarkdownEditor {
this.toolbarEl.addEventListener('click', onToolbarClick);
const onDividerDown = (e) => this._bindDividerDrag(e);
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._cleanups.push(() => {
ta.removeEventListener('input', onInput);
@@ -3790,6 +3906,7 @@ class MarkdownEditor {
ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown);
document.removeEventListener('keydown', onDocKeydown);
});
}
_handleKeydown(e) {
@@ -3874,10 +3991,10 @@ class MarkdownEditor {
ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + newBlock.length;
}
const old = this._value;
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
this._afterProgrammaticEdit(old);
}
_bindDividerDrag(e) {
if (this._mode !== 'split')
@@ -3903,6 +4020,8 @@ class MarkdownEditor {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
this._saveDividerPosition();
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
this._hideFloatingToolbar();
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
@@ -4018,12 +4137,12 @@ class MarkdownEditor {
const content = listMatch[3];
if (!content.trim()) {
e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
return;
}
let nextMarker = marker;
@@ -4036,10 +4155,10 @@ class MarkdownEditor {
const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
return;
}
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
@@ -4048,22 +4167,22 @@ class MarkdownEditor {
const content = quoteMatch[2];
if (!content.trim()) {
e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
return;
}
e.preventDefault();
const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
}
}
_handleBracketAutoClose(e) {
@@ -4078,6 +4197,7 @@ class MarkdownEditor {
const end = ta.selectionEnd;
if (start !== end) {
e.preventDefault();
const oldV = this._value;
const selected = ta.value.slice(start, end);
const wrap = key + selected + close;
ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end);
@@ -4085,7 +4205,7 @@ class MarkdownEditor {
ta.selectionEnd = start + 1 + selected.length;
this._value = ta.value;
this._pushHistory();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
return;
}
const nextChar = ta.value[start] || '';
@@ -4099,12 +4219,13 @@ class MarkdownEditor {
return;
}
e.preventDefault();
const oldV = this._value;
const pair = key + close;
ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 1;
this._value = ta.value;
this._pushHistory();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
}
_bindDragDrop() {
const ta = this.textarea;
@@ -4146,6 +4267,39 @@ class MarkdownEditor {
}
_scheduleHistory() { if (this._historyTimer)
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() {
// Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write
@@ -4176,11 +4330,10 @@ class MarkdownEditor {
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
const oldV = this._value;
this._value = this._history[this._historyIndex];
this.textarea.value = this._value;
this._render();
this._renderGutter();
this._updateWordCount();
this._afterProgrammaticEdit(oldV);
if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop;
if (pv && pvRatio != null) {
@@ -4188,15 +4341,6 @@ class MarkdownEditor {
if (max > 0)
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() {
if (!this.textarea)
@@ -4562,12 +4706,11 @@ class MarkdownEditor {
if (!matches)
return 0;
// 函数式替换:替换文本按字面处理,$ 无特殊语义
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, () => replace));
this.textarea.value = this._value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
return matches.length;
}
replaceAllRegex(pattern, replace) {
@@ -4577,12 +4720,11 @@ class MarkdownEditor {
const matches = this._value.match(re);
if (!matches)
return 0;
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
return matches.length;
}
lineCount() {
@@ -4650,9 +4792,17 @@ class MarkdownEditor {
copyAsHTML() {
const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) {
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(() => { });
// ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
if (typeof ClipboardItem !== 'undefined') {
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;
}
@@ -4664,11 +4814,10 @@ class MarkdownEditor {
ta.value = inserted;
ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
this._afterProgrammaticEdit(oldV);
return this;
}
wrap(before, after) { this._wrapSelection(before, after || before); return this; }
@@ -4854,6 +5003,9 @@ class MarkdownEditor {
if (this._floatingToolbar && this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
if (this._floatMirror && this._floatMirror.parentNode)
this._floatMirror.parentNode.removeChild(this._floatMirror);
this._floatMirror = null;
if (this.el)
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') {
@@ -5143,9 +5295,9 @@ const getSupportedLanguages = () => Object.keys(LANGUAGES);
/**
* MetonaEditor — Type-safe, lightweight Markdown Editor
* @module metona-editor
* @version 0.4.2
* @version 0.4.3
*/
const VERSION = '0.4.2';
const VERSION = '0.4.3';
const globalPlugins = [];
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。