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
+251
-99
@@ -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,7 +2160,12 @@ 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._afterProgrammaticEdit === 'function') {
|
||||||
|
editor._afterProgrammaticEdit(oldV);
|
||||||
|
}
|
||||||
|
else {
|
||||||
if (typeof editor._pushHistory === 'function')
|
if (typeof editor._pushHistory === 'function')
|
||||||
editor._pushHistory();
|
editor._pushHistory();
|
||||||
if (typeof editor._render === 'function')
|
if (typeof editor._render === 'function')
|
||||||
@@ -2163,6 +2174,7 @@ const searchReplacePlugin = {
|
|||||||
editor._updateWordCount();
|
editor._updateWordCount();
|
||||||
if (typeof editor._emit === 'function')
|
if (typeof editor._emit === 'function')
|
||||||
editor._emit('change', editor._value);
|
editor._emit('change', editor._value);
|
||||||
|
}
|
||||||
findNext();
|
findNext();
|
||||||
};
|
};
|
||||||
const replaceAll = () => {
|
const replaceAll = () => {
|
||||||
@@ -2173,9 +2185,14 @@ 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._afterProgrammaticEdit === 'function') {
|
||||||
|
editor._afterProgrammaticEdit(oldV);
|
||||||
|
}
|
||||||
|
else {
|
||||||
if (typeof editor._pushHistory === 'function')
|
if (typeof editor._pushHistory === 'function')
|
||||||
editor._pushHistory();
|
editor._pushHistory();
|
||||||
if (typeof editor._render === 'function')
|
if (typeof editor._render === 'function')
|
||||||
@@ -2184,6 +2201,7 @@ const searchReplacePlugin = {
|
|||||||
editor._updateWordCount();
|
editor._updateWordCount();
|
||||||
if (typeof editor._emit === 'function')
|
if (typeof editor._emit === 'function')
|
||||||
editor._emit('change', editor._value);
|
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 坐标相对 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() {
|
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,7 +3605,8 @@ class MarkdownEditor {
|
|||||||
this._bindEvents();
|
this._bindEvents();
|
||||||
this._bindContextMenu();
|
this._bindContextMenu();
|
||||||
this._trackOutlineScroll();
|
this._trackOutlineScroll();
|
||||||
if (this.config.floatingToolbar !== false)
|
// 无条件绑定事件(show 内部检查 _floatingEnabled):
|
||||||
|
// floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
|
||||||
this._initFloatingToolbar();
|
this._initFloatingToolbar();
|
||||||
this.textarea.value = this._value;
|
this.textarea.value = this._value;
|
||||||
if (!this._wordWrap)
|
if (!this._wordWrap)
|
||||||
@@ -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) {
|
||||||
|
// ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
|
||||||
|
if (typeof ClipboardItem !== 'undefined') {
|
||||||
|
try {
|
||||||
const blob = new Blob([html], { type: 'text/html' });
|
const blob = new Blob([html], { type: 'text/html' });
|
||||||
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
|
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(() => { });
|
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)才能正常生效。
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+14
-1
@@ -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;
|
||||||
|
|||||||
Vendored
+251
-99
@@ -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,7 +2162,12 @@ 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._afterProgrammaticEdit === 'function') {
|
||||||
|
editor._afterProgrammaticEdit(oldV);
|
||||||
|
}
|
||||||
|
else {
|
||||||
if (typeof editor._pushHistory === 'function')
|
if (typeof editor._pushHistory === 'function')
|
||||||
editor._pushHistory();
|
editor._pushHistory();
|
||||||
if (typeof editor._render === 'function')
|
if (typeof editor._render === 'function')
|
||||||
@@ -2165,6 +2176,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
|
|||||||
editor._updateWordCount();
|
editor._updateWordCount();
|
||||||
if (typeof editor._emit === 'function')
|
if (typeof editor._emit === 'function')
|
||||||
editor._emit('change', editor._value);
|
editor._emit('change', editor._value);
|
||||||
|
}
|
||||||
findNext();
|
findNext();
|
||||||
};
|
};
|
||||||
const replaceAll = () => {
|
const replaceAll = () => {
|
||||||
@@ -2175,9 +2187,14 @@ 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._afterProgrammaticEdit === 'function') {
|
||||||
|
editor._afterProgrammaticEdit(oldV);
|
||||||
|
}
|
||||||
|
else {
|
||||||
if (typeof editor._pushHistory === 'function')
|
if (typeof editor._pushHistory === 'function')
|
||||||
editor._pushHistory();
|
editor._pushHistory();
|
||||||
if (typeof editor._render === 'function')
|
if (typeof editor._render === 'function')
|
||||||
@@ -2186,6 +2203,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
|
|||||||
editor._updateWordCount();
|
editor._updateWordCount();
|
||||||
if (typeof editor._emit === 'function')
|
if (typeof editor._emit === 'function')
|
||||||
editor._emit('change', editor._value);
|
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 坐标相对 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() {
|
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,7 +3607,8 @@ 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):
|
||||||
|
// floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
|
||||||
this._initFloatingToolbar();
|
this._initFloatingToolbar();
|
||||||
this.textarea.value = this._value;
|
this.textarea.value = this._value;
|
||||||
if (!this._wordWrap)
|
if (!this._wordWrap)
|
||||||
@@ -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) {
|
||||||
|
// ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
|
||||||
|
if (typeof ClipboardItem !== 'undefined') {
|
||||||
|
try {
|
||||||
const blob = new Blob([html], { type: 'text/html' });
|
const blob = new Blob([html], { type: 'text/html' });
|
||||||
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
|
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(() => { });
|
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)才能正常生效。
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+251
-99
@@ -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,7 +2156,12 @@ 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._afterProgrammaticEdit === 'function') {
|
||||||
|
editor._afterProgrammaticEdit(oldV);
|
||||||
|
}
|
||||||
|
else {
|
||||||
if (typeof editor._pushHistory === 'function')
|
if (typeof editor._pushHistory === 'function')
|
||||||
editor._pushHistory();
|
editor._pushHistory();
|
||||||
if (typeof editor._render === 'function')
|
if (typeof editor._render === 'function')
|
||||||
@@ -2159,6 +2170,7 @@ const searchReplacePlugin = {
|
|||||||
editor._updateWordCount();
|
editor._updateWordCount();
|
||||||
if (typeof editor._emit === 'function')
|
if (typeof editor._emit === 'function')
|
||||||
editor._emit('change', editor._value);
|
editor._emit('change', editor._value);
|
||||||
|
}
|
||||||
findNext();
|
findNext();
|
||||||
};
|
};
|
||||||
const replaceAll = () => {
|
const replaceAll = () => {
|
||||||
@@ -2169,9 +2181,14 @@ 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._afterProgrammaticEdit === 'function') {
|
||||||
|
editor._afterProgrammaticEdit(oldV);
|
||||||
|
}
|
||||||
|
else {
|
||||||
if (typeof editor._pushHistory === 'function')
|
if (typeof editor._pushHistory === 'function')
|
||||||
editor._pushHistory();
|
editor._pushHistory();
|
||||||
if (typeof editor._render === 'function')
|
if (typeof editor._render === 'function')
|
||||||
@@ -2180,6 +2197,7 @@ const searchReplacePlugin = {
|
|||||||
editor._updateWordCount();
|
editor._updateWordCount();
|
||||||
if (typeof editor._emit === 'function')
|
if (typeof editor._emit === 'function')
|
||||||
editor._emit('change', editor._value);
|
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 坐标相对 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() {
|
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,7 +3601,8 @@ class MarkdownEditor {
|
|||||||
this._bindEvents();
|
this._bindEvents();
|
||||||
this._bindContextMenu();
|
this._bindContextMenu();
|
||||||
this._trackOutlineScroll();
|
this._trackOutlineScroll();
|
||||||
if (this.config.floatingToolbar !== false)
|
// 无条件绑定事件(show 内部检查 _floatingEnabled):
|
||||||
|
// floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
|
||||||
this._initFloatingToolbar();
|
this._initFloatingToolbar();
|
||||||
this.textarea.value = this._value;
|
this.textarea.value = this._value;
|
||||||
if (!this._wordWrap)
|
if (!this._wordWrap)
|
||||||
@@ -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) {
|
||||||
|
// ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
|
||||||
|
if (typeof ClipboardItem !== 'undefined') {
|
||||||
|
try {
|
||||||
const blob = new Blob([html], { type: 'text/html' });
|
const blob = new Blob([html], { type: 'text/html' });
|
||||||
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
|
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(() => { });
|
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)才能正常生效。
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user