fix: 复查修复 5 处问题 — URL 双重转义/zen 泄漏/高亮环境不一致等

- parser: href/src/alt 改最小属性转义(escapeAttr 会把已实体化的 &
  再转义成 &,导致链接 URL 双重转义错误);新增 URL/alt 防回归测试
- core: _pushHistory 增加 maxLength 兜底截断(exec/Tab/Smart Enter/括号
  自动闭合等程序化路径此前绕过 maxlength 限制)
- core: destroy 时清理 Zen 模式的 document mousemove 监听(此前泄漏)
- highlight: 自写环境无关转义(仅 & < >),修复 Node 环境下字符串高亮
  失效、浏览器与 SSR 输出不一致的问题
- floating-toolbar/context-menu: toggleFloatingToolbar/registerContextMenu
  恢复链式 return this(拆分时丢失)
- plugins: 补齐 3 个硬编码文案的 i18n(regex/shortcuts/imageTooLarge),
  6 种语言同步
- 测试 768 → 780
This commit is contained in:
2026-08-09 09:26:20 +08:00
parent 5919dd9f66
commit e5f99aee83
12 changed files with 169 additions and 18 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ All notable changes to MetonaEditor will be documented in this file.
- **eslint type-aware**: 启用 `parserOptions.project``consistent-type-imports` / `no-unnecessary-type-assertion` 规则,0 errors。 - **eslint type-aware**: 启用 `parserOptions.project``consistent-type-imports` / `no-unnecessary-type-assertion` 规则,0 errors。
- **CI 并行化**: 测试拆分为 parser / core / rest 三个并行 job,另设跨 Node 18/20/24 的 verify jobtypecheck + lint + build)。 - **CI 并行化**: 测试拆分为 parser / core / rest 三个并行 job,另设跨 Node 18/20/24 的 verify jobtypecheck + lint + build)。
- **docs.html / README 补齐**: 光标选区 API、浮动工具栏、replaceAll、新事件、内置高亮文档。 - **docs.html / README 补齐**: 光标选区 API、浮动工具栏、replaceAll、新事件、内置高亮文档。
- 版本同步 0.2.4 → 0.2.5,测试 725 → 770+ - 版本同步 0.2.4 → 0.2.5,测试 725 → 768
--- ---
+3
View File
@@ -401,6 +401,9 @@ MeEditor.destroy()
// 解析器独立使用 // 解析器独立使用
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlockHandler } from '@metona-team/metona-editor'; import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlockHandler } from '@metona-team/metona-editor';
// 内置语法高亮
import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team/metona-editor';
``` ```
--- ---
+2 -1
View File
@@ -7,8 +7,9 @@
import { t as i18nT } from './i18n'; import { t as i18nT } from './i18n';
import type { MarkdownEditor } from './core'; import type { MarkdownEditor } from './core';
export function registerContextMenu(this: MarkdownEditor, items: any[] = []): void { export function registerContextMenu(this: MarkdownEditor, items: any[] = []): this {
this._contextMenuItems = items; this._contextMenuItems = items;
return this;
} }
export function bindContextMenu(this: MarkdownEditor): void { export function bindContextMenu(this: MarkdownEditor): void {
+8
View File
@@ -512,6 +512,13 @@ export class MarkdownEditor {
_scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } _scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
_pushHistory(): void { _pushHistory(): void {
// Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write
// to the textarea directly and bypass the native maxlength attribute.
if (this.config.maxLength && this.config.maxLength > 0 && this._value.length > this.config.maxLength) {
this._value = this._value.slice(0, this.config.maxLength);
if (this.textarea) this.textarea.value = this._value;
}
const cur = this._history[this._historyIndex]; if (cur === this._value) return; const cur = this._history[this._historyIndex]; if (cur === this._value) return;
this._history = this._history.slice(0, this._historyIndex + 1); this._history.push(this._value); this._history = this._history.slice(0, this._historyIndex + 1); this._history.push(this._value);
const limit = this.config.historyLimit! > 0 ? this.config.historyLimit! : 100; const limit = this.config.historyLimit! > 0 ? this.config.historyLimit! : 100;
@@ -846,6 +853,7 @@ export class MarkdownEditor {
if (this._historyTimer) clearTimeout(this._historyTimer); if (this._historyTimer) clearTimeout(this._historyTimer);
if (this._outlineTimer) clearTimeout(this._outlineTimer); if (this._outlineTimer) clearTimeout(this._outlineTimer);
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = []; this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = [];
if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; }
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {}; this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null; this._floatingToolbar = null;
+2 -1
View File
@@ -85,7 +85,7 @@ export function initFloatingToolbar(this: MarkdownEditor): void {
}); });
} }
export function toggleFloatingToolbar(this: MarkdownEditor): void { export function toggleFloatingToolbar(this: MarkdownEditor): this {
this._floatingEnabled = !this._floatingEnabled; this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) { if (!this._floatingEnabled) {
if (this._floatingToolbar) { if (this._floatingToolbar) {
@@ -93,6 +93,7 @@ export function toggleFloatingToolbar(this: MarkdownEditor): void {
this._floatingToolbar = null; this._floatingToolbar = null;
} }
} }
return this;
} }
export function isFloatingToolbar(this: MarkdownEditor): boolean { return this._floatingEnabled; } export function isFloatingToolbar(this: MarkdownEditor): boolean { return this._floatingEnabled; }
+11 -2
View File
@@ -8,7 +8,16 @@
* escaped text (no raw HTML can leak through). * escaped text (no raw HTML can leak through).
*/ */
import { escapeHTML } from './utils'; /**
* Environment-agnostic escaping for tokenizer input.
* Unlike the `escapeHTML` util (whose quote handling differs between browser
* DOM and Node), we only escape `& < >` here so string/comment token rules
* behave identically in both environments. Quote characters are safe inside
* text nodes and do not need escaping.
*/
const escapeText = (s: string): string => {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
};
// ============ Token rules ============ // ============ Token rules ============
@@ -196,7 +205,7 @@ export const normalizeLanguage = (lang: string): string => {
export const highlight = (code: string, lang: string): string => { export const highlight = (code: string, lang: string): string => {
const canonical = normalizeLanguage(lang); const canonical = normalizeLanguage(lang);
const def = LANGUAGES[canonical]; const def = LANGUAGES[canonical];
const escaped = escapeHTML(code); const escaped = escapeText(code);
if (!def) return escaped; if (!def) return escaped;
return tokenize(escaped, buildRules(def)); return tokenize(escaped, buildRules(def));
}; };
+6
View File
@@ -30,6 +30,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: '渲染失败', renderError: '渲染失败',
outline: '大纲', outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选', cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB',
}, },
'en-US': { 'en-US': {
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
@@ -56,6 +57,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: 'Render failed', renderError: 'Render failed',
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)',
}, },
ja: { ja: {
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線', bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
@@ -82,6 +84,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: 'レンダリング失敗', renderError: 'レンダリング失敗',
outline: 'アウトライン', outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択', cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
}, },
ko: { ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -108,6 +111,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: '렌더링 실패', renderError: '렌더링 실패',
outline: '개요', outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택', cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다({size}KB > {max}KB',
}, },
fr: { fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -134,6 +138,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: 'Échec du rendu', renderError: 'Échec du rendu',
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)',
}, },
de: { de: {
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen', bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
@@ -160,5 +165,6 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: 'Rendern fehlgeschlagen', renderError: 'Rendern fehlgeschlagen',
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)',
}, },
}; };
+15 -9
View File
@@ -142,8 +142,14 @@ const unescapePunct = (text: string): string => text.replace(/\\([!"#$%&'()*+,\-
/** /**
* Safe double-quoted attribute value. Inline-sourced text is already HTML-escaped * Safe double-quoted attribute value. Inline-sourced text is already HTML-escaped
* (entities are safe inside quoted attributes); ref-sourced text is raw but only * (entities are safe inside quoted attributes and decode correctly), so only the
* `"` terminates a double-quoted attribute, so escaping quotes (and newlines) suffices. * quote character that would terminate the attribute needs escaping.
*/
const attrSafe = (v: string): string => v.replace(/"/g, '&quot;').replace(/\r?\n/g, ' ');
/**
* Same as `attrSafe` but also restores backslash-escaped punctuation first
* (used for title attributes where \" is a markdown escape).
*/ */
const titleAttr = (title: string | undefined): string => { const titleAttr = (title: string | undefined): string => {
if (!title) return ''; if (!title) return '';
@@ -695,7 +701,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/); const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m) return match; if (!m) return match;
const u = safeUrl(m[2]); if (!u) return escapeHTML(match); const u = safeUrl(m[2]); if (!u) return escapeHTML(match);
return `<img src="${escapeAttr(u)}" alt="${escapeAttr(m[1])}"${titleAttr(m[3])} loading="lazy"/>`; return `<img src="${attrSafe(u)}" alt="${attrSafe(m[1])}"${titleAttr(m[3])} loading="lazy"/>`;
} }
if (match.startsWith('![') && match.includes('][')) { if (match.startsWith('![') && match.includes('][')) {
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/); const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
@@ -705,17 +711,17 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
try { try {
const ref = JSON.parse(env.refs[refKey]); const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url); if (!u) return escapeHTML(match); const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
return `<img src="${escapeAttr(u)}" alt="${escapeAttr(m[1])}"${titleAttr(escapeHTML(ref.title))} loading="lazy"/>`; return `<img src="${attrSafe(u)}" alt="${attrSafe(m[1])}"${titleAttr(escapeHTML(ref.title))} loading="lazy"/>`;
} catch (_) { return `<img src="" alt="${escapeAttr(m[1])}" class="me-img-ref"/>`; } } catch (_) { return `<img src="" alt="${attrSafe(m[1])}" class="me-img-ref"/>`; }
} }
return `<img src="" alt="${escapeAttr(m[1])}" class="me-img-ref"/>`; return `<img src="" alt="${attrSafe(m[1])}" class="me-img-ref"/>`;
} }
if (match.startsWith('[') && match.includes('](')) { if (match.startsWith('[') && match.includes('](')) {
const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/); const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m) return match; if (!m) return match;
const u = safeUrl(m[2]); if (!u) return match; const u = safeUrl(m[2]); if (!u) return match;
const linkText = renderInline(m[1], env); const linkText = renderInline(m[1], env);
return `<a href="${escapeAttr(u)}"${titleAttr(m[3])} target="_blank" rel="noopener noreferrer">${linkText}</a>`; return `<a href="${attrSafe(u)}"${titleAttr(m[3])} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
} }
if (match.startsWith('[') && match.includes('][')) { if (match.startsWith('[') && match.includes('][')) {
const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/); const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/);
@@ -725,7 +731,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
try { try {
const ref = JSON.parse(env.refs[refKey]); const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url); if (!u) return escapeHTML(match); const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
return `<a href="${escapeAttr(u)}"${titleAttr(escapeHTML(ref.title))} target="_blank" rel="noopener noreferrer">${m[1]}</a>`; return `<a href="${attrSafe(u)}"${titleAttr(escapeHTML(ref.title))} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
} catch (_) { return escapeHTML(match); } } catch (_) { return escapeHTML(match); }
} }
return escapeHTML(match); return escapeHTML(match);
@@ -733,7 +739,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
if (match.startsWith('&lt;http')) { if (match.startsWith('&lt;http')) {
const url = match.slice(4, -4); const url = match.slice(4, -4);
const u = safeUrl(url); const u = safeUrl(url);
return `<a href="${escapeAttr(u)}" target="_blank" rel="noopener noreferrer">${url}</a>`; return `<a href="${attrSafe(u)}" target="_blank" rel="noopener noreferrer">${url}</a>`;
} }
if (match.startsWith('**')) return `<strong>${match.slice(2, -2)}</strong>`; if (match.startsWith('**')) return `<strong>${match.slice(2, -2)}</strong>`;
if (match.startsWith('__')) return `<strong>${match.slice(2, -2)}</strong>`; if (match.startsWith('__')) return `<strong>${match.slice(2, -2)}</strong>`;
+4 -4
View File
@@ -323,7 +323,7 @@ const searchReplacePlugin: Plugin = {
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd); const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
const panel = document.createElement('div'); panel.className = 'me-search'; const panel = document.createElement('div'); panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0'; 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="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="${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>`;
editor.el.appendChild(panel); state._panel = panel; editor.el.appendChild(panel); state._panel = panel;
_updateReplaceVisible(); _updateReplaceVisible();
state._regexMode = false; state._matchCase = true; state._wholeWord = false; state._regexMode = false; state._matchCase = true; state._wholeWord = false;
@@ -483,7 +483,7 @@ const imagePastePlugin: Plugin = {
const sizeKB = file.size / 1024; const sizeKB = file.size / 1024;
if (maxSizeKB > 0 && sizeKB > maxSizeKB) { if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault(); e.preventDefault();
if (typeof editor.toast === 'function') editor.toast(`Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' }); 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' });
break; break;
} }
e.preventDefault(); e.preventDefault();
@@ -527,11 +527,11 @@ const shortcutHelpPlugin: Plugin = {
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'], ['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'], ['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'], ['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'],
['?', t('close') || 'Shortcuts'], ['?', t('shortcuts') || 'Shortcuts'],
]; ];
let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; }); 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'; const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('close') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`; overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); });
document.body.appendChild(overlay); _panel = overlay; document.body.appendChild(overlay); _panel = overlay;
}; };
+76
View File
@@ -2414,3 +2414,79 @@ describe('MarkdownEditor - v0.2.5 分隔条实例隔离', () => {
a.destroy(); b.destroy(); a.destroy(); b.destroy();
}); });
}); });
// ============ v0.2.5 复查:maxLength 兜底 + zen destroy 清理 ============
describe('MarkdownEditor - v0.2.5 maxLength 兜底', () => {
test('exec 命令路径不突破 maxLength', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 20, value: '12345678901234567890' });
ed.focus();
ed.textarea.setSelectionRange(0, 0);
ed.exec('indent');
expect(ed.getValue().length).toBeLessThanOrEqual(20);
ed.destroy();
});
test('Smart Enter 不突破 maxLength', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 30, value: '- item 123456789012345678901234' });
ed.focus();
ed.textarea.setSelectionRange(ed.getValue().length, ed.getValue().length);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.getValue().length).toBeLessThanOrEqual(30);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.5 zen destroy 清理', () => {
test('destroy 移除 zen mousemove 监听', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
expect(ed._zenMouseHandler).not.toBeNull();
const spy = jest.spyOn(document, 'removeEventListener');
ed.destroy();
expect(spy).toHaveBeenCalledWith('mousemove', expect.any(Function));
spy.mockRestore();
});
test('destroy 后 mousemove 不再修改 toolbar', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
const toolbar = ed.toolbarEl;
ed.destroy();
toolbar.style.opacity = '1';
document.dispatchEvent(new MouseEvent('mousemove', { clientY: 10 }));
expect(toolbar.style.opacity).toBe('1');
});
});
// ============ v0.2.5 复查:链式 API 契约 ============
describe('MarkdownEditor - v0.2.5 链式返回', () => {
test('toggleFloatingToolbar 返回 this', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.toggleFloatingToolbar()).toBe(ed);
ed.destroy();
});
test('registerContextMenu 返回 this', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.registerContextMenu([{ label: 'x', onClick: () => {} }])).toBe(ed);
ed.destroy();
});
});
+15
View File
@@ -99,3 +99,18 @@ describe('highlight - 语言注册', () => {
expect(langs).toContain('python'); expect(langs).toContain('python');
}); });
}); });
describe('highlight - 环境一致性', () => {
test('字符串高亮与引号转义环境无关(jsdom 断言)', () => {
const out = highlight('const s = "hi"', 'js');
expect(out).toContain('<span class="me-hl-string">"hi"</span>');
});
test('引号不被实体化,span 文本保持原样', () => {
const out = highlight('"a" + \'b\'', 'js');
expect(out).not.toContain('&quot;');
// 双引号字符串应被完整标记
expect(out).toContain('<span class="me-hl-string">"a"</span>');
expect(out).toContain("<span class=\"me-hl-string\">'b'</span>");
});
});
+26
View File
@@ -1328,3 +1328,29 @@ describe('parseMarkdown - v0.2.5 title 转义', () => {
expect(html).toContain('title="say &quot;hi&quot;"'); expect(html).toContain('title="say &quot;hi&quot;"');
}); });
}); });
describe('parseMarkdown - v0.2.5 URL 属性无双重转义', () => {
test('链接 URL 中的 & 不双重转义', () => {
const html = parseMarkdown('[x](https://example.com/?a=1&b=2)');
expect(html).toContain('href="https://example.com/?a=1&amp;b=2"');
expect(html).not.toContain('&amp;amp;');
});
test('图片 alt 中的 & 不双重转义', () => {
const html = parseMarkdown('![a & b](https://e.com/i.png)');
expect(html).toContain('alt="a &amp; b"');
expect(html).not.toContain('&amp;amp;');
});
test('引用链接 URL 中的 & 不被破坏', () => {
const html = parseMarkdown('[r]: https://example.com/?a=1&b=2\n\n[x][r]');
expect(html).toContain('href="https://example.com/?a=1&b=2"');
expect(html).not.toContain('&amp;amp;');
});
test('URL 中的引号仍被转义防注入', () => {
const html = parseMarkdown('[x](https://example.com/"onclick="alert(1))');
expect(html).not.toContain(' onclick="');
expect(html).toContain('&quot;onclick');
});
});