feat: v0.4.2 — 审查清偿:8 项修复 + setOutline API + 871 测试

修复:插件面板实例级 i18n / undo-redo 滚动保持 / shortcutHelp 卸载残留 / CSS.escape 环境崩溃 / 大纲光标按源码行号定位 / setLocale 即时刷新 / 浮动工具栏 CJK 宽度 / ko 语言包括号

新增:setOutline(isOutline) 运行时大纲 API、getRenderCacheSize 观测接口

测试:841 → 871(补 7 个事件断言、5 个配置项、修 3 处恒真断言)

工程:CI lint 阻断、build.sh npm ci、rollup 死代码清理、覆盖率阈值门禁
This commit is contained in:
2026-08-19 22:30:52 +08:00
parent 5966ac7500
commit 5f4915222e
22 changed files with 704 additions and 107 deletions
-1
View File
@@ -99,6 +99,5 @@ jobs:
run: npm run typecheck run: npm run typecheck
- name: Lint - name: Lint
run: npm run lint run: npm run lint
continue-on-error: true
- name: Build - name: Build
run: npm run build run: npm run build
+26
View File
@@ -2,6 +2,32 @@
All notable changes to MetonaEditor will be documented in this file. All notable changes to MetonaEditor will be documented in this file.
## [0.4.2] - 2026-08-19
### Fixed
- **插件面板文案未跟随实例 locale**: searchReplace / shortcutHelp / imagePaste 面板与提示此前使用全局 `t()`,实例 `locale` 与全局语言不一致时显示错误语言(v0.4.0 修复了核心 UI,插件面板遗漏);现统一经实例 `editor.t()` 取词。
- **undo/redo 后预览区跳顶**: `_applyHistory` 此前硬编码 `previewPane.scrollTop = 0`,分屏下长文档撤销时预览跳回顶部;现按滚动比例保存/恢复。
- **shortcutHelp 卸载面板残留**: `unuse('shortcutHelp')` / `destroy()` 此前保存的是 install 时刻的面板引用(null),已打开的快捷键面板不随之移除;现经 holder 取实时引用清理。
- **大纲点击在无 CSS.escape 环境崩溃**: `outline.ts` 直接调用全局 `CSS.escape()`jsdom 等未暴露 `window.CSS` 的环境下点击大纲链接抛 `ReferenceError`,光标定位静默失效;现能力检测 + 简易转义兜底。
- **大纲点击光标定位不准**: 此前用 `indexOf(标题文本)` 定位,重复标题永远跳第一个;`data-line` 属性误存标题 id 而非行号。现构建时计算各标题源码行号(跳过围栏代码块),点击按行号精确定位,`data-line` 记录 1 基行号。
- **setLocale 不刷新已渲染 UI**: 状态栏统计标签、大纲标题要等下一次输入才切换语言;现切换即时刷新。
- **浮动工具栏中文选区定位偏左**: 列宽估算固定按 ASCII 0.6em,中文全角字符约 1em;现逐字符按 CJK 判定累计宽度。
- **ko 语言包 imageTooLarge 混用中文全角括号**: 统一为半角括号。
### Added
- **运行时大纲 API**: `editor.setOutline(on)` / `editor.isOutline()`,运行时开关大纲面板(替代直接操作 `config.outline` + `_buildOutline()` 的私有用法)。
- **`getRenderCacheSize()`**: 暴露行内渲染缓存当前条目数(parser 导出),供观测 FIFO 淘汰。
- 回归与补强测试 30 个(7 个此前零断言事件:beforeRender / afterRender / zenChange / fullscreen / copy / fileOpened / fileSaved;全局钩子 beforeCreate / beforeDestroy / afterDestroy5 个零测试配置项:placeholder / height / spellcheck / tabSize(含 0 与 4/ historyDebounce;修复 3 处恒真/弱断言:同步滚动(含新增反向联动验证)、渲染缓存 FIFO 真实淘汰、搜索 findNext/prev 选中内容),总数 841 → 871。
- jest 覆盖率阈值:全局 lines 85% / branches 70%parser lines 95%(基线 94.95% / 75.72% / 98.93%)。
### Changed
- **CI lint 改为阻断**: 移除 verify job 中 lint 步骤的 `continue-on-error`lint 失败不再静默放行。
- **build.sh 依赖安装**: `npm install``npm ci`,保证构建可重现。
- **rollup.config.js 死代码清理**: 移除从未被引用的 `terserPlugin` / `isProd` 变量。
- 文档全面同步:README / docs.html / index.html 修正 "CI 4 job"(实际 5 个)、`document.body()` 错误写法、`afterChange` / `fileSaved` 事件参数(以源码为准)、docs.html 补齐约 15 处缺失 API`editor.t()``MeEditor.getStatus()` / `destroy()``configureToolbar` / `removeToolbarButton`、PluginManager & pluginUtils、`safeUrl` / `slugify` / `getSupportedLanguages``formatCurrency`、全局 beforeChange / afterChange 钩子、`data:image` 500KB 限制等);demo.html 私有 API hack 改用 `setOutline()` 公开 API。
---
## [0.4.1] - 2026-08-11 ## [0.4.1] - 2026-08-11
### Added ### Added
+8 -6
View File
@@ -2,9 +2,9 @@
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库 > TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
[![version](https://img.shields.io/badge/version-0.4.1-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor) [![version](https://img.shields.io/badge/version-0.4.2-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
[![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-841%20passed-brightgreen)](./tests) [![tests](https://img.shields.io/badge/tests-871%20passed-brightgreen)](./tests)
[![coverage](https://img.shields.io/badge/coverage-parser%2099%25-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) [![types](https://img.shields.io/badge/types-TypeScript%20strict-blue)](./tsconfig.json)
@@ -21,7 +21,7 @@
- **内置语法高亮** — 零依赖轻量高亮器,js/ts/python/bash/css/html 等 16 个语言标识,规则缓存零重建开销,`MeEditor.highlight` 即插即用 - **内置语法高亮** — 零依赖轻量高亮器,js/ts/python/bash/css/html 等 16 个语言标识,规则缓存零重建开销,`MeEditor.highlight` 即插即用
- **性能** — 字数 / 词数 / 行数差异增量统计,击键零全量扫描;大纲 O(n) 线性构建,长文档即时响应 - **性能** — 字数 / 词数 / 行数差异增量统计,击键零全量扫描;大纲 O(n) 线性构建,长文档即时响应
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护(含脚注 id),白名单裸标签透传,sanitize 钩子 - **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护(含脚注 id),白名单裸标签透传,sanitize 钩子
- **工程化** — 841 个单元测试 + Playwright 真实浏览器冒烟测试,CI 并行 4 job`sideEffects: false` 便于 tree-shaking - **工程化** — 871 个单元测试 + Playwright 真实浏览器冒烟测试,CI 并行 5 job(含 e2e),覆盖率阈值门禁`sideEffects: false` 便于 tree-shaking
- **桌面端优先** — 纯电脑端设计,无移动端冗余代码 - **桌面端优先** — 纯电脑端设计,无移动端冗余代码
- **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性 - **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性
- **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等) - **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等)
@@ -348,7 +348,7 @@ editor.configureToolbar(tools: string[]): this
editor.removeToolbarButton(action: string): this editor.removeToolbarButton(action: string): this
``` ```
### Zen / Word Wrap ### Zen / Word Wrap / Outline
```typescript ```typescript
editor.toggleZen(): this editor.toggleZen(): this
@@ -358,6 +358,8 @@ editor.getZenMaxWidth(): number | string | false
editor.toggleWordWrap(): this editor.toggleWordWrap(): this
editor.setWordWrap(on: boolean): this editor.setWordWrap(on: boolean): this
editor.isWordWrap(): boolean editor.isWordWrap(): boolean
editor.setOutline(on: boolean): this // 运行时开关大纲面板
editor.isOutline(): boolean
``` ```
### Toast ### Toast
@@ -414,7 +416,7 @@ MeEditor.getStatus()
MeEditor.destroy() MeEditor.destroy()
// 解析器独立使用 // 解析器独立使用
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlockHandler } from '@metona-team/metona-editor'; import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler } from '@metona-team/metona-editor';
// 内置语法高亮 // 内置语法高亮
import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team/metona-editor'; import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team/metona-editor';
@@ -818,7 +820,7 @@ src/
|------|------| |------|------|
| 语言 | TypeScript 5 (strict) | | 语言 | TypeScript 5 (strict) |
| 构建 | Rollup 3 | | 构建 | Rollup 3 |
| 测试 | Jest 29 + jsdom841 用例) | | 测试 | Jest 29 + jsdom871 用例,覆盖率阈值门禁 |
| 浏览器冒烟 | Playwright + Chromium22 项断言) | | 浏览器冒烟 | Playwright + Chromium22 项断言) |
| 类型生成 | rollup-plugin-dts | | 类型生成 | rollup-plugin-dts |
| 零运行时依赖 | ✅ | | 零运行时依赖 | ✅ |
+1 -1
View File
@@ -20,7 +20,7 @@ echo "✅ npm版本: $(npm -v)"
echo "" echo ""
echo "📦 安装依赖..." echo "📦 安装依赖..."
npm install npm ci
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "❌ 依赖安装失败" echo "❌ 依赖安装失败"
+11
View File
@@ -13,5 +13,16 @@ module.exports = {
], ],
coverageDirectory: 'coverage', coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'], coverageReporters: ['text', 'lcov'],
coverageThreshold: {
global: {
statements: 85,
branches: 70,
functions: 85,
lines: 85,
},
'src/parser.ts': {
lines: 95,
},
},
verbose: true, verbose: true,
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@metona-team/metona-editor", "name": "@metona-team/metona-editor",
"version": "0.4.1", "version": "0.4.2",
"description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.", "description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.",
"type": "module", "type": "module",
"main": "dist/metona-editor.cjs", "main": "dist/metona-editor.cjs",
-14
View File
@@ -7,7 +7,6 @@ import serve from 'rollup-plugin-serve';
import livereload from 'rollup-plugin-livereload'; import livereload from 'rollup-plugin-livereload';
const isDev = process.env.ROLLUP_WATCH; const isDev = process.env.ROLLUP_WATCH;
const isProd = process.env.NODE_ENV === 'production';
const basePlugins = [ const basePlugins = [
resolve(), resolve(),
@@ -29,19 +28,6 @@ const devPlugins = isDev ? [
}), }),
] : []; ] : [];
const terserPlugin = isProd ? [
terser({
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.warn'],
},
format: {
comments: false,
},
}),
] : [];
export default [ export default [
// UMD development // UMD development
{ {
+18 -15
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <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>"/> <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.4.1 — 全功能演示</title> <title>MetonaEditor v0.4.2 — 全功能演示</title>
<style> <style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} *,*::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} :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> <body>
<header class="header"> <header class="header">
<h1><span class="grad">Metona</span>Editor v0.4.1</h1> <h1><span class="grad">Metona</span>Editor v0.4.2</h1>
<div class="badges"> <div class="badges">
<span class="badge">TypeScript</span> <span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span> <span class="badge">零运行时依赖</span>
<span class="badge">841 tests</span> <span class="badge">871 tests</span>
<span class="badge">6 个插件</span> <span class="badge">6 个插件</span>
<span class="badge">桌面端优先</span> <span class="badge">桌面端优先</span>
</div> </div>
@@ -93,7 +93,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
</div> </div>
<footer class="footer"> <footer class="footer">
MetonaEditor v0.4.1 · 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.4.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
</footer> </footer>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
@@ -101,18 +101,19 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<script> <script>
(function(){ (function(){
var demoMd=[ var demoMd=[
'# 🚀 MetonaEditor v0.4.1 全功能演示', '# 🚀 MetonaEditor v0.4.2 全功能演示',
'', '',
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。', '> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
'> 全模块 TypeScript 严格模式,17 个源文件,841 个测试全部通过。', '> 全模块 TypeScript 严格模式,17 个源文件,871 个测试全部通过。',
'', '',
'## ✨ v0.4.1 新特性', '## ✨ v0.4.2 新特性',
'', '',
'- **Zen 专注模式宽度可配置**`zenMaxWidth` 默认 960px,支持数字 / CSS 字符串 / `false` 不限宽', '- **运行时大纲 API**`editor.setOutline(on)` / `isOutline()` 运行时开关大纲面板',
'- **修复 5 处问题**`wordWrap: false` 生效 · outline 启动即构建 · edit 模式初始行号 · exportTool 卸载清理 · 替换后统计刷新', '- **插件面板实例级 i18n**:查找替换 / 快捷键面板文案跟随实例语言',
'- **Playwright 浏览器冒烟测试**22 项断言真实 Chromium 验证', '- **撤销不再跳顶**:undo / redo 后预览区按滚动比例保持位置',
'- **实例级 i18n**:状态栏 / 工具栏 / 右键菜单 / 大纲全部跟随实例语言', '- **大纲光标精确定位**:按源码行号定位,重复标题不串位',
'- `sideEffects: false` 优化打包 · `MeEditor.destroy()` 全面复位', '- **修复 8 处问题**shortcutHelp 卸载残留 · 无 CSS.escape 环境点击大纲崩溃 · setLocale 即时刷新 UI · 中文选区浮动工具栏偏移等',
'- **测试补强**841 → 871 用例(补齐 7 个事件断言 / 5 个配置项测试 / 修复弱断言)',
'', '',
'## 📐 文本样式', '## 📐 文本样式',
'', '',
@@ -141,6 +142,7 @@ var demoMd=[
'', '',
'| 版本 | 日期 | 测试 | 主题 |', '| 版本 | 日期 | 测试 | 主题 |',
'| :--- | :---: | ---: | --- |', '| :--- | :---: | ---: | --- |',
'| v0.4.2 | 2026-08 | 871 | 审查清偿+大纲API+8项修复 |',
'| v0.4.1 | 2026-08 | 841 | Zen宽度可配置+5项修复 |', '| v0.4.1 | 2026-08 | 841 | Zen宽度可配置+5项修复 |',
'| v0.4.0 | 2026-08 | 826 | E2E冒烟+实例i18n |', '| v0.4.0 | 2026-08 | 826 | E2E冒烟+实例i18n |',
'| v0.3.1 | 2026-08 | 821 | 增量统计+高亮缓存 |', '| v0.3.1 | 2026-08 | 821 | 增量统计+高亮缓存 |',
@@ -270,9 +272,10 @@ function updateModeBtns(mode){
} }
function toggleOutline(btn){ function toggleOutline(btn){
editor.config.outline = !editor.config.outline; // v0.4.2 起使用公开 API setOutline/isOutline(替代操作私有 config/_buildOutline 的写法)
if (editor.config.outline) { editor._buildOutline(); btn.classList.add('on'); } var next = !editor.isOutline();
else { var p = editor.el.querySelector('.me-outline'); if (p) p.remove(); btn.classList.remove('on'); } editor.setOutline(next);
btn.classList.toggle('on', next);
} }
// Custom shortcut // Custom shortcut
+51 -12
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <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>"/> <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.4.1 · 文档</title> <title>MetonaEditor v0.4.2 · 文档</title>
<style> <style>
*{box-sizing:border-box;margin:0;padding:0} *{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%)} :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"> <header class="docs-header">
<h1><span class="grad">API 文档</span></h1> <h1><span class="grad">API 文档</span></h1>
<p class="sub">MetonaEditor v0.4.1 · TypeScript · 完整配置、API 与使用指南</p> <p class="sub">MetonaEditor v0.4.2 · TypeScript · 完整配置、API 与使用指南</p>
</header> </header>
<div class="container"> <div class="container">
@@ -98,6 +98,7 @@ footer a{color:var(--accent);text-decoration:none}
<div class="toc-group">插件</div> <div class="toc-group">插件</div>
<a href="#plugins-convention">插件约定</a> <a href="#plugins-convention">插件约定</a>
<a href="#preset-plugins">预设插件</a> <a href="#preset-plugins">预设插件</a>
<a href="#preset-plugins">PluginManager</a>
<div class="toc-group">主题</div> <div class="toc-group">主题</div>
<a href="#themes">主题系统</a> <a href="#themes">主题系统</a>
<div class="toc-group">国际化</div> <div class="toc-group">国际化</div>
@@ -240,11 +241,13 @@ editor<span class="c-punc">.</span><span class="c-fn">selectAll</span><span clas
<tr><td><code>formatTable</code> <span class="badge">v0.2.2</span></td><td>光标所在表格按列宽对齐</td><td></td></tr> <tr><td><code>formatTable</code> <span class="badge">v0.2.2</span></td><td>光标所在表格按列宽对齐</td><td></td></tr>
<tr><td><code>hr</code></td><td>---水平线</td><td></td></tr> <tr><td><code>hr</code></td><td>---水平线</td><td></td></tr>
<tr><td><code>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab / Shift+Tab</td></tr> <tr><td><code>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab / Shift+Tab</td></tr>
<tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Y</td></tr> <tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z</td></tr>
<tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td></td></tr> <tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td></td></tr>
<tr><td><code>fullscreen</code></td><td>全屏</td><td></td></tr> <tr><td><code>fullscreen</code></td><td>全屏</td><td></td></tr>
<tr><td><code>zen</code></td><td>Zen 模式</td><td></td></tr> <tr><td><code>zen</code></td><td>Zen 模式</td><td></td></tr>
<tr><td><code>wordwrap</code></td><td>自动换行</td><td></td></tr> <tr><td><code>wordwrap</code></td><td>自动换行</td><td></td></tr>
<tr><td>智能 Enter</td><td>列表 / 引用自动延续,空行回车退出</td><td>Enter</td></tr>
<tr><td>查找 / 替换</td><td>searchReplace 插件面板</td><td>Ctrl+F / Ctrl+H</td></tr>
</table> </table>
</section> </section>
@@ -260,6 +263,7 @@ editor<span class="c-punc">.</span><span class="c-fn">toggleZen</span><span clas
editor<span class="c-punc">.</span><span class="c-fn">setZenMaxWidth</span><span class="c-punc">(</span><span class="c-num">1200</span><span class="c-punc">);</span> <span class="c-com">// 运行时调整专注模式宽度(数字 / CSS 字符串 / false</span> editor<span class="c-punc">.</span><span class="c-fn">setZenMaxWidth</span><span class="c-punc">(</span><span class="c-num">1200</span><span class="c-punc">);</span> <span class="c-com">// 运行时调整专注模式宽度(数字 / CSS 字符串 / false</span>
editor<span class="c-punc">.</span><span class="c-fn">getZenMaxWidth</span><span class="c-punc">();</span> <span class="c-com">// => number | string | false</span> editor<span class="c-punc">.</span><span class="c-fn">getZenMaxWidth</span><span class="c-punc">();</span> <span class="c-com">// => number | string | false</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleWordWrap</span><span class="c-punc">();</span> <span class="c-fn">setWordWrap</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isWordWrap</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">toggleWordWrap</span><span class="c-punc">();</span> <span class="c-fn">setWordWrap</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isWordWrap</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">setOutline</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isOutline</span><span class="c-punc">();</span> <span class="c-com">// 运行时开关大纲面板(v0.4.2)</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleFloatingToolbar</span><span class="c-punc">();</span> <span class="c-fn">isFloatingToolbar</span><span class="c-punc">();</span> <span class="c-com">// 选中文本格式栏</span></pre> editor<span class="c-punc">.</span><span class="c-fn">toggleFloatingToolbar</span><span class="c-punc">();</span> <span class="c-fn">isFloatingToolbar</span><span class="c-punc">();</span> <span class="c-com">// 选中文本格式栏</span></pre>
</section> </section>
@@ -280,7 +284,8 @@ editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span cl
<tr><th>事件</th><th>触发时机</th><th>参数</th></tr> <tr><th>事件</th><th>触发时机</th><th>参数</th></tr>
<tr><td><code>input</code></td><td>textarea 输入</td><td><code>(value, editor)</code></td></tr> <tr><td><code>input</code></td><td>textarea 输入</td><td><code>(value, editor)</code></td></tr>
<tr><td><code>change</code></td><td>内容变化</td><td><code>(value, editor)</code></td></tr> <tr><td><code>change</code></td><td>内容变化</td><td><code>(value, editor)</code></td></tr>
<tr><td><code>beforeChange</code> / <code>afterChange</code></td><td>内容变更前</td><td><code>(oldValue, newValue, editor)</code></td></tr> <tr><td><code>beforeChange</code></td><td>内容变更前</td><td><code>(oldValue, newValue, editor)</code></td></tr>
<tr><td><code>afterChange</code></td><td>内容变更后</td><td><code>(newValue, editor)</code></td></tr>
<tr><td><code>focus</code> / <code>blur</code></td><td>聚焦/失焦</td><td><code>(editor)</code></td></tr> <tr><td><code>focus</code> / <code>blur</code></td><td>聚焦/失焦</td><td><code>(editor)</code></td></tr>
<tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(editor)</code></td></tr> <tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(editor)</code></td></tr>
<tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr> <tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr>
@@ -295,22 +300,26 @@ editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span cl
<tr><td><code>autosave</code></td><td>autoSave 触发</td><td><code>({ key, value })</code></td></tr> <tr><td><code>autosave</code></td><td>autoSave 触发</td><td><code>({ key, value })</code></td></tr>
<tr><td><code>linkClick</code></td><td>预览链接点击</td><td><code>({ href, text })</code></td></tr> <tr><td><code>linkClick</code></td><td>预览链接点击</td><td><code>({ href, text })</code></td></tr>
<tr><td><code>copy</code></td><td>复制到剪贴板</td><td><code>({ type })</code></td></tr> <tr><td><code>copy</code></td><td>复制到剪贴板</td><td><code>({ type })</code></td></tr>
<tr><td><code>fileOpened</code> / <code>fileSaved</code></td><td>fileSystem 读写</td><td><code>({ name, handle })</code></td></tr> <tr><td><code>fileOpened</code></td><td>fileSystem 打开文件</td><td><code>({ name, handle }, editor)</code></td></tr>
<tr><td><code>fileSaved</code></td><td>fileSystem 保存文件</td><td><code>({ handle }, editor)</code></td></tr>
</table> </table>
<h4>全局钩子</h4> <h4>全局钩子</h4>
<table> <table>
<tr><th>钩子</th><th>触发时机</th></tr> <tr><th>钩子</th><th>触发时机</th></tr>
<tr><td><code>beforeCreate</code> / <code>afterCreate</code></td><td>实例构造前后</td></tr> <tr><td><code>beforeCreate</code> / <code>afterCreate</code></td><td>实例构造前后</td></tr>
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>全局渲染前后</td></tr> <tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>全局渲染前后</td></tr>
<tr><td><code>beforeChange</code> / <code>afterChange</code></td><td>内容变更前后(全局)</td></tr>
<tr><td><code>beforeDestroy</code> / <code>afterDestroy</code></td><td>销毁前后</td></tr> <tr><td><code>beforeDestroy</code> / <code>afterDestroy</code></td><td>销毁前后</td></tr>
</table> </table>
</section> </section>
<section id="api-plugin"><h3>插件管理</h3> <section id="api-plugin"><h3>插件管理 / 工具栏管理</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装</span> <pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装</span>
editor<span class="c-punc">.</span><span class="c-fn">unuse</span><span class="c-punc">(</span>name<span class="c-punc">);</span> <span class="c-com">// 卸载</span> editor<span class="c-punc">.</span><span class="c-fn">unuse</span><span class="c-punc">(</span>name<span class="c-punc">);</span> <span class="c-com">// 卸载</span>
editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 已安装列表</span> editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 已安装列表</span>
editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加按钮</span></pre> editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加按钮</span>
editor<span class="c-punc">.</span><span class="c-fn">configureToolbar</span><span class="c-punc">(</span><span class="c-punc">[</span><span class="c-str">'bold'</span><span class="c-punc">,</span> <span class="c-str">'italic'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'undo'</span><span class="c-punc">]);</span> <span class="c-com">// 运行时重建工具栏</span>
editor<span class="c-punc">.</span><span class="c-fn">removeToolbarButton</span><span class="c-punc">(</span><span class="c-str">'hr'</span><span class="c-punc">);</span> <span class="c-com">// 移除单个按钮</span></pre>
</section> </section>
<section id="api-shortcuts"><h3>快捷键管理</h3> <section id="api-shortcuts"><h3>快捷键管理</h3>
@@ -344,11 +353,18 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span> MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span> MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span>
<span class="c-com">// 状态查询</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span>
<span class="c-com">// => { version, theme, locale, globalPlugins, presetPlugins }</span>
<span class="c-com">// 销毁全局资源(主题/语言监听、全局插件、全局钩子全面复位,之后 use() 仍可用)</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span>
<span class="c-com">// 解析器独立使用</span> <span class="c-com">// 解析器独立使用</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span> <span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens<span class="c-punc">,</span> safeUrl<span class="c-punc">,</span> slugify<span class="c-punc">,</span> clearRenderCache<span class="c-punc">,</span> getRenderCacheSize<span class="c-punc">,</span> registerBlockHandler <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 内置语法高亮</span> <span class="c-com">// 内置语法高亮</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> highlight<span class="c-punc">,</span> registerLanguage <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span></pre> <span class="c-kw">import</span> <span class="c-punc">{</span> highlight<span class="c-punc">,</span> normalizeLanguage<span class="c-punc">,</span> registerLanguage<span class="c-punc">,</span> getSupportedLanguages <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span></pre>
</section> </section>
<!-- 语法高亮 --> <!-- 语法高亮 -->
@@ -403,9 +419,11 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><
<tr><td>定义列表</td><td><code>Term\n: def</code></td><td>&lt;dl&gt;&lt;dt&gt;&lt;dd&gt;</td></tr> <tr><td>定义列表</td><td><code>Term\n: def</code></td><td>&lt;dl&gt;&lt;dt&gt;&lt;dd&gt;</td></tr>
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+</td></tr> <tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+</td></tr>
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr> <tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
<tr><td>HTML 注释</td><td><code>&lt;!-- note --&gt;</code></td><td>透传</td></tr>
<tr><td>实体引用保护</td><td><code>&amp;amp;</code> <code>&amp;#169;</code></td><td>不被二次转义</td></tr>
</table> </table>
<h4>XSS 防护</h4> <h4>XSS 防护</h4>
<p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,属性级注入防护(<code>href</code> / <code>src</code> / <code>alt</code> / <code>title</code> / 脚注 id 引号转义),白名单裸标签 <code>&lt;u&gt;</code> 透传,提供 <code>sanitize</code> 钩子</p> <p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,属性级注入防护(<code>href</code> / <code>src</code> / <code>alt</code> / <code>title</code> / 脚注 id 引号转义),白名单裸标签 <code>&lt;u&gt;</code> 透传,<code>data:image</code> 限制最大 500KB,提供 <code>sanitize</code> 钩子(可接 DOMPurify 等),<code>highlight</code> 钩子异常自动回退纯文本</p>
</section> </section>
<section id="parser-api"><h2>解析器 API</h2> <section id="parser-api"><h2>解析器 API</h2>
@@ -451,6 +469,20 @@ editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span
<h4>fileSystem</h4> <h4>fileSystem</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'fileSystem'</span><span class="c-punc">);</span> <pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'fileSystem'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">openFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFileAs</span><span class="c-punc">();</span></pre> editor<span class="c-punc">.</span><span class="c-fn">openFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFileAs</span><span class="c-punc">();</span></pre>
<h3>PluginManager &amp; pluginUtils</h3>
<pre><span class="c-kw">import</span> <span class="c-punc">{</span> PluginManager<span class="c-punc">,</span> pluginUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 独立插件注册表</span>
<span class="c-kw">const</span> pm <span class="c-punc">=</span> <span class="c-kw">new</span> <span class="c-fn">PluginManager</span><span class="c-punc">();</span>
pm<span class="c-punc">.</span><span class="c-fn">register</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">,</span> myPlugin<span class="c-punc">);</span>
pm<span class="c-punc">.</span><span class="c-fn">has</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">);</span> pm<span class="c-punc">.</span><span class="c-fn">get</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">);</span> pm<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span>
<span class="c-com">// 工具集(作用于默认全局管理器)</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">validatePlugin</span><span class="c-punc">(</span>plugin<span class="c-punc">);</span> <span class="c-com">// => { valid, errors }</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">createPlugin</span><span class="c-punc">({</span> name<span class="c-punc">:</span> <span class="c-str">'x'</span> <span class="c-punc">});</span> <span class="c-com">// 工厂函数,补齐默认字段</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">getPreset</span><span class="c-punc">(</span><span class="c-str">'autoSave'</span><span class="c-punc">);</span> <span class="c-com">// 预设副本(不污染原对象)</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">getAllPresets</span><span class="c-punc">();</span> <span class="c-com">// 全部预设副本</span></pre>
</section> </section>
<!-- 主题 --> <!-- 主题 -->
@@ -461,10 +493,11 @@ editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class
<span class="c-com">// 注册自定义</span> <span class="c-com">// 注册自定义</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span> <span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span> extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span> <span class="c-punc">});</span> themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span> extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span> <span class="c-punc">});</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">switchTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">);</span> <span class="c-com">// 全局切换到自定义主题</span>
<span class="c-com">// 外部跟随</span> <span class="c-com">// 外部跟随</span>
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">adopt</span><span class="c-punc">();</span> <span class="c-com">// 从父容器继承</span> editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">adopt</span><span class="c-punc">();</span> <span class="c-com">// 从父容器继承</span>
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">syncWithElement</span><span class="c-punc">(</span>document<span class="c-punc">.</span><span class="c-fn">body</span><span class="c-punc">());</span> <span class="c-com">// 跟随外部元素</span> editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">syncWithElement</span><span class="c-punc">(</span>document<span class="c-punc">.</span><span class="c-kw">body</span><span class="c-punc">);</span> <span class="c-com">// 跟随外部元素body 是属性,不是函数)</span>
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">dispose</span><span class="c-punc">();</span> <span class="c-com">// 手动断开跟随(destroy 时自动断开)</span> editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">dispose</span><span class="c-punc">();</span> <span class="c-com">// 手动断开跟随(destroy 时自动断开)</span>
<span class="c-com">// CSS 变量导出</span> <span class="c-com">// CSS 变量导出</span>
@@ -483,9 +516,15 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">addTranslations</span><
<span class="c-com">// 远程加载</span> <span class="c-com">// 远程加载</span>
<span class="c-kw">await</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">loadRemote</span><span class="c-punc">(</span><span class="c-str">'/locales/ja.json'</span><span class="c-punc">,</span> <span class="c-str">'ja'</span><span class="c-punc">);</span> <span class="c-kw">await</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">loadRemote</span><span class="c-punc">(</span><span class="c-str">'/locales/ja.json'</span><span class="c-punc">,</span> <span class="c-str">'ja'</span><span class="c-punc">);</span>
<span class="c-com">// 实例级翻译(跟随实例 locale,状态栏/工具栏/插件面板均使用它)</span>
editor<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">);</span> <span class="c-com">// => '粗体'</span>
editor<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'imageTooLarge'</span><span class="c-punc">,</span> <span class="c-punc">{</span> size<span class="c-punc">:</span> <span class="c-num">800</span><span class="c-punc">,</span> max<span class="c-punc">:</span> <span class="c-num">500</span> <span class="c-punc">});</span> <span class="c-com">// 插值</span>
<span class="c-com">// 格式化</span> <span class="c-com">// 格式化</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatCurrency</span><span class="c-punc">(</span><span class="c-num">99.99</span><span class="c-punc">,</span> <span class="c-str">'USD'</span><span class="c-punc">);</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span></pre> i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span></pre>
<p>RTL 语言(阿拉伯语、希伯来语等)自动应用从右到左布局;分屏分隔条拖拽比例按实例 id 持久化到 localStorage。</p>
</section> </section>
</main> </main>
@@ -494,7 +533,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
<footer> <footer>
<div class="container"> <div class="container">
MetonaEditor v0.4.1 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License MetonaEditor v0.4.2 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
</div> </div>
</footer> </footer>
+8 -8
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <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>"/> <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.4.1 · TypeScript · 桌面端 Markdown 编辑器</title> <title>MetonaEditor v0.4.2 · TypeScript · 桌面端 Markdown 编辑器</title>
<style> <style>
*{box-sizing:border-box;margin:0;padding:0} *{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)} :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> <h1>TypeScript 重构 · 零依赖<br/><span class="grad">桌面端 Markdown 编辑器</span></h1>
<p class="tagline">全模块 TypeScript 严格模式,内置自研解析器(99% 行覆盖率),6 个预设插件,中文优先。为桌面端现代 Web 应用而生。</p> <p class="tagline">全模块 TypeScript 严格模式,内置自研解析器(99% 行覆盖率),6 个预设插件,中文优先。为桌面端现代 Web 应用而生。</p>
<div class="badges"> <div class="badges">
<span class="badge accent">v0.4.1</span> <span class="badge accent">v0.4.2</span>
<span class="badge">TypeScript</span> <span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span> <span class="badge">零运行时依赖</span>
<span class="badge">841 tests</span> <span class="badge">871 tests</span>
<span class="badge">桌面端优先</span> <span class="badge">桌面端优先</span>
<span class="badge">MIT</span> <span class="badge">MIT</span>
</div> </div>
@@ -120,7 +120,7 @@ footer a:hover{text-decoration:underline}
<div class="stats"> <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">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">99%</div><div class="stat-label">解析器行覆盖</div></div>
<div class="stat"><div class="stat-num">841</div><div class="stat-label">单元测试</div></div> <div class="stat"><div class="stat-num">871</div><div class="stat-label">单元测试</div></div>
<div class="stat"><div class="stat-num">~42KB</div><div class="stat-label">gzip 体积</div></div> <div class="stat"><div class="stat-num">~42KB</div><div class="stat-label">gzip 体积</div></div>
</div> </div>
</header> </header>
@@ -147,7 +147,7 @@ footer a:hover{text-decoration:underline}
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg></div><h3>主题 & i18n</h3><p>4 套预设主题 + CSS 变量定制 + 实例级隔离。六种语言开箱即用(中 / 英 / 日 / 韩 / 法 / 德),支持远程加载翻译包。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg></div><h3>主题 & i18n</h3><p>4 套预设主题 + CSS 变量定制 + 实例级隔离。六种语言开箱即用(中 / 英 / 日 / 韩 / 法 / 德),支持远程加载翻译包。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></div><h3>内置语法高亮</h3><p>零依赖轻量高亮器,js / ts / python / bash / css 等 16 个语言标识,代码块一行配置即自动着色。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></div><h3>内置语法高亮</h3><p>零依赖轻量高亮器,js / ts / python / bash / css 等 16 个语言标识,代码块一行配置即自动着色。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><h3>XSS 安全防护</h3><p>HTML 转义、危险协议过滤、属性级注入防护、sanitize 钩子、highlight 异常回退。多层防护,安全可靠。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><h3>XSS 安全防护</h3><p>HTML 转义、危险协议过滤、属性级注入防护、sanitize 钩子、highlight 异常回退。多层防护,安全可靠。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div><h3>性能与工程</h3><p>字数/词数/行数差异增量统计、高亮规则缓存、大纲 O(n) 构建;841 单测 + Playwright 真实浏览器冒烟,CI 并行 4 job。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div><h3>性能与工程</h3><p>字数/词数/行数差异增量统计、高亮规则缓存、大纲 O(n) 构建;871 单测 + Playwright 真实浏览器冒烟,CI 并行 5 job。</p></div>
</div> </div>
</div> </div>
</section> </section>
@@ -193,7 +193,7 @@ editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-
<div class="api-card"><h4>内容操作</h4><ul><li>getValue() <span class="ret">string</span></li><li>setValue(md) <span class="ret">this</span></li><li>getHTML() <span class="ret">string</span></li><li>insert(text) <span class="ret">this</span></li><li>wrap(before, after) <span class="ret">this</span></li><li>replaceAll(search, rep) <span class="ret">number</span></li></ul></div> <div class="api-card"><h4>内容操作</h4><ul><li>getValue() <span class="ret">string</span></li><li>setValue(md) <span class="ret">this</span></li><li>getHTML() <span class="ret">string</span></li><li>insert(text) <span class="ret">this</span></li><li>wrap(before, after) <span class="ret">this</span></li><li>replaceAll(search, rep) <span class="ret">number</span></li></ul></div>
<div class="api-card"><h4>光标与选区</h4><ul><li>getSelectedText() <span class="ret">string</span></li><li>getCursorPosition() <span class="ret">{line,column}</span></li><li>setCursorPosition(l, c) <span class="ret">this</span></li><li>selectLine(n) / selectAll() <span class="ret">this</span></li><li>toggleFloatingToolbar() <span class="ret">this</span></li></ul></div> <div class="api-card"><h4>光标与选区</h4><ul><li>getSelectedText() <span class="ret">string</span></li><li>getCursorPosition() <span class="ret">{line,column}</span></li><li>setCursorPosition(l, c) <span class="ret">this</span></li><li>selectLine(n) / selectAll() <span class="ret">this</span></li><li>toggleFloatingToolbar() <span class="ret">this</span></li></ul></div>
<div class="api-card"><h4>命令执行</h4><ul><li>exec(action) <span class="ret">this</span></li><li>undo() / redo() <span class="ret">this</span></li><li>canUndo() / canRedo() <span class="ret">boolean</span></li></ul></div> <div class="api-card"><h4>命令执行</h4><ul><li>exec(action) <span class="ret">this</span></li><li>undo() / redo() <span class="ret">this</span></li><li>canUndo() / canRedo() <span class="ret">boolean</span></li></ul></div>
<div class="api-card"><h4>模式与全屏</h4><ul><li>setMode(mode) <span class="ret">this</span></li><li>getMode() <span class="ret">string</span></li><li>toggleFullscreen() <span class="ret">this</span></li><li>toggleZen() <span class="ret">this</span></li></ul></div> <div class="api-card"><h4>模式与全屏</h4><ul><li>setMode(mode) <span class="ret">this</span></li><li>getMode() <span class="ret">string</span></li><li>toggleFullscreen() <span class="ret">this</span></li><li>toggleZen() <span class="ret">this</span></li><li>setOutline(on) <span class="ret">this</span></li></ul></div>
<div class="api-card"><h4>事件与插件</h4><ul><li>on(event, fn) <span class="ret">unsub</span></li><li>off(event, fn) <span class="ret">this</span></li><li>use(plugin) <span class="ret">this</span></li><li>unuse(name) <span class="ret">this</span></li><li>addToolbarButton(cfg) <span class="ret">this</span></li></ul></div> <div class="api-card"><h4>事件与插件</h4><ul><li>on(event, fn) <span class="ret">unsub</span></li><li>off(event, fn) <span class="ret">this</span></li><li>use(plugin) <span class="ret">this</span></li><li>unuse(name) <span class="ret">this</span></li><li>addToolbarButton(cfg) <span class="ret">this</span></li></ul></div>
<div class="api-card"><h4>主题与语言</h4><ul><li>setTheme(name) <span class="ret">this</span></li><li>getTheme() <span class="ret">string</span></li><li>setLocale(loc) <span class="ret">this</span></li><li>getLocale() <span class="ret">string</span></li><li>t(key, params) <span class="ret">string</span></li></ul></div> <div class="api-card"><h4>主题与语言</h4><ul><li>setTheme(name) <span class="ret">this</span></li><li>getTheme() <span class="ret">string</span></li><li>setLocale(loc) <span class="ret">this</span></li><li>getLocale() <span class="ret">string</span></li><li>t(key, params) <span class="ret">string</span></li></ul></div>
<div class="api-card"><h4>统计与状态</h4><ul><li>getStats() <span class="ret">Stats</span></li><li>getStatus() <span class="ret">Status</span></li><li>enable() / disable() <span class="ret">this</span></li><li>refresh() <span class="ret">this</span></li><li>destroy() <span class="ret">void</span></li></ul></div> <div class="api-card"><h4>统计与状态</h4><ul><li>getStats() <span class="ret">Stats</span></li><li>getStatus() <span class="ret">Status</span></li><li>enable() / disable() <span class="ret">this</span></li><li>refresh() <span class="ret">this</span></li><li>destroy() <span class="ret">void</span></li></ul></div>
@@ -230,7 +230,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" target="_blank" rel="noopener">源码仓库</a>
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor/issues" target="_blank" rel="noopener">问题反馈</a> <a href="https://git.metona.cn/MetonaTeam/MetonaEditor/issues" target="_blank" rel="noopener">问题反馈</a>
</div> </div>
<div>MetonaEditor v0.4.1 · TypeScript · MIT License · MetonaTeam</div> <div>MetonaEditor v0.4.2 · TypeScript · MIT License · MetonaTeam</div>
</div> </div>
</footer> </footer>
@@ -240,7 +240,7 @@ MeEditor.themes.switchTheme('dark');
MeEditor.create('#editor', { MeEditor.create('#editor', {
mode: 'split', height: 480, theme: 'dark', mode: 'split', height: 480, theme: 'dark',
value: [ value: [
'# 欢迎使用 MetonaEditor v0.4.1', '# 欢迎使用 MetonaEditor v0.4.2',
'', '',
'一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。', '一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。',
'', '',
+23 -1
View File
@@ -560,10 +560,17 @@ export class MarkdownEditor {
canRedo(): boolean { return this._historyIndex < this._history.length - 1; } canRedo(): boolean { return this._historyIndex < this._history.length - 1; }
_applyHistory(): void { _applyHistory(): void {
// 撤销/重做前记录预览区滚动比例,重渲染后按比例恢复,不再跳顶
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
this._value = this._history[this._historyIndex]; this.textarea.value = this._value; this._value = this._history[this._historyIndex]; this.textarea.value = this._value;
this._render(); this._renderGutter(); this._updateWordCount(); this._render(); this._renderGutter(); this._updateWordCount();
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop; if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
if (this.previewPane && this.config.syncScroll) this.previewPane.scrollTop = 0; if (pv && pvRatio != null) {
const max = pv.scrollHeight - pv.clientHeight;
if (max > 0) pv.scrollTop = pvRatio * max;
}
this._emit('change', this._value); this._emit('change', this._value);
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } } if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } }
} }
@@ -669,6 +676,21 @@ 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; }
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on: boolean): this {
this.config.outline = !!on;
if (this._destroyed || !this.el) return this;
if (this.config.outline) {
this._buildOutline();
this._trackOutlineScroll();
} else {
const panel = this.el.querySelector('.me-outline');
if (panel) panel.remove();
}
return this;
}
isOutline(): boolean { return !!this.config.outline; }
_bindToolbarKeyboard(): void { _bindToolbarKeyboard(): void {
if (!this.toolbarEl) return; if (!this.toolbarEl) return;
const onKeydown = (e: KeyboardEvent) => { const onKeydown = (e: KeyboardEvent) => {
+11 -2
View File
@@ -34,7 +34,6 @@ export function initFloatingToolbar(this: MarkdownEditor): void {
const paddingTop = parseFloat(taStyle.paddingTop) || 16; const paddingTop = parseFloat(taStyle.paddingTop) || 16;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18; const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
const fontSize = parseFloat(taStyle.fontSize) || 13.5; const fontSize = parseFloat(taStyle.fontSize) || 13.5;
const charWidth = fontSize * 0.6;
const textBefore = ta.value.substring(0, start); const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n'); const lines = textBefore.split('\n');
@@ -49,7 +48,17 @@ export function initFloatingToolbar(this: MarkdownEditor): void {
let top = lineLocalTop - toolbarHeight - gap; let top = lineLocalTop - toolbarHeight - gap;
if (top < gap) top = lineLocalTop + lineHeight + gap; if (top < gap) top = lineLocalTop + lineHeight + gap;
const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft; // 逐字符估算列偏移:CJK 全角字符宽约 1em,其余按 0.6em,
// 纯 ASCII 选区回退到原 0.6em 估算,中文选区不再整体偏左。
const CJK_RE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/;
const lineText = lines[lines.length - 1] || '';
const measureX = (col: number): number => {
let w = 0;
const n = Math.max(0, Math.min(col, lineText.length));
for (let i = 0; i < n; i++) w += CJK_RE.test(lineText[i]) ? fontSize : fontSize * 0.6;
return w;
};
const colX = taRect.left + paddingLeft + measureX(midCol) - ta.scrollLeft;
let left = colX - paneRect.left - 80; let left = colX - paneRect.left - 80;
left = Math.max(4, Math.min(left, paneRect.width - 200)); left = Math.max(4, Math.min(left, paneRect.width - 200));
+6
View File
@@ -241,6 +241,12 @@ export const createInstanceI18n = (editor: any): InstanceI18n => {
} }
}); });
} }
// 语言切换即时刷新已渲染的 UI(状态栏统计标签、大纲标题),无需等待下一次输入
if (typeof editor._updateWordCount === 'function') { try { editor._updateWordCount(); } catch (_) {} }
if (editor.el && typeof editor.el.querySelector === 'function') {
const outlineTitle = editor.el.querySelector('.me-outline-title');
if (outlineTitle) outlineTitle.textContent = instanceT('outline') || 'Outline';
}
return instanceLocale; return instanceLocale;
}; };
+2 -2
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Type-safe, lightweight Markdown Editor * MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor * @module metona-editor
* @version 0.4.1 * @version 0.4.2
*/ */
import { MarkdownEditor } from './core'; import { MarkdownEditor } from './core';
@@ -14,7 +14,7 @@ import { highlight, normalizeLanguage, registerLanguage, getSupportedLanguages }
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants'; import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants';
import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants'; import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants';
const VERSION = '0.4.1'; const VERSION = '0.4.2';
const globalPlugins: any[] = []; const globalPlugins: any[] = [];
+1 -1
View File
@@ -111,7 +111,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: '렌더링 실패', renderError: '렌더링 실패',
outline: '개요', outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택', cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다{size}KB > {max}KB', regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
}, },
fr: { fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
+48 -5
View File
@@ -8,16 +8,45 @@ import { t as i18nT } from './i18n';
import { escapeHTML } from './utils'; import { escapeHTML } from './utils';
import type { MarkdownEditor } from './core'; import type { MarkdownEditor } from './core';
/** querySelector 用的 id 转义:优先原生 CSS.escape,环境缺失时退化为简易转义 */
const cssEscape = (id: string): string => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(id);
return id.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
export function buildOutline(this: MarkdownEditor): void { export function buildOutline(this: MarkdownEditor): void {
if (!this.config.outline || !this.previewEl) return; if (!this.config.outline || !this.previewEl) return;
const old = this.el.querySelector('.me-outline'); if (old) old.remove(); const old = this.el.querySelector('.me-outline'); if (old) old.remove();
const headings: Array<{ level: number; id: string; text: string }> = []; const headings: Array<{ level: number; id: string; text: string; srcLine: number }> = [];
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi; const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) { while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') }); headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, ''), srcLine: -1 });
} }
if (!headings.length) return; if (!headings.length) return;
// 计算每个渲染标题在源码中的行号(跳过围栏代码块内部),
// 点击时按行号精确定位光标,重复标题文本也不会串位。
const srcLines: number[] = [];
const lines = this._value.split('\n');
let inFence = false; let fenceChar = '';
const isSetextText = (l: string): boolean =>
/\S/.test(l) && !/^\s{0,3}([-*+]|\d+\.)\s/.test(l) && !/^\s{0,3}>/.test(l) && !/^\s{0,3}(#{1,6}\s|`{3,}|~{3,})/.test(l);
for (let li = 0; li < lines.length; li++) {
const l = lines[li];
const fence = l.match(/^\s{0,3}(`{3,}|~{3,})/);
if (fence) {
if (!inFence) { inFence = true; fenceChar = fence[1][0]; }
else if (fence[1][0] === fenceChar) inFence = false;
continue;
}
if (inFence) continue;
if (/^ {0,3}(?:>\s*)*#{1,6}\s+\S/.test(l)) { srcLines.push(li); continue; }
const next = lines[li + 1] || '';
if (li + 1 < lines.length && /^ {0,3}(={3,}|-{3,})\s*$/.test(next) && isSetextText(l)) srcLines.push(li);
}
headings.forEach((h, i) => { h.srcLine = i < srcLines.length ? srcLines[i] : -1; });
const panel = document.createElement('div'); panel.className = 'me-outline'; const panel = document.createElement('div'); panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`; panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`;
// 迭代式树构建(栈模拟嵌套 ul),O(n) 而非递归的 O(n²) // 迭代式树构建(栈模拟嵌套 ul),O(n) 而非递归的 O(n²)
@@ -34,7 +63,7 @@ export function buildOutline(this: MarkdownEditor): void {
if (item.level < minLevel) continue; if (item.level < minLevel) continue;
closeTo(item.level); closeTo(item.level);
h += '<ul>'; h += '<ul>';
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`; h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.srcLine >= 0 ? item.srcLine + 1 : ''}">${escapeHTML(item.text)}</a>`;
stack.push(item.level); stack.push(item.level);
} }
closeTo(-1); closeTo(-1);
@@ -45,8 +74,19 @@ export function buildOutline(this: MarkdownEditor): void {
panel.addEventListener('click', (e) => { panel.addEventListener('click', (e) => {
const a = (e.target as HTMLElement).closest('a'); if (!a) return; e.preventDefault(); const a = (e.target as HTMLElement).closest('a'); if (!a) return; e.preventDefault();
const id = a.getAttribute('href')!.slice(1); const id = a.getAttribute('href')!.slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(id)); const target = this.previewEl.querySelector('#' + cssEscape(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); } } if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
// 光标定位:优先用构建时记录的源码行号(重复标题也正确),兜底 indexOf
const ord = Array.prototype.indexOf.call(panel.querySelectorAll('a'), a);
const lineIdx = ord > -1 && ord < headings.length ? headings[ord].srcLine : -1;
if (lineIdx >= 0) {
const lineText = lines[lineIdx] || '';
const col = (lineText.match(/^ {0,3}(?:>\s*)*#{1,6}\s+/) || lineText.match(/^ {0,3}/) || [''])[0].length;
this.setCursorPosition(lineIdx + 1, col);
} else {
const idx = this._value.indexOf(target?.textContent || '');
if (idx !== -1) { this.textarea.focus(); this.textarea.setSelectionRange(idx, idx); }
}
}); });
} }
@@ -58,6 +98,9 @@ export function updateOutline(this: MarkdownEditor): void {
export function trackOutlineScroll(this: MarkdownEditor): void { export function trackOutlineScroll(this: MarkdownEditor): void {
if (!this.config.outline || !this.previewPane) return; if (!this.config.outline || !this.previewPane) return;
// 防重复绑定:构造时绑定过、setOutline(true) 再次调用时跳过
if ((this as any)._outlineScrollBound) return;
(this as any)._outlineScrollBound = true;
let ticking = false; let ticking = false;
const onScroll = () => { const onScroll = () => {
if (ticking) return; if (ticking) return;
+4 -1
View File
@@ -134,6 +134,9 @@ const cachedRenderInline = (text: string, env: RenderEnv): string => {
const clearRenderCache = (): void => { renderCache.clear(); }; const clearRenderCache = (): void => { renderCache.clear(); };
/** 当前行内渲染缓存条目数(FIFO 上限 MAX_CACHE_SIZE),供观测/测试缓存淘汰 */
const getRenderCacheSize = (): number => renderCache.size;
// ============ Security utils ============ // ============ Security utils ============
const safeUrl = (url: string | null | undefined): string => { const safeUrl = (url: string | null | undefined): string => {
@@ -796,6 +799,6 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
export { export {
parseMarkdown, parseTokens, renderTokens, parseMarkdown, parseTokens, renderTokens,
safeUrl, slugify, clearRenderCache, registerBlockHandler, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler,
}; };
export default parseMarkdown; export default parseMarkdown;
+32 -21
View File
@@ -17,6 +17,7 @@ export interface EditorLike {
el: HTMLElement; el: HTMLElement;
textarea: HTMLTextAreaElement; textarea: HTMLTextAreaElement;
config?: Record<string, any>; config?: Record<string, any>;
t?: (key: string, params?: Record<string, any>) => string;
on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void; on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void;
off?: (name: string, fn: (...args: any[]) => void) => unknown; off?: (name: string, fn: (...args: any[]) => void) => unknown;
_emit?: (name: string, ...args: any[]) => void; _emit?: (name: string, ...args: any[]) => void;
@@ -145,6 +146,11 @@ export const validateConfig = (schema: PluginSchema = {}, config: Record<string,
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** editor.t locale退 t
* */
const instanceT = (editor: EditorLike): ((key: string, params?: Record<string, any>) => string) =>
(key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
const autoSavePlugin: Plugin = { const autoSavePlugin: Plugin = {
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100, name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
install(editor, options?: Record<string, any>) { install(editor, options?: Record<string, any>) {
@@ -270,6 +276,7 @@ const searchReplacePlugin: Plugin = {
name: 'searchReplace', version: '0.2.1', 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;
const tt = instanceT(editor);
// Inject style once globally // Inject style once globally
if (!document.getElementById('me-search-style')) { if (!document.getElementById('me-search-style')) {
const style = document.createElement('style'); style.id = 'me-search-style'; const style = document.createElement('style'); style.id = 'me-search-style';
@@ -325,7 +332,7 @@ const searchReplacePlugin: Plugin = {
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd); const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
const panel = document.createElement('div'); panel.className = 'me-search'; const panel = document.createElement('div'); panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0'; panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${t('regex')||'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`; panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${tt('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${tt('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="${tt('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="${tt('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${tt('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${tt('regex')||'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${tt('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="${tt('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${tt('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${tt('replaceAll')||'All'}</button></div>`;
editor.el.appendChild(panel); state._panel = panel; editor.el.appendChild(panel); state._panel = panel;
_updateReplaceVisible(); _updateReplaceVisible();
state._regexMode = false; state._matchCase = true; state._wholeWord = false; state._regexMode = false; state._matchCase = true; state._wholeWord = false;
@@ -477,6 +484,7 @@ const imagePastePlugin: Plugin = {
name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60, name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60,
install(editor, options: any = {}) { install(editor, options: any = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!editor || !editor.textarea || typeof document === 'undefined') return;
const tt = instanceT(editor);
const maxSizeKB: number = options.maxSizeKB || 500; 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;
@@ -487,7 +495,7 @@ const imagePastePlugin: Plugin = {
const sizeKB = file.size / 1024; const sizeKB = file.size / 1024;
if (maxSizeKB > 0 && sizeKB > maxSizeKB) { if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault(); e.preventDefault();
if (typeof editor.toast === 'function') editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' }); if (typeof editor.toast === 'function') editor.toast(tt('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break; break;
} }
e.preventDefault(); e.preventDefault();
@@ -510,48 +518,51 @@ const shortcutHelpPlugin: Plugin = {
name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200, name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200,
install(editor) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!editor || !editor.textarea || typeof document === 'undefined') return;
const tt = instanceT(editor);
if (!document.getElementById('me-shortcut-style')) { if (!document.getElementById('me-shortcut-style')) {
const s = document.createElement('style'); s.id = 'me-shortcut-style'; const s = document.createElement('style'); s.id = 'me-shortcut-style';
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`; s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
document.head.appendChild(s); document.head.appendChild(s);
} }
let _panel: HTMLElement | null = null; // 面板引用放入 holder:install 时无法预知未来打开的面板,
// 卸载时经 holder 取实时引用,避免面板残留 DOM。
const st = { panel: null as HTMLElement | null };
const _close = () => { if (_panel) { _panel.remove(); _panel = null; } }; const _close = () => { if (st.panel) { st.panel.remove(); st.panel = null; } };
const _open = () => { const _open = () => {
if (_panel) { _close(); return; } if (st.panel) { _close(); return; }
// i18n-aware shortcut labels // i18n-aware shortcut labels(跟随实例语言)
const builtin: [string, string][] = [ const builtin: [string, string][] = [
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'], ['Ctrl+B', tt('bold') || 'Bold'], ['Ctrl+I', tt('italic') || 'Italic'],
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'], ['Ctrl+U', tt('underline') || 'Underline'], ['Ctrl+K', tt('link') || 'Link'],
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'], ['Ctrl+E', tt('code') || 'Code'], ['Ctrl+1/2/3', tt('h1') || 'Heading'],
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'], ['Ctrl+Q', tt('quote') || 'Quote'], ['Ctrl+Z', tt('undo') || 'Undo'],
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'], ['Ctrl+Y', tt('redo') || 'Redo'], ['Ctrl+S', tt('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'], ['Ctrl+F', tt('search') || 'Search'], ['Ctrl+H', tt('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'], ['Tab', tt('indent') || 'Indent'], ['Shift+Tab', tt('outdent') || 'Outdent'],
['?', t('shortcuts') || 'Shortcuts'], ['?', tt('shortcuts') || 'Shortcuts'],
]; ];
let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; }); let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay'; const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`; overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${tt('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); });
document.body.appendChild(overlay); _panel = overlay; document.body.appendChild(overlay); st.panel = overlay;
}; };
const _onKeydown = (e: KeyboardEvent) => { const _onKeydown = (e: KeyboardEvent) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); _open(); } if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); _open(); }
if (e.key === 'Escape' && _panel) _close(); if (e.key === 'Escape' && st.panel) _close();
}; };
editor.textarea.addEventListener('keydown', _onKeydown); editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown }); setPluginState(editor, K_SHORTCUT, { state: st, _open, _close, _onKeydown });
}, },
destroy(editor: EditorLike) { destroy(editor: EditorLike) {
const state = pluginState<{ _panel: HTMLElement | null; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT); const exposed = pluginState<{ state: { panel: HTMLElement | null }; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT);
if (state) { if (state._panel) { state._panel.remove(); } } if (exposed) { if (exposed.state && exposed.state.panel) { exposed.state.panel.remove(); exposed.state.panel = null; } }
const _onKeydown = state?._onKeydown; const _onKeydown = exposed?._onKeydown;
if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown); if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT); deletePluginState(editor, K_SHORTCUT);
}, },
+323 -3
View File
@@ -17,6 +17,11 @@ if (typeof window !== 'undefined' && typeof window.PointerEvent === 'undefined')
}; };
} }
// jsdom 未实现 scrollIntoView(大纲点击跳转依赖),提供 no-op stub
if (typeof Element !== 'undefined' && typeof (Element.prototype as any).scrollIntoView !== 'function') {
(Element.prototype as any).scrollIntoView = () => {};
}
describe('MarkdownEditor - 构造', () => { describe('MarkdownEditor - 构造', () => {
let container; let container;
@@ -781,10 +786,32 @@ describe('MarkdownEditor - 同步滚动', () => {
document.body.appendChild(c); document.body.appendChild(c);
const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n'); const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true }); const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true });
ed.textarea.scrollTop = 100; // jsdom 的 scrollHeight 恒为 0mock 出可滚动尺寸以驱动比例联动
Object.defineProperty(ed.textarea, 'scrollHeight', { configurable: true, value: 2000 });
Object.defineProperty(ed.textarea, 'clientHeight', { configurable: true, value: 500 });
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
ed.textarea.scrollTop = 750; // textarea 滚动比例 0.5
ed.textarea.dispatchEvent(new Event('scroll')); ed.textarea.dispatchEvent(new Event('scroll'));
// preview 应被同步滚动(scrollTop > 0 或为 0,取决于尺寸) // preview 按同比例滚动:0.5 * (1000 - 500) = 250
expect(typeof ed.previewPane.scrollTop).toBe('number'); expect(ed.previewPane.scrollTop).toBe(250);
ed.destroy();
});
test('分屏模式下 preview 滚动反向联动 textarea', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true });
Object.defineProperty(ed.textarea, 'scrollHeight', { configurable: true, value: 2000 });
Object.defineProperty(ed.textarea, 'clientHeight', { configurable: true, value: 500 });
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
ed.previewPane.scrollTop = 250; // preview 滚动比例 0.5
ed.previewPane.dispatchEvent(new Event('scroll'));
// textarea 按同比例滚动:0.5 * (2000 - 500) = 750
expect(ed.textarea.scrollTop).toBe(750);
ed.destroy(); ed.destroy();
}); });
@@ -3009,3 +3036,296 @@ describe('MarkdownEditor - v0.2.5 maxLength 默认值', () => {
ed.destroy(); ed.destroy();
}); });
}); });
// ============ v0.4.2 事件补齐 ============
describe('MarkdownEditor - v0.4.2 事件补齐', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('渲染路径触发 beforeRender / afterRender 事件', () => {
const ed = new MarkdownEditor(container, { value: '# hi', mode: 'split' });
const before = jest.fn(); const after = jest.fn();
ed.on('beforeRender', before); ed.on('afterRender', after);
ed.setValue('# changed');
expect(before).toHaveBeenCalledTimes(1);
expect(after).toHaveBeenCalledTimes(1);
ed.destroy();
});
test('getHTML 触发 beforeRender / afterRender 事件', () => {
const ed = new MarkdownEditor(container, { value: '# hi' });
const before = jest.fn(); const after = jest.fn();
ed.on('beforeRender', before); ed.on('afterRender', after);
ed.getHTML();
expect(before).toHaveBeenCalledTimes(1);
expect(after).toHaveBeenCalledTimes(1);
ed.destroy();
});
test('toggleZen 触发 zenChange 事件', () => {
const ed = new MarkdownEditor(container, { value: 'test' });
const onZen = jest.fn();
ed.on('zenChange', onZen);
ed.toggleZen();
expect(onZen).toHaveBeenCalledWith(true, ed);
ed.toggleZen();
expect(onZen).toHaveBeenLastCalledWith(false, ed);
ed.destroy();
});
test('toggleFullscreen / exitFullscreen 触发 fullscreen 事件', () => {
const ed = new MarkdownEditor(container, { value: 'test' });
const onFs = jest.fn();
ed.on('fullscreen', onFs);
ed.toggleFullscreen();
expect(ed.isFullscreen()).toBe(true);
expect(onFs).toHaveBeenCalledWith(true, ed);
ed.exitFullscreen();
expect(ed.isFullscreen()).toBe(false);
expect(onFs).toHaveBeenLastCalledWith(false, ed);
ed.destroy();
});
test('全局钩子 beforeCreate / beforeDestroy / afterDestroy 触发', () => {
const beforeCreate = jest.fn(); const beforeDestroy = jest.fn(); const afterDestroy = jest.fn();
MarkdownEditor.on('beforeCreate', beforeCreate);
MarkdownEditor.on('beforeDestroy', beforeDestroy);
MarkdownEditor.on('afterDestroy', afterDestroy);
const ed = new MarkdownEditor(container, {});
expect(beforeCreate).toHaveBeenCalledTimes(1);
ed.destroy();
expect(beforeDestroy).toHaveBeenCalledTimes(1);
expect(afterDestroy).toHaveBeenCalledTimes(1);
MarkdownEditor.off('beforeCreate', beforeCreate);
MarkdownEditor.off('beforeDestroy', beforeDestroy);
MarkdownEditor.off('afterDestroy', afterDestroy);
});
test('copyAsMarkdown 写剪贴板并触发 copy 事件', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write: jest.fn() } });
const ed = new MarkdownEditor(container, { value: '# copy test' });
const onCopy = jest.fn();
ed.on('copy', onCopy);
ed.copyAsMarkdown();
await new Promise((r) => setTimeout(r, 0));
expect(writeText).toHaveBeenCalledWith('# copy test');
expect(onCopy).toHaveBeenCalledWith({ type: 'markdown' }, ed);
delete (navigator as any).clipboard;
ed.destroy();
});
test('copyAsHTML 写富文本剪贴板并触发 copy 事件', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
const write = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write } });
// jsdom 无 ClipboardItem,注入 stub 供富文本写入
const items: any[] = [];
(global as any).ClipboardItem = class { constructor(it: any) { items.push(it); } };
const ed = new MarkdownEditor(container, { value: '**bold**' });
const onCopy = jest.fn();
ed.on('copy', onCopy);
ed.copyAsHTML();
await new Promise((r) => setTimeout(r, 0));
expect(write).toHaveBeenCalledTimes(1);
expect(write.mock.calls[0][0]).toHaveLength(1);
expect(onCopy).toHaveBeenCalledWith({ type: 'html' }, ed);
delete (global as any).ClipboardItem;
delete (navigator as any).clipboard;
ed.destroy();
});
});
// ============ v0.4.2 配置项补齐 ============
describe('MarkdownEditor - v0.4.2 配置项补齐', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('placeholder 配置生效', () => {
const ed = new MarkdownEditor(container, { placeholder: '自定义占位' });
expect(ed.textarea.placeholder).toBe('自定义占位');
ed.destroy();
});
test('未配置 placeholder 时使用翻译默认值', () => {
const ed = new MarkdownEditor(container, {});
expect(ed.textarea.placeholder).toBe(ed.t('placeholder'));
ed.destroy();
});
test('height 数字转为 px', () => {
const ed = new MarkdownEditor(container, { height: 520 });
expect(ed.el.style.height).toBe('520px');
ed.destroy();
});
test('height 字符串原样使用', () => {
const ed = new MarkdownEditor(container, { height: '100%' });
expect(ed.el.style.height).toBe('100%');
ed.destroy();
});
test('spellcheck 默认关闭', () => {
const ed = new MarkdownEditor(container, {});
expect(ed.textarea.spellcheck).toBe(false);
ed.destroy();
});
test('spellcheck: true 开启拼写检查', () => {
const ed = new MarkdownEditor(container, { spellcheck: true });
expect(ed.textarea.spellcheck).toBe(true);
ed.destroy();
});
test('tabSize 自定义值生效且 Tab 插入对应空格数', () => {
const ed = new MarkdownEditor(container, { tabSize: 4 });
expect(ed.textarea.style.tabSize).toBe('4');
ed.textarea.setSelectionRange(0, 0);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe(' ');
ed.destroy();
});
test('tabSize: 0 时 Tab 插入制表符', () => {
const ed = new MarkdownEditor(container, { tabSize: 0 });
ed.textarea.setSelectionRange(0, 0);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe('\t');
ed.destroy();
});
test('historyDebounce 自定义防抖间隔生效', () => {
jest.useFakeTimers();
const ed = new MarkdownEditor(container, { historyDebounce: 100 });
const before = ed._history.length;
ed.textarea.value = 'typed';
ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
jest.advanceTimersByTime(50);
expect(ed._history.length).toBe(before);
jest.advanceTimersByTime(60);
expect(ed._history.length).toBe(before + 1);
jest.useRealTimers();
ed.destroy();
});
});
// ============ v0.4.2 undo/redo 滚动保持 ============
describe('MarkdownEditor - v0.4.2 undo/redo 滚动保持', () => {
test('undo/redo 后预览区滚动位置按比例保持', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'v1', mode: 'split' });
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
ed.setValue('v2\n\nmore content');
ed.previewPane.scrollTop = 250; // 滚动比例 50%
ed.undo();
expect(ed.previewPane.scrollTop).toBe(250);
ed.redo();
expect(ed.previewPane.scrollTop).toBe(250);
ed.destroy();
});
});
// ============ v0.4.2 大纲光标定位 ============
describe('MarkdownEditor - v0.4.2 大纲光标定位', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('点击大纲按源码行号定位光标(重复标题不串位)', () => {
const md = '# alpha\n\ntext\n\n# beta\n\n# alpha\n';
const ed = new MarkdownEditor(container, { value: md, outline: true, mode: 'split' });
const links = ed.el.querySelectorAll('.me-outline a');
expect(links.length).toBe(3);
// 第三个链接对应第二个 'alpha' 标题(源码第 7 行)
(links[2] as HTMLElement).click();
expect(ed.getCursorPosition().line).toBe(7);
ed.destroy();
});
test('data-line 记录源码行号(1 基)', () => {
const ed = new MarkdownEditor(container, { value: '# a\n\n## b\n', outline: true, mode: 'split' });
const links = ed.el.querySelectorAll('.me-outline a');
expect(links[0].getAttribute('data-line')).toBe('1');
expect(links[1].getAttribute('data-line')).toBe('3');
ed.destroy();
});
test('setOutline 运行时开关大纲面板', () => {
const ed = new MarkdownEditor(container, { value: '# title\n\nbody', outline: false, mode: 'split' });
expect(ed.isOutline()).toBe(false);
expect(ed.el.querySelector('.me-outline')).toBeNull();
ed.setOutline(true);
expect(ed.isOutline()).toBe(true);
expect(ed.el.querySelector('.me-outline')).not.toBeNull();
ed.setOutline(false);
expect(ed.el.querySelector('.me-outline')).toBeNull();
ed.destroy();
});
});
// ============ v0.4.2 setLocale 即时刷新 ============
describe('MarkdownEditor - v0.4.2 setLocale 即时刷新', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('setLocale 即时刷新状态栏统计标签', () => {
const ed = new MarkdownEditor(container, { value: 'hello', locale: 'zh-CN' });
expect(ed.statusEl!.textContent).toContain('字符');
ed.setLocale('en-US');
expect(ed.statusEl!.textContent).toContain('Characters');
expect(ed.statusEl!.textContent).not.toContain('字符');
ed.destroy();
});
test('setLocale 即时刷新大纲标题', () => {
const ed = new MarkdownEditor(container, { value: '# t', outline: true, locale: 'zh-CN', mode: 'split' });
expect(ed.el.querySelector('.me-outline-title')!.textContent).toBe('大纲');
ed.setLocale('en-US');
expect(ed.el.querySelector('.me-outline-title')!.textContent).toBe('Outline');
ed.destroy();
});
});
// ============ v0.4.2 浮动工具栏中文选区 ============
describe('MarkdownEditor - v0.4.2 浮动工具栏中文选区', () => {
test('中文选区触发浮动工具栏显示且定位计算不抛错', async () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '这是一段中文文本内容' });
ed.textarea.setSelectionRange(0, 6);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
expect(ed._floatingToolbar).not.toBeNull();
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
ed.destroy();
});
});
+9 -6
View File
@@ -29,7 +29,7 @@ describe('index.ts - 全局 API', () => {
test('VERSION 是字符串', () => { test('VERSION 是字符串', () => {
expect(typeof VERSION).toBe('string'); expect(typeof VERSION).toBe('string');
expect(VERSION).toBe('0.4.1'); expect(VERSION).toBe('0.4.2');
}); });
test('api.default 是 api 本身', () => { test('api.default 是 api 本身', () => {
@@ -193,14 +193,17 @@ describe('index.ts - 解析器导出', () => {
}); });
test('registerBlockHandler 注册自定义块', () => { test('registerBlockHandler 注册自定义块', () => {
let matched = false;
registerBlockHandler({ registerBlockHandler({
name: 'testBlock', name: 'testBlockIndex',
priority: 50, priority: 50,
test: () => null, test: (line) => { if (line === '!!!custom!!!') { matched = true; return true; } return null; },
parse: (_l, i) => ({ token: null, newIndex: i }), parse: (_l, i) => ({ token: { type: 'testBlockIndex', content: 'hit' }, newIndex: i + 1 }),
}); });
// 不抛错即通过 const html = parseMarkdown('!!!custom!!!\nafter');
expect(true).toBe(true); // 自定义 handler 被真实命中并产出 token(回退渲染为 me-block-* div
expect(matched).toBe(true);
expect(html).toContain('me-block-testBlockIndex');
}); });
}); });
+9 -3
View File
@@ -3,7 +3,7 @@
* Markdown * Markdown
*/ */
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from '../src/parser'; import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler } from '../src/parser';
describe('parseMarkdown - 基础', () => { describe('parseMarkdown - 基础', () => {
test('空输入返回空字符串', () => { test('空输入返回空字符串', () => {
@@ -909,12 +909,18 @@ describe('parseMarkdown - v0.1.15 registerBlockHandler', () => {
describe('parseMarkdown - 覆盖率:渲染缓存淘汰', () => { describe('parseMarkdown - 覆盖率:渲染缓存淘汰', () => {
test('缓存超过上限触发 FIFO 淘汰', () => { test('缓存超过上限触发 FIFO 淘汰', () => {
// MAX_CACHE_SIZE = 300,填充 301 个极短文本触发淘汰(避免 CI 超时) // MAX_CACHE_SIZE = 300,填充 301 个极短文本触发淘汰(避免 CI 超时)
clearRenderCache();
const short = 'x'; const short = 'x';
for (let i = 0; i < 301; i++) { for (let i = 0; i < 301; i++) {
parseMarkdown(short + i); parseMarkdown(short + i);
} }
// 不应抛错,且至少有一次淘汰发生 // 缓存条目数被 FIFO 上限约束在 300,证明淘汰真实发生
expect(true).toBe(true); expect(getRenderCacheSize()).toBe(300);
// 超限后再写入仍维持上限
parseMarkdown('overflow-entry');
expect(getRenderCacheSize()).toBe(300);
clearRenderCache();
expect(getRenderCacheSize()).toBe(0);
}); });
}); });
+112 -4
View File
@@ -570,7 +570,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo'; input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click(); ed.el.querySelector('.me-search-next').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); // 首个匹配位于 0,且选中内容即搜索词
expect(ed.textarea.selectionStart).toBe(0);
expect(ed.getSelectedText()).toBe('foo');
}); });
test('查找上一个高亮匹配', () => { test('查找上一个高亮匹配', () => {
@@ -580,7 +582,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click(); ed.el.querySelector('.me-search-next').click();
ed.el.querySelector('.me-search-prev').click(); ed.el.querySelector('.me-search-prev').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); // next 选中第一处(0),prev 回绕到最后一处('foo bar foo baz foo' 中 16
expect(ed.textarea.selectionStart).toBe(16);
expect(ed.getSelectedText()).toBe('foo');
}); });
test('空查询不抛错', () => { test('空查询不抛错', () => {
@@ -600,7 +604,8 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo'; input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); expect(ed.textarea.selectionStart).toBe(0);
expect(ed.getSelectedText()).toBe('foo');
}); });
test('Shift+Enter 在 findInput 上查找上一个', () => { test('Shift+Enter 在 findInput 上查找上一个', () => {
@@ -609,7 +614,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo'; input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); // 初始光标在 0,prev 无更早匹配时回绕到最后一处(16)
expect(ed.textarea.selectionStart).toBe(16);
expect(ed.getSelectedText()).toBe('foo');
}); });
test('替换行 display 切换(none ↔ flex', () => { test('替换行 display 切换(none ↔ flex', () => {
@@ -1301,3 +1308,104 @@ describe('v0.2.5 imagePaste 尺寸限制', () => {
ed.destroy(); ed.destroy();
}); });
}); });
// ============ v0.4.2 fileSystem 事件补齐 ============
describe('fileSystem 插件 - fileOpened / fileSaved 事件', () => {
afterEach(() => {
delete (window as any).showOpenFilePicker;
delete (window as any).showSaveFilePicker;
});
test('openFile 触发 fileOpened 事件并载入内容', async () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
const onOpened = jest.fn();
ed.on('fileOpened', onOpened);
const fakeFile = { name: 'doc.md', text: async () => '# opened content' };
const handle = { getFile: async () => fakeFile };
(window as any).showOpenFilePicker = async () => [handle];
ed.use('fileSystem');
const result = await (ed as any).openFile();
expect(result).toEqual({ name: 'doc.md', content: '# opened content', handle });
expect(ed.getValue()).toBe('# opened content');
expect(onOpened).toHaveBeenCalledWith({ name: 'doc.md', handle }, ed);
ed.destroy();
});
test('saveFile 触发 fileSaved 事件', async () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# save me' });
const onSaved = jest.fn();
ed.on('fileSaved', onSaved);
const writable = { write: jest.fn().mockResolvedValue(undefined), close: jest.fn().mockResolvedValue(undefined) };
const handle = { createWritable: async () => writable };
(window as any).showOpenFilePicker = async () => [];
(window as any).showSaveFilePicker = async () => handle;
ed.use('fileSystem');
const ok = await (ed as any).saveFile();
expect(ok).toBe(true);
expect(writable.write).toHaveBeenCalledWith('# save me');
expect(onSaved).toHaveBeenCalledWith({ handle }, ed);
ed.destroy();
});
});
// ============ v0.4.2 插件面板实例级 i18n ============
describe('v0.4.2 插件面板实例级 i18n', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('searchReplace 面板文案跟随实例 localeen-US', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
ed.use('searchReplace');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
expect(input.placeholder).toBe('Find');
const replaceBtn = ed.el.querySelector('.me-search-replace-all');
expect(replaceBtn.textContent).toBe('Replace All');
ed.el.querySelector('.me-search-close').click();
ed.destroy();
});
test('searchReplace 面板文案跟随实例 localezh-CN', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'zh-CN' });
ed.use('searchReplace');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
expect(input.placeholder).toBe('查找内容');
ed.el.querySelector('.me-search-close').click();
ed.destroy();
});
test('shortcutHelp 面板文案跟随实例 localeen-US', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
ed.use('shortcutHelp');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
const overlay = document.querySelector('.me-shortcut-overlay') as HTMLElement;
expect(overlay).not.toBeNull();
expect(overlay.querySelector('h3')!.textContent).toContain('Shortcuts');
expect(overlay.textContent).toContain('Bold');
ed.destroy();
});
test('unuse shortcutHelp 移除已打开的面板(不残留 DOM)', () => {
const ed = new MarkdownEditor(container, { value: 'foo' });
ed.use('shortcutHelp');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
expect(document.querySelector('.me-shortcut-overlay')).not.toBeNull();
ed.unuse('shortcutHelp');
expect(document.querySelector('.me-shortcut-overlay')).toBeNull();
ed.destroy();
});
});