Compare commits

...
9 Commits
Author SHA1 Message Date
thzxx 67f234676c build: 提交 dist 构建产物(v0.4.3 完整 5 格式 + sourcemap + 类型声明)
CI / test-parser (push) Successful in 9m25s
CI / test-core (push) Successful in 9m33s
CI / test-rest (push) Successful in 9m27s
CI / e2e (push) Successful in 9m43s
CI / verify (18.x) (push) Successful in 9m52s
CI / verify (20.x) (push) Successful in 9m52s
CI / verify (24.x) (push) Successful in 9m45s
2026-08-20 20:43:07 +08:00
thzxx d6a618af5d feat: v0.4.3 — 浮动工具栏定位引擎重做 + 程序化编辑统一刷新管线
- 浮动工具栏 mirror 镜像测量:隐藏镜像层复制 textarea 排版样式,选区锚点插零宽字符读像素坐标,CJK/软换行/跨行选区误差全部消除

- 实测尺寸替代魔法数字;滚动 rAF 节流重定位;滚出视口自动隐藏;a11y:role=toolbar + aria-label、visibility 移出 Tab 序列、键盘可达

- 修复死开关/监听器泄漏/销毁后 focus 崩溃/blur 焦点抖动误隐藏

- _afterProgrammaticEdit 统一 8 条程序化路径的渲染/行号/字数/大纲/事件刷新;右键菜单 label 转义、copyAsHTML Firefox 降级、paste 失败提示、Esc 退出全屏

- 测试 871 → 895,e2e 22 → 28 项断言(含真实浏览器浮动工具栏布局验证)
2026-08-20 20:43:01 +08:00
thzxx ac6638247f build: 提交 dist 构建产物(v0.4.2 完整 5 格式 + sourcemap + 类型声明)
CI / test-parser (push) Successful in 9m47s
CI / test-core (push) Successful in 12m44s
CI / test-rest (push) Failing after 31s
CI / e2e (push) Successful in 9m45s
CI / verify (18.x) (push) Successful in 9m54s
CI / verify (20.x) (push) Failing after 11s
CI / verify (24.x) (push) Successful in 10m11s
2026-08-19 22:30:59 +08:00
thzxx 5f4915222e feat: v0.4.2 — 审查清偿:8 项修复 + setOutline API + 871 测试
修复:插件面板实例级 i18n / undo-redo 滚动保持 / shortcutHelp 卸载残留 / CSS.escape 环境崩溃 / 大纲光标按源码行号定位 / setLocale 即时刷新 / 浮动工具栏 CJK 宽度 / ko 语言包括号

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

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

工程:CI lint 阻断、build.sh npm ci、rollup 死代码清理、覆盖率阈值门禁
2026-08-19 22:30:52 +08:00
thzxx 5966ac7500 docs: 安装方式修正为 Gitea 私有源(移除 jsDelivr/unpkg 官方源引用)
CI / test-rest (push) Successful in 9m25s
CI / e2e (push) Successful in 9m41s
CI / verify (18.x) (push) Successful in 9m50s
CI / verify (20.x) (push) Successful in 9m48s
CI / verify (24.x) (push) Successful in 9m43s
CI / test-parser (push) Successful in 9m25s
CI / test-core (push) Successful in 9m35s
2026-08-09 20:25:57 +08:00
thzxx 0cb0d28bb4 build: 提交 dist 构建产物(v0.4.1 完整 5 格式 + sourcemap + 类型声明)
CI / test-rest (push) Successful in 9m29s
CI / e2e (push) Successful in 9m42s
CI / verify (18.x) (push) Successful in 9m54s
CI / verify (20.x) (push) Successful in 9m52s
CI / test-parser (push) Successful in 9m26s
CI / test-core (push) Successful in 9m35s
CI / verify (24.x) (push) Successful in 9m47s
2026-08-09 20:20:39 +08:00
thzxx 4d6767482e feat: v0.4.1 — Zen 专注模式宽度可配置 + 5 项修复 + 841 测试
CI / test-parser (push) Successful in 9m29s
CI / test-core (push) Successful in 9m36s
CI / test-rest (push) Successful in 9m29s
CI / e2e (push) Successful in 9m44s
CI / verify (18.x) (push) Successful in 9m50s
CI / verify (20.x) (push) Successful in 9m50s
CI / verify (24.x) (push) Successful in 9m45s
2026-08-09 20:17:25 +08:00
thzxx b743af8d90 ci(e2e): 改用 Playwright 官方 Docker 镜像,修复 debian11 不支持 chromium 安装
CI / test-parser (push) Successful in 9m25s
CI / verify (18.x) (push) Successful in 9m55s
CI / verify (20.x) (push) Successful in 9m49s
CI / test-core (push) Successful in 9m34s
CI / test-rest (push) Successful in 9m30s
CI / e2e (push) Successful in 10m57s
CI / verify (24.x) (push) Successful in 9m46s
runner 宿主为 Debian 11,Playwright 1.62 的 install-deps 已不支持;
改用 mcr.microsoft.com/playwright:v1.62.1-noble 容器自带 Chromium 与系统依赖。
2026-08-09 13:12:25 +08:00
thzxx cb0caff4cf 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
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 通过
2026-08-09 12:31:14 +08:00
42 changed files with 19714 additions and 294 deletions
+22 -1
View File
@@ -58,6 +58,28 @@ jobs:
env:
NODE_OPTIONS: --max-old-space-size=4096
e2e:
runs-on: debian-latest
# 使用 Playwright 官方镜像:自带 Chromium 与全部系统依赖,
# 避免 runner 宿主(Debian 11)不被 Playwright 1.62 支持的问题。
container: mcr.microsoft.com/playwright:v1.62.1-noble
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: Verify Playwright browsers
run: npx playwright install chromium
- name: Run E2E smoke tests
run: npm run test:e2e
verify:
runs-on: debian-latest
timeout-minutes: 15
@@ -77,6 +99,5 @@ jobs:
run: npm run typecheck
- name: Lint
run: npm run lint
continue-on-error: true
- name: Build
run: npm run build
-1
View File
@@ -1,5 +1,4 @@
node_modules/
dist/
coverage/
.DS_Store
.zcode/
+128
View File
@@ -2,6 +2,134 @@
All notable changes to MetonaEditor will be documented in this file.
## [0.4.3] - 2026-08-20
### Fixed
- **浮动工具栏定位系统性偏移(本轮主题)**: 旧版按"ASCII 0.6em / CJK 1em"逐字符估算宽度并按逻辑行推算纵坐标,中文混排、软换行(长行折行)、跨行选区、字体 fallback 均产生累积误差;现改为 mirror 镜像测量——隐藏镜像层复制 textarea 全部排版样式(font/行高/padding/white-space/overflow-wrap/tabSize/宽度),在选区锚点(文档序中点)插入零宽字符 marker 读取像素坐标,CJK、折行、tab 渲染宽度、字体差异的误差全部消除。锚点滚出可视区自动隐藏;textarea 滚动时 rAF 节流重定位;分隔条拖拽结束与窗口 resize 后隐藏待重新触发。
- **浮动工具栏魔法数字**: 工具栏宽度/高度改为渲染后实测 `getBoundingClientRect()`(居中、右边界 clamp、上下翻转判断全部基于实测值),删除 `-80` / `36` / `200` 等硬编码。
- **selectionDirection 反向修正逻辑错误**: 旧版在 backward 选区时将锚点列偏移乘 -1,但 `selectionStart` 恒为文档序小端,方向不影响中点;已删除。
- **`floatingToolbar: false` 后 toggle 变"死开关"**: 构造时条件跳过 `_initFloatingToolbar()` 导致事件监听器从未绑定,运行时 `toggleFloatingToolbar()` 打开后工具栏永不显示;现构造时无条件绑定事件(show 内部检查启用状态),死开关修复。
- **浮动工具栏隐藏态可聚焦**: 旧版仅 `opacity:0`,按钮仍在 Tab 序列中(可聚焦但不可见);现 `visibility:hidden` 纳入过渡,隐藏时移出 Tab 序列与可访问性树。
- **浮动工具栏按钮键盘不可操作**: 旧版命令绑定在 `mousedown`(键盘 Enter/Space 无效);现 mousedown 仅阻止夺焦,命令统一走 `click`,键盘可达。执行命令后工具栏隐藏。
- **浮动工具栏实例销毁后 focus 崩溃**: 按钮 click 的延迟 `focus()` 回调在 `destroy()` 之后触发时 `textarea` 已为 null,抛 `TypeError`;现增加存活检查。
- **浮动工具栏监听器泄漏**: `initFloatingToolbar` 绑定的 mouseup/keyup/blur/click/scroll/resize 监听器从不清理,destroy 后闭包引用实例;现统一注册到 `_cleanups`,镜像层 DOM 随 destroy 移除。
- **浮动工具栏被焦点抖动误隐藏**: blur 后 300ms 延迟隐藏的回调不校验焦点状态——自动化工具(如 Playwright fill 后还原焦点再 focus 回来)或宿主页面的快速焦点切换会让刚显示的工具栏被延迟回调误杀;现延迟到期时校验 `document.activeElement === textarea`,焦点已回归则不隐藏。
- **程序化编辑路径刷新与事件不一致(8 条路径)**: 智能 Enter / 括号自动闭合 / Tab 缩进 / `insert()` / `insertLink` / `insertImage` / `formatTable` / searchReplace 替换 此前各自为政——Smart Enter 后预览不刷新、括号闭合后字数不更新、Tab 后字数不加、点 h1 后大纲面板不出现新标题、程序化路径不发 `input` / `beforeChange` / `afterChange``onInput` / `onChange` 回调全漏);现统一收敛到 `_afterProgrammaticEdit()` 管线:渲染 + 行号 + 字数 + 大纲 + input/change 事件 + onInput/onChange 回调 + before/afterChange 钩子。undo/redo 与 replaceAll/replaceAllRegex 同步接入。
- **右键菜单自定义项 HTML 注入**: `registerContextMenu``label` / `shortcut` 未转义直接 `innerHTML``<img src=x onerror=...>` 可注入;现经 `escapeHTML` 转义。
- **`copyAsHTML` 在无 ClipboardItem 环境崩溃**: Firefox 等环境 `new ClipboardItem()` 构造即抛未捕获 `TypeError`;现能力检测 + try-catch,降级为纯文本剪贴板写入。
- **右键粘贴静默失败**: `execCommand('paste')` 被现代浏览器安全策略拒绝后无任何反馈;现失败时 toast 提示改用 Ctrl+V`pasteBlocked` 六语言文案)。
- **全屏无法用 Esc 退出**: 现全屏模式下按 Esc 退出(document 级监听,destroy 清理)。
### Added
- **浮动工具栏 a11y**: `role="toolbar"` + 实例语言 `aria-label``formatToolbar` 六语言文案),隐藏态自动移出可访问性树。
- 回归与补强测试 24 个(死开关修复、镜像层懒创建/复用/销毁清理、滚动重定位、a11y 属性、键盘 click 执行并隐藏、resize 隐藏、blur 焦点抖动防护、管线回归:Smart Enter 预览刷新与 input 事件、括号闭合预览与字数、Tab 字数、insert 行号、exec 后大纲刷新、undo/replaceAll 的 afterChange、exec 的 beforeChange 参数对与 onInput 回调、copyAsHTML 降级、paste toast、Esc 全屏、右键菜单转义),总数 871 → 895。
- e2e 冒烟测试 6 项新断言:浮动工具栏真实浏览器布局验证(中文选区显示、水平/垂直定位容差、bold 命令生效、执行后隐藏、长行折行选区保持可视区内),e2e 断言总数 22 → 28。
### Changed
- 行为变更:程序化编辑路径(命令 / 快捷键 / 插件 / API 直改)现补发 `input` 事件与 `beforeChange` / `afterChange` 钩子及 `onInput` / `onChange` 回调,与原生输入路径语义一致(`beforeChange` 的 oldValue 参数由调用方闭包保存,时机与原生路径相同)。
---
## [0.4.2] - 2026-08-19
### Fixed
- **插件面板文案未跟随实例 locale**: searchReplace / shortcutHelp / imagePaste 面板与提示此前使用全局 `t()`,实例 `locale` 与全局语言不一致时显示错误语言(v0.4.0 修复了核心 UI,插件面板遗漏);现统一经实例 `editor.t()` 取词。
- **undo/redo 后预览区跳顶**: `_applyHistory` 此前硬编码 `previewPane.scrollTop = 0`,分屏下长文档撤销时预览跳回顶部;现按滚动比例保存/恢复。
- **shortcutHelp 卸载面板残留**: `unuse('shortcutHelp')` / `destroy()` 此前保存的是 install 时刻的面板引用(null),已打开的快捷键面板不随之移除;现经 holder 取实时引用清理。
- **大纲点击在无 CSS.escape 环境崩溃**: `outline.ts` 直接调用全局 `CSS.escape()`jsdom 等未暴露 `window.CSS` 的环境下点击大纲链接抛 `ReferenceError`,光标定位静默失效;现能力检测 + 简易转义兜底。
- **大纲点击光标定位不准**: 此前用 `indexOf(标题文本)` 定位,重复标题永远跳第一个;`data-line` 属性误存标题 id 而非行号。现构建时计算各标题源码行号(跳过围栏代码块),点击按行号精确定位,`data-line` 记录 1 基行号。
- **setLocale 不刷新已渲染 UI**: 状态栏统计标签、大纲标题要等下一次输入才切换语言;现切换即时刷新。
- **浮动工具栏中文选区定位偏左**: 列宽估算固定按 ASCII 0.6em,中文全角字符约 1em;现逐字符按 CJK 判定累计宽度。
- **ko 语言包 imageTooLarge 混用中文全角括号**: 统一为半角括号。
### Added
- **运行时大纲 API**: `editor.setOutline(on)` / `editor.isOutline()`,运行时开关大纲面板(替代直接操作 `config.outline` + `_buildOutline()` 的私有用法)。
- **`getRenderCacheSize()`**: 暴露行内渲染缓存当前条目数(parser 导出),供观测 FIFO 淘汰。
- 回归与补强测试 30 个(7 个此前零断言事件:beforeRender / afterRender / zenChange / fullscreen / copy / fileOpened / fileSaved;全局钩子 beforeCreate / beforeDestroy / afterDestroy5 个零测试配置项:placeholder / height / spellcheck / tabSize(含 0 与 4/ historyDebounce;修复 3 处恒真/弱断言:同步滚动(含新增反向联动验证)、渲染缓存 FIFO 真实淘汰、搜索 findNext/prev 选中内容),总数 841 → 871。
- jest 覆盖率阈值:全局 lines 85% / branches 70%parser lines 95%(基线 94.95% / 75.72% / 98.93%)。
### Changed
- **CI lint 改为阻断**: 移除 verify job 中 lint 步骤的 `continue-on-error`lint 失败不再静默放行。
- **build.sh 依赖安装**: `npm install``npm ci`,保证构建可重现。
- **rollup.config.js 死代码清理**: 移除从未被引用的 `terserPlugin` / `isProd` 变量。
- 文档全面同步:README / docs.html / index.html 修正 "CI 4 job"(实际 5 个)、`document.body()` 错误写法、`afterChange` / `fileSaved` 事件参数(以源码为准)、docs.html 补齐约 15 处缺失 API`editor.t()``MeEditor.getStatus()` / `destroy()``configureToolbar` / `removeToolbarButton`、PluginManager & pluginUtils、`safeUrl` / `slugify` / `getSupportedLanguages``formatCurrency`、全局 beforeChange / afterChange 钩子、`data:image` 500KB 限制等);demo.html 私有 API hack 改用 `setOutline()` 公开 API。
---
## [0.4.1] - 2026-08-11
### Added
- **`zenMaxWidth` 配置项**: Zen 专注模式内容区宽度可配置(`number` 为 px / `string` 原样作为 CSS 值 / `false` 不限制),默认由硬编码 820px 提升至 960px,经 CSS 变量 `--md-zen-max-width` 实现实例级隔离。
- **运行时 API**: `editor.setZenMaxWidth(width)` / `editor.getZenMaxWidth()` 随时调整专注模式宽度。
### Fixed
- **`wordWrap: false` 配置不生效**: 构造函数此前将 `_wordWrap` 硬编码为 `true`,忽略配置项,导致初始仍自动换行;现读取配置并在构建时应用 `white-space: pre`
- **`outline: true` 启动时不显示大纲面板**: 构造函数此前只注册滚动跟踪、未构建面板,须等首次输入才出现;现构造后自动构建,且 `setMode()` 切换回预览类模式时同步重建。
- **edit 模式初始行号不渲染**: `_render()` 在 edit 模式直接返回导致 gutter 为空;现 edit 模式也维护行号装订线。
- **exportTool 卸载泄漏**: `unuse('exportTool')` / `destroy()` 此前不清理挂载在实例上的 `exportMarkdown` / `exportHTML` / `exportPDF` 方法;现卸载时一并删除。
- **searchReplace 替换后字数统计过期**: `replaceOne` / `replaceAll` 直接改值后未刷新状态栏统计;现调用 `_updateWordCount()`
### Added
- 回归测试 15 个(zenMaxWidth 默认/数字/字符串/false/运行时更新/启动应用、wordWrap: false 生效、outline 构造自动构建与模式切换重建、edit/preview 模式初始行号、exportTool 卸载清理),总数 826 → 841。
---
## [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>` 此前被解析器转义为字面量 `&lt;u&gt;`;现解析器白名单透传裸 `<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
### Fixed
+55 -27
View File
@@ -2,9 +2,9 @@
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
[![version](https://img.shields.io/badge/version-0.2.5-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
[![version](https://img.shields.io/badge/version-0.4.3-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
[![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-780%20passed-brightgreen)](./tests)
[![tests](https://img.shields.io/badge/tests-895%20passed-brightgreen)](./tests)
[![coverage](https://img.shields.io/badge/coverage-parser%2099%25-brightgreen)](./tests)
[![types](https://img.shields.io/badge/types-TypeScript%20strict-blue)](./tsconfig.json)
@@ -17,9 +17,11 @@
- **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步
- **主题系统** — light / dark / warm / autoCSS 变量可定制,实例级隔离,主题继承,外部跟随
- **国际化** — zh-CN / en-US / ja / ko / fr / de 六种语言完整翻译,实例级语言隔离,远程加载翻译包
- **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式、右键上下文菜单
- **内置语法高亮** — 零依赖轻量高亮器,js/ts/python/bash/css/html 等 13 种语言`MeEditor.highlight` 即插即用
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护,sanitize 钩子
- **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式(宽度可配置,默认 960px)、右键上下文菜单、浮动格式工具栏(mirror 镜像像素级定位,CJK / 软换行 / 跨行选区精确跟随,键盘可达)
- **内置语法高亮** — 零依赖轻量高亮器,js/ts/python/bash/css/html 等 16 个语言标识,规则缓存零重建开销`MeEditor.highlight` 即插即用
- **性能** — 字数 / 词数 / 行数差异增量统计,击键零全量扫描;大纲 O(n) 线性构建,长文档即时响应
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护(含脚注 id),白名单裸标签透传,sanitize 钩子
- **工程化** — 895 个单元测试 + Playwright 真实浏览器冒烟测试(28 项断言),CI 并行 5 job(含 e2e),覆盖率阈值门禁,`sideEffects: false` 便于 tree-shaking
- **桌面端优先** — 纯电脑端设计,无移动端冗余代码
- **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性
- **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等)
@@ -50,7 +52,16 @@
### 方式一:npm 安装(推荐)
> 包托管于 MetonaTeam 自建 Gitea 私有 npm 源(`git.metona.cn`),不在 npm 官方源发布,安装时需指定 registry。
```bash
# 直接指定私有源安装
npm install @metona-team/metona-editor --registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
# 或配置项目 .npmrc(推荐,一次配置长期生效)
# .npmrc
@metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
npm install @metona-team/metona-editor
```
@@ -76,15 +87,12 @@ const editor = MeEditor.create('#editor', { mode: 'split' });
### 方式二:CDN 引入
```html
<!-- jsDelivr CDN (推荐) -->
<script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.2.5/dist/metona-editor.min.js"></script>
<!-- unpkg CDN -->
<script src="https://unpkg.com/@metona-team/metona-editor@0.2.5/dist/metona-editor.min.js"></script>
<!-- Gitea 源 -->
<!-- Gitea 源(私有仓库需登录,公开仓库直接可访问) -->
<script src="https://git.metona.cn/MetonaTeam/MetonaEditor/raw/branch/master/dist/metona-editor.js"></script>
<!-- 本地构建产物(下载 dist 后引入) -->
<script src="./dist/metona-editor.min.js"></script>
<div id="editor"></div>
<script>
const editor = MeEditor.create('#editor', { mode: 'split' });
@@ -122,6 +130,7 @@ MeEditor.create(container, {
// 内容
value: '', // 初始 Markdown 文本
placeholder: '', // 占位符
id: '', // 实例 id(默认自动生成,用于分隔条/草稿等存储键隔离)
// 视图
mode: 'split', // 'edit' | 'split' | 'preview'
@@ -141,6 +150,7 @@ MeEditor.create(container, {
syncScroll: true, // 分屏同步滚动
autoBrackets: true, // 括号自动闭合
zenMode: false, // 启动即进入 Zen 专注模式
zenMaxWidth: 960, // Zen 内容区最大宽度:数字为 px / 字符串原样作为 CSS 值 / false 不限制
wordWrap: true, // 自动换行
maxLength: 0, // 最大字符数(0=不限)
floatingToolbar: true, // 选中文本浮动格式栏
@@ -246,6 +256,8 @@ editor.toggleFloatingToolbar(): this // 开关选中文本格式栏
editor.isFloatingToolbar(): boolean
```
选中文本时自动浮现粗体 / 斜体 / 代码 / 链接 / 删除线按钮。定位采用 mirror 镜像测量(隐藏镜像层复制 textarea 排版样式,选区锚点插入零宽字符读像素坐标),中文混排、长行软换行、跨行选区均精确跟随;选区滚出可视区自动隐藏,滚动时节流重定位。按钮键盘可达(Tab 聚焦 + Enter 执行),隐藏态移出 Tab 序列。已知限制:RTL 布局下定位未做镜像适配。
### 命令执行
```typescript
@@ -256,6 +268,7 @@ editor.exec(action: string): this
// h1 h2 h3 quote ul ol hr
// link image table
// indent outdent undo redo
// formatTable(光标所在表格按列宽对齐)
// edit split preview fullscreen
// zen wordwrap
```
@@ -337,14 +350,18 @@ editor.configureToolbar(tools: string[]): this
editor.removeToolbarButton(action: string): this
```
### Zen / Word Wrap
### Zen / Word Wrap / Outline
```typescript
editor.toggleZen(): this
editor.isZen(): boolean
editor.setZenMaxWidth(width: number | string | false): this // 运行时调整专注模式宽度
editor.getZenMaxWidth(): number | string | false
editor.toggleWordWrap(): this
editor.setWordWrap(on: boolean): this
editor.isWordWrap(): boolean
editor.setOutline(on: boolean): this // 运行时开关大纲面板
editor.isOutline(): boolean
```
### Toast
@@ -352,8 +369,8 @@ editor.isWordWrap(): boolean
```typescript
editor.toast(message: string, opts?: {
type?: 'success' | 'error' | 'warning' | 'info';
duration?: number;
animation?: 'fade' | 'slide' | 'scale';
duration?: number; // ms0 = 不自动消失
animation?: 'fade' | 'slide' | 'scale' | 'bounce' | 'flip' | 'rotate' | 'zoom';
}): this
```
@@ -397,11 +414,11 @@ MeEditor.setLocale('en-US')
MeEditor.getStatus()
// => { version, theme, locale, globalPlugins, presetPlugins }
// 销毁全局资源
// 销毁全局资源(主题/语言监听、全局插件、全局钩子全面复位)
MeEditor.destroy()
// 解析器独立使用
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlockHandler } from '@metona-team/metona-editor';
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler } from '@metona-team/metona-editor';
// 内置语法高亮
import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team/metona-editor';
@@ -415,7 +432,7 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team
| 事件 | 触发时机 | 参数 |
|------|---------|------|
| `input` | textarea 原生 input | `(value, editor)` |
| `input` | 内容变化(原生输入与程序化编辑路径均触发) | `(value, editor)` |
| `change` | 内容变化 | `(value, editor)` |
| `focus` | 聚焦 | `(editor)` |
| `blur` | 失焦 | `(editor)` |
@@ -432,8 +449,8 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team
| `linkClick` | 预览区链接点击 | `({ href, text })` |
| `fileOpened` | fileSystem 打开文件 | `({ name, handle })` |
| `fileSaved` | fileSystem 保存文件 | `({ handle })` |
| `beforeChange` | 内容变更前 | `(oldValue, newValue, editor)` |
| `afterChange` | 内容变更后 | `(newValue, editor)` |
| `beforeChange` | 内容变更前(原生输入与程序化编辑路径均触发) | `(oldValue, newValue, editor)` |
| `afterChange` | 内容变更后(原生输入与程序化编辑路径均触发) | `(newValue, editor)` |
| `copy` | 复制到剪贴板 | `({ type })` |
| `selectionChange` | 选区变化 | `({ start, end, text })` |
| `cursorMove` | 光标移动 | `({ line, column })` |
@@ -465,6 +482,7 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team
| 粗斜体 | `***text***` `___text___` | `<em><strong>` |
| 斜体 | `*italic*` `_italic_` | `<em>` |
| 删除线 | `~~text~~` | `<del>` |
| 下划线 | `<u>text</u>`(裸标签透传) | `<u>` |
| 高亮 | `==text==` | `<mark>` |
| 上标 | `x^2^` | `<sup>` |
| 下标 | `H~2~O` | `<sub>` |
@@ -482,6 +500,7 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team
| 图片 | `![alt](url)` | `<img>` |
| 引用链接 | `[text][ref]` + `[ref]: url` | `<a>` |
| 引用图片 | `![alt][ref]` + `[ref]: url` | `<img>` |
| 表格格式化 | `exec('formatTable')` | 光标所在表格按列宽对齐 |
| 自动链接 | `<https://...>` | `<a>` |
| 数学公式 | `$E=mc^2$` `$$\int$$` | `<span>` / `<div>` |
| 脚注 | `text[^1]` | `<sup>` + 底部定义 |
@@ -532,7 +551,8 @@ registerBlockHandler({
- 所有文本经 `escapeHTML` 转义
- 链接 URL 过滤 `javascript:` / `vbscript:` / `file:` / 非图片 `data:`
- 属性级注入防护:`href` / `src` / `alt` / `title` / `language-*` 引号与换行均被转义
- 属性级注入防护:`href` / `src` / `alt` / `title` / 脚注 id / `language-*` 引号与换行均被转义
- 白名单裸标签透传:仅 `<u>` / `</u>`(无属性),带属性的标签仍被转义
- `data:image` 限制最大 500KB
- `sanitize` 钩子供外部净化(如 DOMPurify)
- `highlight` 钩子异常自动回退为纯文本
@@ -666,6 +686,9 @@ editor.getThemeContext().adopt();
// 跟随外部元素
editor.getThemeContext().syncWithElement(document.body);
// 手动断开所有外部跟随订阅(实例 destroy 时也会自动断开)
editor.getThemeContext().dispose();
```
---
@@ -725,6 +748,7 @@ i18nUtils.formatDate('2024-01-15') // 本地化日期
| `Tab` / `Shift+Tab` | 缩进 / 反缩进 |
| `?` | 快捷键帮助 |
| `Enter` | 智能 Enter(列表/引用延续) |
| `Esc` | 退出全屏 |
---
@@ -749,6 +773,9 @@ npm test
# 测试覆盖率
npm run test -- --coverage
# 浏览器冒烟测试(Playwright + Chromium,需先构建)
npm run build && npm run test:e2e
# 性能基准(先构建再运行)
npm run build && npm run bench
@@ -760,11 +787,11 @@ npm run format
```
dist/
├── metona-editor.js UMD(浏览器直接引入)
├── metona-editor.min.js UMD 压缩版(CDN
├── metona-editor.mjs ES Module
├── metona-editor.cjs CommonJS
└── metona-editor.d.ts TypeScript 类型声明
├── metona-editor.js UMD(浏览器直接引入)
├── metona-editor.min.js UMD 压缩版(CDN
├── metona-editor.mjs ES Module
├── metona-editor.cjs CommonJS
└── metona-editor.d.ts TypeScript 类型声明
```
### 源码结构
@@ -796,7 +823,8 @@ src/
|------|------|
| 语言 | TypeScript 5 (strict) |
| 构建 | Rollup 3 |
| 测试 | Jest 29 + jsdom |
| 测试 | Jest 29 + jsdom895 用例,覆盖率阈值门禁) |
| 浏览器冒烟 | Playwright + Chromium28 项断言) |
| 类型生成 | rollup-plugin-dts |
| 零运行时依赖 | ✅ |
+1 -1
View File
@@ -20,7 +20,7 @@ echo "✅ npm版本: $(npm -v)"
echo ""
echo "📦 安装依赖..."
npm install
npm ci
if [ $? -ne 0 ]; then
echo "❌ 依赖安装失败"
+5444
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+772
View File
@@ -0,0 +1,772 @@
/**
* MetonaEditor Plugins — plugin system v2
* @module plugins
* @version 0.2.0
*/
/** The editor surface plugins may rely on. Keeps plugin code type-checked
* without importing the full MarkdownEditor class (avoids circular imports). */
interface EditorLike {
id?: string;
value?: string;
_value?: string;
el: HTMLElement;
textarea: HTMLTextAreaElement;
config?: Record<string, any>;
t?: (key: string, params?: Record<string, any>) => string;
on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void;
off?: (name: string, fn: (...args: any[]) => void) => unknown;
_emit?: (name: string, ...args: any[]) => void;
_pushHistory?: () => void;
_render?: () => void;
_updateWordCount?: () => void;
/** 程序化编辑统一刷新管线(渲染+行号+字数+大纲+事件+钩子) */
_afterProgrammaticEdit?: (oldValue: string) => void;
insert?: (text: string, opts?: {
replace?: boolean;
}) => unknown;
setValue?: (value: string, opts?: {
silent?: boolean;
}) => unknown;
getValue?: () => string;
getHTML?: () => string;
focus?: () => unknown;
toast?: (message: string, opts?: {
type?: string;
duration?: number;
animation?: string;
}) => unknown;
}
interface Plugin {
name: string;
version?: string;
description?: string;
depends?: string[];
priority?: number;
install?: (editor: EditorLike, options?: any) => void | Promise<void>;
destroy?: (editor: EditorLike) => void;
[key: string]: any;
}
interface PluginSchema {
[key: string]: {
type?: string;
required?: boolean;
default?: any;
enum?: any[];
validator?: (val: any) => boolean | string;
};
}
declare class PluginManager {
plugins: Map<string, Plugin & {
enabled: boolean;
}>;
register(name: string, plugin: Plugin): this;
unregister(name: string): this;
get(name: string): Plugin | null;
has(name: string): boolean;
getAll(): Plugin[];
getNames(): string[];
enable(name: string): this;
disable(name: string): this;
isEnabled(name: string): boolean;
destroy(): void;
}
declare const topologicalSort: (plugins: Plugin[]) => Plugin[];
declare const validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
declare const presetPlugins: Record<string, Plugin>;
declare const pluginUtils: {
createManager: () => PluginManager;
manager: PluginManager;
register: (name: string, plugin: Plugin) => PluginManager;
unregister: (name: string) => PluginManager;
get: (name: string) => Plugin | null;
has: (name: string) => boolean;
getAll: () => Plugin[];
getNames: () => string[];
enable: (name: string) => PluginManager;
disable: (name: string) => PluginManager;
isEnabled: (name: string) => boolean;
getPreset: (name: string) => Plugin | null;
getAllPresets: () => Record<string, Plugin>;
createPlugin: (config?: Partial<Plugin>) => Plugin;
validatePlugin: (plugin: any) => {
valid: boolean;
errors: string[];
};
topologicalSort: (plugins: Plugin[]) => Plugin[];
validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
};
declare const loadRemote: (url: string, locale: string) => Promise<boolean>;
interface InstanceI18n {
set: (locale: string) => string;
get: () => string;
getDirection: () => 'ltr' | 'rtl';
t: (key: string, params?: Record<string, any>) => string;
formatNumber: (n: number, opts?: Intl.NumberFormatOptions) => string;
formatDate: (d: Date | string, opts?: Intl.DateTimeFormatOptions) => string;
}
declare const createInstanceI18n: (editor: any) => InstanceI18n;
declare const i18nUtils: {
t: (key: string, params?: Record<string, any>, locale?: string) => string;
getCurrentLocale: () => string;
setCurrentLocale: (locale: string) => void;
switchLocale: (locale: string) => void;
getFallbackLocale: () => string;
setFallbackLocale: (locale: string) => void;
hasTranslation: (key: string) => boolean;
getTranslations: (locale: string) => Record<string, any>;
addTranslations: (locale: string, translations: Record<string, any>) => void;
loadRemote: (url: string, locale: string) => Promise<boolean>;
getSupportedLocales: () => string[];
isLocaleSupported: (locale: string) => boolean;
getLocaleName: (locale: string) => string;
getLocaleDirection: (locale: string) => "ltr" | "rtl";
formatNumber: (number: number, options?: Intl.NumberFormatOptions) => string;
formatCurrency: (amount: number, currency?: string, options?: Intl.NumberFormatOptions) => string;
formatDate: (date: Date | string, options?: Intl.DateTimeFormatOptions) => string;
addLocaleListener: (fn: (locale: string) => void) => () => void;
removeLocaleListener: (fn: (locale: string) => void) => void;
clearLocaleListeners: () => void;
initI18n: () => void;
saveLocale: (locale: string) => void;
loadLocale: () => string;
getDefaultLocale: () => string;
createInstanceI18n: (editor: any) => InstanceI18n;
};
/**
* MetonaEditor Highlight — built-in lightweight syntax highlighter
* @module highlight
* @version 0.4.0
*
* Zero-dependency tokenizer for common languages. Safe by construction:
* input is HTML-escaped first, then annotated with <span> classes on the
* escaped text (no raw HTML can leak through).
*/
interface LanguageDef {
keywords: string[];
builtins: string[];
hasBlocks?: boolean;
hasHashComments?: boolean;
hasTemplateStrings?: boolean;
singleQuoteStrings?: boolean;
}
/** Map a code-block language hint to the canonical language name */
declare const normalizeLanguage: (lang: string) => string;
/** Highlight code with the built-in tokenizer. Falls back to escaped plain text. */
declare const highlight: (code: string, lang: string) => string;
/** Register or override a language definition */
declare const registerLanguage: (name: string, def: LanguageDef) => void;
declare const getSupportedLanguages: () => string[];
/**
* MetonaEditor Icons — SVG toolbar icons
* @module icons
* @version 0.2.0
*/
declare const ICONS: Record<string, string>;
/**
* MetonaEditor Constants — default configs, themes, animations
* @module constants
* @version 0.2.0
*/
type EditMode = 'edit' | 'split' | 'preview';
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string;
type ToolbarItem = string | '|';
interface RenderEnv {
highlight?: (code: string, lang: string) => string;
locale?: string;
refs?: Record<string, string>;
}
interface EditorStyle {
color?: string;
backgroundColor?: string;
[key: string]: string | undefined;
}
interface EditorOptions {
value?: string;
placeholder?: string;
mode?: EditMode;
height?: number | string;
toolbar?: ToolbarItem[] | false;
wordCount?: boolean;
autofocus?: boolean;
spellcheck?: boolean;
historyLimit?: number;
historyDebounce?: number;
syncScroll?: boolean;
tabSize?: number;
readOnly?: boolean;
lineNumbers?: boolean;
outline?: boolean;
autoBrackets?: boolean;
zenMode?: boolean;
zenMaxWidth?: number | string | false;
wordWrap?: boolean;
maxLength?: number;
theme?: ThemeName;
locale?: string;
render?: ((markdown: string, env: RenderEnv) => string) | null;
highlight?: ((code: string, lang: string) => string) | null;
sanitize?: ((html: string) => string) | null;
className?: string;
style?: EditorStyle;
plugins?: any[];
id?: string;
onChange?: ((value: string, editor: any) => void) | null;
onInput?: ((value: string, editor: any) => void) | null;
onFocus?: ((editor: any) => void) | null;
onBlur?: ((editor: any) => void) | null;
onSave?: ((value: string, editor: any) => void) | null;
onModeChange?: ((mode: string, editor: any) => void) | null;
onFullscreen?: ((fullscreen: boolean, editor: any) => void) | null;
onCreate?: ((editor: any) => void) | null;
onDestroy?: ((editor: any) => void) | null;
onLinkClick?: ((href: string, text: string, editor: any) => void) | null;
floatingToolbar?: boolean;
}
interface ThemeConfig {
bg: string;
text: string;
border: string;
shadow: string;
hoverShadow?: string;
toolbarBg?: string;
textareaBg?: string;
previewBg?: string;
codeBg?: string;
codeText?: string;
accent?: string;
muted?: string;
progressBg?: string;
closeHoverBg?: string;
}
interface AnimationConfig {
name?: string;
enter: Record<string, string>;
leave: Record<string, string>;
duration: number;
easing: string;
}
/** Default toolbar button sequence */
declare const DEFAULT_TOOLBAR: ToolbarItem[];
/** Default options */
declare const DEFAULTS: Readonly<EditorOptions>;
declare const THEMES: Record<string, ThemeConfig | string>;
declare const EDIT_MODES: EditMode[];
declare const TOOLBAR_ACTIONS: string[];
/**
* MetonaEditor Parser — lightweight Markdown parser (TypeScript)
* @module parser
* @version 0.2.0
*/
interface Token {
type: string;
[key: string]: any;
}
interface ParseResult {
tokens: Token[];
footnotes: Record<string, string>;
refs: Record<string, string>;
}
interface BlockHandler {
name: string;
priority: number;
test: (line: string, lines: string[], i: number) => any;
parse: (lines: string[], i: number, match: any, tokens: Token[], footnotes: Record<string, string>, refs?: Record<string, string>) => {
token: Token | null;
newIndex: number;
};
}
declare const clearRenderCache: () => void;
declare const safeUrl: (url: string | null | undefined) => string;
declare const slugify: (text: string) => string;
declare const registerBlockHandler: (handler: BlockHandler) => void;
declare const parseTokens: (md: string | null | undefined) => ParseResult;
declare const renderTokens: (tokens: Token[], env?: RenderEnv, footnotes?: Record<string, string>) => string;
declare const parseMarkdown: (md: string | null | undefined, env?: RenderEnv) => string;
/**
* MetonaEditor Core — MarkdownEditor class (TypeScript)
* @module core
* @version 0.2.0
*/
declare class MarkdownEditor {
static _hooks: Map<string, ((editor: MarkdownEditor) => void)[]>;
static on(name: string, fn: (editor: MarkdownEditor) => void): () => void;
static off(name: string, fn: (editor: MarkdownEditor) => void): void;
static trigger(name: string, instance: MarkdownEditor): void;
id: string;
container: HTMLElement;
config: EditorOptions;
el: HTMLElement;
toolbarEl: HTMLElement;
bodyEl: HTMLElement;
editorPane: HTMLElement;
editorInner: HTMLElement;
previewPane: HTMLElement;
dividerEl: HTMLElement;
textarea: HTMLTextAreaElement;
previewEl: HTMLElement;
gutter: HTMLElement;
statusEl: HTMLElement | null;
_value: string;
_mode: EditMode;
_history: string[];
_historyIndex: number;
_cleanups: Array<() => void>;
_plugins: any[];
_listeners: Record<string, Array<(...args: any[]) => void>>;
_renderRaf: number | null;
_historyTimer: ReturnType<typeof setTimeout> | null;
_fullscreen: boolean;
_destroyed: boolean;
_lastRenderedValue: string | null;
_shortcuts: any[];
_contextMenuItems: any[];
_customActions: Record<string, (...args: any[]) => void>;
_outlineTimer: ReturnType<typeof setTimeout> | null;
_zenMode: boolean;
_wordWrap: boolean;
_syncing: boolean;
_statsCache: {
value: string;
stats: any;
} | null;
_pendingCursor: {
start: number;
end: number;
} | null;
_lastSelStart: number;
_lastSelEnd: number;
_zenMouseHandler: ((e: MouseEvent) => void) | null;
_ariaLive: HTMLElement;
_renderFn: (md: string, env: RenderEnv) => string;
_highlightFn: ((code: string, lang: string) => string) | null;
_themeCtx: any;
_i18nCtx: InstanceI18n;
_floatingToolbar: HTMLElement | null;
_floatingEnabled: boolean;
_floatMirror: HTMLElement | null;
_wrapSelection: (before: string, after: string) => void;
_toggleLinePrefix: (prefix: string) => void;
_insertBlock: (text: string) => void;
_insertLink: () => void;
_insertImage: () => void;
_insertTable: (rows?: number, cols?: number) => void;
_formatTable: () => void;
_initFloatingToolbar: () => void;
toggleFloatingToolbar: () => this;
isFloatingToolbar: () => boolean;
_buildFloatingToolbar: () => void;
_hideFloatingToolbar: () => void;
_measureSelectionAnchor: () => {
x: number;
y: number;
} | null;
registerContextMenu: (items: any[]) => this;
_bindContextMenu: () => void;
_showContextMenu: (e: MouseEvent) => void;
_hideContextMenu: () => void;
_execContextAction: (action: string) => void;
_buildOutline: () => void;
_updateOutline: () => void;
_trackOutlineScroll: () => void;
constructor(container: string | HTMLElement, options?: EditorOptions);
_buildDOM(): void;
_buildToolbar(): void;
_createBtn(item: string): HTMLButtonElement;
_bindEvents(): void;
_handleKeydown(e: KeyboardEvent): void;
_handleTab(shift: boolean): void;
_bindDividerDrag(e: PointerEvent): void;
_saveDividerPosition(): void;
_restoreDividerPosition(): void;
_scheduleRender(): void;
_render(): void;
_renderGutter(): void;
_updateCurrentLine(): void;
/** 选区/光标事件:与浮动工具栏解耦,任何实例都会触发 */
_emitCursorEvents(): void;
_handleSmartEnter(e: KeyboardEvent): void;
_handleBracketAutoClose(e: KeyboardEvent): void;
_bindDragDrop(): void;
_scheduleHistory(): void;
/**
* 程序化编辑路径(命令 / 快捷键 / 插件 / API 直改 textarea)的统一刷新管线。
* 此前 8 条路径的 预览/行号/字数/大纲 刷新与事件发射参差不齐,
* 现统一为:渲染 + 行号 + 字数 + 大纲 + input/change 事件 + 回调 + before/afterChange 钩子。
*/
_afterProgrammaticEdit(oldValue: string): void;
_pushHistory(): void;
undo(): this;
redo(): this;
canUndo(): boolean;
canRedo(): boolean;
_applyHistory(): void;
_captureCursor(): void;
_restoreCapturedCursor(): void;
exec(action: string, ...args: any[]): this;
setMode(mode: EditMode): this;
getMode(): EditMode;
_updateModeButtons(): void;
toggleFullscreen(): this;
isFullscreen(): boolean;
exitFullscreen(): this;
toggleZen(): this;
_setZen(on: boolean): void;
isZen(): boolean;
_applyZenWidth(): void;
setZenMaxWidth(width: number | string | false): this;
getZenMaxWidth(): number | string | false;
toggleWordWrap(): this;
setWordWrap(on: boolean): this;
isWordWrap(): boolean;
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on: boolean): this;
isOutline(): boolean;
_bindToolbarKeyboard(): void;
_initAriaLive(): void;
_announce(msg: string): void;
_syncScroll(): void;
_updateWordCount(): void;
getStats(): any;
/**
* 增量统计:基于上一次统计 + 差异区间(共同前缀/后缀之间的片段)计算,
* 避免每次击键对全文做 O(n) 正则。差异区间左右扩展至单词边界,
* 保证英文单词 / 中文串不会被边界切断,diff 结果与全量计算一致。
*/
_computeStats(text: string): any;
getSelectedText(): string;
getCursorPosition(): {
line: number;
column: number;
};
setCursorPosition(line: number, column?: number): this;
scrollToLine(line: number): this;
selectLine(line: number): this;
selectAll(): this;
replaceAll(search: string, replace: string, caseSensitive?: boolean): number;
replaceAllRegex(pattern: RegExp, replace: string): number;
lineCount(): number;
getLine(line: number): string;
_limitLength(value: string): string;
getValue(): string;
setValue(md: string, opts?: {
silent?: boolean;
}): this;
getHTML(): string;
refresh(): this;
copyAsMarkdown(): this;
copyAsHTML(): this;
insert(text: string, opts?: {
replace?: boolean;
}): this;
wrap(before: string, after: string): this;
focus(): this;
blur(): this;
enable(): this;
disable(): this;
isDisabled(): boolean;
setReadOnly(readOnly: boolean): this;
isReadOnly(): boolean;
on(name: string, fn: (...args: any[]) => void): () => void;
off(name: string, fn: (...args: any[]) => void): this;
_emit(name: string, ...args: any[]): void;
use(plugin: string | any, options?: any): this;
unuse(name: string): this;
getPlugins(): any[];
addToolbarButton(config: any): this;
registerShortcut(combo: string, handler: Function | string, description?: string): this;
unregisterShortcut(combo: string): this;
getShortcuts(): any[];
configureToolbar(tools: ToolbarItem[]): this;
removeToolbarButton(action: string): this;
toast(message: string, opts?: {
type?: string;
duration?: number;
animation?: string;
}): this;
setLocale(locale: string): this;
getLocale(): string;
t(key: string, params?: Record<string, any>): string;
setTheme(theme: ThemeName): this;
getTheme(): string;
getThemeContext(): any;
getStatus(): {
id: string;
mode: EditMode;
theme: string;
locale: string;
fullscreen: boolean;
readOnly: boolean;
disabled: boolean;
destroyed: boolean;
plugins: any[];
};
destroy(): void;
isDestroyed(): boolean;
}
/**
* MetonaEditor Themes — theme system
* @module themes
* @version 0.2.0
*/
declare const exportCSSVars: (el?: HTMLElement) => Record<string, string>;
declare const getCSSVariable: (name: string, el?: HTMLElement) => string;
declare const followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
declare const adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
declare const createInstanceTheme: (editor: any) => any;
declare const themeUtils: {
getSystemTheme: () => "light" | "dark";
resolveTheme: (theme: string) => string;
getThemeConfig: (theme: string) => ThemeConfig;
watchSystemTheme: () => void;
unwatchSystemTheme: () => void;
applyTheme: (theme: string) => void;
getCurrentTheme: () => string;
getResolvedTheme: () => string;
switchTheme: (theme: string) => void;
toggleTheme: () => void;
resetToAuto: () => void;
initTheme: () => void;
saveTheme: (theme: string) => void;
loadTheme: () => string;
addThemeListener: (fn: (theme: string, resolved: string) => void) => () => void;
removeThemeListener: (fn: (theme: string, resolved: string) => void) => void;
clearThemeListeners: () => void;
registerTheme: (name: string, config?: Partial<ThemeConfig> & {
extends?: string;
}) => void;
unregisterTheme: (name: string) => void;
getAllThemes: () => Record<string, ThemeConfig | string>;
getThemeNames: () => string[];
hasTheme: (name: string) => boolean;
setThemeVariables: (config: ThemeConfig, target?: HTMLElement) => void;
exportCSSVars: (el?: HTMLElement) => Record<string, string>;
getCSSVariable: (name: string, el?: HTMLElement) => string;
applyThemeToElement: (theme: string, target: HTMLElement) => void;
followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
watch: (source: Function | string, onChange: (theme: string) => void) => () => void;
createInstanceTheme: (editor: any) => any;
};
/**
* MetonaEditor Animations — animation metadata
* @module animations
* @version 0.2.0
*/
declare const animationUtils: {
register(name: string, config: Partial<AnimationConfig> & {
enter?: Record<string, string>;
leave?: Record<string, string>;
}): void;
unregister(name: string): void;
get(name: string): AnimationConfig | null;
getAnimationNames(): string[];
getActiveCount(): number;
cancelAll(): void;
reset(): void;
destroy(): void;
};
declare const VERSION = "0.4.3";
declare function create(container: string | HTMLElement, options?: EditorOptions): MarkdownEditor;
declare function use(plugin: string | any, options?: any): typeof api;
declare function on(name: string, fn: (editor: MarkdownEditor) => void): () => void;
declare function off(name: string, fn: (editor: MarkdownEditor) => void): typeof api;
declare function setTheme(theme: ThemeName, _options?: any): typeof api;
declare function setLocale(locale: string): typeof api;
declare function destroy(): void;
declare function getStatus(): {
version: string;
theme: string;
locale: string;
globalPlugins: any[];
presetPlugins: string[];
};
declare const api: {
VERSION: string;
version: string;
MarkdownEditor: typeof MarkdownEditor;
Editor: typeof MarkdownEditor;
create: typeof create;
use: typeof use;
on: typeof on;
off: typeof off;
setTheme: typeof setTheme;
setLocale: typeof setLocale;
destroy: typeof destroy;
getStatus: typeof getStatus;
parseMarkdown: (md: string | null | undefined, env?: RenderEnv) => string;
parseTokens: (md: string | null | undefined) => ParseResult;
renderTokens: (tokens: Token[], env?: RenderEnv, footnotes?: Record<string, string>) => string;
safeUrl: (url: string | null | undefined) => string;
slugify: (text: string) => string;
clearRenderCache: () => void;
registerBlockHandler: (handler: BlockHandler) => void;
highlight: (code: string, lang: string) => string;
normalizeLanguage: (lang: string) => string;
registerLanguage: (name: string, def: LanguageDef) => void;
getSupportedLanguages: () => string[];
themes: {
getSystemTheme: () => "light" | "dark";
resolveTheme: (theme: string) => string;
getThemeConfig: (theme: string) => ThemeConfig;
watchSystemTheme: () => void;
unwatchSystemTheme: () => void;
applyTheme: (theme: string) => void;
getCurrentTheme: () => string;
getResolvedTheme: () => string;
switchTheme: (theme: string) => void;
toggleTheme: () => void;
resetToAuto: () => void;
initTheme: () => void;
saveTheme: (theme: string) => void;
loadTheme: () => string;
addThemeListener: (fn: (theme: string, resolved: string) => void) => () => void;
removeThemeListener: (fn: (theme: string, resolved: string) => void) => void;
clearThemeListeners: () => void;
registerTheme: (name: string, config?: Partial<ThemeConfig> & {
extends?: string;
}) => void;
unregisterTheme: (name: string) => void;
getAllThemes: () => Record<string, ThemeConfig | string>;
getThemeNames: () => string[];
hasTheme: (name: string) => boolean;
setThemeVariables: (config: ThemeConfig, target?: HTMLElement) => void;
exportCSSVars: (el?: HTMLElement) => Record<string, string>;
getCSSVariable: (name: string, el?: HTMLElement) => string;
applyThemeToElement: (theme: string, target: HTMLElement) => void;
followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
watch: (source: Function | string, onChange: (theme: string) => void) => () => void;
createInstanceTheme: (editor: any) => any;
};
i18n: {
t: (key: string, params?: Record<string, any>, locale?: string) => string;
getCurrentLocale: () => string;
setCurrentLocale: (locale: string) => void;
switchLocale: (locale: string) => void;
getFallbackLocale: () => string;
setFallbackLocale: (locale: string) => void;
hasTranslation: (key: string) => boolean;
getTranslations: (locale: string) => Record<string, any>;
addTranslations: (locale: string, translations: Record<string, any>) => void;
loadRemote: (url: string, locale: string) => Promise<boolean>;
getSupportedLocales: () => string[];
isLocaleSupported: (locale: string) => boolean;
getLocaleName: (locale: string) => string;
getLocaleDirection: (locale: string) => "ltr" | "rtl";
formatNumber: (number: number, options?: Intl.NumberFormatOptions) => string;
formatCurrency: (amount: number, currency?: string, options?: Intl.NumberFormatOptions) => string;
formatDate: (date: Date | string, options?: Intl.DateTimeFormatOptions) => string;
addLocaleListener: (fn: (locale: string) => void) => () => void;
removeLocaleListener: (fn: (locale: string) => void) => void;
clearLocaleListeners: () => void;
initI18n: () => void;
saveLocale: (locale: string) => void;
loadLocale: () => string;
getDefaultLocale: () => string;
createInstanceI18n: (editor: any) => InstanceI18n;
};
animations: {
register(name: string, config: Partial<AnimationConfig> & {
enter?: Record<string, string>;
leave?: Record<string, string>;
}): void;
unregister(name: string): void;
get(name: string): AnimationConfig | null;
getAnimationNames(): string[];
getActiveCount(): number;
cancelAll(): void;
reset(): void;
destroy(): void;
};
plugins: {
createManager: () => PluginManager;
manager: PluginManager;
register: (name: string, plugin: Plugin) => PluginManager;
unregister: (name: string) => PluginManager;
get: (name: string) => Plugin | null;
has: (name: string) => boolean;
getAll: () => Plugin[];
getNames: () => string[];
enable: (name: string) => PluginManager;
disable: (name: string) => PluginManager;
isEnabled: (name: string) => boolean;
getPreset: (name: string) => Plugin | null;
getAllPresets: () => Record<string, Plugin>;
createPlugin: (config?: Partial<Plugin>) => Plugin;
validatePlugin: (plugin: any) => {
valid: boolean;
errors: string[];
};
topologicalSort: (plugins: Plugin[]) => Plugin[];
validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
};
presetPlugins: Record<string, Plugin>;
topologicalSort: (plugins: Plugin[]) => Plugin[];
validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
createInstanceI18n: (editor: any) => InstanceI18n;
loadRemote: (url: string, locale: string) => Promise<boolean>;
exportCSSVars: (el?: HTMLElement) => Record<string, string>;
getCSSVariable: (name: string, el?: HTMLElement) => string;
followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
createInstanceTheme: (editor: any) => any;
DEFAULTS: Readonly<EditorOptions>;
ICONS: Record<string, string>;
THEMES: Record<string, string | ThemeConfig>;
EDIT_MODES: EditMode[];
DEFAULT_TOOLBAR: string[];
TOOLBAR_ACTIONS: string[];
};
export { DEFAULTS, DEFAULT_TOOLBAR, EDIT_MODES, MarkdownEditor as Editor, ICONS, MarkdownEditor, api as MeEditor, THEMES, TOOLBAR_ACTIONS, VERSION, adoptFromParent, animationUtils, api, clearRenderCache, create, createInstanceI18n, createInstanceTheme, api as default, destroy, exportCSSVars, followExternalTheme, getCSSVariable, getStatus, getSupportedLanguages, highlight, i18nUtils, loadRemote, api as meEditor, normalizeLanguage, off, on, parseMarkdown, parseTokens, pluginUtils, presetPlugins, registerBlockHandler, registerLanguage, renderTokens, safeUrl, setLocale, setTheme, slugify, themeUtils, topologicalSort, use, validateConfig };
+5450
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+5395
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+32
View File
@@ -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>
+175
View File
@@ -0,0 +1,175 @@
#!/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');
// 12. 浮动工具栏(真实浏览器布局验证:mirror 镜像测量定位)
// 放在依赖旧值/历史栈的用例之后执行(本节自行填充测试内容)
await page.locator('.me-textarea').fill('# 标题\n\n第二行中文文本内容\n\n第三行 more text');
await page.waitForTimeout(50);
await page.locator('.me-textarea').evaluate((el) => {
el.focus();
const start = el.value.indexOf('第二行');
el.setSelectionRange(start, start + 6);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await page.waitForSelector('.me-float-toolbar.me-visible', { timeout: 3000 });
check('浮动工具栏选区后显示', true);
const pos = await page.evaluate(() => {
const bar = document.querySelector('.me-float-toolbar');
const ta = document.querySelector('.me-textarea');
const bb = bar.getBoundingClientRect();
const tb = ta.getBoundingClientRect();
return { barL: bb.left, barR: bb.right, barT: bb.top, barB: bb.bottom, taL: tb.left, taR: tb.right, taT: tb.top, taB: tb.bottom };
});
check('浮动工具栏水平定位在编辑区内', pos.barR > pos.taL && pos.barL < pos.taR);
check('浮动工具栏垂直定位在选区行附近', pos.barT >= pos.taT - 50 && pos.barB <= pos.taB + 50);
// 等待显示过渡动画结束后再点击(避免 actionability 等待期间竞态)
await page.waitForTimeout(300);
await page.locator('.me-float-toolbar .me-btn').first().click();
check('浮动工具栏 bold 命令', (await page.locator('.me-textarea').inputValue()).includes('**第二行中文文**'));
check('命令执行后浮动工具栏隐藏', (await page.locator('.me-float-toolbar.me-visible').count()) === 0);
// 12.5 长行折行选区(软换行场景下工具栏不跑出可视区)
await page.locator('.me-textarea').fill('# T\n\n' + '很长的中文内容用于触发自动折行的测试文本'.repeat(20));
await page.waitForTimeout(50);
await page.locator('.me-textarea').evaluate((el) => {
el.focus();
el.setSelectionRange(el.value.length - 20, el.value.length);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await page.waitForTimeout(100);
check('长行折行选区工具栏保持可视区内', (await page.evaluate(() => {
const bar = document.querySelector('.me-float-toolbar');
const ta = document.querySelector('.me-textarea');
if (!bar || !bar.classList.contains('me-visible')) return false;
const bb = bar.getBoundingClientRect();
const tb = ta.getBoundingClientRect();
return bb.top >= tb.top - 50 && bb.bottom <= tb.bottom + 50;
})));
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);
});
+11
View File
@@ -13,5 +13,16 @@ module.exports = {
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'],
coverageThreshold: {
global: {
statements: 85,
branches: 70,
functions: 85,
lines: 85,
},
'src/parser.ts': {
lines: 95,
},
},
verbose: true,
};
+50 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@metona-team/metona-editor",
"version": "0.2.2",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@metona-team/metona-editor",
"version": "0.2.2",
"version": "0.3.1",
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.22.0",
@@ -24,6 +24,7 @@
"eslint": "^8.40.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"playwright": "^1.62.1",
"prettier": "^2.8.0",
"rollup": "^3.20.0",
"rollup-plugin-dts": "^5.3.0",
@@ -6567,6 +6568,53 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@metona-team/metona-editor",
"version": "0.2.5",
"version": "0.4.3",
"description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.",
"type": "module",
"main": "dist/metona-editor.cjs",
@@ -23,6 +23,7 @@
"README.md",
"LICENSE"
],
"sideEffects": false,
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
@@ -33,6 +34,7 @@
"format": "prettier --write src/",
"typecheck": "tsc --noEmit",
"bench": "node bench/benchmark.cjs",
"test:e2e": "node e2e/smoke.cjs",
"prepublishOnly": "npm run typecheck && npm test && npm run build"
},
"repository": {
@@ -71,6 +73,7 @@
"eslint": "^8.40.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"playwright": "^1.62.1",
"prettier": "^2.8.0",
"rollup": "^3.20.0",
"rollup-plugin-dts": "^5.3.0",
-14
View File
@@ -7,7 +7,6 @@ import serve from 'rollup-plugin-serve';
import livereload from 'rollup-plugin-livereload';
const isDev = process.env.ROLLUP_WATCH;
const isProd = process.env.NODE_ENV === 'production';
const basePlugins = [
resolve(),
@@ -29,19 +28,6 @@ const devPlugins = isDev ? [
}),
] : [];
const terserPlugin = isProd ? [
terser({
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.warn'],
},
format: {
comments: false,
},
}),
] : [];
export default [
// UMD development
{
+29 -20
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
<title>MetonaEditor v0.2.5 — 全功能演示</title>
<title>MetonaEditor v0.4.3 — 全功能演示</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
@@ -45,11 +45,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<body>
<header class="header">
<h1><span class="grad">Metona</span>Editor v0.2.5</h1>
<h1><span class="grad">Metona</span>Editor v0.4.3</h1>
<div class="badges">
<span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span>
<span class="badge">780 tests</span>
<span class="badge">895 tests</span>
<span class="badge">6 个插件</span>
<span class="badge">桌面端优先</span>
</div>
@@ -85,14 +85,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<div class="info-card"><div class="icon">🎨</div><h4>内置语法高亮</h4><p>零依赖 tokenizerjs / ts / python / bash 等 16 个语言标识</p></div>
<div class="info-card"><div class="icon">📂</div><h4>磁盘文件读写</h4><p>File System Access API 打开 / 保存真实 .md 文件</p></div>
<div class="info-card"><div class="icon">🔢</div><h4>行号装订线</h4><p>当前行高亮,与编辑区滚动同步</p></div>
<div class="info-card"><div class="icon">🧘</div><h4>Zen 专注模式</h4><p>工具栏自动隐藏,鼠标移到顶部滑入</p></div>
<div class="info-card"><div class="icon">🧘</div><h4>Zen 专注模式</h4><p>工具栏自动隐藏,宽度可配置(zenMaxWidth,默认 960px</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>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>
<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.3 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
</footer>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
@@ -100,18 +101,19 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<script>
(function(){
var demoMd=[
'# 🚀 MetonaEditor v0.2.5 全功能演示',
'# 🚀 MetonaEditor v0.4.3 全功能演示',
'',
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
'> 全模块 TypeScript 严格模式,17 个源文件,780 个测试全部通过。',
'> 全模块 TypeScript 严格模式,17 个源文件,895 个测试全部通过。',
'',
'## ✨ v0.2.5 新特性',
'## ✨ v0.4.3 新特性',
'',
'- 内置**零依赖语法高亮**js / ts / python / bash 等 16 个语言标识',
'- `exportPDF()` 打印导出 · `imagePaste` 尺寸限制(maxSizeKB',
'- 搜索面板支持**大小写 / 全字匹配**开关(Aa / ab)',
'- `maxLength` 限制文档大小 · `zenMode: true` 启动即专注',
'- 安全加固:属性级注入防护 · 发布产物 .mjs / .cjs 规范',
'- **浮动工具栏定位重做**:mirror 镜像测量像素级定位,中文混排 / 长行软换行 / 跨行选区精确跟随',
'- **滚动跟随**:选区滚出可视区自动隐藏,滚动时节流重定位,分隔条拖拽与窗口缩放后自动隐藏',
'- **键盘可达**:隐藏态移出 Tab 序列,按钮支持 Enter / Space 执行,`role="toolbar"` + aria-label',
'- **程序化编辑统一刷新管线**:命令 / 快捷键 / 插件路径的预览 / 行号 / 字数 / 大纲刷新与 input / change 事件完全对齐原生输入',
'- **修复 13 处问题**:浮动工具栏死开关 · 监听器泄漏 · 销毁后 focus 崩溃 · 右键菜单注入 · copyAsHTML 在 Firefox 崩溃 · Esc 退出全屏等',
'- **测试补强**871 → 895 用例,e2e 冒烟 22 → 28 项断言(含真实浏览器布局验证)',
'',
'## 📐 文本样式',
'',
@@ -119,7 +121,7 @@ var demoMd=[
'- ***粗斜体*** / ___另一种粗斜体___',
'- *斜体* / _另一种斜体_',
'- ~~删除线~~ / ==高亮标记==',
'- 链接内格式: [**粗体链接**](https://example.com)',
'- <u>下划线</u>Ctrl+U)· 链接内格式: [**粗体链接**](https://example.com)',
'- 化学:H~2~O / 数学:x^2^',
'- 代码:`const x = 42;`',
'- Emoji:smile: :rocket: :fire: :rainbow: :coffee:',
@@ -140,7 +142,13 @@ var demoMd=[
'',
'| 版本 | 日期 | 测试 | 主题 |',
'| :--- | :---: | ---: | --- |',
'| v0.2.5 | 2026-08 | 780 | 内置高亮+安全修复 |',
'| v0.4.3 | 2026-08 | 895 | 浮动工具栏重做+统一编辑管线 |',
'| v0.4.2 | 2026-08 | 871 | 审查清偿+大纲API+8项修复 |',
'| v0.4.1 | 2026-08 | 841 | Zen宽度可配置+5项修复 |',
'| 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.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
@@ -198,8 +206,8 @@ var demoMd=[
'```',
'',
'```bash',
'# 安装与启动',
'npm install @metona-team/metona-editor',
'# 安装Gitea 私有源)',
'npm install @metona-team/metona-editor --registry=https://git.metona.cn/api/packages/MetonaTeam/npm/',
'npm run dev',
'```',
'',
@@ -265,9 +273,10 @@ function updateModeBtns(mode){
}
function toggleOutline(btn){
editor.config.outline = !editor.config.outline;
if (editor.config.outline) { editor._buildOutline(); btn.classList.add('on'); }
else { var p = editor.el.querySelector('.me-outline'); if (p) p.remove(); btn.classList.remove('on'); }
// v0.4.2 起使用公开 API setOutline/isOutline(替代操作私有 config/_buildOutline 的写法)
var next = !editor.isOutline();
editor.setOutline(next);
btn.classList.toggle('on', next);
}
// Custom shortcut
+67 -14
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
<title>MetonaEditor v0.2.5 · 文档</title>
<title>MetonaEditor v0.4.3 · 文档</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)}
@@ -65,7 +65,7 @@ footer a{color:var(--accent);text-decoration:none}
<header class="docs-header">
<h1><span class="grad">API 文档</span></h1>
<p class="sub">MetonaEditor v0.2.5 · TypeScript · 完整配置、API 与使用指南</p>
<p class="sub">MetonaEditor v0.4.3 · TypeScript · 完整配置、API 与使用指南</p>
</header>
<div class="container">
@@ -98,6 +98,7 @@ footer a{color:var(--accent);text-decoration:none}
<div class="toc-group">插件</div>
<a href="#plugins-convention">插件约定</a>
<a href="#preset-plugins">预设插件</a>
<a href="#preset-plugins">PluginManager</a>
<div class="toc-group">主题</div>
<a href="#themes">主题系统</a>
<div class="toc-group">国际化</div>
@@ -109,8 +110,11 @@ footer a{color:var(--accent);text-decoration:none}
<!-- 快速入门 -->
<section id="quickstart">
<h2>安装与引入</h2>
<pre><span class="c-com"># npm 安装</span>
<span class="c-kw">npm</span> install @metona-team/metona-editor
<pre><span class="c-com"># npm 安装Gitea 私有源)</span>
<span class="c-kw">npm</span> install @metona-team/metona-editor <span class="c-punc">--</span>registry<span class="c-punc">=</span>https<span class="c-punc">:</span><span class="c-punc">//</span>git<span class="c-punc">.</span>metona<span class="c-punc">.</span>cn<span class="c-punc">/</span>api<span class="c-punc">/</span>packages<span class="c-punc">/</span>MetonaTeam<span class="c-punc">/</span>npm<span class="c-punc">/</span>
<span class="c-com">// 或项目 .npmrc 配置 scope 后直接安装</span>
<span class="c-com">// @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/</span>
<span class="c-com">// TypeScript / ES Module</span>
<span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
@@ -133,6 +137,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>
<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>
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>
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>
@@ -145,6 +150,8 @@ footer a{color:var(--accent);text-decoration:none}
historyLimit<span class="c-punc">:</span> <span class="c-num">100</span><span class="c-punc">,</span> historyDebounce<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
syncScroll<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> autoBrackets<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
zenMode<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> wordWrap<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
<span class="c-com">// Zen 内容区最大宽度(数字为 px / 字符串为 CSS 值 / false 不限宽)</span>
zenMaxWidth<span class="c-punc">:</span> <span class="c-num">960</span><span class="c-punc">,</span>
maxLength<span class="c-punc">:</span> <span class="c-num">0</span><span class="c-punc">,</span> <span class="c-com">// 最大字符数(0=不限)</span>
floatingToolbar<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> <span class="c-com">// 选中文本浮动格式栏</span>
@@ -231,13 +238,16 @@ 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>image</code></td><td>![alt](url)</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>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab / Shift+Tab</td></tr>
<tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Y</td></tr>
<tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z</td></tr>
<tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td></td></tr>
<tr><td><code>fullscreen</code></td><td>全屏</td><td></td></tr>
<tr><td><code>zen</code></td><td>Zen 模式</td><td></td></tr>
<tr><td><code>wordwrap</code></td><td>自动换行</td><td></td></tr>
<tr><td>智能 Enter</td><td>列表 / 引用自动延续,空行回车退出</td><td>Enter</td></tr>
<tr><td>查找 / 替换</td><td>searchReplace 插件面板</td><td>Ctrl+F / Ctrl+H</td></tr>
</table>
</section>
@@ -250,8 +260,11 @@ editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class=
<pre>editor<span class="c-punc">.</span><span class="c-fn">setMode</span><span class="c-punc">(</span><span class="c-str">'split'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">getMode</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleFullscreen</span><span class="c-punc">();</span> <span class="c-fn">isFullscreen</span><span class="c-punc">();</span> <span class="c-fn">exitFullscreen</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleZen</span><span class="c-punc">();</span> <span class="c-fn">isZen</span><span class="c-punc">();</span> <span class="c-com">// 或配置 zenMode: true 启动即进入</span>
editor<span class="c-punc">.</span><span class="c-fn">setZenMaxWidth</span><span class="c-punc">(</span><span class="c-num">1200</span><span class="c-punc">);</span> <span class="c-com">// 运行时调整专注模式宽度(数字 / CSS 字符串 / false</span>
editor<span class="c-punc">.</span><span class="c-fn">getZenMaxWidth</span><span class="c-punc">();</span> <span class="c-com">// => number | string | false</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleWordWrap</span><span class="c-punc">();</span> <span class="c-fn">setWordWrap</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isWordWrap</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleFloatingToolbar</span><span class="c-punc">();</span> <span class="c-fn">isFloatingToolbar</span><span class="c-punc">();</span> <span class="c-com">// 选中文本格式栏</span></pre>
editor<span class="c-punc">.</span><span class="c-fn">setOutline</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isOutline</span><span class="c-punc">();</span> <span class="c-com">// 运行时开关大纲面板(v0.4.2</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleFloatingToolbar</span><span class="c-punc">();</span> <span class="c-fn">isFloatingToolbar</span><span class="c-punc">();</span> <span class="c-com">// 选中文本格式栏(mirror 镜像像素级定位,v0.4.3</span></pre>
</section>
<section id="api-stats"><h3>统计与状态</h3>
@@ -271,7 +284,8 @@ editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span cl
<tr><th>事件</th><th>触发时机</th><th>参数</th></tr>
<tr><td><code>input</code></td><td>textarea 输入</td><td><code>(value, editor)</code></td></tr>
<tr><td><code>change</code></td><td>内容变化</td><td><code>(value, editor)</code></td></tr>
<tr><td><code>beforeChange</code> / <code>afterChange</code></td><td>内容变更前</td><td><code>(oldValue, newValue, editor)</code></td></tr>
<tr><td><code>beforeChange</code></td><td>内容变更前</td><td><code>(oldValue, newValue, editor)</code></td></tr>
<tr><td><code>afterChange</code></td><td>内容变更后</td><td><code>(newValue, editor)</code></td></tr>
<tr><td><code>focus</code> / <code>blur</code></td><td>聚焦/失焦</td><td><code>(editor)</code></td></tr>
<tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(editor)</code></td></tr>
<tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr>
@@ -286,22 +300,26 @@ editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span cl
<tr><td><code>autosave</code></td><td>autoSave 触发</td><td><code>({ key, value })</code></td></tr>
<tr><td><code>linkClick</code></td><td>预览链接点击</td><td><code>({ href, text })</code></td></tr>
<tr><td><code>copy</code></td><td>复制到剪贴板</td><td><code>({ type })</code></td></tr>
<tr><td><code>fileOpened</code> / <code>fileSaved</code></td><td>fileSystem 读写</td><td><code>({ name, handle })</code></td></tr>
<tr><td><code>fileOpened</code></td><td>fileSystem 打开文件</td><td><code>({ name, handle }, editor)</code></td></tr>
<tr><td><code>fileSaved</code></td><td>fileSystem 保存文件</td><td><code>({ handle }, editor)</code></td></tr>
</table>
<h4>全局钩子</h4>
<table>
<tr><th>钩子</th><th>触发时机</th></tr>
<tr><td><code>beforeCreate</code> / <code>afterCreate</code></td><td>实例构造前后</td></tr>
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>全局渲染前后</td></tr>
<tr><td><code>beforeChange</code> / <code>afterChange</code></td><td>内容变更前后(全局)</td></tr>
<tr><td><code>beforeDestroy</code> / <code>afterDestroy</code></td><td>销毁前后</td></tr>
</table>
</section>
<section id="api-plugin"><h3>插件管理</h3>
<section id="api-plugin"><h3>插件管理 / 工具栏管理</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装</span>
editor<span class="c-punc">.</span><span class="c-fn">unuse</span><span class="c-punc">(</span>name<span class="c-punc">);</span> <span class="c-com">// 卸载</span>
editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 已安装列表</span>
editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加按钮</span></pre>
editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加按钮</span>
editor<span class="c-punc">.</span><span class="c-fn">configureToolbar</span><span class="c-punc">(</span><span class="c-punc">[</span><span class="c-str">'bold'</span><span class="c-punc">,</span> <span class="c-str">'italic'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'undo'</span><span class="c-punc">]);</span> <span class="c-com">// 运行时重建工具栏</span>
editor<span class="c-punc">.</span><span class="c-fn">removeToolbarButton</span><span class="c-punc">(</span><span class="c-str">'hr'</span><span class="c-punc">);</span> <span class="c-com">// 移除单个按钮</span></pre>
</section>
<section id="api-shortcuts"><h3>快捷键管理</h3>
@@ -313,6 +331,7 @@ editor<span class="c-punc">.</span><span class="c-fn">getShortcuts</span><span c
<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>
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>
<!-- 静态 API -->
@@ -334,11 +353,18 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span>
<span class="c-com">// 状态查询</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span>
<span class="c-com">// => { version, theme, locale, globalPlugins, presetPlugins }</span>
<span class="c-com">// 销毁全局资源(主题/语言监听、全局插件、全局钩子全面复位,之后 use() 仍可用)</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span>
<span class="c-com">// 解析器独立使用</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens<span class="c-punc">,</span> safeUrl<span class="c-punc">,</span> slugify<span class="c-punc">,</span> clearRenderCache<span class="c-punc">,</span> getRenderCacheSize<span class="c-punc">,</span> registerBlockHandler <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 内置语法高亮</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> highlight<span class="c-punc">,</span> registerLanguage <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span></pre>
<span class="c-kw">import</span> <span class="c-punc">{</span> highlight<span class="c-punc">,</span> normalizeLanguage<span class="c-punc">,</span> registerLanguage<span class="c-punc">,</span> getSupportedLanguages <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span></pre>
</section>
<!-- 语法高亮 -->
@@ -370,6 +396,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>&lt;em&gt;&lt;strong&gt;</td></tr>
<tr><td>斜体</td><td><code>*italic*</code> <code>_italic_</code></td><td>&lt;em&gt;</td></tr>
<tr><td>删除线 / 高亮</td><td><code>~~del~~</code> <code>==mark==</code></td><td>&lt;del&gt; / &lt;mark&gt;</td></tr>
<tr><td>下划线 <span class="badge">v0.3.0</span></td><td><code>&lt;u&gt;text&lt;/u&gt;</code>(裸标签透传)</td><td>&lt;u&gt;</td></tr>
<tr><td>上标 / 下标</td><td><code>x^2^</code> <code>H~2~O</code></td><td>&lt;sup&gt; / &lt;sub&gt;</td></tr>
<tr><td>行内代码</td><td><code>`code`</code></td><td>&lt;code&gt;</td></tr>
<tr><td>代码块</td><td><code>```lang</code></td><td>&lt;pre&gt;&lt;code&gt;</td></tr>
@@ -381,6 +408,7 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><
<tr><td>任务列表</td><td><code>- [x] done</code></td><td>&lt;li class="me-task-item"&gt;</td></tr>
<tr><td>水平线</td><td><code>---</code> <code>***</code> <code>___</code></td><td>&lt;hr&gt;</td></tr>
<tr><td>表格</td><td><code>| a | b |</code></td><td>&lt;table&gt; + 列对齐</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>图片</td><td><code>![alt](url)</code></td><td>&lt;img&gt;</td></tr>
<tr><td>引用链接 <span class="badge">v0.2.2</span></td><td><code>[text][ref]</code> + <code>[ref]: url</code></td><td>&lt;a&gt; 引用式</td></tr>
@@ -391,9 +419,11 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><
<tr><td>定义列表</td><td><code>Term\n: def</code></td><td>&lt;dl&gt;&lt;dt&gt;&lt;dd&gt;</td></tr>
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+</td></tr>
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
<tr><td>HTML 注释</td><td><code>&lt;!-- note --&gt;</code></td><td>透传</td></tr>
<tr><td>实体引用保护</td><td><code>&amp;amp;</code> <code>&amp;#169;</code></td><td>不被二次转义</td></tr>
</table>
<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>&lt;u&gt;</code> 透传,<code>data:image</code> 限制最大 500KB,提供 <code>sanitize</code> 钩子(可接 DOMPurify 等),<code>highlight</code> 钩子异常自动回退纯文本</p>
</section>
<section id="parser-api"><h2>解析器 API</h2>
@@ -439,6 +469,20 @@ editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span
<h4>fileSystem</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'fileSystem'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">openFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFileAs</span><span class="c-punc">();</span></pre>
<h3>PluginManager &amp; pluginUtils</h3>
<pre><span class="c-kw">import</span> <span class="c-punc">{</span> PluginManager<span class="c-punc">,</span> pluginUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 独立插件注册表</span>
<span class="c-kw">const</span> pm <span class="c-punc">=</span> <span class="c-kw">new</span> <span class="c-fn">PluginManager</span><span class="c-punc">();</span>
pm<span class="c-punc">.</span><span class="c-fn">register</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">,</span> myPlugin<span class="c-punc">);</span>
pm<span class="c-punc">.</span><span class="c-fn">has</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">);</span> pm<span class="c-punc">.</span><span class="c-fn">get</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">);</span> pm<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span>
<span class="c-com">// 工具集(作用于默认全局管理器)</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">validatePlugin</span><span class="c-punc">(</span>plugin<span class="c-punc">);</span> <span class="c-com">// => { valid, errors }</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">createPlugin</span><span class="c-punc">({</span> name<span class="c-punc">:</span> <span class="c-str">'x'</span> <span class="c-punc">});</span> <span class="c-com">// 工厂函数,补齐默认字段</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">getPreset</span><span class="c-punc">(</span><span class="c-str">'autoSave'</span><span class="c-punc">);</span> <span class="c-com">// 预设副本(不污染原对象)</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">getAllPresets</span><span class="c-punc">();</span> <span class="c-com">// 全部预设副本</span></pre>
</section>
<!-- 主题 -->
@@ -449,9 +493,12 @@ editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class
<span class="c-com">// 注册自定义</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span> extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span> <span class="c-punc">});</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">switchTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">);</span> <span class="c-com">// 全局切换到自定义主题</span>
<span class="c-com">// 外部跟随</span>
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-kw">body</span><span class="c-punc">);</span> <span class="c-com">// 跟随外部元素(body 是属性,不是函数)</span>
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">dispose</span><span class="c-punc">();</span> <span class="c-com">// 手动断开跟随(destroy 时自动断开)</span>
<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>
@@ -469,9 +516,15 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">addTranslations</span><
<span class="c-com">// 远程加载</span>
<span class="c-kw">await</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">loadRemote</span><span class="c-punc">(</span><span class="c-str">'/locales/ja.json'</span><span class="c-punc">,</span> <span class="c-str">'ja'</span><span class="c-punc">);</span>
<span class="c-com">// 实例级翻译(跟随实例 locale,状态栏/工具栏/插件面板均使用它)</span>
editor<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">);</span> <span class="c-com">// => '粗体'</span>
editor<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'imageTooLarge'</span><span class="c-punc">,</span> <span class="c-punc">{</span> size<span class="c-punc">:</span> <span class="c-num">800</span><span class="c-punc">,</span> max<span class="c-punc">:</span> <span class="c-num">500</span> <span class="c-punc">});</span> <span class="c-com">// 插值</span>
<span class="c-com">// 格式化</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatCurrency</span><span class="c-punc">(</span><span class="c-num">99.99</span><span class="c-punc">,</span> <span class="c-str">'USD'</span><span class="c-punc">);</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span></pre>
<p>RTL 语言(阿拉伯语、希伯来语等)自动应用从右到左布局;分屏分隔条拖拽比例按实例 id 持久化到 localStorage。</p>
</section>
</main>
@@ -480,7 +533,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
<footer>
<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.3 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
</div>
</footer>
+14 -12
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>"/>
<title>MetonaEditor v0.2.5 · TypeScript · 桌面端 Markdown 编辑器</title>
<title>MetonaEditor v0.4.3 · TypeScript · 桌面端 Markdown 编辑器</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-3:#ec4899;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%);--shadow:0 20px 50px -20px rgba(0,0,0,.5)}
@@ -106,10 +106,10 @@ footer a:hover{text-decoration:underline}
<h1>TypeScript 重构 · 零依赖<br/><span class="grad">桌面端 Markdown 编辑器</span></h1>
<p class="tagline">全模块 TypeScript 严格模式,内置自研解析器(99% 行覆盖率),6 个预设插件,中文优先。为桌面端现代 Web 应用而生。</p>
<div class="badges">
<span class="badge accent">v0.2.5</span>
<span class="badge accent">v0.4.3</span>
<span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span>
<span class="badge">780 tests</span>
<span class="badge">895 tests</span>
<span class="badge">桌面端优先</span>
<span class="badge">MIT</span>
</div>
@@ -120,7 +120,7 @@ footer a:hover{text-decoration:underline}
<div class="stats">
<div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div>
<div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div>
<div class="stat"><div class="stat-num">780</div><div class="stat-label">单元测试</div></div>
<div class="stat"><div class="stat-num">895</div><div class="stat-label">单元测试</div></div>
<div class="stat"><div class="stat-num">~42KB</div><div class="stat-label">gzip 体积</div></div>
</div>
</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"><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"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div><h3>性能与工程</h3><p>字数/词数/行数差异增量统计、高亮规则缓存、大纲 O(n) 构建;895 单测 + Playwright 真实浏览器冒烟,CI 并行 5 job。</p></div>
</div>
</div>
</section>
@@ -156,8 +157,8 @@ footer a:hover{text-decoration:underline}
<span class="section-eyebrow">Quick Start</span>
<h2 class="section-title">三行代码即可使用</h2>
<p class="section-sub">npm / CDN / 本地文件 三种方式,TypeScript / ESM / CJS 全面支持。</p>
<div class="code-block"><span class="c-com">// 方式一:npm 安装(推荐</span>
<span class="c-kw">npm</span> install @metona-team/metona-editor
<div class="code-block"><span class="c-com">// 方式一:npm 安装(Gitea 私有源</span>
<span class="c-kw">npm</span> install @metona-team/metona-editor <span class="c-punc">--</span>registry<span class="c-punc">=</span>https<span class="c-punc">:</span><span class="c-punc">//</span>git<span class="c-punc">.</span>metona<span class="c-punc">.</span>cn<span class="c-punc">/</span>api<span class="c-punc">/</span>packages<span class="c-punc">/</span>MetonaTeam<span class="c-punc">/</span>npm<span class="c-punc">/</span>
<span class="c-com">// ES Module / TypeScript</span>
<span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
@@ -165,8 +166,8 @@ footer a:hover{text-decoration:underline}
<span class="c-com">// CommonJS</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">&lt;script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.2.5/dist/metona-editor.min.js"&gt;&lt;/script&gt;</span>
<span class="c-com">// 方式二:Gitea raw 引入</span>
<span class="c-com">&lt;script src="https://git.metona.cn/MetonaTeam/MetonaEditor/raw/branch/master/dist/metona-editor.js"&gt;&lt;/script&gt;</span>
<span class="c-com">// 方式三:本地文件</span>
<span class="c-com">&lt;script src="./dist/metona-editor.min.js"&gt;&lt;/script&gt;</span>
@@ -192,7 +193,7 @@ editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-
<div class="api-card"><h4>内容操作</h4><ul><li>getValue() <span class="ret">string</span></li><li>setValue(md) <span class="ret">this</span></li><li>getHTML() <span class="ret">string</span></li><li>insert(text) <span class="ret">this</span></li><li>wrap(before, after) <span class="ret">this</span></li><li>replaceAll(search, rep) <span class="ret">number</span></li></ul></div>
<div class="api-card"><h4>光标与选区</h4><ul><li>getSelectedText() <span class="ret">string</span></li><li>getCursorPosition() <span class="ret">{line,column}</span></li><li>setCursorPosition(l, c) <span class="ret">this</span></li><li>selectLine(n) / selectAll() <span class="ret">this</span></li><li>toggleFloatingToolbar() <span class="ret">this</span></li></ul></div>
<div class="api-card"><h4>命令执行</h4><ul><li>exec(action) <span class="ret">this</span></li><li>undo() / redo() <span class="ret">this</span></li><li>canUndo() / canRedo() <span class="ret">boolean</span></li></ul></div>
<div class="api-card"><h4>模式与全屏</h4><ul><li>setMode(mode) <span class="ret">this</span></li><li>getMode() <span class="ret">string</span></li><li>toggleFullscreen() <span class="ret">this</span></li><li>toggleZen() <span class="ret">this</span></li></ul></div>
<div class="api-card"><h4>模式与全屏</h4><ul><li>setMode(mode) <span class="ret">this</span></li><li>getMode() <span class="ret">string</span></li><li>toggleFullscreen() <span class="ret">this</span></li><li>toggleZen() <span class="ret">this</span></li><li>setOutline(on) <span class="ret">this</span></li></ul></div>
<div class="api-card"><h4>事件与插件</h4><ul><li>on(event, fn) <span class="ret">unsub</span></li><li>off(event, fn) <span class="ret">this</span></li><li>use(plugin) <span class="ret">this</span></li><li>unuse(name) <span class="ret">this</span></li><li>addToolbarButton(cfg) <span class="ret">this</span></li></ul></div>
<div class="api-card"><h4>主题与语言</h4><ul><li>setTheme(name) <span class="ret">this</span></li><li>getTheme() <span class="ret">string</span></li><li>setLocale(loc) <span class="ret">this</span></li><li>getLocale() <span class="ret">string</span></li><li>t(key, params) <span class="ret">string</span></li></ul></div>
<div class="api-card"><h4>统计与状态</h4><ul><li>getStats() <span class="ret">Stats</span></li><li>getStatus() <span class="ret">Status</span></li><li>enable() / disable() <span class="ret">this</span></li><li>refresh() <span class="ret">this</span></li><li>destroy() <span class="ret">void</span></li></ul></div>
@@ -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/issues" target="_blank" rel="noopener">问题反馈</a>
</div>
<div>MetonaEditor v0.2.5 · TypeScript · MIT License · MetonaTeam</div>
<div>MetonaEditor v0.4.3 · TypeScript · MIT License · MetonaTeam</div>
</div>
</footer>
@@ -239,7 +240,7 @@ MeEditor.themes.switchTheme('dark');
MeEditor.create('#editor', {
mode: 'split', height: 480, theme: 'dark',
value: [
'# 欢迎使用 MetonaEditor v0.2.5',
'# 欢迎使用 MetonaEditor v0.4.3',
'',
'一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。',
'',
@@ -269,7 +270,7 @@ MeEditor.create('#editor', {
'| --- | --- |',
'| **粗体** / *斜体* / ***粗斜体*** | 强调 |',
'| `code` / ``` ```lang ``` | 代码 |',
'| [链接](url) / ![图片](url) | 链接 |',
'| [链接](https://example.com) / ![图片](https://example.com/img.png) | 链接 |',
'| $E=mc^2$ / $$\int$$ | 数学公式 |',
'| - [x] 任务列表 | GFM |',
'| :rocket: :fire: | Emoji 短码 |',
@@ -279,6 +280,7 @@ MeEditor.create('#editor', {
'- [x] TypeScript 重构完成',
'- [x] 解析器覆盖率 99%',
'- [x] 6 个预设插件',
'- [x] Playwright 浏览器冒烟测试',
'- [ ] VS Code 扩展',
'',
'> **提示**:按 `?` 查看快捷键 · 拖放文件到编辑区 · Ctrl+F 搜索 · Ctrl+H 替换',
+10 -10
View File
@@ -1,7 +1,7 @@
/**
* MetonaEditor Commands — editing command implementations
* @module commands
* @version 0.2.5
* @version 0.4.0
*/
import { t as i18nT } from './i18n';
@@ -16,7 +16,7 @@ export function wrapSelection(this: MarkdownEditor, before: string, after: strin
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end); ta.focus();
if (selected) { ta.selectionStart = start + before.length; ta.selectionEnd = start + before.length + text.length; }
else { ta.selectionStart = ta.selectionEnd = start + before.length; }
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
// ============ Line prefix toggling ============
@@ -33,7 +33,7 @@ export function toggleLinePrefix(this: MarkdownEditor, prefix: string): void {
else newLine = prefix + line;
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
// ============ Block insertion ============
@@ -44,31 +44,31 @@ export function insertBlock(this: MarkdownEditor, text: string): void {
const insert = (needNL ? '\n' : '') + text;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd); ta.focus();
const pos = start + insert.length; ta.selectionStart = ta.selectionEnd = pos;
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
export function insertLink(this: MarkdownEditor): void {
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || i18nT('link') || 'link';
const sel = ta.value.slice(start, end) || this.t('link') || 'link';
const url = 'https://';
const insert = `[${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length;
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
export function insertImage(this: MarkdownEditor): void {
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || i18nT('image') || 'image';
const sel = ta.value.slice(start, end) || this.t('image') || 'image';
const url = 'https://';
const insert = `![${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length;
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
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(' | ');
let md = `| ${header} |\n| ${sep} |\n`;
for (let r = 1; r < rows; r++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`;
@@ -107,7 +107,7 @@ export function formatTable(this: MarkdownEditor): void {
return '| ' + padded.join(' | ') + ' |';
});
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
// ============ Prototype installation ============
+2
View File
@@ -41,6 +41,7 @@ export interface EditorOptions {
outline?: boolean;
autoBrackets?: boolean;
zenMode?: boolean;
zenMaxWidth?: number | string | false;
wordWrap?: boolean;
maxLength?: number;
theme?: ThemeName;
@@ -119,6 +120,7 @@ export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
outline: false,
autoBrackets: true,
zenMode: false,
zenMaxWidth: 960,
wordWrap: true,
maxLength: 0,
theme: 'auto',
+16 -9
View File
@@ -1,10 +1,11 @@
/**
* MetonaEditor Context Menu — right-click menu
* @module context-menu
* @version 0.2.5
* @version 0.4.0
*/
import { t as i18nT } from './i18n';
import { escapeHTML } from './utils';
import type { MarkdownEditor } from './core';
export function registerContextMenu(this: MarkdownEditor, items: any[] = []): MarkdownEditor {
@@ -28,13 +29,13 @@ export function showContextMenu(this: MarkdownEditor, e: MouseEvent): void {
const ta = this.textarea;
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
const defaultItems: Array<{ label?: string; action?: string; shortcut?: string; sep?: boolean; disabled?: boolean; onClick?: () => void }> = [
{ label: i18nT('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
{ label: i18nT('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
{ label: this.t('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
{ label: this.t('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
{ sep: true },
{ label: i18nT('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
{ label: i18nT('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
{ label: i18nT('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
{ label: i18nT('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' },
{ label: this.t('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
{ label: this.t('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
{ label: this.t('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
{ label: this.t('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' },
];
const items = [...defaultItems];
if (this._contextMenuItems.length) {
@@ -56,7 +57,8 @@ export function showContextMenu(this: MarkdownEditor, e: MouseEvent): void {
const el = document.createElement('div');
el.className = 'me-context-menu-item';
if (item.disabled) el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`;
// 自定义菜单项的 label / shortcut 经转义后拼接(防 HTML 注入)
el.innerHTML = `<span>${escapeHTML(item.label)}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${escapeHTML(item.shortcut)}</span>` : ''}`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
if (item.disabled) return;
@@ -87,7 +89,12 @@ export function execContextAction(this: MarkdownEditor, action: string): void {
case 'redo': this.redo(); break;
case 'cut': document.execCommand('cut'); break;
case 'copy': document.execCommand('copy'); break;
case 'paste': document.execCommand('paste'); break;
case 'paste': {
// 现代浏览器出于安全限制,execCommand('paste') 通常静默失败 —— 提示改用 Ctrl+V
const ok = document.execCommand('paste');
if (!ok) this.toast(this.t('pasteBlocked') || '无法直接粘贴,请使用 Ctrl+V', { type: 'info' });
break;
}
case 'selectAll': ta.focus(); ta.select(); break;
default: this.exec(action); break;
}
+234 -53
View File
@@ -5,7 +5,7 @@
*/
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 { injectStyles } from './styles';
import { t as i18nT, getCurrentLocale, getLocaleDirection, createInstanceI18n } from './i18n';
@@ -80,6 +80,10 @@ export class MarkdownEditor {
_zenMode!: boolean;
_wordWrap!: 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;
_ariaLive!: HTMLElement;
_renderFn!: (md: string, env: RenderEnv) => string;
@@ -88,6 +92,7 @@ export class MarkdownEditor {
_i18nCtx!: InstanceI18n;
_floatingToolbar!: HTMLElement | null;
_floatingEnabled!: boolean;
_floatMirror!: HTMLElement | null;
// Prototype-installed methods (see installers at bottom of file)
_wrapSelection!: (before: string, after: string) => void;
@@ -102,6 +107,7 @@ export class MarkdownEditor {
isFloatingToolbar!: () => boolean;
_buildFloatingToolbar!: () => void;
_hideFloatingToolbar!: () => void;
_measureSelectionAnchor!: () => { x: number; y: number } | null;
registerContextMenu!: (items: any[]) => this;
_bindContextMenu!: () => void;
_showContextMenu!: (e: MouseEvent) => void;
@@ -131,19 +137,22 @@ export class MarkdownEditor {
this._listeners = {}; this._renderRaf = null; this._historyTimer = null;
this._fullscreen = false; this._destroyed = false; this._lastRenderedValue = null;
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
this._outlineTimer = null; this._zenMode = this.config.zenMode === true; this._wordWrap = true; this._syncing = false;
this._outlineTimer = null; this._zenMode = this.config.zenMode === true; this._wordWrap = this.config.wordWrap !== false; this._syncing = false;
this._zenMouseHandler = null;
this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false;
this._floatMirror = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
MarkdownEditor.trigger('beforeCreate', this);
injectStyles();
this._buildDOM();
this._themeCtx = createInstanceTheme(this);
// 实例 i18n 需在 _buildDOM 之前创建:DOM 构建与工具栏渲染会读取实例语言
this._i18nCtx = createInstanceI18n(this);
this._buildDOM();
this._applyZenWidth();
this._themeCtx = createInstanceTheme(this);
if (options.theme && typeof document !== 'undefined') {
document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme));
@@ -158,12 +167,16 @@ export class MarkdownEditor {
this._bindEvents();
this._bindContextMenu();
this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) this._initFloatingToolbar();
// 无条件绑定事件(show 内部检查 _floatingEnabled):
// floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
this._initFloatingToolbar();
this.textarea.value = this._value;
if (!this._wordWrap) this.textarea.style.whiteSpace = 'pre';
this._pushHistory();
this._render();
this._updateWordCount();
this._buildOutline();
if (this._zenMode) this._setZen(true);
if (Array.isArray(this.config.plugins)) {
@@ -188,7 +201,7 @@ export class MarkdownEditor {
if (this.config.readOnly) wrapper.classList.add('me-readonly');
wrapper.dataset.id = this.id;
wrapper.setAttribute('role', 'application');
wrapper.setAttribute('aria-label', i18nT('edit'));
wrapper.setAttribute('aria-label', this.t('edit'));
if (this.config.className) {
this.config.className.split(/\s+/).filter(Boolean).forEach((c) => wrapper.classList.add(c));
}
@@ -208,13 +221,13 @@ export class MarkdownEditor {
textarea.spellcheck = !!this.config.spellcheck; textarea.readOnly = !!this.config.readOnly;
if (this.config.maxLength && this.config.maxLength > 0) textarea.maxLength = this.config.maxLength;
textarea.style.tabSize = String(this.config.tabSize || 2);
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
textarea.setAttribute('aria-label', i18nT('edit'));
textarea.placeholder = this.config.placeholder || this.t('placeholder');
textarea.setAttribute('aria-label', this.t('edit'));
editorInner.appendChild(textarea);
editorPane.appendChild(editorInner);
const divider = document.createElement('div'); divider.className = 'me-divider'; divider.setAttribute('role', 'separator');
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);
body.appendChild(editorPane); body.appendChild(divider); body.appendChild(previewPane);
wrapper.appendChild(toolbar); wrapper.appendChild(body);
@@ -252,7 +265,7 @@ export class MarkdownEditor {
_createBtn(item: string): HTMLButtonElement {
const btn = document.createElement('button'); btn.type = 'button';
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>`;
return btn;
}
@@ -278,7 +291,7 @@ export class MarkdownEditor {
ta.addEventListener('keydown', onKeydown);
const onKeypress = (e: KeyboardEvent) => this._handleBracketAutoClose(e);
ta.addEventListener('keypress', onKeypress);
const onCursorActivity = () => { this._updateCurrentLine(); };
const onCursorActivity = () => { this._updateCurrentLine(); this._emitCursorEvents(); };
ta.addEventListener('click', onCursorActivity);
ta.addEventListener('keyup', onCursorActivity);
const onScroll = () => { this._syncScroll(); };
@@ -318,6 +331,11 @@ export class MarkdownEditor {
this.toolbarEl.addEventListener('click', onToolbarClick);
const onDividerDown = (e: PointerEvent) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown);
// 全屏模式按 Esc 退出(与 shortcutHelp 面板的 Esc 关闭互不冲突:条件互斥)
const onDocKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this._fullscreen) this.exitFullscreen();
};
document.addEventListener('keydown', onDocKeydown);
this._bindDragDrop();
this._cleanups.push(() => {
ta.removeEventListener('input', onInput); ta.removeEventListener('keydown', onKeydown);
@@ -327,6 +345,7 @@ export class MarkdownEditor {
ta.removeEventListener('focus', onFocus); ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown);
document.removeEventListener('keydown', onDocKeydown);
});
}
@@ -369,7 +388,8 @@ export class MarkdownEditor {
ta.value = ta.value.slice(0, lineStart) + newBlock + ta.value.slice(end);
ta.selectionStart = lineStart; ta.selectionEnd = lineStart + newBlock.length;
}
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
const old = this._value;
this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(old);
}
_bindDividerDrag(e: PointerEvent): void {
@@ -392,6 +412,8 @@ export class MarkdownEditor {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
this._saveDividerPosition();
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
this._hideFloatingToolbar();
};
window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp);
}
@@ -416,13 +438,13 @@ export class MarkdownEditor {
_scheduleRender(): void { if (this._renderRaf) return; this._renderRaf = requestAnimationFrame(() => { this._renderRaf = null; this._render(); }); }
_render(): void {
if (this._mode === 'edit') return;
if (this._mode === 'edit') { this._renderGutter(); return; }
if (this._lastRenderedValue === this._value) return;
this._lastRenderedValue = this._value;
MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this);
let html: string;
try { html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); }
catch (err: any) { console.error('MeEditor render error:', err); html = `<p style="color:#ef4444">${i18nT('renderError')}: ${escapeHTML(err.message)}</p>`; }
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">${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); } }
this.previewEl.innerHTML = html; this._renderGutter();
MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this);
@@ -430,7 +452,8 @@ export class MarkdownEditor {
_renderGutter(): void {
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;
if (current === lines) return;
if (current < lines) {
@@ -454,6 +477,17 @@ export class MarkdownEditor {
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 {
const ta = this.textarea; const start = ta.selectionStart;
const val = ta.value; const lineStart = val.lastIndexOf('\n', start - 1) + 1;
@@ -461,22 +495,22 @@ export class MarkdownEditor {
const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s(.*)/);
if (listMatch) {
const indent = listMatch[1]; const marker = listMatch[2]; const content = listMatch[3];
if (!content.trim()) { e.preventDefault(); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; }
if (!content.trim()) { e.preventDefault(); const oldV = this._value; ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return; }
let nextMarker = marker;
if (/^\d+\.$/.test(marker)) { const num = parseInt(marker, 10); if (!isNaN(num)) nextMarker = (num + 1) + '.'; }
e.preventDefault(); const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length;
this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return;
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return;
}
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
if (quoteMatch) {
const prefix = quoteMatch[1]; const content = quoteMatch[2];
if (!content.trim()) { e.preventDefault(); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; }
if (!content.trim()) { e.preventDefault(); const oldV = this._value; ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return; }
e.preventDefault(); const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length;
this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
}
@@ -485,21 +519,29 @@ export class MarkdownEditor {
const ta = this.textarea; const key = e.key; const close = BRACKET_PAIRS[key];
if (!close) return;
const start = ta.selectionStart; const end = ta.selectionEnd;
if (start !== end) { e.preventDefault(); const selected = ta.value.slice(start, end); const wrap = key + selected + close; ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.selectionStart = start + 1; ta.selectionEnd = start + 1 + selected.length; this._value = ta.value; this._pushHistory(); this._emit('change', this._value); return; }
if (start !== end) { e.preventDefault(); const oldV = this._value; const selected = ta.value.slice(start, end); const wrap = key + selected + close; ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.selectionStart = start + 1; ta.selectionEnd = start + 1 + selected.length; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return; }
const nextChar = ta.value[start] || '';
if (key === close) { if (nextChar === close) { e.preventDefault(); ta.selectionStart = ta.selectionEnd = start + 1; return; } if (/\w/.test(nextChar)) return; }
e.preventDefault(); const pair = key + close; ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = start + 1; this._value = ta.value; this._pushHistory(); this._emit('change', this._value);
e.preventDefault(); const oldV = this._value; const pair = key + close; ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = start + 1; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
_bindDragDrop(): void {
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 onDrop = (e: DragEvent) => {
e.preventDefault();
const files = e.dataTransfer?.files;
if (files && files.length) {
Array.from(files).forEach((file) => {
if (file.type.startsWith('image/')) { const reader = new FileReader(); reader.onload = () => { this.insert(`![${file.name}](${reader.result})\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(`![${file.name}](${reader.result})\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); }
}); return;
}
@@ -511,6 +553,20 @@ export class MarkdownEditor {
_scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
/**
* 程序化编辑路径(命令 / 快捷键 / 插件 / API 直改 textarea)的统一刷新管线。
* 此前 8 条路径的 预览/行号/字数/大纲 刷新与事件发射参差不齐,
* 现统一为:渲染 + 行号 + 字数 + 大纲 + input/change 事件 + 回调 + before/afterChange 钩子。
*/
_afterProgrammaticEdit(oldValue: string): void {
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', oldValue, this._value);
this._render(); this._renderGutter(); this._updateWordCount(); this._updateOutline();
this._emit('input', this._value); this._emit('change', this._value);
if (typeof this.config.onInput === 'function') { try { this.config.onInput(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); } }
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this);
}
_pushHistory(): void {
// Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write
@@ -526,18 +582,38 @@ export class MarkdownEditor {
this._historyIndex = this._history.length - 1;
}
undo(): this { if (this._historyIndex <= 0) return this; this._historyIndex--; this._applyHistory(); return this; }
redo(): this { if (this._historyIndex >= this._history.length - 1) 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._captureCursor(); this._historyIndex++; this._applyHistory(); this._restoreCapturedCursor(); return this; }
canUndo(): boolean { return this._historyIndex > 0; }
canRedo(): boolean { return this._historyIndex < this._history.length - 1; }
_applyHistory(): void {
// 撤销/重做前记录预览区滚动比例,重渲染后按比例恢复,不再跳顶
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
const oldV = this._value;
this._value = this._history[this._historyIndex]; this.textarea.value = this._value;
this._render(); this._renderGutter(); this._updateWordCount();
this._afterProgrammaticEdit(oldV);
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
if (this.previewPane && this.config.syncScroll) this.previewPane.scrollTop = 0;
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } }
if (pv && pvRatio != null) {
const max = pv.scrollHeight - pv.clientHeight;
if (max > 0) pv.scrollTop = pvRatio * max;
}
}
_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 {
@@ -564,7 +640,7 @@ export class MarkdownEditor {
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._buildOutline(); } 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; }
_updateModeButtons(): void {
@@ -579,7 +655,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; }
exitFullscreen(): this { if (this._fullscreen) this.toggleFullscreen(); return this; }
@@ -588,6 +664,7 @@ export class MarkdownEditor {
_setZen(on: boolean): void {
this._zenMode = !!on; if (!this.el) return;
this.el.classList.toggle('me-zen', this._zenMode);
this._applyZenWidth();
if (this._zenMode && this.toolbarEl) {
if (!this._zenMouseHandler) {
this._zenMouseHandler = (e: MouseEvent) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; };
@@ -596,7 +673,7 @@ export class MarkdownEditor {
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';
} 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.statusEl) this.statusEl.style.display = '';
}
@@ -604,10 +681,43 @@ export class MarkdownEditor {
}
isZen(): boolean { return this._zenMode; }
_applyZenWidth(): void {
if (!this.el) return;
const width = this.config.zenMaxWidth;
if (width == null || width === false || width === '') {
this.el.style.removeProperty('--md-zen-max-width');
} else {
this.el.style.setProperty('--md-zen-max-width', typeof width === 'number' ? `${width}px` : String(width));
}
}
setZenMaxWidth(width: number | string | false): this {
this.config.zenMaxWidth = width;
this._applyZenWidth();
return this;
}
getZenMaxWidth(): number | string | false { return this.config.zenMaxWidth ?? 960; }
toggleWordWrap(): this { this._wordWrap = !this._wordWrap; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
setWordWrap(on: boolean): this { this._wordWrap = !!on; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
isWordWrap(): boolean { return this._wordWrap; }
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on: boolean): this {
this.config.outline = !!on;
if (this._destroyed || !this.el) return this;
if (this.config.outline) {
this._buildOutline();
this._trackOutlineScroll();
} else {
const panel = this.el.querySelector('.me-outline');
if (panel) panel.remove();
}
return this;
}
isOutline(): boolean { return !!this.config.outline; }
_bindToolbarKeyboard(): void {
if (!this.toolbarEl) return;
const onKeydown = (e: KeyboardEvent) => {
@@ -639,16 +749,61 @@ export class MarkdownEditor {
_updateWordCount(): void {
if (!this.config.wordCount || !this.statusEl) return;
const text = this._value || '';
const chars = text.length;
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(' · ');
const stats = this.getStats();
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(' · ');
}
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 {
if (!this.textarea) return '';
@@ -680,7 +835,8 @@ export class MarkdownEditor {
if (!this.textarea) return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
const lineHeight = 22; // approximate
const taStyle = getComputedStyle(this.textarea);
const lineHeight = parseFloat(taStyle.lineHeight) || 22;
this.textarea.scrollTop = (clampedLine - 1) * lineHeight;
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
return this;
@@ -707,9 +863,11 @@ export class MarkdownEditor {
const re = new RegExp(escaped, flags);
const matches = this._value.match(re);
if (!matches) return 0;
this._value = this._limitLength(this._value.replace(re, replace));
// 函数式替换:替换文本按字面处理,$ 无特殊语义
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, () => replace));
this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
this._pushHistory(); this._afterProgrammaticEdit(oldV);
return matches.length;
}
@@ -718,9 +876,10 @@ export class MarkdownEditor {
const re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
const matches = this._value.match(re);
if (!matches) return 0;
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
this._pushHistory(); this._afterProgrammaticEdit(oldV);
return matches.length;
}
@@ -752,7 +911,7 @@ export class MarkdownEditor {
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', 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; }
copyAsMarkdown(): this {
@@ -765,9 +924,16 @@ export class MarkdownEditor {
copyAsHTML(): this {
const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) {
const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => {});
// ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
if (typeof ClipboardItem !== 'undefined') {
try {
const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => {});
return this;
} catch (_) { /* fallthrough 降级 */ }
}
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'html' })).catch(() => {});
}
return this;
}
@@ -777,7 +943,7 @@ export class MarkdownEditor {
const inserted = this._limitLength(ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start));
ta.value = inserted; ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return this;
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return this;
}
wrap(before: string, after: string): this { this._wrapSelection(before, after || before); return this; }
focus(): this { if (this.textarea) this.textarea.focus(); return this; }
@@ -796,6 +962,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; } }
if (!p || typeof p !== 'object') { console.warn('MeEditor: invalid plugin'); return this; }
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') {
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); }
@@ -821,7 +988,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');
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]; }
configureToolbar(tools: ToolbarItem[]): this { if (!this.toolbarEl) return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; }
@@ -829,10 +996,21 @@ export class MarkdownEditor {
toast(message: string, opts: { type?: string; duration?: number; animation?: string } = {}): this {
if (!this.el || typeof document === 'undefined') return this;
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
const anim = ANIMATIONS[animation] || ANIMATIONS.fade;
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>`;
this.el.appendChild(toast); requestAnimationFrame(() => { toast.classList.add('me-toast-enter'); });
const remove = () => { toast.classList.remove('me-toast-enter'); toast.classList.add('me-toast-leave'); setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300); };
toast.style.transition = `opacity ${anim.duration}ms ${anim.easing}, transform ${anim.duration}ms ${anim.easing}, filter ${anim.duration}ms ${anim.easing}`;
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;
}
@@ -844,7 +1022,7 @@ export class MarkdownEditor {
getTheme(): string { if (this._themeCtx) return this._themeCtx.get(); return this.config.theme || 'auto'; }
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 {
if (this._destroyed) return;
@@ -854,9 +1032,12 @@ export class MarkdownEditor {
if (this._outlineTimer) clearTimeout(this._outlineTimer);
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = [];
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 = {};
if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
if (this._floatMirror && this._floatMirror.parentNode) this._floatMirror.parentNode.removeChild(this._floatMirror);
this._floatMirror = null;
if (this.el) this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { try { p.destroy(this); } catch (_) {} } }); this._plugins = [];
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
+132 -50
View File
@@ -1,90 +1,164 @@
/**
* MetonaEditor Floating Toolbar selection format toolbar
* @module floating-toolbar
* @version 0.2.5
* @version 0.4.3
*
* v0.4.3 mirror
* textarea
* marker offsetLeft/offsetTop
* CJKtab
* fallback textarea
* rAF divider / resize
*/
import type { MarkdownEditor } from './core';
/** 浮动工具栏与选区锚点之间的间距(px) */
const ANCHOR_GAP = 6;
export function initFloatingToolbar(this: MarkdownEditor): void {
if (typeof document === 'undefined') return;
// Inject floating toolbar CSS once globally
if (!document.getElementById('me-float-style')) {
const style = document.createElement('style');
style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`;
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;visibility:hidden;transform:translateY(4px);transition:opacity .15s,transform .15s,visibility .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}.me-float-mirror{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;z-index:-1;border:0;margin:0}`;
document.head.appendChild(style);
}
if (!this._floatingEnabled) return;
const ta = this.textarea;
let _lastSelStart = ta.selectionStart;
let _lastSelEnd = ta.selectionEnd;
const emitSelectionChange = () => {
const s = ta.selectionStart; const e = ta.selectionEnd;
if (s !== _lastSelStart || e !== _lastSelEnd) {
_lastSelStart = s; _lastSelEnd = e;
this._emit('selectionChange', { start: s, end: e, text: this._value.slice(s, e) });
}
};
const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return;
emitSelectionChange();
const start = ta.selectionStart; const end = ta.selectionEnd;
if (start === end) { this._hideFloatingToolbar(); return; }
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
const anchor = this._measureSelectionAnchor();
if (!anchor) { this._hideFloatingToolbar(); return; }
// 选区锚点行已滚出 textarea 可视区时隐藏(跟随滚动的重定位会再次显示)
if (anchor.y < -parseFloat(getComputedStyle(ta).lineHeight || '22') || anchor.y > ta.clientHeight) {
this._hideFloatingToolbar();
return;
}
const bar = this._floatingToolbar!;
// 实测尺寸定位(替代旧版 -80 / 36 / 200 等魔法数字)
const barRect = bar.getBoundingClientRect();
const barW = barRect.width || 170;
const barH = barRect.height || 38;
const taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
const fontSize = parseFloat(taStyle.fontSize) || 13.5;
const charWidth = fontSize * 0.6;
const paneW = this.editorPane.getBoundingClientRect().width;
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1;
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1));
let top = anchor.y - barH - ANCHOR_GAP;
if (top < ANCHOR_GAP) top = anchor.y + lineHeight + ANCHOR_GAP;
let left = anchor.x - barW / 2;
left = Math.max(4, Math.min(left, paneW - barW - 4));
if (left < 4) left = 4; // 极窄面板兜底
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop;
const lineLocalTop = lineViewportTop - paneRect.top;
const toolbarHeight = 36;
const gap = 4;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap) top = lineLocalTop + lineHeight + gap;
const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft;
let left = colX - paneRect.left - 80;
left = Math.max(4, Math.min(left, paneRect.width - 200));
this._floatingToolbar!.style.top = top + 'px';
this._floatingToolbar!.style.left = left + 'px';
this._floatingToolbar!.classList.add('me-visible');
bar.style.top = top + 'px';
bar.style.left = left + 'px';
bar.classList.add('me-visible');
};
const hide = () => { this._hideFloatingToolbar(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0));
ta.addEventListener('keyup', () => {
emitSelectionChange();
// 滚动跟随:rAF 节流重定位(保持纯事件驱动,不做轮询)
let rafPending = false;
const reposition = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => { rafPending = false; show(); });
};
const onMouseUp = () => setTimeout(show, 0);
const onKeyup = () => {
// selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0);
else setTimeout(hide, 0);
this._emit('cursorMove', this.getCursorPosition());
});
ta.addEventListener('blur', () => setTimeout(hide, 300));
ta.addEventListener('click', () => {
emitSelectionChange();
};
const onBlur = () => setTimeout(() => {
// 焦点快速抖动场景(如自动化 fill 后还原焦点再 focus 回来、
// 宿主脚本焦点切换):延迟到期时焦点已回到 textarea 则不隐藏
if (document.activeElement === ta) return;
hide();
}, 300);
const onClick = () => {
if (ta.selectionStart === ta.selectionEnd) hide();
};
const onScroll = reposition;
// 布局变化后旧坐标失效,直接隐藏(下次选区交互重新显示)
const onResize = () => hide();
ta.addEventListener('mouseup', onMouseUp);
ta.addEventListener('keyup', onKeyup);
ta.addEventListener('blur', onBlur);
ta.addEventListener('click', onClick);
ta.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);
// 事件绑定与启用状态解耦:floatingToolbar:false 构造的实例,
// toggleFloatingToolbar() 打开后仍可正常显示(修复“死开关”)。
this._cleanups.push(() => {
ta.removeEventListener('mouseup', onMouseUp);
ta.removeEventListener('keyup', onKeyup);
ta.removeEventListener('blur', onBlur);
ta.removeEventListener('click', onClick);
ta.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
});
}
/**
* editorPane
* =
*/
export function measureSelectionAnchor(this: MarkdownEditor): { x: number; y: number } | null {
const ta = this.textarea;
if (!ta || !this.editorPane) return null;
const start = ta.selectionStart; const end = ta.selectionEnd;
if (start === end) return null;
const anchor = Math.floor((start + end) / 2);
// 懒创建镜像层并复制 textarea 排版样式(font/行高/padding/换行/tab
let mirror = this._floatMirror;
if (!mirror || !mirror.parentNode) {
mirror = document.createElement('div');
mirror.className = 'me-float-mirror';
this.editorPane.appendChild(mirror);
this._floatMirror = mirror;
}
const cs = getComputedStyle(ta);
const props: Array<[string, string]> = [
['fontFamily', cs.fontFamily], ['fontSize', cs.fontSize], ['fontWeight', cs.fontWeight],
['fontStyle', cs.fontStyle], ['letterSpacing', cs.letterSpacing], ['lineHeight', cs.lineHeight],
['paddingTop', cs.paddingTop], ['paddingRight', cs.paddingRight],
['paddingBottom', cs.paddingBottom], ['paddingLeft', cs.paddingLeft],
['whiteSpace', cs.whiteSpace], ['overflowWrap', cs.overflowWrap],
['wordBreak', cs.wordBreak], ['tabSize', cs.tabSize], ['boxSizing', 'border-box'],
];
props.forEach(([k, v]) => { (mirror.style as any)[k] = v; });
// 宽度对齐 textarea 文本排版区(clientWidth 含 padding 不含滚动条)
mirror.style.width = ta.clientWidth + 'px';
// 仅需锚点前缀文本:其后内容不影响锚点位置(折行由前缀决定)
mirror.textContent = '';
mirror.appendChild(document.createTextNode(ta.value.slice(0, anchor)));
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
// marker 坐标相对 mirrorposition:absolute 的 offsetParentborder 0),
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
const x = marker.offsetLeft - ta.scrollLeft;
const y = marker.offsetTop - ta.scrollTop;
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
}
export function toggleFloatingToolbar(this: MarkdownEditor): MarkdownEditor {
this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) {
@@ -101,14 +175,21 @@ export function isFloatingToolbar(this: MarkdownEditor): boolean { return this._
export function buildFloatingToolbar(this: MarkdownEditor): void {
const bar = document.createElement('div');
bar.className = 'me-float-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', this.t('formatToolbar') || '格式化选区');
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => {
const btn = this._createBtn(action);
// mousedown 阻止夺焦(textarea 选区与焦点保持);命令执行统一走 click,
// 键盘 Enter/Space 触发的 click 同样生效(可访问性)
btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.stopPropagation();
});
btn.addEventListener('click', () => {
this.exec(action);
// Keep selection after exec
setTimeout(() => this.textarea.focus(), 0);
this._hideFloatingToolbar();
// Keep selection after exec(延迟回调时实例可能已销毁)
setTimeout(() => { if (!this._destroyed && this.textarea) this.textarea.focus(); }, 0);
});
bar.appendChild(btn);
});
@@ -130,4 +211,5 @@ export const installFloatingToolbar = (proto: any): void => {
proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar;
proto._measureSelectionAnchor = measureSelectionAnchor;
};
+19 -2
View File
@@ -1,7 +1,7 @@
/**
* MetonaEditor Highlight built-in lightweight syntax highlighter
* @module highlight
* @version 0.2.5
* @version 0.4.0
*
* Zero-dependency tokenizer for common languages. Safe by construction:
* 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;
};
// ============ 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 ============
const LANGUAGES: Record<string, LanguageDef> = {
@@ -207,12 +223,13 @@ export const highlight = (code: string, lang: string): string => {
const def = LANGUAGES[canonical];
const escaped = escapeText(code);
if (!def) return escaped;
return tokenize(escaped, buildRules(def));
return tokenize(escaped, getRules(canonical));
};
/** Register or override a language definition */
export const registerLanguage = (name: string, def: LanguageDef): void => {
LANGUAGES[name.toLowerCase()] = def;
ruleCache.delete(name.toLowerCase());
};
export const getSupportedLanguages = (): string[] => Object.keys(LANGUAGES);
+6
View File
@@ -241,6 +241,12 @@ export const createInstanceI18n = (editor: any): InstanceI18n => {
}
});
}
// 语言切换即时刷新已渲染的 UI(状态栏统计标签、大纲标题),无需等待下一次输入
if (typeof editor._updateWordCount === 'function') { try { editor._updateWordCount(); } catch (_) {} }
if (editor.el && typeof editor.el.querySelector === 'function') {
const outlineTitle = editor.el.querySelector('.me-outline-title');
if (outlineTitle) outlineTitle.textContent = instanceT('outline') || 'Outline';
}
return instanceLocale;
};
+12 -4
View File
@@ -1,7 +1,7 @@
/**
* MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor
* @version 0.2.5
* @version 0.4.3
*/
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 type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants';
const VERSION = '0.2.5';
const VERSION = '0.4.3';
const globalPlugins: any[] = [];
MarkdownEditor.on('beforeCreate', (editor) => {
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。
const injectGlobalPlugins = (editor: MarkdownEditor): void => {
if (!editor || editor.isDestroyed()) return;
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 {
return new MarkdownEditor(container, options);
@@ -46,6 +50,10 @@ function destroy(): void {
if (i18nUtils) { try { i18nUtils.clearLocaleListeners(); } catch (_) {} }
if (animationUtils) { try { animationUtils.cancelAll(); } catch (_) {} }
globalPlugins.length = 0;
// 清理全局静态钩子(beforeCreate/afterCreate/beforeChange 等),整体复位;
// 随后重建内部 afterCreate 插件注入钩子,保证 destroy 后 MeEditor.use() 仍可用。
try { MarkdownEditor._hooks.clear(); } catch (_) {}
MarkdownEditor.on('afterCreate', injectGlobalPlugins);
}
function getStatus() {
+7 -1
View File
@@ -31,6 +31,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB',
formatToolbar: '格式化选区', pasteBlocked: '无法直接粘贴,请使用 Ctrl+V',
},
'en-US': {
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
@@ -58,6 +59,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: 'Outline',
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)',
formatToolbar: 'Format selection', pasteBlocked: 'Cannot paste directly, use Ctrl+V',
},
ja: {
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
@@ -85,6 +87,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
},
ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -111,7 +114,8 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: '렌더링 실패',
outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다{size}KB > {max}KB',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
formatToolbar: '선택 영역 서식', pasteBlocked: '직접 붙여넣을 수 없습니다. Ctrl+V를 사용하세요',
},
fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -139,6 +143,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: 'Plan',
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)',
formatToolbar: 'Formater la sélection', pasteBlocked: 'Impossible de coller directement, utilisez Ctrl+V',
},
de: {
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
@@ -166,5 +171,6 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: 'Gliederung',
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)',
formatToolbar: 'Auswahl formatieren', pasteBlocked: 'Direktes Einfügen nicht möglich, verwenden Sie Strg+V',
},
};
+68 -16
View File
@@ -1,43 +1,92 @@
/**
* MetonaEditor Outline heading navigation panel
* @module outline
* @version 0.2.5
* @version 0.4.0
*/
import { t as i18nT } from './i18n';
import { escapeHTML } from './utils';
import type { MarkdownEditor } from './core';
/** querySelector 用的 id 转义:优先原生 CSS.escape,环境缺失时退化为简易转义 */
const cssEscape = (id: string): string => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(id);
return id.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
export function buildOutline(this: MarkdownEditor): void {
if (!this.config.outline || !this.previewEl) return;
const old = this.el.querySelector('.me-outline'); if (old) old.remove();
const headings: Array<{ level: number; id: string; text: string }> = [];
const headings: Array<{ level: number; id: string; text: string; srcLine: number }> = [];
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m: RegExpExecArray | null;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') });
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, ''), srcLine: -1 });
}
if (!headings.length) return;
const panel = document.createElement('div'); panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${i18nT('outline') || '大纲'}</div>`;
const buildTree = (items: typeof headings, minLevel: number): string => {
let h = '<ul>'; let i = 0;
while (i < items.length) {
const item = items[i]; if (item.level < minLevel) break;
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
const sub: typeof headings = []; let j = i + 1; while (j < items.length && items[j].level > item.level) { sub.push(items[j]); j++; }
if (sub.length) h += buildTree(sub, item.level + 1);
h += '</li>'; i = j > i + 1 ? j : i + 1;
// 计算每个渲染标题在源码中的行号(跳过围栏代码块内部),
// 点击时按行号精确定位光标,重复标题文本也不会串位。
const srcLines: number[] = [];
const lines = this._value.split('\n');
let inFence = false; let fenceChar = '';
const isSetextText = (l: string): boolean =>
/\S/.test(l) && !/^\s{0,3}([-*+]|\d+\.)\s/.test(l) && !/^\s{0,3}>/.test(l) && !/^\s{0,3}(#{1,6}\s|`{3,}|~{3,})/.test(l);
for (let li = 0; li < lines.length; li++) {
const l = lines[li];
const fence = l.match(/^\s{0,3}(`{3,}|~{3,})/);
if (fence) {
if (!inFence) { inFence = true; fenceChar = fence[1][0]; }
else if (fence[1][0] === fenceChar) inFence = false;
continue;
}
return h + '</ul>';
if (inFence) continue;
if (/^ {0,3}(?:>\s*)*#{1,6}\s+\S/.test(l)) { srcLines.push(li); continue; }
const next = lines[li + 1] || '';
if (li + 1 < lines.length && /^ {0,3}(={3,}|-{3,})\s*$/.test(next) && isSetextText(l)) srcLines.push(li);
}
headings.forEach((h, i) => { h.srcLine = i < srcLines.length ? srcLines[i] : -1; });
const panel = document.createElement('div'); panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`;
// 迭代式树构建(栈模拟嵌套 ul),O(n) 而非递归的 O(n²)
const buildTree = (items: typeof headings, minLevel: number): string => {
let h = '';
const stack: number[] = [];
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.srcLine >= 0 ? item.srcLine + 1 : ''}">${escapeHTML(item.text)}</a>`;
stack.push(item.level);
}
closeTo(-1);
return h;
};
panel.innerHTML += buildTree(headings, 1);
this.el.appendChild(panel);
panel.addEventListener('click', (e) => {
const a = (e.target as HTMLElement).closest('a'); if (!a) return; e.preventDefault();
const id = a.getAttribute('href')!.slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(id));
if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); const idx = this._value.indexOf(target.textContent || ''); if (idx !== -1) { this.textarea.focus(); this.textarea.setSelectionRange(idx, idx); } }
const target = this.previewEl.querySelector('#' + cssEscape(id));
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
// 光标定位:优先用构建时记录的源码行号(重复标题也正确),兜底 indexOf
const ord = Array.prototype.indexOf.call(panel.querySelectorAll('a'), a);
const lineIdx = ord > -1 && ord < headings.length ? headings[ord].srcLine : -1;
if (lineIdx >= 0) {
const lineText = lines[lineIdx] || '';
const col = (lineText.match(/^ {0,3}(?:>\s*)*#{1,6}\s+/) || lineText.match(/^ {0,3}/) || [''])[0].length;
this.setCursorPosition(lineIdx + 1, col);
} else {
const idx = this._value.indexOf(target?.textContent || '');
if (idx !== -1) { this.textarea.focus(); this.textarea.setSelectionRange(idx, idx); }
}
});
}
@@ -49,6 +98,9 @@ export function updateOutline(this: MarkdownEditor): void {
export function trackOutlineScroll(this: MarkdownEditor): void {
if (!this.config.outline || !this.previewPane) return;
// 防重复绑定:构造时绑定过、setOutline(true) 再次调用时跳过
if ((this as any)._outlineScrollBound) return;
(this as any)._outlineScrollBound = true;
let ticking = false;
const onScroll = () => {
if (ticking) return;
+35 -6
View File
@@ -99,17 +99,34 @@ const EMOJI_MAP: Record<string, string> = {
const renderCache = new Map<string, string>();
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 => {
if (!text) return '';
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;
const result = renderInline(text, env);
if (renderCache.size >= MAX_CACHE_SIZE) {
const firstKey = renderCache.keys().next().value!;
renderCache.delete(firstKey);
}
renderCache.set(text, result);
renderCache.set(key, result);
return result;
}
return renderInline(text, env);
@@ -117,6 +134,9 @@ const cachedRenderInline = (text: string, env: RenderEnv): string => {
const clearRenderCache = (): void => { renderCache.clear(); };
/** 当前行内渲染缓存条目数(FIFO 上限 MAX_CACHE_SIZE),供观测/测试缓存淘汰 */
const getRenderCacheSize = (): number => renderCache.size;
// ============ Security utils ============
const safeUrl = (url: string | null | undefined): string => {
@@ -401,7 +421,8 @@ const renderTokens = (tokens: Token[], env: RenderEnv = {}, footnotes: Record<st
if (fnIds.length) {
html += '\n<div class="me-footnotes"><hr/><ol>';
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>';
}
@@ -410,7 +431,9 @@ const renderTokens = (tokens: Token[], env: RenderEnv = {}, footnotes: Record<st
const parseMarkdown = (md: string | null | undefined, env: RenderEnv = {}): string => {
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 ============
@@ -615,6 +638,11 @@ const renderInline = (text: string, env: RenderEnv): string => {
s = s.replace(/<!--[\s\S]*?-->/g, (m) => {
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 = s.replace(/\u0005(\d+)\u0005/g, (_m, idx) => protectedItems[+idx] || _m);
@@ -752,7 +780,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 (/^\[\^/.test(match)) {
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) {
const escaped = match[1];
@@ -770,6 +799,6 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
export {
parseMarkdown, parseTokens, renderTokens,
safeUrl, slugify, clearRenderCache, registerBlockHandler,
safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler,
};
export default parseMarkdown;
+56 -28
View File
@@ -17,12 +17,15 @@ export interface EditorLike {
el: HTMLElement;
textarea: HTMLTextAreaElement;
config?: Record<string, any>;
t?: (key: string, params?: Record<string, any>) => string;
on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void;
off?: (name: string, fn: (...args: any[]) => void) => unknown;
_emit?: (name: string, ...args: any[]) => void;
_pushHistory?: () => void;
_render?: () => void;
_updateWordCount?: () => void;
/** 程序化编辑统一刷新管线(渲染+行号+字数+大纲+事件+钩子) */
_afterProgrammaticEdit?: (oldValue: string) => void;
insert?: (text: string, opts?: { replace?: boolean }) => unknown;
setValue?: (value: string, opts?: { silent?: boolean }) => unknown;
getValue?: () => string;
@@ -145,6 +148,11 @@ export const validateConfig = (schema: PluginSchema = {}, config: Record<string,
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** editor.t locale退 t
* */
const instanceT = (editor: EditorLike): ((key: string, params?: Record<string, any>) => string) =>
(key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
const autoSavePlugin: Plugin = {
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
install(editor, options?: Record<string, any>) {
@@ -261,13 +269,16 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
return editor;
};
},
destroy() {},
destroy(editor: EditorLike) {
if (editor) { delete (editor as any).exportMarkdown; delete (editor as any).exportHTML; delete (editor as any).exportPDF; }
},
};
const searchReplacePlugin: Plugin = {
name: 'searchReplace', version: '0.2.1', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return;
const tt = instanceT(editor);
// Inject style once globally
if (!document.getElementById('me-search-style')) {
const style = document.createElement('style'); style.id = 'me-search-style';
@@ -323,7 +334,7 @@ const searchReplacePlugin: Plugin = {
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
const panel = document.createElement('div'); panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${t('regex')||'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${tt('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${tt('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${tt('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${tt('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${tt('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${tt('regex')||'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${tt('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${tt('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${tt('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${tt('replaceAll')||'All'}</button></div>`;
editor.el.appendChild(panel); state._panel = panel;
_updateReplaceVisible();
state._regexMode = false; state._matchCase = true; state._wholeWord = false;
@@ -411,9 +422,16 @@ const searchReplacePlugin: Plugin = {
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
ta.setSelectionRange(s, s + r.length);
}
editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render();
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
const oldV = editor._value || '';
editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
} else {
if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render();
if (typeof editor._updateWordCount === 'function') editor._updateWordCount();
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
}
findNext();
};
const replaceAll = () => {
@@ -421,11 +439,17 @@ const searchReplacePlugin: Plugin = {
const ta = editor.textarea;
const re = buildPattern(q);
if (!re) return;
const oldV = editor._value || '';
ta.value = ta.value.replace(re, () => r);
ta.setSelectionRange(0,0); editor._value = ta.value;
if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render();
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
} else {
if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render();
if (typeof editor._updateWordCount === 'function') editor._updateWordCount();
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
}
findAll();
};
fi.addEventListener('input', () => { lastIdxs = findAll(); });
@@ -473,6 +497,7 @@ const imagePastePlugin: Plugin = {
name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60,
install(editor, options: any = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined') return;
const tt = instanceT(editor);
const maxSizeKB: number = options.maxSizeKB || 500;
const _onPaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items; if (!items) return;
@@ -483,7 +508,7 @@ const imagePastePlugin: Plugin = {
const sizeKB = file.size / 1024;
if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault();
if (typeof editor.toast === 'function') editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
if (typeof editor.toast === 'function') editor.toast(tt('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break;
}
e.preventDefault();
@@ -506,48 +531,51 @@ const shortcutHelpPlugin: Plugin = {
name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return;
const tt = instanceT(editor);
if (!document.getElementById('me-shortcut-style')) {
const s = document.createElement('style'); s.id = 'me-shortcut-style';
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
document.head.appendChild(s);
}
let _panel: HTMLElement | null = null;
// 面板引用放入 holder:install 时无法预知未来打开的面板,
// 卸载时经 holder 取实时引用,避免面板残留 DOM。
const st = { panel: null as HTMLElement | null };
const _close = () => { if (_panel) { _panel.remove(); _panel = null; } };
const _close = () => { if (st.panel) { st.panel.remove(); st.panel = null; } };
const _open = () => {
if (_panel) { _close(); return; }
// i18n-aware shortcut labels
if (st.panel) { _close(); return; }
// i18n-aware shortcut labels(跟随实例语言)
const builtin: [string, string][] = [
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'],
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'],
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'],
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'],
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'],
['?', t('shortcuts') || 'Shortcuts'],
['Ctrl+B', tt('bold') || 'Bold'], ['Ctrl+I', tt('italic') || 'Italic'],
['Ctrl+U', tt('underline') || 'Underline'], ['Ctrl+K', tt('link') || 'Link'],
['Ctrl+E', tt('code') || 'Code'], ['Ctrl+1/2/3', tt('h1') || 'Heading'],
['Ctrl+Q', tt('quote') || 'Quote'], ['Ctrl+Z', tt('undo') || 'Undo'],
['Ctrl+Y', tt('redo') || 'Redo'], ['Ctrl+S', tt('save') || 'Save'],
['Ctrl+F', tt('search') || 'Search'], ['Ctrl+H', tt('replace') || 'Replace'],
['Tab', tt('indent') || 'Indent'], ['Shift+Tab', tt('outdent') || 'Outdent'],
['?', tt('shortcuts') || 'Shortcuts'],
];
let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${tt('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); });
document.body.appendChild(overlay); _panel = overlay;
document.body.appendChild(overlay); st.panel = overlay;
};
const _onKeydown = (e: KeyboardEvent) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); _open(); }
if (e.key === 'Escape' && _panel) _close();
if (e.key === 'Escape' && st.panel) _close();
};
editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown });
setPluginState(editor, K_SHORTCUT, { state: st, _open, _close, _onKeydown });
},
destroy(editor: EditorLike) {
const state = pluginState<{ _panel: HTMLElement | null; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT);
if (state) { if (state._panel) { state._panel.remove(); } }
const _onKeydown = state?._onKeydown;
const exposed = pluginState<{ state: { panel: HTMLElement | null }; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT);
if (exposed) { if (exposed.state && exposed.state.panel) { exposed.state.panel.remove(); exposed.state.panel = null; } }
const _onKeydown = exposed?._onKeydown;
if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT);
},
+1 -1
View File
@@ -139,7 +139,7 @@ const generateCSS = (): string => {
.me-outline-l2 a{padding-left:12px}
.me-outline-l3 a{padding-left:20px;font-size:12px}
.me-outline-l4 a{padding-left:28px;font-size:12px}
.me-wrapper.me-zen .me-body{max-width:820px;margin:0 auto}
.me-wrapper.me-zen .me-body{max-width:var(--md-zen-max-width,960px);margin:0 auto}
.me-wrapper.me-zen .me-textarea{font-size:15px;line-height:1.8}
.me-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media print{.me-wrapper{border:0!important;box-shadow:none!important}
+16 -4
View File
@@ -239,6 +239,8 @@ export const hasTheme = (name: string): boolean => name in THEMES;
export const createInstanceTheme = (editor: any): any => {
let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto';
// 外部跟随订阅列表,随实例销毁断开,避免 observer/定时器泄漏
const unsubs: Array<() => void> = [];
const apply = (theme: string): string => {
instanceTheme = theme;
const resolved = resolveTheme(theme);
@@ -259,11 +261,21 @@ export const createInstanceTheme = (editor: any): any => {
const getResolved = () => resolveTheme(instanceTheme);
const getConfig = () => getThemeConfig(instanceTheme);
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 adopt = () => editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => {};
const watchExternal = (source: any) => watch(source, (theme) => apply(theme));
const syncWithElement = (element: HTMLElement, opts: any = {}) => {
const unsub = followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected));
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);
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 = () => ({
+1107 -3
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -91,6 +91,14 @@ describe('highlight - 语言注册', () => {
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 返回语言列表', () => {
const langs = getSupportedLanguages();
expect(Array.isArray(langs)).toBe(true);
+47 -7
View File
@@ -29,7 +29,7 @@ describe('index.ts - 全局 API', () => {
test('VERSION 是字符串', () => {
expect(typeof VERSION).toBe('string');
expect(VERSION).toBe('0.2.5');
expect(VERSION).toBe('0.4.3');
});
test('api.default 是 api 本身', () => {
@@ -81,6 +81,24 @@ describe('index.ts - 全局 API', () => {
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 全局钩子', () => {
const fn = jest.fn();
const unsub = on('afterCreate', fn);
@@ -111,11 +129,30 @@ describe('index.ts - 全局 API', () => {
});
test('destroy 清理全局资源', () => {
expect(() => destroy()).not.toThrow();
destroy();
const status = getStatus();
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 被设置', () => {
expect((window as any).MeEditor).toBeDefined();
expect((window as any).MeEditor.VERSION).toBe(VERSION);
@@ -156,14 +193,17 @@ describe('index.ts - 解析器导出', () => {
});
test('registerBlockHandler 注册自定义块', () => {
let matched = false;
registerBlockHandler({
name: 'testBlock',
name: 'testBlockIndex',
priority: 50,
test: () => null,
parse: (_l, i) => ({ token: null, newIndex: i }),
test: (line) => { if (line === '!!!custom!!!') { matched = true; return true; } return null; },
parse: (_l, i) => ({ token: { type: 'testBlockIndex', content: 'hit' }, newIndex: i + 1 }),
});
// 不抛错即通过
expect(true).toBe(true);
const html = parseMarkdown('!!!custom!!!\nafter');
// 自定义 handler 被真实命中并产出 token(回退渲染为 me-block-* div
expect(matched).toBe(true);
expect(html).toContain('me-block-testBlockIndex');
});
});
+96 -3
View File
@@ -3,7 +3,7 @@
* Markdown
*/
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from '../src/parser';
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler } from '../src/parser';
describe('parseMarkdown - 基础', () => {
test('空输入返回空字符串', () => {
@@ -909,12 +909,18 @@ describe('parseMarkdown - v0.1.15 registerBlockHandler', () => {
describe('parseMarkdown - 覆盖率:渲染缓存淘汰', () => {
test('缓存超过上限触发 FIFO 淘汰', () => {
// MAX_CACHE_SIZE = 300,填充 301 个极短文本触发淘汰(避免 CI 超时)
clearRenderCache();
const short = 'x';
for (let i = 0; i < 301; i++) {
parseMarkdown(short + i);
}
// 不应抛错,且至少有一次淘汰发生
expect(true).toBe(true);
// 缓存条目数被 FIFO 上限约束在 300,证明淘汰真实发生
expect(getRenderCacheSize()).toBe(300);
// 超限后再写入仍维持上限
parseMarkdown('overflow-entry');
expect(getRenderCacheSize()).toBe(300);
clearRenderCache();
expect(getRenderCacheSize()).toBe(0);
});
});
@@ -1329,6 +1335,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('&quot; onclick');
});
test('脚注区 id 中的引号被转义', () => {
const html = parseMarkdown('x[^a" onload="x]\n\n[^a" onload="x]: note');
expect(html).not.toContain(' onload="');
expect(html).toContain('&quot; 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('&lt;u&gt;');
});
test('带属性的 <u onclick> 仍被转义', () => {
const html = parseMarkdown('<u onclick="alert(1)">x</u>');
expect(html).not.toContain('<u onclick');
expect(html).toContain('&lt;u');
});
test('行内代码中的 <u> 不被透传', () => {
const html = parseMarkdown('`<u>x</u>`');
expect(html).toContain('<code>&lt;u&gt;x&lt;/u&gt;</code>');
expect(html).not.toContain('<u>');
});
test('代码块中的 <u> 不被透传', () => {
const html = parseMarkdown('```\n<u>x</u>\n```');
expect(html).toContain('&lt;u&gt;');
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 属性无双重转义', () => {
test('链接 URL 中的 & 不双重转义', () => {
const html = parseMarkdown('[x](https://example.com/?a=1&b=2)');
+138 -4
View File
@@ -570,7 +570,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
// 首个匹配位于 0,且选中内容即搜索词
expect(ed.textarea.selectionStart).toBe(0);
expect(ed.getSelectedText()).toBe('foo');
});
test('查找上一个高亮匹配', () => {
@@ -580,7 +582,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click();
ed.el.querySelector('.me-search-prev').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
// next 选中第一处(0),prev 回绕到最后一处('foo bar foo baz foo' 中 16
expect(ed.textarea.selectionStart).toBe(16);
expect(ed.getSelectedText()).toBe('foo');
});
test('空查询不抛错', () => {
@@ -600,7 +604,8 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
expect(ed.textarea.selectionStart).toBe(0);
expect(ed.getSelectedText()).toBe('foo');
});
test('Shift+Enter 在 findInput 上查找上一个', () => {
@@ -609,7 +614,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
// 初始光标在 0,prev 无更早匹配时回绕到最后一处(16)
expect(ed.textarea.selectionStart).toBe(16);
expect(ed.getSelectedText()).toBe('foo');
});
test('替换行 display 切换(none ↔ flex', () => {
@@ -1239,6 +1246,32 @@ describe('v0.2.5 exportTool exportPDF', () => {
});
});
describe('v0.4.1 exportTool 卸载清理', () => {
test('unuse 后实例上的导出方法被删除', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# hi' });
ed.use('exportTool');
expect(typeof (ed as any).exportMarkdown).toBe('function');
ed.unuse('exportTool');
expect((ed as any).exportMarkdown).toBeUndefined();
expect((ed as any).exportHTML).toBeUndefined();
expect((ed as any).exportPDF).toBeUndefined();
ed.destroy();
});
test('destroy 时实例上的导出方法被删除', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# hi', plugins: ['exportTool'] });
expect(typeof (ed as any).exportMarkdown).toBe('function');
ed.destroy();
expect((ed as any).exportMarkdown).toBeUndefined();
});
});
describe('v0.2.5 imagePaste 尺寸限制', () => {
test('超出 maxSizeKB 时 toast 警告且不插入', () => {
document.body.innerHTML = '';
@@ -1275,3 +1308,104 @@ describe('v0.2.5 imagePaste 尺寸限制', () => {
ed.destroy();
});
});
// ============ v0.4.2 fileSystem 事件补齐 ============
describe('fileSystem 插件 - fileOpened / fileSaved 事件', () => {
afterEach(() => {
delete (window as any).showOpenFilePicker;
delete (window as any).showSaveFilePicker;
});
test('openFile 触发 fileOpened 事件并载入内容', async () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
const onOpened = jest.fn();
ed.on('fileOpened', onOpened);
const fakeFile = { name: 'doc.md', text: async () => '# opened content' };
const handle = { getFile: async () => fakeFile };
(window as any).showOpenFilePicker = async () => [handle];
ed.use('fileSystem');
const result = await (ed as any).openFile();
expect(result).toEqual({ name: 'doc.md', content: '# opened content', handle });
expect(ed.getValue()).toBe('# opened content');
expect(onOpened).toHaveBeenCalledWith({ name: 'doc.md', handle }, ed);
ed.destroy();
});
test('saveFile 触发 fileSaved 事件', async () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# save me' });
const onSaved = jest.fn();
ed.on('fileSaved', onSaved);
const writable = { write: jest.fn().mockResolvedValue(undefined), close: jest.fn().mockResolvedValue(undefined) };
const handle = { createWritable: async () => writable };
(window as any).showOpenFilePicker = async () => [];
(window as any).showSaveFilePicker = async () => handle;
ed.use('fileSystem');
const ok = await (ed as any).saveFile();
expect(ok).toBe(true);
expect(writable.write).toHaveBeenCalledWith('# save me');
expect(onSaved).toHaveBeenCalledWith({ handle }, ed);
ed.destroy();
});
});
// ============ v0.4.2 插件面板实例级 i18n ============
describe('v0.4.2 插件面板实例级 i18n', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('searchReplace 面板文案跟随实例 localeen-US', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
ed.use('searchReplace');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
expect(input.placeholder).toBe('Find');
const replaceBtn = ed.el.querySelector('.me-search-replace-all');
expect(replaceBtn.textContent).toBe('Replace All');
ed.el.querySelector('.me-search-close').click();
ed.destroy();
});
test('searchReplace 面板文案跟随实例 localezh-CN', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'zh-CN' });
ed.use('searchReplace');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
expect(input.placeholder).toBe('查找内容');
ed.el.querySelector('.me-search-close').click();
ed.destroy();
});
test('shortcutHelp 面板文案跟随实例 localeen-US', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
ed.use('shortcutHelp');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
const overlay = document.querySelector('.me-shortcut-overlay') as HTMLElement;
expect(overlay).not.toBeNull();
expect(overlay.querySelector('h3')!.textContent).toContain('Shortcuts');
expect(overlay.textContent).toContain('Bold');
ed.destroy();
});
test('unuse shortcutHelp 移除已打开的面板(不残留 DOM)', () => {
const ed = new MarkdownEditor(container, { value: 'foo' });
ed.use('shortcutHelp');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
expect(document.querySelector('.me-shortcut-overlay')).not.toBeNull();
ed.unuse('shortcutHelp');
expect(document.querySelector('.me-shortcut-overlay')).toBeNull();
ed.destroy();
});
});
+51
View File
@@ -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', () => {
test('setTheme / getTheme 实例方法', () => {
document.body.innerHTML = '';