refactor(core): 拆分 4 模块 + 插件状态 Symbol 化 + eslint type-aware
- 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
This commit is contained in:
+4
-1
@@ -9,7 +9,8 @@
|
|||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": 2020,
|
"ecmaVersion": 2020,
|
||||||
"sourceType": "module"
|
"sourceType": "module",
|
||||||
|
"project": "./tsconfig.json"
|
||||||
},
|
},
|
||||||
"plugins": ["@typescript-eslint"],
|
"plugins": ["@typescript-eslint"],
|
||||||
"extends": [
|
"extends": [
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
"no-console": "warn",
|
"no-console": "warn",
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
"@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-undef": "off",
|
||||||
"no-empty": "off",
|
"no-empty": "off",
|
||||||
"no-useless-escape": "off",
|
"no-useless-escape": "off",
|
||||||
|
|||||||
+123
@@ -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 = ``;
|
||||||
|
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;
|
||||||
|
};
|
||||||
+3
-3
@@ -104,9 +104,9 @@ export const DEFAULT_TOOLBAR: ToolbarItem[] = [
|
|||||||
export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
|
export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
|
||||||
value: '',
|
value: '',
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
mode: 'split' as EditMode,
|
mode: 'split',
|
||||||
height: 400,
|
height: 400,
|
||||||
toolbar: DEFAULT_TOOLBAR as ToolbarItem[] | false,
|
toolbar: DEFAULT_TOOLBAR,
|
||||||
wordCount: true,
|
wordCount: true,
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
spellcheck: false,
|
spellcheck: false,
|
||||||
@@ -121,7 +121,7 @@ export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
|
|||||||
zenMode: false,
|
zenMode: false,
|
||||||
wordWrap: true,
|
wordWrap: true,
|
||||||
maxLength: 0,
|
maxLength: 0,
|
||||||
theme: 'auto' as ThemeName,
|
theme: 'auto',
|
||||||
locale: 'zh-CN',
|
locale: 'zh-CN',
|
||||||
render: null,
|
render: null,
|
||||||
highlight: null,
|
highlight: null,
|
||||||
|
|||||||
@@ -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 = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`;
|
||||||
|
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;
|
||||||
|
};
|
||||||
+61
-351
@@ -13,6 +13,10 @@ import type { InstanceI18n } from './i18n';
|
|||||||
import { parseMarkdown } from './parser';
|
import { parseMarkdown } from './parser';
|
||||||
import { presetPlugins, topologicalSort } from './plugins';
|
import { presetPlugins, topologicalSort } from './plugins';
|
||||||
import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes';
|
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 MODES: EditMode[] = ['edit', 'split', 'preview'];
|
||||||
const TOAST_ICONS: Record<string, string> = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
|
const TOAST_ICONS: Record<string, string> = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
|
||||||
@@ -85,15 +89,37 @@ export class MarkdownEditor {
|
|||||||
_floatingToolbar!: HTMLElement | null;
|
_floatingToolbar!: HTMLElement | null;
|
||||||
_floatingEnabled!: boolean;
|
_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 = {}) {
|
constructor(container: string | HTMLElement, options: EditorOptions = {}) {
|
||||||
if (!isBrowser()) {
|
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.id = options.id || generateId();
|
||||||
this.container = typeof container === 'string' ? document.querySelector(container) as HTMLElement : container;
|
this.container = typeof container === 'string' ? document.querySelector(container) as HTMLElement : container;
|
||||||
if (!this.container) {
|
if (!this.container) {
|
||||||
console.error('MeEditor: container not found:', container);
|
console.error('MeEditor: container not found:', container);
|
||||||
this._destroyed = true; return this as any;
|
this._destroyed = true; return this;
|
||||||
}
|
}
|
||||||
this.config = { ...DEFAULTS, ...options };
|
this.config = { ...DEFAULTS, ...options };
|
||||||
if (options.style && typeof options.style === 'object') {
|
if (options.style && typeof options.style === 'object') {
|
||||||
@@ -105,7 +131,7 @@ export class MarkdownEditor {
|
|||||||
this._listeners = {}; this._renderRaf = null; this._historyTimer = null;
|
this._listeners = {}; this._renderRaf = null; this._historyTimer = null;
|
||||||
this._fullscreen = false; this._destroyed = false; this._lastRenderedValue = null;
|
this._fullscreen = false; this._destroyed = false; this._lastRenderedValue = null;
|
||||||
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
|
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._zenMouseHandler = null;
|
||||||
this._floatingToolbar = null;
|
this._floatingToolbar = null;
|
||||||
this._floatingEnabled = this.config.floatingToolbar !== false;
|
this._floatingEnabled = this.config.floatingToolbar !== false;
|
||||||
@@ -138,6 +164,7 @@ export class MarkdownEditor {
|
|||||||
this._pushHistory();
|
this._pushHistory();
|
||||||
this._render();
|
this._render();
|
||||||
this._updateWordCount();
|
this._updateWordCount();
|
||||||
|
if (this._zenMode) this._setZen(true);
|
||||||
|
|
||||||
if (Array.isArray(this.config.plugins)) {
|
if (Array.isArray(this.config.plugins)) {
|
||||||
const plugins = this.config.plugins.map((p: any) => {
|
const plugins = this.config.plugins.map((p: any) => {
|
||||||
@@ -179,6 +206,7 @@ export class MarkdownEditor {
|
|||||||
editorInner.appendChild(gutter);
|
editorInner.appendChild(gutter);
|
||||||
const textarea = document.createElement('textarea'); textarea.className = 'me-textarea';
|
const textarea = document.createElement('textarea'); textarea.className = 'me-textarea';
|
||||||
textarea.spellcheck = !!this.config.spellcheck; textarea.readOnly = !!this.config.readOnly;
|
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.style.tabSize = String(this.config.tabSize || 2);
|
||||||
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
|
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
|
||||||
textarea.setAttribute('aria-label', i18nT('edit'));
|
textarea.setAttribute('aria-label', i18nT('edit'));
|
||||||
@@ -371,13 +399,13 @@ export class MarkdownEditor {
|
|||||||
_saveDividerPosition(): void {
|
_saveDividerPosition(): void {
|
||||||
try {
|
try {
|
||||||
const epFlex = this.editorPane.style.flex;
|
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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
_restoreDividerPosition(): void {
|
_restoreDividerPosition(): void {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('metona-editor-divider');
|
const saved = localStorage.getItem(`metona-editor-divider-${this.id}`);
|
||||||
if (saved && this._mode === 'split') {
|
if (saved && this._mode === 'split') {
|
||||||
this.editorPane.style.flex = saved;
|
this.editorPane.style.flex = saved;
|
||||||
this.previewPane.style.flex = '1 1 auto';
|
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); });
|
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-6])\s+id="([^"]+)"[^>]*>(.+?)<\/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 = `<div class="me-outline-title">${i18nT('outline') || '大纲'}</div>`;
|
|
||||||
const buildTree = (items: typeof headings, minLevel: number): string => {
|
|
||||||
let h = '<ul>'; let i = 0;
|
|
||||||
while (i < items.length) {
|
|
||||||
const item = items[i]; if (item.level < minLevel) break;
|
|
||||||
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
|
|
||||||
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 += '</li>'; i = j > i + 1 ? j : i + 1;
|
|
||||||
}
|
|
||||||
return h + '</ul>';
|
|
||||||
};
|
|
||||||
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); }
|
_scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
|
||||||
|
|
||||||
_pushHistory(): void {
|
_pushHistory(): void {
|
||||||
@@ -592,82 +557,6 @@ export class MarkdownEditor {
|
|||||||
return this;
|
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 = ``; 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; }
|
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; }
|
getMode(): EditMode { return this._mode; }
|
||||||
|
|
||||||
@@ -687,19 +576,24 @@ export class MarkdownEditor {
|
|||||||
isFullscreen(): boolean { return this._fullscreen; }
|
isFullscreen(): boolean { return this._fullscreen; }
|
||||||
exitFullscreen(): this { if (this._fullscreen) this.toggleFullscreen(); return this; }
|
exitFullscreen(): this { if (this._fullscreen) this.toggleFullscreen(); return this; }
|
||||||
|
|
||||||
toggleZen(): this {
|
toggleZen(): this { this._setZen(!this._zenMode); return this; }
|
||||||
this._zenMode = !this._zenMode; if (this.el) this.el.classList.toggle('me-zen', this._zenMode);
|
|
||||||
|
_setZen(on: boolean): void {
|
||||||
|
this._zenMode = !!on; if (!this.el) return;
|
||||||
|
this.el.classList.toggle('me-zen', this._zenMode);
|
||||||
if (this._zenMode && this.toolbarEl) {
|
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';
|
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';
|
if (this.statusEl) this.statusEl.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; }
|
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.toolbarEl) { this.toolbarEl.style.opacity = ''; this.toolbarEl.style.pointerEvents = ''; this.toolbarEl.style.transition = ''; }
|
||||||
if (this.statusEl) this.statusEl.style.display = '';
|
if (this.statusEl) this.statusEl.style.display = '';
|
||||||
}
|
}
|
||||||
this._emit('zenChange', this._zenMode); return this;
|
this._emit('zenChange', this._zenMode);
|
||||||
}
|
}
|
||||||
isZen(): boolean { return 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; }
|
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; }
|
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 {
|
_bindToolbarKeyboard(): void {
|
||||||
if (!this.toolbarEl) return;
|
if (!this.toolbarEl) return;
|
||||||
const onKeydown = (e: KeyboardEvent) => {
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
@@ -921,7 +700,7 @@ export class MarkdownEditor {
|
|||||||
const re = new RegExp(escaped, flags);
|
const re = new RegExp(escaped, flags);
|
||||||
const matches = this._value.match(re);
|
const matches = this._value.match(re);
|
||||||
if (!matches) return 0;
|
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.textarea.value = this._value;
|
||||||
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
|
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
|
||||||
return matches.length;
|
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 re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
|
||||||
const matches = this._value.match(re);
|
const matches = this._value.match(re);
|
||||||
if (!matches) return 0;
|
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.textarea.value = this._value;
|
||||||
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
|
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
|
||||||
return matches.length;
|
return matches.length;
|
||||||
@@ -949,9 +728,15 @@ export class MarkdownEditor {
|
|||||||
return lines[idx];
|
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; }
|
getValue(): string { return this._destroyed ? '' : this._value; }
|
||||||
setValue(md: string, opts: { silent?: boolean } = {}): this {
|
setValue(md: string, opts: { silent?: boolean } = {}): this {
|
||||||
if (this._destroyed) return this;
|
if (this._destroyed) return this;
|
||||||
|
md = this._limitLength(md || '');
|
||||||
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', this._value, md);
|
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', this._value, md);
|
||||||
this._value = md || ''; this.textarea.value = this._value;
|
this._value = md || ''; this.textarea.value = this._value;
|
||||||
if (!opts.silent) this._pushHistory(); this._render(); this._renderGutter(); this._updateWordCount(); this._updateOutline();
|
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 {
|
insert(text: string, opts: { replace?: boolean } = {}): this {
|
||||||
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
|
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();
|
const inserted = this._limitLength(ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start));
|
||||||
ta.selectionStart = ta.selectionEnd = start + text.length;
|
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;
|
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; }
|
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; }
|
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; }
|
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 = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`;
|
|
||||||
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 {
|
toast(message: string, opts: { type?: string; duration?: number; animation?: string } = {}): this {
|
||||||
if (!this.el || typeof document === 'undefined') return this;
|
if (!this.el || typeof document === 'undefined') return this;
|
||||||
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
|
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
|
||||||
@@ -1158,5 +861,12 @@ export class MarkdownEditor {
|
|||||||
isDestroyed(): boolean { return this._destroyed; }
|
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 { MarkdownEditor as Editor };
|
||||||
export default MarkdownEditor;
|
export default MarkdownEditor;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
+1
-1
@@ -27,7 +27,7 @@ const pluralRules: Record<string, PluralRule> = {
|
|||||||
|
|
||||||
const getPluralForm = (locale: string, count: number): string => {
|
const getPluralForm = (locale: string, count: number): string => {
|
||||||
const lang = locale.split('-')[0].toLowerCase();
|
const lang = locale.split('-')[0].toLowerCase();
|
||||||
const rule = pluralRules[lang] || pluralRules.en!;
|
const rule = pluralRules[lang] || pluralRules.en;
|
||||||
return rule(Math.abs(count));
|
return rule(Math.abs(count));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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-6])\s+id="([^"]+)"[^>]*>(.+?)<\/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 = `<div class="me-outline-title">${i18nT('outline') || '大纲'}</div>`;
|
||||||
|
const buildTree = (items: typeof headings, minLevel: number): string => {
|
||||||
|
let h = '<ul>'; let i = 0;
|
||||||
|
while (i < items.length) {
|
||||||
|
const item = items[i]; if (item.level < minLevel) break;
|
||||||
|
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
|
||||||
|
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 += '</li>'; i = j > i + 1 ? j : i + 1;
|
||||||
|
}
|
||||||
|
return h + '</ul>';
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
};
|
||||||
+192
-85
@@ -6,14 +6,51 @@
|
|||||||
|
|
||||||
import { t } from './i18n';
|
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<string, any>;
|
||||||
|
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 = <T>(editor: EditorLike, key: symbol): T | undefined => (editor as any)[key] as T | undefined;
|
||||||
|
const setPluginState = <T>(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 {
|
export interface Plugin {
|
||||||
name: string;
|
name: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
depends?: string[];
|
depends?: string[];
|
||||||
priority?: number;
|
priority?: number;
|
||||||
install?: (editor: any) => void | Promise<void>;
|
install?: (editor: EditorLike, options?: any) => void | Promise<void>;
|
||||||
destroy?: (editor: any) => void;
|
destroy?: (editor: EditorLike) => void;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,22 +155,24 @@ const autoSavePlugin: Plugin = {
|
|||||||
const state = { _timer: null as ReturnType<typeof setTimeout> | null };
|
const state = { _timer: null as ReturnType<typeof setTimeout> | null };
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (state._timer) { clearTimeout(state._timer); state._timer = null; }
|
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); }
|
catch (e) { console.warn('MeEditor autoSave:', e); }
|
||||||
};
|
};
|
||||||
const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); };
|
const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); };
|
||||||
const _onBlur = save;
|
const _onBlur = save;
|
||||||
const _onSave = save;
|
const _onSave = save;
|
||||||
editor.on('change', _onInput);
|
if (typeof editor.on === 'function') {
|
||||||
editor.on('blur', _onBlur);
|
editor.on('change', _onInput);
|
||||||
editor.on('save', _onSave);
|
editor.on('blur', _onBlur);
|
||||||
editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null) editor.setValue(v); return v; } catch (_) { return null; } };
|
editor.on('save', _onSave);
|
||||||
editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; };
|
}
|
||||||
editor.getDraftKey = () => key;
|
(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).__autoSaveCleanup = { state, _onInput, _onBlur, _onSave, save };
|
(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) {
|
destroy(editor) {
|
||||||
const cleanup = (editor as any).__autoSaveCleanup;
|
const cleanup = pluginState<any>(editor, K_AUTOSAVE);
|
||||||
if (cleanup) {
|
if (cleanup) {
|
||||||
if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; }
|
if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; }
|
||||||
if (editor && typeof editor.off === 'function') {
|
if (editor && typeof editor.off === 'function') {
|
||||||
@@ -141,7 +180,7 @@ const autoSavePlugin: Plugin = {
|
|||||||
if (cleanup._onBlur) editor.off('blur', cleanup._onBlur);
|
if (cleanup._onBlur) editor.off('blur', cleanup._onBlur);
|
||||||
if (cleanup._onSave) editor.off('save', cleanup._onSave);
|
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);
|
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())}`; };
|
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; };
|
const e = editor as any;
|
||||||
editor.exportHTML = (filename?: string, opts: any = {}) => {
|
const buildHTML = (opts: any = {}): string => {
|
||||||
const title = opts.title || 'Document';
|
const title = opts.title || 'Document';
|
||||||
const css = opts.css || '';
|
const css = opts.css || '';
|
||||||
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
|
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-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em}
|
||||||
.me-table-wrap{overflow-x:auto;margin:.9em 0}
|
.me-table-wrap{overflow-x:auto;margin:.9em 0}
|
||||||
</style>` : '';
|
</style>` : '';
|
||||||
download(filename || `metona-${stamp()}.html`, `<!DOCTYPE html>\n<html lang="${opts.lang||'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css?`<style>${css}</style>`:''}${embedCSS}\n</head>\n<body>\n${body}\n</body>\n</html>`, 'text/html');
|
return `<!DOCTYPE html>\n<html lang="${opts.lang||'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css?`<style>${css}</style>`:''}${embedCSS}\n</head>\n<body>\n${body}\n</body>\n</html>`;
|
||||||
|
};
|
||||||
|
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;
|
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 = {
|
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) {
|
install(editor) {
|
||||||
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
||||||
// Inject style once globally
|
// Inject style once globally
|
||||||
@@ -220,9 +278,27 @@ const searchReplacePlugin: Plugin = {
|
|||||||
const state = {
|
const state = {
|
||||||
_panel: null as HTMLElement | null,
|
_panel: null as HTMLElement | null,
|
||||||
_regexMode: false,
|
_regexMode: false,
|
||||||
|
_matchCase: true,
|
||||||
|
_wholeWord: false,
|
||||||
_cleanup: null as (() => void) | null,
|
_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 = `(?<!${WORD_CLASS})(?:${src})(?!${WORD_CLASS})`;
|
||||||
|
const flags = 'g' + (state._matchCase ? '' : 'i');
|
||||||
|
try { return new RegExp(src, flags); } catch (_) { return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasWordBoundary = (text: string, idx: number, len: number): boolean => {
|
||||||
|
const isWord = (c: string | undefined) => !!c && /[\w\u4e00-\u9fff]/.test(c);
|
||||||
|
return !isWord(text[idx - 1]) && !isWord(text[idx + len]);
|
||||||
|
};
|
||||||
|
|
||||||
const _updateReplaceVisible = () => {
|
const _updateReplaceVisible = () => {
|
||||||
if (!state._panel) return;
|
if (!state._panel) return;
|
||||||
const show = state._panel.dataset.replace === '1';
|
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 selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
|
||||||
const panel = document.createElement('div'); panel.className = 'me-search';
|
const panel = document.createElement('div'); panel.className = 'me-search';
|
||||||
panel.dataset.replace = showReplace ? '1' : '0';
|
panel.dataset.replace = showReplace ? '1' : '0';
|
||||||
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-regex" title="Regex">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
|
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="Regex">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
|
||||||
editor.el.appendChild(panel); state._panel = panel;
|
editor.el.appendChild(panel); state._panel = panel;
|
||||||
_updateReplaceVisible();
|
_updateReplaceVisible();
|
||||||
state._regexMode = false;
|
state._regexMode = false; state._matchCase = true; state._wholeWord = false;
|
||||||
const fi = panel.querySelector('.me-search-find') as HTMLInputElement;
|
const fi = panel.querySelector('.me-search-find') as HTMLInputElement;
|
||||||
const ri = panel.querySelector('.me-search-replace') as HTMLInputElement;
|
const ri = panel.querySelector('.me-search-replace') as HTMLInputElement;
|
||||||
const ce = panel.querySelector('.me-search-count') as HTMLElement;
|
const ce = panel.querySelector('.me-search-count') as HTMLElement;
|
||||||
const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement;
|
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', () => {
|
regexBtn.addEventListener('click', () => {
|
||||||
state._regexMode = !state._regexMode;
|
state._regexMode = !state._regexMode;
|
||||||
regexBtn.classList.toggle('me-active', state._regexMode);
|
regexBtn.classList.toggle('me-active', state._regexMode);
|
||||||
lastIdxs = findAll();
|
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 findAll = () => {
|
||||||
const q = fi.value; if (!q) { ce.textContent = ''; return []; }
|
const q = fi.value; if (!q) { ce.textContent = ''; return []; }
|
||||||
const idxs: number[] = []; let from = 0;
|
const idxs: number[] = [];
|
||||||
if (state._regexMode) {
|
const re = buildPattern(q);
|
||||||
try {
|
if (!re) { ce.textContent = 'err'; return []; }
|
||||||
const re = new RegExp(q, 'g'); let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = re.exec(editor.textarea.value)) !== null) {
|
while ((m = re.exec(editor.textarea.value)) !== null) {
|
||||||
idxs.push(m.index);
|
idxs.push(m.index);
|
||||||
if (m[0].length === 0) re.lastIndex++;
|
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; }
|
|
||||||
}
|
}
|
||||||
ce.textContent = idxs.length ? `${idxs.length}` : '0';
|
ce.textContent = idxs.length ? `${idxs.length}` : '0';
|
||||||
return idxs;
|
return idxs;
|
||||||
@@ -282,30 +366,48 @@ const searchReplacePlugin: Plugin = {
|
|||||||
editor.textarea.focus();
|
editor.textarea.focus();
|
||||||
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
|
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 = () => {
|
const findNext = () => {
|
||||||
lastIdxs = findAll(); if (!lastIdxs.length) return;
|
lastIdxs = findAll(); if (!lastIdxs.length) return;
|
||||||
const cur = editor.textarea.selectionEnd;
|
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);
|
let next = lastIdxs.find((i: number) => i >= cur);
|
||||||
if (next == null) next = lastIdxs[0];
|
if (next == null) next = lastIdxs[0];
|
||||||
selectAt(next, qlen);
|
selectAt(next, matchLenAt(next) || fi.value.length);
|
||||||
};
|
};
|
||||||
const findPrev = () => {
|
const findPrev = () => {
|
||||||
lastIdxs = findAll(); if (!lastIdxs.length) return;
|
lastIdxs = findAll(); if (!lastIdxs.length) return;
|
||||||
const cur = editor.textarea.selectionStart;
|
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;
|
let prev = -1;
|
||||||
for (let i = lastIdxs.length-1; i>=0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } }
|
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];
|
if (prev === -1) prev = lastIdxs[lastIdxs.length-1];
|
||||||
selectAt(prev, qlen);
|
selectAt(prev, matchLenAt(prev) || fi.value.length);
|
||||||
};
|
};
|
||||||
const replaceOne = () => {
|
const replaceOne = () => {
|
||||||
const q = fi.value, r = ri.value; if (!q) return;
|
const q = fi.value, r = ri.value; if (!q) return;
|
||||||
const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd;
|
const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd;
|
||||||
const matchText = ta.value.substring(s, e);
|
const matchText = ta.value.substring(s, e);
|
||||||
|
let matched = false;
|
||||||
if (state._regexMode) {
|
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 (_) {}
|
const re = buildPattern(q);
|
||||||
} else if (matchText === 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.value = ta.value.substring(0, s) + r + ta.value.substring(e);
|
||||||
ta.setSelectionRange(s, s + r.length);
|
ta.setSelectionRange(s, s + r.length);
|
||||||
}
|
}
|
||||||
@@ -317,11 +419,9 @@ const searchReplacePlugin: Plugin = {
|
|||||||
const replaceAll = () => {
|
const replaceAll = () => {
|
||||||
const q = fi.value, r = ri.value; if (!q) return;
|
const q = fi.value, r = ri.value; if (!q) return;
|
||||||
const ta = editor.textarea;
|
const ta = editor.textarea;
|
||||||
if (state._regexMode) {
|
const re = buildPattern(q);
|
||||||
try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; }
|
if (!re) return;
|
||||||
} else {
|
ta.value = ta.value.replace(re, () => r);
|
||||||
ta.value = ta.value.split(q).join(r);
|
|
||||||
}
|
|
||||||
ta.setSelectionRange(0,0); editor._value = ta.value;
|
ta.setSelectionRange(0,0); editor._value = ta.value;
|
||||||
if (typeof editor._pushHistory === 'function') editor._pushHistory();
|
if (typeof editor._pushHistory === 'function') editor._pushHistory();
|
||||||
if (typeof editor._render === 'function') editor._render();
|
if (typeof editor._render === 'function') editor._render();
|
||||||
@@ -350,49 +450,55 @@ const searchReplacePlugin: Plugin = {
|
|||||||
};
|
};
|
||||||
editor.textarea.addEventListener('keydown', _onKeydown);
|
editor.textarea.addEventListener('keydown', _onKeydown);
|
||||||
|
|
||||||
// Expose for tests
|
// Expose for tests via Symbol-keyed state (no public-property pollution)
|
||||||
(editor as any).__srState = state;
|
setPluginState(editor, K_SEARCH, { state, _open, _close, _onKeydown });
|
||||||
(editor as any).__srOpen = _open;
|
|
||||||
(editor as any).__srClose = _close;
|
|
||||||
(editor as any).__srKeydown = _onKeydown;
|
|
||||||
},
|
},
|
||||||
destroy(editor: any) {
|
destroy(editor: EditorLike) {
|
||||||
const state = (editor as any).__srState;
|
const exposed = pluginState<{ state: any; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SEARCH);
|
||||||
if (state) {
|
if (exposed) {
|
||||||
if (state._cleanup) { state._cleanup(); state._cleanup = null; }
|
const state = exposed.state;
|
||||||
state._panel = null;
|
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 = {
|
const imagePastePlugin: Plugin = {
|
||||||
name: 'imagePaste', version: '0.2.0', description: 'Paste image as base64', priority: 60,
|
name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60,
|
||||||
install(editor) {
|
install(editor, options: any = {}) {
|
||||||
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
||||||
|
const maxSizeKB: number = options.maxSizeKB || 500;
|
||||||
const _onPaste = (e: ClipboardEvent) => {
|
const _onPaste = (e: ClipboardEvent) => {
|
||||||
const items = e.clipboardData?.items; if (!items) return;
|
const items = e.clipboardData?.items; if (!items) return;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.type?.startsWith('image/')) { e.preventDefault();
|
if (item.type?.startsWith('image/')) {
|
||||||
const reader = new FileReader(); reader.onload = () => { editor.insert(`\n`); };
|
const file = item.getAsFile();
|
||||||
reader.readAsDataURL(item.getAsFile()!); break;
|
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(`\n`); };
|
||||||
|
reader.readAsDataURL(file); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
editor.textarea.addEventListener('paste', _onPaste);
|
editor.textarea.addEventListener('paste', _onPaste);
|
||||||
(editor as any).__ipOnPaste = _onPaste;
|
setPluginState(editor, K_IMAGE_PASTE, _onPaste);
|
||||||
},
|
},
|
||||||
destroy(editor: any) {
|
destroy(editor: EditorLike) {
|
||||||
const _onPaste = (editor as any).__ipOnPaste;
|
const _onPaste = pluginState<(e: ClipboardEvent) => void>(editor, K_IMAGE_PASTE);
|
||||||
if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste);
|
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.textarea.addEventListener('keydown', _onKeydown);
|
||||||
|
|
||||||
(editor as any).__shState = { _panel, _open, _close, _onKeydown };
|
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown });
|
||||||
},
|
},
|
||||||
destroy(editor: any) {
|
destroy(editor: EditorLike) {
|
||||||
const state = (editor as any).__shState;
|
const state = pluginState<{ _panel: HTMLElement | null; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT);
|
||||||
if (state) { if (state._panel) { state._panel.remove(); } }
|
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);
|
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';
|
const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function';
|
||||||
let _fileHandle: any = null;
|
let _fileHandle: any = null;
|
||||||
|
|
||||||
editor.openFile = async (opts: any = {}) => {
|
(editor as any).openFile = async (opts: any = {}) => {
|
||||||
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; }
|
if (!hasAPI) { if (typeof editor.toast === 'function') editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; }
|
||||||
try {
|
try {
|
||||||
const [handle] = await (window as any).showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md','.txt','.markdown'] } }], ...opts });
|
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();
|
_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; }
|
} 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;
|
let handle = _fileHandle;
|
||||||
if (!handle || opts.saveAs) {
|
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; }
|
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; }
|
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; }
|
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; }
|
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 as any).saveFileAs = (name?: string) => (editor as any).saveFile({ saveAs: true, name });
|
||||||
editor.getFileHandle = () => _fileHandle;
|
(editor as any).getFileHandle = () => _fileHandle;
|
||||||
(editor as any).__fsCleanup = { getHandle: () => _fileHandle };
|
setPluginState(editor, K_FILESYSTEM, { getHandle: () => _fileHandle });
|
||||||
},
|
},
|
||||||
destroy(editor: any) {
|
destroy(editor: EditorLike) {
|
||||||
if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; }
|
if (editor) { delete (editor as any).openFile; delete (editor as any).saveFile; delete (editor as any).saveFileAs; delete (editor as any).getFileHandle; }
|
||||||
delete (editor as any).__fsCleanup;
|
deletePluginState(editor, K_FILESYSTEM);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -283,7 +283,7 @@ export const presetThemes = {
|
|||||||
|
|
||||||
// Ensure built-in themes in THEMES
|
// Ensure built-in themes in THEMES
|
||||||
for (const [name, theme] of Object.entries(presetThemes)) {
|
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 = {
|
export const themeUtils = {
|
||||||
|
|||||||
@@ -2301,3 +2301,116 @@ describe('MarkdownEditor - v0.2.3 getStatus', () => {
|
|||||||
ed.destroy();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+155
-5
@@ -794,20 +794,21 @@ describe('零散分支补全', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('autoSave save 内部抛错时 warn 不崩溃', () => {
|
test('autoSave save 内部抛错时 warn 不崩溃', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
const c = document.createElement('div');
|
const c = document.createElement('div');
|
||||||
document.body.appendChild(c);
|
document.body.appendChild(c);
|
||||||
const ed = new MarkdownEditor(c, { value: 'test' });
|
const ed = new MarkdownEditor(c, { value: 'test' });
|
||||||
ed.use('autoSave');
|
ed.use('autoSave', { delay: 50 });
|
||||||
// 让 editor.getValue 抛错,触发 save 的 catch 分支
|
// 让 editor.getValue 抛错,触发 save 的 catch 分支
|
||||||
jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); });
|
jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); });
|
||||||
const cleanup = (ed as any).__autoSaveCleanup;
|
ed._emit('change', 'x');
|
||||||
expect(cleanup).toBeDefined();
|
expect(() => jest.advanceTimersByTime(60)).not.toThrow();
|
||||||
expect(() => cleanup.save()).not.toThrow();
|
|
||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
ed.destroy();
|
ed.destroy();
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restoreDraft localStorage.getItem 抛错时返回 null', () => {
|
test('restoreDraft localStorage.getItem 抛错时返回 null', () => {
|
||||||
@@ -833,7 +834,6 @@ describe('零散分支补全', () => {
|
|||||||
// Ctrl+F 打开面板
|
// Ctrl+F 打开面板
|
||||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
|
||||||
expect(ed.el.querySelector('.me-search')).not.toBeNull();
|
expect(ed.el.querySelector('.me-search')).not.toBeNull();
|
||||||
expect((ed as any).__srState._panel).toBeDefined();
|
|
||||||
// Ctrl+Escape 关闭面板(带 Ctrl 才会进入分支)
|
// Ctrl+Escape 关闭面板(带 Ctrl 才会进入分支)
|
||||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true }));
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true }));
|
||||||
expect(ed.el.querySelector('.me-search')).toBeNull();
|
expect(ed.el.querySelector('.me-search')).toBeNull();
|
||||||
@@ -1125,3 +1125,153 @@ describe('v0.2.0 exportTool 插件', () => {
|
|||||||
ed.destroy();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user