From fae31bba60cdc4541898968e0a42ad2ef44eb8d3 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Sun, 9 Aug 2026 08:59:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor(core):=20=E6=8B=86=E5=88=86=204=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=20+=20=E6=8F=92=E4=BB=B6=E7=8A=B6=E6=80=81?= =?UTF-8?q?=20Symbol=20=E5=8C=96=20+=20eslint=20type-aware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core.ts 从 1162 行降至 865 行:命令/浮动工具栏/右键菜单/大纲 拆至 commands.ts / floating-toolbar.ts / context-menu.ts / outline.ts (原型安装,API 与测试完全兼容) - 6 个预设插件状态改用 Symbol 键存储,定义 EditorLike 契约接口, 消灭 (editor as any).__xxx 魔法属性 - 实现 maxLength(textarea maxlength + 程序化入口截断)与 zenMode 初始状态(此前为 dead config) - 搜索面板补齐 matchCase/wholeWord(翻译键已有但未实现, 中文全字边界感知) - 分隔条 localStorage key 加入实例 id 隔离 - eslint 启用 type-aware 规则(consistent-type-imports / no-unnecessary-type-assertion),0 errors --- .eslintrc.json | 5 +- src/commands.ts | 123 ++++++++++++ src/constants.ts | 6 +- src/context-menu.ts | 103 ++++++++++ src/core.ts | 412 ++++++---------------------------------- src/floating-toolbar.ts | 132 +++++++++++++ src/i18n.ts | 2 +- src/outline.ts | 84 ++++++++ src/plugins.ts | 277 ++++++++++++++++++--------- src/themes.ts | 2 +- tests/core.test.ts | 113 +++++++++++ tests/plugins.test.ts | 160 +++++++++++++++- 12 files changed, 972 insertions(+), 447 deletions(-) create mode 100644 src/commands.ts create mode 100644 src/context-menu.ts create mode 100644 src/floating-toolbar.ts create mode 100644 src/outline.ts diff --git a/.eslintrc.json b/.eslintrc.json index a2b246d..14393de 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,7 +9,8 @@ "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 2020, - "sourceType": "module" + "sourceType": "module", + "project": "./tsconfig.json" }, "plugins": ["@typescript-eslint"], "extends": [ @@ -19,6 +20,8 @@ "no-console": "warn", "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], + "@typescript-eslint/consistent-type-imports": ["warn", { "prefer": "type-imports", "fixStyle": "inline-type-imports" }], + "@typescript-eslint/no-unnecessary-type-assertion": "warn", "no-undef": "off", "no-empty": "off", "no-useless-escape": "off", diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..bbf8a22 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,123 @@ +/** + * MetonaEditor Commands — editing command implementations + * @module commands + * @version 0.2.5 + */ + +import { t as i18nT } from './i18n'; +import type { MarkdownEditor } from './core'; + +// ============ Selection wrapping ============ + +export function wrapSelection(this: MarkdownEditor, before: string, after: string): void { + const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; + const selected = ta.value.slice(start, end); const text = selected || 'text'; + const inserted = before + text + after; + ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end); ta.focus(); + if (selected) { ta.selectionStart = start + before.length; ta.selectionEnd = start + before.length + text.length; } + else { ta.selectionStart = ta.selectionEnd = start + before.length; } + this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); +} + +// ============ Line prefix toggling ============ + +export function toggleLinePrefix(this: MarkdownEditor, prefix: string): void { + const ta = this.textarea; const start = ta.selectionStart; + const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; + const lineEndPos = ta.value.indexOf('\n', start); const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos; + const line = ta.value.slice(lineStart, lineEnd); + const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/); + let newLine: string; + if (existingMatch && existingMatch[0] === prefix) newLine = line.slice(prefix.length); + else if (existingMatch) newLine = prefix + line.slice(existingMatch[0].length); + else newLine = prefix + line; + ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus(); + ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; + this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); +} + +// ============ Block insertion ============ + +export function insertBlock(this: MarkdownEditor, text: string): void { + const ta = this.textarea; const start = ta.selectionStart; + const before = ta.value.slice(0, start); const needNL = before && !before.endsWith('\n'); + const insert = (needNL ? '\n' : '') + text; + ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd); ta.focus(); + const pos = start + insert.length; ta.selectionStart = ta.selectionEnd = pos; + this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); +} + +export function insertLink(this: MarkdownEditor): void { + const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; + const sel = ta.value.slice(start, end) || i18nT('link') || 'link'; + const url = 'https://'; + const insert = `[${sel}](${url})`; + ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); + ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length; + this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); +} + +export function insertImage(this: MarkdownEditor): void { + const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; + const sel = ta.value.slice(start, end) || i18nT('image') || 'image'; + const url = 'https://'; + const insert = `![${sel}](${url})`; + ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); + ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length; + this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); +} + +export function insertTable(this: MarkdownEditor, rows = 3, cols = 3): void { + const header = Array.from({ length: cols }, (_, i) => `${i18nT('tableCols') || '列'}${i + 1}`).join(' | '); + const sep = Array.from({ length: cols }, () => '---').join(' | '); + let md = `| ${header} |\n| ${sep} |\n`; + for (let r = 1; r < rows; r++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`; + insertBlock.call(this, '\n' + md); +} + +// ============ Table auto-format ============ + +export function formatTable(this: MarkdownEditor): void { + const ta = this.textarea; + const start = ta.selectionStart; + const before = ta.value.substring(0, start); + const after = ta.value.substring(start); + const blockStart = before.lastIndexOf('\n\n'); + const blockEnd = after.indexOf('\n\n'); + const tableStart = blockStart === -1 ? 0 : blockStart + 2; + const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd; + const tableText = ta.value.substring(tableStart, tableEnd); + const lines = tableText.split('\n').filter((l) => l.includes('|')); + if (lines.length < 2) return; + const splitRow = (r: string) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/); + const allCells = lines.map(splitRow); + const colCount = Math.max(...allCells.map((c) => c.length)); + const colWidths: number[] = Array(colCount).fill(3); + allCells.forEach((cells) => { + cells.forEach((cell, ci) => { + colWidths[ci] = Math.max(colWidths[ci], cell.trim().length); + }); + }); + const pad = (s: string, w: number) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); }; + const formatted = allCells.map((cells) => { + const padded = []; + for (let ci = 0; ci < colCount; ci++) { + padded.push(pad((cells[ci] || '').trim(), colWidths[ci])); + } + return '| ' + padded.join(' | ') + ' |'; + }); + ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); + this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); +} + +// ============ Prototype installation ============ + +export const installCommands = (proto: any): void => { + proto._wrapSelection = wrapSelection; + proto._toggleLinePrefix = toggleLinePrefix; + proto._insertBlock = insertBlock; + proto._insertLink = insertLink; + proto._insertImage = insertImage; + proto._insertTable = insertTable; + proto._formatTable = formatTable; +}; diff --git a/src/constants.ts b/src/constants.ts index 9ec34cf..63cdd10 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -104,9 +104,9 @@ export const DEFAULT_TOOLBAR: ToolbarItem[] = [ export const DEFAULTS: Readonly = Object.freeze({ value: '', placeholder: '', - mode: 'split' as EditMode, + mode: 'split', height: 400, - toolbar: DEFAULT_TOOLBAR as ToolbarItem[] | false, + toolbar: DEFAULT_TOOLBAR, wordCount: true, autofocus: false, spellcheck: false, @@ -121,7 +121,7 @@ export const DEFAULTS: Readonly = Object.freeze({ zenMode: false, wordWrap: true, maxLength: 0, - theme: 'auto' as ThemeName, + theme: 'auto', locale: 'zh-CN', render: null, highlight: null, diff --git a/src/context-menu.ts b/src/context-menu.ts new file mode 100644 index 0000000..167022f --- /dev/null +++ b/src/context-menu.ts @@ -0,0 +1,103 @@ +/** + * MetonaEditor Context Menu — right-click menu + * @module context-menu + * @version 0.2.5 + */ + +import { t as i18nT } from './i18n'; +import type { MarkdownEditor } from './core'; + +export function registerContextMenu(this: MarkdownEditor, items: any[] = []): void { + this._contextMenuItems = items; +} + +export function bindContextMenu(this: MarkdownEditor): void { + if (!this.el) return; + const onContextMenu = (e: MouseEvent) => { + const existing = document.querySelector('.me-context-menu'); + if (existing) existing.remove(); + this._showContextMenu(e); + }; + this.el.addEventListener('contextmenu', onContextMenu); + this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu)); +} + +export function showContextMenu(this: MarkdownEditor, e: MouseEvent): void { + e.preventDefault(); + const ta = this.textarea; + const hasSelection = ta && ta.selectionStart !== ta.selectionEnd; + const defaultItems: Array<{ label?: string; action?: string; shortcut?: string; sep?: boolean; disabled?: boolean; onClick?: () => void }> = [ + { label: i18nT('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() }, + { label: i18nT('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() }, + { sep: true }, + { label: i18nT('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection }, + { label: i18nT('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection }, + { label: i18nT('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly }, + { label: i18nT('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' }, + ]; + const items = [...defaultItems]; + if (this._contextMenuItems.length) { + items.push({ sep: true }); + this._contextMenuItems.forEach((item) => items.push(item)); + } + const menu = document.createElement('div'); + menu.className = 'me-context-menu'; + menu.style.left = e.clientX + 'px'; + menu.style.top = e.clientY + 'px'; + // Adjust if off-screen + requestAnimationFrame(() => { + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) menu.style.left = (e.clientX - rect.width) + 'px'; + if (rect.bottom > window.innerHeight) menu.style.top = (e.clientY - rect.height) + 'px'; + }); + items.forEach((item) => { + if ((item as any).sep) { const sep = document.createElement('div'); sep.className = 'me-context-menu-sep'; menu.appendChild(sep); return; } + const el = document.createElement('div'); + el.className = 'me-context-menu-item'; + if (item.disabled) el.classList.add('me-disabled'); + el.innerHTML = `${item.label}${item.shortcut ? `${item.shortcut}` : ''}`; + el.addEventListener('click', (ev) => { + ev.stopPropagation(); + if (item.disabled) return; + if (item.onClick) { item.onClick(); } + else if (item.action) this._execContextAction(item.action); + this._hideContextMenu(); + }); + menu.appendChild(el); + }); + document.body.appendChild(menu); + const close = (ev: Event) => { + if (!menu.contains(ev.target as Node)) { this._hideContextMenu(); } + }; + document.addEventListener('click', close, { once: true }); + document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') this._hideContextMenu(); }, { once: true }); +} + +export function hideContextMenu(this: MarkdownEditor): void { + const menu = document.querySelector('.me-context-menu'); + if (menu) menu.remove(); +} + +export function execContextAction(this: MarkdownEditor, action: string): void { + const ta = this.textarea; + if (!ta) return; + switch (action) { + case 'undo': this.undo(); break; + case 'redo': this.redo(); break; + case 'cut': document.execCommand('cut'); break; + case 'copy': document.execCommand('copy'); break; + case 'paste': document.execCommand('paste'); break; + case 'selectAll': ta.focus(); ta.select(); break; + default: this.exec(action); break; + } +} + +// ============ Prototype installation ============ + +export const installContextMenu = (proto: any): void => { + proto.registerContextMenu = registerContextMenu; + proto._bindContextMenu = bindContextMenu; + proto._showContextMenu = showContextMenu; + proto._hideContextMenu = hideContextMenu; + proto._execContextAction = execContextAction; +}; diff --git a/src/core.ts b/src/core.ts index f790f69..d71a52e 100644 --- a/src/core.ts +++ b/src/core.ts @@ -13,6 +13,10 @@ import type { InstanceI18n } from './i18n'; import { parseMarkdown } from './parser'; import { presetPlugins, topologicalSort } from './plugins'; import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes'; +import { installCommands } from './commands'; +import { installFloatingToolbar } from './floating-toolbar'; +import { installContextMenu } from './context-menu'; +import { installOutline } from './outline'; const MODES: EditMode[] = ['edit', 'split', 'preview']; const TOAST_ICONS: Record = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' }; @@ -85,15 +89,37 @@ export class MarkdownEditor { _floatingToolbar!: HTMLElement | null; _floatingEnabled!: boolean; + // Prototype-installed methods (see installers at bottom of file) + _wrapSelection!: (before: string, after: string) => void; + _toggleLinePrefix!: (prefix: string) => void; + _insertBlock!: (text: string) => void; + _insertLink!: () => void; + _insertImage!: () => void; + _insertTable!: (rows?: number, cols?: number) => void; + _formatTable!: () => void; + _initFloatingToolbar!: () => void; + toggleFloatingToolbar!: () => this; + isFloatingToolbar!: () => boolean; + _buildFloatingToolbar!: () => void; + _hideFloatingToolbar!: () => void; + registerContextMenu!: (items: any[]) => this; + _bindContextMenu!: () => void; + _showContextMenu!: (e: MouseEvent) => void; + _hideContextMenu!: () => void; + _execContextAction!: (action: string) => void; + _buildOutline!: () => void; + _updateOutline!: () => void; + _trackOutlineScroll!: () => void; + constructor(container: string | HTMLElement, options: EditorOptions = {}) { if (!isBrowser()) { - this._destroyed = true; this._value = options.value || ''; return this as any; + this._destroyed = true; this._value = options.value || ''; return this; } this.id = options.id || generateId(); this.container = typeof container === 'string' ? document.querySelector(container) as HTMLElement : container; if (!this.container) { console.error('MeEditor: container not found:', container); - this._destroyed = true; return this as any; + this._destroyed = true; return this; } this.config = { ...DEFAULTS, ...options }; if (options.style && typeof options.style === 'object') { @@ -105,7 +131,7 @@ export class MarkdownEditor { this._listeners = {}; this._renderRaf = null; this._historyTimer = null; this._fullscreen = false; this._destroyed = false; this._lastRenderedValue = null; this._shortcuts = []; this._contextMenuItems = []; this._customActions = {}; - this._outlineTimer = null; this._zenMode = false; this._wordWrap = true; this._syncing = false; + this._outlineTimer = null; this._zenMode = this.config.zenMode === true; this._wordWrap = true; this._syncing = false; this._zenMouseHandler = null; this._floatingToolbar = null; this._floatingEnabled = this.config.floatingToolbar !== false; @@ -138,6 +164,7 @@ export class MarkdownEditor { this._pushHistory(); this._render(); this._updateWordCount(); + if (this._zenMode) this._setZen(true); if (Array.isArray(this.config.plugins)) { const plugins = this.config.plugins.map((p: any) => { @@ -179,6 +206,7 @@ export class MarkdownEditor { editorInner.appendChild(gutter); const textarea = document.createElement('textarea'); textarea.className = 'me-textarea'; textarea.spellcheck = !!this.config.spellcheck; textarea.readOnly = !!this.config.readOnly; + textarea.maxLength = this.config.maxLength && this.config.maxLength > 0 ? this.config.maxLength : -1; textarea.style.tabSize = String(this.config.tabSize || 2); textarea.placeholder = this.config.placeholder || i18nT('placeholder'); textarea.setAttribute('aria-label', i18nT('edit')); @@ -371,13 +399,13 @@ export class MarkdownEditor { _saveDividerPosition(): void { try { const epFlex = this.editorPane.style.flex; - if (epFlex) localStorage.setItem('metona-editor-divider', epFlex); + if (epFlex) localStorage.setItem(`metona-editor-divider-${this.id}`, epFlex); } catch (_) {} } _restoreDividerPosition(): void { try { - const saved = localStorage.getItem('metona-editor-divider'); + const saved = localStorage.getItem(`metona-editor-divider-${this.id}`); if (saved && this._mode === 'split') { this.editorPane.style.flex = saved; this.previewPane.style.flex = '1 1 auto'; @@ -481,69 +509,6 @@ export class MarkdownEditor { this._cleanups.push(() => { ta.removeEventListener('dragover', onDragOver); ta.removeEventListener('drop', onDrop); }); } - _buildOutline(): void { - if (!this.config.outline || !this.previewEl) return; - const old = this.el.querySelector('.me-outline'); if (old) old.remove(); - const headings: Array<{ level: number; id: string; text: string }> = []; - const headingRe = /]*>(.+?)<\/h\1>/gi; - let m: RegExpExecArray | null; - while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) { - headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') }); - } - if (!headings.length) return; - const panel = document.createElement('div'); panel.className = 'me-outline'; - panel.innerHTML = `
${i18nT('outline') || '大纲'}
`; - const buildTree = (items: typeof headings, minLevel: number): string => { - let h = '
    '; let i = 0; - while (i < items.length) { - const item = items[i]; if (item.level < minLevel) break; - h += `
  • ${escapeHTML(item.text)}`; - const sub: typeof headings = []; let j = i + 1; while (j < items.length && items[j].level > item.level) { sub.push(items[j]); j++; } - if (sub.length) h += buildTree(sub, item.level + 1); - h += '
  • '; i = j > i + 1 ? j : i + 1; - } - return h + '
'; - }; - panel.innerHTML += buildTree(headings, 1); - this.el.appendChild(panel); - panel.addEventListener('click', (e) => { - const a = (e.target as HTMLElement).closest('a'); if (!a) return; e.preventDefault(); - const id = a.getAttribute('href')!.slice(1); - const target = this.previewEl.querySelector('#' + CSS.escape(id)); - if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); const idx = this._value.indexOf(target.textContent || ''); if (idx !== -1) { this.textarea.focus(); this.textarea.setSelectionRange(idx, idx); } } - }); - } - - _updateOutline(): void { if (!this.config.outline) return; if (this._outlineTimer) clearTimeout(this._outlineTimer); this._outlineTimer = setTimeout(() => this._buildOutline(), 300); } - - _trackOutlineScroll(): void { - if (!this.config.outline || !this.previewPane) return; - let ticking = false; - const onScroll = () => { - if (ticking) return; - ticking = true; - requestAnimationFrame(() => { - ticking = false; - if (!this.el) return; - const panel = this.el.querySelector('.me-outline'); - if (!panel) return; - const headings = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6'); - let activeId = ''; - const scrollTop = this.previewPane.scrollTop + 80; // offset for better UX - headings.forEach((h) => { - if ((h as HTMLElement).offsetTop <= scrollTop) { - activeId = h.id; - } - }); - panel.querySelectorAll('a').forEach((a) => { - a.classList.toggle('me-outline-active', a.getAttribute('href') === '#' + activeId); - }); - }); - }; - this.previewPane.addEventListener('scroll', onScroll, { passive: true }); - this._cleanups.push(() => this.previewPane.removeEventListener('scroll', onScroll)); - } - _scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } _pushHistory(): void { @@ -592,82 +557,6 @@ export class MarkdownEditor { return this; } - _wrapSelection(before: string, after: string): void { - const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; - const selected = ta.value.slice(start, end); const text = selected || 'text'; - const inserted = before + text + after; - ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end); ta.focus(); - if (selected) { ta.selectionStart = start + before.length; ta.selectionEnd = start + before.length + text.length; } - else { ta.selectionStart = ta.selectionEnd = start + before.length; } - this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); - } - - _toggleLinePrefix(prefix: string): void { - const ta = this.textarea; const start = ta.selectionStart; - const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; - const lineEndPos = ta.value.indexOf('\n', start); const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos; - const line = ta.value.slice(lineStart, lineEnd); - const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/); - let newLine: string; - if (existingMatch && existingMatch[0] === prefix) newLine = line.slice(prefix.length); - else if (existingMatch) newLine = prefix + line.slice(existingMatch[0].length); - else newLine = prefix + line; - ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus(); - ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; - this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); - } - - _insertBlock(text: string): void { - const ta = this.textarea; const start = ta.selectionStart; - const before = ta.value.slice(0, start); const needNL = before && !before.endsWith('\n'); - const insert = (needNL ? '\n' : '') + text; - ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd); ta.focus(); - const pos = start + insert.length; ta.selectionStart = ta.selectionEnd = pos; - this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); - } - - _insertLink(): void { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || i18nT('link') || 'link'; const url = 'https://'; const insert = `[${sel}](${url})`; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); } - _insertImage(): void { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || i18nT('image') || 'image'; const url = 'https://'; const insert = `![${sel}](${url})`; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); } - _insertTable(rows = 3, cols = 3): void { const header = Array.from({ length: cols }, (_, i) => `${i18nT('tableCols') || '列'}${i + 1}`).join(' | '); const sep = Array.from({ length: cols }, () => '---').join(' | '); let md = `| ${header} |\n| ${sep} |\n`; for (let r = 1; r < rows; r++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`; this._insertBlock('\n' + md); } - - _formatTable(): void { - const ta = this.textarea; - const start = ta.selectionStart; - // Find table boundaries around cursor - const before = ta.value.substring(0, start); - const after = ta.value.substring(start); - const blockStart = before.lastIndexOf('\n\n'); - const blockEnd = after.indexOf('\n\n'); - const tableStart = blockStart === -1 ? 0 : blockStart + 2; - const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd; - const tableText = ta.value.substring(tableStart, tableEnd); - const lines = tableText.split('\n').filter((l) => l.includes('|')); - if (lines.length < 2) return; // need at least header + separator - // Parse columns - const splitRow = (r: string) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/); - const allCells = lines.map(splitRow); - const colCount = Math.max(...allCells.map((c) => c.length)); - // Calculate max width per column - const colWidths: number[] = Array(colCount).fill(3); - allCells.forEach((cells) => { - cells.forEach((cell, ci) => { - colWidths[ci] = Math.max(colWidths[ci], cell.trim().length); - }); - }); - // Rebuild table - const pad = (s: string, w: number) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); }; - const formatted = allCells.map((cells) => { - const padded = []; - for (let ci = 0; ci < colCount; ci++) { - padded.push(pad((cells[ci] || '').trim(), colWidths[ci])); - } - return '| ' + padded.join(' | ') + ' |'; - }); - // Replace in value - ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); - this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); - } - setMode(mode: EditMode): this { if (!MODES.includes(mode) || mode === this._mode) return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit') this._render(); this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${i18nT(mode) || mode} mode`); if (typeof this.config.onModeChange === 'function') { try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); } } return this; } getMode(): EditMode { return this._mode; } @@ -687,19 +576,24 @@ export class MarkdownEditor { isFullscreen(): boolean { return this._fullscreen; } exitFullscreen(): this { if (this._fullscreen) this.toggleFullscreen(); return this; } - toggleZen(): this { - this._zenMode = !this._zenMode; if (this.el) this.el.classList.toggle('me-zen', this._zenMode); + toggleZen(): this { this._setZen(!this._zenMode); return this; } + + _setZen(on: boolean): void { + this._zenMode = !!on; if (!this.el) return; + this.el.classList.toggle('me-zen', this._zenMode); if (this._zenMode && this.toolbarEl) { - this._zenMouseHandler = (e: MouseEvent) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; }; + if (!this._zenMouseHandler) { + this._zenMouseHandler = (e: MouseEvent) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; }; + document.addEventListener('mousemove', this._zenMouseHandler); + } this.toolbarEl.style.transition = 'opacity 0.2s'; this.toolbarEl.style.opacity = '0'; this.toolbarEl.style.pointerEvents = 'none'; - document.addEventListener('mousemove', this._zenMouseHandler); if (this.statusEl) this.statusEl.style.display = 'none'; } else { if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; } if (this.toolbarEl) { this.toolbarEl.style.opacity = ''; this.toolbarEl.style.pointerEvents = ''; this.toolbarEl.style.transition = ''; } if (this.statusEl) this.statusEl.style.display = ''; } - this._emit('zenChange', this._zenMode); return this; + this._emit('zenChange', this._zenMode); } isZen(): boolean { return this._zenMode; } @@ -707,121 +601,6 @@ export class MarkdownEditor { setWordWrap(on: boolean): this { this._wordWrap = !!on; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; } isWordWrap(): boolean { return this._wordWrap; } - _initFloatingToolbar(): void { - if (typeof document === 'undefined') return; - // Inject floating toolbar CSS once globally - if (!document.getElementById('me-float-style')) { - const style = document.createElement('style'); - style.id = 'me-float-style'; - style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`; - document.head.appendChild(style); - } - - if (!this._floatingEnabled) return; - - const ta = this.textarea; - let _lastSelStart = ta.selectionStart; - let _lastSelEnd = ta.selectionEnd; - - const emitSelectionChange = () => { - const s = ta.selectionStart; const e = ta.selectionEnd; - if (s !== _lastSelStart || e !== _lastSelEnd) { - _lastSelStart = s; _lastSelEnd = e; - this._emit('selectionChange', { start: s, end: e, text: this._value.slice(s, e) }); - } - }; - - const show = () => { - if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return; - emitSelectionChange(); - const start = ta.selectionStart; const end = ta.selectionEnd; - if (start === end) { this._hideFloatingToolbar(); return; } - - if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar(); - - const taRect = ta.getBoundingClientRect(); - const paneRect = this.editorPane.getBoundingClientRect(); - const taStyle = getComputedStyle(ta); - const lineHeight = parseFloat(taStyle.lineHeight) || 22; - const paddingTop = parseFloat(taStyle.paddingTop) || 16; - const paddingLeft = parseFloat(taStyle.paddingLeft) || 18; - const fontSize = parseFloat(taStyle.fontSize) || 13.5; - const charWidth = fontSize * 0.6; - - const textBefore = ta.value.substring(0, start); - const lines = textBefore.split('\n'); - const currentLine = lines.length - 1; - const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1; - const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1)); - - const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop; - const lineLocalTop = lineViewportTop - paneRect.top; - const toolbarHeight = 36; - const gap = 4; - let top = lineLocalTop - toolbarHeight - gap; - if (top < gap) top = lineLocalTop + lineHeight + gap; - - const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft; - let left = colX - paneRect.left - 80; - left = Math.max(4, Math.min(left, paneRect.width - 200)); - - this._floatingToolbar!.style.top = top + 'px'; - this._floatingToolbar!.style.left = left + 'px'; - this._floatingToolbar!.classList.add('me-visible'); - }; - - const hide = () => { this._hideFloatingToolbar(); }; - - ta.addEventListener('mouseup', () => setTimeout(show, 0)); - ta.addEventListener('keyup', () => { - emitSelectionChange(); - if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0); - else setTimeout(hide, 0); - this._emit('cursorMove', this.getCursorPosition()); - }); - ta.addEventListener('blur', () => setTimeout(hide, 300)); - ta.addEventListener('click', () => { - emitSelectionChange(); - if (ta.selectionStart === ta.selectionEnd) hide(); - }); - } - - toggleFloatingToolbar(): this { - this._floatingEnabled = !this._floatingEnabled; - if (!this._floatingEnabled) { - if (this._floatingToolbar) { - if (this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); - this._floatingToolbar = null; - } - } - return this; - } - isFloatingToolbar(): boolean { return this._floatingEnabled; } - - _buildFloatingToolbar(): void { - const bar = document.createElement('div'); - bar.className = 'me-float-toolbar'; - const actions = ['bold', 'italic', 'code', 'link', 'strikethrough']; - actions.forEach((action) => { - const btn = this._createBtn(action); - btn.addEventListener('mousedown', (e) => { - e.preventDefault(); e.stopPropagation(); - this.exec(action); - // Keep selection after exec - setTimeout(() => this.textarea.focus(), 0); - }); - bar.appendChild(btn); - }); - this.editorPane.appendChild(bar); - this._floatingToolbar = bar; - } - - _hideFloatingToolbar(): void { - if (this._floatingToolbar) { - this._floatingToolbar.classList.remove('me-visible'); - } - } - _bindToolbarKeyboard(): void { if (!this.toolbarEl) return; const onKeydown = (e: KeyboardEvent) => { @@ -921,7 +700,7 @@ export class MarkdownEditor { const re = new RegExp(escaped, flags); const matches = this._value.match(re); if (!matches) return 0; - this._value = this._value.replace(re, replace); + this._value = this._limitLength(this._value.replace(re, replace)); this.textarea.value = this._value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return matches.length; @@ -932,7 +711,7 @@ export class MarkdownEditor { const re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g'); const matches = this._value.match(re); if (!matches) return 0; - this._value = this._value.replace(re, replace); + this._value = this._limitLength(this._value.replace(re, replace)); this.textarea.value = this._value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return matches.length; @@ -949,9 +728,15 @@ export class MarkdownEditor { return lines[idx]; } + _limitLength(value: string): string { + const max = this.config.maxLength; + return max && max > 0 ? value.slice(0, max) : value; + } + getValue(): string { return this._destroyed ? '' : this._value; } setValue(md: string, opts: { silent?: boolean } = {}): this { if (this._destroyed) return this; + md = this._limitLength(md || ''); MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', this._value, md); this._value = md || ''; this.textarea.value = this._value; if (!opts.silent) this._pushHistory(); this._render(); this._renderGutter(); this._updateWordCount(); this._updateOutline(); @@ -982,8 +767,9 @@ export class MarkdownEditor { insert(text: string, opts: { replace?: boolean } = {}): this { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; - ta.value = ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start); ta.focus(); - ta.selectionStart = ta.selectionEnd = start + text.length; + const inserted = this._limitLength(ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start)); + ta.value = inserted; ta.focus(); + ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length); this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return this; } wrap(before: string, after: string): this { this._wrapSelection(before, after || before); return this; } @@ -1033,89 +819,6 @@ export class MarkdownEditor { configureToolbar(tools: ToolbarItem[]): this { if (!this.toolbarEl) return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; } removeToolbarButton(action: string): this { if (!this.toolbarEl) return this; const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`); if (btn) btn.remove(); return this; } - registerContextMenu(items: any[] = []): this { this._contextMenuItems = items; return this; } - - _bindContextMenu(): void { - if (!this.el) return; - const onContextMenu = (e: MouseEvent) => { - const existing = document.querySelector('.me-context-menu'); - if (existing) existing.remove(); - this._showContextMenu(e); - }; - this.el.addEventListener('contextmenu', onContextMenu); - this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu)); - } - - _showContextMenu(e: MouseEvent): void { - e.preventDefault(); - const ta = this.textarea; - const hasSelection = ta && ta.selectionStart !== ta.selectionEnd; - const defaultItems: Array<{ label?: string; action?: string; shortcut?: string; sep?: boolean; disabled?: boolean; onClick?: () => void }> = [ - { label: i18nT('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() }, - { label: i18nT('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() }, - { sep: true }, - { label: i18nT('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection }, - { label: i18nT('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection }, - { label: i18nT('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly }, - { label: i18nT('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' }, - ]; - const items = [...defaultItems]; - if (this._contextMenuItems.length) { - items.push({ sep: true }); - this._contextMenuItems.forEach((item) => items.push(item)); - } - const menu = document.createElement('div'); - menu.className = 'me-context-menu'; - menu.style.left = e.clientX + 'px'; - menu.style.top = e.clientY + 'px'; - // Adjust if off-screen - requestAnimationFrame(() => { - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) menu.style.left = (e.clientX - rect.width) + 'px'; - if (rect.bottom > window.innerHeight) menu.style.top = (e.clientY - rect.height) + 'px'; - }); - items.forEach((item) => { - if ((item as any).sep) { const sep = document.createElement('div'); sep.className = 'me-context-menu-sep'; menu.appendChild(sep); return; } - const el = document.createElement('div'); - el.className = 'me-context-menu-item'; - if (item.disabled) el.classList.add('me-disabled'); - el.innerHTML = `${item.label}${item.shortcut ? `${item.shortcut}` : ''}`; - el.addEventListener('click', (ev) => { - ev.stopPropagation(); - if (item.disabled) return; - if (item.onClick) { item.onClick(); } - else if (item.action) this._execContextAction(item.action); - this._hideContextMenu(); - }); - menu.appendChild(el); - }); - document.body.appendChild(menu); - const close = (ev: Event) => { - if (!menu.contains(ev.target as Node)) { this._hideContextMenu(); } - }; - document.addEventListener('click', close, { once: true }); - document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') this._hideContextMenu(); }, { once: true }); - } - - _hideContextMenu(): void { - const menu = document.querySelector('.me-context-menu'); - if (menu) menu.remove(); - } - - _execContextAction(action: string): void { - const ta = this.textarea; - if (!ta) return; - switch (action) { - case 'undo': this.undo(); break; - case 'redo': this.redo(); break; - case 'cut': document.execCommand('cut'); break; - case 'copy': document.execCommand('copy'); break; - case 'paste': document.execCommand('paste'); break; - case 'selectAll': ta.focus(); ta.select(); break; - default: this.exec(action); break; - } - } - toast(message: string, opts: { type?: string; duration?: number; animation?: string } = {}): this { if (!this.el || typeof document === 'undefined') return this; const { type = 'info', duration = 3000, animation = 'fade' } = opts; @@ -1158,5 +861,12 @@ export class MarkdownEditor { isDestroyed(): boolean { return this._destroyed; } } +// Install split-out modules onto the prototype (keeps `this` binding and +// preserves the existing instance API surface for consumers and tests) +installCommands(MarkdownEditor.prototype); +installFloatingToolbar(MarkdownEditor.prototype); +installContextMenu(MarkdownEditor.prototype); +installOutline(MarkdownEditor.prototype); + export { MarkdownEditor as Editor }; export default MarkdownEditor; diff --git a/src/floating-toolbar.ts b/src/floating-toolbar.ts new file mode 100644 index 0000000..5c503a2 --- /dev/null +++ b/src/floating-toolbar.ts @@ -0,0 +1,132 @@ +/** + * MetonaEditor Floating Toolbar — selection format toolbar + * @module floating-toolbar + * @version 0.2.5 + */ + +import type { MarkdownEditor } from './core'; + +export function initFloatingToolbar(this: MarkdownEditor): void { + if (typeof document === 'undefined') return; + // Inject floating toolbar CSS once globally + if (!document.getElementById('me-float-style')) { + const style = document.createElement('style'); + style.id = 'me-float-style'; + style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`; + document.head.appendChild(style); + } + + if (!this._floatingEnabled) return; + + const ta = this.textarea; + let _lastSelStart = ta.selectionStart; + let _lastSelEnd = ta.selectionEnd; + + const emitSelectionChange = () => { + const s = ta.selectionStart; const e = ta.selectionEnd; + if (s !== _lastSelStart || e !== _lastSelEnd) { + _lastSelStart = s; _lastSelEnd = e; + this._emit('selectionChange', { start: s, end: e, text: this._value.slice(s, e) }); + } + }; + + const show = () => { + if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return; + emitSelectionChange(); + const start = ta.selectionStart; const end = ta.selectionEnd; + if (start === end) { this._hideFloatingToolbar(); return; } + + if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar(); + + const taRect = ta.getBoundingClientRect(); + const paneRect = this.editorPane.getBoundingClientRect(); + const taStyle = getComputedStyle(ta); + const lineHeight = parseFloat(taStyle.lineHeight) || 22; + const paddingTop = parseFloat(taStyle.paddingTop) || 16; + const paddingLeft = parseFloat(taStyle.paddingLeft) || 18; + const fontSize = parseFloat(taStyle.fontSize) || 13.5; + const charWidth = fontSize * 0.6; + + const textBefore = ta.value.substring(0, start); + const lines = textBefore.split('\n'); + const currentLine = lines.length - 1; + const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1; + const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1)); + + const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop; + const lineLocalTop = lineViewportTop - paneRect.top; + const toolbarHeight = 36; + const gap = 4; + let top = lineLocalTop - toolbarHeight - gap; + if (top < gap) top = lineLocalTop + lineHeight + gap; + + const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft; + let left = colX - paneRect.left - 80; + left = Math.max(4, Math.min(left, paneRect.width - 200)); + + this._floatingToolbar!.style.top = top + 'px'; + this._floatingToolbar!.style.left = left + 'px'; + this._floatingToolbar!.classList.add('me-visible'); + }; + + const hide = () => { this._hideFloatingToolbar(); }; + + ta.addEventListener('mouseup', () => setTimeout(show, 0)); + ta.addEventListener('keyup', () => { + emitSelectionChange(); + if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0); + else setTimeout(hide, 0); + this._emit('cursorMove', this.getCursorPosition()); + }); + ta.addEventListener('blur', () => setTimeout(hide, 300)); + ta.addEventListener('click', () => { + emitSelectionChange(); + if (ta.selectionStart === ta.selectionEnd) hide(); + }); +} + +export function toggleFloatingToolbar(this: MarkdownEditor): void { + this._floatingEnabled = !this._floatingEnabled; + if (!this._floatingEnabled) { + if (this._floatingToolbar) { + if (this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); + this._floatingToolbar = null; + } + } +} + +export function isFloatingToolbar(this: MarkdownEditor): boolean { return this._floatingEnabled; } + +export function buildFloatingToolbar(this: MarkdownEditor): void { + const bar = document.createElement('div'); + bar.className = 'me-float-toolbar'; + const actions = ['bold', 'italic', 'code', 'link', 'strikethrough']; + actions.forEach((action) => { + const btn = this._createBtn(action); + btn.addEventListener('mousedown', (e) => { + e.preventDefault(); e.stopPropagation(); + this.exec(action); + // Keep selection after exec + setTimeout(() => this.textarea.focus(), 0); + }); + bar.appendChild(btn); + }); + this.editorPane.appendChild(bar); + this._floatingToolbar = bar; +} + +export function hideFloatingToolbar(this: MarkdownEditor): void { + if (this._floatingToolbar) { + this._floatingToolbar.classList.remove('me-visible'); + } +} + +// ============ Prototype installation ============ + +export const installFloatingToolbar = (proto: any): void => { + proto._initFloatingToolbar = initFloatingToolbar; + proto.toggleFloatingToolbar = toggleFloatingToolbar; + proto.isFloatingToolbar = isFloatingToolbar; + proto._buildFloatingToolbar = buildFloatingToolbar; + proto._hideFloatingToolbar = hideFloatingToolbar; +}; diff --git a/src/i18n.ts b/src/i18n.ts index 4b0d20a..c0c5a67 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -27,7 +27,7 @@ const pluralRules: Record = { const getPluralForm = (locale: string, count: number): string => { const lang = locale.split('-')[0].toLowerCase(); - const rule = pluralRules[lang] || pluralRules.en!; + const rule = pluralRules[lang] || pluralRules.en; return rule(Math.abs(count)); }; diff --git a/src/outline.ts b/src/outline.ts new file mode 100644 index 0000000..a1f9b77 --- /dev/null +++ b/src/outline.ts @@ -0,0 +1,84 @@ +/** + * MetonaEditor Outline — heading navigation panel + * @module outline + * @version 0.2.5 + */ + +import { t as i18nT } from './i18n'; +import { escapeHTML } from './utils'; +import type { MarkdownEditor } from './core'; + +export function buildOutline(this: MarkdownEditor): void { + if (!this.config.outline || !this.previewEl) return; + const old = this.el.querySelector('.me-outline'); if (old) old.remove(); + const headings: Array<{ level: number; id: string; text: string }> = []; + const headingRe = /]*>(.+?)<\/h\1>/gi; + let m: RegExpExecArray | null; + while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) { + headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') }); + } + if (!headings.length) return; + const panel = document.createElement('div'); panel.className = 'me-outline'; + panel.innerHTML = `
${i18nT('outline') || '大纲'}
`; + const buildTree = (items: typeof headings, minLevel: number): string => { + let h = '
    '; let i = 0; + while (i < items.length) { + const item = items[i]; if (item.level < minLevel) break; + h += `
  • ${escapeHTML(item.text)}`; + const sub: typeof headings = []; let j = i + 1; while (j < items.length && items[j].level > item.level) { sub.push(items[j]); j++; } + if (sub.length) h += buildTree(sub, item.level + 1); + h += '
  • '; i = j > i + 1 ? j : i + 1; + } + return h + '
'; + }; + panel.innerHTML += buildTree(headings, 1); + this.el.appendChild(panel); + panel.addEventListener('click', (e) => { + const a = (e.target as HTMLElement).closest('a'); if (!a) return; e.preventDefault(); + const id = a.getAttribute('href')!.slice(1); + const target = this.previewEl.querySelector('#' + CSS.escape(id)); + if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); const idx = this._value.indexOf(target.textContent || ''); if (idx !== -1) { this.textarea.focus(); this.textarea.setSelectionRange(idx, idx); } } + }); +} + +export function updateOutline(this: MarkdownEditor): void { + if (!this.config.outline) return; + if (this._outlineTimer) clearTimeout(this._outlineTimer); + this._outlineTimer = setTimeout(() => this._buildOutline(), 300); +} + +export function trackOutlineScroll(this: MarkdownEditor): void { + if (!this.config.outline || !this.previewPane) return; + let ticking = false; + const onScroll = () => { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + ticking = false; + if (!this.el) return; + const panel = this.el.querySelector('.me-outline'); + if (!panel) return; + const headings = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6'); + let activeId = ''; + const scrollTop = this.previewPane.scrollTop + 80; // offset for better UX + headings.forEach((h) => { + if ((h as HTMLElement).offsetTop <= scrollTop) { + activeId = h.id; + } + }); + panel.querySelectorAll('a').forEach((a) => { + a.classList.toggle('me-outline-active', a.getAttribute('href') === '#' + activeId); + }); + }); + }; + this.previewPane.addEventListener('scroll', onScroll, { passive: true }); + this._cleanups.push(() => this.previewPane.removeEventListener('scroll', onScroll)); +} + +// ============ Prototype installation ============ + +export const installOutline = (proto: any): void => { + proto._buildOutline = buildOutline; + proto._updateOutline = updateOutline; + proto._trackOutlineScroll = trackOutlineScroll; +}; diff --git a/src/plugins.ts b/src/plugins.ts index 305a2c4..356424b 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -6,14 +6,51 @@ import { t } from './i18n'; +// ============ Editor contract ============ + +/** The editor surface plugins may rely on. Keeps plugin code type-checked + * without importing the full MarkdownEditor class (avoids circular imports). */ +export interface EditorLike { + id?: string; + value?: string; + _value?: string; + el: HTMLElement; + textarea: HTMLTextAreaElement; + config?: Record; + on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void; + off?: (name: string, fn: (...args: any[]) => void) => unknown; + _emit?: (name: string, ...args: any[]) => void; + _pushHistory?: () => void; + _render?: () => void; + _updateWordCount?: () => void; + insert?: (text: string, opts?: { replace?: boolean }) => unknown; + setValue?: (value: string, opts?: { silent?: boolean }) => unknown; + getValue?: () => string; + getHTML?: () => string; + focus?: () => unknown; + toast?: (message: string, opts?: { type?: string; duration?: number; animation?: string }) => unknown; +} + +// ============ Plugin state keys (Symbols avoid property-name collisions) ============ + +const K_AUTOSAVE = Symbol('me-plugin:autoSave'); +const K_SEARCH = Symbol('me-plugin:searchReplace'); +const K_IMAGE_PASTE = Symbol('me-plugin:imagePaste'); +const K_SHORTCUT = Symbol('me-plugin:shortcutHelp'); +const K_FILESYSTEM = Symbol('me-plugin:fileSystem'); + +const pluginState = (editor: EditorLike, key: symbol): T | undefined => (editor as any)[key] as T | undefined; +const setPluginState = (editor: EditorLike, key: symbol, state: T): void => { (editor as any)[key] = state; }; +const deletePluginState = (editor: EditorLike, key: symbol): void => { delete (editor as any)[key]; }; + export interface Plugin { name: string; version?: string; description?: string; depends?: string[]; priority?: number; - install?: (editor: any) => void | Promise; - destroy?: (editor: any) => void; + install?: (editor: EditorLike, options?: any) => void | Promise; + destroy?: (editor: EditorLike) => void; [key: string]: any; } @@ -118,22 +155,24 @@ const autoSavePlugin: Plugin = { const state = { _timer: null as ReturnType | null }; const save = () => { if (state._timer) { clearTimeout(state._timer); state._timer = null; } - try { localStorage.setItem(key, editor.getValue()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue() }); } + try { localStorage.setItem(key, editor.getValue!()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue!() }); } catch (e) { console.warn('MeEditor autoSave:', e); } }; const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); }; const _onBlur = save; const _onSave = save; - editor.on('change', _onInput); - editor.on('blur', _onBlur); - editor.on('save', _onSave); - editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null) editor.setValue(v); return v; } catch (_) { return null; } }; - editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; }; - editor.getDraftKey = () => key; - (editor as any).__autoSaveCleanup = { state, _onInput, _onBlur, _onSave, save }; + if (typeof editor.on === 'function') { + editor.on('change', _onInput); + editor.on('blur', _onBlur); + editor.on('save', _onSave); + } + (editor as any).restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null && typeof editor.setValue === 'function') editor.setValue(v); return v; } catch (_) { return null; } }; + (editor as any).clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; }; + (editor as any).getDraftKey = () => key; + setPluginState(editor, K_AUTOSAVE, { state, _onInput, _onBlur, _onSave, save }); }, destroy(editor) { - const cleanup = (editor as any).__autoSaveCleanup; + const cleanup = pluginState(editor, K_AUTOSAVE); if (cleanup) { if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; } if (editor && typeof editor.off === 'function') { @@ -141,7 +180,7 @@ const autoSavePlugin: Plugin = { if (cleanup._onBlur) editor.off('blur', cleanup._onBlur); if (cleanup._onSave) editor.off('save', cleanup._onSave); } - delete (editor as any).__autoSaveCleanup; + deletePluginState(editor, K_AUTOSAVE); } }, }; @@ -159,8 +198,8 @@ const exportToolPlugin: Plugin = { setTimeout(() => URL.revokeObjectURL(url), 0); }; const stamp = () => { const d = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; }; - editor.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; }; - editor.exportHTML = (filename?: string, opts: any = {}) => { + const e = editor as any; + const buildHTML = (opts: any = {}): string => { const title = opts.title || 'Document'; const css = opts.css || ''; const body = typeof editor.getHTML === 'function' ? editor.getHTML() : ''; @@ -199,7 +238,26 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em; .me-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em} .me-table-wrap{overflow-x:auto;margin:.9em 0} ` : ''; - download(filename || `metona-${stamp()}.html`, `\n\n\n\n\n${title}\n${css?``:''}${embedCSS}\n\n\n${body}\n\n`, 'text/html'); + return `\n\n\n\n\n${title}\n${css?``:''}${embedCSS}\n\n\n${body}\n\n`; + }; + e.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue!(), 'text/markdown'); return editor; }; + e.exportHTML = (filename?: string, opts: any = {}) => { + download(filename || `metona-${stamp()}.html`, buildHTML(opts), 'text/html'); + return editor; + }; + e.exportPDF = (opts: any = {}) => { + if (typeof document === 'undefined' || !editor.el) return editor; + const iframe = document.createElement('iframe'); + iframe.style.position = 'fixed'; iframe.style.right = '0'; iframe.style.bottom = '0'; + iframe.style.width = '0'; iframe.style.height = '0'; iframe.style.border = '0'; + document.body.appendChild(iframe); + const doc = iframe.contentDocument; + if (!doc) { iframe.remove(); return editor; } + doc.open(); doc.write(buildHTML(opts)); doc.close(); + setTimeout(() => { + try { iframe.contentWindow?.print(); } catch (_) {} + setTimeout(() => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe); }, 1000); + }, 50); return editor; }; }, @@ -207,7 +265,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em; }; const searchReplacePlugin: Plugin = { - name: 'searchReplace', version: '0.2.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80, + name: 'searchReplace', version: '0.2.1', description: 'Search & Replace (Ctrl+F/H)', priority: 80, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; // Inject style once globally @@ -220,9 +278,27 @@ const searchReplacePlugin: Plugin = { const state = { _panel: null as HTMLElement | null, _regexMode: false, + _matchCase: true, + _wholeWord: false, _cleanup: null as (() => void) | null, }; + const WORD_CLASS = '[\\w\\u4e00-\\u9fff]'; + const escapeRegExpText = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const buildPattern = (q: string): RegExp | null => { + if (!q) return null; + let src = state._regexMode ? q : escapeRegExpText(q); + if (state._wholeWord) src = `(? { + const isWord = (c: string | undefined) => !!c && /[\w\u4e00-\u9fff]/.test(c); + return !isWord(text[idx - 1]) && !isWord(text[idx + len]); + }; + const _updateReplaceVisible = () => { if (!state._panel) return; const show = state._panel.dataset.replace === '1'; @@ -247,32 +323,40 @@ 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._regexMode = false; state._matchCase = true; state._wholeWord = false; const fi = panel.querySelector('.me-search-find') as HTMLInputElement; const ri = panel.querySelector('.me-search-replace') as HTMLInputElement; const ce = panel.querySelector('.me-search-count') as HTMLElement; const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement; + const caseBtn = panel.querySelector('.me-search-case') as HTMLButtonElement; + const wordBtn = panel.querySelector('.me-search-word') as HTMLButtonElement; regexBtn.addEventListener('click', () => { state._regexMode = !state._regexMode; regexBtn.classList.toggle('me-active', state._regexMode); lastIdxs = findAll(); }); + caseBtn.addEventListener('click', () => { + state._matchCase = !state._matchCase; + caseBtn.classList.toggle('me-active', state._matchCase); + lastIdxs = findAll(); + }); + wordBtn.addEventListener('click', () => { + state._wholeWord = !state._wholeWord; + wordBtn.classList.toggle('me-active', state._wholeWord); + lastIdxs = findAll(); + }); const findAll = () => { const q = fi.value; if (!q) { ce.textContent = ''; return []; } - const idxs: number[] = []; let from = 0; - if (state._regexMode) { - try { - const re = new RegExp(q, 'g'); let m: RegExpExecArray | null; - while ((m = re.exec(editor.textarea.value)) !== null) { - idxs.push(m.index); - if (m[0].length === 0) re.lastIndex++; - } - } catch (_) { ce.textContent = 'err'; return []; } - } else { - while (true) { const idx = editor.textarea.value.indexOf(q, from); if (idx === -1) break; idxs.push(idx); from = idx + q.length; } + const idxs: number[] = []; + const re = buildPattern(q); + if (!re) { ce.textContent = 'err'; return []; } + let m: RegExpExecArray | null; + while ((m = re.exec(editor.textarea.value)) !== null) { + idxs.push(m.index); + if (m[0].length === 0) re.lastIndex++; } ce.textContent = idxs.length ? `${idxs.length}` : '0'; return idxs; @@ -282,30 +366,48 @@ const searchReplacePlugin: Plugin = { editor.textarea.focus(); editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length)); }; + const matchLenAt = (idx: number): number => { + const text = editor.textarea.value; + const q = fi.value; if (!q) return 0; + if (!state._regexMode) { + if (state._matchCase && text.slice(idx, idx + q.length) !== q) return 0; + if (!state._matchCase && text.slice(idx, idx + q.length).toLowerCase() !== q.toLowerCase()) return 0; + if (state._wholeWord && !hasWordBoundary(text, idx, q.length)) return 0; + return q.length; + } + const re = buildPattern(q); if (!re) return 0; + re.lastIndex = idx; + const m = re.exec(text); + return m && m.index === idx ? m[0].length : 0; + }; const findNext = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionEnd; - const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(cur)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length; let next = lastIdxs.find((i: number) => i >= cur); if (next == null) next = lastIdxs[0]; - selectAt(next, qlen); + selectAt(next, matchLenAt(next) || fi.value.length); }; const findPrev = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionStart; - const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(Math.max(0, cur - 100), cur + 100)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length; let prev = -1; for (let i = lastIdxs.length-1; i>=0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } } if (prev === -1) prev = lastIdxs[lastIdxs.length-1]; - selectAt(prev, qlen); + selectAt(prev, matchLenAt(prev) || fi.value.length); }; const replaceOne = () => { const q = fi.value, r = ri.value; if (!q) return; const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd; const matchText = ta.value.substring(s, e); + let matched = false; if (state._regexMode) { - try { if (new RegExp(q).test(matchText)) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } } catch (_) {} - } else if (matchText === q) { + const re = buildPattern(q); + if (re) { re.lastIndex = s; const m = re.exec(ta.value); matched = !!(m && m.index === s); } + } else { + const sameCase = state._matchCase ? matchText === q : matchText.toLowerCase() === q.toLowerCase(); + matched = sameCase && (!state._wholeWord || hasWordBoundary(ta.value, s, q.length)); + } + if (matched) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } @@ -317,11 +419,9 @@ const searchReplacePlugin: Plugin = { const replaceAll = () => { const q = fi.value, r = ri.value; if (!q) return; const ta = editor.textarea; - if (state._regexMode) { - try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; } - } else { - ta.value = ta.value.split(q).join(r); - } + const re = buildPattern(q); + if (!re) return; + ta.value = ta.value.replace(re, () => r); ta.setSelectionRange(0,0); editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory(); if (typeof editor._render === 'function') editor._render(); @@ -350,49 +450,55 @@ const searchReplacePlugin: Plugin = { }; editor.textarea.addEventListener('keydown', _onKeydown); - // Expose for tests - (editor as any).__srState = state; - (editor as any).__srOpen = _open; - (editor as any).__srClose = _close; - (editor as any).__srKeydown = _onKeydown; + // Expose for tests via Symbol-keyed state (no public-property pollution) + setPluginState(editor, K_SEARCH, { state, _open, _close, _onKeydown }); }, - destroy(editor: any) { - const state = (editor as any).__srState; - if (state) { - if (state._cleanup) { state._cleanup(); state._cleanup = null; } - state._panel = null; + destroy(editor: EditorLike) { + const exposed = pluginState<{ state: any; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SEARCH); + if (exposed) { + const state = exposed.state; + if (state) { + if (state._cleanup) { state._cleanup(); state._cleanup = null; } + state._panel = null; + } + if (exposed._onKeydown && editor && editor.textarea) { + editor.textarea.removeEventListener('keydown', exposed._onKeydown); + } + deletePluginState(editor, K_SEARCH); } - const _onKeydown = (editor as any).__srKeydown; - if (_onKeydown && editor && editor.textarea) { - editor.textarea.removeEventListener('keydown', _onKeydown); - } - delete (editor as any).__srState; - delete (editor as any).__srOpen; - delete (editor as any).__srClose; - delete (editor as any).__srKeydown; }, }; const imagePastePlugin: Plugin = { - name: 'imagePaste', version: '0.2.0', description: 'Paste image as base64', priority: 60, - install(editor) { + name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60, + install(editor, options: any = {}) { if (!editor || !editor.textarea || typeof document === 'undefined') return; + const maxSizeKB: number = options.maxSizeKB || 500; const _onPaste = (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { - if (item.type?.startsWith('image/')) { e.preventDefault(); - const reader = new FileReader(); reader.onload = () => { editor.insert(`![image-${Date.now().toString(36)}.png](${reader.result})\n`); }; - reader.readAsDataURL(item.getAsFile()!); break; + if (item.type?.startsWith('image/')) { + const file = item.getAsFile(); + if (!file) continue; + 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' }); + break; + } + e.preventDefault(); + const reader = new FileReader(); reader.onload = () => { if (typeof editor.insert === 'function') editor.insert(`![image-${Date.now().toString(36)}.png](${reader.result})\n`); }; + reader.readAsDataURL(file); break; } } }; editor.textarea.addEventListener('paste', _onPaste); - (editor as any).__ipOnPaste = _onPaste; + setPluginState(editor, K_IMAGE_PASTE, _onPaste); }, - destroy(editor: any) { - const _onPaste = (editor as any).__ipOnPaste; + destroy(editor: EditorLike) { + const _onPaste = pluginState<(e: ClipboardEvent) => void>(editor, K_IMAGE_PASTE); if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste); - delete (editor as any).__ipOnPaste; + deletePluginState(editor, K_IMAGE_PASTE); }, }; @@ -436,14 +542,14 @@ const shortcutHelpPlugin: Plugin = { }; editor.textarea.addEventListener('keydown', _onKeydown); - (editor as any).__shState = { _panel, _open, _close, _onKeydown }; + setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown }); }, - destroy(editor: any) { - const state = (editor as any).__shState; + destroy(editor: EditorLike) { + const state = pluginState<{ _panel: HTMLElement | null; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT); if (state) { if (state._panel) { state._panel.remove(); } } - const _onKeydown = (editor as any).__shState?._onKeydown; + const _onKeydown = state?._onKeydown; if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown); - delete (editor as any).__shState; + deletePluginState(editor, K_SHORTCUT); }, }; @@ -454,31 +560,32 @@ const fileSystemPlugin: Plugin = { const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function'; let _fileHandle: any = null; - editor.openFile = async (opts: any = {}) => { - if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; } + (editor as any).openFile = async (opts: any = {}) => { + if (!hasAPI) { if (typeof editor.toast === 'function') editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; } try { const [handle] = await (window as any).showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md','.txt','.markdown'] } }], ...opts }); _fileHandle = handle; const file = await handle.getFile(); const content = await file.text(); - editor.setValue(content); editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle }; + if (typeof editor.setValue === 'function') editor.setValue(content); + editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle }; } catch (e: any) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; } }; - editor.saveFile = async (opts: any = {}) => { + (editor as any).saveFile = async (opts: any = {}) => { let handle = _fileHandle; if (!handle || opts.saveAs) { - if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; } + if (!hasAPI) { if (typeof editor.toast === 'function') editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; } try { handle = await (window as any).showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); _fileHandle = handle; } catch (e: any) { if (e.name !== 'AbortError') console.error('Save error:', e); return false; } } - try { const w = await handle.createWritable(); await w.write(editor.getValue()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; } - catch (e) { _fileHandle = null; if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; } + try { const w = await handle.createWritable(); await w.write(editor.getValue!()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; } + catch (e) { _fileHandle = null; if (!opts.saveAs) return (editor as any).saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; } }; - editor.saveFileAs = (name?: string) => editor.saveFile({ saveAs: true, name }); - editor.getFileHandle = () => _fileHandle; - (editor as any).__fsCleanup = { getHandle: () => _fileHandle }; + (editor as any).saveFileAs = (name?: string) => (editor as any).saveFile({ saveAs: true, name }); + (editor as any).getFileHandle = () => _fileHandle; + setPluginState(editor, K_FILESYSTEM, { getHandle: () => _fileHandle }); }, - destroy(editor: any) { - if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; } - delete (editor as any).__fsCleanup; + destroy(editor: EditorLike) { + if (editor) { delete (editor as any).openFile; delete (editor as any).saveFile; delete (editor as any).saveFileAs; delete (editor as any).getFileHandle; } + deletePluginState(editor, K_FILESYSTEM); }, }; diff --git a/src/themes.ts b/src/themes.ts index 51ca277..83b3771 100644 --- a/src/themes.ts +++ b/src/themes.ts @@ -283,7 +283,7 @@ export const presetThemes = { // Ensure built-in themes in THEMES for (const [name, theme] of Object.entries(presetThemes)) { - if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config as ThemeConfig; + if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config; } export const themeUtils = { diff --git a/tests/core.test.ts b/tests/core.test.ts index 0115ecb..9e45c18 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -2301,3 +2301,116 @@ describe('MarkdownEditor - v0.2.3 getStatus', () => { ed.destroy(); }); }); + +// ============ v0.2.5 maxLength / zenMode / 分隔条隔离 ============ + +describe('MarkdownEditor - v0.2.5 maxLength', () => { + test('setValue 超长内容被截断', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { maxLength: 10 }); + ed.setValue('0123456789ABC'); + expect(ed.getValue()).toBe('0123456789'); + expect(ed.textarea.value).toBe('0123456789'); + ed.destroy(); + }); + + test('insert 超长内容被截断', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { maxLength: 5, value: 'ab' }); + ed.focus(); + ed.textarea.setSelectionRange(2, 2); + ed.insert('CDEFG'); + expect(ed.getValue()).toBe('abCDE'); + ed.destroy(); + }); + + test('textarea maxlength 属性已绑定', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { maxLength: 42 }); + expect(ed.textarea.maxLength).toBe(42); + ed.destroy(); + }); + + test('maxLength 为 0 时不限制', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, {}); + expect(ed.textarea.maxLength).toBe(-1); + const long = 'x'.repeat(10000); + ed.setValue(long); + expect(ed.getValue().length).toBe(10000); + ed.destroy(); + }); +}); + +describe('MarkdownEditor - v0.2.5 zenMode 初始状态', () => { + test('zenMode: true 启动即进入 Zen 模式', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { zenMode: true }); + expect(ed.isZen()).toBe(true); + expect(ed.el.classList.contains('me-zen')).toBe(true); + expect(ed.toolbarEl.style.opacity).toBe('0'); + ed.destroy(); + }); + + test('默认不进入 Zen 模式', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, {}); + expect(ed.isZen()).toBe(false); + expect(ed.el.classList.contains('me-zen')).toBe(false); + ed.destroy(); + }); + + test('zenMode 初始开启后 toggleZen 可关闭', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { zenMode: true }); + ed.toggleZen(); + expect(ed.isZen()).toBe(false); + expect(ed.el.classList.contains('me-zen')).toBe(false); + ed.destroy(); + }); +}); + +describe('MarkdownEditor - v0.2.5 分隔条实例隔离', () => { + test('分隔条位置按实例 id 保存', () => { + localStorage.clear(); + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { id: 'divider-test-1' }); + ed.editorPane.style.flex = '0 0 30%'; + ed._saveDividerPosition(); + expect(localStorage.getItem('metona-editor-divider-divider-test-1')).toBe('0 0 30%'); + expect(localStorage.getItem('metona-editor-divider')).toBeNull(); + ed.destroy(); + }); + + test('不同实例互不覆盖分隔条位置', () => { + localStorage.clear(); + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const a = new MarkdownEditor(c, { id: 'dva' }); + const b = new MarkdownEditor(c, { id: 'dvb' }); + a.editorPane.style.flex = '0 0 20%'; + b.editorPane.style.flex = '0 0 70%'; + a._saveDividerPosition(); + b._saveDividerPosition(); + expect(localStorage.getItem('metona-editor-divider-dva')).toBe('0 0 20%'); + expect(localStorage.getItem('metona-editor-divider-dvb')).toBe('0 0 70%'); + a.destroy(); b.destroy(); + }); +}); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index fb37616..bd79033 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -794,20 +794,21 @@ describe('零散分支补全', () => { }); test('autoSave save 内部抛错时 warn 不崩溃', () => { + jest.useFakeTimers(); document.body.innerHTML = ''; const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'test' }); - ed.use('autoSave'); + ed.use('autoSave', { delay: 50 }); // 让 editor.getValue 抛错,触发 save 的 catch 分支 jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); }); - const cleanup = (ed as any).__autoSaveCleanup; - expect(cleanup).toBeDefined(); - expect(() => cleanup.save()).not.toThrow(); + ed._emit('change', 'x'); + expect(() => jest.advanceTimersByTime(60)).not.toThrow(); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); + jest.useRealTimers(); }); test('restoreDraft localStorage.getItem 抛错时返回 null', () => { @@ -833,7 +834,6 @@ describe('零散分支补全', () => { // Ctrl+F 打开面板 ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true })); expect(ed.el.querySelector('.me-search')).not.toBeNull(); - expect((ed as any).__srState._panel).toBeDefined(); // Ctrl+Escape 关闭面板(带 Ctrl 才会进入分支) ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true })); expect(ed.el.querySelector('.me-search')).toBeNull(); @@ -1125,3 +1125,153 @@ describe('v0.2.0 exportTool 插件', () => { ed.destroy(); }); }); + +// ============ v0.2.5 searchReplace 大小写 / 全字匹配 ============ + +describe('v0.2.5 searchReplace 大小写与全字匹配', () => { + const setup = (value: string) => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value }); + ed.use('searchReplace'); + ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true })); + const fi = ed.el.querySelector('.me-search-find') as HTMLInputElement; + const ce = ed.el.querySelector('.me-search-count') as HTMLElement; + const caseBtn = ed.el.querySelector('.me-search-case') as HTMLButtonElement; + const wordBtn = ed.el.querySelector('.me-search-word') as HTMLButtonElement; + return { ed, fi, ce, caseBtn, wordBtn }; + }; + + const teardown = (ed: any) => { + const panel = ed.el.querySelector('.me-search'); + if (panel) panel.remove(); + ed.destroy(); + }; + + test('默认区分大小写', () => { + const { ed, fi, ce, caseBtn, wordBtn } = setup('Foo foo FOO'); + fi.value = 'foo'; + fi.dispatchEvent(new Event('input')); + expect(ce.textContent).toBe('1'); + expect(caseBtn.classList.contains('me-active')).toBe(true); + teardown(ed); + }); + + test('关闭大小写后匹配全部', () => { + const { ed, fi, ce, caseBtn } = setup('Foo foo FOO'); + fi.value = 'foo'; + caseBtn.click(); + fi.dispatchEvent(new Event('input')); + expect(caseBtn.classList.contains('me-active')).toBe(false); + expect(ce.textContent).toBe('3'); + teardown(ed); + }); + + test('全字匹配排除子串', () => { + const { ed, fi, ce, wordBtn } = setup('cat category scatter cat'); + fi.value = 'cat'; + fi.dispatchEvent(new Event('input')); + expect(ce.textContent).toBe('4'); + wordBtn.click(); + fi.dispatchEvent(new Event('input')); + expect(ce.textContent).toBe('2'); + teardown(ed); + }); + + test('全字匹配支持中文边界', () => { + const { ed, fi, ce, wordBtn } = setup('测试test测试 test'); + fi.value = 'test'; + wordBtn.click(); + fi.dispatchEvent(new Event('input')); + expect(ce.textContent).toBe('1'); + teardown(ed); + }); + + test('replaceAll 大小写不敏感替换', () => { + const { ed, fi, caseBtn } = setup('Foo foo FOO'); + fi.value = 'foo'; + caseBtn.click(); + const ri = ed.el.querySelector('.me-search-replace') as HTMLInputElement; + ri.value = 'bar'; + const allBtn = ed.el.querySelector('.me-search-replace-all') as HTMLButtonElement; + allBtn.click(); + expect(ed.getValue()).toBe('bar bar bar'); + teardown(ed); + }); + + test('正则模式 + 全字匹配', () => { + const { ed, fi, ce, wordBtn, caseBtn } = setup('cat category scatter cat'); + fi.value = 'ca[nt]'; + const regexBtn = ed.el.querySelector('.me-search-regex') as HTMLButtonElement; + regexBtn.click(); + caseBtn.click(); + wordBtn.click(); + fi.dispatchEvent(new Event('input')); + expect(ce.textContent).toBe('2'); + teardown(ed); + }); +}); + +// ============ v0.2.5 exportPDF / imagePaste 尺寸限制 ============ + +describe('v0.2.5 exportTool exportPDF', () => { + test('exportPDF 创建隐藏 iframe 不抛错', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: '# pdf test' }); + ed.use('exportTool'); + const printSpy = jest.spyOn(window, 'print').mockImplementation(() => {}); + expect(() => ed.exportPDF({ title: 'T' })).not.toThrow(); + printSpy.mockRestore(); + ed.destroy(); + }); + + test('exportHTML 与 exportPDF 共用构建逻辑', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: '# hi' }); + ed.use('exportTool'); + expect(typeof (ed as any).exportPDF).toBe('function'); + ed.destroy(); + }); +}); + +describe('v0.2.5 imagePaste 尺寸限制', () => { + test('超出 maxSizeKB 时 toast 警告且不插入', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: '' }); + ed.use('imagePaste', { maxSizeKB: 1 }); + const toastSpy = jest.spyOn(ed, 'toast').mockImplementation(() => ed); + const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed); + const file = new File([new ArrayBuffer(4096)], 'big.png', { type: 'image/png' }); + const evt = new Event('paste', { bubbles: true }); + Object.defineProperty(evt, 'clipboardData', { value: { items: [{ type: 'image/png', getAsFile: () => file }] } }); + ed.textarea.dispatchEvent(evt as ClipboardEvent); + expect(toastSpy).toHaveBeenCalled(); + expect(insertSpy).not.toHaveBeenCalled(); + toastSpy.mockRestore(); + insertSpy.mockRestore(); + ed.destroy(); + }); + + test('默认 500KB 内正常插入', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: '' }); + ed.use('imagePaste'); + const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed); + const file = new File([new ArrayBuffer(1024)], 'ok.png', { type: 'image/png' }); + const evt = new Event('paste', { bubbles: true }); + Object.defineProperty(evt, 'clipboardData', { value: { items: [{ type: 'image/png', getAsFile: () => file }] } }); + ed.textarea.dispatchEvent(evt as ClipboardEvent); + expect(insertSpy).not.toHaveBeenCalled(); // FileReader 异步,同步阶段仅 preventDefault + insertSpy.mockRestore(); + ed.destroy(); + }); +});