build: 提交 dist 构建产物(v0.4.2 完整 5 格式 + sourcemap + 类型声明)
CI / test-parser (push) Successful in 9m47s
CI / test-core (push) Successful in 12m44s
CI / test-rest (push) Failing after 31s
CI / e2e (push) Successful in 9m45s
CI / verify (18.x) (push) Successful in 9m54s
CI / verify (20.x) (push) Failing after 11s
CI / verify (24.x) (push) Successful in 10m11s

This commit is contained in:
2026-08-19 22:30:59 +08:00
parent 5f4915222e
commit ac6638247f
8 changed files with 432 additions and 116 deletions
+141 -37
View File
@@ -189,7 +189,7 @@ const LOCALES = {
renderError: '렌더링 실패',
outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다{size}KB > {max}KB',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
},
fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -778,6 +778,18 @@ const createInstanceI18n = (editor) => {
}
});
}
// 语言切换即时刷新已渲染的 UI(状态栏统计标签、大纲标题),无需等待下一次输入
if (typeof editor._updateWordCount === 'function') {
try {
editor._updateWordCount();
}
catch (_) { }
}
if (editor.el && typeof editor.el.querySelector === 'function') {
const outlineTitle = editor.el.querySelector('.me-outline-title');
if (outlineTitle)
outlineTitle.textContent = instanceT('outline') || 'Outline';
}
return instanceLocale;
};
return {
@@ -1771,6 +1783,9 @@ const validateConfig = (schema = {}, config = {}) => {
};
// ============ Preset plugins ============
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** 实例级翻译:优先用 editor.t(实例 locale),否则退回全局 t。
* 插件面板文案跟随实例语言而非全局语言。 */
const instanceT = (editor) => (key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
const autoSavePlugin = {
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
install(editor, options) {
@@ -1947,6 +1962,7 @@ const searchReplacePlugin = {
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const tt = instanceT(editor);
// Inject style once globally
if (!document.getElementById('me-search-style')) {
const style = document.createElement('style');
@@ -2013,7 +2029,7 @@ const searchReplacePlugin = {
const panel = document.createElement('div');
panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${t('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${t('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll') || 'All'}</button></div>`;
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${tt('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${tt('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${tt('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${tt('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${tt('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${tt('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${tt('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${tt('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${tt('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${tt('replaceAll') || 'All'}</button></div>`;
editor.el.appendChild(panel);
state._panel = panel;
_updateReplaceVisible();
@@ -2239,6 +2255,7 @@ const imagePastePlugin = {
install(editor, options = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const tt = instanceT(editor);
const maxSizeKB = options.maxSizeKB || 500;
const _onPaste = (e) => {
const items = e.clipboardData?.items;
@@ -2253,7 +2270,7 @@ const imagePastePlugin = {
if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault();
if (typeof editor.toast === 'function')
editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
editor.toast(tt('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break;
}
e.preventDefault();
@@ -2280,62 +2297,66 @@ const shortcutHelpPlugin = {
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const tt = instanceT(editor);
if (!document.getElementById('me-shortcut-style')) {
const s = document.createElement('style');
s.id = 'me-shortcut-style';
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
document.head.appendChild(s);
}
let _panel = null;
const _close = () => { if (_panel) {
_panel.remove();
_panel = null;
// 面板引用放入 holder:install 时无法预知未来打开的面板,
// 卸载时经 holder 取实时引用,避免面板残留 DOM。
const st = { panel: null };
const _close = () => { if (st.panel) {
st.panel.remove();
st.panel = null;
} };
const _open = () => {
if (_panel) {
if (st.panel) {
_close();
return;
}
// i18n-aware shortcut labels
// i18n-aware shortcut labels(跟随实例语言)
const builtin = [
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'],
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'],
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'],
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'],
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'],
['?', t('shortcuts') || 'Shortcuts'],
['Ctrl+B', tt('bold') || 'Bold'], ['Ctrl+I', tt('italic') || 'Italic'],
['Ctrl+U', tt('underline') || 'Underline'], ['Ctrl+K', tt('link') || 'Link'],
['Ctrl+E', tt('code') || 'Code'], ['Ctrl+1/2/3', tt('h1') || 'Heading'],
['Ctrl+Q', tt('quote') || 'Quote'], ['Ctrl+Z', tt('undo') || 'Undo'],
['Ctrl+Y', tt('redo') || 'Redo'], ['Ctrl+S', tt('save') || 'Save'],
['Ctrl+F', tt('search') || 'Search'], ['Ctrl+H', tt('replace') || 'Replace'],
['Tab', tt('indent') || 'Indent'], ['Shift+Tab', tt('outdent') || 'Outdent'],
['?', tt('shortcuts') || 'Shortcuts'],
];
let rows = '';
builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div');
overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${tt('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close'))
_close(); });
document.body.appendChild(overlay);
_panel = overlay;
st.panel = overlay;
};
const _onKeydown = (e) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
_open();
}
if (e.key === 'Escape' && _panel)
if (e.key === 'Escape' && st.panel)
_close();
};
editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown });
setPluginState(editor, K_SHORTCUT, { state: st, _open, _close, _onKeydown });
},
destroy(editor) {
const state = pluginState(editor, K_SHORTCUT);
if (state) {
if (state._panel) {
state._panel.remove();
const exposed = pluginState(editor, K_SHORTCUT);
if (exposed) {
if (exposed.state && exposed.state.panel) {
exposed.state.panel.remove();
exposed.state.panel = null;
}
}
const _onKeydown = state?._onKeydown;
const _onKeydown = exposed?._onKeydown;
if (_onKeydown && editor?.textarea)
editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT);
@@ -3000,7 +3021,6 @@ function initFloatingToolbar() {
const paddingTop = parseFloat(taStyle.paddingTop) || 16;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
const fontSize = parseFloat(taStyle.fontSize) || 13.5;
const charWidth = fontSize * 0.6;
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
@@ -3013,7 +3033,18 @@ function initFloatingToolbar() {
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap)
top = lineLocalTop + lineHeight + gap;
const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft;
// 逐字符估算列偏移: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';
@@ -3213,6 +3244,12 @@ const installContextMenu = (proto) => {
* @module outline
* @version 0.4.0
*/
/** querySelector 用的 id 转义:优先原生 CSS.escape,环境缺失时退化为简易转义 */
const cssEscape = (id) => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function')
return CSS.escape(id);
return id.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
function buildOutline() {
if (!this.config.outline || !this.previewEl)
return;
@@ -3223,10 +3260,40 @@ function buildOutline() {
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') });
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, ''), srcLine: -1 });
}
if (!headings.length)
return;
// 计算每个渲染标题在源码中的行号(跳过围栏代码块内部),
// 点击时按行号精确定位光标,重复标题文本也不会串位。
const srcLines = [];
const lines = this._value.split('\n');
let inFence = false;
let fenceChar = '';
const isSetextText = (l) => /\S/.test(l) && !/^\s{0,3}([-*+]|\d+\.)\s/.test(l) && !/^\s{0,3}>/.test(l) && !/^\s{0,3}(#{1,6}\s|`{3,}|~{3,})/.test(l);
for (let li = 0; li < lines.length; li++) {
const l = lines[li];
const fence = l.match(/^\s{0,3}(`{3,}|~{3,})/);
if (fence) {
if (!inFence) {
inFence = true;
fenceChar = fence[1][0];
}
else if (fence[1][0] === fenceChar)
inFence = false;
continue;
}
if (inFence)
continue;
if (/^ {0,3}(?:>\s*)*#{1,6}\s+\S/.test(l)) {
srcLines.push(li);
continue;
}
const next = lines[li + 1] || '';
if (li + 1 < lines.length && /^ {0,3}(={3,}|-{3,})\s*$/.test(next) && isSetextText(l))
srcLines.push(li);
}
headings.forEach((h, i) => { h.srcLine = i < srcLines.length ? srcLines[i] : -1; });
const panel = document.createElement('div');
panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`;
@@ -3245,7 +3312,7 @@ function buildOutline() {
continue;
closeTo(item.level);
h += '<ul>';
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.srcLine >= 0 ? item.srcLine + 1 : ''}">${escapeHTML(item.text)}</a>`;
stack.push(item.level);
}
closeTo(-1);
@@ -3259,10 +3326,19 @@ function buildOutline() {
return;
e.preventDefault();
const id = a.getAttribute('href').slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(id));
if (target) {
const target = this.previewEl.querySelector('#' + cssEscape(id));
if (target)
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
const idx = this._value.indexOf(target.textContent || '');
// 光标定位:优先用构建时记录的源码行号(重复标题也正确),兜底 indexOf
const ord = Array.prototype.indexOf.call(panel.querySelectorAll('a'), a);
const lineIdx = ord > -1 && ord < headings.length ? headings[ord].srcLine : -1;
if (lineIdx >= 0) {
const lineText = lines[lineIdx] || '';
const col = (lineText.match(/^ {0,3}(?:>\s*)*#{1,6}\s+/) || lineText.match(/^ {0,3}/) || [''])[0].length;
this.setCursorPosition(lineIdx + 1, col);
}
else {
const idx = this._value.indexOf(target?.textContent || '');
if (idx !== -1) {
this.textarea.focus();
this.textarea.setSelectionRange(idx, idx);
@@ -3280,6 +3356,10 @@ function updateOutline() {
function trackOutlineScroll() {
if (!this.config.outline || !this.previewPane)
return;
// 防重复绑定:构造时绑定过、setOutline(true) 再次调用时跳过
if (this._outlineScrollBound)
return;
this._outlineScrollBound = true;
let ticking = false;
const onScroll = () => {
if (ticking)
@@ -4092,6 +4172,10 @@ class MarkdownEditor {
canUndo() { return this._historyIndex > 0; }
canRedo() { return this._historyIndex < this._history.length - 1; }
_applyHistory() {
// 撤销/重做前记录预览区滚动比例,重渲染后按比例恢复,不再跳顶
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
this._value = this._history[this._historyIndex];
this.textarea.value = this._value;
this._render();
@@ -4099,8 +4183,11 @@ class MarkdownEditor {
this._updateWordCount();
if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop;
if (this.previewPane && this.config.syncScroll)
this.previewPane.scrollTop = 0;
if (pv && pvRatio != null) {
const max = pv.scrollHeight - pv.clientHeight;
if (max > 0)
pv.scrollTop = pvRatio * max;
}
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try {
@@ -4267,6 +4354,23 @@ class MarkdownEditor {
setWordWrap(on) { this._wordWrap = !!on; if (this.textarea)
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
isWordWrap() { return this._wordWrap; }
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on) {
this.config.outline = !!on;
if (this._destroyed || !this.el)
return this;
if (this.config.outline) {
this._buildOutline();
this._trackOutlineScroll();
}
else {
const panel = this.el.querySelector('.me-outline');
if (panel)
panel.remove();
}
return this;
}
isOutline() { return !!this.config.outline; }
_bindToolbarKeyboard() {
if (!this.toolbarEl)
return;
@@ -5039,9 +5143,9 @@ const getSupportedLanguages = () => Object.keys(LANGUAGES);
/**
* MetonaEditor — Type-safe, lightweight Markdown Editor
* @module metona-editor
* @version 0.4.1
* @version 0.4.2
*/
const VERSION = '0.4.1';
const VERSION = '0.4.2';
const globalPlugins = [];
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -12,6 +12,7 @@ interface EditorLike {
el: HTMLElement;
textarea: HTMLTextAreaElement;
config?: Record<string, any>;
t?: (key: string, params?: Record<string, any>) => string;
on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void;
off?: (name: string, fn: (...args: any[]) => void) => unknown;
_emit?: (name: string, ...args: any[]) => void;
@@ -422,6 +423,9 @@ declare class MarkdownEditor {
toggleWordWrap(): this;
setWordWrap(on: boolean): this;
isWordWrap(): boolean;
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on: boolean): this;
isOutline(): boolean;
_bindToolbarKeyboard(): void;
_initAriaLive(): void;
_announce(msg: string): void;
@@ -581,7 +585,7 @@ declare const animationUtils: {
destroy(): void;
};
declare const VERSION = "0.4.1";
declare const VERSION = "0.4.2";
declare function create(container: string | HTMLElement, options?: EditorOptions): MarkdownEditor;
declare function use(plugin: string | any, options?: any): typeof api;
declare function on(name: string, fn: (editor: MarkdownEditor) => void): () => void;
+141 -37
View File
@@ -191,7 +191,7 @@
renderError: '렌더링 실패',
outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다{size}KB > {max}KB',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
},
fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -780,6 +780,18 @@
}
});
}
// 语言切换即时刷新已渲染的 UI(状态栏统计标签、大纲标题),无需等待下一次输入
if (typeof editor._updateWordCount === 'function') {
try {
editor._updateWordCount();
}
catch (_) { }
}
if (editor.el && typeof editor.el.querySelector === 'function') {
const outlineTitle = editor.el.querySelector('.me-outline-title');
if (outlineTitle)
outlineTitle.textContent = instanceT('outline') || 'Outline';
}
return instanceLocale;
};
return {
@@ -1773,6 +1785,9 @@
};
// ============ Preset plugins ============
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** editor.t locale退 t
* 插件面板文案跟随实例语言而非全局语言 */
const instanceT = (editor) => (key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
const autoSavePlugin = {
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
install(editor, options) {
@@ -1949,6 +1964,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const tt = instanceT(editor);
// Inject style once globally
if (!document.getElementById('me-search-style')) {
const style = document.createElement('style');
@@ -2015,7 +2031,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const panel = document.createElement('div');
panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${t('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${t('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll') || 'All'}</button></div>`;
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${tt('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${tt('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${tt('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${tt('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${tt('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${tt('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${tt('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${tt('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${tt('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${tt('replaceAll') || 'All'}</button></div>`;
editor.el.appendChild(panel);
state._panel = panel;
_updateReplaceVisible();
@@ -2241,6 +2257,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
install(editor, options = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const tt = instanceT(editor);
const maxSizeKB = options.maxSizeKB || 500;
const _onPaste = (e) => {
const items = e.clipboardData?.items;
@@ -2255,7 +2272,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault();
if (typeof editor.toast === 'function')
editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
editor.toast(tt('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break;
}
e.preventDefault();
@@ -2282,62 +2299,66 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const tt = instanceT(editor);
if (!document.getElementById('me-shortcut-style')) {
const s = document.createElement('style');
s.id = 'me-shortcut-style';
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
document.head.appendChild(s);
}
let _panel = null;
const _close = () => { if (_panel) {
_panel.remove();
_panel = null;
// 面板引用放入 holder:install 时无法预知未来打开的面板,
// 卸载时经 holder 取实时引用,避免面板残留 DOM。
const st = { panel: null };
const _close = () => { if (st.panel) {
st.panel.remove();
st.panel = null;
} };
const _open = () => {
if (_panel) {
if (st.panel) {
_close();
return;
}
// i18n-aware shortcut labels
// i18n-aware shortcut labels(跟随实例语言)
const builtin = [
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'],
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'],
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'],
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'],
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'],
['?', t('shortcuts') || 'Shortcuts'],
['Ctrl+B', tt('bold') || 'Bold'], ['Ctrl+I', tt('italic') || 'Italic'],
['Ctrl+U', tt('underline') || 'Underline'], ['Ctrl+K', tt('link') || 'Link'],
['Ctrl+E', tt('code') || 'Code'], ['Ctrl+1/2/3', tt('h1') || 'Heading'],
['Ctrl+Q', tt('quote') || 'Quote'], ['Ctrl+Z', tt('undo') || 'Undo'],
['Ctrl+Y', tt('redo') || 'Redo'], ['Ctrl+S', tt('save') || 'Save'],
['Ctrl+F', tt('search') || 'Search'], ['Ctrl+H', tt('replace') || 'Replace'],
['Tab', tt('indent') || 'Indent'], ['Shift+Tab', tt('outdent') || 'Outdent'],
['?', tt('shortcuts') || 'Shortcuts'],
];
let rows = '';
builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div');
overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${tt('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close'))
_close(); });
document.body.appendChild(overlay);
_panel = overlay;
st.panel = overlay;
};
const _onKeydown = (e) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
_open();
}
if (e.key === 'Escape' && _panel)
if (e.key === 'Escape' && st.panel)
_close();
};
editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown });
setPluginState(editor, K_SHORTCUT, { state: st, _open, _close, _onKeydown });
},
destroy(editor) {
const state = pluginState(editor, K_SHORTCUT);
if (state) {
if (state._panel) {
state._panel.remove();
const exposed = pluginState(editor, K_SHORTCUT);
if (exposed) {
if (exposed.state && exposed.state.panel) {
exposed.state.panel.remove();
exposed.state.panel = null;
}
}
const _onKeydown = state?._onKeydown;
const _onKeydown = exposed?._onKeydown;
if (_onKeydown && editor?.textarea)
editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT);
@@ -3002,7 +3023,6 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const paddingTop = parseFloat(taStyle.paddingTop) || 16;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
const fontSize = parseFloat(taStyle.fontSize) || 13.5;
const charWidth = fontSize * 0.6;
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
@@ -3015,7 +3035,18 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap)
top = lineLocalTop + lineHeight + gap;
const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft;
// 逐字符估算列偏移: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';
@@ -3215,6 +3246,12 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
* @module outline
* @version 0.4.0
*/
/** querySelector 用的 id 转义:优先原生 CSS.escape,环境缺失时退化为简易转义 */
const cssEscape = (id) => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function')
return CSS.escape(id);
return id.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
function buildOutline() {
if (!this.config.outline || !this.previewEl)
return;
@@ -3225,10 +3262,40 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') });
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, ''), srcLine: -1 });
}
if (!headings.length)
return;
// 计算每个渲染标题在源码中的行号(跳过围栏代码块内部),
// 点击时按行号精确定位光标,重复标题文本也不会串位。
const srcLines = [];
const lines = this._value.split('\n');
let inFence = false;
let fenceChar = '';
const isSetextText = (l) => /\S/.test(l) && !/^\s{0,3}([-*+]|\d+\.)\s/.test(l) && !/^\s{0,3}>/.test(l) && !/^\s{0,3}(#{1,6}\s|`{3,}|~{3,})/.test(l);
for (let li = 0; li < lines.length; li++) {
const l = lines[li];
const fence = l.match(/^\s{0,3}(`{3,}|~{3,})/);
if (fence) {
if (!inFence) {
inFence = true;
fenceChar = fence[1][0];
}
else if (fence[1][0] === fenceChar)
inFence = false;
continue;
}
if (inFence)
continue;
if (/^ {0,3}(?:>\s*)*#{1,6}\s+\S/.test(l)) {
srcLines.push(li);
continue;
}
const next = lines[li + 1] || '';
if (li + 1 < lines.length && /^ {0,3}(={3,}|-{3,})\s*$/.test(next) && isSetextText(l))
srcLines.push(li);
}
headings.forEach((h, i) => { h.srcLine = i < srcLines.length ? srcLines[i] : -1; });
const panel = document.createElement('div');
panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`;
@@ -3247,7 +3314,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
continue;
closeTo(item.level);
h += '<ul>';
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.srcLine >= 0 ? item.srcLine + 1 : ''}">${escapeHTML(item.text)}</a>`;
stack.push(item.level);
}
closeTo(-1);
@@ -3261,10 +3328,19 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
return;
e.preventDefault();
const id = a.getAttribute('href').slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(id));
if (target) {
const target = this.previewEl.querySelector('#' + cssEscape(id));
if (target)
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
const idx = this._value.indexOf(target.textContent || '');
// 光标定位:优先用构建时记录的源码行号(重复标题也正确),兜底 indexOf
const ord = Array.prototype.indexOf.call(panel.querySelectorAll('a'), a);
const lineIdx = ord > -1 && ord < headings.length ? headings[ord].srcLine : -1;
if (lineIdx >= 0) {
const lineText = lines[lineIdx] || '';
const col = (lineText.match(/^ {0,3}(?:>\s*)*#{1,6}\s+/) || lineText.match(/^ {0,3}/) || [''])[0].length;
this.setCursorPosition(lineIdx + 1, col);
}
else {
const idx = this._value.indexOf(target?.textContent || '');
if (idx !== -1) {
this.textarea.focus();
this.textarea.setSelectionRange(idx, idx);
@@ -3282,6 +3358,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
function trackOutlineScroll() {
if (!this.config.outline || !this.previewPane)
return;
// 防重复绑定:构造时绑定过、setOutline(true) 再次调用时跳过
if (this._outlineScrollBound)
return;
this._outlineScrollBound = true;
let ticking = false;
const onScroll = () => {
if (ticking)
@@ -4094,6 +4174,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
canUndo() { return this._historyIndex > 0; }
canRedo() { return this._historyIndex < this._history.length - 1; }
_applyHistory() {
// 撤销/重做前记录预览区滚动比例,重渲染后按比例恢复,不再跳顶
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
this._value = this._history[this._historyIndex];
this.textarea.value = this._value;
this._render();
@@ -4101,8 +4185,11 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
this._updateWordCount();
if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop;
if (this.previewPane && this.config.syncScroll)
this.previewPane.scrollTop = 0;
if (pv && pvRatio != null) {
const max = pv.scrollHeight - pv.clientHeight;
if (max > 0)
pv.scrollTop = pvRatio * max;
}
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try {
@@ -4269,6 +4356,23 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
setWordWrap(on) { this._wordWrap = !!on; if (this.textarea)
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
isWordWrap() { return this._wordWrap; }
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on) {
this.config.outline = !!on;
if (this._destroyed || !this.el)
return this;
if (this.config.outline) {
this._buildOutline();
this._trackOutlineScroll();
}
else {
const panel = this.el.querySelector('.me-outline');
if (panel)
panel.remove();
}
return this;
}
isOutline() { return !!this.config.outline; }
_bindToolbarKeyboard() {
if (!this.toolbarEl)
return;
@@ -5041,9 +5145,9 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
/**
* MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor
* @version 0.4.1
* @version 0.4.2
*/
const VERSION = '0.4.1';
const VERSION = '0.4.2';
const globalPlugins = [];
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+141 -37
View File
@@ -185,7 +185,7 @@ const LOCALES = {
renderError: '렌더링 실패',
outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다{size}KB > {max}KB',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
},
fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -774,6 +774,18 @@ const createInstanceI18n = (editor) => {
}
});
}
// 语言切换即时刷新已渲染的 UI(状态栏统计标签、大纲标题),无需等待下一次输入
if (typeof editor._updateWordCount === 'function') {
try {
editor._updateWordCount();
}
catch (_) { }
}
if (editor.el && typeof editor.el.querySelector === 'function') {
const outlineTitle = editor.el.querySelector('.me-outline-title');
if (outlineTitle)
outlineTitle.textContent = instanceT('outline') || 'Outline';
}
return instanceLocale;
};
return {
@@ -1767,6 +1779,9 @@ const validateConfig = (schema = {}, config = {}) => {
};
// ============ Preset plugins ============
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** editor.t locale退 t
* 插件面板文案跟随实例语言而非全局语言 */
const instanceT = (editor) => (key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
const autoSavePlugin = {
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
install(editor, options) {
@@ -1943,6 +1958,7 @@ const searchReplacePlugin = {
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const tt = instanceT(editor);
// Inject style once globally
if (!document.getElementById('me-search-style')) {
const style = document.createElement('style');
@@ -2009,7 +2025,7 @@ const searchReplacePlugin = {
const panel = document.createElement('div');
panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${t('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${t('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll') || 'All'}</button></div>`;
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${tt('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${tt('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${tt('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${tt('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${tt('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${tt('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${tt('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${tt('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${tt('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${tt('replaceAll') || 'All'}</button></div>`;
editor.el.appendChild(panel);
state._panel = panel;
_updateReplaceVisible();
@@ -2235,6 +2251,7 @@ const imagePastePlugin = {
install(editor, options = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const tt = instanceT(editor);
const maxSizeKB = options.maxSizeKB || 500;
const _onPaste = (e) => {
const items = e.clipboardData?.items;
@@ -2249,7 +2266,7 @@ const imagePastePlugin = {
if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault();
if (typeof editor.toast === 'function')
editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
editor.toast(tt('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break;
}
e.preventDefault();
@@ -2276,62 +2293,66 @@ const shortcutHelpPlugin = {
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const tt = instanceT(editor);
if (!document.getElementById('me-shortcut-style')) {
const s = document.createElement('style');
s.id = 'me-shortcut-style';
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
document.head.appendChild(s);
}
let _panel = null;
const _close = () => { if (_panel) {
_panel.remove();
_panel = null;
// 面板引用放入 holder:install 时无法预知未来打开的面板,
// 卸载时经 holder 取实时引用,避免面板残留 DOM。
const st = { panel: null };
const _close = () => { if (st.panel) {
st.panel.remove();
st.panel = null;
} };
const _open = () => {
if (_panel) {
if (st.panel) {
_close();
return;
}
// i18n-aware shortcut labels
// i18n-aware shortcut labels(跟随实例语言)
const builtin = [
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'],
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'],
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'],
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'],
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'],
['?', t('shortcuts') || 'Shortcuts'],
['Ctrl+B', tt('bold') || 'Bold'], ['Ctrl+I', tt('italic') || 'Italic'],
['Ctrl+U', tt('underline') || 'Underline'], ['Ctrl+K', tt('link') || 'Link'],
['Ctrl+E', tt('code') || 'Code'], ['Ctrl+1/2/3', tt('h1') || 'Heading'],
['Ctrl+Q', tt('quote') || 'Quote'], ['Ctrl+Z', tt('undo') || 'Undo'],
['Ctrl+Y', tt('redo') || 'Redo'], ['Ctrl+S', tt('save') || 'Save'],
['Ctrl+F', tt('search') || 'Search'], ['Ctrl+H', tt('replace') || 'Replace'],
['Tab', tt('indent') || 'Indent'], ['Shift+Tab', tt('outdent') || 'Outdent'],
['?', tt('shortcuts') || 'Shortcuts'],
];
let rows = '';
builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div');
overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${tt('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close'))
_close(); });
document.body.appendChild(overlay);
_panel = overlay;
st.panel = overlay;
};
const _onKeydown = (e) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
_open();
}
if (e.key === 'Escape' && _panel)
if (e.key === 'Escape' && st.panel)
_close();
};
editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown });
setPluginState(editor, K_SHORTCUT, { state: st, _open, _close, _onKeydown });
},
destroy(editor) {
const state = pluginState(editor, K_SHORTCUT);
if (state) {
if (state._panel) {
state._panel.remove();
const exposed = pluginState(editor, K_SHORTCUT);
if (exposed) {
if (exposed.state && exposed.state.panel) {
exposed.state.panel.remove();
exposed.state.panel = null;
}
}
const _onKeydown = state?._onKeydown;
const _onKeydown = exposed?._onKeydown;
if (_onKeydown && editor?.textarea)
editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT);
@@ -2996,7 +3017,6 @@ function initFloatingToolbar() {
const paddingTop = parseFloat(taStyle.paddingTop) || 16;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
const fontSize = parseFloat(taStyle.fontSize) || 13.5;
const charWidth = fontSize * 0.6;
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
@@ -3009,7 +3029,18 @@ function initFloatingToolbar() {
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap)
top = lineLocalTop + lineHeight + gap;
const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft;
// 逐字符估算列偏移: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';
@@ -3209,6 +3240,12 @@ const installContextMenu = (proto) => {
* @module outline
* @version 0.4.0
*/
/** querySelector 用的 id 转义:优先原生 CSS.escape,环境缺失时退化为简易转义 */
const cssEscape = (id) => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function')
return CSS.escape(id);
return id.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
function buildOutline() {
if (!this.config.outline || !this.previewEl)
return;
@@ -3219,10 +3256,40 @@ function buildOutline() {
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') });
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, ''), srcLine: -1 });
}
if (!headings.length)
return;
// 计算每个渲染标题在源码中的行号(跳过围栏代码块内部),
// 点击时按行号精确定位光标,重复标题文本也不会串位。
const srcLines = [];
const lines = this._value.split('\n');
let inFence = false;
let fenceChar = '';
const isSetextText = (l) => /\S/.test(l) && !/^\s{0,3}([-*+]|\d+\.)\s/.test(l) && !/^\s{0,3}>/.test(l) && !/^\s{0,3}(#{1,6}\s|`{3,}|~{3,})/.test(l);
for (let li = 0; li < lines.length; li++) {
const l = lines[li];
const fence = l.match(/^\s{0,3}(`{3,}|~{3,})/);
if (fence) {
if (!inFence) {
inFence = true;
fenceChar = fence[1][0];
}
else if (fence[1][0] === fenceChar)
inFence = false;
continue;
}
if (inFence)
continue;
if (/^ {0,3}(?:>\s*)*#{1,6}\s+\S/.test(l)) {
srcLines.push(li);
continue;
}
const next = lines[li + 1] || '';
if (li + 1 < lines.length && /^ {0,3}(={3,}|-{3,})\s*$/.test(next) && isSetextText(l))
srcLines.push(li);
}
headings.forEach((h, i) => { h.srcLine = i < srcLines.length ? srcLines[i] : -1; });
const panel = document.createElement('div');
panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`;
@@ -3241,7 +3308,7 @@ function buildOutline() {
continue;
closeTo(item.level);
h += '<ul>';
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.srcLine >= 0 ? item.srcLine + 1 : ''}">${escapeHTML(item.text)}</a>`;
stack.push(item.level);
}
closeTo(-1);
@@ -3255,10 +3322,19 @@ function buildOutline() {
return;
e.preventDefault();
const id = a.getAttribute('href').slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(id));
if (target) {
const target = this.previewEl.querySelector('#' + cssEscape(id));
if (target)
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
const idx = this._value.indexOf(target.textContent || '');
// 光标定位:优先用构建时记录的源码行号(重复标题也正确),兜底 indexOf
const ord = Array.prototype.indexOf.call(panel.querySelectorAll('a'), a);
const lineIdx = ord > -1 && ord < headings.length ? headings[ord].srcLine : -1;
if (lineIdx >= 0) {
const lineText = lines[lineIdx] || '';
const col = (lineText.match(/^ {0,3}(?:>\s*)*#{1,6}\s+/) || lineText.match(/^ {0,3}/) || [''])[0].length;
this.setCursorPosition(lineIdx + 1, col);
}
else {
const idx = this._value.indexOf(target?.textContent || '');
if (idx !== -1) {
this.textarea.focus();
this.textarea.setSelectionRange(idx, idx);
@@ -3276,6 +3352,10 @@ function updateOutline() {
function trackOutlineScroll() {
if (!this.config.outline || !this.previewPane)
return;
// 防重复绑定:构造时绑定过、setOutline(true) 再次调用时跳过
if (this._outlineScrollBound)
return;
this._outlineScrollBound = true;
let ticking = false;
const onScroll = () => {
if (ticking)
@@ -4088,6 +4168,10 @@ class MarkdownEditor {
canUndo() { return this._historyIndex > 0; }
canRedo() { return this._historyIndex < this._history.length - 1; }
_applyHistory() {
// 撤销/重做前记录预览区滚动比例,重渲染后按比例恢复,不再跳顶
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
this._value = this._history[this._historyIndex];
this.textarea.value = this._value;
this._render();
@@ -4095,8 +4179,11 @@ class MarkdownEditor {
this._updateWordCount();
if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop;
if (this.previewPane && this.config.syncScroll)
this.previewPane.scrollTop = 0;
if (pv && pvRatio != null) {
const max = pv.scrollHeight - pv.clientHeight;
if (max > 0)
pv.scrollTop = pvRatio * max;
}
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try {
@@ -4263,6 +4350,23 @@ class MarkdownEditor {
setWordWrap(on) { this._wordWrap = !!on; if (this.textarea)
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
isWordWrap() { return this._wordWrap; }
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on) {
this.config.outline = !!on;
if (this._destroyed || !this.el)
return this;
if (this.config.outline) {
this._buildOutline();
this._trackOutlineScroll();
}
else {
const panel = this.el.querySelector('.me-outline');
if (panel)
panel.remove();
}
return this;
}
isOutline() { return !!this.config.outline; }
_bindToolbarKeyboard() {
if (!this.toolbarEl)
return;
@@ -5035,9 +5139,9 @@ const getSupportedLanguages = () => Object.keys(LANGUAGES);
/**
* MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor
* @version 0.4.1
* @version 0.4.2
*/
const VERSION = '0.4.1';
const VERSION = '0.4.2';
const globalPlugins = [];
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。
+1 -1
View File
File diff suppressed because one or more lines are too long