diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dfa845..fc52959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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。 - **CI 并行化**: 测试拆分为 parser / core / rest 三个并行 job,另设跨 Node 18/20/24 的 verify job(typecheck + lint + build)。 - **docs.html / README 补齐**: 光标选区 API、浮动工具栏、replaceAll、新事件、内置高亮文档。 -- 版本同步 0.2.4 → 0.2.5,测试 725 → 770+。 +- 版本同步 0.2.4 → 0.2.5,测试 725 → 768。 --- diff --git a/README.md b/README.md index 45b7e3d..125a863 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,9 @@ MeEditor.destroy() // 解析器独立使用 import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlockHandler } from '@metona-team/metona-editor'; + +// 内置语法高亮 +import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team/metona-editor'; ``` --- diff --git a/src/context-menu.ts b/src/context-menu.ts index 167022f..9725b2d 100644 --- a/src/context-menu.ts +++ b/src/context-menu.ts @@ -7,8 +7,9 @@ import { t as i18nT } from './i18n'; import type { MarkdownEditor } from './core'; -export function registerContextMenu(this: MarkdownEditor, items: any[] = []): void { +export function registerContextMenu(this: MarkdownEditor, items: any[] = []): this { this._contextMenuItems = items; + return this; } export function bindContextMenu(this: MarkdownEditor): void { diff --git a/src/core.ts b/src/core.ts index d71a52e..689f8c7 100644 --- a/src/core.ts +++ b/src/core.ts @@ -512,6 +512,13 @@ export class MarkdownEditor { _scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } _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; this._history = this._history.slice(0, this._historyIndex + 1); this._history.push(this._value); 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._outlineTimer) clearTimeout(this._outlineTimer); 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 = {}; if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); this._floatingToolbar = null; diff --git a/src/floating-toolbar.ts b/src/floating-toolbar.ts index 5c503a2..f3fcd28 100644 --- a/src/floating-toolbar.ts +++ b/src/floating-toolbar.ts @@ -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; if (!this._floatingEnabled) { if (this._floatingToolbar) { @@ -93,6 +93,7 @@ export function toggleFloatingToolbar(this: MarkdownEditor): void { this._floatingToolbar = null; } } + return this; } export function isFloatingToolbar(this: MarkdownEditor): boolean { return this._floatingEnabled; } diff --git a/src/highlight.ts b/src/highlight.ts index c10f4fe..fd065ed 100644 --- a/src/highlight.ts +++ b/src/highlight.ts @@ -8,7 +8,16 @@ * 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, '&').replace(//g, '>'); +}; // ============ Token rules ============ @@ -196,7 +205,7 @@ export const normalizeLanguage = (lang: string): string => { export const highlight = (code: string, lang: string): string => { const canonical = normalizeLanguage(lang); const def = LANGUAGES[canonical]; - const escaped = escapeHTML(code); + const escaped = escapeText(code); if (!def) return escaped; return tokenize(escaped, buildRules(def)); }; diff --git a/src/locales.ts b/src/locales.ts index 97020ce..a6dd5d3 100644 --- a/src/locales.ts +++ b/src/locales.ts @@ -30,6 +30,7 @@ export const LOCALES: Record> = { renderError: '渲染失败', outline: '大纲', cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选', + regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB)', }, 'en-US': { bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', @@ -56,6 +57,7 @@ export const LOCALES: Record> = { renderError: 'Render failed', outline: 'Outline', cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All', + regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)', }, ja: { bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線', @@ -82,6 +84,7 @@ export const LOCALES: Record> = { renderError: 'レンダリング失敗', outline: 'アウトライン', cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択', + regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB)', }, ko: { bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', @@ -108,6 +111,7 @@ export const LOCALES: Record> = { renderError: '렌더링 실패', outline: '개요', cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택', + regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다({size}KB > {max}KB)', }, fr: { bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', @@ -134,6 +138,7 @@ export const LOCALES: Record> = { renderError: 'Échec du rendu', outline: 'Plan', cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner', + regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)', }, de: { bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen', @@ -160,5 +165,6 @@ export const LOCALES: Record> = { renderError: 'Rendern fehlgeschlagen', outline: 'Gliederung', cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen', + regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)', }, }; diff --git a/src/parser.ts b/src/parser.ts index 78bce23..e099be6 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -142,8 +142,14 @@ const unescapePunct = (text: string): string => text.replace(/\\([!"#$%&'()*+,\- /** * 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 - * `"` terminates a double-quoted attribute, so escaping quotes (and newlines) suffices. + * (entities are safe inside quoted attributes and decode correctly), so only the + * quote character that would terminate the attribute needs escaping. + */ +const attrSafe = (v: string): string => v.replace(/"/g, '"').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 => { if (!title) return ''; @@ -695,7 +701,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/); if (!m) return match; const u = safeUrl(m[2]); if (!u) return escapeHTML(match); - return `${escapeAttr(m[1])}`; + return `${attrSafe(m[1])}`; } if (match.startsWith('![') && match.includes('][')) { const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/); @@ -705,17 +711,17 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin try { const ref = JSON.parse(env.refs[refKey]); const u = safeUrl(ref.url); if (!u) return escapeHTML(match); - return `${escapeAttr(m[1])}`; - } catch (_) { return `${escapeAttr(m[1])}`; } + return `${attrSafe(m[1])}`; + } catch (_) { return `${attrSafe(m[1])}`; } } - return `${escapeAttr(m[1])}`; + return `${attrSafe(m[1])}`; } if (match.startsWith('[') && match.includes('](')) { const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/); if (!m) return match; const u = safeUrl(m[2]); if (!u) return match; const linkText = renderInline(m[1], env); - return `${linkText}`; + return `${linkText}`; } if (match.startsWith('[') && match.includes('][')) { const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/); @@ -725,7 +731,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin try { const ref = JSON.parse(env.refs[refKey]); const u = safeUrl(ref.url); if (!u) return escapeHTML(match); - return `${m[1]}`; + return `${m[1]}`; } catch (_) { return escapeHTML(match); } } return escapeHTML(match); @@ -733,7 +739,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin if (match.startsWith('<http')) { const url = match.slice(4, -4); const u = safeUrl(url); - return `${url}`; + return `${url}`; } if (match.startsWith('**')) return `${match.slice(2, -2)}`; if (match.startsWith('__')) return `${match.slice(2, -2)}`; diff --git a/src/plugins.ts b/src/plugins.ts index 356424b..d00545f 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -323,7 +323,7 @@ const searchReplacePlugin: Plugin = { const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd); const panel = document.createElement('div'); panel.className = 'me-search'; panel.dataset.replace = showReplace ? '1' : '0'; - panel.innerHTML = `
`; + panel.innerHTML = `
`; editor.el.appendChild(panel); state._panel = panel; _updateReplaceVisible(); state._regexMode = false; state._matchCase = true; state._wholeWord = false; @@ -483,7 +483,7 @@ const imagePastePlugin: Plugin = { const sizeKB = file.size / 1024; if (maxSizeKB > 0 && sizeKB > maxSizeKB) { 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; } e.preventDefault(); @@ -527,11 +527,11 @@ const shortcutHelpPlugin: Plugin = { ['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('close') || 'Shortcuts'], + ['?', t('shortcuts') || 'Shortcuts'], ]; let rows = ''; builtin.forEach(([c, d]) => { rows += `${c}${d}`; }); const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay'; - overlay.innerHTML = `

⌨️ ${t('close') || 'Shortcuts'}

${rows}
`; + overlay.innerHTML = `

⌨️ ${t('shortcuts') || 'Shortcuts'}

${rows}
`; overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); }); document.body.appendChild(overlay); _panel = overlay; }; diff --git a/tests/core.test.ts b/tests/core.test.ts index 9e45c18..993a1ee 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -2414,3 +2414,79 @@ describe('MarkdownEditor - v0.2.5 分隔条实例隔离', () => { 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(); + }); +}); diff --git a/tests/highlight.test.ts b/tests/highlight.test.ts index 2ecde4a..37e5686 100644 --- a/tests/highlight.test.ts +++ b/tests/highlight.test.ts @@ -99,3 +99,18 @@ describe('highlight - 语言注册', () => { expect(langs).toContain('python'); }); }); + +describe('highlight - 环境一致性', () => { + test('字符串高亮与引号转义环境无关(jsdom 断言)', () => { + const out = highlight('const s = "hi"', 'js'); + expect(out).toContain('"hi"'); + }); + + test('引号不被实体化,span 文本保持原样', () => { + const out = highlight('"a" + \'b\'', 'js'); + expect(out).not.toContain('"'); + // 双引号字符串应被完整标记 + expect(out).toContain('"a"'); + expect(out).toContain("'b'"); + }); +}); diff --git a/tests/parser.test.ts b/tests/parser.test.ts index c3c83e5..fdb9f36 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -1328,3 +1328,29 @@ describe('parseMarkdown - v0.2.5 title 转义', () => { expect(html).toContain('title="say "hi""'); }); }); + +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&b=2"'); + expect(html).not.toContain('&amp;'); + }); + + test('图片 alt 中的 & 不双重转义', () => { + const html = parseMarkdown('![a & b](https://e.com/i.png)'); + expect(html).toContain('alt="a & b"'); + expect(html).not.toContain('&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;'); + }); + + test('URL 中的引号仍被转义防注入', () => { + const html = parseMarkdown('[x](https://example.com/"onclick="alert(1))'); + expect(html).not.toContain(' onclick="'); + expect(html).toContain('"onclick'); + }); +});