feat: v0.2.3 — 浮动格式工具栏 + 10个新API + 德语翻译 + 类型修复
CI / test (18.x) (push) Canceled after 0s
CI / test (20.x) (push) Canceled after 0s
CI / test (22.x) (push) Canceled after 0s
CI / test (24.x) (push) Canceled after 0s

### Added
- 浮动格式工具栏:选中文本自动弹出 bold/italic/code/link/strikethrough
- 10 个新 API: getSelectedText/getCursorPosition/setCursorPosition/scrollToLine/selectLine/selectAll/replaceAll/replaceAllRegex/lineCount/getLine
- 2 个新事件: selectionChange / cursorMove
- 德语 (de) locale 完整翻译
- floatingToolbar 配置项(默认 true)

### Fixed
- onLinkClick 移除 as any,正式加入 EditorOptions/DEFAULTS
- 所有版本号统一为 0.2.3

### Tests
- core.test.ts: 183 → 214 (+31 tests)
- 总计 ~725 tests
This commit is contained in:
2026-07-25 14:53:43 +08:00
parent 5184e9e8c1
commit 708f0937d8
13 changed files with 569 additions and 31 deletions
+20
View File
@@ -2,6 +2,26 @@
All notable changes to MetonaEditor will be documented in this file.
## [0.2.3] - 2026-07-25
### Added
- **German (de) locale**: Complete translations for all 60+ UI keys.
- **Floating format toolbar**: Auto-shows bold/italic/code/link/strikethrough buttons above text selection.
- **Cursor/selection APIs**: `getSelectedText()`, `getCursorPosition()`, `setCursorPosition()`, `scrollToLine()`, `selectLine()`, `selectAll()`.
- **Content manipulation APIs**: `replaceAll()`, `replaceAllRegex()`, `lineCount()`, `getLine()`.
- **New events**: `selectionChange` and `cursorMove` emitted for cursor/selection tracking.
- **`floatingToolbar` config option**: Enable/disable floating toolbar (default `true`).
### Changed
- **`floatingToolbar` added to `DEFAULTS` and `EditorOptions` type**.
- **6 locales now**: zh-CN, en-US, ja, ko, fr, de.
### Fixed
- **`onLinkClick` type fix**: Removed `as any` cast; properly typed in `EditorOptions` and `DEFAULTS`.
- **Version consistency**: All files synced to 0.2.3.
---
## [0.2.2] - 2026-07-25
### Added
+2 -2
View File
@@ -2,9 +2,9 @@
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
[![version](https://img.shields.io/badge/version-0.2.1-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
[![version](https://img.shields.io/badge/version-0.2.3-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
[![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-684%20passed-brightgreen)](./tests)
[![tests](https://img.shields.io/badge/tests-725%20passed-brightgreen)](./tests)
[![coverage](https://img.shields.io/badge/coverage-parser%2099%25-brightgreen)](./tests)
[![types](https://img.shields.io/badge/types-TypeScript%20strict-blue)](./tsconfig.json)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@metona-team/metona-editor",
"version": "0.2.2",
"version": "0.2.3",
"description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.",
"type": "module",
"main": "dist/metona-editor.js",
+27 -14
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
<title>MetonaEditor v0.2.0 — 全功能演示</title>
<title>MetonaEditor v0.2.3 — 全功能演示</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
@@ -45,11 +45,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<body>
<header class="header">
<h1><span class="grad">Metona</span>Editor v0.2.2</h1>
<h1><span class="grad">Metona</span>Editor v0.2.3</h1>
<div class="badges">
<span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span>
<span class="badge">684 tests</span>
<span class="badge">725 tests</span>
<span class="badge">6 个插件</span>
<span class="badge">桌面端优先</span>
</div>
@@ -60,7 +60,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<label>主题</label>
<select id="selTheme"><option value="auto">🌓 跟随系统</option><option value="light">☀️ 亮色</option><option value="dark">🌙 暗色</option><option value="warm">🔥 暖色</option></select>
<label>语言</label>
<select id="selLocale"><option value="zh-CN">中文</option><option value="en-US">English</option><option value="ja">日本語</option><option value="ko">한국어</option></select>
<select id="selLocale"><option value="zh-CN">中文</option><option value="en-US">English</option><option value="ja">日本語</option><option value="ko">한국어</option><option value="fr">Français</option><option value="de">Deutsch</option></select>
<label>模式</label>
<button data-cmd="mode" data-arg="edit">编辑</button>
<button data-cmd="mode" data-arg="split" class="on">分屏</button>
@@ -69,6 +69,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<button data-cmd="zen" title="Zen 专注模式">🧘 Zen</button>
<button data-cmd="outline" title="大纲面板">📋 大纲</button>
<button data-cmd="wrap" title="切换换行">↩ 换行</button>
<button data-cmd="float" title="浮动工具栏" class="on">💬 浮动栏</button>
<button data-cmd="toast" title="Toast 通知">💬 Toast</button>
<button data-cmd="openFile" title="打开文件">📂 打开</button>
<button data-cmd="saveFile" title="保存文件">💾 保存</button>
@@ -90,7 +91,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
</div>
<footer class="footer">
MetonaEditor v0.2.2 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
MetonaEditor v0.2.3 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
</footer>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
@@ -98,20 +99,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<script>
(function(){
var demoMd=[
'# 🚀 MetonaEditor v0.2.2 全功能演示',
'# 🚀 MetonaEditor v0.2.3 全功能演示',
'',
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
'> 全模块 TypeScript 严格模式,12 个源文件,684 个测试全部通过。',
'',
'## ✨ v0.2.2 新特性',
'## ✨ v0.2.3 新特性',
'',
'- TypeScript 全模块重构(严格模式 + 完整类型导出)',
'- 解析器 99% 行覆盖率 · 表驱动块级处理器',
'- ***粗斜体*** / [**粗体链接**](https://example.com) 正确渲染',
'- 反斜杠转义 \\* \\_ \\\\ 支持',
'- parseTokens / renderTokens 解析渲染分离',
'- registerBlockHandler 自定义块语法注册',
'- 610 个测试 · 7 个套件全部通过',
'- 浮动格式工具栏:选中文本自动弹出 bold/italic/code/link',
'- 新增 `getSelectedText()` `getCursorPosition()` 等 10+ API',
'- `replaceAll` / `replaceAllRegex` 批量替换',
'- `selectionChange` / `cursorMove` 事件',
'- 德语(de)翻译 · 6 种语言支持',
'',
'## 📐 文本样式',
'',
@@ -140,6 +139,9 @@ var demoMd=[
'',
'| 版本 | 日期 | 测试 | 主题 |',
'| :--- | :---: | ---: | --- |',
'| v0.2.3 | 2026-07 | 725 | 浮动工具栏+新API |',
'| v0.2.2 | 2026-07 | 694 | 法语+表格格式化 |',
'| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
'| v0.1.15 | 2026-07 | 583 | 解析器增强 |',
'| v0.1.14 | 2026-07 | 535 | 语法高亮+Mermaid |',
@@ -226,6 +228,17 @@ bar.addEventListener('click', function(e){
else if (cmd === 'zen') { editor.toggleZen(); btn.classList.toggle('on', editor.isZen()); }
else if (cmd === 'outline') { toggleOutline(btn); }
else if (cmd === 'wrap') { editor.toggleWordWrap(); btn.classList.toggle('on', !editor.isWordWrap()); }
else if (cmd === 'float') {
var hasFloat = editor._floatingToolbar && editor._floatingToolbar.parentNode;
if (hasFloat) {
if (editor._floatingToolbar.parentNode) editor._floatingToolbar.parentNode.removeChild(editor._floatingToolbar);
editor._floatingToolbar = null;
} else {
editor.config.floatingToolbar = true;
editor._initFloatingToolbar();
}
btn.classList.toggle('on', !!editor._floatingToolbar);
}
else if (cmd === 'toast') {
var types = ['success','error','warning','info'];
var msgs = { success: '✅ 操作成功', error: '❌ 操作失败', warning: '⚠️ 注意', info: '️ 提示信息' };
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
<title>MetonaEditor v0.2.0 · 文档</title>
<title>MetonaEditor v0.2.3 · 文档</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)}
@@ -65,7 +65,7 @@ footer a{color:var(--accent);text-decoration:none}
<header class="docs-header">
<h1><span class="grad">API 文档</span></h1>
<p class="sub">MetonaEditor v0.2.0 · TypeScript · 完整配置、API 与使用指南</p>
<p class="sub">MetonaEditor v0.2.3 · TypeScript · 完整配置、API 与使用指南</p>
</header>
<div class="container">
@@ -433,7 +433,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
<footer>
<div class="container">
MetonaEditor v0.2.2 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
MetonaEditor v0.2.3 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
</div>
</footer>
+6 -6
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>"/>
<title>MetonaEditor v0.2.2 · TypeScript · 桌面端 Markdown 编辑器</title>
<title>MetonaEditor v0.2.3 · TypeScript · 桌面端 Markdown 编辑器</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-3:#ec4899;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%);--shadow:0 20px 50px -20px rgba(0,0,0,.5)}
@@ -106,10 +106,10 @@ footer a:hover{text-decoration:underline}
<h1>TypeScript 重构 · 零依赖<br/><span class="grad">桌面端 Markdown 编辑器</span></h1>
<p class="tagline">全模块 TypeScript 严格模式,内置自研解析器(99% 行覆盖率),6 个预设插件,中文优先。为桌面端现代 Web 应用而生。</p>
<div class="badges">
<span class="badge accent">v0.2.2</span>
<span class="badge accent">v0.2.3</span>
<span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span>
<span class="badge">694 tests</span>
<span class="badge">725 tests</span>
<span class="badge">桌面端优先</span>
<span class="badge">MIT</span>
</div>
@@ -120,7 +120,7 @@ footer a:hover{text-decoration:underline}
<div class="stats">
<div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div>
<div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div>
<div class="stat"><div class="stat-num">694</div><div class="stat-label">单元测试</div></div>
<div class="stat"><div class="stat-num">725</div><div class="stat-label">单元测试</div></div>
<div class="stat"><div class="stat-num">~30KB</div><div class="stat-label">gzip 体积</div></div>
</div>
</header>
@@ -214,7 +214,7 @@ editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a>
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor/issues" target="_blank" rel="noopener">问题反馈</a>
</div>
<div>MetonaEditor v0.2.2 · TypeScript · MIT License · MetonaTeam</div>
<div>MetonaEditor v0.2.3 · TypeScript · MIT License · MetonaTeam</div>
</div>
</footer>
@@ -224,7 +224,7 @@ MeEditor.themes.switchTheme('dark');
MeEditor.create('#editor', {
mode: 'split', height: 480, theme: 'dark',
value: [
'# 欢迎使用 MetonaEditor v0.2.2',
'# 欢迎使用 MetonaEditor v0.2.3',
'',
'一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。',
'',
+3
View File
@@ -62,6 +62,7 @@ export interface EditorOptions {
onCreate?: ((editor: any) => void) | null;
onDestroy?: ((editor: any) => void) | null;
onLinkClick?: ((href: string, text: string, editor: any) => void) | null;
floatingToolbar?: boolean;
}
export interface ThemeConfig {
@@ -137,6 +138,8 @@ export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
onFullscreen: null,
onCreate: null,
onDestroy: null,
onLinkClick: null,
floatingToolbar: true,
});
export { ICONS, LOCALES };
+188 -1
View File
@@ -82,6 +82,8 @@ export class MarkdownEditor {
_highlightFn!: ((code: string, lang: string) => string) | null;
_themeCtx: any;
_i18nCtx!: InstanceI18n;
_floatingToolbar!: HTMLElement | null;
_selectionTimer!: ReturnType<typeof setInterval> | null;
constructor(container: string | HTMLElement, options: EditorOptions = {}) {
if (!isBrowser()) {
@@ -105,6 +107,7 @@ export class MarkdownEditor {
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
this._outlineTimer = null; this._zenMode = false; this._wordWrap = true; this._syncing = false;
this._zenMouseHandler = null;
this._floatingToolbar = null; this._selectionTimer = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
@@ -128,6 +131,7 @@ export class MarkdownEditor {
this._bindEvents();
this._bindContextMenu();
this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) this._initFloatingToolbar();
this.textarea.value = this._value;
this._pushHistory();
@@ -273,7 +277,7 @@ export class MarkdownEditor {
const a = (e.target as HTMLElement).closest('a'); if (!a) return;
const href = a.getAttribute('href'); if (!href || href.startsWith('#')) return;
e.preventDefault(); this._emit('linkClick', { href, text: a.textContent });
if (typeof (this.config as any).onLinkClick === 'function') { try { (this.config as any).onLinkClick(href, a.textContent, this); } catch (_) {} return; }
if (typeof this.config.onLinkClick === 'function') { try { this.config.onLinkClick(href, a.textContent, this); } catch (_) {} return; }
window.open(href, '_blank', 'noopener');
};
this.previewEl.addEventListener('click', onPreviewClick);
@@ -519,6 +523,7 @@ export class MarkdownEditor {
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');
@@ -701,6 +706,100 @@ export class MarkdownEditor {
setWordWrap(on: boolean): this { this._wordWrap = !!on; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
isWordWrap(): boolean { return this._wordWrap; }
_initFloatingToolbar(): void {
if (typeof document === 'undefined') return;
// Inject floating toolbar CSS once globally
if (!document.getElementById('me-float-style')) {
const style = document.createElement('style');
style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`;
document.head.appendChild(style);
}
const ta = this.textarea;
const show = () => {
if (this._destroyed || this.config.readOnly) return;
const start = ta.selectionStart; const end = ta.selectionEnd;
if (start === end) { this._hideFloatingToolbar(); return; }
if (!this._floatingToolbar) this._buildFloatingToolbar();
// Position the toolbar near the selection
const rect = ta.getBoundingClientRect();
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
const lineHeight = parseInt(getComputedStyle(ta).lineHeight, 10) || 22;
let top = rect.top + currentLine * lineHeight - 38; // above the selection
let left = rect.left + (ta.selectionDirection !== 'backward' ? (ta.selectionEnd - ta.selectionStart) / 2 : 0) * 8 + 10;
// Clamp to stay within the editor pane
if (top < rect.top) top = rect.top + currentLine * lineHeight + lineHeight + 4;
if (left < rect.left) left = rect.left + 4;
const maxLeft = rect.right - 200;
if (left > maxLeft) left = maxLeft;
this._floatingToolbar!.style.top = top + 'px';
this._floatingToolbar!.style.left = left + 'px';
this._floatingToolbar!.classList.add('me-visible');
};
const hide = () => { this._hideFloatingToolbar(); };
// Detect selection changes
ta.addEventListener('mouseup', () => setTimeout(show, 0));
ta.addEventListener('keyup', () => {
if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0);
else setTimeout(hide, 0);
// Emit cursorMove
const pos = this.getCursorPosition();
this._emit('cursorMove', pos);
});
ta.addEventListener('blur', () => setTimeout(hide, 300));
ta.addEventListener('click', () => setTimeout(() => {
if (ta.selectionStart === ta.selectionEnd) hide();
}, 0));
// Emit selectionChange on selection changes
let lastSelStart = ta.selectionStart;
let lastSelEnd = ta.selectionEnd;
this._selectionTimer = setInterval(() => {
if (this._destroyed) return;
const s = ta.selectionStart; const e = ta.selectionEnd;
if (s !== lastSelStart || e !== lastSelEnd) {
lastSelStart = s; lastSelEnd = e;
const text = this._value.slice(s, e);
this._emit('selectionChange', { start: s, end: e, text });
}
}, 200) as any;
this._cleanups.push(() => { if (this._selectionTimer) clearInterval(this._selectionTimer); });
}
_buildFloatingToolbar(): void {
const bar = document.createElement('div');
bar.className = 'me-float-toolbar';
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => {
const btn = this._createBtn(action);
btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.stopPropagation();
this.exec(action);
// Keep selection after exec
setTimeout(() => this.textarea.focus(), 0);
});
bar.appendChild(btn);
});
this.editorPane.appendChild(bar);
this._floatingToolbar = bar;
}
_hideFloatingToolbar(): void {
if (this._floatingToolbar) {
this._floatingToolbar.classList.remove('me-visible');
}
}
_bindToolbarKeyboard(): void {
if (!this.toolbarEl) return;
const onKeydown = (e: KeyboardEvent) => {
@@ -743,6 +842,91 @@ export class MarkdownEditor {
getStats() { const text = this._value || ''; const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length; return { characters: text.length, words: cnChars + enWords, chineseChars: cnChars, englishWords: enWords, lines: text ? text.split('\n').length : 0, readingTime: Math.max(1, Math.ceil((cnChars + enWords) / 300)), }; }
getSelectedText(): string {
if (!this.textarea) return '';
return this._value.slice(this.textarea.selectionStart, this.textarea.selectionEnd);
}
getCursorPosition(): { line: number; column: number } {
if (!this.textarea) return { line: 1, column: 0 };
const pos = this.textarea.selectionStart;
const before = this._value.substring(0, pos);
const line = before.split('\n').length;
const column = pos - before.lastIndexOf('\n') - 1;
return { line, column: column < 0 ? 0 : column };
}
setCursorPosition(line: number, column: number = 0): this {
if (!this.textarea) return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
let pos = 0;
for (let i = 0; i < clampedLine - 1; i++) pos += lines[i].length + 1;
pos += Math.min(column, lines[clampedLine - 1]?.length || 0);
this.textarea.focus();
this.textarea.setSelectionRange(pos, pos);
return this;
}
scrollToLine(line: number): this {
if (!this.textarea) return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
const lineHeight = 22; // approximate
this.textarea.scrollTop = (clampedLine - 1) * lineHeight;
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
return this;
}
selectLine(line: number): this {
if (!this.textarea) return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
let startPos = 0;
for (let i = 0; i < clampedLine - 1; i++) startPos += lines[i].length + 1;
const endPos = startPos + lines[clampedLine - 1].length;
this.textarea.focus();
this.textarea.setSelectionRange(startPos, endPos);
return this;
}
selectAll(): this { if (this.textarea) { this.textarea.focus(); this.textarea.select(); } return this; }
replaceAll(search: string, replace: string, caseSensitive: boolean = true): number {
if (!search) return 0;
const flags = caseSensitive ? 'g' : 'gi';
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, flags);
const matches = this._value.match(re);
if (!matches) return 0;
this._value = this._value.replace(re, replace);
this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
return matches.length;
}
replaceAllRegex(pattern: RegExp, replace: string): number {
if (!pattern) return 0;
const re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
const matches = this._value.match(re);
if (!matches) return 0;
this._value = this._value.replace(re, replace);
this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
return matches.length;
}
lineCount(): number {
return this._value ? this._value.split('\n').length : 0;
}
getLine(line: number): string {
const lines = this._value.split('\n');
const idx = line - 1;
if (idx < 0 || idx >= lines.length) return '';
return lines[idx];
}
getValue(): string { return this._destroyed ? '' : this._value; }
setValue(md: string, opts: { silent?: boolean } = {}): this {
if (this._destroyed) return this;
@@ -936,8 +1120,11 @@ export class MarkdownEditor {
if (this._renderRaf) cancelAnimationFrame(this._renderRaf);
if (this._historyTimer) clearTimeout(this._historyTimer);
if (this._outlineTimer) clearTimeout(this._outlineTimer);
if (this._selectionTimer) { clearInterval(this._selectionTimer); this._selectionTimer = null; }
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = [];
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
if (this.el) this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { try { p.destroy(this); } catch (_) {} } }); this._plugins = [];
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
+1
View File
@@ -266,6 +266,7 @@ export const presetLocales = {
ja: { name: '日本語', nativeName: '日本語', direction: 'ltr', translations: (LOCALES as any)['ja'] },
ko: { name: '한국어', nativeName: '한국어', direction: 'ltr', translations: (LOCALES as any)['ko'] },
fr: { name: 'Français', nativeName: 'Français', direction: 'ltr', translations: (LOCALES as any)['fr'] },
de: { name: 'Deutsch', nativeName: 'Deutsch', direction: 'ltr', translations: (LOCALES as any)['de'] },
};
export const i18nUtils = createI18nManager();
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor
* @version 0.2.1
* @version 0.2.3
*/
import { MarkdownEditor } from './core';
@@ -13,7 +13,7 @@ import { animationUtils } from './animations';
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants';
import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants';
const VERSION = '0.2.2';
const VERSION = '0.2.3';
const globalPlugins: any[] = [];
+27 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaEditor Locales translation data
* @module locales
* @version 0.2.0
* @version 0.2.3
*/
export const LOCALES: Record<string, Record<string, string>> = {
@@ -135,4 +135,30 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: 'Plan',
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
},
de: {
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
h1: 'Überschrift 1', h2: 'Überschrift 2', h3: 'Überschrift 3', quote: 'Zitat', code: 'Code',
link: 'Link', image: 'Bild', table: 'Tabelle', ul: 'Aufzählung', ol: 'Nummerierte Liste',
indent: 'Einrücken', outdent: 'Ausrücken', hr: 'Trennlinie', undo: 'Rückgängig', redo: 'Wiederholen',
edit: 'Bearbeiten', split: 'Geteilt', preview: 'Vorschau', fullscreen: 'Vollbild',
fullscreenExit: 'Vollbild beenden', theme: 'Design', light: 'Hell', dark: 'Dunkel',
auto: 'Auto', warm: 'Warm', wordCount: 'Wörter', characters: 'Zeichen',
words: 'Wörter', lines: 'Zeilen', readingTime: 'Lesezeit', minutes: 'min',
placeholder: 'Markdown eingeben...', empty: 'Kein Inhalt', copied: 'Kopiert',
copyContent: 'Inhalt kopieren', copyHTML: 'HTML kopieren', copySuccess: 'Erfolgreich kopiert',
copyFailed: 'Kopieren fehlgeschlagen', clearContent: 'Inhalt löschen', clearConfirm: 'Gesamten Inhalt löschen?',
linkPlaceholder: 'Link-URL eingeben', imagePlaceholder: 'Bild-URL eingeben',
altPlaceholder: 'Alternativtext eingeben', tableRows: 'Zeilen', tableCols: 'Spalten',
confirm: 'Bestätigen', cancel: 'Abbrechen', exportMarkdown: 'Markdown exportieren',
exportHTML: 'HTML exportieren', search: 'Suchen', replace: 'Ersetzen', replaceAll: 'Alle ersetzen',
searchPlaceholder: 'Suchen', replacePlaceholder: 'Ersetzen mit',
findNext: 'Nächstes', findPrev: 'Vorheriges', matchCase: 'Groß-/Kleinschreibung',
wholeWord: 'Ganzes Wort', close: 'Schließen', open: 'Öffnen', save: 'Speichern',
saved: 'Gespeichert', saving: 'Speichern...', delete: 'Löschen', confirmDelete: 'Wirklich löschen?',
unsavedChanges: 'Ungespeicherte Änderungen', error: 'Fehler', success: 'Erfolg',
warning: 'Warnung', info: 'Info', loading: 'Laden...', retry: 'Wiederholen',
renderError: 'Rendern fehlgeschlagen',
outline: 'Gliederung',
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
},
};
+288
View File
@@ -2016,3 +2016,291 @@ describe('MarkdownEditor - v0.2.2 大纲滚动跟踪', () => {
ed.destroy();
});
});
// ============ v0.2.3 光标/选区 API ============
describe('MarkdownEditor - v0.2.3 光标/选区 API', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: 'line one\nline two\nline three' });
});
afterEach(() => ed.destroy());
test('getSelectedText 返回选区文本', () => {
ed.textarea.setSelectionRange(0, 8);
expect(ed.getSelectedText()).toBe('line one');
});
test('getSelectedText 无选区返回空字符串', () => {
ed.textarea.setSelectionRange(5, 5);
expect(ed.getSelectedText()).toBe('');
});
test('getCursorPosition 返回行列', () => {
ed.textarea.setSelectionRange(0, 0);
let pos = ed.getCursorPosition();
expect(pos.line).toBe(1);
expect(pos.column).toBe(0);
// 第二行位置 5
ed.textarea.setSelectionRange(14, 14); // 'line two' 的 'line ' 之后
pos = ed.getCursorPosition();
expect(pos.line).toBe(2);
expect(pos.column).toBeGreaterThanOrEqual(0);
});
test('setCursorPosition 移动到指定位置', () => {
ed.setCursorPosition(2, 3);
expect(ed.textarea.selectionStart).toBeGreaterThan(0);
expect(ed.textarea.selectionStart).toBe(ed.textarea.selectionEnd);
});
test('setCursorPosition 超出范围自动 clamp', () => {
ed.setCursorPosition(99, 999);
// 不应抛错,应在末尾
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
});
test('scrollToLine 不抛错', () => {
// 构建多行内容测试滚动
ed.setValue(Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n'));
expect(() => ed.scrollToLine(30)).not.toThrow();
expect(() => ed.scrollToLine(1)).not.toThrow();
expect(() => ed.scrollToLine(999)).not.toThrow();
});
test('selectLine 选中指定行', () => {
ed.selectLine(2);
const sel = ed.getSelectedText();
expect(sel).toBe('line two');
});
test('selectLine 超出范围自动 clamp', () => {
ed.selectLine(100);
expect(() => ed.selectLine(0)).not.toThrow();
});
test('selectAll 选中全部内容', () => {
ed.selectAll();
expect(ed.getSelectedText()).toBe('line one\nline two\nline three');
});
test('selectAll 在空内容上不抛错', () => {
ed.setValue('');
expect(() => ed.selectAll()).not.toThrow();
});
});
// ============ v0.2.3 内容操作 API ============
describe('MarkdownEditor - v0.2.3 内容操作 API', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: 'hello world, hello universe' });
});
afterEach(() => ed.destroy());
test('replaceAll 替换所有匹配', () => {
const count = ed.replaceAll('hello', 'hi');
expect(count).toBe(2);
expect(ed.getValue()).toBe('hi world, hi universe');
});
test('replaceAll 无匹配返回 0', () => {
const count = ed.replaceAll('xyz', 'abc');
expect(count).toBe(0);
});
test('replaceAll 空字符串返回 0', () => {
expect(ed.replaceAll('', 'x')).toBe(0);
});
test('replaceAll caseSensitive 选项', () => {
const count = ed.replaceAll('Hello', 'hi', false);
expect(count).toBe(2);
});
test('replaceAllRegex 正则替换', () => {
const count = ed.replaceAllRegex(/hello/gi, 'hi');
expect(count).toBe(2);
expect(ed.getValue()).toBe('hi world, hi universe');
});
test('replaceAllRegex 无匹配返回 0', () => {
expect(ed.replaceAllRegex(/xyz/g, 'abc')).toBe(0);
});
test('replaceAllRegex 空模式返回 0', () => {
expect(ed.replaceAllRegex(null, 'x')).toBe(0);
});
test('replaceAll 触发 change 事件', () => {
const handler = jest.fn();
ed.on('change', handler);
ed.replaceAll('hello', 'hi');
expect(handler).toHaveBeenCalled();
});
test('lineCount 返回行数', () => {
expect(ed.lineCount()).toBe(1);
ed.setValue('a\nb\nc');
expect(ed.lineCount()).toBe(3);
});
test('lineCount 空内容返回 0', () => {
ed.setValue('');
expect(ed.lineCount()).toBe(0);
});
test('getLine 获取指定行', () => {
ed.setValue('line1\nline2\nline3');
expect(ed.getLine(1)).toBe('line1');
expect(ed.getLine(2)).toBe('line2');
expect(ed.getLine(3)).toBe('line3');
});
test('getLine 越界返回空字符串', () => {
expect(ed.getLine(0)).toBe('');
expect(ed.getLine(999)).toBe('');
});
});
// ============ v0.2.3 浮动工具栏 ============
describe('MarkdownEditor - v0.2.3 浮动工具栏', () => {
test('floatingToolbar 默认创建', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world' });
// 浮动工具栏应被注入 CSS
const style = document.getElementById('me-float-style');
expect(style).not.toBeNull();
// _floatingToolbar 在首次 selection 时才创建 DOM
expect(ed._floatingToolbar).toBeNull();
// _selectionTimer 已启动
expect(ed._selectionTimer).not.toBeNull();
ed.destroy();
});
test('floatingToolbar: false 不启用', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello', floatingToolbar: false });
// 浮动工具栏应不启动(_selectionTimer 为 null
expect(ed._selectionTimer).toBeNull();
expect(ed._floatingToolbar).toBeNull();
ed.destroy();
});
test('选中文本时显示浮动工具栏', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world' });
// 手动创建并显示
ed._buildFloatingToolbar();
expect(ed._floatingToolbar).not.toBeNull();
expect(ed._floatingToolbar!.classList.contains('me-float-toolbar')).toBe(true);
// 检查包含按钮
const btns = ed._floatingToolbar!.querySelectorAll('.me-btn');
expect(btns.length).toBe(5); // bold, italic, code, link, strikethrough
ed.destroy();
});
test('destroy 清理浮动工具栏', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test' });
ed._buildFloatingToolbar();
expect(ed._floatingToolbar).not.toBeNull();
ed.destroy();
expect(ed._floatingToolbar).toBeNull();
expect(ed._selectionTimer).toBeNull();
});
test('selectionChange 事件触发', (done) => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world' });
const handler = jest.fn();
ed.on('selectionChange', handler);
ed.textarea.setSelectionRange(0, 5);
// selectionChange 通过 interval 检测,等 300ms
setTimeout(() => {
expect(handler).toHaveBeenCalled();
expect(handler.mock.calls[0][0]).toHaveProperty('start', 0);
expect(handler.mock.calls[0][0]).toHaveProperty('end', 5);
expect(handler.mock.calls[0][0]).toHaveProperty('text', 'hello');
ed.destroy();
done();
}, 400);
});
test('cursorMove 事件在按键后触发', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'ab' });
const handler = jest.fn();
ed.on('cursorMove', handler);
ed.textarea.setSelectionRange(1, 1);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }));
expect(handler).toHaveBeenCalled();
ed.destroy();
});
});
// ============ v0.2.3 config.onLinkClick 修复验证 ============
describe('MarkdownEditor - v0.2.3 onLinkClick 类型修复', () => {
test('onLinkClick 在 DEFAULTS 中存在', () => {
const { DEFAULTS } = require('../src/constants');
expect('onLinkClick' in DEFAULTS).toBe(true);
expect(DEFAULTS.onLinkClick).toBeNull();
});
test('onLinkClick 回调正常触发', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const handler = jest.fn();
const ed = new MarkdownEditor(c, {
value: '[click](https://example.com)',
onLinkClick: handler,
mode: 'split',
});
// Need to render first
const link = ed.previewEl.querySelector('a');
if (link) {
link.click();
expect(handler).toHaveBeenCalledWith('https://example.com', 'click', ed);
}
ed.destroy();
});
});
// ============ v0.2.3 getStatus 新字段验证 ============
describe('MarkdownEditor - v0.2.3 getStatus', () => {
test('getStatus 返回完整结构', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { readOnly: true });
const s = ed.getStatus();
expect(s.readOnly).toBe(true);
expect(s.disabled).toBe(false);
expect(Array.isArray(s.plugins)).toBe(true);
ed.destroy();
});
});
+1 -1
View File
@@ -29,7 +29,7 @@ describe('index.ts - 全局 API', () => {
test('VERSION 是字符串', () => {
expect(typeof VERSION).toBe('string');
expect(VERSION).toBe('0.2.1');
expect(VERSION).toBe('0.2.3');
});
test('api.default 是 api 本身', () => {