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
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:
Vendored
+271
-119
@@ -111,6 +111,7 @@
|
||||
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',
|
||||
@@ -138,6 +139,7 @@
|
||||
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: '打ち消し線',
|
||||
@@ -165,6 +167,7 @@
|
||||
outline: 'アウトライン',
|
||||
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
|
||||
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB)',
|
||||
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
|
||||
},
|
||||
ko: {
|
||||
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
|
||||
@@ -192,6 +195,7 @@
|
||||
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é',
|
||||
@@ -219,6 +223,7 @@
|
||||
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',
|
||||
@@ -246,6 +251,7 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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.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 = () => {
|
||||
@@ -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);
|
||||
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(); });
|
||||
@@ -2851,11 +2869,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
|
||||
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) {
|
||||
@@ -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.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) {
|
||||
@@ -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();
|
||||
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;
|
||||
@@ -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.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;
|
||||
@@ -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.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(' | ');
|
||||
@@ -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(' | ') + ' |';
|
||||
});
|
||||
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) => {
|
||||
@@ -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
|
||||
* @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;
|
||||
@@ -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')) {
|
||||
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)
|
||||
@@ -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)
|
||||
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 坐标相对 mirror(position:absolute 的 offsetParent,border 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) {
|
||||
@@ -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() {
|
||||
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);
|
||||
});
|
||||
@@ -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._buildFloatingToolbar = buildFloatingToolbar;
|
||||
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';
|
||||
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)
|
||||
@@ -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':
|
||||
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();
|
||||
@@ -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._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);
|
||||
@@ -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._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';
|
||||
@@ -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);
|
||||
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);
|
||||
@@ -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);
|
||||
this.toolbarEl.removeEventListener('click', onToolbarClick);
|
||||
this.dividerEl.removeEventListener('pointerdown', onDividerDown);
|
||||
document.removeEventListener('keydown', onDocKeydown);
|
||||
});
|
||||
}
|
||||
_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.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')
|
||||
@@ -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('pointerup', onUp);
|
||||
this._saveDividerPosition();
|
||||
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
|
||||
this._hideFloatingToolbar();
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
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];
|
||||
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;
|
||||
@@ -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 + ' ';
|
||||
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?)(.*)/);
|
||||
@@ -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];
|
||||
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) {
|
||||
@@ -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;
|
||||
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);
|
||||
@@ -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;
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._emit('change', this._value);
|
||||
this._afterProgrammaticEdit(oldV);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
@@ -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)
|
||||
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
|
||||
@@ -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 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) {
|
||||
@@ -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)
|
||||
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)
|
||||
@@ -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)
|
||||
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) {
|
||||
@@ -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);
|
||||
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() {
|
||||
@@ -4652,9 +4794,17 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
|
||||
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;
|
||||
}
|
||||
@@ -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.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; }
|
||||
@@ -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)
|
||||
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') {
|
||||
@@ -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
|
||||
* @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)才能正常生效。
|
||||
|
||||
Reference in New Issue
Block a user