Compare commits
15
Commits
9fe209ba88
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f234676c | ||
|
|
d6a618af5d | ||
|
|
ac6638247f | ||
|
|
5f4915222e | ||
|
|
5966ac7500 | ||
|
|
0cb0d28bb4 | ||
|
|
4d6767482e | ||
|
|
b743af8d90 | ||
|
|
cb0caff4cf | ||
|
|
64aedb9546 | ||
|
|
d22380ac7a | ||
|
|
e5f99aee83 | ||
|
|
5919dd9f66 | ||
|
|
fae31bba60 | ||
|
|
ddc650feeb |
+4
-1
@@ -9,7 +9,8 @@
|
|||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": 2020,
|
"ecmaVersion": 2020,
|
||||||
"sourceType": "module"
|
"sourceType": "module",
|
||||||
|
"project": "./tsconfig.json"
|
||||||
},
|
},
|
||||||
"plugins": ["@typescript-eslint"],
|
"plugins": ["@typescript-eslint"],
|
||||||
"extends": [
|
"extends": [
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
"no-console": "warn",
|
"no-console": "warn",
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||||
|
"@typescript-eslint/consistent-type-imports": ["warn", { "prefer": "type-imports", "fixStyle": "inline-type-imports" }],
|
||||||
|
"@typescript-eslint/no-unnecessary-type-assertion": "warn",
|
||||||
"no-undef": "off",
|
"no-undef": "off",
|
||||||
"no-empty": "off",
|
"no-empty": "off",
|
||||||
"no-useless-escape": "off",
|
"no-useless-escape": "off",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
+76
-16
@@ -7,37 +7,97 @@ on:
|
|||||||
branches: [master]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test-parser:
|
||||||
runs-on: debian-latest
|
runs-on: debian-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [18.x, 20.x, 22.x, 24.x]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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: Run parser tests
|
||||||
|
run: npx jest tests/parser.test.ts tests/highlight.test.ts --forceExit --maxWorkers=1 --testTimeout=15000
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
|
||||||
|
test-core:
|
||||||
|
runs-on: debian-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.x
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Run core tests
|
||||||
|
run: npx jest tests/core.test.ts tests/plugins.test.ts tests/index.test.ts --forceExit --maxWorkers=1 --testTimeout=15000
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
|
||||||
|
test-rest:
|
||||||
|
runs-on: debian-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.x
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Run remaining tests
|
||||||
|
run: npx jest tests/themes.test.ts tests/i18n.test.ts tests/utils.test.ts tests/animations.test.ts tests/styles.test.ts --forceExit --maxWorkers=1 --testTimeout=15000
|
||||||
|
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
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [18.x, 20.x, 24.x]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: npx jest --forceExit --maxWorkers=1 --testTimeout=15000
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
|
||||||
coverage/
|
coverage/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.zcode/
|
||||||
|
|
||||||
# 本地 npm 发布凭据(含 _auth,勿提交)
|
# 本地 npm 发布凭据(含 _auth,勿提交)
|
||||||
.npmrc
|
.npmrc
|
||||||
|
|||||||
+154
@@ -2,6 +2,160 @@
|
|||||||
|
|
||||||
All notable changes to MetonaEditor will be documented in this file.
|
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 / afterDestroy;5 个零测试配置项: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>` 此前被解析器转义为字面量 `<u>`;现解析器白名单透传裸 `<u>` / `</u>`(无属性,带属性的 `<u onclick=...>` 仍被转义,无注入面),代码块 / 行内代码内不受影响。
|
||||||
|
- **`getStatus().locale` 与实例不一致**: 此前返回全局 locale,实例 `setLocale` 后状态不跟随;现返回实例 locale,渲染 env 的 `locale` 字段同步改为实例级。
|
||||||
|
- **`replaceAll` 替换串 `$` 语义陷阱**: `replaceAll` 的替换文本现按字面处理(`$` 无特殊含义);`replaceAllRegex` 保留正则捕获组语义(`$1`)不变。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`use()` 同名防重**: 同名插件重复安装时 warn 并跳过(`unuse` 后可重新安装),消除全局插件与 `config.plugins` 重复安装问题。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 回归测试 19 个(脚注注入 / 全局插件 / 缓存指纹 / underline / getStatus locale / replaceAll `$` / use 防重),总数 782 → 801。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.5] - 2026-08-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **npm 发布配置**: `module` / `exports.import` 不再指向 TS 源码,产物改用标准 `.mjs` / `.cjs` 扩展名(此前 `"type": "module"` 导致 Node 将 `.js` 产物按 ESM 解析,`require()` 拿不到任何导出)。
|
||||||
|
- **XSS 属性注入**: 链接/图片的 `href`/`src`/`alt`/`title` 及代码块 `language-*` 类与标题属性现经属性级转义(`escapeHTML` 在浏览器环境不转义双引号,此前的属性拼接存在注入面)。
|
||||||
|
- **本地存储串扰**: 分隔条比例键改为 `metona-editor-divider-${id}`,多实例互不覆盖。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **maxLength 生效**: 之前仅有类型与默认值,现绑定 textarea `maxlength` 并对 `setValue` / `insert` / `replaceAll` / `replaceAllRegex` 截断。
|
||||||
|
- **zenMode 初始状态**: 配置 `zenMode: true` 启动即进入专注模式(此前仅 `toggleZen()` 可进入)。
|
||||||
|
- **搜索面板大小写 / 全字匹配**: `matchCase` / `wholeWord` 翻译键此前存在但未实现,现补齐 UI 开关与匹配逻辑(普通与正则模式均支持,中文全字边界感知)。
|
||||||
|
- **内置轻量语法高亮**: 新模块 `src/highlight.ts`,零依赖 tokenizer,13 种语言,`MeEditor.highlight` 即插即用,输出 `me-hl-*` 类。
|
||||||
|
- **exportPDF**: exportTool 新增 `editor.exportPDF()`(隐藏 iframe + window.print)。
|
||||||
|
- **imagePaste 尺寸限制**: `editor.use('imagePaste', { maxSizeKB: 500 })`,超限 toast 警告。
|
||||||
|
- **性能基准**: `npm run bench`(parseMarkdown 1MB ≈ 51ms,highlight 200 函数 ≈ 1.2ms)。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **core.ts 拆分**: 命令、浮动工具栏、右键菜单、大纲 4 组方法拆至独立模块(`commands.ts` / `floating-toolbar.ts` / `context-menu.ts` / `outline.ts`),core 从 1162 行降至 865 行,API 完全兼容。
|
||||||
|
- **插件状态类型收紧**: 6 个预设插件的实例状态改用 Symbol 键存储(`EditorLike` 接口 + `pluginState` 助手),消灭 `(editor as any).__xxx` 魔法属性。
|
||||||
|
- **eslint type-aware**: 启用 `parserOptions.project` 与 `consistent-type-imports` / `no-unnecessary-type-assertion` 规则,0 errors。
|
||||||
|
- **CI 并行化**: 测试拆分为 parser / core / rest 三个并行 job,另设跨 Node 18/20/24 的 verify job(typecheck + lint + build)。
|
||||||
|
- **docs.html / README 补齐**: 光标选区 API、浮动工具栏、replaceAll、新事件、内置高亮文档。
|
||||||
|
- 版本同步 0.2.4 → 0.2.5,测试 725 → 768。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.2.4] - 2026-07-25
|
## [0.2.4] - 2026-07-25
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+2
-2
@@ -104,8 +104,8 @@ npm run build
|
|||||||
# Output in dist/
|
# Output in dist/
|
||||||
# ├── metona-editor.js UMD
|
# ├── metona-editor.js UMD
|
||||||
# ├── metona-editor.min.js UMD minified
|
# ├── metona-editor.min.js UMD minified
|
||||||
# ├── metona-editor.esm.js ES Module
|
# ├── metona-editor.mjs ES Module
|
||||||
# ├── metona-editor.cjs.js CommonJS
|
# ├── metona-editor.cjs CommonJS
|
||||||
# └── metona-editor.d.ts TypeScript declarations
|
# └── metona-editor.d.ts TypeScript declarations
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
|
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
|
||||||
|
|
||||||
[](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
|
[](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](./tests)
|
[](./tests)
|
||||||
[](./tests)
|
[](./tests)
|
||||||
[](./tsconfig.json)
|
[](./tsconfig.json)
|
||||||
|
|
||||||
@@ -12,13 +12,16 @@
|
|||||||
|
|
||||||
- **TypeScript 源码** — 全模块 TypeScript 严格模式,完整类型导出,IDE 智能提示
|
- **TypeScript 源码** — 全模块 TypeScript 严格模式,完整类型导出,IDE 智能提示
|
||||||
- **零运行时依赖** — 打包后单文件,无任何第三方库,UMD / ESM / CJS 三种格式
|
- **零运行时依赖** — 打包后单文件,无任何第三方库,UMD / ESM / CJS 三种格式
|
||||||
- **自研解析器** — CommonMark + GFM 扩展,96% 语句覆盖率,parseTokens / renderTokens 分离 API,registerBlockHandler 自定义块语法
|
- **自研解析器** — CommonMark + GFM 扩展,99% 行覆盖率,parseTokens / renderTokens 分离 API,registerBlockHandler 自定义块语法
|
||||||
- **插件系统 v2** — 拓扑排序依赖管理,6 个预设插件开箱即用,安装 / 卸载生命周期
|
- **插件系统 v2** — 拓扑排序依赖管理,6 个预设插件开箱即用,安装 / 卸载生命周期
|
||||||
- **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步
|
- **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步
|
||||||
- **主题系统** — light / dark / warm / auto,CSS 变量可定制,实例级隔离,主题继承,外部跟随
|
- **主题系统** — light / dark / warm / auto,CSS 变量可定制,实例级隔离,主题继承,外部跟随
|
||||||
- **国际化** — zh-CN / en-US / ja / ko 完整翻译,实例级语言隔离,远程加载翻译包
|
- **国际化** — zh-CN / en-US / ja / ko / fr / de 六种语言完整翻译,实例级语言隔离,远程加载翻译包
|
||||||
- **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式、右键上下文菜单
|
- **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式(宽度可配置,默认 960px)、右键上下文菜单、浮动格式工具栏(mirror 镜像像素级定位,CJK / 软换行 / 跨行选区精确跟随,键盘可达)
|
||||||
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),sanitize 钩子
|
- **内置语法高亮** — 零依赖轻量高亮器,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 属性
|
- **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性
|
||||||
- **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等)
|
- **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等)
|
||||||
@@ -37,6 +40,7 @@
|
|||||||
- [事件系统](#事件系统)
|
- [事件系统](#事件系统)
|
||||||
- [Markdown 解析器](#markdown-解析器)
|
- [Markdown 解析器](#markdown-解析器)
|
||||||
- [插件系统](#插件系统)
|
- [插件系统](#插件系统)
|
||||||
|
- [语法高亮](#语法高亮)
|
||||||
- [主题系统](#主题系统)
|
- [主题系统](#主题系统)
|
||||||
- [国际化](#国际化)
|
- [国际化](#国际化)
|
||||||
- [构建与测试](#构建与测试)
|
- [构建与测试](#构建与测试)
|
||||||
@@ -48,7 +52,16 @@
|
|||||||
|
|
||||||
### 方式一:npm 安装(推荐)
|
### 方式一:npm 安装(推荐)
|
||||||
|
|
||||||
|
> 包托管于 MetonaTeam 自建 Gitea 私有 npm 源(`git.metona.cn`),不在 npm 官方源发布,安装时需指定 registry。
|
||||||
|
|
||||||
```bash
|
```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
|
npm install @metona-team/metona-editor
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -74,15 +87,12 @@ const editor = MeEditor.create('#editor', { mode: 'split' });
|
|||||||
### 方式二:CDN 引入
|
### 方式二:CDN 引入
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- jsDelivr CDN (推荐) -->
|
<!-- Gitea 源(私有仓库需登录,公开仓库直接可访问) -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.2.4/dist/metona-editor.min.js"></script>
|
|
||||||
|
|
||||||
<!-- unpkg CDN -->
|
|
||||||
<script src="https://unpkg.com/@metona-team/metona-editor@0.2.4/dist/metona-editor.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Gitea 源 -->
|
|
||||||
<script src="https://git.metona.cn/MetonaTeam/MetonaEditor/raw/branch/master/dist/metona-editor.js"></script>
|
<script src="https://git.metona.cn/MetonaTeam/MetonaEditor/raw/branch/master/dist/metona-editor.js"></script>
|
||||||
|
|
||||||
|
<!-- 本地构建产物(下载 dist 后引入) -->
|
||||||
|
<script src="./dist/metona-editor.min.js"></script>
|
||||||
|
|
||||||
<div id="editor"></div>
|
<div id="editor"></div>
|
||||||
<script>
|
<script>
|
||||||
const editor = MeEditor.create('#editor', { mode: 'split' });
|
const editor = MeEditor.create('#editor', { mode: 'split' });
|
||||||
@@ -95,8 +105,8 @@ const editor = MeEditor.create('#editor', { mode: 'split' });
|
|||||||
# 下载产物文件到项目中
|
# 下载产物文件到项目中
|
||||||
# dist/metona-editor.js → UMD (浏览器)
|
# dist/metona-editor.js → UMD (浏览器)
|
||||||
# dist/metona-editor.min.js → UMD 压缩
|
# dist/metona-editor.min.js → UMD 压缩
|
||||||
# dist/metona-editor.esm.js → ES Module
|
# dist/metona-editor.mjs → ES Module
|
||||||
# dist/metona-editor.cjs.js → CommonJS
|
# dist/metona-editor.cjs → CommonJS
|
||||||
```
|
```
|
||||||
|
|
||||||
```html
|
```html
|
||||||
@@ -106,9 +116,9 @@ const editor = MeEditor.create('#editor', { mode: 'split' });
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Node.js ESM
|
// Node.js ESM
|
||||||
import MeEditor from './dist/metona-editor.esm.js';
|
import MeEditor from './dist/metona-editor.mjs';
|
||||||
// Node.js CJS
|
// Node.js CJS
|
||||||
const MeEditor = require('./dist/metona-editor.cjs.js');
|
const MeEditor = require('./dist/metona-editor.cjs');
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -120,6 +130,7 @@ MeEditor.create(container, {
|
|||||||
// 内容
|
// 内容
|
||||||
value: '', // 初始 Markdown 文本
|
value: '', // 初始 Markdown 文本
|
||||||
placeholder: '', // 占位符
|
placeholder: '', // 占位符
|
||||||
|
id: '', // 实例 id(默认自动生成,用于分隔条/草稿等存储键隔离)
|
||||||
|
|
||||||
// 视图
|
// 视图
|
||||||
mode: 'split', // 'edit' | 'split' | 'preview'
|
mode: 'split', // 'edit' | 'split' | 'preview'
|
||||||
@@ -138,13 +149,15 @@ MeEditor.create(container, {
|
|||||||
historyDebounce: 400, // 历史栈防抖(ms)
|
historyDebounce: 400, // 历史栈防抖(ms)
|
||||||
syncScroll: true, // 分屏同步滚动
|
syncScroll: true, // 分屏同步滚动
|
||||||
autoBrackets: true, // 括号自动闭合
|
autoBrackets: true, // 括号自动闭合
|
||||||
zenMode: false, // Zen 专注模式
|
zenMode: false, // 启动即进入 Zen 专注模式
|
||||||
|
zenMaxWidth: 960, // Zen 内容区最大宽度:数字为 px / 字符串原样作为 CSS 值 / false 不限制
|
||||||
wordWrap: true, // 自动换行
|
wordWrap: true, // 自动换行
|
||||||
maxLength: 0, // 最大字符数(0=不限)
|
maxLength: 0, // 最大字符数(0=不限)
|
||||||
|
floatingToolbar: true, // 选中文本浮动格式栏
|
||||||
|
|
||||||
// 主题与语言
|
// 主题与语言
|
||||||
theme: 'auto', // 'light' | 'dark' | 'warm' | 'auto'
|
theme: 'auto', // 'light' | 'dark' | 'warm' | 'auto'
|
||||||
locale: 'zh-CN', // 'zh-CN' | 'en-US'
|
locale: 'zh-CN', // 'zh-CN' | 'en-US' | 'ja' | 'ko' | 'fr' | 'de'
|
||||||
|
|
||||||
// 渲染钩子
|
// 渲染钩子
|
||||||
render: null, // (md: string, env: RenderEnv) => string
|
render: null, // (md: string, env: RenderEnv) => string
|
||||||
@@ -217,8 +230,34 @@ editor.getHTML(): string
|
|||||||
editor.refresh(): this // 强制重渲染(内容未变时)
|
editor.refresh(): this // 强制重渲染(内容未变时)
|
||||||
editor.insert(text: string, opts?: { replace?: boolean }): this
|
editor.insert(text: string, opts?: { replace?: boolean }): this
|
||||||
editor.wrap(before: string, after?: string): this
|
editor.wrap(before: string, after?: string): this
|
||||||
|
editor.replaceAll(search: string, replace: string, caseSensitive?: boolean): number
|
||||||
|
editor.replaceAllRegex(pattern: RegExp, replace: string): number
|
||||||
|
editor.lineCount(): number
|
||||||
|
editor.getLine(line: number): string
|
||||||
|
editor.copyAsMarkdown(): this // 复制源码到剪贴板
|
||||||
|
editor.copyAsHTML(): this // 复制渲染 HTML 到剪贴板
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 光标与选区
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
editor.getSelectedText(): string
|
||||||
|
editor.getCursorPosition(): { line: number; column: number }
|
||||||
|
editor.setCursorPosition(line: number, column?: number): this
|
||||||
|
editor.scrollToLine(line: number): this
|
||||||
|
editor.selectLine(line: number): this
|
||||||
|
editor.selectAll(): this
|
||||||
|
```
|
||||||
|
|
||||||
|
### 浮动工具栏
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
editor.toggleFloatingToolbar(): this // 开关选中文本格式栏
|
||||||
|
editor.isFloatingToolbar(): boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
选中文本时自动浮现粗体 / 斜体 / 代码 / 链接 / 删除线按钮。定位采用 mirror 镜像测量(隐藏镜像层复制 textarea 排版样式,选区锚点插入零宽字符读像素坐标),中文混排、长行软换行、跨行选区均精确跟随;选区滚出可视区自动隐藏,滚动时节流重定位。按钮键盘可达(Tab 聚焦 + Enter 执行),隐藏态移出 Tab 序列。已知限制:RTL 布局下定位未做镜像适配。
|
||||||
|
|
||||||
### 命令执行
|
### 命令执行
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -229,6 +268,7 @@ editor.exec(action: string): this
|
|||||||
// h1 h2 h3 quote ul ol hr
|
// h1 h2 h3 quote ul ol hr
|
||||||
// link image table
|
// link image table
|
||||||
// indent outdent undo redo
|
// indent outdent undo redo
|
||||||
|
// formatTable(光标所在表格按列宽对齐)
|
||||||
// edit split preview fullscreen
|
// edit split preview fullscreen
|
||||||
// zen wordwrap
|
// zen wordwrap
|
||||||
```
|
```
|
||||||
@@ -310,14 +350,18 @@ editor.configureToolbar(tools: string[]): this
|
|||||||
editor.removeToolbarButton(action: string): this
|
editor.removeToolbarButton(action: string): this
|
||||||
```
|
```
|
||||||
|
|
||||||
### Zen / Word Wrap
|
### Zen / Word Wrap / Outline
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
editor.toggleZen(): this
|
editor.toggleZen(): this
|
||||||
editor.isZen(): boolean
|
editor.isZen(): boolean
|
||||||
|
editor.setZenMaxWidth(width: number | string | false): this // 运行时调整专注模式宽度
|
||||||
|
editor.getZenMaxWidth(): number | string | false
|
||||||
editor.toggleWordWrap(): this
|
editor.toggleWordWrap(): this
|
||||||
editor.setWordWrap(on: boolean): this
|
editor.setWordWrap(on: boolean): this
|
||||||
editor.isWordWrap(): boolean
|
editor.isWordWrap(): boolean
|
||||||
|
editor.setOutline(on: boolean): this // 运行时开关大纲面板
|
||||||
|
editor.isOutline(): boolean
|
||||||
```
|
```
|
||||||
|
|
||||||
### Toast
|
### Toast
|
||||||
@@ -325,8 +369,8 @@ editor.isWordWrap(): boolean
|
|||||||
```typescript
|
```typescript
|
||||||
editor.toast(message: string, opts?: {
|
editor.toast(message: string, opts?: {
|
||||||
type?: 'success' | 'error' | 'warning' | 'info';
|
type?: 'success' | 'error' | 'warning' | 'info';
|
||||||
duration?: number;
|
duration?: number; // ms,0 = 不自动消失
|
||||||
animation?: 'fade' | 'slide' | 'scale';
|
animation?: 'fade' | 'slide' | 'scale' | 'bounce' | 'flip' | 'rotate' | 'zoom';
|
||||||
}): this
|
}): this
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -370,11 +414,14 @@ MeEditor.setLocale('en-US')
|
|||||||
MeEditor.getStatus()
|
MeEditor.getStatus()
|
||||||
// => { version, theme, locale, globalPlugins, presetPlugins }
|
// => { version, theme, locale, globalPlugins, presetPlugins }
|
||||||
|
|
||||||
// 销毁全局资源
|
// 销毁全局资源(主题/语言监听、全局插件、全局钩子全面复位)
|
||||||
MeEditor.destroy()
|
MeEditor.destroy()
|
||||||
|
|
||||||
// 解析器独立使用
|
// 解析器独立使用
|
||||||
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlockHandler } from '@metona-team/metona-editor';
|
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler } from '@metona-team/metona-editor';
|
||||||
|
|
||||||
|
// 内置语法高亮
|
||||||
|
import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team/metona-editor';
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -385,7 +432,7 @@ import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlo
|
|||||||
|
|
||||||
| 事件 | 触发时机 | 参数 |
|
| 事件 | 触发时机 | 参数 |
|
||||||
|------|---------|------|
|
|------|---------|------|
|
||||||
| `input` | textarea 原生 input | `(value, editor)` |
|
| `input` | 内容变化(原生输入与程序化编辑路径均触发) | `(value, editor)` |
|
||||||
| `change` | 内容变化 | `(value, editor)` |
|
| `change` | 内容变化 | `(value, editor)` |
|
||||||
| `focus` | 聚焦 | `(editor)` |
|
| `focus` | 聚焦 | `(editor)` |
|
||||||
| `blur` | 失焦 | `(editor)` |
|
| `blur` | 失焦 | `(editor)` |
|
||||||
@@ -402,9 +449,11 @@ import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlo
|
|||||||
| `linkClick` | 预览区链接点击 | `({ href, text })` |
|
| `linkClick` | 预览区链接点击 | `({ href, text })` |
|
||||||
| `fileOpened` | fileSystem 打开文件 | `({ name, handle })` |
|
| `fileOpened` | fileSystem 打开文件 | `({ name, handle })` |
|
||||||
| `fileSaved` | fileSystem 保存文件 | `({ handle })` |
|
| `fileSaved` | fileSystem 保存文件 | `({ handle })` |
|
||||||
| `beforeChange` | 内容变更前 | `(oldValue, newValue, editor)` |
|
| `beforeChange` | 内容变更前(原生输入与程序化编辑路径均触发) | `(oldValue, newValue, editor)` |
|
||||||
| `afterChange` | 内容变更后 | `(newValue, editor)` |
|
| `afterChange` | 内容变更后(原生输入与程序化编辑路径均触发) | `(newValue, editor)` |
|
||||||
| `copy` | 复制到剪贴板 | `({ type })` |
|
| `copy` | 复制到剪贴板 | `({ type })` |
|
||||||
|
| `selectionChange` | 选区变化 | `({ start, end, text })` |
|
||||||
|
| `cursorMove` | 光标移动 | `({ line, column })` |
|
||||||
|
|
||||||
### 全局钩子
|
### 全局钩子
|
||||||
|
|
||||||
@@ -433,6 +482,7 @@ import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlo
|
|||||||
| 粗斜体 | `***text***` `___text___` | `<em><strong>` |
|
| 粗斜体 | `***text***` `___text___` | `<em><strong>` |
|
||||||
| 斜体 | `*italic*` `_italic_` | `<em>` |
|
| 斜体 | `*italic*` `_italic_` | `<em>` |
|
||||||
| 删除线 | `~~text~~` | `<del>` |
|
| 删除线 | `~~text~~` | `<del>` |
|
||||||
|
| 下划线 | `<u>text</u>`(裸标签透传) | `<u>` |
|
||||||
| 高亮 | `==text==` | `<mark>` |
|
| 高亮 | `==text==` | `<mark>` |
|
||||||
| 上标 | `x^2^` | `<sup>` |
|
| 上标 | `x^2^` | `<sup>` |
|
||||||
| 下标 | `H~2~O` | `<sub>` |
|
| 下标 | `H~2~O` | `<sub>` |
|
||||||
@@ -450,6 +500,7 @@ import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlo
|
|||||||
| 图片 | `` | `<img>` |
|
| 图片 | `` | `<img>` |
|
||||||
| 引用链接 | `[text][ref]` + `[ref]: url` | `<a>` |
|
| 引用链接 | `[text][ref]` + `[ref]: url` | `<a>` |
|
||||||
| 引用图片 | `![alt][ref]` + `[ref]: url` | `<img>` |
|
| 引用图片 | `![alt][ref]` + `[ref]: url` | `<img>` |
|
||||||
|
| 表格格式化 | `exec('formatTable')` | 光标所在表格按列宽对齐 |
|
||||||
| 自动链接 | `<https://...>` | `<a>` |
|
| 自动链接 | `<https://...>` | `<a>` |
|
||||||
| 数学公式 | `$E=mc^2$` `$$\int$$` | `<span>` / `<div>` |
|
| 数学公式 | `$E=mc^2$` `$$\int$$` | `<span>` / `<div>` |
|
||||||
| 脚注 | `text[^1]` | `<sup>` + 底部定义 |
|
| 脚注 | `text[^1]` | `<sup>` + 底部定义 |
|
||||||
@@ -500,6 +551,8 @@ registerBlockHandler({
|
|||||||
|
|
||||||
- 所有文本经 `escapeHTML` 转义
|
- 所有文本经 `escapeHTML` 转义
|
||||||
- 链接 URL 过滤 `javascript:` / `vbscript:` / `file:` / 非图片 `data:`
|
- 链接 URL 过滤 `javascript:` / `vbscript:` / `file:` / 非图片 `data:`
|
||||||
|
- 属性级注入防护:`href` / `src` / `alt` / `title` / 脚注 id / `language-*` 引号与换行均被转义
|
||||||
|
- 白名单裸标签透传:仅 `<u>` / `</u>`(无属性),带属性的标签仍被转义
|
||||||
- `data:image` 限制最大 500KB
|
- `data:image` 限制最大 500KB
|
||||||
- `sanitize` 钩子供外部净化(如 DOMPurify)
|
- `sanitize` 钩子供外部净化(如 DOMPurify)
|
||||||
- `highlight` 钩子异常自动回退为纯文本
|
- `highlight` 钩子异常自动回退为纯文本
|
||||||
@@ -536,9 +589,9 @@ editor.unuse('myPlugin');
|
|||||||
| 插件 | 说明 | 用法 |
|
| 插件 | 说明 | 用法 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `autoSave` | localStorage 自动保存草稿 | `editor.use('autoSave', { delay: 1000 })` |
|
| `autoSave` | localStorage 自动保存草稿 | `editor.use('autoSave', { delay: 1000 })` |
|
||||||
| `exportTool` | 导出 .md / .html 文件 | `editor.use('exportTool'); editor.exportMarkdown()` |
|
| `exportTool` | 导出 .md / .html 文件,支持 PDF(打印) | `editor.use('exportTool'); editor.exportMarkdown()` |
|
||||||
| `searchReplace` | Ctrl+F 查找 / Ctrl+H 替换 | `editor.use('searchReplace')` |
|
| `searchReplace` | Ctrl+F 查找 / Ctrl+H 替换,支持正则、大小写、全字匹配 | `editor.use('searchReplace')` |
|
||||||
| `imagePaste` | 粘贴剪贴板图片转 base64 | `editor.use('imagePaste')` |
|
| `imagePaste` | 粘贴剪贴板图片转 base64,可限尺寸 | `editor.use('imagePaste', { maxSizeKB: 500 })` |
|
||||||
| `shortcutHelp` | 按 ? 弹出快捷键面板 | `editor.use('shortcutHelp')` |
|
| `shortcutHelp` | 按 ? 弹出快捷键面板 | `editor.use('shortcutHelp')` |
|
||||||
| `fileSystem` | File System Access API 读写磁盘 | `editor.use('fileSystem'); editor.openFile()` |
|
| `fileSystem` | File System Access API 读写磁盘 | `editor.use('fileSystem'); editor.openFile()` |
|
||||||
|
|
||||||
@@ -559,6 +612,27 @@ pluginUtils.getPreset('autoSave') // 预设副本
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 语法高亮
|
||||||
|
|
||||||
|
v0.2.5 起内置零依赖轻量高亮器,支持 js / ts / tsx / jsx / python / bash / css / html / json / yaml / markdown / java / go / rust(含 shell / md 别名共 16 个语言标识)。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import MeEditor from '@metona-team/metona-editor';
|
||||||
|
|
||||||
|
// 作为 highlight 钩子(代码块自动高亮)
|
||||||
|
MeEditor.create('#editor', { highlight: MeEditor.highlight });
|
||||||
|
|
||||||
|
// 独立调用
|
||||||
|
const html = MeEditor.highlight(code, 'typescript');
|
||||||
|
|
||||||
|
// 注册自定义语言
|
||||||
|
MeEditor.registerLanguage('myLang', { keywords: ['kw'], builtins: [] });
|
||||||
|
```
|
||||||
|
|
||||||
|
高亮类名:`me-hl-keyword` / `me-hl-string` / `me-hl-comment` / `me-hl-number` / `me-hl-builtin` / `me-hl-function`,内置浅色 / 深色自适应配色,也可用 CSS 覆盖定制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 主题系统
|
## 主题系统
|
||||||
|
|
||||||
### 内置主题
|
### 内置主题
|
||||||
@@ -612,6 +686,9 @@ editor.getThemeContext().adopt();
|
|||||||
|
|
||||||
// 跟随外部元素
|
// 跟随外部元素
|
||||||
editor.getThemeContext().syncWithElement(document.body);
|
editor.getThemeContext().syncWithElement(document.body);
|
||||||
|
|
||||||
|
// 手动断开所有外部跟随订阅(实例 destroy 时也会自动断开)
|
||||||
|
editor.getThemeContext().dispose();
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -671,6 +748,7 @@ i18nUtils.formatDate('2024-01-15') // 本地化日期
|
|||||||
| `Tab` / `Shift+Tab` | 缩进 / 反缩进 |
|
| `Tab` / `Shift+Tab` | 缩进 / 反缩进 |
|
||||||
| `?` | 快捷键帮助 |
|
| `?` | 快捷键帮助 |
|
||||||
| `Enter` | 智能 Enter(列表/引用延续) |
|
| `Enter` | 智能 Enter(列表/引用延续) |
|
||||||
|
| `Esc` | 退出全屏 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -695,6 +773,12 @@ npm test
|
|||||||
# 测试覆盖率
|
# 测试覆盖率
|
||||||
npm run test -- --coverage
|
npm run test -- --coverage
|
||||||
|
|
||||||
|
# 浏览器冒烟测试(Playwright + Chromium,需先构建)
|
||||||
|
npm run build && npm run test:e2e
|
||||||
|
|
||||||
|
# 性能基准(先构建再运行)
|
||||||
|
npm run build && npm run bench
|
||||||
|
|
||||||
# 代码格式化
|
# 代码格式化
|
||||||
npm run format
|
npm run format
|
||||||
```
|
```
|
||||||
@@ -703,29 +787,34 @@ npm run format
|
|||||||
|
|
||||||
```
|
```
|
||||||
dist/
|
dist/
|
||||||
├── metona-editor.js UMD(浏览器直接引入)
|
├── metona-editor.js UMD(浏览器直接引入)
|
||||||
├── metona-editor.min.js UMD 压缩版(CDN)
|
├── metona-editor.min.js UMD 压缩版(CDN)
|
||||||
├── metona-editor.esm.js ES Module
|
├── metona-editor.mjs ES Module
|
||||||
├── metona-editor.cjs.js CommonJS
|
├── metona-editor.cjs CommonJS
|
||||||
└── metona-editor.d.ts TypeScript 类型声明
|
└── metona-editor.d.ts TypeScript 类型声明
|
||||||
```
|
```
|
||||||
|
|
||||||
### 源码结构
|
### 源码结构
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── index.ts 入口 / 全局API
|
├── index.ts 入口 / 全局API
|
||||||
├── core.ts MarkdownEditor 类
|
├── core.ts MarkdownEditor 类(构造 / 事件 / 历史 / 模式)
|
||||||
├── parser.ts 自研 Markdown 解析器
|
├── commands.ts 编辑命令实现(包裹 / 前缀 / 插入 / 表格格式化)
|
||||||
├── plugins.ts 插件系统 & 6 个预设
|
├── floating-toolbar.ts 选中文本浮动格式栏
|
||||||
├── themes.ts 主题系统
|
├── context-menu.ts 右键上下文菜单
|
||||||
├── i18n.ts 国际化
|
├── outline.ts 大纲面板
|
||||||
├── styles.ts CSS-in-JS
|
├── parser.ts 自研 Markdown 解析器
|
||||||
├── constants.ts 常量 / 类型定义
|
├── highlight.ts 内置轻量语法高亮器
|
||||||
├── utils.ts 工具函数
|
├── plugins.ts 插件系统 & 6 个预设
|
||||||
├── animations.ts 动画元数据
|
├── themes.ts 主题系统
|
||||||
├── icons.ts 工具栏 SVG 图标
|
├── i18n.ts 国际化
|
||||||
└── locales.ts 中英文翻译数据
|
├── styles.ts CSS-in-JS
|
||||||
|
├── constants.ts 常量 / 类型定义
|
||||||
|
├── utils.ts 工具函数
|
||||||
|
├── animations.ts 动画元数据
|
||||||
|
├── icons.ts 工具栏 SVG 图标
|
||||||
|
└── locales.ts 六语言翻译数据
|
||||||
```
|
```
|
||||||
|
|
||||||
### 技术栈
|
### 技术栈
|
||||||
@@ -734,7 +823,8 @@ src/
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 语言 | TypeScript 5 (strict) |
|
| 语言 | TypeScript 5 (strict) |
|
||||||
| 构建 | Rollup 3 |
|
| 构建 | Rollup 3 |
|
||||||
| 测试 | Jest 29 + jsdom |
|
| 测试 | Jest 29 + jsdom(895 用例,覆盖率阈值门禁) |
|
||||||
|
| 浏览器冒烟 | Playwright + Chromium(28 项断言) |
|
||||||
| 类型生成 | rollup-plugin-dts |
|
| 类型生成 | rollup-plugin-dts |
|
||||||
| 零运行时依赖 | ✅ |
|
| 零运行时依赖 | ✅ |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* MetonaEditor 性能基准 — 解析器吞吐测试
|
||||||
|
* 用法: npm run build && npm run bench
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MeEditor = require('../dist/metona-editor.cjs');
|
||||||
|
const { parseMarkdown } = MeEditor;
|
||||||
|
|
||||||
|
const buildSample = (kb) => {
|
||||||
|
const chunk = [
|
||||||
|
'# 性能基准文档',
|
||||||
|
'',
|
||||||
|
'这是一个 **粗体** 与 *斜体* 的段落,包含 `inline code` 和 [链接](https://example.com)。',
|
||||||
|
'',
|
||||||
|
'- 列表项一',
|
||||||
|
'- 列表项二',
|
||||||
|
' - 嵌套项',
|
||||||
|
' - 嵌套项二',
|
||||||
|
'- 列表项三',
|
||||||
|
'',
|
||||||
|
'1. 有序一',
|
||||||
|
'2. 有序二',
|
||||||
|
'',
|
||||||
|
'> 引用内容',
|
||||||
|
'> 继续引用',
|
||||||
|
'',
|
||||||
|
'```js',
|
||||||
|
'function hello(name) {',
|
||||||
|
' const msg = `Hello, ${name}!`;',
|
||||||
|
' return msg;',
|
||||||
|
'}',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'| 列一 | 列二 | 列三 |',
|
||||||
|
'| --- | :---: | ---: |',
|
||||||
|
'| A | B | C |',
|
||||||
|
'| D | E | F |',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'引用链接:[文档][ref]',
|
||||||
|
'',
|
||||||
|
'[ref]: https://example.com/docs "参考"',
|
||||||
|
'',
|
||||||
|
'脚注示例[^1]',
|
||||||
|
'',
|
||||||
|
'[^1]: 脚注内容',
|
||||||
|
'',
|
||||||
|
'## 二级标题',
|
||||||
|
'',
|
||||||
|
'$$E = mc^2$$',
|
||||||
|
'',
|
||||||
|
'H~2~O 与 x^2^,还有 :rocket: emoji。',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
const repeat = Math.max(1, Math.ceil((kb * 1024) / chunk.length));
|
||||||
|
return chunk.repeat(repeat);
|
||||||
|
};
|
||||||
|
|
||||||
|
const median = (arr) => {
|
||||||
|
const s = [...arr].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(s.length / 2);
|
||||||
|
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const benchmark = (label, fn, iterations = 5) => {
|
||||||
|
// warmup
|
||||||
|
fn();
|
||||||
|
const times = [];
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
const t0 = process.hrtime.bigint();
|
||||||
|
fn();
|
||||||
|
const t1 = process.hrtime.bigint();
|
||||||
|
times.push(Number(t1 - t0) / 1e6);
|
||||||
|
}
|
||||||
|
const ms = median(times);
|
||||||
|
return { label, ms: ms.toFixed(1), opsPerSec: (1000 / ms).toFixed(1) };
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('MetonaEditor 性能基准');
|
||||||
|
console.log('=====================');
|
||||||
|
console.log(`版本: ${MeEditor.VERSION}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
for (const size of [64, 256, 1024]) {
|
||||||
|
const sample = buildSample(size);
|
||||||
|
const r = benchmark(`parseMarkdown ${size}KB (${sample.length.toLocaleString()} chars)`, () => {
|
||||||
|
const html = parseMarkdown(sample);
|
||||||
|
if (!html) throw new Error('empty output');
|
||||||
|
});
|
||||||
|
console.log(` ${r.label}: ${r.ms} ms (${r.opsPerSec} ops/s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 语法高亮基准
|
||||||
|
try {
|
||||||
|
const code = Array.from({ length: 200 }, (_, i) =>
|
||||||
|
`function f${i}(a, b) { const s = "str${i}"; // comment\n return a + b + ${i}; }`,
|
||||||
|
).join('\n');
|
||||||
|
const r = benchmark('highlight 200 functions (js)', () => {
|
||||||
|
MeEditor.highlight(code, 'js');
|
||||||
|
});
|
||||||
|
console.log(` ${r.label}: ${r.ms} ms (${r.opsPerSec} ops/s)`);
|
||||||
|
} catch (_) {
|
||||||
|
console.log(' (highlight benchmark skipped)');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('完成。');
|
||||||
@@ -20,7 +20,7 @@ echo "✅ npm版本: $(npm -v)"
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "📦 安装依赖..."
|
echo "📦 安装依赖..."
|
||||||
npm install
|
npm ci
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo "❌ 依赖安装失败"
|
echo "❌ 依赖安装失败"
|
||||||
@@ -61,15 +61,15 @@ echo "✅ 构建成功完成!"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "📁 文件结构:"
|
echo "📁 文件结构:"
|
||||||
echo " - dist/metona-editor.js (UMD格式)"
|
echo " - dist/metona-editor.js (UMD格式)"
|
||||||
echo " - dist/metona-editor.esm.js (ES Module格式)"
|
echo " - dist/metona-editor.mjs (ES Module格式)"
|
||||||
echo " - dist/metona-editor.cjs.js (CommonJS格式)"
|
echo " - dist/metona-editor.cjs (CommonJS格式)"
|
||||||
echo " - dist/metona-editor.min.js (压缩版本)"
|
echo " - dist/metona-editor.min.js (压缩版本)"
|
||||||
echo " - dist/metona-editor.d.ts (TypeScript声明)"
|
echo " - dist/metona-editor.d.ts (TypeScript声明)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "🚀 使用方法:"
|
echo "🚀 使用方法:"
|
||||||
echo " 1. 浏览器: <script src='dist/metona-editor.js'></script>"
|
echo " 1. 浏览器: <script src='dist/metona-editor.js'></script>"
|
||||||
echo " 2. ES Module: import MeEditor from 'dist/metona-editor.esm.js'"
|
echo " 2. ES Module: import MeEditor from 'dist/metona-editor.mjs'"
|
||||||
echo " 3. CommonJS: const MeEditor = require('dist/metona-editor.cjs.js')"
|
echo " 3. CommonJS: const MeEditor = require('dist/metona-editor.cjs')"
|
||||||
echo ""
|
echo ""
|
||||||
echo "📝 示例:"
|
echo "📝 示例:"
|
||||||
echo " 打开 site/index.html 查看官网"
|
echo " 打开 site/index.html 查看官网"
|
||||||
|
|||||||
+1580
-672
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+127
-32
@@ -3,14 +3,47 @@
|
|||||||
* @module plugins
|
* @module plugins
|
||||||
* @version 0.2.0
|
* @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 {
|
interface Plugin {
|
||||||
name: string;
|
name: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
depends?: string[];
|
depends?: string[];
|
||||||
priority?: number;
|
priority?: number;
|
||||||
install?: (editor: any) => void | Promise<void>;
|
install?: (editor: EditorLike, options?: any) => void | Promise<void>;
|
||||||
destroy?: (editor: any) => void;
|
destroy?: (editor: EditorLike) => void;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
interface PluginSchema {
|
interface PluginSchema {
|
||||||
@@ -109,6 +142,31 @@ declare const i18nUtils: {
|
|||||||
createInstanceI18n: (editor: any) => InstanceI18n;
|
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
|
* MetonaEditor Icons — SVG toolbar icons
|
||||||
* @module icons
|
* @module icons
|
||||||
@@ -153,6 +211,7 @@ interface EditorOptions {
|
|||||||
outline?: boolean;
|
outline?: boolean;
|
||||||
autoBrackets?: boolean;
|
autoBrackets?: boolean;
|
||||||
zenMode?: boolean;
|
zenMode?: boolean;
|
||||||
|
zenMaxWidth?: number | string | false;
|
||||||
wordWrap?: boolean;
|
wordWrap?: boolean;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
theme?: ThemeName;
|
theme?: ThemeName;
|
||||||
@@ -284,6 +343,16 @@ declare class MarkdownEditor {
|
|||||||
_zenMode: boolean;
|
_zenMode: boolean;
|
||||||
_wordWrap: boolean;
|
_wordWrap: boolean;
|
||||||
_syncing: boolean;
|
_syncing: boolean;
|
||||||
|
_statsCache: {
|
||||||
|
value: string;
|
||||||
|
stats: any;
|
||||||
|
} | null;
|
||||||
|
_pendingCursor: {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
} | null;
|
||||||
|
_lastSelStart: number;
|
||||||
|
_lastSelEnd: number;
|
||||||
_zenMouseHandler: ((e: MouseEvent) => void) | null;
|
_zenMouseHandler: ((e: MouseEvent) => void) | null;
|
||||||
_ariaLive: HTMLElement;
|
_ariaLive: HTMLElement;
|
||||||
_renderFn: (md: string, env: RenderEnv) => string;
|
_renderFn: (md: string, env: RenderEnv) => string;
|
||||||
@@ -292,6 +361,31 @@ declare class MarkdownEditor {
|
|||||||
_i18nCtx: InstanceI18n;
|
_i18nCtx: InstanceI18n;
|
||||||
_floatingToolbar: HTMLElement | null;
|
_floatingToolbar: HTMLElement | null;
|
||||||
_floatingEnabled: boolean;
|
_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);
|
constructor(container: string | HTMLElement, options?: EditorOptions);
|
||||||
_buildDOM(): void;
|
_buildDOM(): void;
|
||||||
_buildToolbar(): void;
|
_buildToolbar(): void;
|
||||||
@@ -306,27 +400,27 @@ declare class MarkdownEditor {
|
|||||||
_render(): void;
|
_render(): void;
|
||||||
_renderGutter(): void;
|
_renderGutter(): void;
|
||||||
_updateCurrentLine(): void;
|
_updateCurrentLine(): void;
|
||||||
|
/** 选区/光标事件:与浮动工具栏解耦,任何实例都会触发 */
|
||||||
|
_emitCursorEvents(): void;
|
||||||
_handleSmartEnter(e: KeyboardEvent): void;
|
_handleSmartEnter(e: KeyboardEvent): void;
|
||||||
_handleBracketAutoClose(e: KeyboardEvent): void;
|
_handleBracketAutoClose(e: KeyboardEvent): void;
|
||||||
_bindDragDrop(): void;
|
_bindDragDrop(): void;
|
||||||
_buildOutline(): void;
|
|
||||||
_updateOutline(): void;
|
|
||||||
_trackOutlineScroll(): void;
|
|
||||||
_scheduleHistory(): void;
|
_scheduleHistory(): void;
|
||||||
|
/**
|
||||||
|
* 程序化编辑路径(命令 / 快捷键 / 插件 / API 直改 textarea)的统一刷新管线。
|
||||||
|
* 此前 8 条路径的 预览/行号/字数/大纲 刷新与事件发射参差不齐,
|
||||||
|
* 现统一为:渲染 + 行号 + 字数 + 大纲 + input/change 事件 + 回调 + before/afterChange 钩子。
|
||||||
|
*/
|
||||||
|
_afterProgrammaticEdit(oldValue: string): void;
|
||||||
_pushHistory(): void;
|
_pushHistory(): void;
|
||||||
undo(): this;
|
undo(): this;
|
||||||
redo(): this;
|
redo(): this;
|
||||||
canUndo(): boolean;
|
canUndo(): boolean;
|
||||||
canRedo(): boolean;
|
canRedo(): boolean;
|
||||||
_applyHistory(): void;
|
_applyHistory(): void;
|
||||||
|
_captureCursor(): void;
|
||||||
|
_restoreCapturedCursor(): void;
|
||||||
exec(action: string, ...args: any[]): this;
|
exec(action: string, ...args: any[]): this;
|
||||||
_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;
|
|
||||||
setMode(mode: EditMode): this;
|
setMode(mode: EditMode): this;
|
||||||
getMode(): EditMode;
|
getMode(): EditMode;
|
||||||
_updateModeButtons(): void;
|
_updateModeButtons(): void;
|
||||||
@@ -334,28 +428,29 @@ declare class MarkdownEditor {
|
|||||||
isFullscreen(): boolean;
|
isFullscreen(): boolean;
|
||||||
exitFullscreen(): this;
|
exitFullscreen(): this;
|
||||||
toggleZen(): this;
|
toggleZen(): this;
|
||||||
|
_setZen(on: boolean): void;
|
||||||
isZen(): boolean;
|
isZen(): boolean;
|
||||||
|
_applyZenWidth(): void;
|
||||||
|
setZenMaxWidth(width: number | string | false): this;
|
||||||
|
getZenMaxWidth(): number | string | false;
|
||||||
toggleWordWrap(): this;
|
toggleWordWrap(): this;
|
||||||
setWordWrap(on: boolean): this;
|
setWordWrap(on: boolean): this;
|
||||||
isWordWrap(): boolean;
|
isWordWrap(): boolean;
|
||||||
_initFloatingToolbar(): void;
|
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
|
||||||
toggleFloatingToolbar(): this;
|
setOutline(on: boolean): this;
|
||||||
isFloatingToolbar(): boolean;
|
isOutline(): boolean;
|
||||||
_buildFloatingToolbar(): void;
|
|
||||||
_hideFloatingToolbar(): void;
|
|
||||||
_bindToolbarKeyboard(): void;
|
_bindToolbarKeyboard(): void;
|
||||||
_initAriaLive(): void;
|
_initAriaLive(): void;
|
||||||
_announce(msg: string): void;
|
_announce(msg: string): void;
|
||||||
_syncScroll(): void;
|
_syncScroll(): void;
|
||||||
_updateWordCount(): void;
|
_updateWordCount(): void;
|
||||||
getStats(): {
|
getStats(): any;
|
||||||
characters: number;
|
/**
|
||||||
words: number;
|
* 增量统计:基于上一次统计 + 差异区间(共同前缀/后缀之间的片段)计算,
|
||||||
chineseChars: number;
|
* 避免每次击键对全文做 O(n) 正则。差异区间左右扩展至单词边界,
|
||||||
englishWords: number;
|
* 保证英文单词 / 中文串不会被边界切断,diff 结果与全量计算一致。
|
||||||
lines: number;
|
*/
|
||||||
readingTime: number;
|
_computeStats(text: string): any;
|
||||||
};
|
|
||||||
getSelectedText(): string;
|
getSelectedText(): string;
|
||||||
getCursorPosition(): {
|
getCursorPosition(): {
|
||||||
line: number;
|
line: number;
|
||||||
@@ -369,6 +464,7 @@ declare class MarkdownEditor {
|
|||||||
replaceAllRegex(pattern: RegExp, replace: string): number;
|
replaceAllRegex(pattern: RegExp, replace: string): number;
|
||||||
lineCount(): number;
|
lineCount(): number;
|
||||||
getLine(line: number): string;
|
getLine(line: number): string;
|
||||||
|
_limitLength(value: string): string;
|
||||||
getValue(): string;
|
getValue(): string;
|
||||||
setValue(md: string, opts?: {
|
setValue(md: string, opts?: {
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
@@ -400,11 +496,6 @@ declare class MarkdownEditor {
|
|||||||
getShortcuts(): any[];
|
getShortcuts(): any[];
|
||||||
configureToolbar(tools: ToolbarItem[]): this;
|
configureToolbar(tools: ToolbarItem[]): this;
|
||||||
removeToolbarButton(action: string): this;
|
removeToolbarButton(action: string): this;
|
||||||
registerContextMenu(items?: any[]): this;
|
|
||||||
_bindContextMenu(): void;
|
|
||||||
_showContextMenu(e: MouseEvent): void;
|
|
||||||
_hideContextMenu(): void;
|
|
||||||
_execContextAction(action: string): void;
|
|
||||||
toast(message: string, opts?: {
|
toast(message: string, opts?: {
|
||||||
type?: string;
|
type?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
@@ -507,7 +598,7 @@ declare const animationUtils: {
|
|||||||
destroy(): void;
|
destroy(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
declare const VERSION = "0.2.4";
|
declare const VERSION = "0.4.3";
|
||||||
declare function create(container: string | HTMLElement, options?: EditorOptions): MarkdownEditor;
|
declare function create(container: string | HTMLElement, options?: EditorOptions): MarkdownEditor;
|
||||||
declare function use(plugin: string | any, options?: any): typeof api;
|
declare function use(plugin: string | any, options?: any): typeof api;
|
||||||
declare function on(name: string, fn: (editor: MarkdownEditor) => void): () => void;
|
declare function on(name: string, fn: (editor: MarkdownEditor) => void): () => void;
|
||||||
@@ -542,6 +633,10 @@ declare const api: {
|
|||||||
slugify: (text: string) => string;
|
slugify: (text: string) => string;
|
||||||
clearRenderCache: () => void;
|
clearRenderCache: () => void;
|
||||||
registerBlockHandler: (handler: BlockHandler) => void;
|
registerBlockHandler: (handler: BlockHandler) => void;
|
||||||
|
highlight: (code: string, lang: string) => string;
|
||||||
|
normalizeLanguage: (lang: string) => string;
|
||||||
|
registerLanguage: (name: string, def: LanguageDef) => void;
|
||||||
|
getSupportedLanguages: () => string[];
|
||||||
themes: {
|
themes: {
|
||||||
getSystemTheme: () => "light" | "dark";
|
getSystemTheme: () => "light" | "dark";
|
||||||
resolveTheme: (theme: string) => string;
|
resolveTheme: (theme: string) => string;
|
||||||
@@ -674,4 +769,4 @@ declare const api: {
|
|||||||
TOOLBAR_ACTIONS: 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, i18nUtils, loadRemote, api as meEditor, off, on, parseMarkdown, parseTokens, pluginUtils, presetPlugins, registerBlockHandler, renderTokens, safeUrl, setLocale, setTheme, slugify, themeUtils, topologicalSort, use, validateConfig };
|
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 };
|
||||||
|
|||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1579
-671
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1577
-673
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
@@ -13,5 +13,16 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
coverageDirectory: 'coverage',
|
coverageDirectory: 'coverage',
|
||||||
coverageReporters: ['text', 'lcov'],
|
coverageReporters: ['text', 'lcov'],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 85,
|
||||||
|
branches: 70,
|
||||||
|
functions: 85,
|
||||||
|
lines: 85,
|
||||||
|
},
|
||||||
|
'src/parser.ts': {
|
||||||
|
lines: 95,
|
||||||
|
},
|
||||||
|
},
|
||||||
verbose: true,
|
verbose: true,
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+50
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@metona-team/metona-editor",
|
"name": "@metona-team/metona-editor",
|
||||||
"version": "0.2.2",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@metona-team/metona-editor",
|
"name": "@metona-team/metona-editor",
|
||||||
"version": "0.2.2",
|
"version": "0.3.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.22.0",
|
"@babel/core": "^7.22.0",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"jest-environment-jsdom": "^29.5.0",
|
"jest-environment-jsdom": "^29.5.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prettier": "^2.8.0",
|
"prettier": "^2.8.0",
|
||||||
"rollup": "^3.20.0",
|
"rollup": "^3.20.0",
|
||||||
"rollup-plugin-dts": "^5.3.0",
|
"rollup-plugin-dts": "^5.3.0",
|
||||||
@@ -6567,6 +6568,53 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|||||||
+14
-8
@@ -1,19 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "@metona-team/metona-editor",
|
"name": "@metona-team/metona-editor",
|
||||||
"version": "0.2.4",
|
"version": "0.4.3",
|
||||||
"description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.",
|
"description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/metona-editor.js",
|
"main": "dist/metona-editor.cjs",
|
||||||
"module": "src/index.ts",
|
"module": "dist/metona-editor.mjs",
|
||||||
"unpkg": "dist/metona-editor.min.js",
|
"unpkg": "dist/metona-editor.min.js",
|
||||||
"jsdelivr": "dist/metona-editor.min.js",
|
"jsdelivr": "dist/metona-editor.min.js",
|
||||||
"types": "dist/metona-editor.d.ts",
|
"types": "dist/metona-editor.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"import": "./src/index.ts",
|
"types": "./dist/metona-editor.d.ts",
|
||||||
"require": "./dist/metona-editor.js",
|
"import": "./dist/metona-editor.mjs",
|
||||||
"types": "./dist/metona-editor.d.ts"
|
"require": "./dist/metona-editor.cjs",
|
||||||
}
|
"default": "./dist/metona-editor.js"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/",
|
"dist/",
|
||||||
@@ -21,15 +23,18 @@
|
|||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
|
"sideEffects": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rollup -c",
|
"build": "rollup -c",
|
||||||
"dev": "rollup -c -w",
|
"dev": "rollup -c -w",
|
||||||
"test": "jest --coverage",
|
"test": "jest --coverage",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"lint": "eslint \"src/**/*.ts\"",
|
"lint": "eslint \"src/**/*.ts\"",
|
||||||
"lint:fix": "eslint src/ --fix",
|
"lint:fix": "eslint \"src/**/*.ts\" --fix",
|
||||||
"format": "prettier --write src/",
|
"format": "prettier --write src/",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"bench": "node bench/benchmark.cjs",
|
||||||
|
"test:e2e": "node e2e/smoke.cjs",
|
||||||
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -68,6 +73,7 @@
|
|||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"jest-environment-jsdom": "^29.5.0",
|
"jest-environment-jsdom": "^29.5.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prettier": "^2.8.0",
|
"prettier": "^2.8.0",
|
||||||
"rollup": "^3.20.0",
|
"rollup": "^3.20.0",
|
||||||
"rollup-plugin-dts": "^5.3.0",
|
"rollup-plugin-dts": "^5.3.0",
|
||||||
|
|||||||
+2
-16
@@ -7,7 +7,6 @@ import serve from 'rollup-plugin-serve';
|
|||||||
import livereload from 'rollup-plugin-livereload';
|
import livereload from 'rollup-plugin-livereload';
|
||||||
|
|
||||||
const isDev = process.env.ROLLUP_WATCH;
|
const isDev = process.env.ROLLUP_WATCH;
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
|
||||||
|
|
||||||
const basePlugins = [
|
const basePlugins = [
|
||||||
resolve(),
|
resolve(),
|
||||||
@@ -29,19 +28,6 @@ const devPlugins = isDev ? [
|
|||||||
}),
|
}),
|
||||||
] : [];
|
] : [];
|
||||||
|
|
||||||
const terserPlugin = isProd ? [
|
|
||||||
terser({
|
|
||||||
compress: {
|
|
||||||
drop_console: true,
|
|
||||||
drop_debugger: true,
|
|
||||||
pure_funcs: ['console.log', 'console.warn'],
|
|
||||||
},
|
|
||||||
format: {
|
|
||||||
comments: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
] : [];
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
// UMD development
|
// UMD development
|
||||||
{
|
{
|
||||||
@@ -61,7 +47,7 @@ export default [
|
|||||||
{
|
{
|
||||||
input: 'src/index.ts',
|
input: 'src/index.ts',
|
||||||
output: {
|
output: {
|
||||||
file: 'dist/metona-editor.esm.js',
|
file: 'dist/metona-editor.mjs',
|
||||||
format: 'es',
|
format: 'es',
|
||||||
exports: 'named',
|
exports: 'named',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
@@ -72,7 +58,7 @@ export default [
|
|||||||
{
|
{
|
||||||
input: 'src/index.ts',
|
input: 'src/index.ts',
|
||||||
output: {
|
output: {
|
||||||
file: 'dist/metona-editor.cjs.js',
|
file: 'dist/metona-editor.cjs',
|
||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
exports: 'named',
|
exports: 'named',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|||||||
+47
-19
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
|
||||||
<title>MetonaEditor v0.2.4 — 全功能演示</title>
|
<title>MetonaEditor v0.4.3 — 全功能演示</title>
|
||||||
<style>
|
<style>
|
||||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
|
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
|
||||||
@@ -45,11 +45,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1><span class="grad">Metona</span>Editor v0.2.4</h1>
|
<h1><span class="grad">Metona</span>Editor v0.4.3</h1>
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
<span class="badge">TypeScript</span>
|
<span class="badge">TypeScript</span>
|
||||||
<span class="badge">零运行时依赖</span>
|
<span class="badge">零运行时依赖</span>
|
||||||
<span class="badge">725 tests</span>
|
<span class="badge">895 tests</span>
|
||||||
<span class="badge">6 个插件</span>
|
<span class="badge">6 个插件</span>
|
||||||
<span class="badge">桌面端优先</span>
|
<span class="badge">桌面端优先</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,18 +80,20 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-card"><div class="icon">🏗️</div><h4>TypeScript 全模块重构</h4><p>12 个 .ts 源文件,严格模式,完整类型导出</p></div>
|
<div class="info-card"><div class="icon">🏗️</div><h4>TypeScript 全模块重构</h4><p>17 个 .ts 源文件,严格模式,完整类型导出</p></div>
|
||||||
<div class="info-card"><div class="icon">📝</div><h4>自研解析器 99% 行覆盖</h4><p>CommonMark + GFM:粗斜体、链接内格式、脚注、数学公式等</p></div>
|
<div class="info-card"><div class="icon">📝</div><h4>自研解析器 99% 行覆盖</h4><p>CommonMark + GFM:粗斜体、链接内格式、脚注、数学公式等</p></div>
|
||||||
|
<div class="info-card"><div class="icon">🎨</div><h4>内置语法高亮</h4><p>零依赖 tokenizer,js / 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>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>行号装订线</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>快捷键面板</h4><p>按 ? 查看全部快捷键,Ctrl+F/H 搜索替换</p></div>
|
||||||
<div class="info-card"><div class="icon">📊</div><h4>Mermaid 图表</h4><p>```mermaid 代码块,加载 Mermaid.js 即可渲染</p></div>
|
<div class="info-card"><div class="icon">📊</div><h4>Mermaid 图表</h4><p>```mermaid 代码块,加载 Mermaid.js 即可渲染</p></div>
|
||||||
<div class="info-card"><div class="icon">🔌</div><h4>6 个预设插件</h4><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem</p></div>
|
<div class="info-card"><div class="icon">🔌</div><h4>6 个预设插件</h4><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem</p></div>
|
||||||
|
<div class="info-card"><div class="icon">⚡</div><h4>增量性能</h4><p>字数/词数/行数差异统计,击键零全量扫描;高亮规则缓存;大纲 O(n)</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
MetonaEditor v0.2.4 · 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>
|
</footer>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||||
@@ -99,18 +101,19 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var demoMd=[
|
var demoMd=[
|
||||||
'# 🚀 MetonaEditor v0.2.4 全功能演示',
|
'# 🚀 MetonaEditor v0.4.3 全功能演示',
|
||||||
'',
|
'',
|
||||||
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
|
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
|
||||||
'> 全模块 TypeScript 严格模式,12 个源文件,725 个测试全部通过。',
|
'> 全模块 TypeScript 严格模式,17 个源文件,895 个测试全部通过。',
|
||||||
'',
|
'',
|
||||||
'## ✨ v0.2.4 新特性',
|
'## ✨ v0.4.3 新特性',
|
||||||
'',
|
'',
|
||||||
'- 浮动格式工具栏:选中文本自动弹出 bold/italic/code/link',
|
'- **浮动工具栏定位重做**:mirror 镜像测量像素级定位,中文混排 / 长行软换行 / 跨行选区精确跟随',
|
||||||
'- 新增 `getSelectedText()` `getCursorPosition()` 等 10+ API',
|
'- **滚动跟随**:选区滚出可视区自动隐藏,滚动时节流重定位,分隔条拖拽与窗口缩放后自动隐藏',
|
||||||
'- `replaceAll` / `replaceAllRegex` 批量替换',
|
'- **键盘可达**:隐藏态移出 Tab 序列,按钮支持 Enter / Space 执行,`role="toolbar"` + aria-label',
|
||||||
'- `selectionChange` / `cursorMove` 事件',
|
'- **程序化编辑统一刷新管线**:命令 / 快捷键 / 插件路径的预览 / 行号 / 字数 / 大纲刷新与 input / change 事件完全对齐原生输入',
|
||||||
'- 德语(de)翻译 · 6 种语言支持',
|
'- **修复 13 处问题**:浮动工具栏死开关 · 监听器泄漏 · 销毁后 focus 崩溃 · 右键菜单注入 · copyAsHTML 在 Firefox 崩溃 · Esc 退出全屏等',
|
||||||
|
'- **测试补强**:871 → 895 用例,e2e 冒烟 22 → 28 项断言(含真实浏览器布局验证)',
|
||||||
'',
|
'',
|
||||||
'## 📐 文本样式',
|
'## 📐 文本样式',
|
||||||
'',
|
'',
|
||||||
@@ -118,7 +121,7 @@ var demoMd=[
|
|||||||
'- ***粗斜体*** / ___另一种粗斜体___',
|
'- ***粗斜体*** / ___另一种粗斜体___',
|
||||||
'- *斜体* / _另一种斜体_',
|
'- *斜体* / _另一种斜体_',
|
||||||
'- ~~删除线~~ / ==高亮标记==',
|
'- ~~删除线~~ / ==高亮标记==',
|
||||||
'- 链接内格式: [**粗体链接**](https://example.com)',
|
'- <u>下划线</u>(Ctrl+U)· 链接内格式: [**粗体链接**](https://example.com)',
|
||||||
'- 化学:H~2~O / 数学:x^2^',
|
'- 化学:H~2~O / 数学:x^2^',
|
||||||
'- 代码:`const x = 42;`',
|
'- 代码:`const x = 42;`',
|
||||||
'- Emoji::smile: :rocket: :fire: :rainbow: :coffee:',
|
'- Emoji::smile: :rocket: :fire: :rainbow: :coffee:',
|
||||||
@@ -139,7 +142,13 @@ var demoMd=[
|
|||||||
'',
|
'',
|
||||||
'| 版本 | 日期 | 测试 | 主题 |',
|
'| 版本 | 日期 | 测试 | 主题 |',
|
||||||
'| :--- | :---: | ---: | --- |',
|
'| :--- | :---: | ---: | --- |',
|
||||||
'| v0.2.4 | 2026-07 | 725 | 浮动工具栏+新API |',
|
'| 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.2 | 2026-07 | 694 | 法语+表格格式化 |',
|
||||||
'| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
|
'| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
|
||||||
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
|
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
|
||||||
@@ -185,6 +194,23 @@ var demoMd=[
|
|||||||
'editor.exec("bold").exec("h1").focus();',
|
'editor.exec("bold").exec("h1").focus();',
|
||||||
'```',
|
'```',
|
||||||
'',
|
'',
|
||||||
|
'```python',
|
||||||
|
'def fib(n):',
|
||||||
|
' """返回斐波那契数列第 n 项"""',
|
||||||
|
' a, b = 0, 1',
|
||||||
|
' for _ in range(n):',
|
||||||
|
' a, b = b, a + b',
|
||||||
|
' return a',
|
||||||
|
'',
|
||||||
|
'print(fib(10)) # 55',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'```bash',
|
||||||
|
'# 安装(Gitea 私有源)',
|
||||||
|
'npm install @metona-team/metona-editor --registry=https://git.metona.cn/api/packages/MetonaTeam/npm/',
|
||||||
|
'npm run dev',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
'## 📌 脚注',
|
'## 📌 脚注',
|
||||||
'',
|
'',
|
||||||
'这是一个带脚注的句子[^1],另一个脚注[^note]。',
|
'这是一个带脚注的句子[^1],另一个脚注[^note]。',
|
||||||
@@ -204,6 +230,7 @@ var editor = MeEditor.create('#editor',{
|
|||||||
mode: 'split', height: 620,
|
mode: 'split', height: 620,
|
||||||
lineNumbers: true, autoBrackets: true, wordCount: true,
|
lineNumbers: true, autoBrackets: true, wordCount: true,
|
||||||
theme: 'auto', locale: 'zh-CN', outline: false,
|
theme: 'auto', locale: 'zh-CN', outline: false,
|
||||||
|
highlight: MeEditor.highlight,
|
||||||
plugins: ['autoSave','exportTool','searchReplace','imagePaste','shortcutHelp','fileSystem'],
|
plugins: ['autoSave','exportTool','searchReplace','imagePaste','shortcutHelp','fileSystem'],
|
||||||
onLinkClick: function(uri, text, ed){
|
onLinkClick: function(uri, text, ed){
|
||||||
ed.toast('链接: ' + uri, { type: 'info', duration: 2000 });
|
ed.toast('链接: ' + uri, { type: 'info', duration: 2000 });
|
||||||
@@ -246,9 +273,10 @@ function updateModeBtns(mode){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleOutline(btn){
|
function toggleOutline(btn){
|
||||||
editor.config.outline = !editor.config.outline;
|
// v0.4.2 起使用公开 API setOutline/isOutline(替代操作私有 config/_buildOutline 的写法)
|
||||||
if (editor.config.outline) { editor._buildOutline(); btn.classList.add('on'); }
|
var next = !editor.isOutline();
|
||||||
else { var p = editor.el.querySelector('.me-outline'); if (p) p.remove(); btn.classList.remove('on'); }
|
editor.setOutline(next);
|
||||||
|
btn.classList.toggle('on', next);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom shortcut
|
// Custom shortcut
|
||||||
|
|||||||
+116
-16
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
|
||||||
<title>MetonaEditor v0.2.4 · 文档</title>
|
<title>MetonaEditor v0.4.3 · 文档</title>
|
||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)}
|
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)}
|
||||||
@@ -65,7 +65,7 @@ footer a{color:var(--accent);text-decoration:none}
|
|||||||
|
|
||||||
<header class="docs-header">
|
<header class="docs-header">
|
||||||
<h1><span class="grad">API 文档</span></h1>
|
<h1><span class="grad">API 文档</span></h1>
|
||||||
<p class="sub">MetonaEditor v0.2.4 · TypeScript · 完整配置、API 与使用指南</p>
|
<p class="sub">MetonaEditor v0.4.3 · TypeScript · 完整配置、API 与使用指南</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -78,6 +78,7 @@ footer a{color:var(--accent);text-decoration:none}
|
|||||||
<a href="#toolbar">工具栏</a>
|
<a href="#toolbar">工具栏</a>
|
||||||
<div class="toc-group">实例 API</div>
|
<div class="toc-group">实例 API</div>
|
||||||
<a href="#api-content">内容操作</a>
|
<a href="#api-content">内容操作</a>
|
||||||
|
<a href="#api-cursor">光标与选区</a>
|
||||||
<a href="#api-exec">命令执行</a>
|
<a href="#api-exec">命令执行</a>
|
||||||
<a href="#api-history">历史栈</a>
|
<a href="#api-history">历史栈</a>
|
||||||
<a href="#api-mode">模式 / 全屏 / Zen</a>
|
<a href="#api-mode">模式 / 全屏 / Zen</a>
|
||||||
@@ -89,12 +90,15 @@ footer a{color:var(--accent);text-decoration:none}
|
|||||||
<a href="#api-destroy">生命周期</a>
|
<a href="#api-destroy">生命周期</a>
|
||||||
<div class="toc-group">静态 API</div>
|
<div class="toc-group">静态 API</div>
|
||||||
<a href="#static-api">全局函数</a>
|
<a href="#static-api">全局函数</a>
|
||||||
|
<div class="toc-group">语法高亮</div>
|
||||||
|
<a href="#highlight">内置高亮器</a>
|
||||||
<div class="toc-group">解析器</div>
|
<div class="toc-group">解析器</div>
|
||||||
<a href="#parser-syntax">语法参考</a>
|
<a href="#parser-syntax">语法参考</a>
|
||||||
<a href="#parser-api">解析器 API</a>
|
<a href="#parser-api">解析器 API</a>
|
||||||
<div class="toc-group">插件</div>
|
<div class="toc-group">插件</div>
|
||||||
<a href="#plugins-convention">插件约定</a>
|
<a href="#plugins-convention">插件约定</a>
|
||||||
<a href="#preset-plugins">预设插件</a>
|
<a href="#preset-plugins">预设插件</a>
|
||||||
|
<a href="#preset-plugins">PluginManager</a>
|
||||||
<div class="toc-group">主题</div>
|
<div class="toc-group">主题</div>
|
||||||
<a href="#themes">主题系统</a>
|
<a href="#themes">主题系统</a>
|
||||||
<div class="toc-group">国际化</div>
|
<div class="toc-group">国际化</div>
|
||||||
@@ -106,8 +110,11 @@ footer a{color:var(--accent);text-decoration:none}
|
|||||||
<!-- 快速入门 -->
|
<!-- 快速入门 -->
|
||||||
<section id="quickstart">
|
<section id="quickstart">
|
||||||
<h2>安装与引入</h2>
|
<h2>安装与引入</h2>
|
||||||
<pre><span class="c-com"># npm 安装</span>
|
<pre><span class="c-com"># npm 安装(Gitea 私有源)</span>
|
||||||
<span class="c-kw">npm</span> install @metona-team/metona-editor
|
<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-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>
|
<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>
|
||||||
@@ -130,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>
|
<pre>MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span>
|
||||||
<span class="c-com">// 内容</span>
|
<span class="c-com">// 内容</span>
|
||||||
value<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> placeholder<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span>
|
value<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> placeholder<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span>
|
||||||
|
id<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 实例 id(默认自动生成,隔离存储键)</span>
|
||||||
|
|
||||||
<span class="c-com">// 视图</span>
|
<span class="c-com">// 视图</span>
|
||||||
mode<span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span> height<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
|
mode<span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span> height<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
|
||||||
@@ -142,6 +150,10 @@ 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>
|
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>
|
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>
|
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>
|
||||||
|
|
||||||
<span class="c-com">// 主题 / 语言</span>
|
<span class="c-com">// 主题 / 语言</span>
|
||||||
theme<span class="c-punc">:</span> <span class="c-str">'auto'</span><span class="c-punc">,</span> locale<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span>
|
theme<span class="c-punc">:</span> <span class="c-str">'auto'</span><span class="c-punc">,</span> locale<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span>
|
||||||
@@ -194,9 +206,23 @@ editor<span class="c-punc">.</span><span class="c-fn">getHTML</span><span class=
|
|||||||
editor<span class="c-punc">.</span><span class="c-fn">refresh</span><span class="c-punc">();</span> <span class="c-com">// 强制重渲染</span>
|
editor<span class="c-punc">.</span><span class="c-fn">refresh</span><span class="c-punc">();</span> <span class="c-com">// 强制重渲染</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">insert</span><span class="c-punc">(</span>text<span class="c-punc">,</span> <span class="c-punc">{</span> replace <span class="c-punc">});</span> <span class="c-com">// => this</span>
|
editor<span class="c-punc">.</span><span class="c-fn">insert</span><span class="c-punc">(</span>text<span class="c-punc">,</span> <span class="c-punc">{</span> replace <span class="c-punc">});</span> <span class="c-com">// => this</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">wrap</span><span class="c-punc">(</span>before<span class="c-punc">,</span> after<span class="c-punc">);</span> <span class="c-com">// => this</span>
|
editor<span class="c-punc">.</span><span class="c-fn">wrap</span><span class="c-punc">(</span>before<span class="c-punc">,</span> after<span class="c-punc">);</span> <span class="c-com">// => this</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">replaceAll</span><span class="c-punc">(</span>search<span class="c-punc">,</span> replace<span class="c-punc">,</span> caseSensitive<span class="c-punc">)</span> <span class="c-com">// => 替换数</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">replaceAllRegex</span><span class="c-punc">(</span>pattern<span class="c-punc">,</span> replace<span class="c-punc">)</span> <span class="c-com">// => 替换数</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">lineCount</span><span class="c-punc">();</span> <span class="c-fn">getLine</span><span class="c-punc">(</span>n<span class="c-punc">);</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">copyAsMarkdown</span><span class="c-punc">();</span> <span class="c-fn">copyAsHTML</span><span class="c-punc">();</span> <span class="c-com">// 剪贴板</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c-punc">();</span> <span class="c-fn">blur</span><span class="c-punc">();</span></pre>
|
editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c-punc">();</span> <span class="c-fn">blur</span><span class="c-punc">();</span></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="api-cursor"><h3>光标与选区</h3>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">getSelectedText</span><span class="c-punc">();</span> <span class="c-com">// => string</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">getCursorPosition</span><span class="c-punc">();</span> <span class="c-com">// => { line, column }</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">setCursorPosition</span><span class="c-punc">(</span>line<span class="c-punc">,</span> column<span class="c-punc">);</span> <span class="c-com">// => this</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">scrollToLine</span><span class="c-punc">(</span>line<span class="c-punc">);</span> <span class="c-com">// => this</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">selectLine</span><span class="c-punc">(</span>line<span class="c-punc">);</span> <span class="c-com">// => this</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">selectAll</span><span class="c-punc">();</span> <span class="c-com">// => this</span></pre>
|
||||||
|
<p>伴随事件 <code>selectionChange</code>(<code>{ start, end, text }</code>)与 <code>cursorMove</code>(<code>{ line, column }</code>)。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="api-exec"><h3>命令执行</h3>
|
<section id="api-exec"><h3>命令执行</h3>
|
||||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-punc">(</span>action<span class="c-punc">)</span> <span class="c-com">// => this, 支持链式</span></pre>
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-punc">(</span>action<span class="c-punc">)</span> <span class="c-com">// => this, 支持链式</span></pre>
|
||||||
<table>
|
<table>
|
||||||
@@ -212,13 +238,16 @@ editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c
|
|||||||
<tr><td><code>link</code></td><td>[text](url)</td><td>Ctrl+K</td></tr>
|
<tr><td><code>link</code></td><td>[text](url)</td><td>Ctrl+K</td></tr>
|
||||||
<tr><td><code>image</code></td><td></td><td>—</td></tr>
|
<tr><td><code>image</code></td><td></td><td>—</td></tr>
|
||||||
<tr><td><code>table</code></td><td>插入表格</td><td>—</td></tr>
|
<tr><td><code>table</code></td><td>插入表格</td><td>—</td></tr>
|
||||||
|
<tr><td><code>formatTable</code> <span class="badge">v0.2.2</span></td><td>光标所在表格按列宽对齐</td><td>—</td></tr>
|
||||||
<tr><td><code>hr</code></td><td>---水平线</td><td>—</td></tr>
|
<tr><td><code>hr</code></td><td>---水平线</td><td>—</td></tr>
|
||||||
<tr><td><code>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab / Shift+Tab</td></tr>
|
<tr><td><code>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab / Shift+Tab</td></tr>
|
||||||
<tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Y</td></tr>
|
<tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z</td></tr>
|
||||||
<tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td>—</td></tr>
|
<tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td>—</td></tr>
|
||||||
<tr><td><code>fullscreen</code></td><td>全屏</td><td>—</td></tr>
|
<tr><td><code>fullscreen</code></td><td>全屏</td><td>—</td></tr>
|
||||||
<tr><td><code>zen</code></td><td>Zen 模式</td><td>—</td></tr>
|
<tr><td><code>zen</code></td><td>Zen 模式</td><td>—</td></tr>
|
||||||
<tr><td><code>wordwrap</code></td><td>自动换行</td><td>—</td></tr>
|
<tr><td><code>wordwrap</code></td><td>自动换行</td><td>—</td></tr>
|
||||||
|
<tr><td>智能 Enter</td><td>列表 / 引用自动延续,空行回车退出</td><td>Enter</td></tr>
|
||||||
|
<tr><td>查找 / 替换</td><td>searchReplace 插件面板</td><td>Ctrl+F / Ctrl+H</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -227,11 +256,15 @@ editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c
|
|||||||
editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">canRedo</span><span class="c-punc">();</span></pre>
|
editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">canRedo</span><span class="c-punc">();</span></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="api-mode"><h3>模式 / 全屏 / Zen</h3>
|
<section id="api-mode"><h3>模式 / 全屏 / Zen / 浮动工具栏</h3>
|
||||||
<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>
|
<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">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>
|
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">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></pre>
|
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">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>
|
||||||
|
|
||||||
<section id="api-stats"><h3>统计与状态</h3>
|
<section id="api-stats"><h3>统计与状态</h3>
|
||||||
@@ -251,6 +284,8 @@ editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span cl
|
|||||||
<tr><th>事件</th><th>触发时机</th><th>参数</th></tr>
|
<tr><th>事件</th><th>触发时机</th><th>参数</th></tr>
|
||||||
<tr><td><code>input</code></td><td>textarea 输入</td><td><code>(value, editor)</code></td></tr>
|
<tr><td><code>input</code></td><td>textarea 输入</td><td><code>(value, editor)</code></td></tr>
|
||||||
<tr><td><code>change</code></td><td>内容变化</td><td><code>(value, editor)</code></td></tr>
|
<tr><td><code>change</code></td><td>内容变化</td><td><code>(value, editor)</code></td></tr>
|
||||||
|
<tr><td><code>beforeChange</code></td><td>内容变更前</td><td><code>(oldValue, newValue, editor)</code></td></tr>
|
||||||
|
<tr><td><code>afterChange</code></td><td>内容变更后</td><td><code>(newValue, editor)</code></td></tr>
|
||||||
<tr><td><code>focus</code> / <code>blur</code></td><td>聚焦/失焦</td><td><code>(editor)</code></td></tr>
|
<tr><td><code>focus</code> / <code>blur</code></td><td>聚焦/失焦</td><td><code>(editor)</code></td></tr>
|
||||||
<tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(editor)</code></td></tr>
|
<tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(editor)</code></td></tr>
|
||||||
<tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr>
|
<tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr>
|
||||||
@@ -258,25 +293,33 @@ editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span cl
|
|||||||
<tr><td><code>themeChange</code></td><td>主题变更</td><td><code>({ theme, resolved, config })</code></td></tr>
|
<tr><td><code>themeChange</code></td><td>主题变更</td><td><code>({ theme, resolved, config })</code></td></tr>
|
||||||
<tr><td><code>localeChange</code></td><td>语言变更</td><td><code>({ locale, direction })</code></td></tr>
|
<tr><td><code>localeChange</code></td><td>语言变更</td><td><code>({ locale, direction })</code></td></tr>
|
||||||
<tr><td><code>zenChange</code></td><td>Zen 切换</td><td><code>(zen, editor)</code></td></tr>
|
<tr><td><code>zenChange</code></td><td>Zen 切换</td><td><code>(zen, editor)</code></td></tr>
|
||||||
|
<tr><td><code>selectionChange</code></td><td>选区变化</td><td><code>({ start, end, text })</code></td></tr>
|
||||||
|
<tr><td><code>cursorMove</code></td><td>光标移动</td><td><code>({ line, column })</code></td></tr>
|
||||||
<tr><td><code>destroy</code></td><td>销毁</td><td><code>()</code></td></tr>
|
<tr><td><code>destroy</code></td><td>销毁</td><td><code>()</code></td></tr>
|
||||||
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>渲染前后</td><td><code>(editor)</code></td></tr>
|
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>渲染前后</td><td><code>(editor)</code></td></tr>
|
||||||
<tr><td><code>autosave</code></td><td>autoSave 触发</td><td><code>({ key, value })</code></td></tr>
|
<tr><td><code>autosave</code></td><td>autoSave 触发</td><td><code>({ key, value })</code></td></tr>
|
||||||
<tr><td><code>linkClick</code></td><td>预览链接点击</td><td><code>({ href, text })</code></td></tr>
|
<tr><td><code>linkClick</code></td><td>预览链接点击</td><td><code>({ href, text })</code></td></tr>
|
||||||
|
<tr><td><code>copy</code></td><td>复制到剪贴板</td><td><code>({ type })</code></td></tr>
|
||||||
|
<tr><td><code>fileOpened</code></td><td>fileSystem 打开文件</td><td><code>({ name, handle }, editor)</code></td></tr>
|
||||||
|
<tr><td><code>fileSaved</code></td><td>fileSystem 保存文件</td><td><code>({ handle }, editor)</code></td></tr>
|
||||||
</table>
|
</table>
|
||||||
<h4>全局钩子</h4>
|
<h4>全局钩子</h4>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>钩子</th><th>触发时机</th></tr>
|
<tr><th>钩子</th><th>触发时机</th></tr>
|
||||||
<tr><td><code>beforeCreate</code> / <code>afterCreate</code></td><td>实例构造前后</td></tr>
|
<tr><td><code>beforeCreate</code> / <code>afterCreate</code></td><td>实例构造前后</td></tr>
|
||||||
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>全局渲染前后</td></tr>
|
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>全局渲染前后</td></tr>
|
||||||
|
<tr><td><code>beforeChange</code> / <code>afterChange</code></td><td>内容变更前后(全局)</td></tr>
|
||||||
<tr><td><code>beforeDestroy</code> / <code>afterDestroy</code></td><td>销毁前后</td></tr>
|
<tr><td><code>beforeDestroy</code> / <code>afterDestroy</code></td><td>销毁前后</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="api-plugin"><h3>插件管理</h3>
|
<section id="api-plugin"><h3>插件管理 / 工具栏管理</h3>
|
||||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装</span>
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">unuse</span><span class="c-punc">(</span>name<span class="c-punc">);</span> <span class="c-com">// 卸载</span>
|
editor<span class="c-punc">.</span><span class="c-fn">unuse</span><span class="c-punc">(</span>name<span class="c-punc">);</span> <span class="c-com">// 卸载</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 已安装列表</span>
|
editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 已安装列表</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加按钮</span></pre>
|
editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加按钮</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">configureToolbar</span><span class="c-punc">(</span><span class="c-punc">[</span><span class="c-str">'bold'</span><span class="c-punc">,</span> <span class="c-str">'italic'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'undo'</span><span class="c-punc">]);</span> <span class="c-com">// 运行时重建工具栏</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">removeToolbarButton</span><span class="c-punc">(</span><span class="c-str">'hr'</span><span class="c-punc">);</span> <span class="c-com">// 移除单个按钮</span></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="api-shortcuts"><h3>快捷键管理</h3>
|
<section id="api-shortcuts"><h3>快捷键管理</h3>
|
||||||
@@ -288,6 +331,7 @@ editor<span class="c-punc">.</span><span class="c-fn">getShortcuts</span><span c
|
|||||||
<section id="api-destroy"><h3>生命周期 / Toast</h3>
|
<section id="api-destroy"><h3>生命周期 / Toast</h3>
|
||||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span> <span class="c-fn">isDestroyed</span><span class="c-punc">();</span>
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span> <span class="c-fn">isDestroyed</span><span class="c-punc">();</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">toast</span><span class="c-punc">(</span>msg<span class="c-punc">,</span> <span class="c-punc">{</span> type<span class="c-punc">,</span> duration<span class="c-punc">,</span> animation <span class="c-punc">});</span></pre>
|
editor<span class="c-punc">.</span><span class="c-fn">toast</span><span class="c-punc">(</span>msg<span class="c-punc">,</span> <span class="c-punc">{</span> type<span class="c-punc">,</span> duration<span class="c-punc">,</span> animation <span class="c-punc">});</span></pre>
|
||||||
|
<p>toast 动画支持 <code>fade</code> / <code>slide</code> / <code>scale</code> / <code>bounce</code> / <code>flip</code> / <code>rotate</code> / <code>zoom</code> 七种,<code>duration: 0</code> 不自动消失。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 静态 API -->
|
<!-- 静态 API -->
|
||||||
@@ -309,8 +353,37 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c
|
|||||||
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
|
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
|
||||||
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span>
|
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 状态查询</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span>
|
||||||
|
<span class="c-com">// => { version, theme, locale, globalPlugins, presetPlugins }</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 销毁全局资源(主题/语言监听、全局插件、全局钩子全面复位,之后 use() 仍可用)</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span>
|
||||||
|
|
||||||
<span class="c-com">// 解析器独立使用</span>
|
<span class="c-com">// 解析器独立使用</span>
|
||||||
<span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span></pre>
|
<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> normalizeLanguage<span class="c-punc">,</span> registerLanguage<span class="c-punc">,</span> getSupportedLanguages <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 语法高亮 -->
|
||||||
|
<section id="highlight"><h2>内置语法高亮</h2>
|
||||||
|
<p>v0.2.5 起内置零依赖轻量高亮器,支持 js / ts / tsx / jsx / python / bash / css / html / json / yaml / markdown / java / go / rust(含 shell / md 别名共 16 个语言标识)。</p>
|
||||||
|
<pre><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>
|
||||||
|
|
||||||
|
<span class="c-com">// 方式一:作为 highlight 钩子(代码块自动高亮)</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">,</span> <span class="c-punc">{</span> highlight<span class="c-punc">:</span> MeEditor<span class="c-punc">.</span><span class="c-fn">highlight</span> <span class="c-punc">});</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 方式二:独立调用</span>
|
||||||
|
<span class="c-kw">const</span> html <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">highlight</span><span class="c-punc">(</span>code<span class="c-punc">,</span> <span class="c-str">'typescript'</span><span class="c-punc">);</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 注册自定义语言</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><span class="c-punc">(</span><span class="c-str">'myLang'</span><span class="c-punc">,</span> <span class="c-punc">{</span> keywords<span class="c-punc">:</span> <span class="c-punc">[</span><span class="c-str">'kw'</span><span class="c-punc">]</span><span class="c-punc">,</span> builtins<span class="c-punc">:</span> <span class="c-punc">[]</span> <span class="c-punc">});</span></pre>
|
||||||
|
<p>高亮输出使用 <code>me-hl-keyword</code> / <code>me-hl-string</code> / <code>me-hl-comment</code> / <code>me-hl-number</code> / <code>me-hl-builtin</code> / <code>me-hl-function</code> 类。内置浅色 / 深色自适应配色,可通过 CSS 覆盖定制:</p>
|
||||||
|
<pre><span class="c-com">/* 自定义配色(覆盖内置默认) */</span>
|
||||||
|
<span class="c-kw">.me-preview</span> <span class="c-punc">.</span><span class="c-kw">me-hl-keyword</span><span class="c-punc">{</span> color<span class="c-punc">:</span> <span class="c-str">#a855f7</span> <span class="c-punc">}</span>
|
||||||
|
<span class="c-kw">.me-preview</span> <span class="c-punc">.</span><span class="c-kw">me-hl-string</span><span class="c-punc">{</span> color<span class="c-punc">:</span> <span class="c-str">#22c55e</span> <span class="c-punc">}</span></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 解析器语法 -->
|
<!-- 解析器语法 -->
|
||||||
@@ -323,6 +396,7 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span cl
|
|||||||
<tr><td>粗斜体 <span class="badge">v0.1.15</span></td><td><code>***text***</code> <code>___text___</code></td><td><em><strong></td></tr>
|
<tr><td>粗斜体 <span class="badge">v0.1.15</span></td><td><code>***text***</code> <code>___text___</code></td><td><em><strong></td></tr>
|
||||||
<tr><td>斜体</td><td><code>*italic*</code> <code>_italic_</code></td><td><em></td></tr>
|
<tr><td>斜体</td><td><code>*italic*</code> <code>_italic_</code></td><td><em></td></tr>
|
||||||
<tr><td>删除线 / 高亮</td><td><code>~~del~~</code> <code>==mark==</code></td><td><del> / <mark></td></tr>
|
<tr><td>删除线 / 高亮</td><td><code>~~del~~</code> <code>==mark==</code></td><td><del> / <mark></td></tr>
|
||||||
|
<tr><td>下划线 <span class="badge">v0.3.0</span></td><td><code><u>text</u></code>(裸标签透传)</td><td><u></td></tr>
|
||||||
<tr><td>上标 / 下标</td><td><code>x^2^</code> <code>H~2~O</code></td><td><sup> / <sub></td></tr>
|
<tr><td>上标 / 下标</td><td><code>x^2^</code> <code>H~2~O</code></td><td><sup> / <sub></td></tr>
|
||||||
<tr><td>行内代码</td><td><code>`code`</code></td><td><code></td></tr>
|
<tr><td>行内代码</td><td><code>`code`</code></td><td><code></td></tr>
|
||||||
<tr><td>代码块</td><td><code>```lang</code></td><td><pre><code></td></tr>
|
<tr><td>代码块</td><td><code>```lang</code></td><td><pre><code></td></tr>
|
||||||
@@ -334,6 +408,7 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span cl
|
|||||||
<tr><td>任务列表</td><td><code>- [x] done</code></td><td><li class="me-task-item"></td></tr>
|
<tr><td>任务列表</td><td><code>- [x] done</code></td><td><li class="me-task-item"></td></tr>
|
||||||
<tr><td>水平线</td><td><code>---</code> <code>***</code> <code>___</code></td><td><hr></td></tr>
|
<tr><td>水平线</td><td><code>---</code> <code>***</code> <code>___</code></td><td><hr></td></tr>
|
||||||
<tr><td>表格</td><td><code>| a | b |</code></td><td><table> + 列对齐</td></tr>
|
<tr><td>表格</td><td><code>| a | b |</code></td><td><table> + 列对齐</td></tr>
|
||||||
|
<tr><td>表格格式化 <span class="badge">v0.2.2</span></td><td><code>exec('formatTable')</code></td><td>列宽对齐</td></tr>
|
||||||
<tr><td>链接 <span class="badge">v0.1.15</span></td><td><code>[**t**](url)</code></td><td>文本内支持行内格式</td></tr>
|
<tr><td>链接 <span class="badge">v0.1.15</span></td><td><code>[**t**](url)</code></td><td>文本内支持行内格式</td></tr>
|
||||||
<tr><td>图片</td><td><code></code></td><td><img></td></tr>
|
<tr><td>图片</td><td><code></code></td><td><img></td></tr>
|
||||||
<tr><td>引用链接 <span class="badge">v0.2.2</span></td><td><code>[text][ref]</code> + <code>[ref]: url</code></td><td><a> 引用式</td></tr>
|
<tr><td>引用链接 <span class="badge">v0.2.2</span></td><td><code>[text][ref]</code> + <code>[ref]: url</code></td><td><a> 引用式</td></tr>
|
||||||
@@ -344,9 +419,11 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span cl
|
|||||||
<tr><td>定义列表</td><td><code>Term\n: def</code></td><td><dl><dt><dd></td></tr>
|
<tr><td>定义列表</td><td><code>Term\n: def</code></td><td><dl><dt><dd></td></tr>
|
||||||
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+)</td></tr>
|
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+)</td></tr>
|
||||||
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
|
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
|
||||||
|
<tr><td>HTML 注释</td><td><code><!-- note --></code></td><td>透传</td></tr>
|
||||||
|
<tr><td>实体引用保护</td><td><code>&amp;</code> <code>&#169;</code></td><td>不被二次转义</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<h4>XSS 防护</h4>
|
<h4>XSS 防护</h4>
|
||||||
<p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,提供 <code>sanitize</code> 钩子。</p>
|
<p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,属性级注入防护(<code>href</code> / <code>src</code> / <code>alt</code> / <code>title</code> / 脚注 id 引号转义),白名单裸标签 <code><u></code> 透传,<code>data:image</code> 限制最大 500KB,提供 <code>sanitize</code> 钩子(可接 DOMPurify 等),<code>highlight</code> 钩子异常自动回退纯文本。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="parser-api"><h2>解析器 API</h2>
|
<section id="parser-api"><h2>解析器 API</h2>
|
||||||
@@ -381,17 +458,31 @@ tokens<span class="c-punc">.</span><span class="c-fn">unshift</span><span class=
|
|||||||
editor<span class="c-punc">.</span><span class="c-fn">restoreDraft</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">clearDraft</span><span class="c-punc">();</span></pre>
|
editor<span class="c-punc">.</span><span class="c-fn">restoreDraft</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">clearDraft</span><span class="c-punc">();</span></pre>
|
||||||
<h4>exportTool</h4>
|
<h4>exportTool</h4>
|
||||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'exportTool'</span><span class="c-punc">);</span>
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'exportTool'</span><span class="c-punc">);</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span class="c-punc">(</span><span class="c-str">'doc.md'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">exportHTML</span><span class="c-punc">(</span><span class="c-str">'doc.html'</span><span class="c-punc">);</span></pre>
|
editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span class="c-punc">(</span><span class="c-str">'doc.md'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">exportHTML</span><span class="c-punc">(</span><span class="c-str">'doc.html'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">exportPDF</span><span class="c-punc">();</span></pre>
|
||||||
<h4>searchReplace</h4>
|
<h4>searchReplace</h4>
|
||||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'searchReplace'</span><span class="c-punc">);</span>
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'searchReplace'</span><span class="c-punc">);</span>
|
||||||
<span class="c-com">// Ctrl+F 查找 / Ctrl+H 替换</span></pre>
|
<span class="c-com">// Ctrl+F 查找 / Ctrl+H 替换,支持正则 .* 、大小写 Aa、全字 ab 开关</span></pre>
|
||||||
<h4>imagePaste</h4>
|
<h4>imagePaste</h4>
|
||||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'imagePaste'</span><span class="c-punc">);</span> <span class="c-com">// 粘贴图片自动转 base64</span></pre>
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'imagePaste'</span><span class="c-punc">,</span> <span class="c-punc">{</span> maxSizeKB<span class="c-punc">:</span> <span class="c-num">500</span> <span class="c-punc">});</span> <span class="c-com">// 粘贴图片转 base64,超限警告</span></pre>
|
||||||
<h4>shortcutHelp</h4>
|
<h4>shortcutHelp</h4>
|
||||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'shortcutHelp'</span><span class="c-punc">);</span> <span class="c-com">// 按 ? 弹出快捷键面板</span></pre>
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'shortcutHelp'</span><span class="c-punc">);</span> <span class="c-com">// 按 ? 弹出快捷键面板</span></pre>
|
||||||
<h4>fileSystem</h4>
|
<h4>fileSystem</h4>
|
||||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'fileSystem'</span><span class="c-punc">);</span>
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'fileSystem'</span><span class="c-punc">);</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">openFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFileAs</span><span class="c-punc">();</span></pre>
|
editor<span class="c-punc">.</span><span class="c-fn">openFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFileAs</span><span class="c-punc">();</span></pre>
|
||||||
|
|
||||||
|
<h3>PluginManager & pluginUtils</h3>
|
||||||
|
<pre><span class="c-kw">import</span> <span class="c-punc">{</span> PluginManager<span class="c-punc">,</span> pluginUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 独立插件注册表</span>
|
||||||
|
<span class="c-kw">const</span> pm <span class="c-punc">=</span> <span class="c-kw">new</span> <span class="c-fn">PluginManager</span><span class="c-punc">();</span>
|
||||||
|
pm<span class="c-punc">.</span><span class="c-fn">register</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">,</span> myPlugin<span class="c-punc">);</span>
|
||||||
|
pm<span class="c-punc">.</span><span class="c-fn">has</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">);</span> pm<span class="c-punc">.</span><span class="c-fn">get</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">);</span> pm<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 工具集(作用于默认全局管理器)</span>
|
||||||
|
pluginUtils<span class="c-punc">.</span><span class="c-fn">validatePlugin</span><span class="c-punc">(</span>plugin<span class="c-punc">);</span> <span class="c-com">// => { valid, errors }</span>
|
||||||
|
pluginUtils<span class="c-punc">.</span><span class="c-fn">createPlugin</span><span class="c-punc">({</span> name<span class="c-punc">:</span> <span class="c-str">'x'</span> <span class="c-punc">});</span> <span class="c-com">// 工厂函数,补齐默认字段</span>
|
||||||
|
pluginUtils<span class="c-punc">.</span><span class="c-fn">getPreset</span><span class="c-punc">(</span><span class="c-str">'autoSave'</span><span class="c-punc">);</span> <span class="c-com">// 预设副本(不污染原对象)</span>
|
||||||
|
pluginUtils<span class="c-punc">.</span><span class="c-fn">getAllPresets</span><span class="c-punc">();</span> <span class="c-com">// 全部预设副本</span></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 主题 -->
|
<!-- 主题 -->
|
||||||
@@ -402,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-com">// 注册自定义</span>
|
||||||
<span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
<span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||||
themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span> extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span> <span class="c-punc">});</span>
|
themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span> extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span> <span class="c-punc">});</span>
|
||||||
|
themeUtils<span class="c-punc">.</span><span class="c-fn">switchTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">);</span> <span class="c-com">// 全局切换到自定义主题</span>
|
||||||
|
|
||||||
<span class="c-com">// 外部跟随</span>
|
<span class="c-com">// 外部跟随</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">adopt</span><span class="c-punc">();</span> <span class="c-com">// 从父容器继承</span>
|
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">adopt</span><span class="c-punc">();</span> <span class="c-com">// 从父容器继承</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">syncWithElement</span><span class="c-punc">(</span>document<span class="c-punc">.</span><span class="c-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>
|
<span class="c-com">// CSS 变量导出</span>
|
||||||
themeUtils<span class="c-punc">.</span><span class="c-fn">exportCSSVars</span><span class="c-punc">();</span> themeUtils<span class="c-punc">.</span><span class="c-fn">getCSSVariable</span><span class="c-punc">(</span><span class="c-str">'accent'</span><span class="c-punc">);</span></pre>
|
themeUtils<span class="c-punc">.</span><span class="c-fn">exportCSSVars</span><span class="c-punc">();</span> themeUtils<span class="c-punc">.</span><span class="c-fn">getCSSVariable</span><span class="c-punc">(</span><span class="c-str">'accent'</span><span class="c-punc">);</span></pre>
|
||||||
@@ -422,9 +516,15 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">addTranslations</span><
|
|||||||
<span class="c-com">// 远程加载</span>
|
<span class="c-com">// 远程加载</span>
|
||||||
<span class="c-kw">await</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">loadRemote</span><span class="c-punc">(</span><span class="c-str">'/locales/ja.json'</span><span class="c-punc">,</span> <span class="c-str">'ja'</span><span class="c-punc">);</span>
|
<span class="c-kw">await</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">loadRemote</span><span class="c-punc">(</span><span class="c-str">'/locales/ja.json'</span><span class="c-punc">,</span> <span class="c-str">'ja'</span><span class="c-punc">);</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 实例级翻译(跟随实例 locale,状态栏/工具栏/插件面板均使用它)</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">);</span> <span class="c-com">// => '粗体'</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'imageTooLarge'</span><span class="c-punc">,</span> <span class="c-punc">{</span> size<span class="c-punc">:</span> <span class="c-num">800</span><span class="c-punc">,</span> max<span class="c-punc">:</span> <span class="c-num">500</span> <span class="c-punc">});</span> <span class="c-com">// 插值</span>
|
||||||
|
|
||||||
<span class="c-com">// 格式化</span>
|
<span class="c-com">// 格式化</span>
|
||||||
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span>
|
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span>
|
||||||
|
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatCurrency</span><span class="c-punc">(</span><span class="c-num">99.99</span><span class="c-punc">,</span> <span class="c-str">'USD'</span><span class="c-punc">);</span>
|
||||||
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span></pre>
|
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span></pre>
|
||||||
|
<p>RTL 语言(阿拉伯语、希伯来语等)自动应用从右到左布局;分屏分隔条拖拽比例按实例 id 持久化到 localStorage。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
@@ -433,7 +533,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
MetonaEditor v0.2.4 · 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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
+20
-16
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>"/>
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>"/>
|
||||||
<title>MetonaEditor v0.2.4 · TypeScript · 桌面端 Markdown 编辑器</title>
|
<title>MetonaEditor v0.4.3 · TypeScript · 桌面端 Markdown 编辑器</title>
|
||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-3:#ec4899;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%);--shadow:0 20px 50px -20px rgba(0,0,0,.5)}
|
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-3:#ec4899;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%);--shadow:0 20px 50px -20px rgba(0,0,0,.5)}
|
||||||
@@ -106,10 +106,10 @@ footer a:hover{text-decoration:underline}
|
|||||||
<h1>TypeScript 重构 · 零依赖<br/><span class="grad">桌面端 Markdown 编辑器</span></h1>
|
<h1>TypeScript 重构 · 零依赖<br/><span class="grad">桌面端 Markdown 编辑器</span></h1>
|
||||||
<p class="tagline">全模块 TypeScript 严格模式,内置自研解析器(99% 行覆盖率),6 个预设插件,中文优先。为桌面端现代 Web 应用而生。</p>
|
<p class="tagline">全模块 TypeScript 严格模式,内置自研解析器(99% 行覆盖率),6 个预设插件,中文优先。为桌面端现代 Web 应用而生。</p>
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
<span class="badge accent">v0.2.4</span>
|
<span class="badge accent">v0.4.3</span>
|
||||||
<span class="badge">TypeScript</span>
|
<span class="badge">TypeScript</span>
|
||||||
<span class="badge">零运行时依赖</span>
|
<span class="badge">零运行时依赖</span>
|
||||||
<span class="badge">725 tests</span>
|
<span class="badge">895 tests</span>
|
||||||
<span class="badge">桌面端优先</span>
|
<span class="badge">桌面端优先</span>
|
||||||
<span class="badge">MIT</span>
|
<span class="badge">MIT</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,8 +120,8 @@ footer a:hover{text-decoration:underline}
|
|||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div>
|
<div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div>
|
||||||
<div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div>
|
<div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div>
|
||||||
<div class="stat"><div class="stat-num">725</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">~30KB</div><div class="stat-label">gzip 体积</div></div>
|
<div class="stat"><div class="stat-num">~42KB</div><div class="stat-label">gzip 体积</div></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -144,8 +144,10 @@ 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"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></div><h3>分屏实时预览</h3><p>edit / split / preview 三模式切换,拖拽分隔条调整比例,双向比例同步滚动。</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"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></div><h3>分屏实时预览</h3><p>edit / split / preview 三模式切换,拖拽分隔条调整比例,双向比例同步滚动。</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 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div><h3>零运行时依赖</h3><p>不依赖任何第三方运行时库,打包后单文件,UMD / ESM / CJS 三种格式开箱即用。</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 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div><h3>零运行时依赖</h3><p>不依赖任何第三方运行时库,打包后单文件,UMD / ESM / CJS 三种格式开箱即用。</p></div>
|
||||||
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></div><h3>6 个预设插件</h3><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem,插件 v2 支持拓扑排序、异步、卸载。</p></div>
|
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></div><h3>6 个预设插件</h3><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem,插件 v2 支持拓扑排序、异步、卸载。</p></div>
|
||||||
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg></div><h3>主题 & i18n</h3><p>4 套预设主题 + CSS 变量定制 + 实例级隔离。中英双语开箱即用,支持远程加载翻译包。</p></div>
|
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"><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="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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -155,8 +157,8 @@ footer a:hover{text-decoration:underline}
|
|||||||
<span class="section-eyebrow">Quick Start</span>
|
<span class="section-eyebrow">Quick Start</span>
|
||||||
<h2 class="section-title">三行代码即可使用</h2>
|
<h2 class="section-title">三行代码即可使用</h2>
|
||||||
<p class="section-sub">npm / CDN / 本地文件 三种方式,TypeScript / ESM / CJS 全面支持。</p>
|
<p class="section-sub">npm / CDN / 本地文件 三种方式,TypeScript / ESM / CJS 全面支持。</p>
|
||||||
<div class="code-block"><span class="c-com">// 方式一:npm 安装(推荐)</span>
|
<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-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-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>
|
<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>
|
||||||
@@ -164,8 +166,8 @@ footer a:hover{text-decoration:underline}
|
|||||||
<span class="c-com">// CommonJS</span>
|
<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-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">// 方式二:Gitea raw 引入</span>
|
||||||
<span class="c-com"><script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.2.4/dist/metona-editor.min.js"></script></span>
|
<span class="c-com"><script src="https://git.metona.cn/MetonaTeam/MetonaEditor/raw/branch/master/dist/metona-editor.js"></script></span>
|
||||||
|
|
||||||
<span class="c-com">// 方式三:本地文件</span>
|
<span class="c-com">// 方式三:本地文件</span>
|
||||||
<span class="c-com"><script src="./dist/metona-editor.min.js"></script></span>
|
<span class="c-com"><script src="./dist/metona-editor.min.js"></script></span>
|
||||||
@@ -188,9 +190,10 @@ editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-
|
|||||||
<span class="section-eyebrow">API Reference</span>
|
<span class="section-eyebrow">API Reference</span>
|
||||||
<h2 class="section-title">API 速览</h2>
|
<h2 class="section-title">API 速览</h2>
|
||||||
<div class="api-grid">
|
<div class="api-grid">
|
||||||
<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>focus() / blur() <span class="ret">this</span></li></ul></div>
|
<div class="api-card"><h4>内容操作</h4><ul><li>getValue() <span class="ret">string</span></li><li>setValue(md) <span class="ret">this</span></li><li>getHTML() <span class="ret">string</span></li><li>insert(text) <span class="ret">this</span></li><li>wrap(before, after) <span class="ret">this</span></li><li>replaceAll(search, rep) <span class="ret">number</span></li></ul></div>
|
||||||
|
<div class="api-card"><h4>光标与选区</h4><ul><li>getSelectedText() <span class="ret">string</span></li><li>getCursorPosition() <span class="ret">{line,column}</span></li><li>setCursorPosition(l, c) <span class="ret">this</span></li><li>selectLine(n) / selectAll() <span class="ret">this</span></li><li>toggleFloatingToolbar() <span class="ret">this</span></li></ul></div>
|
||||||
<div class="api-card"><h4>命令执行</h4><ul><li>exec(action) <span class="ret">this</span></li><li>undo() / redo() <span class="ret">this</span></li><li>canUndo() / canRedo() <span class="ret">boolean</span></li></ul></div>
|
<div class="api-card"><h4>命令执行</h4><ul><li>exec(action) <span class="ret">this</span></li><li>undo() / redo() <span class="ret">this</span></li><li>canUndo() / canRedo() <span class="ret">boolean</span></li></ul></div>
|
||||||
<div class="api-card"><h4>模式与全屏</h4><ul><li>setMode(mode) <span class="ret">this</span></li><li>getMode() <span class="ret">string</span></li><li>toggleFullscreen() <span class="ret">this</span></li><li>toggleZen() <span class="ret">this</span></li></ul></div>
|
<div class="api-card"><h4>模式与全屏</h4><ul><li>setMode(mode) <span class="ret">this</span></li><li>getMode() <span class="ret">string</span></li><li>toggleFullscreen() <span class="ret">this</span></li><li>toggleZen() <span class="ret">this</span></li><li>setOutline(on) <span class="ret">this</span></li></ul></div>
|
||||||
<div class="api-card"><h4>事件与插件</h4><ul><li>on(event, fn) <span class="ret">unsub</span></li><li>off(event, fn) <span class="ret">this</span></li><li>use(plugin) <span class="ret">this</span></li><li>unuse(name) <span class="ret">this</span></li><li>addToolbarButton(cfg) <span class="ret">this</span></li></ul></div>
|
<div class="api-card"><h4>事件与插件</h4><ul><li>on(event, fn) <span class="ret">unsub</span></li><li>off(event, fn) <span class="ret">this</span></li><li>use(plugin) <span class="ret">this</span></li><li>unuse(name) <span class="ret">this</span></li><li>addToolbarButton(cfg) <span class="ret">this</span></li></ul></div>
|
||||||
<div class="api-card"><h4>主题与语言</h4><ul><li>setTheme(name) <span class="ret">this</span></li><li>getTheme() <span class="ret">string</span></li><li>setLocale(loc) <span class="ret">this</span></li><li>getLocale() <span class="ret">string</span></li><li>t(key, params) <span class="ret">string</span></li></ul></div>
|
<div class="api-card"><h4>主题与语言</h4><ul><li>setTheme(name) <span class="ret">this</span></li><li>getTheme() <span class="ret">string</span></li><li>setLocale(loc) <span class="ret">this</span></li><li>getLocale() <span class="ret">string</span></li><li>t(key, params) <span class="ret">string</span></li></ul></div>
|
||||||
<div class="api-card"><h4>统计与状态</h4><ul><li>getStats() <span class="ret">Stats</span></li><li>getStatus() <span class="ret">Status</span></li><li>enable() / disable() <span class="ret">this</span></li><li>refresh() <span class="ret">this</span></li><li>destroy() <span class="ret">void</span></li></ul></div>
|
<div class="api-card"><h4>统计与状态</h4><ul><li>getStats() <span class="ret">Stats</span></li><li>getStatus() <span class="ret">Status</span></li><li>enable() / disable() <span class="ret">this</span></li><li>refresh() <span class="ret">this</span></li><li>destroy() <span class="ret">void</span></li></ul></div>
|
||||||
@@ -227,7 +230,7 @@ editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-
|
|||||||
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a>
|
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a>
|
||||||
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor/issues" target="_blank" rel="noopener">问题反馈</a>
|
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor/issues" target="_blank" rel="noopener">问题反馈</a>
|
||||||
</div>
|
</div>
|
||||||
<div>MetonaEditor v0.2.4 · TypeScript · MIT License · MetonaTeam</div>
|
<div>MetonaEditor v0.4.3 · TypeScript · MIT License · MetonaTeam</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -237,7 +240,7 @@ MeEditor.themes.switchTheme('dark');
|
|||||||
MeEditor.create('#editor', {
|
MeEditor.create('#editor', {
|
||||||
mode: 'split', height: 480, theme: 'dark',
|
mode: 'split', height: 480, theme: 'dark',
|
||||||
value: [
|
value: [
|
||||||
'# 欢迎使用 MetonaEditor v0.2.4',
|
'# 欢迎使用 MetonaEditor v0.4.3',
|
||||||
'',
|
'',
|
||||||
'一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。',
|
'一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。',
|
||||||
'',
|
'',
|
||||||
@@ -267,7 +270,7 @@ MeEditor.create('#editor', {
|
|||||||
'| --- | --- |',
|
'| --- | --- |',
|
||||||
'| **粗体** / *斜体* / ***粗斜体*** | 强调 |',
|
'| **粗体** / *斜体* / ***粗斜体*** | 强调 |',
|
||||||
'| `code` / ``` ```lang ``` | 代码 |',
|
'| `code` / ``` ```lang ``` | 代码 |',
|
||||||
'| [链接](url) /  | 链接 |',
|
'| [链接](https://example.com) /  | 链接 |',
|
||||||
'| $E=mc^2$ / $$\int$$ | 数学公式 |',
|
'| $E=mc^2$ / $$\int$$ | 数学公式 |',
|
||||||
'| - [x] 任务列表 | GFM |',
|
'| - [x] 任务列表 | GFM |',
|
||||||
'| :rocket: :fire: | Emoji 短码 |',
|
'| :rocket: :fire: | Emoji 短码 |',
|
||||||
@@ -277,6 +280,7 @@ MeEditor.create('#editor', {
|
|||||||
'- [x] TypeScript 重构完成',
|
'- [x] TypeScript 重构完成',
|
||||||
'- [x] 解析器覆盖率 99%',
|
'- [x] 解析器覆盖率 99%',
|
||||||
'- [x] 6 个预设插件',
|
'- [x] 6 个预设插件',
|
||||||
|
'- [x] Playwright 浏览器冒烟测试',
|
||||||
'- [ ] VS Code 扩展',
|
'- [ ] VS Code 扩展',
|
||||||
'',
|
'',
|
||||||
'> **提示**:按 `?` 查看快捷键 · 拖放文件到编辑区 · Ctrl+F 搜索 · Ctrl+H 替换',
|
'> **提示**:按 `?` 查看快捷键 · 拖放文件到编辑区 · Ctrl+F 搜索 · Ctrl+H 替换',
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Commands — editing command implementations
|
||||||
|
* @module commands
|
||||||
|
* @version 0.4.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { t as i18nT } from './i18n';
|
||||||
|
import type { MarkdownEditor } from './core';
|
||||||
|
|
||||||
|
// ============ Selection wrapping ============
|
||||||
|
|
||||||
|
export function wrapSelection(this: MarkdownEditor, before: string, after: string): void {
|
||||||
|
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
|
||||||
|
const selected = ta.value.slice(start, end); const text = selected || 'text';
|
||||||
|
const inserted = before + text + after;
|
||||||
|
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end); ta.focus();
|
||||||
|
if (selected) { ta.selectionStart = start + before.length; ta.selectionEnd = start + before.length + text.length; }
|
||||||
|
else { ta.selectionStart = ta.selectionEnd = start + before.length; }
|
||||||
|
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Line prefix toggling ============
|
||||||
|
|
||||||
|
export function toggleLinePrefix(this: MarkdownEditor, prefix: string): void {
|
||||||
|
const ta = this.textarea; const start = ta.selectionStart;
|
||||||
|
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const lineEndPos = ta.value.indexOf('\n', start); const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos;
|
||||||
|
const line = ta.value.slice(lineStart, lineEnd);
|
||||||
|
const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/);
|
||||||
|
let newLine: string;
|
||||||
|
if (existingMatch && existingMatch[0] === prefix) newLine = line.slice(prefix.length);
|
||||||
|
else if (existingMatch) newLine = prefix + line.slice(existingMatch[0].length);
|
||||||
|
else newLine = prefix + line;
|
||||||
|
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus();
|
||||||
|
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
|
||||||
|
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Block insertion ============
|
||||||
|
|
||||||
|
export function insertBlock(this: MarkdownEditor, text: string): void {
|
||||||
|
const ta = this.textarea; const start = ta.selectionStart;
|
||||||
|
const before = ta.value.slice(0, start); const needNL = before && !before.endsWith('\n');
|
||||||
|
const insert = (needNL ? '\n' : '') + text;
|
||||||
|
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd); ta.focus();
|
||||||
|
const pos = start + insert.length; ta.selectionStart = ta.selectionEnd = pos;
|
||||||
|
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) || 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;
|
||||||
|
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) || this.t('image') || 'image';
|
||||||
|
const url = 'https://';
|
||||||
|
const insert = ``;
|
||||||
|
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
|
||||||
|
ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length;
|
||||||
|
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) => `${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`;
|
||||||
|
insertBlock.call(this, '\n' + md);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Table auto-format ============
|
||||||
|
|
||||||
|
export function formatTable(this: MarkdownEditor): void {
|
||||||
|
const ta = this.textarea;
|
||||||
|
const start = ta.selectionStart;
|
||||||
|
const before = ta.value.substring(0, start);
|
||||||
|
const after = ta.value.substring(start);
|
||||||
|
const blockStart = before.lastIndexOf('\n\n');
|
||||||
|
const blockEnd = after.indexOf('\n\n');
|
||||||
|
const tableStart = blockStart === -1 ? 0 : blockStart + 2;
|
||||||
|
const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd;
|
||||||
|
const tableText = ta.value.substring(tableStart, tableEnd);
|
||||||
|
const lines = tableText.split('\n').filter((l) => l.includes('|'));
|
||||||
|
if (lines.length < 2) return;
|
||||||
|
const splitRow = (r: string) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
|
||||||
|
const allCells = lines.map(splitRow);
|
||||||
|
const colCount = Math.max(...allCells.map((c) => c.length));
|
||||||
|
const colWidths: number[] = Array(colCount).fill(3);
|
||||||
|
allCells.forEach((cells) => {
|
||||||
|
cells.forEach((cell, ci) => {
|
||||||
|
colWidths[ci] = Math.max(colWidths[ci], cell.trim().length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const pad = (s: string, w: number) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); };
|
||||||
|
const formatted = allCells.map((cells) => {
|
||||||
|
const padded = [];
|
||||||
|
for (let ci = 0; ci < colCount; ci++) {
|
||||||
|
padded.push(pad((cells[ci] || '').trim(), colWidths[ci]));
|
||||||
|
}
|
||||||
|
return '| ' + padded.join(' | ') + ' |';
|
||||||
|
});
|
||||||
|
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
|
||||||
|
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Prototype installation ============
|
||||||
|
|
||||||
|
export const installCommands = (proto: any): void => {
|
||||||
|
proto._wrapSelection = wrapSelection;
|
||||||
|
proto._toggleLinePrefix = toggleLinePrefix;
|
||||||
|
proto._insertBlock = insertBlock;
|
||||||
|
proto._insertLink = insertLink;
|
||||||
|
proto._insertImage = insertImage;
|
||||||
|
proto._insertTable = insertTable;
|
||||||
|
proto._formatTable = formatTable;
|
||||||
|
};
|
||||||
+5
-3
@@ -41,6 +41,7 @@ export interface EditorOptions {
|
|||||||
outline?: boolean;
|
outline?: boolean;
|
||||||
autoBrackets?: boolean;
|
autoBrackets?: boolean;
|
||||||
zenMode?: boolean;
|
zenMode?: boolean;
|
||||||
|
zenMaxWidth?: number | string | false;
|
||||||
wordWrap?: boolean;
|
wordWrap?: boolean;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
theme?: ThemeName;
|
theme?: ThemeName;
|
||||||
@@ -104,9 +105,9 @@ export const DEFAULT_TOOLBAR: ToolbarItem[] = [
|
|||||||
export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
|
export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
|
||||||
value: '',
|
value: '',
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
mode: 'split' as EditMode,
|
mode: 'split',
|
||||||
height: 400,
|
height: 400,
|
||||||
toolbar: DEFAULT_TOOLBAR as ToolbarItem[] | false,
|
toolbar: DEFAULT_TOOLBAR,
|
||||||
wordCount: true,
|
wordCount: true,
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
spellcheck: false,
|
spellcheck: false,
|
||||||
@@ -119,9 +120,10 @@ export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
|
|||||||
outline: false,
|
outline: false,
|
||||||
autoBrackets: true,
|
autoBrackets: true,
|
||||||
zenMode: false,
|
zenMode: false,
|
||||||
|
zenMaxWidth: 960,
|
||||||
wordWrap: true,
|
wordWrap: true,
|
||||||
maxLength: 0,
|
maxLength: 0,
|
||||||
theme: 'auto' as ThemeName,
|
theme: 'auto',
|
||||||
locale: 'zh-CN',
|
locale: 'zh-CN',
|
||||||
render: null,
|
render: null,
|
||||||
highlight: null,
|
highlight: null,
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Context Menu — right-click menu
|
||||||
|
* @module context-menu
|
||||||
|
* @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 {
|
||||||
|
this._contextMenuItems = items;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindContextMenu(this: MarkdownEditor): void {
|
||||||
|
if (!this.el) return;
|
||||||
|
const onContextMenu = (e: MouseEvent) => {
|
||||||
|
const existing = document.querySelector('.me-context-menu');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
this._showContextMenu(e);
|
||||||
|
};
|
||||||
|
this.el.addEventListener('contextmenu', onContextMenu);
|
||||||
|
this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showContextMenu(this: MarkdownEditor, e: MouseEvent): void {
|
||||||
|
e.preventDefault();
|
||||||
|
const ta = this.textarea;
|
||||||
|
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
|
||||||
|
const defaultItems: Array<{ label?: string; action?: string; shortcut?: string; sep?: boolean; disabled?: boolean; onClick?: () => void }> = [
|
||||||
|
{ label: 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: 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) {
|
||||||
|
items.push({ sep: true });
|
||||||
|
this._contextMenuItems.forEach((item) => items.push(item));
|
||||||
|
}
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'me-context-menu';
|
||||||
|
menu.style.left = e.clientX + 'px';
|
||||||
|
menu.style.top = e.clientY + 'px';
|
||||||
|
// Adjust if off-screen
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const rect = menu.getBoundingClientRect();
|
||||||
|
if (rect.right > window.innerWidth) menu.style.left = (e.clientX - rect.width) + 'px';
|
||||||
|
if (rect.bottom > window.innerHeight) menu.style.top = (e.clientY - rect.height) + 'px';
|
||||||
|
});
|
||||||
|
items.forEach((item) => {
|
||||||
|
if ((item as any).sep) { const sep = document.createElement('div'); sep.className = 'me-context-menu-sep'; menu.appendChild(sep); return; }
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'me-context-menu-item';
|
||||||
|
if (item.disabled) el.classList.add('me-disabled');
|
||||||
|
// 自定义菜单项的 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;
|
||||||
|
if (item.onClick) { item.onClick(); }
|
||||||
|
else if (item.action) this._execContextAction(item.action);
|
||||||
|
this._hideContextMenu();
|
||||||
|
});
|
||||||
|
menu.appendChild(el);
|
||||||
|
});
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
const close = (ev: Event) => {
|
||||||
|
if (!menu.contains(ev.target as Node)) { this._hideContextMenu(); }
|
||||||
|
};
|
||||||
|
document.addEventListener('click', close, { once: true });
|
||||||
|
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') this._hideContextMenu(); }, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideContextMenu(this: MarkdownEditor): void {
|
||||||
|
const menu = document.querySelector('.me-context-menu');
|
||||||
|
if (menu) menu.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function execContextAction(this: MarkdownEditor, action: string): void {
|
||||||
|
const ta = this.textarea;
|
||||||
|
if (!ta) return;
|
||||||
|
switch (action) {
|
||||||
|
case 'undo': this.undo(); break;
|
||||||
|
case 'redo': this.redo(); break;
|
||||||
|
case 'cut': document.execCommand('cut'); break;
|
||||||
|
case 'copy': document.execCommand('copy'); break;
|
||||||
|
case 'paste': {
|
||||||
|
// 现代浏览器出于安全限制,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Prototype installation ============
|
||||||
|
|
||||||
|
export const installContextMenu = (proto: any): void => {
|
||||||
|
proto.registerContextMenu = registerContextMenu;
|
||||||
|
proto._bindContextMenu = bindContextMenu;
|
||||||
|
proto._showContextMenu = showContextMenu;
|
||||||
|
proto._hideContextMenu = hideContextMenu;
|
||||||
|
proto._execContextAction = execContextAction;
|
||||||
|
};
|
||||||
+297
-398
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Floating Toolbar — selection format toolbar
|
||||||
|
* @module floating-toolbar
|
||||||
|
* @version 0.4.3
|
||||||
|
*
|
||||||
|
* 定位策略(v0.4.3 重做):mirror 镜像测量。
|
||||||
|
* 创建与 textarea 排版样式一致的隐藏镜像层,在选区锚点(文档序中点)处
|
||||||
|
* 插入零宽字符 marker,读取其 offsetLeft/offsetTop 得到像素级精确坐标,
|
||||||
|
* 彻底替代旧版按字符宽度估算的方案(CJK、软换行、tab 宽度、字体
|
||||||
|
* 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;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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ta = this.textarea;
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return;
|
||||||
|
const start = ta.selectionStart; const end = ta.selectionEnd;
|
||||||
|
if (start === end) { this._hideFloatingToolbar(); return; }
|
||||||
|
|
||||||
|
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar();
|
||||||
|
|
||||||
|
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 paneW = this.editorPane.getBoundingClientRect().width;
|
||||||
|
|
||||||
|
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; // 极窄面板兜底
|
||||||
|
|
||||||
|
bar.style.top = top + 'px';
|
||||||
|
bar.style.left = left + 'px';
|
||||||
|
bar.classList.add('me-visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hide = () => { this._hideFloatingToolbar(); };
|
||||||
|
|
||||||
|
// 滚动跟随: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);
|
||||||
|
};
|
||||||
|
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 坐标相对 mirror(position:absolute 的 offsetParent,border 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) {
|
||||||
|
if (this._floatingToolbar) {
|
||||||
|
if (this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
|
||||||
|
this._floatingToolbar = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFloatingToolbar(this: MarkdownEditor): boolean { return this._floatingEnabled; }
|
||||||
|
|
||||||
|
export function buildFloatingToolbar(this: MarkdownEditor): void {
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'me-float-toolbar';
|
||||||
|
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);
|
||||||
|
this._hideFloatingToolbar();
|
||||||
|
// Keep selection after exec(延迟回调时实例可能已销毁)
|
||||||
|
setTimeout(() => { if (!this._destroyed && this.textarea) this.textarea.focus(); }, 0);
|
||||||
|
});
|
||||||
|
bar.appendChild(btn);
|
||||||
|
});
|
||||||
|
this.editorPane.appendChild(bar);
|
||||||
|
this._floatingToolbar = bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideFloatingToolbar(this: MarkdownEditor): void {
|
||||||
|
if (this._floatingToolbar) {
|
||||||
|
this._floatingToolbar.classList.remove('me-visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Prototype installation ============
|
||||||
|
|
||||||
|
export const installFloatingToolbar = (proto: any): void => {
|
||||||
|
proto._initFloatingToolbar = initFloatingToolbar;
|
||||||
|
proto.toggleFloatingToolbar = toggleFloatingToolbar;
|
||||||
|
proto.isFloatingToolbar = isFloatingToolbar;
|
||||||
|
proto._buildFloatingToolbar = buildFloatingToolbar;
|
||||||
|
proto._hideFloatingToolbar = hideFloatingToolbar;
|
||||||
|
proto._measureSelectionAnchor = measureSelectionAnchor;
|
||||||
|
};
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment-agnostic escaping for tokenizer input.
|
||||||
|
* Unlike the `escapeHTML` util (whose quote handling differs between browser
|
||||||
|
* DOM and Node), we only escape `& < >` here so string/comment token rules
|
||||||
|
* behave identically in both environments. Quote characters are safe inside
|
||||||
|
* text nodes and do not need escaping.
|
||||||
|
*/
|
||||||
|
const escapeText = (s: string): string => {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Token rules ============
|
||||||
|
|
||||||
|
interface HighlightRule {
|
||||||
|
cls: string;
|
||||||
|
re: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LanguageDef {
|
||||||
|
keywords: string[];
|
||||||
|
builtins: string[];
|
||||||
|
hasBlocks?: boolean; // /* */ block comments
|
||||||
|
hasHashComments?: boolean; // # comments (python/bash)
|
||||||
|
hasTemplateStrings?: boolean; // `...` (js/ts)
|
||||||
|
singleQuoteStrings?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
const buildRules = (lang: LanguageDef): HighlightRule[] => {
|
||||||
|
const rules: HighlightRule[] = [];
|
||||||
|
|
||||||
|
// Comments (highest priority; protects keywords/strings inside)
|
||||||
|
if (lang.hasHashComments) {
|
||||||
|
rules.push({ cls: 'me-hl-comment', re: /#[^\n]*/y });
|
||||||
|
} else {
|
||||||
|
rules.push({ cls: 'me-hl-comment', re: /\/\/[^\n]*/y });
|
||||||
|
}
|
||||||
|
if (lang.hasBlocks) rules.push({ cls: 'me-hl-comment', re: /\/\*[\s\S]*?\*\//y });
|
||||||
|
|
||||||
|
// Strings (double + single quotes with backslash escapes)
|
||||||
|
const strRe = lang.singleQuoteStrings === false
|
||||||
|
? /"(?:\\.|[^"\\\n])*"/y
|
||||||
|
: /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/y;
|
||||||
|
rules.push({ cls: 'me-hl-string', re: strRe });
|
||||||
|
if (lang.hasTemplateStrings) rules.push({ cls: 'me-hl-string', re: /`(?:\\.|[^`\\])*`/y });
|
||||||
|
|
||||||
|
// Numbers
|
||||||
|
rules.push({ cls: 'me-hl-number', re: /\b0x[\da-fA-F_]+\b|\b\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?\b/y });
|
||||||
|
|
||||||
|
// Keywords
|
||||||
|
if (lang.keywords.length) {
|
||||||
|
rules.push({ cls: 'me-hl-keyword', re: new RegExp(`\\b(?:${lang.keywords.map(escapeRe).join('|')})\\b`, 'y') });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builtins / globals
|
||||||
|
if (lang.builtins.length) {
|
||||||
|
rules.push({ cls: 'me-hl-builtin', re: new RegExp(`\\b(?:${lang.builtins.map(escapeRe).join('|')})\\b`, 'y') });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function calls: identifier immediately followed by (
|
||||||
|
rules.push({ cls: 'me-hl-function', re: /[A-Za-z_$][\w$]*(?=\s*\()/y });
|
||||||
|
|
||||||
|
return rules;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenize = (text: string, rules: HighlightRule[]): string => {
|
||||||
|
let out = '';
|
||||||
|
let pos = 0;
|
||||||
|
const len = text.length;
|
||||||
|
while (pos < len) {
|
||||||
|
let matched = false;
|
||||||
|
for (const rule of rules) {
|
||||||
|
rule.re.lastIndex = pos;
|
||||||
|
const m = rule.re.exec(text);
|
||||||
|
if (m && m.index === pos && m[0].length > 0) {
|
||||||
|
out += `<span class="${rule.cls}">${m[0]}</span>`;
|
||||||
|
pos += m[0].length;
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
out += text[pos];
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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> = {
|
||||||
|
javascript: {
|
||||||
|
keywords: ['async','await','break','case','catch','class','const','continue','debugger','default','delete','do','else','export','extends','finally','for','from','function','get','if','import','in','instanceof','let','new','of','return','set','static','super','switch','this','throw','try','typeof','var','void','while','with','yield'],
|
||||||
|
builtins: ['console','document','window','globalThis','Math','JSON','Promise','Object','Array','String','Number','Boolean','Map','Set','Symbol','RegExp','Date','Error','parseInt','parseFloat','isNaN','setTimeout','setInterval','fetch','require','module','exports','process','Buffer'],
|
||||||
|
hasBlocks: true,
|
||||||
|
hasTemplateStrings: true,
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
keywords: ['abstract','any','as','async','await','boolean','break','case','catch','class','const','continue','debugger','declare','default','delete','do','else','enum','export','extends','finally','for','from','function','get','if','implements','import','in','infer','instanceof','interface','is','keyof','let','namespace','never','new','of','override','private','protected','public','readonly','return','satisfies','set','static','string','super','switch','symbol','this','throw','try','type','typeof','undefined','unique','unknown','var','void','while','with','yield'],
|
||||||
|
builtins: ['console','document','window','globalThis','Math','JSON','Promise','Object','Array','String','Number','Boolean','Map','Set','Symbol','RegExp','Date','Error','parseInt','parseFloat','setTimeout','setInterval','fetch','require','module','exports','process','Buffer'],
|
||||||
|
hasBlocks: true,
|
||||||
|
hasTemplateStrings: true,
|
||||||
|
},
|
||||||
|
jsx: {
|
||||||
|
keywords: ['async','await','break','case','catch','class','const','continue','default','do','else','export','extends','finally','for','from','function','if','import','in','instanceof','let','new','of','return','static','super','switch','this','throw','try','typeof','var','void','while','with','yield','useState','useEffect','useRef','useMemo','useCallback'],
|
||||||
|
builtins: ['console','document','window','Math','JSON','Promise','Object','Array','String','Number','Map','Set','React'],
|
||||||
|
hasBlocks: true,
|
||||||
|
hasTemplateStrings: true,
|
||||||
|
},
|
||||||
|
tsx: {
|
||||||
|
keywords: ['abstract','any','as','async','await','boolean','break','case','catch','class','const','continue','default','declare','do','else','enum','export','extends','finally','for','from','function','if','implements','import','in','infer','instanceof','interface','is','keyof','let','namespace','never','new','of','override','private','protected','public','readonly','return','static','string','super','switch','symbol','this','throw','try','type','typeof','undefined','unknown','var','void','while','with','yield','useState','useEffect','useRef'],
|
||||||
|
builtins: ['console','document','window','Math','JSON','Promise','Object','Array','String','Number','Map','Set','React'],
|
||||||
|
hasBlocks: true,
|
||||||
|
hasTemplateStrings: true,
|
||||||
|
},
|
||||||
|
python: {
|
||||||
|
keywords: ['and','as','assert','async','await','break','class','continue','def','del','elif','else','except','False','finally','for','from','global','if','import','in','is','lambda','None','nonlocal','not','or','pass','raise','return','True','try','while','with','yield'],
|
||||||
|
builtins: ['print','len','range','type','str','int','float','list','dict','set','tuple','bool','enumerate','zip','map','filter','sum','min','max','abs','round','open','input','repr','sorted','object','super','self'],
|
||||||
|
hasHashComments: true,
|
||||||
|
},
|
||||||
|
bash: {
|
||||||
|
keywords: ['if','then','else','elif','fi','for','while','until','do','done','case','esac','function','in','return','break','continue','export','local','readonly','set','unset','shift','source','alias','declare','echo','exit'],
|
||||||
|
builtins: ['echo','printf','cd','pwd','ls','mkdir','rm','cp','mv','cat','grep','sed','awk','curl','wget','npm','node','git','docker','sudo','find','xargs','tar','zip','unzip','chmod','chown','touch','head','tail','wc','sort','uniq','cut','tee','ps','kill','top','clear','history','man','which'],
|
||||||
|
hasHashComments: true,
|
||||||
|
singleQuoteStrings: false,
|
||||||
|
},
|
||||||
|
shell: { hasHashComments: true, keywords: ['if','then','else','elif','fi','for','while','do','done','case','in','function','return','break','continue','export','local','echo','exit'], builtins: ['echo','cd','pwd','ls','rm','cp','mv','cat','grep','sed','awk','curl','wget','git','sudo','find','chmod','chown','touch','head','tail','wc','sort','tee','ps','kill','clear','man','which'], singleQuoteStrings: false },
|
||||||
|
css: {
|
||||||
|
keywords: ['important','inherit','initial','unset','none','auto','absolute','relative','fixed','static','sticky','flex','grid','block','inline','inline-block','hidden','visible','bold','normal','italic','solid','dashed','dotted','transparent','center','left','right','top','bottom','wrap','nowrap','repeat','cover','contain','pointer','auto'],
|
||||||
|
builtins: ['var','calc','min','max','clamp','rgb','rgba','hsl','hsla','url','linear-gradient','radial-gradient','translate','scale','rotate','repeat','minmax','fit-content','attr','counter'],
|
||||||
|
hasBlocks: true,
|
||||||
|
singleQuoteStrings: false,
|
||||||
|
},
|
||||||
|
html: {
|
||||||
|
keywords: ['html','head','body','div','span','p','a','img','ul','ol','li','table','thead','tbody','tr','th','td','h1','h2','h3','h4','h5','h6','section','article','nav','header','footer','main','aside','form','input','button','select','option','textarea','label','script','style','link','meta','title','iframe','video','audio','canvas','svg','strong','em','code','pre','blockquote','br','hr','template'],
|
||||||
|
builtins: [],
|
||||||
|
singleQuoteStrings: false,
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
keywords: ['true','false','null'],
|
||||||
|
builtins: [],
|
||||||
|
singleQuoteStrings: false,
|
||||||
|
},
|
||||||
|
yaml: {
|
||||||
|
keywords: ['true','false','null','yes','no','on','off'],
|
||||||
|
builtins: [],
|
||||||
|
hasHashComments: true,
|
||||||
|
singleQuoteStrings: false,
|
||||||
|
},
|
||||||
|
markdown: {
|
||||||
|
keywords: [],
|
||||||
|
builtins: [],
|
||||||
|
hasHashComments: true,
|
||||||
|
singleQuoteStrings: false,
|
||||||
|
},
|
||||||
|
md: { keywords: [], builtins: [], hasHashComments: true, singleQuoteStrings: false },
|
||||||
|
java: {
|
||||||
|
keywords: ['abstract','boolean','break','byte','case','catch','char','class','const','continue','default','do','double','else','enum','extends','final','finally','float','for','goto','if','implements','import','instanceof','int','interface','long','native','new','package','private','protected','public','return','short','static','strictfp','super','switch','synchronized','this','throw','throws','transient','try','void','volatile','while','var'],
|
||||||
|
builtins: ['System','String','Integer','Double','Math','Object','Class','Exception','Thread','Arrays','List','ArrayList','Map','HashMap','Set','HashSet','Optional','Stream','Collectors','Objects'],
|
||||||
|
hasBlocks: true,
|
||||||
|
},
|
||||||
|
go: {
|
||||||
|
keywords: ['break','case','chan','const','continue','default','defer','else','fallthrough','for','func','go','goto','if','import','interface','map','package','range','return','select','struct','switch','type','var'],
|
||||||
|
builtins: ['fmt','len','cap','make','new','append','copy','panic','recover','error','string','int','int64','uint','bool','byte','rune','float64','nil','print','println','Close','Error'],
|
||||||
|
hasBlocks: true,
|
||||||
|
singleQuoteStrings: false,
|
||||||
|
},
|
||||||
|
rust: {
|
||||||
|
keywords: ['as','async','await','break','const','continue','crate','dyn','else','enum','extern','false','fn','for','if','impl','in','let','loop','match','mod','move','mut','pub','ref','return','self','Self','static','struct','super','trait','true','type','unsafe','use','where','while'],
|
||||||
|
builtins: ['println','print','eprintln','format','vec','String','str','Option','Result','Some','None','Ok','Err','Vec','Box','Rc','Arc','HashMap','Iterator','match'],
|
||||||
|
hasBlocks: true,
|
||||||
|
singleQuoteStrings: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Public API ============
|
||||||
|
|
||||||
|
const aliasMap: Record<string, string> = {
|
||||||
|
js: 'javascript', ts: 'typescript', tsx: 'tsx', jsx: 'jsx',
|
||||||
|
py: 'python', sh: 'bash', shell: 'shell', zsh: 'bash',
|
||||||
|
yml: 'yaml', mjs: 'javascript', cjs: 'javascript', md: 'markdown',
|
||||||
|
htm: 'html', c: 'go', cpp: 'go', cs: 'java',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map a code-block language hint to the canonical language name */
|
||||||
|
export const normalizeLanguage = (lang: string): string => {
|
||||||
|
const l = (lang || '').trim().toLowerCase();
|
||||||
|
if (!l) return '';
|
||||||
|
return aliasMap[l] || (LANGUAGES[l] ? l : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Highlight code with the built-in tokenizer. Falls back to escaped plain text. */
|
||||||
|
export const highlight = (code: string, lang: string): string => {
|
||||||
|
const canonical = normalizeLanguage(lang);
|
||||||
|
const def = LANGUAGES[canonical];
|
||||||
|
const escaped = escapeText(code);
|
||||||
|
if (!def) return escaped;
|
||||||
|
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);
|
||||||
|
|
||||||
|
export const highlightUtils = {
|
||||||
|
highlight, normalizeLanguage, registerLanguage, getSupportedLanguages,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default highlight;
|
||||||
+7
-1
@@ -27,7 +27,7 @@ const pluralRules: Record<string, PluralRule> = {
|
|||||||
|
|
||||||
const getPluralForm = (locale: string, count: number): string => {
|
const getPluralForm = (locale: string, count: number): string => {
|
||||||
const lang = locale.split('-')[0].toLowerCase();
|
const lang = locale.split('-')[0].toLowerCase();
|
||||||
const rule = pluralRules[lang] || pluralRules.en!;
|
const rule = pluralRules[lang] || pluralRules.en;
|
||||||
return rule(Math.abs(count));
|
return rule(Math.abs(count));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,6 +241,12 @@ export const createInstanceI18n = (editor: any): InstanceI18n => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 语言切换即时刷新已渲染的 UI(状态栏统计标签、大纲标题),无需等待下一次输入
|
||||||
|
if (typeof editor._updateWordCount === 'function') { try { editor._updateWordCount(); } catch (_) {} }
|
||||||
|
if (editor.el && typeof editor.el.querySelector === 'function') {
|
||||||
|
const outlineTitle = editor.el.querySelector('.me-outline-title');
|
||||||
|
if (outlineTitle) outlineTitle.textContent = instanceT('outline') || 'Outline';
|
||||||
|
}
|
||||||
return instanceLocale;
|
return instanceLocale;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor — Type-safe, lightweight Markdown Editor
|
* MetonaEditor — Type-safe, lightweight Markdown Editor
|
||||||
* @module metona-editor
|
* @module metona-editor
|
||||||
* @version 0.2.4
|
* @version 0.4.3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MarkdownEditor } from './core';
|
import { MarkdownEditor } from './core';
|
||||||
@@ -10,17 +10,22 @@ import { themeUtils, exportCSSVars, getCSSVariable, followExternalTheme, adoptFr
|
|||||||
import { i18nUtils, createInstanceI18n, loadRemote } from './i18n';
|
import { i18nUtils, createInstanceI18n, loadRemote } from './i18n';
|
||||||
import { pluginUtils, presetPlugins, topologicalSort, validateConfig } from './plugins';
|
import { pluginUtils, presetPlugins, topologicalSort, validateConfig } from './plugins';
|
||||||
import { animationUtils } from './animations';
|
import { animationUtils } from './animations';
|
||||||
|
import { highlight, normalizeLanguage, registerLanguage, getSupportedLanguages } from './highlight';
|
||||||
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants';
|
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants';
|
||||||
import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants';
|
import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants';
|
||||||
|
|
||||||
const VERSION = '0.2.4';
|
const VERSION = '0.4.3';
|
||||||
|
|
||||||
const globalPlugins: any[] = [];
|
const globalPlugins: any[] = [];
|
||||||
|
|
||||||
MarkdownEditor.on('beforeCreate', (editor) => {
|
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
|
||||||
|
// (searchReplace / shortcutHelp / imagePaste)才能正常生效。
|
||||||
|
const injectGlobalPlugins = (editor: MarkdownEditor): void => {
|
||||||
if (!editor || editor.isDestroyed()) return;
|
if (!editor || editor.isDestroyed()) return;
|
||||||
globalPlugins.forEach((p) => { try { editor.use(p); } catch (e) { console.error('MeEditor global plugin install error:', e); } });
|
globalPlugins.forEach((p) => { try { editor.use(p); } catch (e) { console.error('MeEditor global plugin install error:', e); } });
|
||||||
});
|
};
|
||||||
|
|
||||||
|
MarkdownEditor.on('afterCreate', injectGlobalPlugins);
|
||||||
|
|
||||||
function create(container: string | HTMLElement, options: EditorOptions = {}): MarkdownEditor {
|
function create(container: string | HTMLElement, options: EditorOptions = {}): MarkdownEditor {
|
||||||
return new MarkdownEditor(container, options);
|
return new MarkdownEditor(container, options);
|
||||||
@@ -45,6 +50,10 @@ function destroy(): void {
|
|||||||
if (i18nUtils) { try { i18nUtils.clearLocaleListeners(); } catch (_) {} }
|
if (i18nUtils) { try { i18nUtils.clearLocaleListeners(); } catch (_) {} }
|
||||||
if (animationUtils) { try { animationUtils.cancelAll(); } catch (_) {} }
|
if (animationUtils) { try { animationUtils.cancelAll(); } catch (_) {} }
|
||||||
globalPlugins.length = 0;
|
globalPlugins.length = 0;
|
||||||
|
// 清理全局静态钩子(beforeCreate/afterCreate/beforeChange 等),整体复位;
|
||||||
|
// 随后重建内部 afterCreate 插件注入钩子,保证 destroy 后 MeEditor.use() 仍可用。
|
||||||
|
try { MarkdownEditor._hooks.clear(); } catch (_) {}
|
||||||
|
MarkdownEditor.on('afterCreate', injectGlobalPlugins);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatus() {
|
function getStatus() {
|
||||||
@@ -61,6 +70,7 @@ const api = {
|
|||||||
MarkdownEditor, Editor: MarkdownEditor,
|
MarkdownEditor, Editor: MarkdownEditor,
|
||||||
create, use, on, off, setTheme, setLocale, destroy, getStatus,
|
create, use, on, off, setTheme, setLocale, destroy, getStatus,
|
||||||
parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler,
|
parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler,
|
||||||
|
highlight, normalizeLanguage, registerLanguage, getSupportedLanguages,
|
||||||
themes: themeUtils, i18n: i18nUtils, animations: animationUtils, plugins: pluginUtils, presetPlugins,
|
themes: themeUtils, i18n: i18nUtils, animations: animationUtils, plugins: pluginUtils, presetPlugins,
|
||||||
topologicalSort, validateConfig, createInstanceI18n, loadRemote,
|
topologicalSort, validateConfig, createInstanceI18n, loadRemote,
|
||||||
exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme,
|
exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme,
|
||||||
@@ -70,4 +80,5 @@ const api = {
|
|||||||
if (typeof window !== 'undefined') { (window as any).MeEditor = api; }
|
if (typeof window !== 'undefined') { (window as any).MeEditor = api; }
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
export { api, api as meEditor, api as MeEditor, MarkdownEditor, MarkdownEditor as Editor, create, use, on, off, setTheme, setLocale, destroy, getStatus, parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler, themeUtils, i18nUtils, pluginUtils, presetPlugins, animationUtils, topologicalSort, validateConfig, createInstanceI18n, loadRemote, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme, DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS, VERSION, };
|
export { api, api as meEditor, api as MeEditor, MarkdownEditor, MarkdownEditor as Editor, create, use, on, off, setTheme, setLocale, destroy, getStatus, parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler, highlight, normalizeLanguage, registerLanguage, getSupportedLanguages, themeUtils, i18nUtils, pluginUtils, presetPlugins, animationUtils, topologicalSort, validateConfig, createInstanceI18n, loadRemote, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme, DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS, VERSION, };
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
|||||||
renderError: '渲染失败',
|
renderError: '渲染失败',
|
||||||
outline: '大纲',
|
outline: '大纲',
|
||||||
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
|
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
|
||||||
|
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB)',
|
||||||
|
formatToolbar: '格式化选区', pasteBlocked: '无法直接粘贴,请使用 Ctrl+V',
|
||||||
},
|
},
|
||||||
'en-US': {
|
'en-US': {
|
||||||
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
|
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
|
||||||
@@ -56,6 +58,8 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
|||||||
renderError: 'Render failed',
|
renderError: 'Render failed',
|
||||||
outline: 'Outline',
|
outline: 'Outline',
|
||||||
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
|
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: {
|
ja: {
|
||||||
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
|
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
|
||||||
@@ -82,6 +86,8 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
|||||||
renderError: 'レンダリング失敗',
|
renderError: 'レンダリング失敗',
|
||||||
outline: 'アウトライン',
|
outline: 'アウトライン',
|
||||||
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
|
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
|
||||||
|
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB)',
|
||||||
|
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
|
||||||
},
|
},
|
||||||
ko: {
|
ko: {
|
||||||
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
|
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
|
||||||
@@ -108,6 +114,8 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
|||||||
renderError: '렌더링 실패',
|
renderError: '렌더링 실패',
|
||||||
outline: '개요',
|
outline: '개요',
|
||||||
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
|
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
|
||||||
|
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
|
||||||
|
formatToolbar: '선택 영역 서식', pasteBlocked: '직접 붙여넣을 수 없습니다. Ctrl+V를 사용하세요',
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
|
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
|
||||||
@@ -134,6 +142,8 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
|||||||
renderError: 'Échec du rendu',
|
renderError: 'Échec du rendu',
|
||||||
outline: 'Plan',
|
outline: 'Plan',
|
||||||
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
|
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: {
|
de: {
|
||||||
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
|
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
|
||||||
@@ -160,5 +170,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
|||||||
renderError: 'Rendern fehlgeschlagen',
|
renderError: 'Rendern fehlgeschlagen',
|
||||||
outline: 'Gliederung',
|
outline: 'Gliederung',
|
||||||
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
|
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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Outline — heading navigation panel
|
||||||
|
* @module outline
|
||||||
|
* @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; 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, ''), srcLine: -1 });
|
||||||
|
}
|
||||||
|
if (!headings.length) return;
|
||||||
|
|
||||||
|
// 计算每个渲染标题在源码中的行号(跳过围栏代码块内部),
|
||||||
|
// 点击时按行号精确定位光标,重复标题文本也不会串位。
|
||||||
|
const srcLines: number[] = [];
|
||||||
|
const lines = this._value.split('\n');
|
||||||
|
let inFence = false; let fenceChar = '';
|
||||||
|
const isSetextText = (l: string): boolean =>
|
||||||
|
/\S/.test(l) && !/^\s{0,3}([-*+]|\d+\.)\s/.test(l) && !/^\s{0,3}>/.test(l) && !/^\s{0,3}(#{1,6}\s|`{3,}|~{3,})/.test(l);
|
||||||
|
for (let li = 0; li < lines.length; li++) {
|
||||||
|
const l = lines[li];
|
||||||
|
const fence = l.match(/^\s{0,3}(`{3,}|~{3,})/);
|
||||||
|
if (fence) {
|
||||||
|
if (!inFence) { inFence = true; fenceChar = fence[1][0]; }
|
||||||
|
else if (fence[1][0] === fenceChar) inFence = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence) continue;
|
||||||
|
if (/^ {0,3}(?:>\s*)*#{1,6}\s+\S/.test(l)) { srcLines.push(li); continue; }
|
||||||
|
const next = lines[li + 1] || '';
|
||||||
|
if (li + 1 < lines.length && /^ {0,3}(={3,}|-{3,})\s*$/.test(next) && isSetextText(l)) srcLines.push(li);
|
||||||
|
}
|
||||||
|
headings.forEach((h, i) => { h.srcLine = i < srcLines.length ? srcLines[i] : -1; });
|
||||||
|
|
||||||
|
const panel = document.createElement('div'); panel.className = 'me-outline';
|
||||||
|
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('#' + 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); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOutline(this: MarkdownEditor): void {
|
||||||
|
if (!this.config.outline) return;
|
||||||
|
if (this._outlineTimer) clearTimeout(this._outlineTimer);
|
||||||
|
this._outlineTimer = setTimeout(() => this._buildOutline(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackOutlineScroll(this: MarkdownEditor): void {
|
||||||
|
if (!this.config.outline || !this.previewPane) return;
|
||||||
|
// 防重复绑定:构造时绑定过、setOutline(true) 再次调用时跳过
|
||||||
|
if ((this as any)._outlineScrollBound) return;
|
||||||
|
(this as any)._outlineScrollBound = true;
|
||||||
|
let ticking = false;
|
||||||
|
const onScroll = () => {
|
||||||
|
if (ticking) return;
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ticking = false;
|
||||||
|
if (!this.el) return;
|
||||||
|
const panel = this.el.querySelector('.me-outline');
|
||||||
|
if (!panel) return;
|
||||||
|
const headings = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||||
|
let activeId = '';
|
||||||
|
const scrollTop = this.previewPane.scrollTop + 80; // offset for better UX
|
||||||
|
headings.forEach((h) => {
|
||||||
|
if ((h as HTMLElement).offsetTop <= scrollTop) {
|
||||||
|
activeId = h.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
panel.querySelectorAll('a').forEach((a) => {
|
||||||
|
a.classList.toggle('me-outline-active', a.getAttribute('href') === '#' + activeId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
this.previewPane.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
this._cleanups.push(() => this.previewPane.removeEventListener('scroll', onScroll));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Prototype installation ============
|
||||||
|
|
||||||
|
export const installOutline = (proto: any): void => {
|
||||||
|
proto._buildOutline = buildOutline;
|
||||||
|
proto._updateOutline = updateOutline;
|
||||||
|
proto._trackOutlineScroll = trackOutlineScroll;
|
||||||
|
};
|
||||||
+67
-23
@@ -4,7 +4,7 @@
|
|||||||
* @version 0.2.0
|
* @version 0.2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { escapeHTML } from './utils';
|
import { escapeHTML, escapeAttr } from './utils';
|
||||||
import type { RenderEnv } from './constants';
|
import type { RenderEnv } from './constants';
|
||||||
|
|
||||||
// ============ Types ============
|
// ============ Types ============
|
||||||
@@ -99,17 +99,34 @@ const EMOJI_MAP: Record<string, string> = {
|
|||||||
const renderCache = new Map<string, string>();
|
const renderCache = new Map<string, string>();
|
||||||
const MAX_CACHE_SIZE = 300;
|
const MAX_CACHE_SIZE = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 引用链接 `[x][r]` 的解析依赖 env.refs,若仅以文本为缓存 key,
|
||||||
|
* 不同文档中相同文本会命中彼此的缓存导致链接串数据。
|
||||||
|
* 故缓存 key 附加 refs 指纹(parseMarkdown 预计算 `_refsFp`,
|
||||||
|
* 直接调用 renderTokens 时兜底现场计算)。
|
||||||
|
*/
|
||||||
|
const refsFingerprint = (env: RenderEnv): string => {
|
||||||
|
let fp = (env as any)._refsFp;
|
||||||
|
if (fp === undefined) {
|
||||||
|
const keys = env.refs ? Object.keys(env.refs) : [];
|
||||||
|
fp = keys.length > 0 ? JSON.stringify(env.refs) : '';
|
||||||
|
}
|
||||||
|
return fp || '';
|
||||||
|
};
|
||||||
|
|
||||||
const cachedRenderInline = (text: string, env: RenderEnv): string => {
|
const cachedRenderInline = (text: string, env: RenderEnv): string => {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
if (!env.highlight && text.length < 600) {
|
if (!env.highlight && text.length < 600) {
|
||||||
const cached = renderCache.get(text);
|
const fp = refsFingerprint(env);
|
||||||
|
const key = fp ? text + '\u0001' + fp : text;
|
||||||
|
const cached = renderCache.get(key);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
const result = renderInline(text, env);
|
const result = renderInline(text, env);
|
||||||
if (renderCache.size >= MAX_CACHE_SIZE) {
|
if (renderCache.size >= MAX_CACHE_SIZE) {
|
||||||
const firstKey = renderCache.keys().next().value!;
|
const firstKey = renderCache.keys().next().value!;
|
||||||
renderCache.delete(firstKey);
|
renderCache.delete(firstKey);
|
||||||
}
|
}
|
||||||
renderCache.set(text, result);
|
renderCache.set(key, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
return renderInline(text, env);
|
return renderInline(text, env);
|
||||||
@@ -117,6 +134,9 @@ const cachedRenderInline = (text: string, env: RenderEnv): string => {
|
|||||||
|
|
||||||
const clearRenderCache = (): void => { renderCache.clear(); };
|
const clearRenderCache = (): void => { renderCache.clear(); };
|
||||||
|
|
||||||
|
/** 当前行内渲染缓存条目数(FIFO 上限 MAX_CACHE_SIZE),供观测/测试缓存淘汰 */
|
||||||
|
const getRenderCacheSize = (): number => renderCache.size;
|
||||||
|
|
||||||
// ============ Security utils ============
|
// ============ Security utils ============
|
||||||
|
|
||||||
const safeUrl = (url: string | null | undefined): string => {
|
const safeUrl = (url: string | null | undefined): string => {
|
||||||
@@ -137,6 +157,25 @@ const slugify = (text: string): string => {
|
|||||||
return slug || 'heading';
|
return slug || 'heading';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Restore backslash-escaped punctuation in title text: \" -> " */
|
||||||
|
const unescapePunct = (text: string): string => text.replace(/\\([!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])/g, '$1');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe double-quoted attribute value. Inline-sourced text is already HTML-escaped
|
||||||
|
* (entities are safe inside quoted attributes and decode correctly), so only the
|
||||||
|
* quote character that would terminate the attribute needs escaping.
|
||||||
|
*/
|
||||||
|
const attrSafe = (v: string): string => v.replace(/"/g, '"').replace(/\r?\n/g, ' ');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as `attrSafe` but also restores backslash-escaped punctuation first
|
||||||
|
* (used for title attributes where \" is a markdown escape).
|
||||||
|
*/
|
||||||
|
const titleAttr = (title: string | undefined): string => {
|
||||||
|
if (!title) return '';
|
||||||
|
return ` title="${unescapePunct(title).replace(/"/g, '"').replace(/\r?\n/g, ' ')}"`;
|
||||||
|
};
|
||||||
|
|
||||||
// ============ Block handler registry ============
|
// ============ Block handler registry ============
|
||||||
|
|
||||||
const blockHandlers: BlockHandler[] = [];
|
const blockHandlers: BlockHandler[] = [];
|
||||||
@@ -382,7 +421,8 @@ const renderTokens = (tokens: Token[], env: RenderEnv = {}, footnotes: Record<st
|
|||||||
if (fnIds.length) {
|
if (fnIds.length) {
|
||||||
html += '\n<div class="me-footnotes"><hr/><ol>';
|
html += '\n<div class="me-footnotes"><hr/><ol>';
|
||||||
fnIds.forEach((id) => {
|
fnIds.forEach((id) => {
|
||||||
html += `<li id="fn-${id}" class="me-footnote-item"><a href="#fnref-${id}" class="me-footnote-backref">↩</a> ${cachedRenderInline(footnotes[id], env)}</li>`;
|
const safeId = attrSafe(id);
|
||||||
|
html += `<li id="fn-${safeId}" class="me-footnote-item"><a href="#fnref-${safeId}" class="me-footnote-backref">↩</a> ${cachedRenderInline(footnotes[id], env)}</li>`;
|
||||||
});
|
});
|
||||||
html += '</ol></div>';
|
html += '</ol></div>';
|
||||||
}
|
}
|
||||||
@@ -391,7 +431,9 @@ const renderTokens = (tokens: Token[], env: RenderEnv = {}, footnotes: Record<st
|
|||||||
|
|
||||||
const parseMarkdown = (md: string | null | undefined, env: RenderEnv = {}): string => {
|
const parseMarkdown = (md: string | null | undefined, env: RenderEnv = {}): string => {
|
||||||
const { tokens, footnotes, refs } = parseTokens(md);
|
const { tokens, footnotes, refs } = parseTokens(md);
|
||||||
return renderTokens(tokens, { ...env, refs }, footnotes);
|
const resolvedEnv: RenderEnv = { ...env, refs };
|
||||||
|
if (refs && Object.keys(refs).length > 0) (resolvedEnv as any)._refsFp = JSON.stringify(refs);
|
||||||
|
return renderTokens(tokens, resolvedEnv, footnotes);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ List parsing ============
|
// ============ List parsing ============
|
||||||
@@ -449,9 +491,9 @@ const parseList = (
|
|||||||
const nOl = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`));
|
const nOl = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`));
|
||||||
if (nUl || nOl) {
|
if (nUl || nOl) {
|
||||||
const isNOl = !!nOl;
|
const isNOl = !!nOl;
|
||||||
const nM = isNOl ? nOl![2] : nUl![2];
|
const nM = isNOl ? nOl[2] : nUl![2];
|
||||||
const nI = isNOl ? nOl![1].length : nUl![1].length;
|
const nI = isNOl ? nOl[1].length : nUl![1].length;
|
||||||
const nS = isNOl ? parseInt(nOl![2], 10) : undefined;
|
const nS = isNOl ? parseInt(nOl[2], 10) : undefined;
|
||||||
const nested = parseList(lines, i, nM, nI, isNOl, nS);
|
const nested = parseList(lines, i, nM, nI, isNOl, nS);
|
||||||
subTokens.push({ type: isNOl ? 'ol' : 'ul', items: nested.items, start: nS });
|
subTokens.push({ type: isNOl ? 'ol' : 'ul', items: nested.items, start: nS });
|
||||||
i = nested.endIdx;
|
i = nested.endIdx;
|
||||||
@@ -538,8 +580,8 @@ const renderListItem = (it: ListItem, env: RenderEnv, idx?: number): string => {
|
|||||||
|
|
||||||
const renderCode = (code: string, lang: string, env: RenderEnv, attrs?: Record<string, string>): string => {
|
const renderCode = (code: string, lang: string, env: RenderEnv, attrs?: Record<string, string>): string => {
|
||||||
if (lang === 'mermaid') return `<div class="me-mermaid"><pre class="mermaid">${escapeHTML(code)}</pre></div>`;
|
if (lang === 'mermaid') return `<div class="me-mermaid"><pre class="mermaid">${escapeHTML(code)}</pre></div>`;
|
||||||
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : '';
|
const langClass = lang ? ` class="language-${escapeAttr(lang)}"` : '';
|
||||||
const titleHtml = attrs?.title ? `<div class="me-code-title">${escapeHTML(attrs.title)}</div>` : '';
|
const titleHtml = attrs?.title ? `<div class="me-code-title">${escapeAttr(attrs.title)}</div>` : '';
|
||||||
if (env.highlight && typeof env.highlight === 'function' && lang) {
|
if (env.highlight && typeof env.highlight === 'function' && lang) {
|
||||||
try {
|
try {
|
||||||
const highlighted = env.highlight(code, lang);
|
const highlighted = env.highlight(code, lang);
|
||||||
@@ -596,6 +638,11 @@ const renderInline = (text: string, env: RenderEnv): string => {
|
|||||||
s = s.replace(/<!--[\s\S]*?-->/g, (m) => {
|
s = s.replace(/<!--[\s\S]*?-->/g, (m) => {
|
||||||
protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`;
|
protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`;
|
||||||
});
|
});
|
||||||
|
// 白名单裸标签透传:仅 `<u>` / `</u>`(无任何属性),供 Ctrl+U 下划线命令使用。
|
||||||
|
// 带属性的 `<u onclick=...>` 不匹配,仍会被 escapeHTML 转义,不引入注入面。
|
||||||
|
s = s.replace(/<\/?u\s*>/gi, (m) => {
|
||||||
|
protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`;
|
||||||
|
});
|
||||||
|
|
||||||
s = escapeHTML(s);
|
s = escapeHTML(s);
|
||||||
s = s.replace(/\u0005(\d+)\u0005/g, (_m, idx) => protectedItems[+idx] || _m);
|
s = s.replace(/\u0005(\d+)\u0005/g, (_m, idx) => protectedItems[+idx] || _m);
|
||||||
@@ -682,8 +729,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
|||||||
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
|
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
|
||||||
if (!m) return match;
|
if (!m) return match;
|
||||||
const u = safeUrl(m[2]); if (!u) return escapeHTML(match);
|
const u = safeUrl(m[2]); if (!u) return escapeHTML(match);
|
||||||
const t = m[3] ? ` title="${m[3]}"` : '';
|
return `<img src="${attrSafe(u)}" alt="${attrSafe(m[1])}"${titleAttr(m[3])} loading="lazy"/>`;
|
||||||
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
|
|
||||||
}
|
}
|
||||||
if (match.startsWith('![') && match.includes('][')) {
|
if (match.startsWith('![') && match.includes('][')) {
|
||||||
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
|
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
|
||||||
@@ -693,19 +739,17 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
|||||||
try {
|
try {
|
||||||
const ref = JSON.parse(env.refs[refKey]);
|
const ref = JSON.parse(env.refs[refKey]);
|
||||||
const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
|
const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
|
||||||
const t = ref.title ? ` title="${ref.title}"` : '';
|
return `<img src="${attrSafe(u)}" alt="${attrSafe(m[1])}"${titleAttr(escapeHTML(ref.title))} loading="lazy"/>`;
|
||||||
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
|
} catch (_) { return `<img src="" alt="${attrSafe(m[1])}" class="me-img-ref"/>`; }
|
||||||
} catch (_) { return `<img src="" alt="${m[1]}" class="me-img-ref"/>`; }
|
|
||||||
}
|
}
|
||||||
return `<img src="" alt="${m[1]}" class="me-img-ref"/>`;
|
return `<img src="" alt="${attrSafe(m[1])}" class="me-img-ref"/>`;
|
||||||
}
|
}
|
||||||
if (match.startsWith('[') && match.includes('](')) {
|
if (match.startsWith('[') && match.includes('](')) {
|
||||||
const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
|
const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
|
||||||
if (!m) return match;
|
if (!m) return match;
|
||||||
const u = safeUrl(m[2]); if (!u) return match;
|
const u = safeUrl(m[2]); if (!u) return match;
|
||||||
const t = m[3] ? ` title="${m[3]}"` : '';
|
|
||||||
const linkText = renderInline(m[1], env);
|
const linkText = renderInline(m[1], env);
|
||||||
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
|
return `<a href="${attrSafe(u)}"${titleAttr(m[3])} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
|
||||||
}
|
}
|
||||||
if (match.startsWith('[') && match.includes('][')) {
|
if (match.startsWith('[') && match.includes('][')) {
|
||||||
const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/);
|
const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/);
|
||||||
@@ -715,8 +759,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
|||||||
try {
|
try {
|
||||||
const ref = JSON.parse(env.refs[refKey]);
|
const ref = JSON.parse(env.refs[refKey]);
|
||||||
const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
|
const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
|
||||||
const t = ref.title ? ` title="${ref.title}"` : '';
|
return `<a href="${attrSafe(u)}"${titleAttr(escapeHTML(ref.title))} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
|
||||||
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
|
|
||||||
} catch (_) { return escapeHTML(match); }
|
} catch (_) { return escapeHTML(match); }
|
||||||
}
|
}
|
||||||
return escapeHTML(match);
|
return escapeHTML(match);
|
||||||
@@ -724,7 +767,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
|||||||
if (match.startsWith('<http')) {
|
if (match.startsWith('<http')) {
|
||||||
const url = match.slice(4, -4);
|
const url = match.slice(4, -4);
|
||||||
const u = safeUrl(url);
|
const u = safeUrl(url);
|
||||||
return `<a href="${u}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
return `<a href="${attrSafe(u)}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||||
}
|
}
|
||||||
if (match.startsWith('**')) return `<strong>${match.slice(2, -2)}</strong>`;
|
if (match.startsWith('**')) return `<strong>${match.slice(2, -2)}</strong>`;
|
||||||
if (match.startsWith('__')) return `<strong>${match.slice(2, -2)}</strong>`;
|
if (match.startsWith('__')) return `<strong>${match.slice(2, -2)}</strong>`;
|
||||||
@@ -737,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 (match.startsWith('$') && !match.startsWith('$$')) return `<span class="me-math-inline">${escapeHTML(match.slice(1, -1))}</span>`;
|
||||||
if (/^\[\^/.test(match)) {
|
if (/^\[\^/.test(match)) {
|
||||||
const fnId = match.slice(2, -1);
|
const fnId = match.slice(2, -1);
|
||||||
return `<sup class="me-footnote-ref"><a href="#fn-${fnId}" id="fnref-${fnId}">[${fnId}]</a></sup>`;
|
const safeId = attrSafe(fnId);
|
||||||
|
return `<sup class="me-footnote-ref"><a href="#fn-${safeId}" id="fnref-${safeId}">[${safeId}]</a></sup>`;
|
||||||
}
|
}
|
||||||
if (match.startsWith('\\') && match.length === 2) {
|
if (match.startsWith('\\') && match.length === 2) {
|
||||||
const escaped = match[1];
|
const escaped = match[1];
|
||||||
@@ -755,6 +799,6 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
parseMarkdown, parseTokens, renderTokens,
|
parseMarkdown, parseTokens, renderTokens,
|
||||||
safeUrl, slugify, clearRenderCache, registerBlockHandler,
|
safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler,
|
||||||
};
|
};
|
||||||
export default parseMarkdown;
|
export default parseMarkdown;
|
||||||
|
|||||||
+243
-108
@@ -6,14 +6,54 @@
|
|||||||
|
|
||||||
import { t } from './i18n';
|
import { t } from './i18n';
|
||||||
|
|
||||||
|
// ============ Editor contract ============
|
||||||
|
|
||||||
|
/** The editor surface plugins may rely on. Keeps plugin code type-checked
|
||||||
|
* without importing the full MarkdownEditor class (avoids circular imports). */
|
||||||
|
export interface EditorLike {
|
||||||
|
id?: string;
|
||||||
|
value?: string;
|
||||||
|
_value?: string;
|
||||||
|
el: HTMLElement;
|
||||||
|
textarea: HTMLTextAreaElement;
|
||||||
|
config?: Record<string, any>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Plugin state keys (Symbols avoid property-name collisions) ============
|
||||||
|
|
||||||
|
const K_AUTOSAVE = Symbol('me-plugin:autoSave');
|
||||||
|
const K_SEARCH = Symbol('me-plugin:searchReplace');
|
||||||
|
const K_IMAGE_PASTE = Symbol('me-plugin:imagePaste');
|
||||||
|
const K_SHORTCUT = Symbol('me-plugin:shortcutHelp');
|
||||||
|
const K_FILESYSTEM = Symbol('me-plugin:fileSystem');
|
||||||
|
|
||||||
|
const pluginState = <T>(editor: EditorLike, key: symbol): T | undefined => (editor as any)[key] as T | undefined;
|
||||||
|
const setPluginState = <T>(editor: EditorLike, key: symbol, state: T): void => { (editor as any)[key] = state; };
|
||||||
|
const deletePluginState = (editor: EditorLike, key: symbol): void => { delete (editor as any)[key]; };
|
||||||
|
|
||||||
export interface Plugin {
|
export interface Plugin {
|
||||||
name: string;
|
name: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
depends?: string[];
|
depends?: string[];
|
||||||
priority?: number;
|
priority?: number;
|
||||||
install?: (editor: any) => void | Promise<void>;
|
install?: (editor: EditorLike, options?: any) => void | Promise<void>;
|
||||||
destroy?: (editor: any) => void;
|
destroy?: (editor: EditorLike) => void;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +148,11 @@ export const validateConfig = (schema: PluginSchema = {}, config: Record<string,
|
|||||||
|
|
||||||
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
/** 实例级翻译:优先用 editor.t(实例 locale),否则退回全局 t。
|
||||||
|
* 插件面板文案跟随实例语言而非全局语言。 */
|
||||||
|
const instanceT = (editor: EditorLike): ((key: string, params?: Record<string, any>) => string) =>
|
||||||
|
(key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
|
||||||
|
|
||||||
const autoSavePlugin: Plugin = {
|
const autoSavePlugin: Plugin = {
|
||||||
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
|
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
|
||||||
install(editor, options?: Record<string, any>) {
|
install(editor, options?: Record<string, any>) {
|
||||||
@@ -118,22 +163,24 @@ const autoSavePlugin: Plugin = {
|
|||||||
const state = { _timer: null as ReturnType<typeof setTimeout> | null };
|
const state = { _timer: null as ReturnType<typeof setTimeout> | null };
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (state._timer) { clearTimeout(state._timer); state._timer = null; }
|
if (state._timer) { clearTimeout(state._timer); state._timer = null; }
|
||||||
try { localStorage.setItem(key, editor.getValue()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue() }); }
|
try { localStorage.setItem(key, editor.getValue!()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue!() }); }
|
||||||
catch (e) { console.warn('MeEditor autoSave:', e); }
|
catch (e) { console.warn('MeEditor autoSave:', e); }
|
||||||
};
|
};
|
||||||
const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); };
|
const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); };
|
||||||
const _onBlur = save;
|
const _onBlur = save;
|
||||||
const _onSave = save;
|
const _onSave = save;
|
||||||
editor.on('change', _onInput);
|
if (typeof editor.on === 'function') {
|
||||||
editor.on('blur', _onBlur);
|
editor.on('change', _onInput);
|
||||||
editor.on('save', _onSave);
|
editor.on('blur', _onBlur);
|
||||||
editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null) editor.setValue(v); return v; } catch (_) { return null; } };
|
editor.on('save', _onSave);
|
||||||
editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; };
|
}
|
||||||
editor.getDraftKey = () => key;
|
(editor as any).restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null && typeof editor.setValue === 'function') editor.setValue(v); return v; } catch (_) { return null; } };
|
||||||
(editor as any).__autoSaveCleanup = { state, _onInput, _onBlur, _onSave, save };
|
(editor as any).clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; };
|
||||||
|
(editor as any).getDraftKey = () => key;
|
||||||
|
setPluginState(editor, K_AUTOSAVE, { state, _onInput, _onBlur, _onSave, save });
|
||||||
},
|
},
|
||||||
destroy(editor) {
|
destroy(editor) {
|
||||||
const cleanup = (editor as any).__autoSaveCleanup;
|
const cleanup = pluginState<any>(editor, K_AUTOSAVE);
|
||||||
if (cleanup) {
|
if (cleanup) {
|
||||||
if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; }
|
if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; }
|
||||||
if (editor && typeof editor.off === 'function') {
|
if (editor && typeof editor.off === 'function') {
|
||||||
@@ -141,7 +188,7 @@ const autoSavePlugin: Plugin = {
|
|||||||
if (cleanup._onBlur) editor.off('blur', cleanup._onBlur);
|
if (cleanup._onBlur) editor.off('blur', cleanup._onBlur);
|
||||||
if (cleanup._onSave) editor.off('save', cleanup._onSave);
|
if (cleanup._onSave) editor.off('save', cleanup._onSave);
|
||||||
}
|
}
|
||||||
delete (editor as any).__autoSaveCleanup;
|
deletePluginState(editor, K_AUTOSAVE);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -159,8 +206,8 @@ const exportToolPlugin: Plugin = {
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
};
|
};
|
||||||
const stamp = () => { const d = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; };
|
const stamp = () => { const d = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; };
|
||||||
editor.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; };
|
const e = editor as any;
|
||||||
editor.exportHTML = (filename?: string, opts: any = {}) => {
|
const buildHTML = (opts: any = {}): string => {
|
||||||
const title = opts.title || 'Document';
|
const title = opts.title || 'Document';
|
||||||
const css = opts.css || '';
|
const css = opts.css || '';
|
||||||
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
|
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
|
||||||
@@ -199,17 +246,39 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
|
|||||||
.me-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em}
|
.me-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em}
|
||||||
.me-table-wrap{overflow-x:auto;margin:.9em 0}
|
.me-table-wrap{overflow-x:auto;margin:.9em 0}
|
||||||
</style>` : '';
|
</style>` : '';
|
||||||
download(filename || `metona-${stamp()}.html`, `<!DOCTYPE html>\n<html lang="${opts.lang||'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css?`<style>${css}</style>`:''}${embedCSS}\n</head>\n<body>\n${body}\n</body>\n</html>`, 'text/html');
|
return `<!DOCTYPE html>\n<html lang="${opts.lang||'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css?`<style>${css}</style>`:''}${embedCSS}\n</head>\n<body>\n${body}\n</body>\n</html>`;
|
||||||
|
};
|
||||||
|
e.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue!(), 'text/markdown'); return editor; };
|
||||||
|
e.exportHTML = (filename?: string, opts: any = {}) => {
|
||||||
|
download(filename || `metona-${stamp()}.html`, buildHTML(opts), 'text/html');
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
e.exportPDF = (opts: any = {}) => {
|
||||||
|
if (typeof document === 'undefined' || !editor.el) return editor;
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.style.position = 'fixed'; iframe.style.right = '0'; iframe.style.bottom = '0';
|
||||||
|
iframe.style.width = '0'; iframe.style.height = '0'; iframe.style.border = '0';
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc) { iframe.remove(); return editor; }
|
||||||
|
doc.open(); doc.write(buildHTML(opts)); doc.close();
|
||||||
|
setTimeout(() => {
|
||||||
|
try { iframe.contentWindow?.print(); } catch (_) {}
|
||||||
|
setTimeout(() => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe); }, 1000);
|
||||||
|
}, 50);
|
||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
destroy() {},
|
destroy(editor: EditorLike) {
|
||||||
|
if (editor) { delete (editor as any).exportMarkdown; delete (editor as any).exportHTML; delete (editor as any).exportPDF; }
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchReplacePlugin: Plugin = {
|
const searchReplacePlugin: Plugin = {
|
||||||
name: 'searchReplace', version: '0.2.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
|
name: 'searchReplace', version: '0.2.1', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
|
||||||
install(editor) {
|
install(editor) {
|
||||||
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
||||||
|
const tt = instanceT(editor);
|
||||||
// Inject style once globally
|
// Inject style once globally
|
||||||
if (!document.getElementById('me-search-style')) {
|
if (!document.getElementById('me-search-style')) {
|
||||||
const style = document.createElement('style'); style.id = 'me-search-style';
|
const style = document.createElement('style'); style.id = 'me-search-style';
|
||||||
@@ -220,9 +289,27 @@ const searchReplacePlugin: Plugin = {
|
|||||||
const state = {
|
const state = {
|
||||||
_panel: null as HTMLElement | null,
|
_panel: null as HTMLElement | null,
|
||||||
_regexMode: false,
|
_regexMode: false,
|
||||||
|
_matchCase: true,
|
||||||
|
_wholeWord: false,
|
||||||
_cleanup: null as (() => void) | null,
|
_cleanup: null as (() => void) | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WORD_CLASS = '[\\w\\u4e00-\\u9fff]';
|
||||||
|
const escapeRegExpText = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
const buildPattern = (q: string): RegExp | null => {
|
||||||
|
if (!q) return null;
|
||||||
|
let src = state._regexMode ? q : escapeRegExpText(q);
|
||||||
|
if (state._wholeWord) src = `(?<!${WORD_CLASS})(?:${src})(?!${WORD_CLASS})`;
|
||||||
|
const flags = 'g' + (state._matchCase ? '' : 'i');
|
||||||
|
try { return new RegExp(src, flags); } catch (_) { return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasWordBoundary = (text: string, idx: number, len: number): boolean => {
|
||||||
|
const isWord = (c: string | undefined) => !!c && /[\w\u4e00-\u9fff]/.test(c);
|
||||||
|
return !isWord(text[idx - 1]) && !isWord(text[idx + len]);
|
||||||
|
};
|
||||||
|
|
||||||
const _updateReplaceVisible = () => {
|
const _updateReplaceVisible = () => {
|
||||||
if (!state._panel) return;
|
if (!state._panel) return;
|
||||||
const show = state._panel.dataset.replace === '1';
|
const show = state._panel.dataset.replace === '1';
|
||||||
@@ -247,32 +334,40 @@ const searchReplacePlugin: Plugin = {
|
|||||||
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
|
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
|
||||||
const panel = document.createElement('div'); panel.className = 'me-search';
|
const panel = document.createElement('div'); panel.className = 'me-search';
|
||||||
panel.dataset.replace = showReplace ? '1' : '0';
|
panel.dataset.replace = showReplace ? '1' : '0';
|
||||||
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-regex" title="Regex">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
|
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${tt('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${tt('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${tt('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${tt('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${tt('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${tt('regex')||'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${tt('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${tt('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${tt('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${tt('replaceAll')||'All'}</button></div>`;
|
||||||
editor.el.appendChild(panel); state._panel = panel;
|
editor.el.appendChild(panel); state._panel = panel;
|
||||||
_updateReplaceVisible();
|
_updateReplaceVisible();
|
||||||
state._regexMode = false;
|
state._regexMode = false; state._matchCase = true; state._wholeWord = false;
|
||||||
const fi = panel.querySelector('.me-search-find') as HTMLInputElement;
|
const fi = panel.querySelector('.me-search-find') as HTMLInputElement;
|
||||||
const ri = panel.querySelector('.me-search-replace') as HTMLInputElement;
|
const ri = panel.querySelector('.me-search-replace') as HTMLInputElement;
|
||||||
const ce = panel.querySelector('.me-search-count') as HTMLElement;
|
const ce = panel.querySelector('.me-search-count') as HTMLElement;
|
||||||
const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement;
|
const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement;
|
||||||
|
const caseBtn = panel.querySelector('.me-search-case') as HTMLButtonElement;
|
||||||
|
const wordBtn = panel.querySelector('.me-search-word') as HTMLButtonElement;
|
||||||
regexBtn.addEventListener('click', () => {
|
regexBtn.addEventListener('click', () => {
|
||||||
state._regexMode = !state._regexMode;
|
state._regexMode = !state._regexMode;
|
||||||
regexBtn.classList.toggle('me-active', state._regexMode);
|
regexBtn.classList.toggle('me-active', state._regexMode);
|
||||||
lastIdxs = findAll();
|
lastIdxs = findAll();
|
||||||
});
|
});
|
||||||
|
caseBtn.addEventListener('click', () => {
|
||||||
|
state._matchCase = !state._matchCase;
|
||||||
|
caseBtn.classList.toggle('me-active', state._matchCase);
|
||||||
|
lastIdxs = findAll();
|
||||||
|
});
|
||||||
|
wordBtn.addEventListener('click', () => {
|
||||||
|
state._wholeWord = !state._wholeWord;
|
||||||
|
wordBtn.classList.toggle('me-active', state._wholeWord);
|
||||||
|
lastIdxs = findAll();
|
||||||
|
});
|
||||||
const findAll = () => {
|
const findAll = () => {
|
||||||
const q = fi.value; if (!q) { ce.textContent = ''; return []; }
|
const q = fi.value; if (!q) { ce.textContent = ''; return []; }
|
||||||
const idxs: number[] = []; let from = 0;
|
const idxs: number[] = [];
|
||||||
if (state._regexMode) {
|
const re = buildPattern(q);
|
||||||
try {
|
if (!re) { ce.textContent = 'err'; return []; }
|
||||||
const re = new RegExp(q, 'g'); let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = re.exec(editor.textarea.value)) !== null) {
|
while ((m = re.exec(editor.textarea.value)) !== null) {
|
||||||
idxs.push(m.index);
|
idxs.push(m.index);
|
||||||
if (m[0].length === 0) re.lastIndex++;
|
if (m[0].length === 0) re.lastIndex++;
|
||||||
}
|
|
||||||
} catch (_) { ce.textContent = 'err'; return []; }
|
|
||||||
} else {
|
|
||||||
while (true) { const idx = editor.textarea.value.indexOf(q, from); if (idx === -1) break; idxs.push(idx); from = idx + q.length; }
|
|
||||||
}
|
}
|
||||||
ce.textContent = idxs.length ? `${idxs.length}` : '0';
|
ce.textContent = idxs.length ? `${idxs.length}` : '0';
|
||||||
return idxs;
|
return idxs;
|
||||||
@@ -282,50 +377,79 @@ const searchReplacePlugin: Plugin = {
|
|||||||
editor.textarea.focus();
|
editor.textarea.focus();
|
||||||
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
|
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
|
||||||
};
|
};
|
||||||
|
const matchLenAt = (idx: number): number => {
|
||||||
|
const text = editor.textarea.value;
|
||||||
|
const q = fi.value; if (!q) return 0;
|
||||||
|
if (!state._regexMode) {
|
||||||
|
if (state._matchCase && text.slice(idx, idx + q.length) !== q) return 0;
|
||||||
|
if (!state._matchCase && text.slice(idx, idx + q.length).toLowerCase() !== q.toLowerCase()) return 0;
|
||||||
|
if (state._wholeWord && !hasWordBoundary(text, idx, q.length)) return 0;
|
||||||
|
return q.length;
|
||||||
|
}
|
||||||
|
const re = buildPattern(q); if (!re) return 0;
|
||||||
|
re.lastIndex = idx;
|
||||||
|
const m = re.exec(text);
|
||||||
|
return m && m.index === idx ? m[0].length : 0;
|
||||||
|
};
|
||||||
const findNext = () => {
|
const findNext = () => {
|
||||||
lastIdxs = findAll(); if (!lastIdxs.length) return;
|
lastIdxs = findAll(); if (!lastIdxs.length) return;
|
||||||
const cur = editor.textarea.selectionEnd;
|
const cur = editor.textarea.selectionEnd;
|
||||||
const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(cur)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length;
|
|
||||||
let next = lastIdxs.find((i: number) => i >= cur);
|
let next = lastIdxs.find((i: number) => i >= cur);
|
||||||
if (next == null) next = lastIdxs[0];
|
if (next == null) next = lastIdxs[0];
|
||||||
selectAt(next, qlen);
|
selectAt(next, matchLenAt(next) || fi.value.length);
|
||||||
};
|
};
|
||||||
const findPrev = () => {
|
const findPrev = () => {
|
||||||
lastIdxs = findAll(); if (!lastIdxs.length) return;
|
lastIdxs = findAll(); if (!lastIdxs.length) return;
|
||||||
const cur = editor.textarea.selectionStart;
|
const cur = editor.textarea.selectionStart;
|
||||||
const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(Math.max(0, cur - 100), cur + 100)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length;
|
|
||||||
let prev = -1;
|
let prev = -1;
|
||||||
for (let i = lastIdxs.length-1; i>=0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } }
|
for (let i = lastIdxs.length-1; i>=0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } }
|
||||||
if (prev === -1) prev = lastIdxs[lastIdxs.length-1];
|
if (prev === -1) prev = lastIdxs[lastIdxs.length-1];
|
||||||
selectAt(prev, qlen);
|
selectAt(prev, matchLenAt(prev) || fi.value.length);
|
||||||
};
|
};
|
||||||
const replaceOne = () => {
|
const replaceOne = () => {
|
||||||
const q = fi.value, r = ri.value; if (!q) return;
|
const q = fi.value, r = ri.value; if (!q) return;
|
||||||
const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd;
|
const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd;
|
||||||
const matchText = ta.value.substring(s, e);
|
const matchText = ta.value.substring(s, e);
|
||||||
|
let matched = false;
|
||||||
if (state._regexMode) {
|
if (state._regexMode) {
|
||||||
try { if (new RegExp(q).test(matchText)) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } } catch (_) {}
|
const re = buildPattern(q);
|
||||||
} else if (matchText === q) {
|
if (re) { re.lastIndex = s; const m = re.exec(ta.value); matched = !!(m && m.index === s); }
|
||||||
|
} else {
|
||||||
|
const sameCase = state._matchCase ? matchText === q : matchText.toLowerCase() === q.toLowerCase();
|
||||||
|
matched = sameCase && (!state._wholeWord || hasWordBoundary(ta.value, s, q.length));
|
||||||
|
}
|
||||||
|
if (matched) {
|
||||||
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
|
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
|
||||||
ta.setSelectionRange(s, s + r.length);
|
ta.setSelectionRange(s, s + r.length);
|
||||||
}
|
}
|
||||||
editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory();
|
const oldV = editor._value || '';
|
||||||
if (typeof editor._render === 'function') editor._render();
|
editor._value = ta.value;
|
||||||
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);
|
||||||
|
}
|
||||||
findNext();
|
findNext();
|
||||||
};
|
};
|
||||||
const replaceAll = () => {
|
const replaceAll = () => {
|
||||||
const q = fi.value, r = ri.value; if (!q) return;
|
const q = fi.value, r = ri.value; if (!q) return;
|
||||||
const ta = editor.textarea;
|
const ta = editor.textarea;
|
||||||
if (state._regexMode) {
|
const re = buildPattern(q);
|
||||||
try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; }
|
if (!re) return;
|
||||||
} else {
|
const oldV = editor._value || '';
|
||||||
ta.value = ta.value.split(q).join(r);
|
ta.value = ta.value.replace(re, () => r);
|
||||||
}
|
|
||||||
ta.setSelectionRange(0,0); editor._value = ta.value;
|
ta.setSelectionRange(0,0); editor._value = ta.value;
|
||||||
if (typeof editor._pushHistory === 'function') editor._pushHistory();
|
if (typeof editor._afterProgrammaticEdit === 'function') {
|
||||||
if (typeof editor._render === 'function') editor._render();
|
editor._afterProgrammaticEdit(oldV);
|
||||||
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
|
} 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();
|
findAll();
|
||||||
};
|
};
|
||||||
fi.addEventListener('input', () => { lastIdxs = findAll(); });
|
fi.addEventListener('input', () => { lastIdxs = findAll(); });
|
||||||
@@ -350,49 +474,56 @@ const searchReplacePlugin: Plugin = {
|
|||||||
};
|
};
|
||||||
editor.textarea.addEventListener('keydown', _onKeydown);
|
editor.textarea.addEventListener('keydown', _onKeydown);
|
||||||
|
|
||||||
// Expose for tests
|
// Expose for tests via Symbol-keyed state (no public-property pollution)
|
||||||
(editor as any).__srState = state;
|
setPluginState(editor, K_SEARCH, { state, _open, _close, _onKeydown });
|
||||||
(editor as any).__srOpen = _open;
|
|
||||||
(editor as any).__srClose = _close;
|
|
||||||
(editor as any).__srKeydown = _onKeydown;
|
|
||||||
},
|
},
|
||||||
destroy(editor: any) {
|
destroy(editor: EditorLike) {
|
||||||
const state = (editor as any).__srState;
|
const exposed = pluginState<{ state: any; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SEARCH);
|
||||||
if (state) {
|
if (exposed) {
|
||||||
if (state._cleanup) { state._cleanup(); state._cleanup = null; }
|
const state = exposed.state;
|
||||||
state._panel = null;
|
if (state) {
|
||||||
|
if (state._cleanup) { state._cleanup(); state._cleanup = null; }
|
||||||
|
state._panel = null;
|
||||||
|
}
|
||||||
|
if (exposed._onKeydown && editor && editor.textarea) {
|
||||||
|
editor.textarea.removeEventListener('keydown', exposed._onKeydown);
|
||||||
|
}
|
||||||
|
deletePluginState(editor, K_SEARCH);
|
||||||
}
|
}
|
||||||
const _onKeydown = (editor as any).__srKeydown;
|
|
||||||
if (_onKeydown && editor && editor.textarea) {
|
|
||||||
editor.textarea.removeEventListener('keydown', _onKeydown);
|
|
||||||
}
|
|
||||||
delete (editor as any).__srState;
|
|
||||||
delete (editor as any).__srOpen;
|
|
||||||
delete (editor as any).__srClose;
|
|
||||||
delete (editor as any).__srKeydown;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const imagePastePlugin: Plugin = {
|
const imagePastePlugin: Plugin = {
|
||||||
name: 'imagePaste', version: '0.2.0', description: 'Paste image as base64', priority: 60,
|
name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60,
|
||||||
install(editor) {
|
install(editor, options: any = {}) {
|
||||||
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
||||||
|
const tt = instanceT(editor);
|
||||||
|
const maxSizeKB: number = options.maxSizeKB || 500;
|
||||||
const _onPaste = (e: ClipboardEvent) => {
|
const _onPaste = (e: ClipboardEvent) => {
|
||||||
const items = e.clipboardData?.items; if (!items) return;
|
const items = e.clipboardData?.items; if (!items) return;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.type?.startsWith('image/')) { e.preventDefault();
|
if (item.type?.startsWith('image/')) {
|
||||||
const reader = new FileReader(); reader.onload = () => { editor.insert(`\n`); };
|
const file = item.getAsFile();
|
||||||
reader.readAsDataURL(item.getAsFile()!); break;
|
if (!file) continue;
|
||||||
|
const sizeKB = file.size / 1024;
|
||||||
|
if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (typeof editor.toast === 'function') editor.toast(tt('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
const reader = new FileReader(); reader.onload = () => { if (typeof editor.insert === 'function') editor.insert(`\n`); };
|
||||||
|
reader.readAsDataURL(file); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
editor.textarea.addEventListener('paste', _onPaste);
|
editor.textarea.addEventListener('paste', _onPaste);
|
||||||
(editor as any).__ipOnPaste = _onPaste;
|
setPluginState(editor, K_IMAGE_PASTE, _onPaste);
|
||||||
},
|
},
|
||||||
destroy(editor: any) {
|
destroy(editor: EditorLike) {
|
||||||
const _onPaste = (editor as any).__ipOnPaste;
|
const _onPaste = pluginState<(e: ClipboardEvent) => void>(editor, K_IMAGE_PASTE);
|
||||||
if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste);
|
if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste);
|
||||||
delete (editor as any).__ipOnPaste;
|
deletePluginState(editor, K_IMAGE_PASTE);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -400,50 +531,53 @@ const shortcutHelpPlugin: Plugin = {
|
|||||||
name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200,
|
name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200,
|
||||||
install(editor) {
|
install(editor) {
|
||||||
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
||||||
|
const tt = instanceT(editor);
|
||||||
if (!document.getElementById('me-shortcut-style')) {
|
if (!document.getElementById('me-shortcut-style')) {
|
||||||
const s = document.createElement('style'); s.id = 'me-shortcut-style';
|
const s = document.createElement('style'); s.id = 'me-shortcut-style';
|
||||||
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
|
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _panel: HTMLElement | null = null;
|
// 面板引用放入 holder:install 时无法预知未来打开的面板,
|
||||||
|
// 卸载时经 holder 取实时引用,避免面板残留 DOM。
|
||||||
|
const st = { panel: null as HTMLElement | null };
|
||||||
|
|
||||||
const _close = () => { if (_panel) { _panel.remove(); _panel = null; } };
|
const _close = () => { if (st.panel) { st.panel.remove(); st.panel = null; } };
|
||||||
|
|
||||||
const _open = () => {
|
const _open = () => {
|
||||||
if (_panel) { _close(); return; }
|
if (st.panel) { _close(); return; }
|
||||||
// i18n-aware shortcut labels
|
// i18n-aware shortcut labels(跟随实例语言)
|
||||||
const builtin: [string, string][] = [
|
const builtin: [string, string][] = [
|
||||||
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'],
|
['Ctrl+B', tt('bold') || 'Bold'], ['Ctrl+I', tt('italic') || 'Italic'],
|
||||||
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'],
|
['Ctrl+U', tt('underline') || 'Underline'], ['Ctrl+K', tt('link') || 'Link'],
|
||||||
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'],
|
['Ctrl+E', tt('code') || 'Code'], ['Ctrl+1/2/3', tt('h1') || 'Heading'],
|
||||||
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'],
|
['Ctrl+Q', tt('quote') || 'Quote'], ['Ctrl+Z', tt('undo') || 'Undo'],
|
||||||
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'],
|
['Ctrl+Y', tt('redo') || 'Redo'], ['Ctrl+S', tt('save') || 'Save'],
|
||||||
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'],
|
['Ctrl+F', tt('search') || 'Search'], ['Ctrl+H', tt('replace') || 'Replace'],
|
||||||
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'],
|
['Tab', tt('indent') || 'Indent'], ['Shift+Tab', tt('outdent') || 'Outdent'],
|
||||||
['?', t('close') || 'Shortcuts'],
|
['?', tt('shortcuts') || 'Shortcuts'],
|
||||||
];
|
];
|
||||||
let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
|
let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
|
||||||
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
|
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
|
||||||
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('close') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
|
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${tt('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); });
|
overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); });
|
||||||
document.body.appendChild(overlay); _panel = overlay;
|
document.body.appendChild(overlay); st.panel = overlay;
|
||||||
};
|
};
|
||||||
|
|
||||||
const _onKeydown = (e: KeyboardEvent) => {
|
const _onKeydown = (e: KeyboardEvent) => {
|
||||||
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); _open(); }
|
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); _open(); }
|
||||||
if (e.key === 'Escape' && _panel) _close();
|
if (e.key === 'Escape' && st.panel) _close();
|
||||||
};
|
};
|
||||||
editor.textarea.addEventListener('keydown', _onKeydown);
|
editor.textarea.addEventListener('keydown', _onKeydown);
|
||||||
|
|
||||||
(editor as any).__shState = { _panel, _open, _close, _onKeydown };
|
setPluginState(editor, K_SHORTCUT, { state: st, _open, _close, _onKeydown });
|
||||||
},
|
},
|
||||||
destroy(editor: any) {
|
destroy(editor: EditorLike) {
|
||||||
const state = (editor as any).__shState;
|
const exposed = pluginState<{ state: { panel: HTMLElement | null }; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT);
|
||||||
if (state) { if (state._panel) { state._panel.remove(); } }
|
if (exposed) { if (exposed.state && exposed.state.panel) { exposed.state.panel.remove(); exposed.state.panel = null; } }
|
||||||
const _onKeydown = (editor as any).__shState?._onKeydown;
|
const _onKeydown = exposed?._onKeydown;
|
||||||
if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown);
|
if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown);
|
||||||
delete (editor as any).__shState;
|
deletePluginState(editor, K_SHORTCUT);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -454,31 +588,32 @@ const fileSystemPlugin: Plugin = {
|
|||||||
const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function';
|
const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function';
|
||||||
let _fileHandle: any = null;
|
let _fileHandle: any = null;
|
||||||
|
|
||||||
editor.openFile = async (opts: any = {}) => {
|
(editor as any).openFile = async (opts: any = {}) => {
|
||||||
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; }
|
if (!hasAPI) { if (typeof editor.toast === 'function') editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; }
|
||||||
try {
|
try {
|
||||||
const [handle] = await (window as any).showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md','.txt','.markdown'] } }], ...opts });
|
const [handle] = await (window as any).showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md','.txt','.markdown'] } }], ...opts });
|
||||||
_fileHandle = handle; const file = await handle.getFile(); const content = await file.text();
|
_fileHandle = handle; const file = await handle.getFile(); const content = await file.text();
|
||||||
editor.setValue(content); editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle };
|
if (typeof editor.setValue === 'function') editor.setValue(content);
|
||||||
|
editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle };
|
||||||
} catch (e: any) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; }
|
} catch (e: any) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; }
|
||||||
};
|
};
|
||||||
editor.saveFile = async (opts: any = {}) => {
|
(editor as any).saveFile = async (opts: any = {}) => {
|
||||||
let handle = _fileHandle;
|
let handle = _fileHandle;
|
||||||
if (!handle || opts.saveAs) {
|
if (!handle || opts.saveAs) {
|
||||||
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; }
|
if (!hasAPI) { if (typeof editor.toast === 'function') editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; }
|
||||||
try { handle = await (window as any).showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); _fileHandle = handle; }
|
try { handle = await (window as any).showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); _fileHandle = handle; }
|
||||||
catch (e: any) { if (e.name !== 'AbortError') console.error('Save error:', e); return false; }
|
catch (e: any) { if (e.name !== 'AbortError') console.error('Save error:', e); return false; }
|
||||||
}
|
}
|
||||||
try { const w = await handle.createWritable(); await w.write(editor.getValue()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; }
|
try { const w = await handle.createWritable(); await w.write(editor.getValue!()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; }
|
||||||
catch (e) { _fileHandle = null; if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; }
|
catch (e) { _fileHandle = null; if (!opts.saveAs) return (editor as any).saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; }
|
||||||
};
|
};
|
||||||
editor.saveFileAs = (name?: string) => editor.saveFile({ saveAs: true, name });
|
(editor as any).saveFileAs = (name?: string) => (editor as any).saveFile({ saveAs: true, name });
|
||||||
editor.getFileHandle = () => _fileHandle;
|
(editor as any).getFileHandle = () => _fileHandle;
|
||||||
(editor as any).__fsCleanup = { getHandle: () => _fileHandle };
|
setPluginState(editor, K_FILESYSTEM, { getHandle: () => _fileHandle });
|
||||||
},
|
},
|
||||||
destroy(editor: any) {
|
destroy(editor: EditorLike) {
|
||||||
if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; }
|
if (editor) { delete (editor as any).openFile; delete (editor as any).saveFile; delete (editor as any).saveFileAs; delete (editor as any).getFileHandle; }
|
||||||
delete (editor as any).__fsCleanup;
|
deletePluginState(editor, K_FILESYSTEM);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -72,6 +72,18 @@ const generateCSS = (): string => {
|
|||||||
.me-code-title{font-family:var(--md-mono);font-size:.85em;padding:8px 14px;background:var(--md-border);border-radius:8px 8px 0 0;color:var(--md-text);margin:.9em 0 -0.9em;font-weight:600}
|
.me-code-title{font-family:var(--md-mono);font-size:.85em;padding:8px 14px;background:var(--md-border);border-radius:8px 8px 0 0;color:var(--md-text);margin:.9em 0 -0.9em;font-weight:600}
|
||||||
.me-code-title+pre{margin-top:0;border-radius:0 0 8px 8px;border-top:none}
|
.me-code-title+pre{margin-top:0;border-radius:0 0 8px 8px;border-top:none}
|
||||||
.me-preview pre code{padding:0;background:transparent;color:var(--md-code-text);font-size:.9em;line-height:1.6;border-radius:0}
|
.me-preview pre code{padding:0;background:transparent;color:var(--md-code-text);font-size:.9em;line-height:1.6;border-radius:0}
|
||||||
|
.me-hl-keyword{color:#c678dd}
|
||||||
|
.me-hl-string{color:#98c379}
|
||||||
|
.me-hl-comment{color:#7f848e;font-style:italic}
|
||||||
|
.me-hl-number{color:#d19a66}
|
||||||
|
.me-hl-builtin{color:#e5c07b}
|
||||||
|
.me-hl-function{color:#61afef}
|
||||||
|
.me-theme-light .me-hl-keyword{color:#9c4cc0}
|
||||||
|
.me-theme-light .me-hl-string{color:#4f9d4f}
|
||||||
|
.me-theme-light .me-hl-comment{color:#8b8b8b}
|
||||||
|
.me-theme-light .me-hl-number{color:#b06a22}
|
||||||
|
.me-theme-light .me-hl-builtin{color:#9a7d0f}
|
||||||
|
.me-theme-light .me-hl-function{color:#2a7fd4}
|
||||||
.me-preview img{max-width:100%;height:auto;border-radius:6px;vertical-align:middle}
|
.me-preview img{max-width:100%;height:auto;border-radius:6px;vertical-align:middle}
|
||||||
.me-table-wrap{overflow-x:auto;margin:.9em 0}
|
.me-table-wrap{overflow-x:auto;margin:.9em 0}
|
||||||
.me-preview table{border-collapse:collapse;width:100%;font-size:.93em;display:block}
|
.me-preview table{border-collapse:collapse;width:100%;font-size:.93em;display:block}
|
||||||
@@ -127,7 +139,7 @@ const generateCSS = (): string => {
|
|||||||
.me-outline-l2 a{padding-left:12px}
|
.me-outline-l2 a{padding-left:12px}
|
||||||
.me-outline-l3 a{padding-left:20px;font-size:12px}
|
.me-outline-l3 a{padding-left:20px;font-size:12px}
|
||||||
.me-outline-l4 a{padding-left:28px;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-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}
|
.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}
|
@media print{.me-wrapper{border:0!important;box-shadow:none!important}
|
||||||
|
|||||||
+17
-5
@@ -239,6 +239,8 @@ export const hasTheme = (name: string): boolean => name in THEMES;
|
|||||||
|
|
||||||
export const createInstanceTheme = (editor: any): any => {
|
export const createInstanceTheme = (editor: any): any => {
|
||||||
let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto';
|
let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto';
|
||||||
|
// 外部跟随订阅列表,随实例销毁断开,避免 observer/定时器泄漏
|
||||||
|
const unsubs: Array<() => void> = [];
|
||||||
const apply = (theme: string): string => {
|
const apply = (theme: string): string => {
|
||||||
instanceTheme = theme;
|
instanceTheme = theme;
|
||||||
const resolved = resolveTheme(theme);
|
const resolved = resolveTheme(theme);
|
||||||
@@ -259,11 +261,21 @@ export const createInstanceTheme = (editor: any): any => {
|
|||||||
const getResolved = () => resolveTheme(instanceTheme);
|
const getResolved = () => resolveTheme(instanceTheme);
|
||||||
const getConfig = () => getThemeConfig(instanceTheme);
|
const getConfig = () => getThemeConfig(instanceTheme);
|
||||||
const getVars = () => editor.el ? exportCSSVars(editor.el) : {};
|
const getVars = () => editor.el ? exportCSSVars(editor.el) : {};
|
||||||
const syncWithElement = (element: HTMLElement, opts: any = {}) => followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected));
|
const syncWithElement = (element: HTMLElement, opts: any = {}) => {
|
||||||
const adopt = () => editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => {};
|
const unsub = followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected));
|
||||||
const watchExternal = (source: any) => watch(source, (theme) => apply(theme));
|
unsubs.push(unsub); return unsub;
|
||||||
|
};
|
||||||
|
const adopt = () => {
|
||||||
|
const unsub = editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => {};
|
||||||
|
unsubs.push(unsub); return unsub;
|
||||||
|
};
|
||||||
|
const watchExternal = (source: any) => {
|
||||||
|
const unsub = watch(source, (theme) => apply(theme));
|
||||||
|
unsubs.push(unsub); return unsub;
|
||||||
|
};
|
||||||
|
const dispose = (): void => { unsubs.forEach((fn) => { try { fn(); } catch (_) {} }); unsubs.length = 0; };
|
||||||
apply(instanceTheme);
|
apply(instanceTheme);
|
||||||
return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal };
|
return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal, dispose };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createThemeManager = () => ({
|
export const createThemeManager = () => ({
|
||||||
@@ -283,7 +295,7 @@ export const presetThemes = {
|
|||||||
|
|
||||||
// Ensure built-in themes in THEMES
|
// Ensure built-in themes in THEMES
|
||||||
for (const [name, theme] of Object.entries(presetThemes)) {
|
for (const [name, theme] of Object.entries(presetThemes)) {
|
||||||
if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config as ThemeConfig;
|
if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const themeUtils = {
|
export const themeUtils = {
|
||||||
|
|||||||
+10
-1
@@ -25,6 +25,15 @@ export const escapeHTML = (s: unknown): string => {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** HTML-escape a string for use inside a double-quoted attribute value */
|
||||||
|
export const escapeAttr = (s: unknown): string => {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
};
|
||||||
|
|
||||||
/** Detect dark mode preference */
|
/** Detect dark mode preference */
|
||||||
export const prefersDark = (): boolean => {
|
export const prefersDark = (): boolean => {
|
||||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||||
@@ -69,7 +78,7 @@ export function throttle<T extends (...args: any[]) => void>(
|
|||||||
/** Deep-merge two objects */
|
/** Deep-merge two objects */
|
||||||
export const deepMerge = <T extends Record<string, unknown>>(target: T, source: Partial<T>): T => {
|
export const deepMerge = <T extends Record<string, unknown>>(target: T, source: Partial<T>): T => {
|
||||||
const output: Record<string, unknown> = { ...target };
|
const output: Record<string, unknown> = { ...target };
|
||||||
for (const key of Object.keys(source as Record<string, unknown>)) {
|
for (const key of Object.keys(source)) {
|
||||||
const sv = (source as Record<string, unknown>)[key];
|
const sv = (source as Record<string, unknown>)[key];
|
||||||
const tv = (target as Record<string, unknown>)[key];
|
const tv = (target as Record<string, unknown>)[key];
|
||||||
if (sv instanceof Object && key in target && tv instanceof Object) {
|
if (sv instanceof Object && key in target && tv instanceof Object) {
|
||||||
|
|||||||
+1335
-3
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* highlight.ts 单元测试
|
||||||
|
* 覆盖内置轻量语法高亮器的安全性与标记输出
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { highlight, normalizeLanguage, registerLanguage, getSupportedLanguages } from '../src/highlight';
|
||||||
|
|
||||||
|
describe('highlight - 基础', () => {
|
||||||
|
test('未知语言返回转义纯文本', () => {
|
||||||
|
const out = highlight('const x = 1;', 'nonexistent');
|
||||||
|
expect(out).toBe('const x = 1;');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('支持的语言别名归一化', () => {
|
||||||
|
expect(normalizeLanguage('js')).toBe('javascript');
|
||||||
|
expect(normalizeLanguage('ts')).toBe('typescript');
|
||||||
|
expect(normalizeLanguage('py')).toBe('python');
|
||||||
|
expect(normalizeLanguage('sh')).toBe('bash');
|
||||||
|
expect(normalizeLanguage('yml')).toBe('yaml');
|
||||||
|
expect(normalizeLanguage('Java')).toBe('java');
|
||||||
|
expect(normalizeLanguage('unknown')).toBe('');
|
||||||
|
expect(normalizeLanguage('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关键词被标记', () => {
|
||||||
|
const out = highlight('const x = 1', 'js');
|
||||||
|
expect(out).toContain('<span class="me-hl-keyword">const</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字符串被标记', () => {
|
||||||
|
const out = highlight('const s = "hello"', 'js');
|
||||||
|
expect(out).toContain('<span class="me-hl-string">"hello"</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注释被标记', () => {
|
||||||
|
const out = highlight('// comment\nconst x = 1', 'js');
|
||||||
|
expect(out).toContain('<span class="me-hl-comment">// comment</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('数字被标记', () => {
|
||||||
|
const out = highlight('const n = 42', 'js');
|
||||||
|
expect(out).toContain('<span class="me-hl-number">42</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('函数调用被标记', () => {
|
||||||
|
const out = highlight('foo(bar)', 'js');
|
||||||
|
expect(out).toContain('<span class="me-hl-function">foo</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('python 井号注释与 def 关键词', () => {
|
||||||
|
const out = highlight('# note\ndef main():', 'python');
|
||||||
|
expect(out).toContain('<span class="me-hl-comment"># note</span>');
|
||||||
|
expect(out).toContain('<span class="me-hl-keyword">def</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('内置全局对象被标记', () => {
|
||||||
|
const out = highlight('console.log("x")', 'js');
|
||||||
|
expect(out).toContain('<span class="me-hl-builtin">console</span>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('highlight - 安全性', () => {
|
||||||
|
test('HTML 特殊字符被转义,不产生裸标签', () => {
|
||||||
|
const out = highlight('<script>alert(1)</script>', 'js');
|
||||||
|
expect(out).not.toContain('<script>');
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
expect(out).toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字符串中的引号安全', () => {
|
||||||
|
const out = highlight('const s = "a<b>&"', 'js');
|
||||||
|
expect(out).not.toContain('a<b');
|
||||||
|
expect(out).toContain('a<b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('恶意输入不破坏 span 结构', () => {
|
||||||
|
const out = highlight('"</span><script>x</script>"', 'js');
|
||||||
|
// 所有 span 配对(每开一个 <span 就有一个 </span>)
|
||||||
|
const opens = (out.match(/<span/g) || []).length;
|
||||||
|
const closes = (out.match(/<\/span>/g) || []).length;
|
||||||
|
expect(opens).toBe(closes);
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('highlight - 语言注册', () => {
|
||||||
|
test('注册自定义语言', () => {
|
||||||
|
registerLanguage('foo', { keywords: ['frobnicate'], builtins: [] });
|
||||||
|
expect(normalizeLanguage('foo')).toBe('foo');
|
||||||
|
const out = highlight('frobnicate the widget', 'foo');
|
||||||
|
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);
|
||||||
|
expect(langs).toContain('javascript');
|
||||||
|
expect(langs).toContain('typescript');
|
||||||
|
expect(langs).toContain('python');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('highlight - 环境一致性', () => {
|
||||||
|
test('字符串高亮与引号转义环境无关(jsdom 断言)', () => {
|
||||||
|
const out = highlight('const s = "hi"', 'js');
|
||||||
|
expect(out).toContain('<span class="me-hl-string">"hi"</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引号不被实体化,span 文本保持原样', () => {
|
||||||
|
const out = highlight('"a" + \'b\'', 'js');
|
||||||
|
expect(out).not.toContain('"');
|
||||||
|
// 双引号字符串应被完整标记
|
||||||
|
expect(out).toContain('<span class="me-hl-string">"a"</span>');
|
||||||
|
expect(out).toContain("<span class=\"me-hl-string\">'b'</span>");
|
||||||
|
});
|
||||||
|
});
|
||||||
+47
-7
@@ -29,7 +29,7 @@ describe('index.ts - 全局 API', () => {
|
|||||||
|
|
||||||
test('VERSION 是字符串', () => {
|
test('VERSION 是字符串', () => {
|
||||||
expect(typeof VERSION).toBe('string');
|
expect(typeof VERSION).toBe('string');
|
||||||
expect(VERSION).toBe('0.2.4');
|
expect(VERSION).toBe('0.4.3');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('api.default 是 api 本身', () => {
|
test('api.default 是 api 本身', () => {
|
||||||
@@ -81,6 +81,24 @@ describe('index.ts - 全局 API', () => {
|
|||||||
ed.destroy();
|
ed.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('全局 searchReplace 在新实例上可用(DOM 已就绪)', () => {
|
||||||
|
destroy();
|
||||||
|
use('searchReplace');
|
||||||
|
const ed = create(container, { value: 'foo bar' });
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
|
||||||
|
expect(ed.el.querySelector('.me-search')).not.toBeNull();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('全局插件与 config.plugins 同名时只安装一次', () => {
|
||||||
|
destroy();
|
||||||
|
use('autoSave');
|
||||||
|
const ed = create(container, { value: 'test', plugins: ['autoSave'] });
|
||||||
|
const count = ed.getPlugins().filter((p: any) => p.name === 'autoSave').length;
|
||||||
|
expect(count).toBe(1);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test('on / off 全局钩子', () => {
|
test('on / off 全局钩子', () => {
|
||||||
const fn = jest.fn();
|
const fn = jest.fn();
|
||||||
const unsub = on('afterCreate', fn);
|
const unsub = on('afterCreate', fn);
|
||||||
@@ -111,11 +129,30 @@ describe('index.ts - 全局 API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('destroy 清理全局资源', () => {
|
test('destroy 清理全局资源', () => {
|
||||||
expect(() => destroy()).not.toThrow();
|
destroy();
|
||||||
const status = getStatus();
|
const status = getStatus();
|
||||||
expect(status.globalPlugins.length).toBe(0);
|
expect(status.globalPlugins.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('destroy 清理用户全局钩子但保留内部注入钩子', () => {
|
||||||
|
destroy();
|
||||||
|
const fn = jest.fn();
|
||||||
|
on('afterCreate', fn);
|
||||||
|
expect(MarkdownEditor._hooks.get('afterCreate')!.length).toBeGreaterThan(0);
|
||||||
|
destroy();
|
||||||
|
const hooks = MarkdownEditor._hooks.get('afterCreate') || [];
|
||||||
|
expect(hooks.length).toBe(1); // 仅剩内部 injectGlobalPlugins
|
||||||
|
expect(hooks[0]).not.toBe(fn);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy 后 MeEditor.use 全局插件仍可用', () => {
|
||||||
|
destroy();
|
||||||
|
use('autoSave');
|
||||||
|
const ed = create(container, { value: 'x' });
|
||||||
|
expect(ed.getPlugins().some((p: any) => p.name === 'autoSave')).toBe(true);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test('window.MeEditor 被设置', () => {
|
test('window.MeEditor 被设置', () => {
|
||||||
expect((window as any).MeEditor).toBeDefined();
|
expect((window as any).MeEditor).toBeDefined();
|
||||||
expect((window as any).MeEditor.VERSION).toBe(VERSION);
|
expect((window as any).MeEditor.VERSION).toBe(VERSION);
|
||||||
@@ -156,14 +193,17 @@ describe('index.ts - 解析器导出', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('registerBlockHandler 注册自定义块', () => {
|
test('registerBlockHandler 注册自定义块', () => {
|
||||||
|
let matched = false;
|
||||||
registerBlockHandler({
|
registerBlockHandler({
|
||||||
name: 'testBlock',
|
name: 'testBlockIndex',
|
||||||
priority: 50,
|
priority: 50,
|
||||||
test: () => null,
|
test: (line) => { if (line === '!!!custom!!!') { matched = true; return true; } return null; },
|
||||||
parse: (_l, i) => ({ token: null, newIndex: i }),
|
parse: (_l, i) => ({ token: { type: 'testBlockIndex', content: 'hit' }, newIndex: i + 1 }),
|
||||||
});
|
});
|
||||||
// 不抛错即通过
|
const html = parseMarkdown('!!!custom!!!\nafter');
|
||||||
expect(true).toBe(true);
|
// 自定义 handler 被真实命中并产出 token(回退渲染为 me-block-* div)
|
||||||
|
expect(matched).toBe(true);
|
||||||
|
expect(html).toContain('me-block-testBlockIndex');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+180
-3
@@ -3,7 +3,7 @@
|
|||||||
* 覆盖 Markdown 解析器的所有语法分支与安全特性
|
* 覆盖 Markdown 解析器的所有语法分支与安全特性
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from '../src/parser';
|
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler } from '../src/parser';
|
||||||
|
|
||||||
describe('parseMarkdown - 基础', () => {
|
describe('parseMarkdown - 基础', () => {
|
||||||
test('空输入返回空字符串', () => {
|
test('空输入返回空字符串', () => {
|
||||||
@@ -909,12 +909,18 @@ describe('parseMarkdown - v0.1.15 registerBlockHandler', () => {
|
|||||||
describe('parseMarkdown - 覆盖率:渲染缓存淘汰', () => {
|
describe('parseMarkdown - 覆盖率:渲染缓存淘汰', () => {
|
||||||
test('缓存超过上限触发 FIFO 淘汰', () => {
|
test('缓存超过上限触发 FIFO 淘汰', () => {
|
||||||
// MAX_CACHE_SIZE = 300,填充 301 个极短文本触发淘汰(避免 CI 超时)
|
// MAX_CACHE_SIZE = 300,填充 301 个极短文本触发淘汰(避免 CI 超时)
|
||||||
|
clearRenderCache();
|
||||||
const short = 'x';
|
const short = 'x';
|
||||||
for (let i = 0; i < 301; i++) {
|
for (let i = 0; i < 301; i++) {
|
||||||
parseMarkdown(short + i);
|
parseMarkdown(short + i);
|
||||||
}
|
}
|
||||||
// 不应抛错,且至少有一次淘汰发生
|
// 缓存条目数被 FIFO 上限约束在 300,证明淘汰真实发生
|
||||||
expect(true).toBe(true);
|
expect(getRenderCacheSize()).toBe(300);
|
||||||
|
// 超限后再写入仍维持上限
|
||||||
|
parseMarkdown('overflow-entry');
|
||||||
|
expect(getRenderCacheSize()).toBe(300);
|
||||||
|
clearRenderCache();
|
||||||
|
expect(getRenderCacheSize()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1270,3 +1276,174 @@ describe('parseMarkdown - v0.2.2 自定义 token 渲染', () => {
|
|||||||
expect(html).toContain('<strong>Important!</strong>');
|
expect(html).toContain('<strong>Important!</strong>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ v0.2.5 链接 title 属性转义 ============
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.2.5 title 转义', () => {
|
||||||
|
test('链接 title 中的引号被转义', () => {
|
||||||
|
const html = parseMarkdown('[text](https://example.com "say \\"hi\\"")');
|
||||||
|
expect(html).toContain('title="say "hi""');
|
||||||
|
expect(html).not.toContain('title="say "hi""');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('链接 title 中的尖括号被转义', () => {
|
||||||
|
const html = parseMarkdown('[text](https://example.com "a<b>c")');
|
||||||
|
expect(html).toContain('title="a<b>c"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图片 title 被转义', () => {
|
||||||
|
const html = parseMarkdown('');
|
||||||
|
expect(html).toContain('title="x"y"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用链接 title 被转义', () => {
|
||||||
|
const html = parseMarkdown('[ref]: https://example.com "a\\"b"\n\n[text][ref]');
|
||||||
|
expect(html).toContain('title="a"b"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用图片 title 被转义', () => {
|
||||||
|
const html = parseMarkdown('[img]: https://example.com/i.png "c<d>"\n\n![alt][img]');
|
||||||
|
expect(html).toContain('title="c<d>"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('链接 URL 中的引号不能注入属性', () => {
|
||||||
|
const html = parseMarkdown('[x](https://example.com/"onclick="alert(1))');
|
||||||
|
expect(html).not.toContain(' onclick=');
|
||||||
|
expect(html).toContain('"onclick');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图片 alt 中的引号不能注入属性', () => {
|
||||||
|
const html = parseMarkdown('');
|
||||||
|
expect(html).not.toContain(' onerror="');
|
||||||
|
expect(html).toContain('"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('代码块语言属性中的引号不能注入', () => {
|
||||||
|
const html = parseMarkdown('```js" onclick="x\ncode\n```');
|
||||||
|
expect(html).not.toContain('language-js');
|
||||||
|
expect(html).not.toContain('onclick="x"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('代码块 title 属性中的引号不能注入', () => {
|
||||||
|
const html = parseMarkdown('```js title="a" onload="x"\ncode\n```');
|
||||||
|
expect(html).not.toContain('onload');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('链接 title 中的反斜杠转义被还原', () => {
|
||||||
|
const html = parseMarkdown('[text](https://example.com "say \\"hi\\"")');
|
||||||
|
expect(html).toContain('title="say "hi""');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.2.5 脚注 id 属性注入防护', () => {
|
||||||
|
test('脚注引用 id 中的引号不能注入属性', () => {
|
||||||
|
const html = parseMarkdown('[^a" onclick="alert(1)]\n\n[^a" onclick="alert(1)]: note');
|
||||||
|
expect(html).not.toContain(' onclick="');
|
||||||
|
expect(html).not.toContain('onclick="alert(1)"');
|
||||||
|
expect(html).toContain('" onclick');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('脚注区 id 中的引号被转义', () => {
|
||||||
|
const html = parseMarkdown('x[^a" onload="x]\n\n[^a" onload="x]: note');
|
||||||
|
expect(html).not.toContain(' onload="');
|
||||||
|
expect(html).toContain('" onload');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('正常脚注 id 不受影响', () => {
|
||||||
|
const html = parseMarkdown('text[^1]\n\n[^1]: note');
|
||||||
|
expect(html).toContain('id="fnref-1"');
|
||||||
|
expect(html).toContain('id="fn-1"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ v0.3.0 白名单裸标签透传(underline) ============
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.3.0 underline 渲染', () => {
|
||||||
|
test('裸 <u> 标签透传为下划线', () => {
|
||||||
|
const html = parseMarkdown('<u>underlined</u>');
|
||||||
|
expect(html).toContain('<p><u>underlined</u></p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+U 命令输出在预览中可见', () => {
|
||||||
|
const html = parseMarkdown('<u>hello</u> world');
|
||||||
|
expect(html).toContain('<u>hello</u>');
|
||||||
|
expect(html).not.toContain('<u>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('带属性的 <u onclick> 仍被转义', () => {
|
||||||
|
const html = parseMarkdown('<u onclick="alert(1)">x</u>');
|
||||||
|
expect(html).not.toContain('<u onclick');
|
||||||
|
expect(html).toContain('<u');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('行内代码中的 <u> 不被透传', () => {
|
||||||
|
const html = parseMarkdown('`<u>x</u>`');
|
||||||
|
expect(html).toContain('<code><u>x</u></code>');
|
||||||
|
expect(html).not.toContain('<u>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('代码块中的 <u> 不被透传', () => {
|
||||||
|
const html = parseMarkdown('```\n<u>x</u>\n```');
|
||||||
|
expect(html).toContain('<u>');
|
||||||
|
expect(html).not.toContain('<u>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('<script> 仍被转义(白名单外)', () => {
|
||||||
|
const html = parseMarkdown('<script>alert(1)</script>');
|
||||||
|
expect(html).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ v0.3.0 渲染缓存 refs 指纹 ============
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.3.0 渲染缓存 refs 指纹', () => {
|
||||||
|
test('相同文本不同引用定义互不串数据', () => {
|
||||||
|
clearRenderCache();
|
||||||
|
const htmlA = parseMarkdown('[x][r]\n\n[r]: https://a.com');
|
||||||
|
const htmlB = parseMarkdown('[x][r]\n\n[r]: https://b.com');
|
||||||
|
expect(htmlA).toContain('href="https://a.com"');
|
||||||
|
expect(htmlB).toContain('href="https://b.com"');
|
||||||
|
expect(htmlA).not.toContain('href="https://b.com"');
|
||||||
|
expect(htmlB).not.toContain('href="https://a.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无引用定义的文本仍可命中缓存', () => {
|
||||||
|
clearRenderCache();
|
||||||
|
expect(parseMarkdown('**bold** text')).toBe(parseMarkdown('**bold** text'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用链接与无引用文本混合时各自正确', () => {
|
||||||
|
clearRenderCache();
|
||||||
|
const htmlA = parseMarkdown('[x][r] and **plain**\n\n[r]: https://a.com');
|
||||||
|
const htmlB = parseMarkdown('[x][r] and **plain**\n\n[r]: https://b.com');
|
||||||
|
expect(htmlA).toContain('https://a.com');
|
||||||
|
expect(htmlB).toContain('https://b.com');
|
||||||
|
expect(htmlB).toContain('<strong>plain</strong>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.2.5 URL 属性无双重转义', () => {
|
||||||
|
test('链接 URL 中的 & 不双重转义', () => {
|
||||||
|
const html = parseMarkdown('[x](https://example.com/?a=1&b=2)');
|
||||||
|
expect(html).toContain('href="https://example.com/?a=1&b=2"');
|
||||||
|
expect(html).not.toContain('&amp;');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图片 alt 中的 & 不双重转义', () => {
|
||||||
|
const html = parseMarkdown('');
|
||||||
|
expect(html).toContain('alt="a & b"');
|
||||||
|
expect(html).not.toContain('&amp;');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用链接 URL 中的 & 不被破坏', () => {
|
||||||
|
const html = parseMarkdown('[r]: https://example.com/?a=1&b=2\n\n[x][r]');
|
||||||
|
expect(html).toContain('href="https://example.com/?a=1&b=2"');
|
||||||
|
expect(html).not.toContain('&amp;');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('URL 中的引号仍被转义防注入', () => {
|
||||||
|
const html = parseMarkdown('[x](https://example.com/"onclick="alert(1))');
|
||||||
|
expect(html).not.toContain(' onclick="');
|
||||||
|
expect(html).toContain('"onclick');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+293
-9
@@ -570,7 +570,9 @@ describe('searchReplace 插件 - 完整流程', () => {
|
|||||||
input.value = 'foo';
|
input.value = 'foo';
|
||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
ed.el.querySelector('.me-search-next').click();
|
ed.el.querySelector('.me-search-next').click();
|
||||||
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
|
// 首个匹配位于 0,且选中内容即搜索词
|
||||||
|
expect(ed.textarea.selectionStart).toBe(0);
|
||||||
|
expect(ed.getSelectedText()).toBe('foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('查找上一个高亮匹配', () => {
|
test('查找上一个高亮匹配', () => {
|
||||||
@@ -580,7 +582,9 @@ describe('searchReplace 插件 - 完整流程', () => {
|
|||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
ed.el.querySelector('.me-search-next').click();
|
ed.el.querySelector('.me-search-next').click();
|
||||||
ed.el.querySelector('.me-search-prev').click();
|
ed.el.querySelector('.me-search-prev').click();
|
||||||
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
|
// next 选中第一处(0),prev 回绕到最后一处('foo bar foo baz foo' 中 16)
|
||||||
|
expect(ed.textarea.selectionStart).toBe(16);
|
||||||
|
expect(ed.getSelectedText()).toBe('foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('空查询不抛错', () => {
|
test('空查询不抛错', () => {
|
||||||
@@ -600,7 +604,8 @@ describe('searchReplace 插件 - 完整流程', () => {
|
|||||||
input.value = 'foo';
|
input.value = 'foo';
|
||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
|
expect(ed.textarea.selectionStart).toBe(0);
|
||||||
|
expect(ed.getSelectedText()).toBe('foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Shift+Enter 在 findInput 上查找上一个', () => {
|
test('Shift+Enter 在 findInput 上查找上一个', () => {
|
||||||
@@ -609,7 +614,9 @@ describe('searchReplace 插件 - 完整流程', () => {
|
|||||||
input.value = 'foo';
|
input.value = 'foo';
|
||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
|
||||||
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
|
// 初始光标在 0,prev 无更早匹配时回绕到最后一处(16)
|
||||||
|
expect(ed.textarea.selectionStart).toBe(16);
|
||||||
|
expect(ed.getSelectedText()).toBe('foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('替换行 display 切换(none ↔ flex)', () => {
|
test('替换行 display 切换(none ↔ flex)', () => {
|
||||||
@@ -794,20 +801,21 @@ describe('零散分支补全', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('autoSave save 内部抛错时 warn 不崩溃', () => {
|
test('autoSave save 内部抛错时 warn 不崩溃', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
const c = document.createElement('div');
|
const c = document.createElement('div');
|
||||||
document.body.appendChild(c);
|
document.body.appendChild(c);
|
||||||
const ed = new MarkdownEditor(c, { value: 'test' });
|
const ed = new MarkdownEditor(c, { value: 'test' });
|
||||||
ed.use('autoSave');
|
ed.use('autoSave', { delay: 50 });
|
||||||
// 让 editor.getValue 抛错,触发 save 的 catch 分支
|
// 让 editor.getValue 抛错,触发 save 的 catch 分支
|
||||||
jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); });
|
jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); });
|
||||||
const cleanup = (ed as any).__autoSaveCleanup;
|
ed._emit('change', 'x');
|
||||||
expect(cleanup).toBeDefined();
|
expect(() => jest.advanceTimersByTime(60)).not.toThrow();
|
||||||
expect(() => cleanup.save()).not.toThrow();
|
|
||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
ed.destroy();
|
ed.destroy();
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restoreDraft localStorage.getItem 抛错时返回 null', () => {
|
test('restoreDraft localStorage.getItem 抛错时返回 null', () => {
|
||||||
@@ -833,7 +841,6 @@ describe('零散分支补全', () => {
|
|||||||
// Ctrl+F 打开面板
|
// Ctrl+F 打开面板
|
||||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
|
||||||
expect(ed.el.querySelector('.me-search')).not.toBeNull();
|
expect(ed.el.querySelector('.me-search')).not.toBeNull();
|
||||||
expect((ed as any).__srState._panel).toBeDefined();
|
|
||||||
// Ctrl+Escape 关闭面板(带 Ctrl 才会进入分支)
|
// Ctrl+Escape 关闭面板(带 Ctrl 才会进入分支)
|
||||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true }));
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true }));
|
||||||
expect(ed.el.querySelector('.me-search')).toBeNull();
|
expect(ed.el.querySelector('.me-search')).toBeNull();
|
||||||
@@ -1125,3 +1132,280 @@ describe('v0.2.0 exportTool 插件', () => {
|
|||||||
ed.destroy();
|
ed.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ v0.2.5 searchReplace 大小写 / 全字匹配 ============
|
||||||
|
|
||||||
|
describe('v0.2.5 searchReplace 大小写与全字匹配', () => {
|
||||||
|
const setup = (value: string) => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value });
|
||||||
|
ed.use('searchReplace');
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
|
||||||
|
const fi = ed.el.querySelector('.me-search-find') as HTMLInputElement;
|
||||||
|
const ce = ed.el.querySelector('.me-search-count') as HTMLElement;
|
||||||
|
const caseBtn = ed.el.querySelector('.me-search-case') as HTMLButtonElement;
|
||||||
|
const wordBtn = ed.el.querySelector('.me-search-word') as HTMLButtonElement;
|
||||||
|
return { ed, fi, ce, caseBtn, wordBtn };
|
||||||
|
};
|
||||||
|
|
||||||
|
const teardown = (ed: any) => {
|
||||||
|
const panel = ed.el.querySelector('.me-search');
|
||||||
|
if (panel) panel.remove();
|
||||||
|
ed.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
test('默认区分大小写', () => {
|
||||||
|
const { ed, fi, ce, caseBtn, wordBtn } = setup('Foo foo FOO');
|
||||||
|
fi.value = 'foo';
|
||||||
|
fi.dispatchEvent(new Event('input'));
|
||||||
|
expect(ce.textContent).toBe('1');
|
||||||
|
expect(caseBtn.classList.contains('me-active')).toBe(true);
|
||||||
|
teardown(ed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关闭大小写后匹配全部', () => {
|
||||||
|
const { ed, fi, ce, caseBtn } = setup('Foo foo FOO');
|
||||||
|
fi.value = 'foo';
|
||||||
|
caseBtn.click();
|
||||||
|
fi.dispatchEvent(new Event('input'));
|
||||||
|
expect(caseBtn.classList.contains('me-active')).toBe(false);
|
||||||
|
expect(ce.textContent).toBe('3');
|
||||||
|
teardown(ed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('全字匹配排除子串', () => {
|
||||||
|
const { ed, fi, ce, wordBtn } = setup('cat category scatter cat');
|
||||||
|
fi.value = 'cat';
|
||||||
|
fi.dispatchEvent(new Event('input'));
|
||||||
|
expect(ce.textContent).toBe('4');
|
||||||
|
wordBtn.click();
|
||||||
|
fi.dispatchEvent(new Event('input'));
|
||||||
|
expect(ce.textContent).toBe('2');
|
||||||
|
teardown(ed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('全字匹配支持中文边界', () => {
|
||||||
|
const { ed, fi, ce, wordBtn } = setup('测试test测试 test');
|
||||||
|
fi.value = 'test';
|
||||||
|
wordBtn.click();
|
||||||
|
fi.dispatchEvent(new Event('input'));
|
||||||
|
expect(ce.textContent).toBe('1');
|
||||||
|
teardown(ed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaceAll 大小写不敏感替换', () => {
|
||||||
|
const { ed, fi, caseBtn } = setup('Foo foo FOO');
|
||||||
|
fi.value = 'foo';
|
||||||
|
caseBtn.click();
|
||||||
|
const ri = ed.el.querySelector('.me-search-replace') as HTMLInputElement;
|
||||||
|
ri.value = 'bar';
|
||||||
|
const allBtn = ed.el.querySelector('.me-search-replace-all') as HTMLButtonElement;
|
||||||
|
allBtn.click();
|
||||||
|
expect(ed.getValue()).toBe('bar bar bar');
|
||||||
|
teardown(ed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('正则模式 + 全字匹配', () => {
|
||||||
|
const { ed, fi, ce, wordBtn, caseBtn } = setup('cat category scatter cat');
|
||||||
|
fi.value = 'ca[nt]';
|
||||||
|
const regexBtn = ed.el.querySelector('.me-search-regex') as HTMLButtonElement;
|
||||||
|
regexBtn.click();
|
||||||
|
caseBtn.click();
|
||||||
|
wordBtn.click();
|
||||||
|
fi.dispatchEvent(new Event('input'));
|
||||||
|
expect(ce.textContent).toBe('2');
|
||||||
|
teardown(ed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ v0.2.5 exportPDF / imagePaste 尺寸限制 ============
|
||||||
|
|
||||||
|
describe('v0.2.5 exportTool exportPDF', () => {
|
||||||
|
test('exportPDF 创建隐藏 iframe 不抛错', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '# pdf test' });
|
||||||
|
ed.use('exportTool');
|
||||||
|
const printSpy = jest.spyOn(window, 'print').mockImplementation(() => {});
|
||||||
|
expect(() => ed.exportPDF({ title: 'T' })).not.toThrow();
|
||||||
|
printSpy.mockRestore();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exportHTML 与 exportPDF 共用构建逻辑', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '# hi' });
|
||||||
|
ed.use('exportTool');
|
||||||
|
expect(typeof (ed as any).exportPDF).toBe('function');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('v0.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 = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '' });
|
||||||
|
ed.use('imagePaste', { maxSizeKB: 1 });
|
||||||
|
const toastSpy = jest.spyOn(ed, 'toast').mockImplementation(() => ed);
|
||||||
|
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
|
||||||
|
const file = new File([new ArrayBuffer(4096)], 'big.png', { type: 'image/png' });
|
||||||
|
const evt = new Event('paste', { bubbles: true });
|
||||||
|
Object.defineProperty(evt, 'clipboardData', { value: { items: [{ type: 'image/png', getAsFile: () => file }] } });
|
||||||
|
ed.textarea.dispatchEvent(evt as ClipboardEvent);
|
||||||
|
expect(toastSpy).toHaveBeenCalled();
|
||||||
|
expect(insertSpy).not.toHaveBeenCalled();
|
||||||
|
toastSpy.mockRestore();
|
||||||
|
insertSpy.mockRestore();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('默认 500KB 内正常插入', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '' });
|
||||||
|
ed.use('imagePaste');
|
||||||
|
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
|
||||||
|
const file = new File([new ArrayBuffer(1024)], 'ok.png', { type: 'image/png' });
|
||||||
|
const evt = new Event('paste', { bubbles: true });
|
||||||
|
Object.defineProperty(evt, 'clipboardData', { value: { items: [{ type: 'image/png', getAsFile: () => file }] } });
|
||||||
|
ed.textarea.dispatchEvent(evt as ClipboardEvent);
|
||||||
|
expect(insertSpy).not.toHaveBeenCalled(); // FileReader 异步,同步阶段仅 preventDefault
|
||||||
|
insertSpy.mockRestore();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ 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 面板文案跟随实例 locale(en-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 面板文案跟随实例 locale(zh-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 面板文案跟随实例 locale(en-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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -758,6 +758,57 @@ describe('v0.1.5 watch 外部主题源', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('v0.3.1 实例主题 dispose', () => {
|
||||||
|
test('dispose 断开外部跟随的 MutationObserver', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.setAttribute('data-theme', 'dark');
|
||||||
|
document.body.appendChild(div);
|
||||||
|
const mockEditor = {
|
||||||
|
el: div,
|
||||||
|
container: div,
|
||||||
|
config: { theme: 'light' },
|
||||||
|
_emit: jest.fn(),
|
||||||
|
refresh: jest.fn(),
|
||||||
|
};
|
||||||
|
const ctx = createInstanceTheme(mockEditor);
|
||||||
|
const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect');
|
||||||
|
ctx.syncWithElement(div);
|
||||||
|
ctx.dispose();
|
||||||
|
expect(disconnectSpy).toHaveBeenCalled();
|
||||||
|
disconnectSpy.mockRestore();
|
||||||
|
document.body.removeChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose 后可重复调用且不抛错', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
document.body.appendChild(div);
|
||||||
|
const mockEditor = {
|
||||||
|
el: div,
|
||||||
|
container: div,
|
||||||
|
config: { theme: 'light' },
|
||||||
|
_emit: jest.fn(),
|
||||||
|
refresh: jest.fn(),
|
||||||
|
};
|
||||||
|
const ctx = createInstanceTheme(mockEditor);
|
||||||
|
expect(() => { ctx.dispose(); ctx.dispose(); }).not.toThrow();
|
||||||
|
document.body.removeChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MarkdownEditor destroy 时断开实例主题订阅', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
c.setAttribute('data-theme', 'dark');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
const ctx = ed.getThemeContext();
|
||||||
|
const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect');
|
||||||
|
ctx.adopt();
|
||||||
|
ed.destroy();
|
||||||
|
expect(disconnectSpy).toHaveBeenCalled();
|
||||||
|
disconnectSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('v0.1.5 MarkdownEditor 实例主题 API', () => {
|
describe('v0.1.5 MarkdownEditor 实例主题 API', () => {
|
||||||
test('setTheme / getTheme 实例方法', () => {
|
test('setTheme / getTheme 实例方法', () => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user