feat: v0.4.0 — 安全修复×6 + 增量性能×3 + Playwright E2E + 实例级 i18n
CI / test-parser (push) Successful in 9m27s
CI / test-core (push) Successful in 9m34s
CI / test-rest (push) Successful in 9m27s
CI / e2e (push) Failing after 5m15s
CI / verify (20.x) (push) Successful in 9m54s
CI / verify (18.x) (push) Successful in 9m56s
CI / verify (24.x) (push) Successful in 9m48s
CI / test-parser (push) Successful in 9m27s
CI / test-core (push) Successful in 9m34s
CI / test-rest (push) Successful in 9m27s
CI / e2e (push) Failing after 5m15s
CI / verify (20.x) (push) Successful in 9m54s
CI / verify (18.x) (push) Successful in 9m56s
CI / verify (24.x) (push) Successful in 9m48s
0.3.0 修复版: - 脚注 id 属性注入 XSS 防护(行内引用 + 脚注区) - 全局插件注入移至 afterCreate(searchReplace 等依赖 textarea 的插件真正生效)+ use() 同名防重 - 行内渲染缓存附加 refs 指纹,防跨文档引用链接串数据 - 白名单裸标签 <u>/</u> 透传,underline(Ctrl+U)预览可见 - getStatus()/渲染 env 使用实例 locale;replaceAll 替换文本按字面处理 - replaceAllRegex 保留 $1 捕获组语义 0.3.1 性能版: - 高亮规则按语言缓存(registerLanguage 覆盖失效),bench +33% - 统计增量计算:字数/词数/行数差异区间,击键零全量扫描 - outline 树构建 O(n²) → 迭代栈 O(n) - 拖放图片 >500KB 拦截、scrollToLine 真实行高、undo/redo 光标恢复 - selectionChange/cursorMove 与浮动工具栏解耦、unregisterShortcut 大小写归一 - toast 接入 7 种 ANIMATIONS、实例主题订阅随 destroy 断开(dispose) 0.4.0 工程版: - Playwright 真实浏览器冒烟测试(e2e/,22 项断言)+ CI e2e job - sideEffects: false 便于 tree-shaking - MeEditor.destroy() 全面复位(全局钩子清理 + 内部注入钩子重建) - 实例 locale 统一作用于全部 UI 文案(状态栏/工具栏/右键菜单/大纲) 测试 782 → 826,全绿;typecheck/lint/build 通过
This commit is contained in:
@@ -58,6 +58,25 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
runs-on: debian-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.x
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
- name: Run E2E smoke tests
|
||||||
|
run: npm run test:e2e
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
runs-on: debian-latest
|
runs-on: debian-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|||||||
@@ -2,6 +2,62 @@
|
|||||||
|
|
||||||
All notable changes to MetonaEditor will be documented in this file.
|
All notable changes to MetonaEditor will be documented in this file.
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-08-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Playwright 浏览器冒烟测试**: `e2e/` 目录(本地宿主页 + 真实 Chromium),22 项断言覆盖渲染 / 输入实时预览 / 工具栏命令 / 三模式切换 / 主题 / 搜索插件 / 状态栏 / 撤销 / 实例 API,并自动检测页面 console 错误。`npm run test:e2e` 一键运行,CI 新增 e2e job。
|
||||||
|
- **`sideEffects: false`**: 便于消费方打包器 tree-shaking。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **实例 locale 未作用于全部 UI 文案**: 状态栏、工具栏 tooltip、右键菜单、大纲标题、插入链接/图片占位、模式切换播报此前混用全局 `t`;浏览器默认语言与实例 `locale` 不一致时显示错误语言。现 `_i18nCtx` 提前至 DOM 构建前创建,所有 UI 文案统一走实例 `t()`。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`MeEditor.destroy()` 全面复位**: 额外清理全局静态钩子(beforeCreate/afterCreate/beforeChange 等),并自动重建内部全局插件注入钩子,保证 `destroy()` 后 `MeEditor.use()` 依旧可用。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 回归测试 5 个(实例 locale 决定状态栏/按钮/右键菜单文案、destroy 钩子清理与重建、destroy 后全局插件仍可用),总数 821 → 826。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-08-10
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **高亮规则缓存**: `highlight()` 不再每次调用重建正则规则表,按语言缓存(`registerLanguage` 覆盖时自动失效)。
|
||||||
|
- **统计增量计算**: `getStats()` / `_updateWordCount()` / `_renderGutter()` 基于共同前缀/后缀的差异区间增量计算(含单词边界扩展保证一致),击键时不再对全文做 O(n) 正则。
|
||||||
|
- **大纲线性构建**: outline 树构建由递归 O(n²) 改为迭代栈 O(n),长文档无延迟。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **拖放图片大小限制**: 拖入 >500KB 的图片时 toast 警告且不插入,与 imagePaste 插件行为一致。
|
||||||
|
- **`scrollToLine` 真实行高**: 使用 `getComputedStyle().lineHeight` 替代硬编码 22px。
|
||||||
|
- **undo/redo 光标恢复**: 撤销/重做后光标位置 clamp 恢复(不越界)。
|
||||||
|
- **selectionChange / cursorMove 与浮动工具栏解耦**: 事件现无条件触发(`floatingToolbar: false` 时仍可用)。
|
||||||
|
- **`unregisterShortcut` 大小写归一**: 注册 `Ctrl+B` 可用 `ctrl+b` 注销。
|
||||||
|
- **toast 动画接入 ANIMATIONS**: `animation` 参数真正应用 enter/leave 变换与时长缓动(slide/fade/scale/bounce/flip/rotate/zoom)。
|
||||||
|
- **实例主题订阅随销毁断开**: `getThemeContext()` 新增 `dispose()`,`destroy()` 时自动断开外部跟随的 MutationObserver / 轮询订阅。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 回归测试 20 个(高亮缓存失效 / 增量统计一致性与边界 / outline 树结构配对与长文档 / undo 光标 / selectionChange 解耦 / 快捷键归一 / toast 动画 / 拖放限大小 / 主题 dispose),总数 801 → 821。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-08-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **脚注 XSS 属性注入**: 脚注引用的 `fnId`(`[^id]` 中的 id)此前未转义直接拼入 `href` / `id` 属性,`[^a" onclick="x]` 可注入事件属性;现经属性级转义,行内引用与脚注区渲染均覆盖。
|
||||||
|
- **全局插件静默失效**: `MeEditor.use('searchReplace' | 'shortcutHelp' | 'imagePaste')` 此前在 `beforeCreate` 钩子安装,但此时 DOM 尚未构建、`editor.textarea` 不存在,三个依赖 textarea 的插件静默跳过;现改为 `afterCreate` 注入,DOM 就绪后再安装。
|
||||||
|
- **引用链接跨文档串数据**: 行内渲染缓存仅以文本为 key,不同文档中相同文本 + 不同 `[ref]: url` 定义会命中彼此缓存返回错误链接;现缓存 key 附加 refs 指纹(`parseMarkdown` 预计算,`renderTokens` 直接调用时兜底)。
|
||||||
|
- **underline 预览不可见**: `Ctrl+U` 生成的 `<u>` 此前被解析器转义为字面量 `<u>`;现解析器白名单透传裸 `<u>` / `</u>`(无属性,带属性的 `<u onclick=...>` 仍被转义,无注入面),代码块 / 行内代码内不受影响。
|
||||||
|
- **`getStatus().locale` 与实例不一致**: 此前返回全局 locale,实例 `setLocale` 后状态不跟随;现返回实例 locale,渲染 env 的 `locale` 字段同步改为实例级。
|
||||||
|
- **`replaceAll` 替换串 `$` 语义陷阱**: `replaceAll` 的替换文本现按字面处理(`$` 无特殊含义);`replaceAllRegex` 保留正则捕获组语义(`$1`)不变。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`use()` 同名防重**: 同名插件重复安装时 warn 并跳过(`unuse` 后可重新安装),消除全局插件与 `config.plugins` 重复安装问题。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 回归测试 19 个(脚注注入 / 全局插件 / 缓存指纹 / underline / getStatus locale / replaceAll `$` / use 防重),总数 782 → 801。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.2.5] - 2026-08-09
|
## [0.2.5] - 2026-08-09
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
|
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
|
||||||
|
|
||||||
[](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
|
[](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](./tests)
|
[](./tests)
|
||||||
[](./tests)
|
[](./tests)
|
||||||
[](./tsconfig.json)
|
[](./tsconfig.json)
|
||||||
|
|
||||||
@@ -18,8 +18,10 @@
|
|||||||
- **主题系统** — light / dark / warm / auto,CSS 变量可定制,实例级隔离,主题继承,外部跟随
|
- **主题系统** — light / dark / warm / auto,CSS 变量可定制,实例级隔离,主题继承,外部跟随
|
||||||
- **国际化** — zh-CN / en-US / ja / ko / fr / de 六种语言完整翻译,实例级语言隔离,远程加载翻译包
|
- **国际化** — zh-CN / en-US / ja / ko / fr / de 六种语言完整翻译,实例级语言隔离,远程加载翻译包
|
||||||
- **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式、右键上下文菜单
|
- **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式、右键上下文菜单
|
||||||
- **内置语法高亮** — 零依赖轻量高亮器,js/ts/python/bash/css/html 等 13 种语言,`MeEditor.highlight` 即插即用
|
- **内置语法高亮** — 零依赖轻量高亮器,js/ts/python/bash/css/html 等 16 个语言标识,规则缓存零重建开销,`MeEditor.highlight` 即插即用
|
||||||
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护,sanitize 钩子
|
- **性能** — 字数 / 词数 / 行数差异增量统计,击键零全量扫描;大纲 O(n) 线性构建,长文档即时响应
|
||||||
|
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护(含脚注 id),白名单裸标签透传,sanitize 钩子
|
||||||
|
- **工程化** — 826 个单元测试 + Playwright 真实浏览器冒烟测试,CI 并行 4 job,`sideEffects: false` 便于 tree-shaking
|
||||||
- **桌面端优先** — 纯电脑端设计,无移动端冗余代码
|
- **桌面端优先** — 纯电脑端设计,无移动端冗余代码
|
||||||
- **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性
|
- **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性
|
||||||
- **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等)
|
- **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等)
|
||||||
@@ -77,10 +79,10 @@ const editor = MeEditor.create('#editor', { mode: 'split' });
|
|||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- jsDelivr CDN (推荐) -->
|
<!-- jsDelivr CDN (推荐) -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.2.5/dist/metona-editor.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.4.0/dist/metona-editor.min.js"></script>
|
||||||
|
|
||||||
<!-- unpkg CDN -->
|
<!-- unpkg CDN -->
|
||||||
<script src="https://unpkg.com/@metona-team/metona-editor@0.2.5/dist/metona-editor.min.js"></script>
|
<script src="https://unpkg.com/@metona-team/metona-editor@0.4.0/dist/metona-editor.min.js"></script>
|
||||||
|
|
||||||
<!-- Gitea 源 -->
|
<!-- Gitea 源 -->
|
||||||
<script src="https://git.metona.cn/MetonaTeam/MetonaEditor/raw/branch/master/dist/metona-editor.js"></script>
|
<script src="https://git.metona.cn/MetonaTeam/MetonaEditor/raw/branch/master/dist/metona-editor.js"></script>
|
||||||
@@ -122,6 +124,7 @@ MeEditor.create(container, {
|
|||||||
// 内容
|
// 内容
|
||||||
value: '', // 初始 Markdown 文本
|
value: '', // 初始 Markdown 文本
|
||||||
placeholder: '', // 占位符
|
placeholder: '', // 占位符
|
||||||
|
id: '', // 实例 id(默认自动生成,用于分隔条/草稿等存储键隔离)
|
||||||
|
|
||||||
// 视图
|
// 视图
|
||||||
mode: 'split', // 'edit' | 'split' | 'preview'
|
mode: 'split', // 'edit' | 'split' | 'preview'
|
||||||
@@ -256,6 +259,7 @@ editor.exec(action: string): this
|
|||||||
// h1 h2 h3 quote ul ol hr
|
// h1 h2 h3 quote ul ol hr
|
||||||
// link image table
|
// link image table
|
||||||
// indent outdent undo redo
|
// indent outdent undo redo
|
||||||
|
// formatTable(光标所在表格按列宽对齐)
|
||||||
// edit split preview fullscreen
|
// edit split preview fullscreen
|
||||||
// zen wordwrap
|
// zen wordwrap
|
||||||
```
|
```
|
||||||
@@ -352,8 +356,8 @@ editor.isWordWrap(): boolean
|
|||||||
```typescript
|
```typescript
|
||||||
editor.toast(message: string, opts?: {
|
editor.toast(message: string, opts?: {
|
||||||
type?: 'success' | 'error' | 'warning' | 'info';
|
type?: 'success' | 'error' | 'warning' | 'info';
|
||||||
duration?: number;
|
duration?: number; // ms,0 = 不自动消失
|
||||||
animation?: 'fade' | 'slide' | 'scale';
|
animation?: 'fade' | 'slide' | 'scale' | 'bounce' | 'flip' | 'rotate' | 'zoom';
|
||||||
}): this
|
}): this
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -397,7 +401,7 @@ MeEditor.setLocale('en-US')
|
|||||||
MeEditor.getStatus()
|
MeEditor.getStatus()
|
||||||
// => { version, theme, locale, globalPlugins, presetPlugins }
|
// => { version, theme, locale, globalPlugins, presetPlugins }
|
||||||
|
|
||||||
// 销毁全局资源
|
// 销毁全局资源(主题/语言监听、全局插件、全局钩子全面复位)
|
||||||
MeEditor.destroy()
|
MeEditor.destroy()
|
||||||
|
|
||||||
// 解析器独立使用
|
// 解析器独立使用
|
||||||
@@ -465,6 +469,7 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team
|
|||||||
| 粗斜体 | `***text***` `___text___` | `<em><strong>` |
|
| 粗斜体 | `***text***` `___text___` | `<em><strong>` |
|
||||||
| 斜体 | `*italic*` `_italic_` | `<em>` |
|
| 斜体 | `*italic*` `_italic_` | `<em>` |
|
||||||
| 删除线 | `~~text~~` | `<del>` |
|
| 删除线 | `~~text~~` | `<del>` |
|
||||||
|
| 下划线 | `<u>text</u>`(裸标签透传) | `<u>` |
|
||||||
| 高亮 | `==text==` | `<mark>` |
|
| 高亮 | `==text==` | `<mark>` |
|
||||||
| 上标 | `x^2^` | `<sup>` |
|
| 上标 | `x^2^` | `<sup>` |
|
||||||
| 下标 | `H~2~O` | `<sub>` |
|
| 下标 | `H~2~O` | `<sub>` |
|
||||||
@@ -482,6 +487,7 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team
|
|||||||
| 图片 | `` | `<img>` |
|
| 图片 | `` | `<img>` |
|
||||||
| 引用链接 | `[text][ref]` + `[ref]: url` | `<a>` |
|
| 引用链接 | `[text][ref]` + `[ref]: url` | `<a>` |
|
||||||
| 引用图片 | `![alt][ref]` + `[ref]: url` | `<img>` |
|
| 引用图片 | `![alt][ref]` + `[ref]: url` | `<img>` |
|
||||||
|
| 表格格式化 | `exec('formatTable')` | 光标所在表格按列宽对齐 |
|
||||||
| 自动链接 | `<https://...>` | `<a>` |
|
| 自动链接 | `<https://...>` | `<a>` |
|
||||||
| 数学公式 | `$E=mc^2$` `$$\int$$` | `<span>` / `<div>` |
|
| 数学公式 | `$E=mc^2$` `$$\int$$` | `<span>` / `<div>` |
|
||||||
| 脚注 | `text[^1]` | `<sup>` + 底部定义 |
|
| 脚注 | `text[^1]` | `<sup>` + 底部定义 |
|
||||||
@@ -532,7 +538,8 @@ registerBlockHandler({
|
|||||||
|
|
||||||
- 所有文本经 `escapeHTML` 转义
|
- 所有文本经 `escapeHTML` 转义
|
||||||
- 链接 URL 过滤 `javascript:` / `vbscript:` / `file:` / 非图片 `data:`
|
- 链接 URL 过滤 `javascript:` / `vbscript:` / `file:` / 非图片 `data:`
|
||||||
- 属性级注入防护:`href` / `src` / `alt` / `title` / `language-*` 引号与换行均被转义
|
- 属性级注入防护:`href` / `src` / `alt` / `title` / 脚注 id / `language-*` 引号与换行均被转义
|
||||||
|
- 白名单裸标签透传:仅 `<u>` / `</u>`(无属性),带属性的标签仍被转义
|
||||||
- `data:image` 限制最大 500KB
|
- `data:image` 限制最大 500KB
|
||||||
- `sanitize` 钩子供外部净化(如 DOMPurify)
|
- `sanitize` 钩子供外部净化(如 DOMPurify)
|
||||||
- `highlight` 钩子异常自动回退为纯文本
|
- `highlight` 钩子异常自动回退为纯文本
|
||||||
@@ -666,6 +673,9 @@ editor.getThemeContext().adopt();
|
|||||||
|
|
||||||
// 跟随外部元素
|
// 跟随外部元素
|
||||||
editor.getThemeContext().syncWithElement(document.body);
|
editor.getThemeContext().syncWithElement(document.body);
|
||||||
|
|
||||||
|
// 手动断开所有外部跟随订阅(实例 destroy 时也会自动断开)
|
||||||
|
editor.getThemeContext().dispose();
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -749,6 +759,9 @@ npm test
|
|||||||
# 测试覆盖率
|
# 测试覆盖率
|
||||||
npm run test -- --coverage
|
npm run test -- --coverage
|
||||||
|
|
||||||
|
# 浏览器冒烟测试(Playwright + Chromium,需先构建)
|
||||||
|
npm run build && npm run test:e2e
|
||||||
|
|
||||||
# 性能基准(先构建再运行)
|
# 性能基准(先构建再运行)
|
||||||
npm run build && npm run bench
|
npm run build && npm run bench
|
||||||
|
|
||||||
@@ -760,11 +773,11 @@ npm run format
|
|||||||
|
|
||||||
```
|
```
|
||||||
dist/
|
dist/
|
||||||
├── metona-editor.js UMD(浏览器直接引入)
|
├── metona-editor.js UMD(浏览器直接引入)
|
||||||
├── metona-editor.min.js UMD 压缩版(CDN)
|
├── metona-editor.min.js UMD 压缩版(CDN)
|
||||||
├── metona-editor.mjs ES Module
|
├── metona-editor.mjs ES Module
|
||||||
├── metona-editor.cjs CommonJS
|
├── metona-editor.cjs CommonJS
|
||||||
└── metona-editor.d.ts TypeScript 类型声明
|
└── metona-editor.d.ts TypeScript 类型声明
|
||||||
```
|
```
|
||||||
|
|
||||||
### 源码结构
|
### 源码结构
|
||||||
@@ -796,7 +809,8 @@ src/
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 语言 | TypeScript 5 (strict) |
|
| 语言 | TypeScript 5 (strict) |
|
||||||
| 构建 | Rollup 3 |
|
| 构建 | Rollup 3 |
|
||||||
| 测试 | Jest 29 + jsdom |
|
| 测试 | Jest 29 + jsdom(826 用例) |
|
||||||
|
| 浏览器冒烟 | Playwright + Chromium(22 项断言) |
|
||||||
| 类型生成 | rollup-plugin-dts |
|
| 类型生成 | rollup-plugin-dts |
|
||||||
| 零运行时依赖 | ✅ |
|
| 零运行时依赖 | ✅ |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>MetonaEditor E2E Host</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="editor"></div>
|
||||||
|
<script src="../dist/metona-editor.js"></script>
|
||||||
|
<script>
|
||||||
|
window.__ed = MeEditor.create('#editor', {
|
||||||
|
value: [
|
||||||
|
'# E2E Title',
|
||||||
|
'',
|
||||||
|
'**bold** and *italic* and <u>under</u>',
|
||||||
|
'',
|
||||||
|
'```js',
|
||||||
|
'const x = 1; // comment',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'| a | b |',
|
||||||
|
'| --- | --- |',
|
||||||
|
'| 1 | 2 |',
|
||||||
|
].join('\n'),
|
||||||
|
mode: 'split',
|
||||||
|
locale: 'zh-CN',
|
||||||
|
highlight: MeEditor.highlight,
|
||||||
|
plugins: ['searchReplace'],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* MetonaEditor 浏览器冒烟测试(Playwright + 本地静态服务)
|
||||||
|
* 前置: npm run build(需 dist/metona-editor.js 存在)
|
||||||
|
* 用法: npm run test:e2e
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.mjs': 'text/javascript',
|
||||||
|
'.cjs': 'text/javascript',
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let p = decodeURIComponent((req.url || '/').split('?')[0]);
|
||||||
|
if (p === '/') p = '/e2e/host.html';
|
||||||
|
const file = path.resolve(ROOT, '.' + p);
|
||||||
|
if (!file.startsWith(ROOT) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||||
|
res.writeHead(404); res.end('404 Not Found'); return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'text/plain' });
|
||||||
|
fs.createReadStream(file).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
const check = (name, cond) => {
|
||||||
|
if (!cond) throw new Error(`FAIL: ${name}`);
|
||||||
|
passed++;
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||||
|
const port = server.address().port;
|
||||||
|
console.log(`MetonaEditor E2E · http://127.0.0.1:${port}/`);
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
||||||
|
page.on('pageerror', (e) => errors.push(String(e)));
|
||||||
|
|
||||||
|
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' });
|
||||||
|
|
||||||
|
// 1. 编辑器渲染
|
||||||
|
await page.waitForSelector('.me-wrapper', { timeout: 10000 });
|
||||||
|
check('编辑器 DOM 渲染', await page.locator('.me-wrapper').count() === 1);
|
||||||
|
check('textarea 存在', await page.locator('.me-textarea').count() === 1);
|
||||||
|
check('初始 value 写入', (await page.locator('.me-textarea').inputValue()).includes('# E2E Title'));
|
||||||
|
|
||||||
|
// 2. 预览渲染
|
||||||
|
const h1 = await page.locator('.me-preview h1').textContent();
|
||||||
|
check('预览渲染 h1', h1 === 'E2E Title');
|
||||||
|
check('预览渲染粗体', (await page.locator('.me-preview strong').count()) === 1);
|
||||||
|
check('预览渲染下划线 <u>', (await page.locator('.me-preview u').count()) === 1);
|
||||||
|
check('预览渲染表格', (await page.locator('.me-preview table').count()) === 1);
|
||||||
|
|
||||||
|
// 3. 语法高亮
|
||||||
|
check('代码块高亮 keyword', (await page.locator('.me-preview .me-hl-keyword').count()) > 0);
|
||||||
|
check('代码块高亮 comment', (await page.locator('.me-preview .me-hl-comment').count()) > 0);
|
||||||
|
|
||||||
|
// 4. 输入 → 预览实时更新
|
||||||
|
await page.locator('.me-textarea').fill('# 输入测试\n\nhello **world**');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
check('输入后预览更新 h1', (await page.locator('.me-preview h1').textContent()) === '输入测试');
|
||||||
|
check('输入后预览更新 strong', (await page.locator('.me-preview strong').textContent()) === 'world');
|
||||||
|
|
||||||
|
// 5. 工具栏命令(bold 包裹选区)
|
||||||
|
await page.locator('.me-textarea').fill('select me');
|
||||||
|
await page.locator('.me-textarea').evaluate((el) => { el.focus(); el.setSelectionRange(0, 9); });
|
||||||
|
await page.locator('.me-btn[data-action="bold"]').click();
|
||||||
|
check('工具栏 bold 命令', (await page.locator('.me-textarea').inputValue()) === '**select me**');
|
||||||
|
|
||||||
|
// 6. 模式切换
|
||||||
|
await page.locator('.me-btn[data-mode="preview"]').click();
|
||||||
|
check('切换到 preview 模式', await page.locator('.me-body.me-mode-preview').count() === 1);
|
||||||
|
await page.locator('.me-btn[data-mode="edit"]').click();
|
||||||
|
check('切换到 edit 模式', await page.locator('.me-body.me-mode-edit').count() === 1);
|
||||||
|
await page.locator('.me-btn[data-mode="split"]').click();
|
||||||
|
check('切换到 split 模式', await page.locator('.me-body.me-mode-split').count() === 1);
|
||||||
|
|
||||||
|
// 7. 主题切换
|
||||||
|
await page.evaluate(() => window.__ed.setTheme('dark'));
|
||||||
|
check('实例主题 dark', (await page.locator('.me-wrapper').getAttribute('data-md-theme')) === 'dark');
|
||||||
|
|
||||||
|
// 8. 搜索插件(Ctrl+F)
|
||||||
|
await page.locator('.me-textarea').press('Control+f');
|
||||||
|
check('Ctrl+F 打开搜索面板', (await page.locator('.me-search').count()) === 1);
|
||||||
|
await page.locator('.me-search-find').fill('select');
|
||||||
|
await page.locator('.me-search-next').click();
|
||||||
|
check('搜索面板查找选中', (await page.locator('.me-textarea').evaluate((el) => el.value.substring(el.selectionStart, el.selectionEnd))) === 'select');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// 9. 统计状态栏
|
||||||
|
check('状态栏字数统计', (await page.locator('.me-statusbar').textContent()).includes('字符'));
|
||||||
|
|
||||||
|
// 10. 撤销(bold 后撤销回到初始内容)
|
||||||
|
await page.locator('.me-textarea').press('Control+z');
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
check('Ctrl+Z 撤销', (await page.locator('.me-textarea').inputValue()).includes('E2E Title'));
|
||||||
|
|
||||||
|
// 11. 实例 API 直调
|
||||||
|
const stats = await page.evaluate(() => window.__ed.getStats());
|
||||||
|
check('getStats 返回结构', typeof stats.characters === 'number' && typeof stats.words === 'number');
|
||||||
|
const status = await page.evaluate(() => window.__ed.getStatus());
|
||||||
|
check('getStatus 返回结构', status.mode === 'split' && status.theme === 'dark' && status.locale === 'zh-CN');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
server.close();
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
console.error('\n页面错误:', errors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`\n全部通过: ${passed} 项断言 · 无页面错误`);
|
||||||
|
})().catch((e) => {
|
||||||
|
console.error('\n' + (e && e.message ? e.message : e));
|
||||||
|
try { server.close(); } catch (_) {}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Generated
+50
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@metona-team/metona-editor",
|
"name": "@metona-team/metona-editor",
|
||||||
"version": "0.2.2",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@metona-team/metona-editor",
|
"name": "@metona-team/metona-editor",
|
||||||
"version": "0.2.2",
|
"version": "0.3.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.22.0",
|
"@babel/core": "^7.22.0",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"jest-environment-jsdom": "^29.5.0",
|
"jest-environment-jsdom": "^29.5.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prettier": "^2.8.0",
|
"prettier": "^2.8.0",
|
||||||
"rollup": "^3.20.0",
|
"rollup": "^3.20.0",
|
||||||
"rollup-plugin-dts": "^5.3.0",
|
"rollup-plugin-dts": "^5.3.0",
|
||||||
@@ -6567,6 +6568,53 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@metona-team/metona-editor",
|
"name": "@metona-team/metona-editor",
|
||||||
"version": "0.2.5",
|
"version": "0.4.0",
|
||||||
"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",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
|
"sideEffects": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rollup -c",
|
"build": "rollup -c",
|
||||||
"dev": "rollup -c -w",
|
"dev": "rollup -c -w",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"format": "prettier --write src/",
|
"format": "prettier --write src/",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"bench": "node bench/benchmark.cjs",
|
"bench": "node bench/benchmark.cjs",
|
||||||
|
"test:e2e": "node e2e/smoke.cjs",
|
||||||
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -71,6 +73,7 @@
|
|||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"jest-environment-jsdom": "^29.5.0",
|
"jest-environment-jsdom": "^29.5.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prettier": "^2.8.0",
|
"prettier": "^2.8.0",
|
||||||
"rollup": "^3.20.0",
|
"rollup": "^3.20.0",
|
||||||
"rollup-plugin-dts": "^5.3.0",
|
"rollup-plugin-dts": "^5.3.0",
|
||||||
|
|||||||
+16
-14
@@ -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.2.5 — 全功能演示</title>
|
<title>MetonaEditor v0.4.0 — 全功能演示</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.2.5</h1>
|
<h1><span class="grad">Metona</span>Editor v0.4.0</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">780 tests</span>
|
<span class="badge">826 tests</span>
|
||||||
<span class="badge">6 个插件</span>
|
<span class="badge">6 个插件</span>
|
||||||
<span class="badge">桌面端优先</span>
|
<span class="badge">桌面端优先</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,10 +89,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
<div class="info-card"><div class="icon">⌨️</div><h4>快捷键面板</h4><p>按 ? 查看全部快捷键,Ctrl+F/H 搜索替换</p></div>
|
<div class="info-card"><div class="icon">⌨️</div><h4>快捷键面板</h4><p>按 ? 查看全部快捷键,Ctrl+F/H 搜索替换</p></div>
|
||||||
<div class="info-card"><div class="icon">📊</div><h4>Mermaid 图表</h4><p>```mermaid 代码块,加载 Mermaid.js 即可渲染</p></div>
|
<div class="info-card"><div class="icon">📊</div><h4>Mermaid 图表</h4><p>```mermaid 代码块,加载 Mermaid.js 即可渲染</p></div>
|
||||||
<div class="info-card"><div class="icon">🔌</div><h4>6 个预设插件</h4><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem</p></div>
|
<div class="info-card"><div class="icon">🔌</div><h4>6 个预设插件</h4><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem</p></div>
|
||||||
|
<div class="info-card"><div class="icon">⚡</div><h4>增量性能</h4><p>字数/词数/行数差异统计,击键零全量扫描;高亮规则缓存;大纲 O(n)</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
MetonaEditor v0.2.5 · 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.0 · 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>
|
||||||
@@ -100,18 +101,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var demoMd=[
|
var demoMd=[
|
||||||
'# 🚀 MetonaEditor v0.2.5 全功能演示',
|
'# 🚀 MetonaEditor v0.4.0 全功能演示',
|
||||||
'',
|
'',
|
||||||
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
|
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
|
||||||
'> 全模块 TypeScript 严格模式,17 个源文件,780 个测试全部通过。',
|
'> 全模块 TypeScript 严格模式,17 个源文件,826 个测试全部通过。',
|
||||||
'',
|
'',
|
||||||
'## ✨ v0.2.5 新特性',
|
'## ✨ v0.4.0 新特性',
|
||||||
'',
|
'',
|
||||||
'- 内置**零依赖语法高亮**:js / ts / python / bash 等 16 个语言标识',
|
'- **Playwright 浏览器冒烟测试**:22 项断言真实 Chromium 验证',
|
||||||
'- `exportPDF()` 打印导出 · `imagePaste` 尺寸限制(maxSizeKB)',
|
'- **实例级 i18n**:状态栏 / 工具栏 / 右键菜单 / 大纲全部跟随实例语言',
|
||||||
'- 搜索面板支持**大小写 / 全字匹配**开关(Aa / ab)',
|
'- `sideEffects: false` 优化打包 · `MeEditor.destroy()` 全面复位',
|
||||||
'- `maxLength` 限制文档大小 · `zenMode: true` 启动即专注',
|
|
||||||
'- 安全加固:属性级注入防护 · 发布产物 .mjs / .cjs 规范',
|
|
||||||
'',
|
'',
|
||||||
'## 📐 文本样式',
|
'## 📐 文本样式',
|
||||||
'',
|
'',
|
||||||
@@ -119,7 +118,7 @@ var demoMd=[
|
|||||||
'- ***粗斜体*** / ___另一种粗斜体___',
|
'- ***粗斜体*** / ___另一种粗斜体___',
|
||||||
'- *斜体* / _另一种斜体_',
|
'- *斜体* / _另一种斜体_',
|
||||||
'- ~~删除线~~ / ==高亮标记==',
|
'- ~~删除线~~ / ==高亮标记==',
|
||||||
'- 链接内格式: [**粗体链接**](https://example.com)',
|
'- <u>下划线</u>(Ctrl+U)· 链接内格式: [**粗体链接**](https://example.com)',
|
||||||
'- 化学:H~2~O / 数学:x^2^',
|
'- 化学:H~2~O / 数学:x^2^',
|
||||||
'- 代码:`const x = 42;`',
|
'- 代码:`const x = 42;`',
|
||||||
'- Emoji::smile: :rocket: :fire: :rainbow: :coffee:',
|
'- Emoji::smile: :rocket: :fire: :rainbow: :coffee:',
|
||||||
@@ -140,7 +139,10 @@ var demoMd=[
|
|||||||
'',
|
'',
|
||||||
'| 版本 | 日期 | 测试 | 主题 |',
|
'| 版本 | 日期 | 测试 | 主题 |',
|
||||||
'| :--- | :---: | ---: | --- |',
|
'| :--- | :---: | ---: | --- |',
|
||||||
'| v0.2.5 | 2026-08 | 780 | 内置高亮+安全修复 |',
|
'| v0.4.0 | 2026-08 | 826 | E2E冒烟+实例i18n |',
|
||||||
|
'| v0.3.1 | 2026-08 | 821 | 增量统计+高亮缓存 |',
|
||||||
|
'| v0.3.0 | 2026-08 | 801 | 安全修复+缓存指纹 |',
|
||||||
|
'| v0.2.5 | 2026-08 | 782 | 内置高亮+安全修复 |',
|
||||||
'| v0.2.2 | 2026-07 | 694 | 法语+表格格式化 |',
|
'| v0.2.2 | 2026-07 | 694 | 法语+表格格式化 |',
|
||||||
'| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
|
'| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
|
||||||
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
|
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
|
||||||
|
|||||||
+11
-4
@@ -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.2.5 · 文档</title>
|
<title>MetonaEditor v0.4.0 · 文档</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.2.5 · TypeScript · 完整配置、API 与使用指南</p>
|
<p class="sub">MetonaEditor v0.4.0 · TypeScript · 完整配置、API 与使用指南</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -133,6 +133,7 @@ footer a{color:var(--accent);text-decoration:none}
|
|||||||
<pre>MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span>
|
<pre>MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span>
|
||||||
<span class="c-com">// 内容</span>
|
<span class="c-com">// 内容</span>
|
||||||
value<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> placeholder<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span>
|
value<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> placeholder<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span>
|
||||||
|
id<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 实例 id(默认自动生成,隔离存储键)</span>
|
||||||
|
|
||||||
<span class="c-com">// 视图</span>
|
<span class="c-com">// 视图</span>
|
||||||
mode<span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span> height<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
|
mode<span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span> height<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
|
||||||
@@ -231,6 +232,7 @@ editor<span class="c-punc">.</span><span class="c-fn">selectAll</span><span clas
|
|||||||
<tr><td><code>link</code></td><td>[text](url)</td><td>Ctrl+K</td></tr>
|
<tr><td><code>link</code></td><td>[text](url)</td><td>Ctrl+K</td></tr>
|
||||||
<tr><td><code>image</code></td><td></td><td>—</td></tr>
|
<tr><td><code>image</code></td><td></td><td>—</td></tr>
|
||||||
<tr><td><code>table</code></td><td>插入表格</td><td>—</td></tr>
|
<tr><td><code>table</code></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 / Y</td></tr>
|
||||||
@@ -313,6 +315,7 @@ editor<span class="c-punc">.</span><span class="c-fn">getShortcuts</span><span c
|
|||||||
<section id="api-destroy"><h3>生命周期 / Toast</h3>
|
<section id="api-destroy"><h3>生命周期 / Toast</h3>
|
||||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span> <span class="c-fn">isDestroyed</span><span class="c-punc">();</span>
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span> <span class="c-fn">isDestroyed</span><span class="c-punc">();</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">toast</span><span class="c-punc">(</span>msg<span class="c-punc">,</span> <span class="c-punc">{</span> type<span class="c-punc">,</span> duration<span class="c-punc">,</span> animation <span class="c-punc">});</span></pre>
|
editor<span class="c-punc">.</span><span class="c-fn">toast</span><span class="c-punc">(</span>msg<span class="c-punc">,</span> <span class="c-punc">{</span> type<span class="c-punc">,</span> duration<span class="c-punc">,</span> animation <span class="c-punc">});</span></pre>
|
||||||
|
<p>toast 动画支持 <code>fade</code> / <code>slide</code> / <code>scale</code> / <code>bounce</code> / <code>flip</code> / <code>rotate</code> / <code>zoom</code> 七种,<code>duration: 0</code> 不自动消失。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 静态 API -->
|
<!-- 静态 API -->
|
||||||
@@ -370,6 +373,7 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><
|
|||||||
<tr><td>粗斜体 <span class="badge">v0.1.15</span></td><td><code>***text***</code> <code>___text___</code></td><td><em><strong></td></tr>
|
<tr><td>粗斜体 <span class="badge">v0.1.15</span></td><td><code>***text***</code> <code>___text___</code></td><td><em><strong></td></tr>
|
||||||
<tr><td>斜体</td><td><code>*italic*</code> <code>_italic_</code></td><td><em></td></tr>
|
<tr><td>斜体</td><td><code>*italic*</code> <code>_italic_</code></td><td><em></td></tr>
|
||||||
<tr><td>删除线 / 高亮</td><td><code>~~del~~</code> <code>==mark==</code></td><td><del> / <mark></td></tr>
|
<tr><td>删除线 / 高亮</td><td><code>~~del~~</code> <code>==mark==</code></td><td><del> / <mark></td></tr>
|
||||||
|
<tr><td>下划线 <span class="badge">v0.3.0</span></td><td><code><u>text</u></code>(裸标签透传)</td><td><u></td></tr>
|
||||||
<tr><td>上标 / 下标</td><td><code>x^2^</code> <code>H~2~O</code></td><td><sup> / <sub></td></tr>
|
<tr><td>上标 / 下标</td><td><code>x^2^</code> <code>H~2~O</code></td><td><sup> / <sub></td></tr>
|
||||||
<tr><td>行内代码</td><td><code>`code`</code></td><td><code></td></tr>
|
<tr><td>行内代码</td><td><code>`code`</code></td><td><code></td></tr>
|
||||||
<tr><td>代码块</td><td><code>```lang</code></td><td><pre><code></td></tr>
|
<tr><td>代码块</td><td><code>```lang</code></td><td><pre><code></td></tr>
|
||||||
@@ -381,6 +385,7 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><
|
|||||||
<tr><td>任务列表</td><td><code>- [x] done</code></td><td><li class="me-task-item"></td></tr>
|
<tr><td>任务列表</td><td><code>- [x] done</code></td><td><li class="me-task-item"></td></tr>
|
||||||
<tr><td>水平线</td><td><code>---</code> <code>***</code> <code>___</code></td><td><hr></td></tr>
|
<tr><td>水平线</td><td><code>---</code> <code>***</code> <code>___</code></td><td><hr></td></tr>
|
||||||
<tr><td>表格</td><td><code>| a | b |</code></td><td><table> + 列对齐</td></tr>
|
<tr><td>表格</td><td><code>| a | b |</code></td><td><table> + 列对齐</td></tr>
|
||||||
|
<tr><td>表格格式化 <span class="badge">v0.2.2</span></td><td><code>exec('formatTable')</code></td><td>列宽对齐</td></tr>
|
||||||
<tr><td>链接 <span class="badge">v0.1.15</span></td><td><code>[**t**](url)</code></td><td>文本内支持行内格式</td></tr>
|
<tr><td>链接 <span class="badge">v0.1.15</span></td><td><code>[**t**](url)</code></td><td>文本内支持行内格式</td></tr>
|
||||||
<tr><td>图片</td><td><code></code></td><td><img></td></tr>
|
<tr><td>图片</td><td><code></code></td><td><img></td></tr>
|
||||||
<tr><td>引用链接 <span class="badge">v0.2.2</span></td><td><code>[text][ref]</code> + <code>[ref]: url</code></td><td><a> 引用式</td></tr>
|
<tr><td>引用链接 <span class="badge">v0.2.2</span></td><td><code>[text][ref]</code> + <code>[ref]: url</code></td><td><a> 引用式</td></tr>
|
||||||
@@ -393,7 +398,7 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><
|
|||||||
<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>
|
||||||
</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> 引号转义),提供 <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><u></code> 透传,提供 <code>sanitize</code> 钩子。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="parser-api"><h2>解析器 API</h2>
|
<section id="parser-api"><h2>解析器 API</h2>
|
||||||
@@ -452,6 +457,8 @@ themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><s
|
|||||||
|
|
||||||
<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">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>
|
||||||
themeUtils<span class="c-punc">.</span><span class="c-fn">exportCSSVars</span><span class="c-punc">();</span> themeUtils<span class="c-punc">.</span><span class="c-fn">getCSSVariable</span><span class="c-punc">(</span><span class="c-str">'accent'</span><span class="c-punc">);</span></pre>
|
themeUtils<span class="c-punc">.</span><span class="c-fn">exportCSSVars</span><span class="c-punc">();</span> themeUtils<span class="c-punc">.</span><span class="c-fn">getCSSVariable</span><span class="c-punc">(</span><span class="c-str">'accent'</span><span class="c-punc">);</span></pre>
|
||||||
@@ -480,7 +487,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
MetonaEditor v0.2.5 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
MetonaEditor v0.4.0 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
+10
-8
@@ -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.2.5 · TypeScript · 桌面端 Markdown 编辑器</title>
|
<title>MetonaEditor v0.4.0 · 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.2.5</span>
|
<span class="badge accent">v0.4.0</span>
|
||||||
<span class="badge">TypeScript</span>
|
<span class="badge">TypeScript</span>
|
||||||
<span class="badge">零运行时依赖</span>
|
<span class="badge">零运行时依赖</span>
|
||||||
<span class="badge">780 tests</span>
|
<span class="badge">826 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">780</div><div class="stat-label">单元测试</div></div>
|
<div class="stat"><div class="stat-num">826</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,6 +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) 构建;826 单测 + Playwright 真实浏览器冒烟,CI 并行 4 job。</p></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -166,7 +167,7 @@ footer a:hover{text-decoration:underline}
|
|||||||
<span class="c-kw">const</span> MeEditor <span class="c-punc">=</span> <span class="c-fn">require</span><span class="c-punc">(</span><span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">);</span>
|
<span class="c-kw">const</span> MeEditor <span class="c-punc">=</span> <span class="c-fn">require</span><span class="c-punc">(</span><span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">);</span>
|
||||||
|
|
||||||
<span class="c-com">// 方式二:CDN 引入</span>
|
<span class="c-com">// 方式二:CDN 引入</span>
|
||||||
<span class="c-com"><script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.2.5/dist/metona-editor.min.js"></script></span>
|
<span class="c-com"><script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.4.0/dist/metona-editor.min.js"></script></span>
|
||||||
|
|
||||||
<span class="c-com">// 方式三:本地文件</span>
|
<span class="c-com">// 方式三:本地文件</span>
|
||||||
<span class="c-com"><script src="./dist/metona-editor.min.js"></script></span>
|
<span class="c-com"><script src="./dist/metona-editor.min.js"></script></span>
|
||||||
@@ -229,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.2.5 · TypeScript · MIT License · MetonaTeam</div>
|
<div>MetonaEditor v0.4.0 · TypeScript · MIT License · MetonaTeam</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -239,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.2.5',
|
'# 欢迎使用 MetonaEditor v0.4.0',
|
||||||
'',
|
'',
|
||||||
'一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。',
|
'一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。',
|
||||||
'',
|
'',
|
||||||
@@ -269,7 +270,7 @@ MeEditor.create('#editor', {
|
|||||||
'| --- | --- |',
|
'| --- | --- |',
|
||||||
'| **粗体** / *斜体* / ***粗斜体*** | 强调 |',
|
'| **粗体** / *斜体* / ***粗斜体*** | 强调 |',
|
||||||
'| `code` / ``` ```lang ``` | 代码 |',
|
'| `code` / ``` ```lang ``` | 代码 |',
|
||||||
'| [链接](url) /  | 链接 |',
|
'| [链接](https://example.com) /  | 链接 |',
|
||||||
'| $E=mc^2$ / $$\int$$ | 数学公式 |',
|
'| $E=mc^2$ / $$\int$$ | 数学公式 |',
|
||||||
'| - [x] 任务列表 | GFM |',
|
'| - [x] 任务列表 | GFM |',
|
||||||
'| :rocket: :fire: | Emoji 短码 |',
|
'| :rocket: :fire: | Emoji 短码 |',
|
||||||
@@ -279,6 +280,7 @@ MeEditor.create('#editor', {
|
|||||||
'- [x] TypeScript 重构完成',
|
'- [x] TypeScript 重构完成',
|
||||||
'- [x] 解析器覆盖率 99%',
|
'- [x] 解析器覆盖率 99%',
|
||||||
'- [x] 6 个预设插件',
|
'- [x] 6 个预设插件',
|
||||||
|
'- [x] Playwright 浏览器冒烟测试',
|
||||||
'- [ ] VS Code 扩展',
|
'- [ ] VS Code 扩展',
|
||||||
'',
|
'',
|
||||||
'> **提示**:按 `?` 查看快捷键 · 拖放文件到编辑区 · Ctrl+F 搜索 · Ctrl+H 替换',
|
'> **提示**:按 `?` 查看快捷键 · 拖放文件到编辑区 · Ctrl+F 搜索 · Ctrl+H 替换',
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Commands — editing command implementations
|
* MetonaEditor Commands — editing command implementations
|
||||||
* @module commands
|
* @module commands
|
||||||
* @version 0.2.5
|
* @version 0.4.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { t as i18nT } from './i18n';
|
import { t as i18nT } from './i18n';
|
||||||
@@ -49,7 +49,7 @@ export function insertBlock(this: MarkdownEditor, text: string): void {
|
|||||||
|
|
||||||
export function insertLink(this: MarkdownEditor): void {
|
export function insertLink(this: MarkdownEditor): void {
|
||||||
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
|
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
|
||||||
const sel = ta.value.slice(start, end) || i18nT('link') || 'link';
|
const sel = ta.value.slice(start, end) || this.t('link') || 'link';
|
||||||
const url = 'https://';
|
const url = 'https://';
|
||||||
const insert = `[${sel}](${url})`;
|
const insert = `[${sel}](${url})`;
|
||||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
|
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
|
||||||
@@ -59,7 +59,7 @@ export function insertLink(this: MarkdownEditor): void {
|
|||||||
|
|
||||||
export function insertImage(this: MarkdownEditor): void {
|
export function insertImage(this: MarkdownEditor): void {
|
||||||
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
|
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
|
||||||
const sel = ta.value.slice(start, end) || i18nT('image') || 'image';
|
const sel = ta.value.slice(start, end) || this.t('image') || 'image';
|
||||||
const url = 'https://';
|
const url = 'https://';
|
||||||
const insert = ``;
|
const insert = ``;
|
||||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
|
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
|
||||||
@@ -68,7 +68,7 @@ export function insertImage(this: MarkdownEditor): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function insertTable(this: MarkdownEditor, rows = 3, cols = 3): void {
|
export function insertTable(this: MarkdownEditor, rows = 3, cols = 3): void {
|
||||||
const header = Array.from({ length: cols }, (_, i) => `${i18nT('tableCols') || '列'}${i + 1}`).join(' | ');
|
const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | ');
|
||||||
const sep = Array.from({ length: cols }, () => '---').join(' | ');
|
const sep = Array.from({ length: cols }, () => '---').join(' | ');
|
||||||
let md = `| ${header} |\n| ${sep} |\n`;
|
let md = `| ${header} |\n| ${sep} |\n`;
|
||||||
for (let r = 1; r < rows; r++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`;
|
for (let r = 1; r < rows; r++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`;
|
||||||
|
|||||||
+7
-7
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Context Menu — right-click menu
|
* MetonaEditor Context Menu — right-click menu
|
||||||
* @module context-menu
|
* @module context-menu
|
||||||
* @version 0.2.5
|
* @version 0.4.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { t as i18nT } from './i18n';
|
import { t as i18nT } from './i18n';
|
||||||
@@ -28,13 +28,13 @@ export function showContextMenu(this: MarkdownEditor, e: MouseEvent): void {
|
|||||||
const ta = this.textarea;
|
const ta = this.textarea;
|
||||||
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
|
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
|
||||||
const defaultItems: Array<{ label?: string; action?: string; shortcut?: string; sep?: boolean; disabled?: boolean; onClick?: () => void }> = [
|
const defaultItems: Array<{ label?: string; action?: string; shortcut?: string; sep?: boolean; disabled?: boolean; onClick?: () => void }> = [
|
||||||
{ label: i18nT('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
|
{ label: this.t('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
|
||||||
{ label: i18nT('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
|
{ label: this.t('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
|
||||||
{ sep: true },
|
{ sep: true },
|
||||||
{ label: i18nT('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
|
{ label: this.t('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
|
||||||
{ label: i18nT('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
|
{ label: this.t('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
|
||||||
{ label: i18nT('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
|
{ label: this.t('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
|
||||||
{ label: i18nT('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' },
|
{ label: this.t('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' },
|
||||||
];
|
];
|
||||||
const items = [...defaultItems];
|
const items = [...defaultItems];
|
||||||
if (this._contextMenuItems.length) {
|
if (this._contextMenuItems.length) {
|
||||||
|
|||||||
+131
-32
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { generateId, escapeHTML, isBrowser } from './utils';
|
import { generateId, escapeHTML, isBrowser } from './utils';
|
||||||
import { DEFAULTS, ICONS, THEMES } from './constants';
|
import { DEFAULTS, ICONS, THEMES, ANIMATIONS } from './constants';
|
||||||
import type { EditorOptions, EditMode, ToolbarItem, RenderEnv, ThemeName } from './constants';
|
import type { EditorOptions, EditMode, ToolbarItem, RenderEnv, ThemeName } from './constants';
|
||||||
import { injectStyles } from './styles';
|
import { injectStyles } from './styles';
|
||||||
import { t as i18nT, getCurrentLocale, getLocaleDirection, createInstanceI18n } from './i18n';
|
import { t as i18nT, getCurrentLocale, getLocaleDirection, createInstanceI18n } from './i18n';
|
||||||
@@ -80,6 +80,10 @@ export class MarkdownEditor {
|
|||||||
_zenMode!: boolean;
|
_zenMode!: boolean;
|
||||||
_wordWrap!: boolean;
|
_wordWrap!: boolean;
|
||||||
_syncing!: boolean;
|
_syncing!: boolean;
|
||||||
|
_statsCache: { value: string; stats: any } | null = null;
|
||||||
|
_pendingCursor: { start: number; end: number } | null = null;
|
||||||
|
_lastSelStart = 0;
|
||||||
|
_lastSelEnd = 0;
|
||||||
_zenMouseHandler!: ((e: MouseEvent) => void) | null;
|
_zenMouseHandler!: ((e: MouseEvent) => void) | null;
|
||||||
_ariaLive!: HTMLElement;
|
_ariaLive!: HTMLElement;
|
||||||
_renderFn!: (md: string, env: RenderEnv) => string;
|
_renderFn!: (md: string, env: RenderEnv) => string;
|
||||||
@@ -141,9 +145,10 @@ export class MarkdownEditor {
|
|||||||
|
|
||||||
MarkdownEditor.trigger('beforeCreate', this);
|
MarkdownEditor.trigger('beforeCreate', this);
|
||||||
injectStyles();
|
injectStyles();
|
||||||
|
// 实例 i18n 需在 _buildDOM 之前创建:DOM 构建与工具栏渲染会读取实例语言
|
||||||
|
this._i18nCtx = createInstanceI18n(this);
|
||||||
this._buildDOM();
|
this._buildDOM();
|
||||||
this._themeCtx = createInstanceTheme(this);
|
this._themeCtx = createInstanceTheme(this);
|
||||||
this._i18nCtx = createInstanceI18n(this);
|
|
||||||
|
|
||||||
if (options.theme && typeof document !== 'undefined') {
|
if (options.theme && typeof document !== 'undefined') {
|
||||||
document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme));
|
document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme));
|
||||||
@@ -188,7 +193,7 @@ export class MarkdownEditor {
|
|||||||
if (this.config.readOnly) wrapper.classList.add('me-readonly');
|
if (this.config.readOnly) wrapper.classList.add('me-readonly');
|
||||||
wrapper.dataset.id = this.id;
|
wrapper.dataset.id = this.id;
|
||||||
wrapper.setAttribute('role', 'application');
|
wrapper.setAttribute('role', 'application');
|
||||||
wrapper.setAttribute('aria-label', i18nT('edit'));
|
wrapper.setAttribute('aria-label', this.t('edit'));
|
||||||
if (this.config.className) {
|
if (this.config.className) {
|
||||||
this.config.className.split(/\s+/).filter(Boolean).forEach((c) => wrapper.classList.add(c));
|
this.config.className.split(/\s+/).filter(Boolean).forEach((c) => wrapper.classList.add(c));
|
||||||
}
|
}
|
||||||
@@ -208,13 +213,13 @@ export class MarkdownEditor {
|
|||||||
textarea.spellcheck = !!this.config.spellcheck; textarea.readOnly = !!this.config.readOnly;
|
textarea.spellcheck = !!this.config.spellcheck; textarea.readOnly = !!this.config.readOnly;
|
||||||
if (this.config.maxLength && this.config.maxLength > 0) textarea.maxLength = this.config.maxLength;
|
if (this.config.maxLength && this.config.maxLength > 0) textarea.maxLength = this.config.maxLength;
|
||||||
textarea.style.tabSize = String(this.config.tabSize || 2);
|
textarea.style.tabSize = String(this.config.tabSize || 2);
|
||||||
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
|
textarea.placeholder = this.config.placeholder || this.t('placeholder');
|
||||||
textarea.setAttribute('aria-label', i18nT('edit'));
|
textarea.setAttribute('aria-label', this.t('edit'));
|
||||||
editorInner.appendChild(textarea);
|
editorInner.appendChild(textarea);
|
||||||
editorPane.appendChild(editorInner);
|
editorPane.appendChild(editorInner);
|
||||||
const divider = document.createElement('div'); divider.className = 'me-divider'; divider.setAttribute('role', 'separator');
|
const divider = document.createElement('div'); divider.className = 'me-divider'; divider.setAttribute('role', 'separator');
|
||||||
const previewPane = document.createElement('div'); previewPane.className = 'me-preview-pane';
|
const previewPane = document.createElement('div'); previewPane.className = 'me-preview-pane';
|
||||||
previewPane.setAttribute('role', 'region'); previewPane.setAttribute('aria-label', i18nT('preview'));
|
previewPane.setAttribute('role', 'region'); previewPane.setAttribute('aria-label', this.t('preview'));
|
||||||
const preview = document.createElement('div'); preview.className = 'me-preview'; previewPane.appendChild(preview);
|
const preview = document.createElement('div'); preview.className = 'me-preview'; previewPane.appendChild(preview);
|
||||||
body.appendChild(editorPane); body.appendChild(divider); body.appendChild(previewPane);
|
body.appendChild(editorPane); body.appendChild(divider); body.appendChild(previewPane);
|
||||||
wrapper.appendChild(toolbar); wrapper.appendChild(body);
|
wrapper.appendChild(toolbar); wrapper.appendChild(body);
|
||||||
@@ -252,7 +257,7 @@ export class MarkdownEditor {
|
|||||||
_createBtn(item: string): HTMLButtonElement {
|
_createBtn(item: string): HTMLButtonElement {
|
||||||
const btn = document.createElement('button'); btn.type = 'button';
|
const btn = document.createElement('button'); btn.type = 'button';
|
||||||
btn.className = `me-btn me-btn-${item}`; btn.dataset.action = item;
|
btn.className = `me-btn me-btn-${item}`; btn.dataset.action = item;
|
||||||
const label = i18nT(item) || item; btn.title = label; btn.setAttribute('aria-label', label);
|
const label = this.t(item) || item; btn.title = label; btn.setAttribute('aria-label', label);
|
||||||
btn.innerHTML = ICONS[item] || `<span>${escapeHTML(item)}</span>`;
|
btn.innerHTML = ICONS[item] || `<span>${escapeHTML(item)}</span>`;
|
||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
@@ -278,7 +283,7 @@ export class MarkdownEditor {
|
|||||||
ta.addEventListener('keydown', onKeydown);
|
ta.addEventListener('keydown', onKeydown);
|
||||||
const onKeypress = (e: KeyboardEvent) => this._handleBracketAutoClose(e);
|
const onKeypress = (e: KeyboardEvent) => this._handleBracketAutoClose(e);
|
||||||
ta.addEventListener('keypress', onKeypress);
|
ta.addEventListener('keypress', onKeypress);
|
||||||
const onCursorActivity = () => { this._updateCurrentLine(); };
|
const onCursorActivity = () => { this._updateCurrentLine(); this._emitCursorEvents(); };
|
||||||
ta.addEventListener('click', onCursorActivity);
|
ta.addEventListener('click', onCursorActivity);
|
||||||
ta.addEventListener('keyup', onCursorActivity);
|
ta.addEventListener('keyup', onCursorActivity);
|
||||||
const onScroll = () => { this._syncScroll(); };
|
const onScroll = () => { this._syncScroll(); };
|
||||||
@@ -421,8 +426,8 @@ export class MarkdownEditor {
|
|||||||
this._lastRenderedValue = this._value;
|
this._lastRenderedValue = this._value;
|
||||||
MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this);
|
MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this);
|
||||||
let html: string;
|
let html: string;
|
||||||
try { html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); }
|
try { html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: this.getLocale() }); }
|
||||||
catch (err: any) { console.error('MeEditor render error:', err); html = `<p style="color:#ef4444">${i18nT('renderError')}: ${escapeHTML(err.message)}</p>`; }
|
catch (err: any) { console.error('MeEditor render error:', err); html = `<p style="color:#ef4444">${this.t('renderError')}: ${escapeHTML(err.message)}</p>`; }
|
||||||
if (typeof this.config.sanitize === 'function') { try { html = this.config.sanitize(html); } catch (e) { console.error('sanitize error:', e); } }
|
if (typeof this.config.sanitize === 'function') { try { html = this.config.sanitize(html); } catch (e) { console.error('sanitize error:', e); } }
|
||||||
this.previewEl.innerHTML = html; this._renderGutter();
|
this.previewEl.innerHTML = html; this._renderGutter();
|
||||||
MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this);
|
MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this);
|
||||||
@@ -430,7 +435,8 @@ export class MarkdownEditor {
|
|||||||
|
|
||||||
_renderGutter(): void {
|
_renderGutter(): void {
|
||||||
if (!this.config.lineNumbers || !this.gutter) return;
|
if (!this.config.lineNumbers || !this.gutter) return;
|
||||||
const lines = this._value ? this._value.split('\n').length : 1;
|
const stats = this.getStats();
|
||||||
|
const lines = stats.lines > 0 ? stats.lines : 1;
|
||||||
const current = this.gutter.children.length;
|
const current = this.gutter.children.length;
|
||||||
if (current === lines) return;
|
if (current === lines) return;
|
||||||
if (current < lines) {
|
if (current < lines) {
|
||||||
@@ -454,6 +460,17 @@ export class MarkdownEditor {
|
|||||||
const cur = this.gutter.children[lineNum - 1] as HTMLElement; if (cur) cur.classList.add('me-gutter-active');
|
const cur = this.gutter.children[lineNum - 1] as HTMLElement; if (cur) cur.classList.add('me-gutter-active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 选区/光标事件:与浮动工具栏解耦,任何实例都会触发 */
|
||||||
|
_emitCursorEvents(): void {
|
||||||
|
if (!this.textarea) return;
|
||||||
|
const s = this.textarea.selectionStart; const e = this.textarea.selectionEnd;
|
||||||
|
if (s !== this._lastSelStart || e !== this._lastSelEnd) {
|
||||||
|
this._lastSelStart = s; this._lastSelEnd = e;
|
||||||
|
this._emit('selectionChange', { start: s, end: e, text: this._value.slice(s, e) });
|
||||||
|
}
|
||||||
|
this._emit('cursorMove', this.getCursorPosition());
|
||||||
|
}
|
||||||
|
|
||||||
_handleSmartEnter(e: KeyboardEvent): void {
|
_handleSmartEnter(e: KeyboardEvent): void {
|
||||||
const ta = this.textarea; const start = ta.selectionStart;
|
const ta = this.textarea; const start = ta.selectionStart;
|
||||||
const val = ta.value; const lineStart = val.lastIndexOf('\n', start - 1) + 1;
|
const val = ta.value; const lineStart = val.lastIndexOf('\n', start - 1) + 1;
|
||||||
@@ -493,13 +510,21 @@ export class MarkdownEditor {
|
|||||||
|
|
||||||
_bindDragDrop(): void {
|
_bindDragDrop(): void {
|
||||||
const ta = this.textarea; if (!ta) return;
|
const ta = this.textarea; if (!ta) return;
|
||||||
|
const DROP_IMAGE_MAX_KB = 500;
|
||||||
const onDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; };
|
const onDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; };
|
||||||
const onDrop = (e: DragEvent) => {
|
const onDrop = (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const files = e.dataTransfer?.files;
|
const files = e.dataTransfer?.files;
|
||||||
if (files && files.length) {
|
if (files && files.length) {
|
||||||
Array.from(files).forEach((file) => {
|
Array.from(files).forEach((file) => {
|
||||||
if (file.type.startsWith('image/')) { const reader = new FileReader(); reader.onload = () => { this.insert(`\n`); }; reader.readAsDataURL(file); }
|
if (file.type.startsWith('image/')) {
|
||||||
|
const sizeKB = file.size / 1024;
|
||||||
|
if (sizeKB > DROP_IMAGE_MAX_KB) {
|
||||||
|
this.toast(this.t('imageTooLarge', { size: sizeKB.toFixed(0), max: DROP_IMAGE_MAX_KB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${DROP_IMAGE_MAX_KB}KB)`, { type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader(); reader.onload = () => { this.insert(`\n`); }; reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
else if (file.type.startsWith('text/') || /\.(md|txt|js|ts|json|css|html|xml|yml|yaml)$/i.test(file.name)) { const reader = new FileReader(); reader.onload = () => this.insert(reader.result as string); reader.readAsText(file); }
|
else if (file.type.startsWith('text/') || /\.(md|txt|js|ts|json|css|html|xml|yml|yaml)$/i.test(file.name)) { const reader = new FileReader(); reader.onload = () => this.insert(reader.result as string); reader.readAsText(file); }
|
||||||
}); return;
|
}); return;
|
||||||
}
|
}
|
||||||
@@ -526,8 +551,8 @@ export class MarkdownEditor {
|
|||||||
this._historyIndex = this._history.length - 1;
|
this._historyIndex = this._history.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): this { if (this._historyIndex <= 0) return this; this._historyIndex--; this._applyHistory(); return this; }
|
undo(): this { if (this._historyIndex <= 0) return this; this._captureCursor(); this._historyIndex--; this._applyHistory(); this._restoreCapturedCursor(); return this; }
|
||||||
redo(): this { if (this._historyIndex >= this._history.length - 1) return this; this._historyIndex++; this._applyHistory(); return this; }
|
redo(): this { if (this._historyIndex >= this._history.length - 1) return this; this._captureCursor(); this._historyIndex++; this._applyHistory(); this._restoreCapturedCursor(); return this; }
|
||||||
canUndo(): boolean { return this._historyIndex > 0; }
|
canUndo(): boolean { return this._historyIndex > 0; }
|
||||||
canRedo(): boolean { return this._historyIndex < this._history.length - 1; }
|
canRedo(): boolean { return this._historyIndex < this._history.length - 1; }
|
||||||
|
|
||||||
@@ -540,6 +565,20 @@ export class MarkdownEditor {
|
|||||||
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); } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_captureCursor(): void {
|
||||||
|
if (!this.textarea) return;
|
||||||
|
this._pendingCursor = { start: this.textarea.selectionStart, end: this.textarea.selectionEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreCapturedCursor(): void {
|
||||||
|
if (!this._pendingCursor || !this.textarea) return;
|
||||||
|
const len = this._value.length;
|
||||||
|
const start = Math.min(this._pendingCursor.start, len);
|
||||||
|
const end = Math.min(this._pendingCursor.end, len);
|
||||||
|
try { this.textarea.setSelectionRange(start, end); } catch {}
|
||||||
|
this._pendingCursor = null;
|
||||||
|
}
|
||||||
|
|
||||||
exec(action: string, ...args: any[]): this {
|
exec(action: string, ...args: any[]): this {
|
||||||
const actions: Record<string, () => void> = {
|
const actions: Record<string, () => void> = {
|
||||||
bold: () => this._wrapSelection('**', '**'), italic: () => this._wrapSelection('*', '*'),
|
bold: () => this._wrapSelection('**', '**'), italic: () => this._wrapSelection('*', '*'),
|
||||||
@@ -564,7 +603,7 @@ export class MarkdownEditor {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMode(mode: EditMode): this { if (!MODES.includes(mode) || mode === this._mode) return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit') this._render(); this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${i18nT(mode) || mode} mode`); if (typeof this.config.onModeChange === 'function') { try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); } } return this; }
|
setMode(mode: EditMode): this { if (!MODES.includes(mode) || mode === this._mode) return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit') this._render(); this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${this.t(mode) || mode} mode`); if (typeof this.config.onModeChange === 'function') { try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); } } return this; }
|
||||||
getMode(): EditMode { return this._mode; }
|
getMode(): EditMode { return this._mode; }
|
||||||
|
|
||||||
_updateModeButtons(): void {
|
_updateModeButtons(): void {
|
||||||
@@ -579,7 +618,7 @@ export class MarkdownEditor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFullscreen(): this { this._fullscreen = !this._fullscreen; this.el.classList.toggle('me-fullscreen', this._fullscreen); const fsBtn = this.toolbarEl.querySelector('.me-btn-fullscreen') as HTMLButtonElement; if (fsBtn) fsBtn.title = this._fullscreen ? (i18nT('fullscreenExit') || '退出全屏') : (i18nT('fullscreen') || '全屏'); this._emit('fullscreen', this._fullscreen); if (typeof this.config.onFullscreen === 'function') { try { this.config.onFullscreen(this._fullscreen, this); } catch (e) { console.error(e); } } return this; }
|
toggleFullscreen(): this { this._fullscreen = !this._fullscreen; this.el.classList.toggle('me-fullscreen', this._fullscreen); const fsBtn = this.toolbarEl.querySelector('.me-btn-fullscreen') as HTMLButtonElement; if (fsBtn) fsBtn.title = this._fullscreen ? (this.t('fullscreenExit') || '退出全屏') : (this.t('fullscreen') || '全屏'); this._emit('fullscreen', this._fullscreen); if (typeof this.config.onFullscreen === 'function') { try { this.config.onFullscreen(this._fullscreen, this); } catch (e) { console.error(e); } } return this; }
|
||||||
isFullscreen(): boolean { return this._fullscreen; }
|
isFullscreen(): boolean { return this._fullscreen; }
|
||||||
exitFullscreen(): this { if (this._fullscreen) this.toggleFullscreen(); return this; }
|
exitFullscreen(): this { if (this._fullscreen) this.toggleFullscreen(); return this; }
|
||||||
|
|
||||||
@@ -596,7 +635,7 @@ export class MarkdownEditor {
|
|||||||
this.toolbarEl.style.transition = 'opacity 0.2s'; this.toolbarEl.style.opacity = '0'; this.toolbarEl.style.pointerEvents = 'none';
|
this.toolbarEl.style.transition = 'opacity 0.2s'; this.toolbarEl.style.opacity = '0'; this.toolbarEl.style.pointerEvents = 'none';
|
||||||
if (this.statusEl) this.statusEl.style.display = 'none';
|
if (this.statusEl) this.statusEl.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; }
|
if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; }
|
||||||
if (this.toolbarEl) { this.toolbarEl.style.opacity = ''; this.toolbarEl.style.pointerEvents = ''; this.toolbarEl.style.transition = ''; }
|
if (this.toolbarEl) { this.toolbarEl.style.opacity = ''; this.toolbarEl.style.pointerEvents = ''; this.toolbarEl.style.transition = ''; }
|
||||||
if (this.statusEl) this.statusEl.style.display = '';
|
if (this.statusEl) this.statusEl.style.display = '';
|
||||||
}
|
}
|
||||||
@@ -639,16 +678,61 @@ export class MarkdownEditor {
|
|||||||
|
|
||||||
_updateWordCount(): void {
|
_updateWordCount(): void {
|
||||||
if (!this.config.wordCount || !this.statusEl) return;
|
if (!this.config.wordCount || !this.statusEl) return;
|
||||||
const text = this._value || '';
|
const stats = this.getStats();
|
||||||
const chars = text.length;
|
this.statusEl.textContent = [`${this.t('characters')}: ${stats.characters}`, `${this.t('words')}: ${stats.words}`, `${this.t('lines')}: ${stats.lines}`, `${this.t('readingTime')}: ${stats.readingTime} ${this.t('minutes')}`].join(' · ');
|
||||||
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
|
||||||
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
|
||||||
const words = cnChars + enWords; const lines = text ? text.split('\n').length : 0;
|
|
||||||
const readingMin = Math.max(1, Math.ceil(words / 300));
|
|
||||||
this.statusEl.textContent = [`${i18nT('characters')}: ${chars}`, `${i18nT('words')}: ${words}`, `${i18nT('lines')}: ${lines}`, `${i18nT('readingTime')}: ${readingMin} ${i18nT('minutes')}`].join(' · ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getStats() { const text = this._value || ''; const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length; return { characters: text.length, words: cnChars + enWords, chineseChars: cnChars, englishWords: enWords, lines: text ? text.split('\n').length : 0, readingTime: Math.max(1, Math.ceil((cnChars + enWords) / 300)), }; }
|
getStats() {
|
||||||
|
const text = this._value || '';
|
||||||
|
if (this._statsCache && this._statsCache.value === text) return this._statsCache.stats;
|
||||||
|
const stats = this._computeStats(text);
|
||||||
|
this._statsCache = { value: text, stats };
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增量统计:基于上一次统计 + 差异区间(共同前缀/后缀之间的片段)计算,
|
||||||
|
* 避免每次击键对全文做 O(n) 正则。差异区间左右扩展至单词边界,
|
||||||
|
* 保证英文单词 / 中文串不会被边界切断,diff 结果与全量计算一致。
|
||||||
|
*/
|
||||||
|
_computeStats(text: string) {
|
||||||
|
const prev = this._statsCache;
|
||||||
|
if (!prev) {
|
||||||
|
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||||
|
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
||||||
|
const words = cnChars + enWords;
|
||||||
|
const lines = text ? text.split('\n').length : 0;
|
||||||
|
return { characters: text.length, words, chineseChars: cnChars, englishWords: enWords, lines, readingTime: Math.max(1, Math.ceil(words / 300)) };
|
||||||
|
}
|
||||||
|
const oldText = prev.value;
|
||||||
|
const oldStats = prev.stats;
|
||||||
|
const isWordChar = (c: string | undefined) => !!c && /[\w\u4e00-\u9fff]/.test(c);
|
||||||
|
let p = 0;
|
||||||
|
const maxP = Math.min(oldText.length, text.length);
|
||||||
|
while (p < maxP && oldText[p] === text[p]) p++;
|
||||||
|
let s = 0;
|
||||||
|
const maxS = maxP - p;
|
||||||
|
while (s < maxS && oldText[oldText.length - 1 - s] === text[text.length - 1 - s]) s++;
|
||||||
|
if (p === oldText.length && p === text.length) return oldStats;
|
||||||
|
let chunkStart = p;
|
||||||
|
while (chunkStart > 0 && (isWordChar(oldText[chunkStart - 1]) || isWordChar(text[chunkStart - 1]))) chunkStart--;
|
||||||
|
let chunkEndOld = oldText.length - s;
|
||||||
|
let chunkEndNew = text.length - s;
|
||||||
|
while (chunkEndOld < oldText.length && isWordChar(oldText[chunkEndOld])) chunkEndOld++;
|
||||||
|
while (chunkEndNew < text.length && isWordChar(text[chunkEndNew])) chunkEndNew++;
|
||||||
|
const oldChunk = oldText.slice(chunkStart, chunkEndOld);
|
||||||
|
const newChunk = text.slice(chunkStart, chunkEndNew);
|
||||||
|
const cnDelta = (newChunk.match(/[\u4e00-\u9fa5]/g) || []).length - (oldChunk.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||||
|
const enDelta = (newChunk.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length - (oldChunk.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
||||||
|
const lineDelta = (newChunk.match(/\n/g) || []).length - (oldChunk.match(/\n/g) || []).length;
|
||||||
|
const chineseChars = oldStats.chineseChars + cnDelta;
|
||||||
|
const englishWords = Math.max(0, oldStats.englishWords + enDelta);
|
||||||
|
// 行数 = 基础行(空文本 0 / 非空 1) + \n 计数;\n 计数增量更新
|
||||||
|
const oldBase = oldText ? 1 : 0;
|
||||||
|
const lines = (text ? 1 : 0) + (oldStats.lines - oldBase) + lineDelta;
|
||||||
|
const words = chineseChars + englishWords;
|
||||||
|
return { characters: text.length, words, chineseChars, englishWords, lines, readingTime: Math.max(1, Math.ceil(words / 300)) };
|
||||||
|
}
|
||||||
|
|
||||||
getSelectedText(): string {
|
getSelectedText(): string {
|
||||||
if (!this.textarea) return '';
|
if (!this.textarea) return '';
|
||||||
@@ -680,7 +764,8 @@ export class MarkdownEditor {
|
|||||||
if (!this.textarea) return this;
|
if (!this.textarea) return this;
|
||||||
const lines = this._value.split('\n');
|
const lines = this._value.split('\n');
|
||||||
const clampedLine = Math.max(1, Math.min(line, lines.length));
|
const clampedLine = Math.max(1, Math.min(line, lines.length));
|
||||||
const lineHeight = 22; // approximate
|
const taStyle = getComputedStyle(this.textarea);
|
||||||
|
const lineHeight = parseFloat(taStyle.lineHeight) || 22;
|
||||||
this.textarea.scrollTop = (clampedLine - 1) * lineHeight;
|
this.textarea.scrollTop = (clampedLine - 1) * lineHeight;
|
||||||
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
|
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
|
||||||
return this;
|
return this;
|
||||||
@@ -707,7 +792,8 @@ export class MarkdownEditor {
|
|||||||
const re = new RegExp(escaped, flags);
|
const re = new RegExp(escaped, flags);
|
||||||
const matches = this._value.match(re);
|
const matches = this._value.match(re);
|
||||||
if (!matches) return 0;
|
if (!matches) return 0;
|
||||||
this._value = this._limitLength(this._value.replace(re, replace));
|
// 函数式替换:替换文本按字面处理,$ 无特殊语义
|
||||||
|
this._value = this._limitLength(this._value.replace(re, () => replace));
|
||||||
this.textarea.value = this._value;
|
this.textarea.value = this._value;
|
||||||
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
|
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
|
||||||
return matches.length;
|
return matches.length;
|
||||||
@@ -752,7 +838,7 @@ export class MarkdownEditor {
|
|||||||
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this);
|
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
getHTML(): string { MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); if (typeof this.config.sanitize === 'function') { try { html = this.config.sanitize(html); } catch (e) { console.error(e); } } MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this); return html; }
|
getHTML(): string { MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: this.getLocale() }); if (typeof this.config.sanitize === 'function') { try { html = this.config.sanitize(html); } catch (e) { console.error(e); } } MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this); return html; }
|
||||||
refresh(): this { this._lastRenderedValue = null; this._render(); return this; }
|
refresh(): this { this._lastRenderedValue = null; this._render(); return this; }
|
||||||
|
|
||||||
copyAsMarkdown(): this {
|
copyAsMarkdown(): this {
|
||||||
@@ -796,6 +882,7 @@ export class MarkdownEditor {
|
|||||||
let p = plugin; if (typeof plugin === 'string') { p = presetPlugins[plugin]; if (!p) { console.warn(`MeEditor: preset plugin "${plugin}" not found`); return this; } }
|
let p = plugin; if (typeof plugin === 'string') { p = presetPlugins[plugin]; if (!p) { console.warn(`MeEditor: preset plugin "${plugin}" not found`); return this; } }
|
||||||
if (!p || typeof p !== 'object') { console.warn('MeEditor: invalid plugin'); return this; }
|
if (!p || typeof p !== 'object') { console.warn('MeEditor: invalid plugin'); return this; }
|
||||||
const merged = { ...p, ...options };
|
const merged = { ...p, ...options };
|
||||||
|
if (this._plugins.some((x) => x.name === merged.name)) { console.warn(`MeEditor: plugin "${merged.name}" already installed, skipped`); return this; }
|
||||||
if (typeof merged.install === 'function') {
|
if (typeof merged.install === 'function') {
|
||||||
try { const result = merged.install(this, options); if (result && typeof result.then === 'function') { result.catch((e: any) => console.error(`MeEditor: async plugin "${merged.name}" error:`, e)); } }
|
try { const result = merged.install(this, options); if (result && typeof result.then === 'function') { result.catch((e: any) => console.error(`MeEditor: async plugin "${merged.name}" error:`, e)); } }
|
||||||
catch (e) { console.error(`MeEditor: plugin "${merged.name}" install error:`, e); }
|
catch (e) { console.error(`MeEditor: plugin "${merged.name}" install error:`, e); }
|
||||||
@@ -821,7 +908,7 @@ export class MarkdownEditor {
|
|||||||
const ctrl = parts.includes('ctrl') || parts.includes('cmd'); const shift = parts.includes('shift'); const alt = parts.includes('alt'); const meta = parts.includes('meta');
|
const ctrl = parts.includes('ctrl') || parts.includes('cmd'); const shift = parts.includes('shift'); const alt = parts.includes('alt'); const meta = parts.includes('meta');
|
||||||
this._shortcuts.push({ combo, key, ctrl, shift, alt, meta, handler, description }); return this;
|
this._shortcuts.push({ combo, key, ctrl, shift, alt, meta, handler, description }); return this;
|
||||||
}
|
}
|
||||||
unregisterShortcut(combo: string): this { this._shortcuts = this._shortcuts.filter((s) => s.combo !== combo); return this; }
|
unregisterShortcut(combo: string): this { this._shortcuts = this._shortcuts.filter((s) => s.combo.toLowerCase() !== combo.toLowerCase()); return this; }
|
||||||
getShortcuts(): any[] { return [...this._shortcuts]; }
|
getShortcuts(): any[] { return [...this._shortcuts]; }
|
||||||
|
|
||||||
configureToolbar(tools: ToolbarItem[]): this { if (!this.toolbarEl) return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; }
|
configureToolbar(tools: ToolbarItem[]): this { if (!this.toolbarEl) return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; }
|
||||||
@@ -829,10 +916,21 @@ export class MarkdownEditor {
|
|||||||
toast(message: string, opts: { type?: string; duration?: number; animation?: string } = {}): this {
|
toast(message: string, opts: { type?: string; duration?: number; animation?: string } = {}): this {
|
||||||
if (!this.el || typeof document === 'undefined') return this;
|
if (!this.el || typeof document === 'undefined') return this;
|
||||||
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
|
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
|
||||||
|
const anim = ANIMATIONS[animation] || ANIMATIONS.fade;
|
||||||
const toast = document.createElement('div'); toast.className = `me-toast me-toast-${type} me-toast-${animation}`;
|
const toast = document.createElement('div'); toast.className = `me-toast me-toast-${type} me-toast-${animation}`;
|
||||||
toast.innerHTML = `<span class="me-toast-icon">${TOAST_ICONS[type] || ''}</span><span class="me-toast-msg">${escapeHTML(String(message))}</span>`;
|
toast.innerHTML = `<span class="me-toast-icon">${TOAST_ICONS[type] || ''}</span><span class="me-toast-msg">${escapeHTML(String(message))}</span>`;
|
||||||
this.el.appendChild(toast); requestAnimationFrame(() => { toast.classList.add('me-toast-enter'); });
|
toast.style.transition = `opacity ${anim.duration}ms ${anim.easing}, transform ${anim.duration}ms ${anim.easing}, filter ${anim.duration}ms ${anim.easing}`;
|
||||||
const remove = () => { toast.classList.remove('me-toast-enter'); toast.classList.add('me-toast-leave'); setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300); };
|
for (const [k, v] of Object.entries(anim.enter)) (toast.style as any)[k] = v;
|
||||||
|
this.el.appendChild(toast);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
for (const k of Object.keys(anim.enter)) (toast.style as any)[k] = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const remove = () => {
|
||||||
|
for (const [k, v] of Object.entries(anim.leave)) (toast.style as any)[k] = v;
|
||||||
|
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, anim.duration);
|
||||||
|
};
|
||||||
if (duration > 0) setTimeout(remove, duration); toast.addEventListener('click', remove); return this;
|
if (duration > 0) setTimeout(remove, duration); toast.addEventListener('click', remove); return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,7 +942,7 @@ export class MarkdownEditor {
|
|||||||
getTheme(): string { if (this._themeCtx) return this._themeCtx.get(); return this.config.theme || 'auto'; }
|
getTheme(): string { if (this._themeCtx) return this._themeCtx.get(); return this.config.theme || 'auto'; }
|
||||||
getThemeContext(): any { return this._themeCtx || null; }
|
getThemeContext(): any { return this._themeCtx || null; }
|
||||||
|
|
||||||
getStatus() { return { id: this.id, mode: this._mode, theme: this.getTheme(), locale: getCurrentLocale(), fullscreen: this._fullscreen, readOnly: this.config.readOnly || false, disabled: this.textarea ? this.textarea.disabled : false, destroyed: this._destroyed, plugins: this._plugins.map((p) => p.name || 'custom'), }; }
|
getStatus() { return { id: this.id, mode: this._mode, theme: this.getTheme(), locale: this.getLocale(), fullscreen: this._fullscreen, readOnly: this.config.readOnly || false, disabled: this.textarea ? this.textarea.disabled : false, destroyed: this._destroyed, plugins: this._plugins.map((p) => p.name || 'custom'), }; }
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (this._destroyed) return;
|
if (this._destroyed) return;
|
||||||
@@ -854,6 +952,7 @@ export class MarkdownEditor {
|
|||||||
if (this._outlineTimer) clearTimeout(this._outlineTimer);
|
if (this._outlineTimer) clearTimeout(this._outlineTimer);
|
||||||
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = [];
|
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = [];
|
||||||
if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; }
|
if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; }
|
||||||
|
if (this._themeCtx && typeof this._themeCtx.dispose === 'function') { try { this._themeCtx.dispose(); } catch (_) {} }
|
||||||
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
|
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
|
||||||
if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
|
if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
|
||||||
this._floatingToolbar = null;
|
this._floatingToolbar = null;
|
||||||
|
|||||||
+2
-15
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Floating Toolbar — selection format toolbar
|
* MetonaEditor Floating Toolbar — selection format toolbar
|
||||||
* @module floating-toolbar
|
* @module floating-toolbar
|
||||||
* @version 0.2.5
|
* @version 0.4.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MarkdownEditor } from './core';
|
import type { MarkdownEditor } from './core';
|
||||||
@@ -19,20 +19,9 @@ export function initFloatingToolbar(this: MarkdownEditor): void {
|
|||||||
if (!this._floatingEnabled) return;
|
if (!this._floatingEnabled) return;
|
||||||
|
|
||||||
const ta = this.textarea;
|
const ta = this.textarea;
|
||||||
let _lastSelStart = ta.selectionStart;
|
|
||||||
let _lastSelEnd = ta.selectionEnd;
|
|
||||||
|
|
||||||
const emitSelectionChange = () => {
|
|
||||||
const s = ta.selectionStart; const e = ta.selectionEnd;
|
|
||||||
if (s !== _lastSelStart || e !== _lastSelEnd) {
|
|
||||||
_lastSelStart = s; _lastSelEnd = e;
|
|
||||||
this._emit('selectionChange', { start: s, end: e, text: this._value.slice(s, e) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return;
|
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return;
|
||||||
emitSelectionChange();
|
|
||||||
const start = ta.selectionStart; const end = ta.selectionEnd;
|
const start = ta.selectionStart; const end = ta.selectionEnd;
|
||||||
if (start === end) { this._hideFloatingToolbar(); return; }
|
if (start === end) { this._hideFloatingToolbar(); return; }
|
||||||
|
|
||||||
@@ -73,14 +62,12 @@ export function initFloatingToolbar(this: MarkdownEditor): void {
|
|||||||
|
|
||||||
ta.addEventListener('mouseup', () => setTimeout(show, 0));
|
ta.addEventListener('mouseup', () => setTimeout(show, 0));
|
||||||
ta.addEventListener('keyup', () => {
|
ta.addEventListener('keyup', () => {
|
||||||
emitSelectionChange();
|
// selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
|
||||||
if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0);
|
if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0);
|
||||||
else setTimeout(hide, 0);
|
else setTimeout(hide, 0);
|
||||||
this._emit('cursorMove', this.getCursorPosition());
|
|
||||||
});
|
});
|
||||||
ta.addEventListener('blur', () => setTimeout(hide, 300));
|
ta.addEventListener('blur', () => setTimeout(hide, 300));
|
||||||
ta.addEventListener('click', () => {
|
ta.addEventListener('click', () => {
|
||||||
emitSelectionChange();
|
|
||||||
if (ta.selectionStart === ta.selectionEnd) hide();
|
if (ta.selectionStart === ta.selectionEnd) hide();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-2
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Highlight — built-in lightweight syntax highlighter
|
* MetonaEditor Highlight — built-in lightweight syntax highlighter
|
||||||
* @module highlight
|
* @module highlight
|
||||||
* @version 0.2.5
|
* @version 0.4.0
|
||||||
*
|
*
|
||||||
* Zero-dependency tokenizer for common languages. Safe by construction:
|
* Zero-dependency tokenizer for common languages. Safe by construction:
|
||||||
* input is HTML-escaped first, then annotated with <span> classes on the
|
* input is HTML-escaped first, then annotated with <span> classes on the
|
||||||
@@ -98,6 +98,22 @@ const tokenize = (text: string, rules: HighlightRule[]): string => {
|
|||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============ Rule cache ============
|
||||||
|
|
||||||
|
// 规则正则与语言定义绑定且只读(tokenize 同步执行,每次先重置 lastIndex,
|
||||||
|
// 共享正则实例在单线程环境安全)。避免每次 highlight 调用重建正则。
|
||||||
|
const ruleCache = new Map<string, HighlightRule[]>();
|
||||||
|
|
||||||
|
const getRules = (lang: string): HighlightRule[] => {
|
||||||
|
let rules = ruleCache.get(lang);
|
||||||
|
if (!rules) {
|
||||||
|
const def = LANGUAGES[lang];
|
||||||
|
rules = def ? buildRules(def) : [];
|
||||||
|
ruleCache.set(lang, rules);
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
};
|
||||||
|
|
||||||
// ============ Language definitions ============
|
// ============ Language definitions ============
|
||||||
|
|
||||||
const LANGUAGES: Record<string, LanguageDef> = {
|
const LANGUAGES: Record<string, LanguageDef> = {
|
||||||
@@ -207,12 +223,13 @@ export const highlight = (code: string, lang: string): string => {
|
|||||||
const def = LANGUAGES[canonical];
|
const def = LANGUAGES[canonical];
|
||||||
const escaped = escapeText(code);
|
const escaped = escapeText(code);
|
||||||
if (!def) return escaped;
|
if (!def) return escaped;
|
||||||
return tokenize(escaped, buildRules(def));
|
return tokenize(escaped, getRules(canonical));
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Register or override a language definition */
|
/** Register or override a language definition */
|
||||||
export const registerLanguage = (name: string, def: LanguageDef): void => {
|
export const registerLanguage = (name: string, def: LanguageDef): void => {
|
||||||
LANGUAGES[name.toLowerCase()] = def;
|
LANGUAGES[name.toLowerCase()] = def;
|
||||||
|
ruleCache.delete(name.toLowerCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSupportedLanguages = (): string[] => Object.keys(LANGUAGES);
|
export const getSupportedLanguages = (): string[] => Object.keys(LANGUAGES);
|
||||||
|
|||||||
+12
-4
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor — Type-safe, lightweight Markdown Editor
|
* MetonaEditor — Type-safe, lightweight Markdown Editor
|
||||||
* @module metona-editor
|
* @module metona-editor
|
||||||
* @version 0.2.5
|
* @version 0.4.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MarkdownEditor } from './core';
|
import { MarkdownEditor } from './core';
|
||||||
@@ -14,14 +14,18 @@ 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.2.5';
|
const VERSION = '0.4.0';
|
||||||
|
|
||||||
const globalPlugins: any[] = [];
|
const globalPlugins: any[] = [];
|
||||||
|
|
||||||
MarkdownEditor.on('beforeCreate', (editor) => {
|
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
|
||||||
|
// (searchReplace / shortcutHelp / imagePaste)才能正常生效。
|
||||||
|
const injectGlobalPlugins = (editor: MarkdownEditor): void => {
|
||||||
if (!editor || editor.isDestroyed()) return;
|
if (!editor || editor.isDestroyed()) return;
|
||||||
globalPlugins.forEach((p) => { try { editor.use(p); } catch (e) { console.error('MeEditor global plugin install error:', e); } });
|
globalPlugins.forEach((p) => { try { editor.use(p); } catch (e) { console.error('MeEditor global plugin install error:', e); } });
|
||||||
});
|
};
|
||||||
|
|
||||||
|
MarkdownEditor.on('afterCreate', injectGlobalPlugins);
|
||||||
|
|
||||||
function create(container: string | HTMLElement, options: EditorOptions = {}): MarkdownEditor {
|
function create(container: string | HTMLElement, options: EditorOptions = {}): MarkdownEditor {
|
||||||
return new MarkdownEditor(container, options);
|
return new MarkdownEditor(container, options);
|
||||||
@@ -46,6 +50,10 @@ function destroy(): void {
|
|||||||
if (i18nUtils) { try { i18nUtils.clearLocaleListeners(); } catch (_) {} }
|
if (i18nUtils) { try { i18nUtils.clearLocaleListeners(); } catch (_) {} }
|
||||||
if (animationUtils) { try { animationUtils.cancelAll(); } catch (_) {} }
|
if (animationUtils) { try { animationUtils.cancelAll(); } catch (_) {} }
|
||||||
globalPlugins.length = 0;
|
globalPlugins.length = 0;
|
||||||
|
// 清理全局静态钩子(beforeCreate/afterCreate/beforeChange 等),整体复位;
|
||||||
|
// 随后重建内部 afterCreate 插件注入钩子,保证 destroy 后 MeEditor.use() 仍可用。
|
||||||
|
try { MarkdownEditor._hooks.clear(); } catch (_) {}
|
||||||
|
MarkdownEditor.on('afterCreate', injectGlobalPlugins);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatus() {
|
function getStatus() {
|
||||||
|
|||||||
+18
-9
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Outline — heading navigation panel
|
* MetonaEditor Outline — heading navigation panel
|
||||||
* @module outline
|
* @module outline
|
||||||
* @version 0.2.5
|
* @version 0.4.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { t as i18nT } from './i18n';
|
import { t as i18nT } from './i18n';
|
||||||
@@ -19,17 +19,26 @@ export function buildOutline(this: MarkdownEditor): void {
|
|||||||
}
|
}
|
||||||
if (!headings.length) return;
|
if (!headings.length) return;
|
||||||
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">${i18nT('outline') || '大纲'}</div>`;
|
panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`;
|
||||||
|
// 迭代式树构建(栈模拟嵌套 ul),O(n) 而非递归的 O(n²)
|
||||||
const buildTree = (items: typeof headings, minLevel: number): string => {
|
const buildTree = (items: typeof headings, minLevel: number): string => {
|
||||||
let h = '<ul>'; let i = 0;
|
let h = '';
|
||||||
while (i < items.length) {
|
const stack: number[] = [];
|
||||||
const item = items[i]; if (item.level < minLevel) break;
|
const closeTo = (targetLevel: number): void => {
|
||||||
|
while (stack.length && stack[stack.length - 1] >= targetLevel) {
|
||||||
|
h += '</li></ul>';
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.level < minLevel) continue;
|
||||||
|
closeTo(item.level);
|
||||||
|
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.id}">${escapeHTML(item.text)}</a>`;
|
||||||
const sub: typeof headings = []; let j = i + 1; while (j < items.length && items[j].level > item.level) { sub.push(items[j]); j++; }
|
stack.push(item.level);
|
||||||
if (sub.length) h += buildTree(sub, item.level + 1);
|
|
||||||
h += '</li>'; i = j > i + 1 ? j : i + 1;
|
|
||||||
}
|
}
|
||||||
return h + '</ul>';
|
closeTo(-1);
|
||||||
|
return h;
|
||||||
};
|
};
|
||||||
panel.innerHTML += buildTree(headings, 1);
|
panel.innerHTML += buildTree(headings, 1);
|
||||||
this.el.appendChild(panel);
|
this.el.appendChild(panel);
|
||||||
|
|||||||
+31
-5
@@ -99,17 +99,34 @@ const EMOJI_MAP: Record<string, string> = {
|
|||||||
const renderCache = new Map<string, string>();
|
const renderCache = new Map<string, string>();
|
||||||
const MAX_CACHE_SIZE = 300;
|
const MAX_CACHE_SIZE = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 引用链接 `[x][r]` 的解析依赖 env.refs,若仅以文本为缓存 key,
|
||||||
|
* 不同文档中相同文本会命中彼此的缓存导致链接串数据。
|
||||||
|
* 故缓存 key 附加 refs 指纹(parseMarkdown 预计算 `_refsFp`,
|
||||||
|
* 直接调用 renderTokens 时兜底现场计算)。
|
||||||
|
*/
|
||||||
|
const refsFingerprint = (env: RenderEnv): string => {
|
||||||
|
let fp = (env as any)._refsFp;
|
||||||
|
if (fp === undefined) {
|
||||||
|
const keys = env.refs ? Object.keys(env.refs) : [];
|
||||||
|
fp = keys.length > 0 ? JSON.stringify(env.refs) : '';
|
||||||
|
}
|
||||||
|
return fp || '';
|
||||||
|
};
|
||||||
|
|
||||||
const cachedRenderInline = (text: string, env: RenderEnv): string => {
|
const cachedRenderInline = (text: string, env: RenderEnv): string => {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
if (!env.highlight && text.length < 600) {
|
if (!env.highlight && text.length < 600) {
|
||||||
const cached = renderCache.get(text);
|
const fp = refsFingerprint(env);
|
||||||
|
const key = fp ? text + '\u0001' + fp : text;
|
||||||
|
const cached = renderCache.get(key);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
const result = renderInline(text, env);
|
const result = renderInline(text, env);
|
||||||
if (renderCache.size >= MAX_CACHE_SIZE) {
|
if (renderCache.size >= MAX_CACHE_SIZE) {
|
||||||
const firstKey = renderCache.keys().next().value!;
|
const firstKey = renderCache.keys().next().value!;
|
||||||
renderCache.delete(firstKey);
|
renderCache.delete(firstKey);
|
||||||
}
|
}
|
||||||
renderCache.set(text, result);
|
renderCache.set(key, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
return renderInline(text, env);
|
return renderInline(text, env);
|
||||||
@@ -401,7 +418,8 @@ const renderTokens = (tokens: Token[], env: RenderEnv = {}, footnotes: Record<st
|
|||||||
if (fnIds.length) {
|
if (fnIds.length) {
|
||||||
html += '\n<div class="me-footnotes"><hr/><ol>';
|
html += '\n<div class="me-footnotes"><hr/><ol>';
|
||||||
fnIds.forEach((id) => {
|
fnIds.forEach((id) => {
|
||||||
html += `<li id="fn-${id}" class="me-footnote-item"><a href="#fnref-${id}" class="me-footnote-backref">↩</a> ${cachedRenderInline(footnotes[id], env)}</li>`;
|
const safeId = attrSafe(id);
|
||||||
|
html += `<li id="fn-${safeId}" class="me-footnote-item"><a href="#fnref-${safeId}" class="me-footnote-backref">↩</a> ${cachedRenderInline(footnotes[id], env)}</li>`;
|
||||||
});
|
});
|
||||||
html += '</ol></div>';
|
html += '</ol></div>';
|
||||||
}
|
}
|
||||||
@@ -410,7 +428,9 @@ const renderTokens = (tokens: Token[], env: RenderEnv = {}, footnotes: Record<st
|
|||||||
|
|
||||||
const parseMarkdown = (md: string | null | undefined, env: RenderEnv = {}): string => {
|
const parseMarkdown = (md: string | null | undefined, env: RenderEnv = {}): string => {
|
||||||
const { tokens, footnotes, refs } = parseTokens(md);
|
const { tokens, footnotes, refs } = parseTokens(md);
|
||||||
return renderTokens(tokens, { ...env, refs }, footnotes);
|
const resolvedEnv: RenderEnv = { ...env, refs };
|
||||||
|
if (refs && Object.keys(refs).length > 0) (resolvedEnv as any)._refsFp = JSON.stringify(refs);
|
||||||
|
return renderTokens(tokens, resolvedEnv, footnotes);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ List parsing ============
|
// ============ List parsing ============
|
||||||
@@ -615,6 +635,11 @@ const renderInline = (text: string, env: RenderEnv): string => {
|
|||||||
s = s.replace(/<!--[\s\S]*?-->/g, (m) => {
|
s = s.replace(/<!--[\s\S]*?-->/g, (m) => {
|
||||||
protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`;
|
protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`;
|
||||||
});
|
});
|
||||||
|
// 白名单裸标签透传:仅 `<u>` / `</u>`(无任何属性),供 Ctrl+U 下划线命令使用。
|
||||||
|
// 带属性的 `<u onclick=...>` 不匹配,仍会被 escapeHTML 转义,不引入注入面。
|
||||||
|
s = s.replace(/<\/?u\s*>/gi, (m) => {
|
||||||
|
protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`;
|
||||||
|
});
|
||||||
|
|
||||||
s = escapeHTML(s);
|
s = escapeHTML(s);
|
||||||
s = s.replace(/\u0005(\d+)\u0005/g, (_m, idx) => protectedItems[+idx] || _m);
|
s = s.replace(/\u0005(\d+)\u0005/g, (_m, idx) => protectedItems[+idx] || _m);
|
||||||
@@ -752,7 +777,8 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
|||||||
if (match.startsWith('$') && !match.startsWith('$$')) return `<span class="me-math-inline">${escapeHTML(match.slice(1, -1))}</span>`;
|
if (match.startsWith('$') && !match.startsWith('$$')) return `<span class="me-math-inline">${escapeHTML(match.slice(1, -1))}</span>`;
|
||||||
if (/^\[\^/.test(match)) {
|
if (/^\[\^/.test(match)) {
|
||||||
const fnId = match.slice(2, -1);
|
const fnId = match.slice(2, -1);
|
||||||
return `<sup class="me-footnote-ref"><a href="#fn-${fnId}" id="fnref-${fnId}">[${fnId}]</a></sup>`;
|
const safeId = attrSafe(fnId);
|
||||||
|
return `<sup class="me-footnote-ref"><a href="#fn-${safeId}" id="fnref-${safeId}">[${safeId}]</a></sup>`;
|
||||||
}
|
}
|
||||||
if (match.startsWith('\\') && match.length === 2) {
|
if (match.startsWith('\\') && match.length === 2) {
|
||||||
const escaped = match[1];
|
const escaped = match[1];
|
||||||
|
|||||||
+16
-4
@@ -239,6 +239,8 @@ export const hasTheme = (name: string): boolean => name in THEMES;
|
|||||||
|
|
||||||
export const createInstanceTheme = (editor: any): any => {
|
export const createInstanceTheme = (editor: any): any => {
|
||||||
let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto';
|
let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto';
|
||||||
|
// 外部跟随订阅列表,随实例销毁断开,避免 observer/定时器泄漏
|
||||||
|
const unsubs: Array<() => void> = [];
|
||||||
const apply = (theme: string): string => {
|
const apply = (theme: string): string => {
|
||||||
instanceTheme = theme;
|
instanceTheme = theme;
|
||||||
const resolved = resolveTheme(theme);
|
const resolved = resolveTheme(theme);
|
||||||
@@ -259,11 +261,21 @@ export const createInstanceTheme = (editor: any): any => {
|
|||||||
const getResolved = () => resolveTheme(instanceTheme);
|
const getResolved = () => resolveTheme(instanceTheme);
|
||||||
const getConfig = () => getThemeConfig(instanceTheme);
|
const getConfig = () => getThemeConfig(instanceTheme);
|
||||||
const getVars = () => editor.el ? exportCSSVars(editor.el) : {};
|
const getVars = () => editor.el ? exportCSSVars(editor.el) : {};
|
||||||
const syncWithElement = (element: HTMLElement, opts: any = {}) => followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected));
|
const syncWithElement = (element: HTMLElement, opts: any = {}) => {
|
||||||
const adopt = () => editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => {};
|
const unsub = followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected));
|
||||||
const watchExternal = (source: any) => watch(source, (theme) => apply(theme));
|
unsubs.push(unsub); return unsub;
|
||||||
|
};
|
||||||
|
const adopt = () => {
|
||||||
|
const unsub = editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => {};
|
||||||
|
unsubs.push(unsub); return unsub;
|
||||||
|
};
|
||||||
|
const watchExternal = (source: any) => {
|
||||||
|
const unsub = watch(source, (theme) => apply(theme));
|
||||||
|
unsubs.push(unsub); return unsub;
|
||||||
|
};
|
||||||
|
const dispose = (): void => { unsubs.forEach((fn) => { try { fn(); } catch (_) {} }); unsubs.length = 0; };
|
||||||
apply(instanceTheme);
|
apply(instanceTheme);
|
||||||
return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal };
|
return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal, dispose };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createThemeManager = () => ({
|
export const createThemeManager = () => ({
|
||||||
|
|||||||
@@ -461,6 +461,29 @@ describe('MarkdownEditor - 插件', () => {
|
|||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('同名插件重复 use 只安装一次', () => {
|
||||||
|
let installs = 0;
|
||||||
|
const p = { name: 'dup-plugin', install: () => { installs++; } };
|
||||||
|
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
ed.use(p);
|
||||||
|
ed.use(p);
|
||||||
|
ed.use(p);
|
||||||
|
expect(installs).toBe(1);
|
||||||
|
expect(ed.getPlugins().length).toBe(1);
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unuse 后同名插件可重新安装', () => {
|
||||||
|
let installs = 0;
|
||||||
|
const p = { name: 're-install', install: () => { installs++; }, destroy() {} };
|
||||||
|
ed.use(p);
|
||||||
|
ed.unuse('re-install');
|
||||||
|
ed.use(p);
|
||||||
|
expect(installs).toBe(2);
|
||||||
|
expect(ed.getPlugins().length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('addToolbarButton 追加按钮', () => {
|
test('addToolbarButton 追加按钮', () => {
|
||||||
ed.addToolbarButton({ action: 'custom', title: 'Custom' });
|
ed.addToolbarButton({ action: 'custom', title: 'Custom' });
|
||||||
const btn = ed.toolbarEl.querySelector('.me-btn-custom-custom');
|
const btn = ed.toolbarEl.querySelector('.me-btn-custom-custom');
|
||||||
@@ -1705,6 +1728,44 @@ describe('MarkdownEditor - v0.2.0 outline 构建', () => {
|
|||||||
}
|
}
|
||||||
ed.destroy();
|
ed.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('outline 树结构完整(嵌套 ul 正确闭合)', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {
|
||||||
|
value: '# A\n## A1\n### A1a\n# B\n## B1\n### B1a\n#### B1a1\n## B2',
|
||||||
|
outline: true, mode: 'split',
|
||||||
|
});
|
||||||
|
ed._buildOutline();
|
||||||
|
const panel = ed.el.querySelector('.me-outline');
|
||||||
|
const html = panel.innerHTML;
|
||||||
|
const ulOpen = (html.match(/<ul>/g) || []).length;
|
||||||
|
const ulClose = (html.match(/<\/ul>/g) || []).length;
|
||||||
|
expect(ulOpen).toBe(ulClose);
|
||||||
|
const liOpen = (html.match(/<li /g) || []).length;
|
||||||
|
const liClose = (html.match(/<\/li>/g) || []).length;
|
||||||
|
expect(liOpen).toBe(liClose);
|
||||||
|
expect(liOpen).toBe(8);
|
||||||
|
expect(panel.querySelectorAll('a').length).toBe(8);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outline 长文档线性构建不抛错', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const lines = [];
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const lvl = (i % 4) + 1;
|
||||||
|
lines.push('#'.repeat(lvl) + ' Heading ' + i);
|
||||||
|
}
|
||||||
|
const ed = new MarkdownEditor(c, { value: lines.join('\n'), outline: true, mode: 'split' });
|
||||||
|
ed._buildOutline();
|
||||||
|
const panel = ed.el.querySelector('.me-outline');
|
||||||
|
expect(panel.querySelectorAll('a').length).toBe(200);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MarkdownEditor - v0.2.0 gutter 更新', () => {
|
describe('MarkdownEditor - v0.2.0 gutter 更新', () => {
|
||||||
@@ -2098,6 +2159,260 @@ describe('MarkdownEditor - v0.2.3 光标/选区 API', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ v0.3.1 增量统计 ============
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.3.1 增量统计', () => {
|
||||||
|
let ed;
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
ed = new MarkdownEditor(c, { value: '' });
|
||||||
|
});
|
||||||
|
afterEach(() => ed.destroy());
|
||||||
|
|
||||||
|
const fullStats = (v: string) => {
|
||||||
|
const cnChars = (v.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||||
|
const enWords = (v.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
||||||
|
return { characters: v.length, chineseChars: cnChars, englishWords: enWords, words: cnChars + enWords, lines: v ? v.split('\n').length : 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('增量结果与全量一致(逐步输入)', () => {
|
||||||
|
const inputs = ['hello world', 'hello world foo', 'hello world foobar', '你好世界 hello', '你好世界\nhello\n世界', '中文 x 混合 test\n第二行', ''];
|
||||||
|
for (const v of inputs) {
|
||||||
|
ed.setValue(v, { silent: true });
|
||||||
|
const stats = ed.getStats();
|
||||||
|
const expectStats = fullStats(v);
|
||||||
|
expect(stats.characters).toBe(expectStats.characters);
|
||||||
|
expect(stats.chineseChars).toBe(expectStats.chineseChars);
|
||||||
|
expect(stats.englishWords).toBe(expectStats.englishWords);
|
||||||
|
expect(stats.words).toBe(expectStats.words);
|
||||||
|
expect(stats.lines).toBe(expectStats.lines);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('单词中间删除字符后统计正确', () => {
|
||||||
|
ed.setValue('hello world', { silent: true });
|
||||||
|
expect(ed.getStats().englishWords).toBe(2);
|
||||||
|
ed.setValue('helloworld', { silent: true });
|
||||||
|
expect(ed.getStats().englishWords).toBe(1);
|
||||||
|
ed.setValue('hello', { silent: true });
|
||||||
|
expect(ed.getStats().englishWords).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('追加单词与删除行后统计正确', () => {
|
||||||
|
ed.setValue('hello world', { silent: true });
|
||||||
|
ed.setValue('hello world extra', { silent: true });
|
||||||
|
expect(ed.getStats().englishWords).toBe(3);
|
||||||
|
ed.setValue('hello world extra\nnew line', { silent: true });
|
||||||
|
expect(ed.getStats().lines).toBe(2);
|
||||||
|
ed.setValue('single', { silent: true });
|
||||||
|
expect(ed.getStats().lines).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('连续追加 50 次后与全量一致', () => {
|
||||||
|
ed.setValue('初始文本 with words', { silent: true });
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
ed.setValue(ed.getValue() + ' 追加' + i + 'x', { silent: true });
|
||||||
|
}
|
||||||
|
const v = ed.getValue();
|
||||||
|
const stats = ed.getStats();
|
||||||
|
const expectStats = fullStats(v);
|
||||||
|
expect(stats.chineseChars).toBe(expectStats.chineseChars);
|
||||||
|
expect(stats.englishWords).toBe(expectStats.englishWords);
|
||||||
|
expect(stats.lines).toBe(expectStats.lines);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gutter 行数跟随统计缓存', () => {
|
||||||
|
ed.setValue('a\nb\nc', { silent: true });
|
||||||
|
expect(ed.gutter.children.length).toBe(3);
|
||||||
|
ed.setValue('a\nb', { silent: true });
|
||||||
|
expect(ed.gutter.children.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ v0.3.1 P2 体验一致性 ============
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.3.1 undo 光标恢复', () => {
|
||||||
|
test('undo 后光标位置被 clamp 恢复', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '' });
|
||||||
|
ed.setValue('hello world');
|
||||||
|
ed.textarea.setSelectionRange(11, 11);
|
||||||
|
ed.setValue('hello world foo');
|
||||||
|
ed.textarea.setSelectionRange(15, 15);
|
||||||
|
ed.undo();
|
||||||
|
expect(ed.getValue()).toBe('hello world');
|
||||||
|
expect(ed.textarea.selectionStart).toBe(11);
|
||||||
|
expect(ed.textarea.selectionEnd).toBe(11);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redo 后光标不越界', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '' });
|
||||||
|
ed.setValue('ab');
|
||||||
|
ed.undo();
|
||||||
|
ed.redo();
|
||||||
|
expect(ed.getValue()).toBe('ab');
|
||||||
|
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.3.1 selectionChange 与浮动工具栏解耦', () => {
|
||||||
|
test('floatingToolbar: false 时 selectionChange 仍触发', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'hello world', floatingToolbar: false });
|
||||||
|
const handler = jest.fn();
|
||||||
|
ed.on('selectionChange', handler);
|
||||||
|
ed.textarea.setSelectionRange(0, 5);
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
expect(handler).toHaveBeenCalled();
|
||||||
|
expect(handler.mock.calls[0][0]).toHaveProperty('start', 0);
|
||||||
|
expect(handler.mock.calls[0][0]).toHaveProperty('end', 5);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('floatingToolbar: false 时 cursorMove 仍触发', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'ab', floatingToolbar: false });
|
||||||
|
const handler = jest.fn();
|
||||||
|
ed.on('cursorMove', handler);
|
||||||
|
ed.textarea.setSelectionRange(1, 1);
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
expect(handler).toHaveBeenCalled();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.3.1 unregisterShortcut 大小写归一', () => {
|
||||||
|
test('注册 Ctrl+B 后可用 ctrl+b 注销', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
ed.registerShortcut('Ctrl+B', 'bold');
|
||||||
|
expect(ed.getShortcuts().length).toBe(1);
|
||||||
|
ed.unregisterShortcut('ctrl+b');
|
||||||
|
expect(ed.getShortcuts().length).toBe(0);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.3.1 toast 动画接入', () => {
|
||||||
|
test('所有内置动画类型不抛错', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
['fade', 'slide', 'scale', 'bounce', 'flip', 'rotate', 'zoom'].forEach((a) => {
|
||||||
|
ed.toast('msg', { animation: a, duration: 0 });
|
||||||
|
const t = ed.el.querySelector('.me-toast');
|
||||||
|
expect(t).not.toBeNull();
|
||||||
|
expect(t.className).toContain(`me-toast-${a}`);
|
||||||
|
t.remove();
|
||||||
|
});
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toast 应用动画进入样式', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
ed.toast('hello', { animation: 'slide', duration: 0 });
|
||||||
|
const t = ed.el.querySelector('.me-toast') as HTMLElement;
|
||||||
|
expect(t).not.toBeNull();
|
||||||
|
expect(t.style.transition).toContain('400ms');
|
||||||
|
expect(t.style.opacity).toBe('0');
|
||||||
|
t.remove();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.3.1 拖放图片大小限制', () => {
|
||||||
|
test('超大图片拖入时 toast 警告且不插入', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '' });
|
||||||
|
const toastSpy = jest.spyOn(ed, 'toast').mockImplementation(() => ed);
|
||||||
|
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
|
||||||
|
const file = new File([new ArrayBuffer(1024 * 1024)], 'big.png', { type: 'image/png' });
|
||||||
|
const dt = { files: [file] };
|
||||||
|
const evt = new Event('drop', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(evt, 'dataTransfer', { value: dt });
|
||||||
|
ed.textarea.dispatchEvent(evt as DragEvent);
|
||||||
|
expect(toastSpy).toHaveBeenCalled();
|
||||||
|
expect(insertSpy).not.toHaveBeenCalled();
|
||||||
|
toastSpy.mockRestore();
|
||||||
|
insertSpy.mockRestore();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('小图片拖入正常插入', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '' });
|
||||||
|
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
|
||||||
|
const file = new File([new ArrayBuffer(1024)], 'ok.png', { type: 'image/png' });
|
||||||
|
const dt = { files: [file] };
|
||||||
|
const evt = new Event('drop', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(evt, 'dataTransfer', { value: dt });
|
||||||
|
ed.textarea.dispatchEvent(evt as DragEvent);
|
||||||
|
expect(insertSpy).not.toHaveBeenCalled(); // FileReader 异步,同步阶段仅校验
|
||||||
|
insertSpy.mockRestore();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.4.0 实例 locale 决定 UI 文案', () => {
|
||||||
|
test('实例 locale 为 en-US 时状态栏与按钮为英文', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'hello', locale: 'en-US', wordCount: true });
|
||||||
|
expect(ed.statusEl.textContent).toContain('Characters');
|
||||||
|
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]') as HTMLElement;
|
||||||
|
expect(boldBtn.title).toBe('Bold');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('实例 locale 为 zh-CN 时状态栏与按钮为中文(不受浏览器默认影响)', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '你好', locale: 'zh-CN', wordCount: true });
|
||||||
|
expect(ed.statusEl.textContent).toContain('字符');
|
||||||
|
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]') as HTMLElement;
|
||||||
|
expect(boldBtn.title).toBe('粗体');
|
||||||
|
expect(ed.textarea.getAttribute('aria-label')).toBe('编辑');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('右键菜单标签跟随实例 locale', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'text', locale: 'en-US' });
|
||||||
|
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 10, clientY: 10, bubbles: true }));
|
||||||
|
const menu = document.querySelector('.me-context-menu');
|
||||||
|
expect(menu.textContent).toContain('Undo');
|
||||||
|
if (menu) menu.remove();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ============ v0.2.3 内容操作 API ============
|
// ============ v0.2.3 内容操作 API ============
|
||||||
|
|
||||||
describe('MarkdownEditor - v0.2.3 内容操作 API', () => {
|
describe('MarkdownEditor - v0.2.3 内容操作 API', () => {
|
||||||
@@ -2151,6 +2466,19 @@ describe('MarkdownEditor - v0.2.3 内容操作 API', () => {
|
|||||||
expect(handler).toHaveBeenCalled();
|
expect(handler).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('replaceAll 替换文本中的 $ 按字面处理', () => {
|
||||||
|
ed.setValue('a b a');
|
||||||
|
const count = ed.replaceAll('a', '$1');
|
||||||
|
expect(count).toBe(2);
|
||||||
|
expect(ed.getValue()).toBe('$1 b $1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaceAllRegex 保留正则捕获组语义', () => {
|
||||||
|
ed.setValue('hello 2024');
|
||||||
|
ed.replaceAllRegex(/(\d{4})/, '$1!');
|
||||||
|
expect(ed.getValue()).toBe('hello 2024!');
|
||||||
|
});
|
||||||
|
|
||||||
test('lineCount 返回行数', () => {
|
test('lineCount 返回行数', () => {
|
||||||
expect(ed.lineCount()).toBe(1);
|
expect(ed.lineCount()).toBe(1);
|
||||||
ed.setValue('a\nb\nc');
|
ed.setValue('a\nb\nc');
|
||||||
@@ -2300,6 +2628,17 @@ describe('MarkdownEditor - v0.2.3 getStatus', () => {
|
|||||||
expect(Array.isArray(s.plugins)).toBe(true);
|
expect(Array.isArray(s.plugins)).toBe(true);
|
||||||
ed.destroy();
|
ed.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getStatus locale 跟随实例语言', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { locale: 'en-US' });
|
||||||
|
ed.setLocale('de');
|
||||||
|
expect(ed.getStatus().locale).toBe('de');
|
||||||
|
expect(ed.getLocale()).toBe('de');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ v0.2.5 maxLength / zenMode / 分隔条隔离 ============
|
// ============ v0.2.5 maxLength / zenMode / 分隔条隔离 ============
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ describe('highlight - 语言注册', () => {
|
|||||||
expect(out).toContain('<span class="me-hl-keyword">frobnicate</span>');
|
expect(out).toContain('<span class="me-hl-keyword">frobnicate</span>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('覆盖已有语言定义后规则缓存失效', () => {
|
||||||
|
registerLanguage('langA', { keywords: ['one'], builtins: [] });
|
||||||
|
expect(highlight('one', 'langA')).toContain('me-hl-keyword');
|
||||||
|
registerLanguage('langA', { keywords: ['two'], builtins: [] });
|
||||||
|
expect(highlight('two', 'langA')).toContain('me-hl-keyword');
|
||||||
|
expect(highlight('one', 'langA')).not.toContain('me-hl-keyword');
|
||||||
|
});
|
||||||
|
|
||||||
test('getSupportedLanguages 返回语言列表', () => {
|
test('getSupportedLanguages 返回语言列表', () => {
|
||||||
const langs = getSupportedLanguages();
|
const langs = getSupportedLanguages();
|
||||||
expect(Array.isArray(langs)).toBe(true);
|
expect(Array.isArray(langs)).toBe(true);
|
||||||
|
|||||||
+39
-2
@@ -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.2.5');
|
expect(VERSION).toBe('0.4.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('api.default 是 api 本身', () => {
|
test('api.default 是 api 本身', () => {
|
||||||
@@ -81,6 +81,24 @@ describe('index.ts - 全局 API', () => {
|
|||||||
ed.destroy();
|
ed.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('全局 searchReplace 在新实例上可用(DOM 已就绪)', () => {
|
||||||
|
destroy();
|
||||||
|
use('searchReplace');
|
||||||
|
const ed = create(container, { value: 'foo bar' });
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
|
||||||
|
expect(ed.el.querySelector('.me-search')).not.toBeNull();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('全局插件与 config.plugins 同名时只安装一次', () => {
|
||||||
|
destroy();
|
||||||
|
use('autoSave');
|
||||||
|
const ed = create(container, { value: 'test', plugins: ['autoSave'] });
|
||||||
|
const count = ed.getPlugins().filter((p: any) => p.name === 'autoSave').length;
|
||||||
|
expect(count).toBe(1);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test('on / off 全局钩子', () => {
|
test('on / off 全局钩子', () => {
|
||||||
const fn = jest.fn();
|
const fn = jest.fn();
|
||||||
const unsub = on('afterCreate', fn);
|
const unsub = on('afterCreate', fn);
|
||||||
@@ -111,11 +129,30 @@ describe('index.ts - 全局 API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('destroy 清理全局资源', () => {
|
test('destroy 清理全局资源', () => {
|
||||||
expect(() => destroy()).not.toThrow();
|
destroy();
|
||||||
const status = getStatus();
|
const status = getStatus();
|
||||||
expect(status.globalPlugins.length).toBe(0);
|
expect(status.globalPlugins.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('destroy 清理用户全局钩子但保留内部注入钩子', () => {
|
||||||
|
destroy();
|
||||||
|
const fn = jest.fn();
|
||||||
|
on('afterCreate', fn);
|
||||||
|
expect(MarkdownEditor._hooks.get('afterCreate')!.length).toBeGreaterThan(0);
|
||||||
|
destroy();
|
||||||
|
const hooks = MarkdownEditor._hooks.get('afterCreate') || [];
|
||||||
|
expect(hooks.length).toBe(1); // 仅剩内部 injectGlobalPlugins
|
||||||
|
expect(hooks[0]).not.toBe(fn);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy 后 MeEditor.use 全局插件仍可用', () => {
|
||||||
|
destroy();
|
||||||
|
use('autoSave');
|
||||||
|
const ed = create(container, { value: 'x' });
|
||||||
|
expect(ed.getPlugins().some((p: any) => p.name === 'autoSave')).toBe(true);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test('window.MeEditor 被设置', () => {
|
test('window.MeEditor 被设置', () => {
|
||||||
expect((window as any).MeEditor).toBeDefined();
|
expect((window as any).MeEditor).toBeDefined();
|
||||||
expect((window as any).MeEditor.VERSION).toBe(VERSION);
|
expect((window as any).MeEditor.VERSION).toBe(VERSION);
|
||||||
|
|||||||
@@ -1329,6 +1329,93 @@ describe('parseMarkdown - v0.2.5 title 转义', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.2.5 脚注 id 属性注入防护', () => {
|
||||||
|
test('脚注引用 id 中的引号不能注入属性', () => {
|
||||||
|
const html = parseMarkdown('[^a" onclick="alert(1)]\n\n[^a" onclick="alert(1)]: note');
|
||||||
|
expect(html).not.toContain(' onclick="');
|
||||||
|
expect(html).not.toContain('onclick="alert(1)"');
|
||||||
|
expect(html).toContain('" onclick');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('脚注区 id 中的引号被转义', () => {
|
||||||
|
const html = parseMarkdown('x[^a" onload="x]\n\n[^a" onload="x]: note');
|
||||||
|
expect(html).not.toContain(' onload="');
|
||||||
|
expect(html).toContain('" onload');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('正常脚注 id 不受影响', () => {
|
||||||
|
const html = parseMarkdown('text[^1]\n\n[^1]: note');
|
||||||
|
expect(html).toContain('id="fnref-1"');
|
||||||
|
expect(html).toContain('id="fn-1"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ v0.3.0 白名单裸标签透传(underline) ============
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.3.0 underline 渲染', () => {
|
||||||
|
test('裸 <u> 标签透传为下划线', () => {
|
||||||
|
const html = parseMarkdown('<u>underlined</u>');
|
||||||
|
expect(html).toContain('<p><u>underlined</u></p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+U 命令输出在预览中可见', () => {
|
||||||
|
const html = parseMarkdown('<u>hello</u> world');
|
||||||
|
expect(html).toContain('<u>hello</u>');
|
||||||
|
expect(html).not.toContain('<u>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('带属性的 <u onclick> 仍被转义', () => {
|
||||||
|
const html = parseMarkdown('<u onclick="alert(1)">x</u>');
|
||||||
|
expect(html).not.toContain('<u onclick');
|
||||||
|
expect(html).toContain('<u');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('行内代码中的 <u> 不被透传', () => {
|
||||||
|
const html = parseMarkdown('`<u>x</u>`');
|
||||||
|
expect(html).toContain('<code><u>x</u></code>');
|
||||||
|
expect(html).not.toContain('<u>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('代码块中的 <u> 不被透传', () => {
|
||||||
|
const html = parseMarkdown('```\n<u>x</u>\n```');
|
||||||
|
expect(html).toContain('<u>');
|
||||||
|
expect(html).not.toContain('<u>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('<script> 仍被转义(白名单外)', () => {
|
||||||
|
const html = parseMarkdown('<script>alert(1)</script>');
|
||||||
|
expect(html).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ v0.3.0 渲染缓存 refs 指纹 ============
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.3.0 渲染缓存 refs 指纹', () => {
|
||||||
|
test('相同文本不同引用定义互不串数据', () => {
|
||||||
|
clearRenderCache();
|
||||||
|
const htmlA = parseMarkdown('[x][r]\n\n[r]: https://a.com');
|
||||||
|
const htmlB = parseMarkdown('[x][r]\n\n[r]: https://b.com');
|
||||||
|
expect(htmlA).toContain('href="https://a.com"');
|
||||||
|
expect(htmlB).toContain('href="https://b.com"');
|
||||||
|
expect(htmlA).not.toContain('href="https://b.com"');
|
||||||
|
expect(htmlB).not.toContain('href="https://a.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无引用定义的文本仍可命中缓存', () => {
|
||||||
|
clearRenderCache();
|
||||||
|
expect(parseMarkdown('**bold** text')).toBe(parseMarkdown('**bold** text'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用链接与无引用文本混合时各自正确', () => {
|
||||||
|
clearRenderCache();
|
||||||
|
const htmlA = parseMarkdown('[x][r] and **plain**\n\n[r]: https://a.com');
|
||||||
|
const htmlB = parseMarkdown('[x][r] and **plain**\n\n[r]: https://b.com');
|
||||||
|
expect(htmlA).toContain('https://a.com');
|
||||||
|
expect(htmlB).toContain('https://b.com');
|
||||||
|
expect(htmlB).toContain('<strong>plain</strong>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('parseMarkdown - v0.2.5 URL 属性无双重转义', () => {
|
describe('parseMarkdown - v0.2.5 URL 属性无双重转义', () => {
|
||||||
test('链接 URL 中的 & 不双重转义', () => {
|
test('链接 URL 中的 & 不双重转义', () => {
|
||||||
const html = parseMarkdown('[x](https://example.com/?a=1&b=2)');
|
const html = parseMarkdown('[x](https://example.com/?a=1&b=2)');
|
||||||
|
|||||||
@@ -758,6 +758,57 @@ describe('v0.1.5 watch 外部主题源', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('v0.3.1 实例主题 dispose', () => {
|
||||||
|
test('dispose 断开外部跟随的 MutationObserver', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.setAttribute('data-theme', 'dark');
|
||||||
|
document.body.appendChild(div);
|
||||||
|
const mockEditor = {
|
||||||
|
el: div,
|
||||||
|
container: div,
|
||||||
|
config: { theme: 'light' },
|
||||||
|
_emit: jest.fn(),
|
||||||
|
refresh: jest.fn(),
|
||||||
|
};
|
||||||
|
const ctx = createInstanceTheme(mockEditor);
|
||||||
|
const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect');
|
||||||
|
ctx.syncWithElement(div);
|
||||||
|
ctx.dispose();
|
||||||
|
expect(disconnectSpy).toHaveBeenCalled();
|
||||||
|
disconnectSpy.mockRestore();
|
||||||
|
document.body.removeChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose 后可重复调用且不抛错', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
document.body.appendChild(div);
|
||||||
|
const mockEditor = {
|
||||||
|
el: div,
|
||||||
|
container: div,
|
||||||
|
config: { theme: 'light' },
|
||||||
|
_emit: jest.fn(),
|
||||||
|
refresh: jest.fn(),
|
||||||
|
};
|
||||||
|
const ctx = createInstanceTheme(mockEditor);
|
||||||
|
expect(() => { ctx.dispose(); ctx.dispose(); }).not.toThrow();
|
||||||
|
document.body.removeChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MarkdownEditor destroy 时断开实例主题订阅', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
c.setAttribute('data-theme', 'dark');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
const ctx = ed.getThemeContext();
|
||||||
|
const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect');
|
||||||
|
ctx.adopt();
|
||||||
|
ed.destroy();
|
||||||
|
expect(disconnectSpy).toHaveBeenCalled();
|
||||||
|
disconnectSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('v0.1.5 MarkdownEditor 实例主题 API', () => {
|
describe('v0.1.5 MarkdownEditor 实例主题 API', () => {
|
||||||
test('setTheme / getTheme 实例方法', () => {
|
test('setTheme / getTheme 实例方法', () => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user