Compare commits

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

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

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

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

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

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

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

工程:CI lint 阻断、build.sh npm ci、rollup 死代码清理、覆盖率阈值门禁
2026-08-19 22:30:52 +08:00
33 changed files with 2521 additions and 608 deletions
-1
View File
@@ -99,6 +99,5 @@ jobs:
run: npm run typecheck run: npm run typecheck
- name: Lint - name: Lint
run: npm run lint run: npm run lint
continue-on-error: true
- name: Build - name: Build
run: npm run build run: npm run build
+54
View File
@@ -2,6 +2,60 @@
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 / afterDestroy5 个零测试配置项:placeholder / height / spellcheck / tabSize(含 0 与 4/ historyDebounce;修复 3 处恒真/弱断言:同步滚动(含新增反向联动验证)、渲染缓存 FIFO 真实淘汰、搜索 findNext/prev 选中内容),总数 841 → 871。
- jest 覆盖率阈值:全局 lines 85% / branches 70%parser lines 95%(基线 94.95% / 75.72% / 98.93%)。
### Changed
- **CI lint 改为阻断**: 移除 verify job 中 lint 步骤的 `continue-on-error`lint 失败不再静默放行。
- **build.sh 依赖安装**: `npm install``npm ci`,保证构建可重现。
- **rollup.config.js 死代码清理**: 移除从未被引用的 `terserPlugin` / `isProd` 变量。
- 文档全面同步:README / docs.html / index.html 修正 "CI 4 job"(实际 5 个)、`document.body()` 错误写法、`afterChange` / `fileSaved` 事件参数(以源码为准)、docs.html 补齐约 15 处缺失 API`editor.t()``MeEditor.getStatus()` / `destroy()``configureToolbar` / `removeToolbarButton`、PluginManager & pluginUtils、`safeUrl` / `slugify` / `getSupportedLanguages``formatCurrency`、全局 beforeChange / afterChange 钩子、`data:image` 500KB 限制等);demo.html 私有 API hack 改用 `setOutline()` 公开 API。
---
## [0.4.1] - 2026-08-11 ## [0.4.1] - 2026-08-11
### Added ### Added
+16 -11
View File
@@ -2,9 +2,9 @@
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库 > TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
[![version](https://img.shields.io/badge/version-0.4.1-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor) [![version](https://img.shields.io/badge/version-0.4.3-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
[![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-841%20passed-brightgreen)](./tests) [![tests](https://img.shields.io/badge/tests-895%20passed-brightgreen)](./tests)
[![coverage](https://img.shields.io/badge/coverage-parser%2099%25-brightgreen)](./tests) [![coverage](https://img.shields.io/badge/coverage-parser%2099%25-brightgreen)](./tests)
[![types](https://img.shields.io/badge/types-TypeScript%20strict-blue)](./tsconfig.json) [![types](https://img.shields.io/badge/types-TypeScript%20strict-blue)](./tsconfig.json)
@@ -17,11 +17,11 @@
- **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步 - **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步
- **主题系统** — light / dark / warm / autoCSS 变量可定制,实例级隔离,主题继承,外部跟随 - **主题系统** — light / dark / warm / autoCSS 变量可定制,实例级隔离,主题继承,外部跟随
- **国际化** — zh-CN / en-US / ja / ko / fr / de 六种语言完整翻译,实例级语言隔离,远程加载翻译包 - **国际化** — zh-CN / en-US / ja / ko / fr / de 六种语言完整翻译,实例级语言隔离,远程加载翻译包
- **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式(宽度可配置,默认 960px)、右键上下文菜单 - **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式(宽度可配置,默认 960px)、右键上下文菜单、浮动格式工具栏(mirror 镜像像素级定位,CJK / 软换行 / 跨行选区精确跟随,键盘可达)
- **内置语法高亮** — 零依赖轻量高亮器,js/ts/python/bash/css/html 等 16 个语言标识,规则缓存零重建开销,`MeEditor.highlight` 即插即用 - **内置语法高亮** — 零依赖轻量高亮器,js/ts/python/bash/css/html 等 16 个语言标识,规则缓存零重建开销,`MeEditor.highlight` 即插即用
- **性能** — 字数 / 词数 / 行数差异增量统计,击键零全量扫描;大纲 O(n) 线性构建,长文档即时响应 - **性能** — 字数 / 词数 / 行数差异增量统计,击键零全量扫描;大纲 O(n) 线性构建,长文档即时响应
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护(含脚注 id),白名单裸标签透传,sanitize 钩子 - **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护(含脚注 id),白名单裸标签透传,sanitize 钩子
- **工程化** — 841 个单元测试 + Playwright 真实浏览器冒烟测试,CI 并行 4 job`sideEffects: false` 便于 tree-shaking - **工程化** — 895 个单元测试 + Playwright 真实浏览器冒烟测试28 项断言)CI 并行 5 job(含 e2e),覆盖率阈值门禁`sideEffects: false` 便于 tree-shaking
- **桌面端优先** — 纯电脑端设计,无移动端冗余代码 - **桌面端优先** — 纯电脑端设计,无移动端冗余代码
- **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性 - **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性
- **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等) - **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等)
@@ -256,6 +256,8 @@ editor.toggleFloatingToolbar(): this // 开关选中文本格式栏
editor.isFloatingToolbar(): boolean editor.isFloatingToolbar(): boolean
``` ```
选中文本时自动浮现粗体 / 斜体 / 代码 / 链接 / 删除线按钮。定位采用 mirror 镜像测量(隐藏镜像层复制 textarea 排版样式,选区锚点插入零宽字符读像素坐标),中文混排、长行软换行、跨行选区均精确跟随;选区滚出可视区自动隐藏,滚动时节流重定位。按钮键盘可达(Tab 聚焦 + Enter 执行),隐藏态移出 Tab 序列。已知限制:RTL 布局下定位未做镜像适配。
### 命令执行 ### 命令执行
```typescript ```typescript
@@ -348,7 +350,7 @@ editor.configureToolbar(tools: string[]): this
editor.removeToolbarButton(action: string): this editor.removeToolbarButton(action: string): this
``` ```
### Zen / Word Wrap ### Zen / Word Wrap / Outline
```typescript ```typescript
editor.toggleZen(): this editor.toggleZen(): this
@@ -358,6 +360,8 @@ editor.getZenMaxWidth(): number | string | false
editor.toggleWordWrap(): this editor.toggleWordWrap(): this
editor.setWordWrap(on: boolean): this editor.setWordWrap(on: boolean): this
editor.isWordWrap(): boolean editor.isWordWrap(): boolean
editor.setOutline(on: boolean): this // 运行时开关大纲面板
editor.isOutline(): boolean
``` ```
### Toast ### Toast
@@ -414,7 +418,7 @@ MeEditor.getStatus()
MeEditor.destroy() MeEditor.destroy()
// 解析器独立使用 // 解析器独立使用
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlockHandler } from '@metona-team/metona-editor'; import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler } from '@metona-team/metona-editor';
// 内置语法高亮 // 内置语法高亮
import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team/metona-editor'; import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team/metona-editor';
@@ -428,7 +432,7 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team
| 事件 | 触发时机 | 参数 | | 事件 | 触发时机 | 参数 |
|------|---------|------| |------|---------|------|
| `input` | textarea 原生 input | `(value, editor)` | | `input` | 内容变化(原生输入与程序化编辑路径均触发) | `(value, editor)` |
| `change` | 内容变化 | `(value, editor)` | | `change` | 内容变化 | `(value, editor)` |
| `focus` | 聚焦 | `(editor)` | | `focus` | 聚焦 | `(editor)` |
| `blur` | 失焦 | `(editor)` | | `blur` | 失焦 | `(editor)` |
@@ -445,8 +449,8 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team
| `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 })` | | `selectionChange` | 选区变化 | `({ start, end, text })` |
| `cursorMove` | 光标移动 | `({ line, column })` | | `cursorMove` | 光标移动 | `({ line, column })` |
@@ -744,6 +748,7 @@ i18nUtils.formatDate('2024-01-15') // 本地化日期
| `Tab` / `Shift+Tab` | 缩进 / 反缩进 | | `Tab` / `Shift+Tab` | 缩进 / 反缩进 |
| `?` | 快捷键帮助 | | `?` | 快捷键帮助 |
| `Enter` | 智能 Enter(列表/引用延续) | | `Enter` | 智能 Enter(列表/引用延续) |
| `Esc` | 退出全屏 |
--- ---
@@ -818,8 +823,8 @@ src/
|------|------| |------|------|
| 语言 | TypeScript 5 (strict) | | 语言 | TypeScript 5 (strict) |
| 构建 | Rollup 3 | | 构建 | Rollup 3 |
| 测试 | Jest 29 + jsdom841 用例) | | 测试 | Jest 29 + jsdom895 用例,覆盖率阈值门禁 |
| 浏览器冒烟 | Playwright + Chromium22 项断言) | | 浏览器冒烟 | Playwright + Chromium28 项断言) |
| 类型生成 | rollup-plugin-dts | | 类型生成 | rollup-plugin-dts |
| 零运行时依赖 | ✅ | | 零运行时依赖 | ✅ |
+1 -1
View File
@@ -20,7 +20,7 @@ echo "✅ npm版本: $(npm -v)"
echo "" echo ""
echo "📦 安装依赖..." echo "📦 安装依赖..."
npm install npm ci
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "❌ 依赖安装失败" echo "❌ 依赖安装失败"
+377 -121
View File
@@ -109,6 +109,7 @@ const LOCALES = {
outline: '大纲', outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选', cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB', 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',
@@ -136,6 +137,7 @@ const LOCALES = {
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)', 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: '打ち消し線',
@@ -163,6 +165,7 @@ const LOCALES = {
outline: 'アウトライン', outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択', cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB', regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
}, },
ko: { ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -189,7 +192,8 @@ const LOCALES = {
renderError: '렌더링 실패', renderError: '렌더링 실패',
outline: '개요', outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택', cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다{size}KB > {max}KB', regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
formatToolbar: '선택 영역 서식', pasteBlocked: '직접 붙여넣을 수 없습니다. Ctrl+V를 사용하세요',
}, },
fr: { fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -217,6 +221,7 @@ const LOCALES = {
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)', 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',
@@ -244,6 +249,7 @@ const LOCALES = {
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)', 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',
}, },
}; };
@@ -778,6 +784,18 @@ const createInstanceI18n = (editor) => {
} }
}); });
} }
// 语言切换即时刷新已渲染的 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;
}; };
return { return {
@@ -1771,6 +1789,9 @@ const validateConfig = (schema = {}, config = {}) => {
}; };
// ============ Preset plugins ============ // ============ Preset plugins ============
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** 实例级翻译:优先用 editor.t(实例 locale),否则退回全局 t。
* 插件面板文案跟随实例语言而非全局语言。 */
const instanceT = (editor) => (key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
const autoSavePlugin = { const autoSavePlugin = {
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) { install(editor, options) {
@@ -1947,6 +1968,7 @@ const searchReplacePlugin = {
install(editor) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') if (!editor || !editor.textarea || typeof document === 'undefined')
return; 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'); const style = document.createElement('style');
@@ -2013,7 +2035,7 @@ const searchReplacePlugin = {
const panel = document.createElement('div'); const panel = document.createElement('div');
panel.className = 'me-search'; panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0'; panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${t('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${t('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll') || 'All'}</button></div>`; panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${tt('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${tt('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${tt('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${tt('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${tt('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${tt('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${tt('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${tt('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${tt('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${tt('replaceAll') || 'All'}</button></div>`;
editor.el.appendChild(panel); editor.el.appendChild(panel);
state._panel = panel; state._panel = panel;
_updateReplaceVisible(); _updateReplaceVisible();
@@ -2138,7 +2160,12 @@ const searchReplacePlugin = {
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);
} }
const oldV = editor._value || '';
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
}
else {
if (typeof editor._pushHistory === 'function') if (typeof editor._pushHistory === 'function')
editor._pushHistory(); editor._pushHistory();
if (typeof editor._render === 'function') if (typeof editor._render === 'function')
@@ -2147,6 +2174,7 @@ const searchReplacePlugin = {
editor._updateWordCount(); editor._updateWordCount();
if (typeof editor._emit === 'function') if (typeof editor._emit === 'function')
editor._emit('change', editor._value); editor._emit('change', editor._value);
}
findNext(); findNext();
}; };
const replaceAll = () => { const replaceAll = () => {
@@ -2157,9 +2185,14 @@ const searchReplacePlugin = {
const re = buildPattern(q); const re = buildPattern(q);
if (!re) if (!re)
return; return;
const oldV = editor._value || '';
ta.value = ta.value.replace(re, () => r); ta.value = ta.value.replace(re, () => r);
ta.setSelectionRange(0, 0); ta.setSelectionRange(0, 0);
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
}
else {
if (typeof editor._pushHistory === 'function') if (typeof editor._pushHistory === 'function')
editor._pushHistory(); editor._pushHistory();
if (typeof editor._render === 'function') if (typeof editor._render === 'function')
@@ -2168,6 +2201,7 @@ const searchReplacePlugin = {
editor._updateWordCount(); editor._updateWordCount();
if (typeof editor._emit === 'function') if (typeof editor._emit === 'function')
editor._emit('change', editor._value); editor._emit('change', editor._value);
}
findAll(); findAll();
}; };
fi.addEventListener('input', () => { lastIdxs = findAll(); }); fi.addEventListener('input', () => { lastIdxs = findAll(); });
@@ -2239,6 +2273,7 @@ const imagePastePlugin = {
install(editor, options = {}) { install(editor, options = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined') if (!editor || !editor.textarea || typeof document === 'undefined')
return; return;
const tt = instanceT(editor);
const maxSizeKB = options.maxSizeKB || 500; const maxSizeKB = options.maxSizeKB || 500;
const _onPaste = (e) => { const _onPaste = (e) => {
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
@@ -2253,7 +2288,7 @@ const imagePastePlugin = {
if (maxSizeKB > 0 && sizeKB > maxSizeKB) { if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault(); e.preventDefault();
if (typeof editor.toast === 'function') if (typeof editor.toast === 'function')
editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' }); editor.toast(tt('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break; break;
} }
e.preventDefault(); e.preventDefault();
@@ -2280,62 +2315,66 @@ const shortcutHelpPlugin = {
install(editor) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') if (!editor || !editor.textarea || typeof document === 'undefined')
return; return;
const tt = instanceT(editor);
if (!document.getElementById('me-shortcut-style')) { if (!document.getElementById('me-shortcut-style')) {
const s = document.createElement('style'); const s = document.createElement('style');
s.id = 'me-shortcut-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 = null; // 面板引用放入 holder:install 时无法预知未来打开的面板,
const _close = () => { if (_panel) { // 卸载时经 holder 取实时引用,避免面板残留 DOM。
_panel.remove(); const st = { panel: null };
_panel = null; const _close = () => { if (st.panel) {
st.panel.remove();
st.panel = null;
} }; } };
const _open = () => { const _open = () => {
if (_panel) { if (st.panel) {
_close(); _close();
return; return;
} }
// i18n-aware shortcut labels // i18n-aware shortcut labels(跟随实例语言)
const builtin = [ const builtin = [
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'], ['Ctrl+B', tt('bold') || 'Bold'], ['Ctrl+I', tt('italic') || 'Italic'],
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'], ['Ctrl+U', tt('underline') || 'Underline'], ['Ctrl+K', tt('link') || 'Link'],
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'], ['Ctrl+E', tt('code') || 'Code'], ['Ctrl+1/2/3', tt('h1') || 'Heading'],
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'], ['Ctrl+Q', tt('quote') || 'Quote'], ['Ctrl+Z', tt('undo') || 'Undo'],
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'], ['Ctrl+Y', tt('redo') || 'Redo'], ['Ctrl+S', tt('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'], ['Ctrl+F', tt('search') || 'Search'], ['Ctrl+H', tt('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'], ['Tab', tt('indent') || 'Indent'], ['Shift+Tab', tt('outdent') || 'Outdent'],
['?', t('shortcuts') || 'Shortcuts'], ['?', tt('shortcuts') || 'Shortcuts'],
]; ];
let rows = ''; let rows = '';
builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; }); builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'me-shortcut-overlay'; overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`; overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${tt('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close')) overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close'))
_close(); }); _close(); });
document.body.appendChild(overlay); document.body.appendChild(overlay);
_panel = overlay; st.panel = overlay;
}; };
const _onKeydown = (e) => { const _onKeydown = (e) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault(); e.preventDefault();
_open(); _open();
} }
if (e.key === 'Escape' && _panel) if (e.key === 'Escape' && st.panel)
_close(); _close();
}; };
editor.textarea.addEventListener('keydown', _onKeydown); editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown }); setPluginState(editor, K_SHORTCUT, { state: st, _open, _close, _onKeydown });
}, },
destroy(editor) { destroy(editor) {
const state = pluginState(editor, K_SHORTCUT); const exposed = pluginState(editor, K_SHORTCUT);
if (state) { if (exposed) {
if (state._panel) { if (exposed.state && exposed.state.panel) {
state._panel.remove(); exposed.state.panel.remove();
exposed.state.panel = null;
} }
} }
const _onKeydown = state?._onKeydown; const _onKeydown = exposed?._onKeydown;
if (_onKeydown && editor?.textarea) if (_onKeydown && editor?.textarea)
editor.textarea.removeEventListener('keydown', _onKeydown); editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT); deletePluginState(editor, K_SHORTCUT);
@@ -2828,11 +2867,10 @@ function wrapSelection(before, after) {
else { else {
ta.selectionStart = ta.selectionEnd = start + before.length; ta.selectionStart = ta.selectionEnd = start + before.length;
} }
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Line prefix toggling ============ // ============ Line prefix toggling ============
function toggleLinePrefix(prefix) { function toggleLinePrefix(prefix) {
@@ -2853,11 +2891,10 @@ function toggleLinePrefix(prefix) {
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Block insertion ============ // ============ Block insertion ============
function insertBlock(text) { function insertBlock(text) {
@@ -2870,11 +2907,10 @@ function insertBlock(text) {
ta.focus(); ta.focus();
const pos = start + insert.length; const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos; ta.selectionStart = ta.selectionEnd = pos;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
function insertLink() { function insertLink() {
const ta = this.textarea; const ta = this.textarea;
@@ -2887,10 +2923,10 @@ function insertLink() {
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 3; ta.selectionStart = start + sel.length + 3;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertImage() { function insertImage() {
const ta = this.textarea; const ta = this.textarea;
@@ -2903,10 +2939,10 @@ function insertImage() {
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 4; ta.selectionStart = start + sel.length + 4;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertTable(rows = 3, cols = 3) { function insertTable(rows = 3, cols = 3) {
const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | '); const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | ');
@@ -2948,10 +2984,10 @@ function formatTable() {
return '| ' + padded.join(' | ') + ' |'; return '| ' + padded.join(' | ') + ' |';
}); });
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
// ============ Prototype installation ============ // ============ Prototype installation ============
const installCommands = (proto) => { const installCommands = (proto) => {
@@ -2967,8 +3003,17 @@ const installCommands = (proto) => {
/** /**
* MetonaEditor Floating Toolbar — selection format toolbar * MetonaEditor Floating Toolbar — selection format toolbar
* @module floating-toolbar * @module floating-toolbar
* @version 0.4.0 * @version 0.4.3
*
* 定位策略(v0.4.3 重做):mirror 镜像测量。
* 创建与 textarea 排版样式一致的隐藏镜像层,在选区锚点(文档序中点)处
* 插入零宽字符 marker,读取其 offsetLeft/offsetTop 得到像素级精确坐标,
* 彻底替代旧版按字符宽度估算的方案(CJK、软换行、tab 宽度、字体
* fallback 的误差全部消除)。锚点滚出可视区自动隐藏;textarea 滚动时
* rAF 节流重定位;divider 拖拽结束 / 窗口 resize 后隐藏待重新触发。
*/ */
/** 浮动工具栏与选区锚点之间的间距(px) */
const ANCHOR_GAP = 6;
function initFloatingToolbar() { function initFloatingToolbar() {
if (typeof document === 'undefined') if (typeof document === 'undefined')
return; return;
@@ -2976,11 +3021,9 @@ function initFloatingToolbar() {
if (!document.getElementById('me-float-style')) { if (!document.getElementById('me-float-style')) {
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'me-float-style'; style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`; style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;visibility:hidden;transform:translateY(4px);transition:opacity .15s,transform .15s,visibility .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}.me-float-mirror{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;z-index:-1;border:0;margin:0}`;
document.head.appendChild(style); document.head.appendChild(style);
} }
if (!this._floatingEnabled)
return;
const ta = this.textarea; const ta = this.textarea;
const show = () => { const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) if (this._destroyed || this.config.readOnly || !this._floatingEnabled)
@@ -2993,48 +3036,130 @@ function initFloatingToolbar() {
} }
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) if (!this._floatingToolbar || !this._floatingToolbar.parentNode)
this._buildFloatingToolbar(); this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect(); const anchor = this._measureSelectionAnchor();
const paneRect = this.editorPane.getBoundingClientRect(); 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 taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22; const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16; const paneW = this.editorPane.getBoundingClientRect().width;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18; let top = anchor.y - barH - ANCHOR_GAP;
const fontSize = parseFloat(taStyle.fontSize) || 13.5; if (top < ANCHOR_GAP)
const charWidth = fontSize * 0.6; top = anchor.y + lineHeight + ANCHOR_GAP;
const textBefore = ta.value.substring(0, start); let left = anchor.x - barW / 2;
const lines = textBefore.split('\n'); left = Math.max(4, Math.min(left, paneW - barW - 4));
const currentLine = lines.length - 1; if (left < 4)
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1; left = 4; // 极窄面板兜底
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1)); bar.style.top = top + 'px';
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop; bar.style.left = left + 'px';
const lineLocalTop = lineViewportTop - paneRect.top; bar.classList.add('me-visible');
const toolbarHeight = 36;
const gap = 4;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap)
top = lineLocalTop + lineHeight + gap;
const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft;
let left = colX - paneRect.left - 80;
left = Math.max(4, Math.min(left, paneRect.width - 200));
this._floatingToolbar.style.top = top + 'px';
this._floatingToolbar.style.left = left + 'px';
this._floatingToolbar.classList.add('me-visible');
}; };
const hide = () => { this._hideFloatingToolbar(); }; const hide = () => { this._hideFloatingToolbar(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0)); // 滚动跟随:rAF 节流重定位(保持纯事件驱动,不做轮询)
ta.addEventListener('keyup', () => { 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 统一发射 // selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
if (ta.selectionStart !== ta.selectionEnd) if (ta.selectionStart !== ta.selectionEnd)
setTimeout(show, 0); setTimeout(show, 0);
else else
setTimeout(hide, 0); setTimeout(hide, 0);
}); };
ta.addEventListener('blur', () => setTimeout(hide, 300)); const onBlur = () => setTimeout(() => {
ta.addEventListener('click', () => { // 焦点快速抖动场景(如自动化 fill 后还原焦点再 focus 回来、
// 宿主脚本焦点切换):延迟到期时焦点已回到 textarea 则不隐藏
if (document.activeElement === ta)
return;
hide();
}, 300);
const onClick = () => {
if (ta.selectionStart === ta.selectionEnd) if (ta.selectionStart === ta.selectionEnd)
hide(); 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 的局部坐标)。
* 锚点 = 选区文档序中点:单行选区即视觉中部,跨行选区约在选区垂直中心。
*/
function measureSelectionAnchor() {
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 = [
['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[k] = v; });
// 宽度对齐 textarea 文本排版区(clientWidth 含 padding 不含滚动条)
mirror.style.width = ta.clientWidth + 'px';
// 仅需锚点前缀文本:其后内容不影响锚点位置(折行由前缀决定)
mirror.textContent = '';
mirror.appendChild(document.createTextNode(ta.value.slice(0, anchor)));
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
// marker 坐标相对 mirrorposition:absolute 的 offsetParentborder 0),
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
const x = marker.offsetLeft - ta.scrollLeft;
const y = marker.offsetTop - ta.scrollTop;
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
}
function toggleFloatingToolbar() { function toggleFloatingToolbar() {
this._floatingEnabled = !this._floatingEnabled; this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) { if (!this._floatingEnabled) {
@@ -3050,15 +3175,23 @@ function isFloatingToolbar() { return this._floatingEnabled; }
function buildFloatingToolbar() { function buildFloatingToolbar() {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.className = 'me-float-toolbar'; bar.className = 'me-float-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', this.t('formatToolbar') || '格式化选区');
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough']; const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => { actions.forEach((action) => {
const btn = this._createBtn(action); const btn = this._createBtn(action);
// mousedown 阻止夺焦(textarea 选区与焦点保持);命令执行统一走 click,
// 键盘 Enter/Space 触发的 click 同样生效(可访问性)
btn.addEventListener('mousedown', (e) => { btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
});
btn.addEventListener('click', () => {
this.exec(action); this.exec(action);
// Keep selection after exec this._hideFloatingToolbar();
setTimeout(() => this.textarea.focus(), 0); // Keep selection after exec(延迟回调时实例可能已销毁)
setTimeout(() => { if (!this._destroyed && this.textarea)
this.textarea.focus(); }, 0);
}); });
bar.appendChild(btn); bar.appendChild(btn);
}); });
@@ -3077,6 +3210,7 @@ const installFloatingToolbar = (proto) => {
proto.isFloatingToolbar = isFloatingToolbar; proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar; proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar; proto._hideFloatingToolbar = hideFloatingToolbar;
proto._measureSelectionAnchor = measureSelectionAnchor;
}; };
/** /**
@@ -3141,7 +3275,8 @@ function showContextMenu(e) {
el.className = 'me-context-menu-item'; el.className = 'me-context-menu-item';
if (item.disabled) if (item.disabled)
el.classList.add('me-disabled'); el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`; // 自定义菜单项的 label / shortcut 经转义后拼接(防 HTML 注入)
el.innerHTML = `<span>${escapeHTML(item.label)}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${escapeHTML(item.shortcut)}</span>` : ''}`;
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
if (item.disabled) if (item.disabled)
@@ -3187,9 +3322,13 @@ function execContextAction(action) {
case 'copy': case 'copy':
document.execCommand('copy'); document.execCommand('copy');
break; break;
case 'paste': case 'paste': {
document.execCommand('paste'); // 现代浏览器出于安全限制,execCommand('paste') 通常静默失败 —— 提示改用 Ctrl+V
const ok = document.execCommand('paste');
if (!ok)
this.toast(this.t('pasteBlocked') || '无法直接粘贴,请使用 Ctrl+V', { type: 'info' });
break; break;
}
case 'selectAll': case 'selectAll':
ta.focus(); ta.focus();
ta.select(); ta.select();
@@ -3213,6 +3352,12 @@ const installContextMenu = (proto) => {
* @module outline * @module outline
* @version 0.4.0 * @version 0.4.0
*/ */
/** querySelector 用的 id 转义:优先原生 CSS.escape,环境缺失时退化为简易转义 */
const cssEscape = (id) => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function')
return CSS.escape(id);
return id.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
function buildOutline() { function buildOutline() {
if (!this.config.outline || !this.previewEl) if (!this.config.outline || !this.previewEl)
return; return;
@@ -3223,10 +3368,40 @@ function buildOutline() {
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi; const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m; let m;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) { while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') }); headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, ''), srcLine: -1 });
} }
if (!headings.length) if (!headings.length)
return; return;
// 计算每个渲染标题在源码中的行号(跳过围栏代码块内部),
// 点击时按行号精确定位光标,重复标题文本也不会串位。
const srcLines = [];
const lines = this._value.split('\n');
let inFence = false;
let fenceChar = '';
const isSetextText = (l) => /\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'); const panel = document.createElement('div');
panel.className = 'me-outline'; panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`; panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`;
@@ -3245,7 +3420,7 @@ function buildOutline() {
continue; continue;
closeTo(item.level); closeTo(item.level);
h += '<ul>'; h += '<ul>';
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`; h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.srcLine >= 0 ? item.srcLine + 1 : ''}">${escapeHTML(item.text)}</a>`;
stack.push(item.level); stack.push(item.level);
} }
closeTo(-1); closeTo(-1);
@@ -3259,10 +3434,19 @@ function buildOutline() {
return; return;
e.preventDefault(); e.preventDefault();
const id = a.getAttribute('href').slice(1); const id = a.getAttribute('href').slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(id)); const target = this.previewEl.querySelector('#' + cssEscape(id));
if (target) { if (target)
target.scrollIntoView({ behavior: 'smooth', block: 'start' }); target.scrollIntoView({ behavior: 'smooth', block: 'start' });
const idx = this._value.indexOf(target.textContent || ''); // 光标定位:优先用构建时记录的源码行号(重复标题也正确),兜底 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) { if (idx !== -1) {
this.textarea.focus(); this.textarea.focus();
this.textarea.setSelectionRange(idx, idx); this.textarea.setSelectionRange(idx, idx);
@@ -3280,6 +3464,10 @@ function updateOutline() {
function trackOutlineScroll() { function trackOutlineScroll() {
if (!this.config.outline || !this.previewPane) if (!this.config.outline || !this.previewPane)
return; return;
// 防重复绑定:构造时绑定过、setOutline(true) 再次调用时跳过
if (this._outlineScrollBound)
return;
this._outlineScrollBound = true;
let ticking = false; let ticking = false;
const onScroll = () => { const onScroll = () => {
if (ticking) if (ticking)
@@ -3395,6 +3583,7 @@ class MarkdownEditor {
this._zenMouseHandler = null; this._zenMouseHandler = null;
this._floatingToolbar = null; this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false; this._floatingEnabled = this.config.floatingToolbar !== false;
this._floatMirror = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown; this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null; this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
MarkdownEditor.trigger('beforeCreate', this); MarkdownEditor.trigger('beforeCreate', this);
@@ -3416,7 +3605,8 @@ class MarkdownEditor {
this._bindEvents(); this._bindEvents();
this._bindContextMenu(); this._bindContextMenu();
this._trackOutlineScroll(); this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) // 无条件绑定事件(show 内部检查 _floatingEnabled):
// floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
this._initFloatingToolbar(); this._initFloatingToolbar();
this.textarea.value = this._value; this.textarea.value = this._value;
if (!this._wordWrap) if (!this._wordWrap)
@@ -3697,6 +3887,12 @@ class MarkdownEditor {
this.toolbarEl.addEventListener('click', onToolbarClick); this.toolbarEl.addEventListener('click', onToolbarClick);
const onDividerDown = (e) => this._bindDividerDrag(e); const onDividerDown = (e) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown); this.dividerEl.addEventListener('pointerdown', onDividerDown);
// 全屏模式按 Esc 退出(与 shortcutHelp 面板的 Esc 关闭互不冲突:条件互斥)
const onDocKeydown = (e) => {
if (e.key === 'Escape' && this._fullscreen)
this.exitFullscreen();
};
document.addEventListener('keydown', onDocKeydown);
this._bindDragDrop(); this._bindDragDrop();
this._cleanups.push(() => { this._cleanups.push(() => {
ta.removeEventListener('input', onInput); ta.removeEventListener('input', onInput);
@@ -3710,6 +3906,7 @@ class MarkdownEditor {
ta.removeEventListener('blur', onBlur); ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick); this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown); this.dividerEl.removeEventListener('pointerdown', onDividerDown);
document.removeEventListener('keydown', onDocKeydown);
}); });
} }
_handleKeydown(e) { _handleKeydown(e) {
@@ -3794,10 +3991,10 @@ class MarkdownEditor {
ta.selectionStart = lineStart; ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + newBlock.length; ta.selectionEnd = lineStart + newBlock.length;
} }
const old = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(old);
this._emit('change', this._value);
} }
_bindDividerDrag(e) { _bindDividerDrag(e) {
if (this._mode !== 'split') if (this._mode !== 'split')
@@ -3823,6 +4020,8 @@ class MarkdownEditor {
window.removeEventListener('pointermove', onMove); window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp); window.removeEventListener('pointerup', onUp);
this._saveDividerPosition(); this._saveDividerPosition();
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
this._hideFloatingToolbar();
}; };
window.addEventListener('pointermove', onMove); window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp); window.addEventListener('pointerup', onUp);
@@ -3938,12 +4137,12 @@ class MarkdownEditor {
const content = listMatch[3]; const content = listMatch[3];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
let nextMarker = marker; let nextMarker = marker;
@@ -3956,10 +4155,10 @@ class MarkdownEditor {
const insert = '\n' + indent + nextMarker + ' '; const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/); const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
@@ -3968,22 +4167,22 @@ class MarkdownEditor {
const content = quoteMatch[2]; const content = quoteMatch[2];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
e.preventDefault(); e.preventDefault();
const insert = '\n' + prefix; const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
} }
_handleBracketAutoClose(e) { _handleBracketAutoClose(e) {
@@ -3998,6 +4197,7 @@ class MarkdownEditor {
const end = ta.selectionEnd; const end = ta.selectionEnd;
if (start !== end) { if (start !== end) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const selected = ta.value.slice(start, end); const selected = ta.value.slice(start, end);
const wrap = key + selected + close; const wrap = key + selected + close;
ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end);
@@ -4005,7 +4205,7 @@ class MarkdownEditor {
ta.selectionEnd = start + 1 + selected.length; ta.selectionEnd = start + 1 + selected.length;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
return; return;
} }
const nextChar = ta.value[start] || ''; const nextChar = ta.value[start] || '';
@@ -4019,12 +4219,13 @@ class MarkdownEditor {
return; return;
} }
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const pair = key + close; const pair = key + close;
ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 1; ta.selectionStart = ta.selectionEnd = start + 1;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
} }
_bindDragDrop() { _bindDragDrop() {
const ta = this.textarea; const ta = this.textarea;
@@ -4066,6 +4267,39 @@ class MarkdownEditor {
} }
_scheduleHistory() { if (this._historyTimer) _scheduleHistory() { if (this._historyTimer)
clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
/**
* 程序化编辑路径(命令 / 快捷键 / 插件 / API 直改 textarea)的统一刷新管线。
* 此前 8 条路径的 预览/行号/字数/大纲 刷新与事件发射参差不齐,
* 现统一为:渲染 + 行号 + 字数 + 大纲 + input/change 事件 + 回调 + before/afterChange 钩子。
*/
_afterProgrammaticEdit(oldValue) {
MarkdownEditor.trigger('beforeChange', this);
this._emit('beforeChange', oldValue, this._value);
this._render();
this._renderGutter();
this._updateWordCount();
this._updateOutline();
this._emit('input', this._value);
this._emit('change', this._value);
if (typeof this.config.onInput === 'function') {
try {
this.config.onInput(this._value, this);
}
catch (e) {
console.error(e);
}
}
if (typeof this.config.onChange === 'function') {
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
}
this._emit('afterChange', this._value);
MarkdownEditor.trigger('afterChange', this);
}
_pushHistory() { _pushHistory() {
// Enforce maxLength as a last-resort guard for programmatic edit paths // Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write // (tab/indent, smart-enter, bracket auto-close, exec commands) that write
@@ -4092,23 +4326,20 @@ class MarkdownEditor {
canUndo() { return this._historyIndex > 0; } canUndo() { return this._historyIndex > 0; }
canRedo() { return this._historyIndex < this._history.length - 1; } canRedo() { return this._historyIndex < this._history.length - 1; }
_applyHistory() { _applyHistory() {
// 撤销/重做前记录预览区滚动比例,重渲染后按比例恢复,不再跳顶
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
const oldV = this._value;
this._value = this._history[this._historyIndex]; this._value = this._history[this._historyIndex];
this.textarea.value = this._value; this.textarea.value = this._value;
this._render(); this._afterProgrammaticEdit(oldV);
this._renderGutter();
this._updateWordCount();
if (this.gutter) if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop; this.gutter.scrollTop = this.textarea.scrollTop;
if (this.previewPane && this.config.syncScroll) if (pv && pvRatio != null) {
this.previewPane.scrollTop = 0; const max = pv.scrollHeight - pv.clientHeight;
this._emit('change', this._value); if (max > 0)
if (typeof this.config.onChange === 'function') { pv.scrollTop = pvRatio * max;
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
} }
} }
_captureCursor() { _captureCursor() {
@@ -4267,6 +4498,23 @@ class MarkdownEditor {
setWordWrap(on) { this._wordWrap = !!on; if (this.textarea) setWordWrap(on) { this._wordWrap = !!on; if (this.textarea)
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; } this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
isWordWrap() { return this._wordWrap; } isWordWrap() { return this._wordWrap; }
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on) {
this.config.outline = !!on;
if (this._destroyed || !this.el)
return this;
if (this.config.outline) {
this._buildOutline();
this._trackOutlineScroll();
}
else {
const panel = this.el.querySelector('.me-outline');
if (panel)
panel.remove();
}
return this;
}
isOutline() { return !!this.config.outline; }
_bindToolbarKeyboard() { _bindToolbarKeyboard() {
if (!this.toolbarEl) if (!this.toolbarEl)
return; return;
@@ -4458,12 +4706,11 @@ class MarkdownEditor {
if (!matches) if (!matches)
return 0; return 0;
// 函数式替换:替换文本按字面处理,$ 无特殊语义 // 函数式替换:替换文本按字面处理,$ 无特殊语义
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, () => replace)); this._value = this._limitLength(this._value.replace(re, () => replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
replaceAllRegex(pattern, replace) { replaceAllRegex(pattern, replace) {
@@ -4473,12 +4720,11 @@ class MarkdownEditor {
const matches = this._value.match(re); const matches = this._value.match(re);
if (!matches) if (!matches)
return 0; return 0;
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, replace)); this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
lineCount() { lineCount() {
@@ -4546,9 +4792,17 @@ class MarkdownEditor {
copyAsHTML() { copyAsHTML() {
const html = this.getHTML(); const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) { if (typeof navigator !== 'undefined' && navigator.clipboard) {
// ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
if (typeof ClipboardItem !== 'undefined') {
try {
const blob = new Blob([html], { type: 'text/html' }); const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) }); const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { }); navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
return this;
}
catch (_) { /* fallthrough 降级 */ }
}
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
} }
return this; return this;
} }
@@ -4560,11 +4814,10 @@ class MarkdownEditor {
ta.value = inserted; ta.value = inserted;
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length); ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return this; return this;
} }
wrap(before, after) { this._wrapSelection(before, after || before); return this; } wrap(before, after) { this._wrapSelection(before, after || before); return this; }
@@ -4750,6 +5003,9 @@ class MarkdownEditor {
if (this._floatingToolbar && this._floatingToolbar.parentNode) if (this._floatingToolbar && this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null; this._floatingToolbar = null;
if (this._floatMirror && this._floatMirror.parentNode)
this._floatMirror.parentNode.removeChild(this._floatMirror);
this._floatMirror = null;
if (this.el) if (this.el)
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove()); this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { this._plugins.forEach((p) => { if (typeof p.destroy === 'function') {
@@ -5039,9 +5295,9 @@ const getSupportedLanguages = () => Object.keys(LANGUAGES);
/** /**
* MetonaEditor — Type-safe, lightweight Markdown Editor * MetonaEditor — Type-safe, lightweight Markdown Editor
* @module metona-editor * @module metona-editor
* @version 0.4.1 * @version 0.4.3
*/ */
const VERSION = '0.4.1'; const VERSION = '0.4.3';
const globalPlugins = []; const globalPlugins = [];
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件 // 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。 // searchReplace / shortcutHelp / imagePaste)才能正常生效。
+1 -1
View File
File diff suppressed because one or more lines are too long
+18 -1
View File
@@ -12,12 +12,15 @@ interface EditorLike {
el: HTMLElement; el: HTMLElement;
textarea: HTMLTextAreaElement; textarea: HTMLTextAreaElement;
config?: Record<string, any>; config?: Record<string, any>;
t?: (key: string, params?: Record<string, any>) => string;
on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void; on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void;
off?: (name: string, fn: (...args: any[]) => void) => unknown; off?: (name: string, fn: (...args: any[]) => void) => unknown;
_emit?: (name: string, ...args: any[]) => void; _emit?: (name: string, ...args: any[]) => void;
_pushHistory?: () => void; _pushHistory?: () => void;
_render?: () => void; _render?: () => void;
_updateWordCount?: () => void; _updateWordCount?: () => void;
/** 程序化编辑统一刷新管线(渲染+行号+字数+大纲+事件+钩子) */
_afterProgrammaticEdit?: (oldValue: string) => void;
insert?: (text: string, opts?: { insert?: (text: string, opts?: {
replace?: boolean; replace?: boolean;
}) => unknown; }) => unknown;
@@ -358,6 +361,7 @@ 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; _wrapSelection: (before: string, after: string) => void;
_toggleLinePrefix: (prefix: string) => void; _toggleLinePrefix: (prefix: string) => void;
_insertBlock: (text: string) => void; _insertBlock: (text: string) => void;
@@ -370,6 +374,10 @@ declare class MarkdownEditor {
isFloatingToolbar: () => boolean; isFloatingToolbar: () => boolean;
_buildFloatingToolbar: () => void; _buildFloatingToolbar: () => void;
_hideFloatingToolbar: () => void; _hideFloatingToolbar: () => void;
_measureSelectionAnchor: () => {
x: number;
y: number;
} | null;
registerContextMenu: (items: any[]) => this; registerContextMenu: (items: any[]) => this;
_bindContextMenu: () => void; _bindContextMenu: () => void;
_showContextMenu: (e: MouseEvent) => void; _showContextMenu: (e: MouseEvent) => void;
@@ -398,6 +406,12 @@ declare class MarkdownEditor {
_handleBracketAutoClose(e: KeyboardEvent): void; _handleBracketAutoClose(e: KeyboardEvent): void;
_bindDragDrop(): void; _bindDragDrop(): 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;
@@ -422,6 +436,9 @@ declare class MarkdownEditor {
toggleWordWrap(): this; toggleWordWrap(): this;
setWordWrap(on: boolean): this; setWordWrap(on: boolean): this;
isWordWrap(): boolean; isWordWrap(): boolean;
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on: boolean): this;
isOutline(): boolean;
_bindToolbarKeyboard(): void; _bindToolbarKeyboard(): void;
_initAriaLive(): void; _initAriaLive(): void;
_announce(msg: string): void; _announce(msg: string): void;
@@ -581,7 +598,7 @@ declare const animationUtils: {
destroy(): void; destroy(): void;
}; };
declare const VERSION = "0.4.1"; 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;
+377 -121
View File
@@ -111,6 +111,7 @@
outline: '大纲', outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选', cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB', 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',
@@ -138,6 +139,7 @@
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)', 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: '打ち消し線',
@@ -165,6 +167,7 @@
outline: 'アウトライン', outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択', cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB', regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
}, },
ko: { ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -191,7 +194,8 @@
renderError: '렌더링 실패', renderError: '렌더링 실패',
outline: '개요', outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택', cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다{size}KB > {max}KB', regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
formatToolbar: '선택 영역 서식', pasteBlocked: '직접 붙여넣을 수 없습니다. Ctrl+V를 사용하세요',
}, },
fr: { fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -219,6 +223,7 @@
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)', 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',
@@ -246,6 +251,7 @@
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)', 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',
}, },
}; };
@@ -780,6 +786,18 @@
} }
}); });
} }
// 语言切换即时刷新已渲染的 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;
}; };
return { return {
@@ -1773,6 +1791,9 @@
}; };
// ============ Preset plugins ============ // ============ Preset plugins ============
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** editor.t locale退 t
* 插件面板文案跟随实例语言而非全局语言 */
const instanceT = (editor) => (key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
const autoSavePlugin = { const autoSavePlugin = {
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) { install(editor, options) {
@@ -1949,6 +1970,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
install(editor) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') if (!editor || !editor.textarea || typeof document === 'undefined')
return; 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'); const style = document.createElement('style');
@@ -2015,7 +2037,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const panel = document.createElement('div'); const panel = document.createElement('div');
panel.className = 'me-search'; panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0'; panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${t('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${t('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll') || 'All'}</button></div>`; panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${tt('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${tt('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${tt('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${tt('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${tt('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${tt('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${tt('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${tt('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${tt('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${tt('replaceAll') || 'All'}</button></div>`;
editor.el.appendChild(panel); editor.el.appendChild(panel);
state._panel = panel; state._panel = panel;
_updateReplaceVisible(); _updateReplaceVisible();
@@ -2140,7 +2162,12 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
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);
} }
const oldV = editor._value || '';
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
}
else {
if (typeof editor._pushHistory === 'function') if (typeof editor._pushHistory === 'function')
editor._pushHistory(); editor._pushHistory();
if (typeof editor._render === 'function') if (typeof editor._render === 'function')
@@ -2149,6 +2176,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
editor._updateWordCount(); editor._updateWordCount();
if (typeof editor._emit === 'function') if (typeof editor._emit === 'function')
editor._emit('change', editor._value); editor._emit('change', editor._value);
}
findNext(); findNext();
}; };
const replaceAll = () => { const replaceAll = () => {
@@ -2159,9 +2187,14 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const re = buildPattern(q); const re = buildPattern(q);
if (!re) if (!re)
return; return;
const oldV = editor._value || '';
ta.value = ta.value.replace(re, () => r); ta.value = ta.value.replace(re, () => r);
ta.setSelectionRange(0, 0); ta.setSelectionRange(0, 0);
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
}
else {
if (typeof editor._pushHistory === 'function') if (typeof editor._pushHistory === 'function')
editor._pushHistory(); editor._pushHistory();
if (typeof editor._render === 'function') if (typeof editor._render === 'function')
@@ -2170,6 +2203,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
editor._updateWordCount(); editor._updateWordCount();
if (typeof editor._emit === 'function') if (typeof editor._emit === 'function')
editor._emit('change', editor._value); editor._emit('change', editor._value);
}
findAll(); findAll();
}; };
fi.addEventListener('input', () => { lastIdxs = findAll(); }); fi.addEventListener('input', () => { lastIdxs = findAll(); });
@@ -2241,6 +2275,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
install(editor, options = {}) { install(editor, options = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined') if (!editor || !editor.textarea || typeof document === 'undefined')
return; return;
const tt = instanceT(editor);
const maxSizeKB = options.maxSizeKB || 500; const maxSizeKB = options.maxSizeKB || 500;
const _onPaste = (e) => { const _onPaste = (e) => {
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
@@ -2255,7 +2290,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
if (maxSizeKB > 0 && sizeKB > maxSizeKB) { if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault(); e.preventDefault();
if (typeof editor.toast === 'function') if (typeof editor.toast === 'function')
editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' }); editor.toast(tt('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break; break;
} }
e.preventDefault(); e.preventDefault();
@@ -2282,62 +2317,66 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
install(editor) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') if (!editor || !editor.textarea || typeof document === 'undefined')
return; return;
const tt = instanceT(editor);
if (!document.getElementById('me-shortcut-style')) { if (!document.getElementById('me-shortcut-style')) {
const s = document.createElement('style'); const s = document.createElement('style');
s.id = 'me-shortcut-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 = null; // 面板引用放入 holder:install 时无法预知未来打开的面板,
const _close = () => { if (_panel) { // 卸载时经 holder 取实时引用,避免面板残留 DOM。
_panel.remove(); const st = { panel: null };
_panel = null; const _close = () => { if (st.panel) {
st.panel.remove();
st.panel = null;
} }; } };
const _open = () => { const _open = () => {
if (_panel) { if (st.panel) {
_close(); _close();
return; return;
} }
// i18n-aware shortcut labels // i18n-aware shortcut labels(跟随实例语言)
const builtin = [ const builtin = [
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'], ['Ctrl+B', tt('bold') || 'Bold'], ['Ctrl+I', tt('italic') || 'Italic'],
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'], ['Ctrl+U', tt('underline') || 'Underline'], ['Ctrl+K', tt('link') || 'Link'],
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'], ['Ctrl+E', tt('code') || 'Code'], ['Ctrl+1/2/3', tt('h1') || 'Heading'],
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'], ['Ctrl+Q', tt('quote') || 'Quote'], ['Ctrl+Z', tt('undo') || 'Undo'],
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'], ['Ctrl+Y', tt('redo') || 'Redo'], ['Ctrl+S', tt('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'], ['Ctrl+F', tt('search') || 'Search'], ['Ctrl+H', tt('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'], ['Tab', tt('indent') || 'Indent'], ['Shift+Tab', tt('outdent') || 'Outdent'],
['?', t('shortcuts') || 'Shortcuts'], ['?', tt('shortcuts') || 'Shortcuts'],
]; ];
let rows = ''; let rows = '';
builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; }); builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'me-shortcut-overlay'; overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`; overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${tt('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close')) overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close'))
_close(); }); _close(); });
document.body.appendChild(overlay); document.body.appendChild(overlay);
_panel = overlay; st.panel = overlay;
}; };
const _onKeydown = (e) => { const _onKeydown = (e) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault(); e.preventDefault();
_open(); _open();
} }
if (e.key === 'Escape' && _panel) if (e.key === 'Escape' && st.panel)
_close(); _close();
}; };
editor.textarea.addEventListener('keydown', _onKeydown); editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown }); setPluginState(editor, K_SHORTCUT, { state: st, _open, _close, _onKeydown });
}, },
destroy(editor) { destroy(editor) {
const state = pluginState(editor, K_SHORTCUT); const exposed = pluginState(editor, K_SHORTCUT);
if (state) { if (exposed) {
if (state._panel) { if (exposed.state && exposed.state.panel) {
state._panel.remove(); exposed.state.panel.remove();
exposed.state.panel = null;
} }
} }
const _onKeydown = state?._onKeydown; const _onKeydown = exposed?._onKeydown;
if (_onKeydown && editor?.textarea) if (_onKeydown && editor?.textarea)
editor.textarea.removeEventListener('keydown', _onKeydown); editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT); deletePluginState(editor, K_SHORTCUT);
@@ -2830,11 +2869,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
else { else {
ta.selectionStart = ta.selectionEnd = start + before.length; ta.selectionStart = ta.selectionEnd = start + before.length;
} }
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Line prefix toggling ============ // ============ Line prefix toggling ============
function toggleLinePrefix(prefix) { function toggleLinePrefix(prefix) {
@@ -2855,11 +2893,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Block insertion ============ // ============ Block insertion ============
function insertBlock(text) { function insertBlock(text) {
@@ -2872,11 +2909,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.focus(); ta.focus();
const pos = start + insert.length; const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos; ta.selectionStart = ta.selectionEnd = pos;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
function insertLink() { function insertLink() {
const ta = this.textarea; const ta = this.textarea;
@@ -2889,10 +2925,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 3; ta.selectionStart = start + sel.length + 3;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertImage() { function insertImage() {
const ta = this.textarea; const ta = this.textarea;
@@ -2905,10 +2941,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 4; ta.selectionStart = start + sel.length + 4;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertTable(rows = 3, cols = 3) { function insertTable(rows = 3, cols = 3) {
const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | '); const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | ');
@@ -2950,10 +2986,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
return '| ' + padded.join(' | ') + ' |'; return '| ' + padded.join(' | ') + ' |';
}); });
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
// ============ Prototype installation ============ // ============ Prototype installation ============
const installCommands = (proto) => { const installCommands = (proto) => {
@@ -2969,8 +3005,17 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
/** /**
* MetonaEditor Floating Toolbar selection format toolbar * MetonaEditor Floating Toolbar selection format toolbar
* @module floating-toolbar * @module floating-toolbar
* @version 0.4.0 * @version 0.4.3
*
* 定位策略v0.4.3 重做mirror 镜像测量
* 创建与 textarea 排版样式一致的隐藏镜像层在选区锚点文档序中点
* 插入零宽字符 marker读取其 offsetLeft/offsetTop 得到像素级精确坐标
* 彻底替代旧版按字符宽度估算的方案CJK软换行tab 宽度字体
* fallback 的误差全部消除锚点滚出可视区自动隐藏textarea 滚动时
* rAF 节流重定位divider 拖拽结束 / 窗口 resize 后隐藏待重新触发
*/ */
/** 浮动工具栏与选区锚点之间的间距(px) */
const ANCHOR_GAP = 6;
function initFloatingToolbar() { function initFloatingToolbar() {
if (typeof document === 'undefined') if (typeof document === 'undefined')
return; return;
@@ -2978,11 +3023,9 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
if (!document.getElementById('me-float-style')) { if (!document.getElementById('me-float-style')) {
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'me-float-style'; style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`; style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;visibility:hidden;transform:translateY(4px);transition:opacity .15s,transform .15s,visibility .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}.me-float-mirror{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;z-index:-1;border:0;margin:0}`;
document.head.appendChild(style); document.head.appendChild(style);
} }
if (!this._floatingEnabled)
return;
const ta = this.textarea; const ta = this.textarea;
const show = () => { const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) if (this._destroyed || this.config.readOnly || !this._floatingEnabled)
@@ -2995,48 +3038,130 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
} }
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) if (!this._floatingToolbar || !this._floatingToolbar.parentNode)
this._buildFloatingToolbar(); this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect(); const anchor = this._measureSelectionAnchor();
const paneRect = this.editorPane.getBoundingClientRect(); 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 taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22; const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16; const paneW = this.editorPane.getBoundingClientRect().width;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18; let top = anchor.y - barH - ANCHOR_GAP;
const fontSize = parseFloat(taStyle.fontSize) || 13.5; if (top < ANCHOR_GAP)
const charWidth = fontSize * 0.6; top = anchor.y + lineHeight + ANCHOR_GAP;
const textBefore = ta.value.substring(0, start); let left = anchor.x - barW / 2;
const lines = textBefore.split('\n'); left = Math.max(4, Math.min(left, paneW - barW - 4));
const currentLine = lines.length - 1; if (left < 4)
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1; left = 4; // 极窄面板兜底
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1)); bar.style.top = top + 'px';
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop; bar.style.left = left + 'px';
const lineLocalTop = lineViewportTop - paneRect.top; bar.classList.add('me-visible');
const toolbarHeight = 36;
const gap = 4;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap)
top = lineLocalTop + lineHeight + gap;
const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft;
let left = colX - paneRect.left - 80;
left = Math.max(4, Math.min(left, paneRect.width - 200));
this._floatingToolbar.style.top = top + 'px';
this._floatingToolbar.style.left = left + 'px';
this._floatingToolbar.classList.add('me-visible');
}; };
const hide = () => { this._hideFloatingToolbar(); }; const hide = () => { this._hideFloatingToolbar(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0)); // 滚动跟随:rAF 节流重定位(保持纯事件驱动,不做轮询)
ta.addEventListener('keyup', () => { 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 统一发射 // selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
if (ta.selectionStart !== ta.selectionEnd) if (ta.selectionStart !== ta.selectionEnd)
setTimeout(show, 0); setTimeout(show, 0);
else else
setTimeout(hide, 0); setTimeout(hide, 0);
}); };
ta.addEventListener('blur', () => setTimeout(hide, 300)); const onBlur = () => setTimeout(() => {
ta.addEventListener('click', () => { // 焦点快速抖动场景(如自动化 fill 后还原焦点再 focus 回来、
// 宿主脚本焦点切换):延迟到期时焦点已回到 textarea 则不隐藏
if (document.activeElement === ta)
return;
hide();
}, 300);
const onClick = () => {
if (ta.selectionStart === ta.selectionEnd) if (ta.selectionStart === ta.selectionEnd)
hide(); 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 的局部坐标
* 锚点 = 选区文档序中点单行选区即视觉中部跨行选区约在选区垂直中心
*/
function measureSelectionAnchor() {
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 = [
['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[k] = v; });
// 宽度对齐 textarea 文本排版区(clientWidth 含 padding 不含滚动条)
mirror.style.width = ta.clientWidth + 'px';
// 仅需锚点前缀文本:其后内容不影响锚点位置(折行由前缀决定)
mirror.textContent = '';
mirror.appendChild(document.createTextNode(ta.value.slice(0, anchor)));
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
// marker 坐标相对 mirrorposition:absolute 的 offsetParentborder 0),
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
const x = marker.offsetLeft - ta.scrollLeft;
const y = marker.offsetTop - ta.scrollTop;
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
}
function toggleFloatingToolbar() { function toggleFloatingToolbar() {
this._floatingEnabled = !this._floatingEnabled; this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) { if (!this._floatingEnabled) {
@@ -3052,15 +3177,23 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
function buildFloatingToolbar() { function buildFloatingToolbar() {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.className = 'me-float-toolbar'; bar.className = 'me-float-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', this.t('formatToolbar') || '格式化选区');
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough']; const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => { actions.forEach((action) => {
const btn = this._createBtn(action); const btn = this._createBtn(action);
// mousedown 阻止夺焦(textarea 选区与焦点保持);命令执行统一走 click,
// 键盘 Enter/Space 触发的 click 同样生效(可访问性)
btn.addEventListener('mousedown', (e) => { btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
});
btn.addEventListener('click', () => {
this.exec(action); this.exec(action);
// Keep selection after exec this._hideFloatingToolbar();
setTimeout(() => this.textarea.focus(), 0); // Keep selection after exec(延迟回调时实例可能已销毁)
setTimeout(() => { if (!this._destroyed && this.textarea)
this.textarea.focus(); }, 0);
}); });
bar.appendChild(btn); bar.appendChild(btn);
}); });
@@ -3079,6 +3212,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
proto.isFloatingToolbar = isFloatingToolbar; proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar; proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar; proto._hideFloatingToolbar = hideFloatingToolbar;
proto._measureSelectionAnchor = measureSelectionAnchor;
}; };
/** /**
@@ -3143,7 +3277,8 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
el.className = 'me-context-menu-item'; el.className = 'me-context-menu-item';
if (item.disabled) if (item.disabled)
el.classList.add('me-disabled'); el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`; // 自定义菜单项的 label / shortcut 经转义后拼接(防 HTML 注入)
el.innerHTML = `<span>${escapeHTML(item.label)}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${escapeHTML(item.shortcut)}</span>` : ''}`;
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
if (item.disabled) if (item.disabled)
@@ -3189,9 +3324,13 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
case 'copy': case 'copy':
document.execCommand('copy'); document.execCommand('copy');
break; break;
case 'paste': case 'paste': {
document.execCommand('paste'); // 现代浏览器出于安全限制,execCommand('paste') 通常静默失败 —— 提示改用 Ctrl+V
const ok = document.execCommand('paste');
if (!ok)
this.toast(this.t('pasteBlocked') || '无法直接粘贴,请使用 Ctrl+V', { type: 'info' });
break; break;
}
case 'selectAll': case 'selectAll':
ta.focus(); ta.focus();
ta.select(); ta.select();
@@ -3215,6 +3354,12 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
* @module outline * @module outline
* @version 0.4.0 * @version 0.4.0
*/ */
/** querySelector 用的 id 转义:优先原生 CSS.escape,环境缺失时退化为简易转义 */
const cssEscape = (id) => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function')
return CSS.escape(id);
return id.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
function buildOutline() { function buildOutline() {
if (!this.config.outline || !this.previewEl) if (!this.config.outline || !this.previewEl)
return; return;
@@ -3225,10 +3370,40 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi; const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m; let m;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) { while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') }); headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, ''), srcLine: -1 });
} }
if (!headings.length) if (!headings.length)
return; return;
// 计算每个渲染标题在源码中的行号(跳过围栏代码块内部),
// 点击时按行号精确定位光标,重复标题文本也不会串位。
const srcLines = [];
const lines = this._value.split('\n');
let inFence = false;
let fenceChar = '';
const isSetextText = (l) => /\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'); const panel = document.createElement('div');
panel.className = 'me-outline'; panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`; panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`;
@@ -3247,7 +3422,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
continue; continue;
closeTo(item.level); closeTo(item.level);
h += '<ul>'; h += '<ul>';
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`; h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.srcLine >= 0 ? item.srcLine + 1 : ''}">${escapeHTML(item.text)}</a>`;
stack.push(item.level); stack.push(item.level);
} }
closeTo(-1); closeTo(-1);
@@ -3261,10 +3436,19 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
return; return;
e.preventDefault(); e.preventDefault();
const id = a.getAttribute('href').slice(1); const id = a.getAttribute('href').slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(id)); const target = this.previewEl.querySelector('#' + cssEscape(id));
if (target) { if (target)
target.scrollIntoView({ behavior: 'smooth', block: 'start' }); target.scrollIntoView({ behavior: 'smooth', block: 'start' });
const idx = this._value.indexOf(target.textContent || ''); // 光标定位:优先用构建时记录的源码行号(重复标题也正确),兜底 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) { if (idx !== -1) {
this.textarea.focus(); this.textarea.focus();
this.textarea.setSelectionRange(idx, idx); this.textarea.setSelectionRange(idx, idx);
@@ -3282,6 +3466,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
function trackOutlineScroll() { function trackOutlineScroll() {
if (!this.config.outline || !this.previewPane) if (!this.config.outline || !this.previewPane)
return; return;
// 防重复绑定:构造时绑定过、setOutline(true) 再次调用时跳过
if (this._outlineScrollBound)
return;
this._outlineScrollBound = true;
let ticking = false; let ticking = false;
const onScroll = () => { const onScroll = () => {
if (ticking) if (ticking)
@@ -3397,6 +3585,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
this._zenMouseHandler = null; this._zenMouseHandler = null;
this._floatingToolbar = null; this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false; this._floatingEnabled = this.config.floatingToolbar !== false;
this._floatMirror = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown; this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null; this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
MarkdownEditor.trigger('beforeCreate', this); MarkdownEditor.trigger('beforeCreate', this);
@@ -3418,7 +3607,8 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
this._bindEvents(); this._bindEvents();
this._bindContextMenu(); this._bindContextMenu();
this._trackOutlineScroll(); this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) // 无条件绑定事件(show 内部检查 _floatingEnabled):
// floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
this._initFloatingToolbar(); this._initFloatingToolbar();
this.textarea.value = this._value; this.textarea.value = this._value;
if (!this._wordWrap) if (!this._wordWrap)
@@ -3699,6 +3889,12 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
this.toolbarEl.addEventListener('click', onToolbarClick); this.toolbarEl.addEventListener('click', onToolbarClick);
const onDividerDown = (e) => this._bindDividerDrag(e); const onDividerDown = (e) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown); this.dividerEl.addEventListener('pointerdown', onDividerDown);
// 全屏模式按 Esc 退出(与 shortcutHelp 面板的 Esc 关闭互不冲突:条件互斥)
const onDocKeydown = (e) => {
if (e.key === 'Escape' && this._fullscreen)
this.exitFullscreen();
};
document.addEventListener('keydown', onDocKeydown);
this._bindDragDrop(); this._bindDragDrop();
this._cleanups.push(() => { this._cleanups.push(() => {
ta.removeEventListener('input', onInput); ta.removeEventListener('input', onInput);
@@ -3712,6 +3908,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.removeEventListener('blur', onBlur); ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick); this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown); this.dividerEl.removeEventListener('pointerdown', onDividerDown);
document.removeEventListener('keydown', onDocKeydown);
}); });
} }
_handleKeydown(e) { _handleKeydown(e) {
@@ -3796,10 +3993,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.selectionStart = lineStart; ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + newBlock.length; ta.selectionEnd = lineStart + newBlock.length;
} }
const old = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(old);
this._emit('change', this._value);
} }
_bindDividerDrag(e) { _bindDividerDrag(e) {
if (this._mode !== 'split') if (this._mode !== 'split')
@@ -3825,6 +4022,8 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
window.removeEventListener('pointermove', onMove); window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp); window.removeEventListener('pointerup', onUp);
this._saveDividerPosition(); this._saveDividerPosition();
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
this._hideFloatingToolbar();
}; };
window.addEventListener('pointermove', onMove); window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp); window.addEventListener('pointerup', onUp);
@@ -3940,12 +4139,12 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const content = listMatch[3]; const content = listMatch[3];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
let nextMarker = marker; let nextMarker = marker;
@@ -3958,10 +4157,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const insert = '\n' + indent + nextMarker + ' '; const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/); const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
@@ -3970,22 +4169,22 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const content = quoteMatch[2]; const content = quoteMatch[2];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
e.preventDefault(); e.preventDefault();
const insert = '\n' + prefix; const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
} }
_handleBracketAutoClose(e) { _handleBracketAutoClose(e) {
@@ -4000,6 +4199,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const end = ta.selectionEnd; const end = ta.selectionEnd;
if (start !== end) { if (start !== end) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const selected = ta.value.slice(start, end); const selected = ta.value.slice(start, end);
const wrap = key + selected + close; const wrap = key + selected + close;
ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end);
@@ -4007,7 +4207,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.selectionEnd = start + 1 + selected.length; ta.selectionEnd = start + 1 + selected.length;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
return; return;
} }
const nextChar = ta.value[start] || ''; const nextChar = ta.value[start] || '';
@@ -4021,12 +4221,13 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
return; return;
} }
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const pair = key + close; const pair = key + close;
ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 1; ta.selectionStart = ta.selectionEnd = start + 1;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
} }
_bindDragDrop() { _bindDragDrop() {
const ta = this.textarea; const ta = this.textarea;
@@ -4068,6 +4269,39 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
} }
_scheduleHistory() { if (this._historyTimer) _scheduleHistory() { if (this._historyTimer)
clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
/**
* 程序化编辑路径命令 / 快捷键 / 插件 / API 直改 textarea的统一刷新管线
* 此前 8 条路径的 预览/行号/字数/大纲 刷新与事件发射参差不齐
* 现统一为渲染 + 行号 + 字数 + 大纲 + input/change 事件 + 回调 + before/afterChange 钩子
*/
_afterProgrammaticEdit(oldValue) {
MarkdownEditor.trigger('beforeChange', this);
this._emit('beforeChange', oldValue, this._value);
this._render();
this._renderGutter();
this._updateWordCount();
this._updateOutline();
this._emit('input', this._value);
this._emit('change', this._value);
if (typeof this.config.onInput === 'function') {
try {
this.config.onInput(this._value, this);
}
catch (e) {
console.error(e);
}
}
if (typeof this.config.onChange === 'function') {
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
}
this._emit('afterChange', this._value);
MarkdownEditor.trigger('afterChange', this);
}
_pushHistory() { _pushHistory() {
// Enforce maxLength as a last-resort guard for programmatic edit paths // Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write // (tab/indent, smart-enter, bracket auto-close, exec commands) that write
@@ -4094,23 +4328,20 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
canUndo() { return this._historyIndex > 0; } canUndo() { return this._historyIndex > 0; }
canRedo() { return this._historyIndex < this._history.length - 1; } canRedo() { return this._historyIndex < this._history.length - 1; }
_applyHistory() { _applyHistory() {
// 撤销/重做前记录预览区滚动比例,重渲染后按比例恢复,不再跳顶
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
const oldV = this._value;
this._value = this._history[this._historyIndex]; this._value = this._history[this._historyIndex];
this.textarea.value = this._value; this.textarea.value = this._value;
this._render(); this._afterProgrammaticEdit(oldV);
this._renderGutter();
this._updateWordCount();
if (this.gutter) if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop; this.gutter.scrollTop = this.textarea.scrollTop;
if (this.previewPane && this.config.syncScroll) if (pv && pvRatio != null) {
this.previewPane.scrollTop = 0; const max = pv.scrollHeight - pv.clientHeight;
this._emit('change', this._value); if (max > 0)
if (typeof this.config.onChange === 'function') { pv.scrollTop = pvRatio * max;
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
} }
} }
_captureCursor() { _captureCursor() {
@@ -4269,6 +4500,23 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
setWordWrap(on) { this._wordWrap = !!on; if (this.textarea) setWordWrap(on) { this._wordWrap = !!on; if (this.textarea)
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; } this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
isWordWrap() { return this._wordWrap; } isWordWrap() { return this._wordWrap; }
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on) {
this.config.outline = !!on;
if (this._destroyed || !this.el)
return this;
if (this.config.outline) {
this._buildOutline();
this._trackOutlineScroll();
}
else {
const panel = this.el.querySelector('.me-outline');
if (panel)
panel.remove();
}
return this;
}
isOutline() { return !!this.config.outline; }
_bindToolbarKeyboard() { _bindToolbarKeyboard() {
if (!this.toolbarEl) if (!this.toolbarEl)
return; return;
@@ -4460,12 +4708,11 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
if (!matches) if (!matches)
return 0; return 0;
// 函数式替换:替换文本按字面处理,$ 无特殊语义 // 函数式替换:替换文本按字面处理,$ 无特殊语义
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, () => replace)); this._value = this._limitLength(this._value.replace(re, () => replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
replaceAllRegex(pattern, replace) { replaceAllRegex(pattern, replace) {
@@ -4475,12 +4722,11 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
const matches = this._value.match(re); const matches = this._value.match(re);
if (!matches) if (!matches)
return 0; return 0;
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, replace)); this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
lineCount() { lineCount() {
@@ -4548,9 +4794,17 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
copyAsHTML() { copyAsHTML() {
const html = this.getHTML(); const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) { if (typeof navigator !== 'undefined' && navigator.clipboard) {
// ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
if (typeof ClipboardItem !== 'undefined') {
try {
const blob = new Blob([html], { type: 'text/html' }); const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) }); const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { }); navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
return this;
}
catch (_) { /* fallthrough 降级 */ }
}
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
} }
return this; return this;
} }
@@ -4562,11 +4816,10 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
ta.value = inserted; ta.value = inserted;
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length); ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return this; return this;
} }
wrap(before, after) { this._wrapSelection(before, after || before); return this; } wrap(before, after) { this._wrapSelection(before, after || before); return this; }
@@ -4752,6 +5005,9 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
if (this._floatingToolbar && this._floatingToolbar.parentNode) if (this._floatingToolbar && this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null; this._floatingToolbar = null;
if (this._floatMirror && this._floatMirror.parentNode)
this._floatMirror.parentNode.removeChild(this._floatMirror);
this._floatMirror = null;
if (this.el) if (this.el)
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove()); this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { this._plugins.forEach((p) => { if (typeof p.destroy === 'function') {
@@ -5041,9 +5297,9 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
/** /**
* MetonaEditor Type-safe, lightweight Markdown Editor * MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor * @module metona-editor
* @version 0.4.1 * @version 0.4.3
*/ */
const VERSION = '0.4.1'; const VERSION = '0.4.3';
const globalPlugins = []; const globalPlugins = [];
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件 // 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。 // searchReplace / shortcutHelp / imagePaste)才能正常生效。
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+377 -121
View File
@@ -105,6 +105,7 @@ const LOCALES = {
outline: '大纲', outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选', cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB', 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',
@@ -132,6 +133,7 @@ const LOCALES = {
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)', 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: '打ち消し線',
@@ -159,6 +161,7 @@ const LOCALES = {
outline: 'アウトライン', outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択', cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB', regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
}, },
ko: { ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -185,7 +188,8 @@ const LOCALES = {
renderError: '렌더링 실패', renderError: '렌더링 실패',
outline: '개요', outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택', cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다{size}KB > {max}KB', regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
formatToolbar: '선택 영역 서식', pasteBlocked: '직접 붙여넣을 수 없습니다. Ctrl+V를 사용하세요',
}, },
fr: { fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -213,6 +217,7 @@ const LOCALES = {
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)', 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',
@@ -240,6 +245,7 @@ const LOCALES = {
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)', 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',
}, },
}; };
@@ -774,6 +780,18 @@ const createInstanceI18n = (editor) => {
} }
}); });
} }
// 语言切换即时刷新已渲染的 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;
}; };
return { return {
@@ -1767,6 +1785,9 @@ const validateConfig = (schema = {}, config = {}) => {
}; };
// ============ Preset plugins ============ // ============ Preset plugins ============
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** editor.t locale退 t
* 插件面板文案跟随实例语言而非全局语言 */
const instanceT = (editor) => (key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
const autoSavePlugin = { const autoSavePlugin = {
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) { install(editor, options) {
@@ -1943,6 +1964,7 @@ const searchReplacePlugin = {
install(editor) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') if (!editor || !editor.textarea || typeof document === 'undefined')
return; 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'); const style = document.createElement('style');
@@ -2009,7 +2031,7 @@ const searchReplacePlugin = {
const panel = document.createElement('div'); const panel = document.createElement('div');
panel.className = 'me-search'; panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0'; panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${t('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${t('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll') || 'All'}</button></div>`; panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${tt('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${tt('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${tt('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${tt('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${tt('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${tt('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${tt('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${tt('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${tt('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${tt('replaceAll') || 'All'}</button></div>`;
editor.el.appendChild(panel); editor.el.appendChild(panel);
state._panel = panel; state._panel = panel;
_updateReplaceVisible(); _updateReplaceVisible();
@@ -2134,7 +2156,12 @@ const searchReplacePlugin = {
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);
} }
const oldV = editor._value || '';
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
}
else {
if (typeof editor._pushHistory === 'function') if (typeof editor._pushHistory === 'function')
editor._pushHistory(); editor._pushHistory();
if (typeof editor._render === 'function') if (typeof editor._render === 'function')
@@ -2143,6 +2170,7 @@ const searchReplacePlugin = {
editor._updateWordCount(); editor._updateWordCount();
if (typeof editor._emit === 'function') if (typeof editor._emit === 'function')
editor._emit('change', editor._value); editor._emit('change', editor._value);
}
findNext(); findNext();
}; };
const replaceAll = () => { const replaceAll = () => {
@@ -2153,9 +2181,14 @@ const searchReplacePlugin = {
const re = buildPattern(q); const re = buildPattern(q);
if (!re) if (!re)
return; return;
const oldV = editor._value || '';
ta.value = ta.value.replace(re, () => r); ta.value = ta.value.replace(re, () => r);
ta.setSelectionRange(0, 0); ta.setSelectionRange(0, 0);
editor._value = ta.value; editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
}
else {
if (typeof editor._pushHistory === 'function') if (typeof editor._pushHistory === 'function')
editor._pushHistory(); editor._pushHistory();
if (typeof editor._render === 'function') if (typeof editor._render === 'function')
@@ -2164,6 +2197,7 @@ const searchReplacePlugin = {
editor._updateWordCount(); editor._updateWordCount();
if (typeof editor._emit === 'function') if (typeof editor._emit === 'function')
editor._emit('change', editor._value); editor._emit('change', editor._value);
}
findAll(); findAll();
}; };
fi.addEventListener('input', () => { lastIdxs = findAll(); }); fi.addEventListener('input', () => { lastIdxs = findAll(); });
@@ -2235,6 +2269,7 @@ const imagePastePlugin = {
install(editor, options = {}) { install(editor, options = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined') if (!editor || !editor.textarea || typeof document === 'undefined')
return; return;
const tt = instanceT(editor);
const maxSizeKB = options.maxSizeKB || 500; const maxSizeKB = options.maxSizeKB || 500;
const _onPaste = (e) => { const _onPaste = (e) => {
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
@@ -2249,7 +2284,7 @@ const imagePastePlugin = {
if (maxSizeKB > 0 && sizeKB > maxSizeKB) { if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault(); e.preventDefault();
if (typeof editor.toast === 'function') if (typeof editor.toast === 'function')
editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' }); editor.toast(tt('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break; break;
} }
e.preventDefault(); e.preventDefault();
@@ -2276,62 +2311,66 @@ const shortcutHelpPlugin = {
install(editor) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') if (!editor || !editor.textarea || typeof document === 'undefined')
return; return;
const tt = instanceT(editor);
if (!document.getElementById('me-shortcut-style')) { if (!document.getElementById('me-shortcut-style')) {
const s = document.createElement('style'); const s = document.createElement('style');
s.id = 'me-shortcut-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 = null; // 面板引用放入 holder:install 时无法预知未来打开的面板,
const _close = () => { if (_panel) { // 卸载时经 holder 取实时引用,避免面板残留 DOM。
_panel.remove(); const st = { panel: null };
_panel = null; const _close = () => { if (st.panel) {
st.panel.remove();
st.panel = null;
} }; } };
const _open = () => { const _open = () => {
if (_panel) { if (st.panel) {
_close(); _close();
return; return;
} }
// i18n-aware shortcut labels // i18n-aware shortcut labels(跟随实例语言)
const builtin = [ const builtin = [
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'], ['Ctrl+B', tt('bold') || 'Bold'], ['Ctrl+I', tt('italic') || 'Italic'],
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'], ['Ctrl+U', tt('underline') || 'Underline'], ['Ctrl+K', tt('link') || 'Link'],
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'], ['Ctrl+E', tt('code') || 'Code'], ['Ctrl+1/2/3', tt('h1') || 'Heading'],
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'], ['Ctrl+Q', tt('quote') || 'Quote'], ['Ctrl+Z', tt('undo') || 'Undo'],
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'], ['Ctrl+Y', tt('redo') || 'Redo'], ['Ctrl+S', tt('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'], ['Ctrl+F', tt('search') || 'Search'], ['Ctrl+H', tt('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'], ['Tab', tt('indent') || 'Indent'], ['Shift+Tab', tt('outdent') || 'Outdent'],
['?', t('shortcuts') || 'Shortcuts'], ['?', tt('shortcuts') || 'Shortcuts'],
]; ];
let rows = ''; let rows = '';
builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; }); builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'me-shortcut-overlay'; overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`; overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${tt('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close')) overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close'))
_close(); }); _close(); });
document.body.appendChild(overlay); document.body.appendChild(overlay);
_panel = overlay; st.panel = overlay;
}; };
const _onKeydown = (e) => { const _onKeydown = (e) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault(); e.preventDefault();
_open(); _open();
} }
if (e.key === 'Escape' && _panel) if (e.key === 'Escape' && st.panel)
_close(); _close();
}; };
editor.textarea.addEventListener('keydown', _onKeydown); editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown }); setPluginState(editor, K_SHORTCUT, { state: st, _open, _close, _onKeydown });
}, },
destroy(editor) { destroy(editor) {
const state = pluginState(editor, K_SHORTCUT); const exposed = pluginState(editor, K_SHORTCUT);
if (state) { if (exposed) {
if (state._panel) { if (exposed.state && exposed.state.panel) {
state._panel.remove(); exposed.state.panel.remove();
exposed.state.panel = null;
} }
} }
const _onKeydown = state?._onKeydown; const _onKeydown = exposed?._onKeydown;
if (_onKeydown && editor?.textarea) if (_onKeydown && editor?.textarea)
editor.textarea.removeEventListener('keydown', _onKeydown); editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT); deletePluginState(editor, K_SHORTCUT);
@@ -2824,11 +2863,10 @@ function wrapSelection(before, after) {
else { else {
ta.selectionStart = ta.selectionEnd = start + before.length; ta.selectionStart = ta.selectionEnd = start + before.length;
} }
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Line prefix toggling ============ // ============ Line prefix toggling ============
function toggleLinePrefix(prefix) { function toggleLinePrefix(prefix) {
@@ -2849,11 +2887,10 @@ function toggleLinePrefix(prefix) {
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
// ============ Block insertion ============ // ============ Block insertion ============
function insertBlock(text) { function insertBlock(text) {
@@ -2866,11 +2903,10 @@ function insertBlock(text) {
ta.focus(); ta.focus();
const pos = start + insert.length; const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos; ta.selectionStart = ta.selectionEnd = pos;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
} }
function insertLink() { function insertLink() {
const ta = this.textarea; const ta = this.textarea;
@@ -2883,10 +2919,10 @@ function insertLink() {
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 3; ta.selectionStart = start + sel.length + 3;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertImage() { function insertImage() {
const ta = this.textarea; const ta = this.textarea;
@@ -2899,10 +2935,10 @@ function insertImage() {
ta.focus(); ta.focus();
ta.selectionStart = start + sel.length + 4; ta.selectionStart = start + sel.length + 4;
ta.selectionEnd = ta.selectionStart + url.length; ta.selectionEnd = ta.selectionStart + url.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
function insertTable(rows = 3, cols = 3) { function insertTable(rows = 3, cols = 3) {
const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | '); const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | ');
@@ -2944,10 +2980,10 @@ function formatTable() {
return '| ' + padded.join(' | ') + ' |'; return '| ' + padded.join(' | ') + ' |';
}); });
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
// ============ Prototype installation ============ // ============ Prototype installation ============
const installCommands = (proto) => { const installCommands = (proto) => {
@@ -2963,8 +2999,17 @@ const installCommands = (proto) => {
/** /**
* MetonaEditor Floating Toolbar selection format toolbar * MetonaEditor Floating Toolbar selection format toolbar
* @module floating-toolbar * @module floating-toolbar
* @version 0.4.0 * @version 0.4.3
*
* 定位策略v0.4.3 重做mirror 镜像测量
* 创建与 textarea 排版样式一致的隐藏镜像层在选区锚点文档序中点
* 插入零宽字符 marker读取其 offsetLeft/offsetTop 得到像素级精确坐标
* 彻底替代旧版按字符宽度估算的方案CJK软换行tab 宽度字体
* fallback 的误差全部消除锚点滚出可视区自动隐藏textarea 滚动时
* rAF 节流重定位divider 拖拽结束 / 窗口 resize 后隐藏待重新触发
*/ */
/** 浮动工具栏与选区锚点之间的间距(px) */
const ANCHOR_GAP = 6;
function initFloatingToolbar() { function initFloatingToolbar() {
if (typeof document === 'undefined') if (typeof document === 'undefined')
return; return;
@@ -2972,11 +3017,9 @@ function initFloatingToolbar() {
if (!document.getElementById('me-float-style')) { if (!document.getElementById('me-float-style')) {
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'me-float-style'; style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`; style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;visibility:hidden;transform:translateY(4px);transition:opacity .15s,transform .15s,visibility .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}.me-float-mirror{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;z-index:-1;border:0;margin:0}`;
document.head.appendChild(style); document.head.appendChild(style);
} }
if (!this._floatingEnabled)
return;
const ta = this.textarea; const ta = this.textarea;
const show = () => { const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) if (this._destroyed || this.config.readOnly || !this._floatingEnabled)
@@ -2989,48 +3032,130 @@ function initFloatingToolbar() {
} }
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) if (!this._floatingToolbar || !this._floatingToolbar.parentNode)
this._buildFloatingToolbar(); this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect(); const anchor = this._measureSelectionAnchor();
const paneRect = this.editorPane.getBoundingClientRect(); 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 taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22; const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16; const paneW = this.editorPane.getBoundingClientRect().width;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18; let top = anchor.y - barH - ANCHOR_GAP;
const fontSize = parseFloat(taStyle.fontSize) || 13.5; if (top < ANCHOR_GAP)
const charWidth = fontSize * 0.6; top = anchor.y + lineHeight + ANCHOR_GAP;
const textBefore = ta.value.substring(0, start); let left = anchor.x - barW / 2;
const lines = textBefore.split('\n'); left = Math.max(4, Math.min(left, paneW - barW - 4));
const currentLine = lines.length - 1; if (left < 4)
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1; left = 4; // 极窄面板兜底
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1)); bar.style.top = top + 'px';
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop; bar.style.left = left + 'px';
const lineLocalTop = lineViewportTop - paneRect.top; bar.classList.add('me-visible');
const toolbarHeight = 36;
const gap = 4;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap)
top = lineLocalTop + lineHeight + gap;
const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft;
let left = colX - paneRect.left - 80;
left = Math.max(4, Math.min(left, paneRect.width - 200));
this._floatingToolbar.style.top = top + 'px';
this._floatingToolbar.style.left = left + 'px';
this._floatingToolbar.classList.add('me-visible');
}; };
const hide = () => { this._hideFloatingToolbar(); }; const hide = () => { this._hideFloatingToolbar(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0)); // 滚动跟随:rAF 节流重定位(保持纯事件驱动,不做轮询)
ta.addEventListener('keyup', () => { 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 统一发射 // selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
if (ta.selectionStart !== ta.selectionEnd) if (ta.selectionStart !== ta.selectionEnd)
setTimeout(show, 0); setTimeout(show, 0);
else else
setTimeout(hide, 0); setTimeout(hide, 0);
}); };
ta.addEventListener('blur', () => setTimeout(hide, 300)); const onBlur = () => setTimeout(() => {
ta.addEventListener('click', () => { // 焦点快速抖动场景(如自动化 fill 后还原焦点再 focus 回来、
// 宿主脚本焦点切换):延迟到期时焦点已回到 textarea 则不隐藏
if (document.activeElement === ta)
return;
hide();
}, 300);
const onClick = () => {
if (ta.selectionStart === ta.selectionEnd) if (ta.selectionStart === ta.selectionEnd)
hide(); 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 的局部坐标
* 锚点 = 选区文档序中点单行选区即视觉中部跨行选区约在选区垂直中心
*/
function measureSelectionAnchor() {
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 = [
['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[k] = v; });
// 宽度对齐 textarea 文本排版区(clientWidth 含 padding 不含滚动条)
mirror.style.width = ta.clientWidth + 'px';
// 仅需锚点前缀文本:其后内容不影响锚点位置(折行由前缀决定)
mirror.textContent = '';
mirror.appendChild(document.createTextNode(ta.value.slice(0, anchor)));
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
// marker 坐标相对 mirrorposition:absolute 的 offsetParentborder 0),
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
const x = marker.offsetLeft - ta.scrollLeft;
const y = marker.offsetTop - ta.scrollTop;
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
}
function toggleFloatingToolbar() { function toggleFloatingToolbar() {
this._floatingEnabled = !this._floatingEnabled; this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) { if (!this._floatingEnabled) {
@@ -3046,15 +3171,23 @@ function isFloatingToolbar() { return this._floatingEnabled; }
function buildFloatingToolbar() { function buildFloatingToolbar() {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.className = 'me-float-toolbar'; bar.className = 'me-float-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', this.t('formatToolbar') || '格式化选区');
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough']; const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => { actions.forEach((action) => {
const btn = this._createBtn(action); const btn = this._createBtn(action);
// mousedown 阻止夺焦(textarea 选区与焦点保持);命令执行统一走 click,
// 键盘 Enter/Space 触发的 click 同样生效(可访问性)
btn.addEventListener('mousedown', (e) => { btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
});
btn.addEventListener('click', () => {
this.exec(action); this.exec(action);
// Keep selection after exec this._hideFloatingToolbar();
setTimeout(() => this.textarea.focus(), 0); // Keep selection after exec(延迟回调时实例可能已销毁)
setTimeout(() => { if (!this._destroyed && this.textarea)
this.textarea.focus(); }, 0);
}); });
bar.appendChild(btn); bar.appendChild(btn);
}); });
@@ -3073,6 +3206,7 @@ const installFloatingToolbar = (proto) => {
proto.isFloatingToolbar = isFloatingToolbar; proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar; proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar; proto._hideFloatingToolbar = hideFloatingToolbar;
proto._measureSelectionAnchor = measureSelectionAnchor;
}; };
/** /**
@@ -3137,7 +3271,8 @@ function showContextMenu(e) {
el.className = 'me-context-menu-item'; el.className = 'me-context-menu-item';
if (item.disabled) if (item.disabled)
el.classList.add('me-disabled'); el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`; // 自定义菜单项的 label / shortcut 经转义后拼接(防 HTML 注入)
el.innerHTML = `<span>${escapeHTML(item.label)}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${escapeHTML(item.shortcut)}</span>` : ''}`;
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
if (item.disabled) if (item.disabled)
@@ -3183,9 +3318,13 @@ function execContextAction(action) {
case 'copy': case 'copy':
document.execCommand('copy'); document.execCommand('copy');
break; break;
case 'paste': case 'paste': {
document.execCommand('paste'); // 现代浏览器出于安全限制,execCommand('paste') 通常静默失败 —— 提示改用 Ctrl+V
const ok = document.execCommand('paste');
if (!ok)
this.toast(this.t('pasteBlocked') || '无法直接粘贴,请使用 Ctrl+V', { type: 'info' });
break; break;
}
case 'selectAll': case 'selectAll':
ta.focus(); ta.focus();
ta.select(); ta.select();
@@ -3209,6 +3348,12 @@ const installContextMenu = (proto) => {
* @module outline * @module outline
* @version 0.4.0 * @version 0.4.0
*/ */
/** querySelector 用的 id 转义:优先原生 CSS.escape,环境缺失时退化为简易转义 */
const cssEscape = (id) => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function')
return CSS.escape(id);
return id.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
function buildOutline() { function buildOutline() {
if (!this.config.outline || !this.previewEl) if (!this.config.outline || !this.previewEl)
return; return;
@@ -3219,10 +3364,40 @@ function buildOutline() {
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi; const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m; let m;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) { while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') }); headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, ''), srcLine: -1 });
} }
if (!headings.length) if (!headings.length)
return; return;
// 计算每个渲染标题在源码中的行号(跳过围栏代码块内部),
// 点击时按行号精确定位光标,重复标题文本也不会串位。
const srcLines = [];
const lines = this._value.split('\n');
let inFence = false;
let fenceChar = '';
const isSetextText = (l) => /\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'); const panel = document.createElement('div');
panel.className = 'me-outline'; panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`; panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`;
@@ -3241,7 +3416,7 @@ function buildOutline() {
continue; continue;
closeTo(item.level); closeTo(item.level);
h += '<ul>'; h += '<ul>';
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`; h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.srcLine >= 0 ? item.srcLine + 1 : ''}">${escapeHTML(item.text)}</a>`;
stack.push(item.level); stack.push(item.level);
} }
closeTo(-1); closeTo(-1);
@@ -3255,10 +3430,19 @@ function buildOutline() {
return; return;
e.preventDefault(); e.preventDefault();
const id = a.getAttribute('href').slice(1); const id = a.getAttribute('href').slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(id)); const target = this.previewEl.querySelector('#' + cssEscape(id));
if (target) { if (target)
target.scrollIntoView({ behavior: 'smooth', block: 'start' }); target.scrollIntoView({ behavior: 'smooth', block: 'start' });
const idx = this._value.indexOf(target.textContent || ''); // 光标定位:优先用构建时记录的源码行号(重复标题也正确),兜底 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) { if (idx !== -1) {
this.textarea.focus(); this.textarea.focus();
this.textarea.setSelectionRange(idx, idx); this.textarea.setSelectionRange(idx, idx);
@@ -3276,6 +3460,10 @@ function updateOutline() {
function trackOutlineScroll() { function trackOutlineScroll() {
if (!this.config.outline || !this.previewPane) if (!this.config.outline || !this.previewPane)
return; return;
// 防重复绑定:构造时绑定过、setOutline(true) 再次调用时跳过
if (this._outlineScrollBound)
return;
this._outlineScrollBound = true;
let ticking = false; let ticking = false;
const onScroll = () => { const onScroll = () => {
if (ticking) if (ticking)
@@ -3391,6 +3579,7 @@ class MarkdownEditor {
this._zenMouseHandler = null; this._zenMouseHandler = null;
this._floatingToolbar = null; this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false; this._floatingEnabled = this.config.floatingToolbar !== false;
this._floatMirror = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown; this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null; this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
MarkdownEditor.trigger('beforeCreate', this); MarkdownEditor.trigger('beforeCreate', this);
@@ -3412,7 +3601,8 @@ class MarkdownEditor {
this._bindEvents(); this._bindEvents();
this._bindContextMenu(); this._bindContextMenu();
this._trackOutlineScroll(); this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) // 无条件绑定事件(show 内部检查 _floatingEnabled):
// floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
this._initFloatingToolbar(); this._initFloatingToolbar();
this.textarea.value = this._value; this.textarea.value = this._value;
if (!this._wordWrap) if (!this._wordWrap)
@@ -3693,6 +3883,12 @@ class MarkdownEditor {
this.toolbarEl.addEventListener('click', onToolbarClick); this.toolbarEl.addEventListener('click', onToolbarClick);
const onDividerDown = (e) => this._bindDividerDrag(e); const onDividerDown = (e) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown); this.dividerEl.addEventListener('pointerdown', onDividerDown);
// 全屏模式按 Esc 退出(与 shortcutHelp 面板的 Esc 关闭互不冲突:条件互斥)
const onDocKeydown = (e) => {
if (e.key === 'Escape' && this._fullscreen)
this.exitFullscreen();
};
document.addEventListener('keydown', onDocKeydown);
this._bindDragDrop(); this._bindDragDrop();
this._cleanups.push(() => { this._cleanups.push(() => {
ta.removeEventListener('input', onInput); ta.removeEventListener('input', onInput);
@@ -3706,6 +3902,7 @@ class MarkdownEditor {
ta.removeEventListener('blur', onBlur); ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick); this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown); this.dividerEl.removeEventListener('pointerdown', onDividerDown);
document.removeEventListener('keydown', onDocKeydown);
}); });
} }
_handleKeydown(e) { _handleKeydown(e) {
@@ -3790,10 +3987,10 @@ class MarkdownEditor {
ta.selectionStart = lineStart; ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + newBlock.length; ta.selectionEnd = lineStart + newBlock.length;
} }
const old = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(old);
this._emit('change', this._value);
} }
_bindDividerDrag(e) { _bindDividerDrag(e) {
if (this._mode !== 'split') if (this._mode !== 'split')
@@ -3819,6 +4016,8 @@ class MarkdownEditor {
window.removeEventListener('pointermove', onMove); window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp); window.removeEventListener('pointerup', onUp);
this._saveDividerPosition(); this._saveDividerPosition();
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
this._hideFloatingToolbar();
}; };
window.addEventListener('pointermove', onMove); window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp); window.addEventListener('pointerup', onUp);
@@ -3934,12 +4133,12 @@ class MarkdownEditor {
const content = listMatch[3]; const content = listMatch[3];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
let nextMarker = marker; let nextMarker = marker;
@@ -3952,10 +4151,10 @@ class MarkdownEditor {
const insert = '\n' + indent + nextMarker + ' '; const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/); const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
@@ -3964,22 +4163,22 @@ class MarkdownEditor {
const content = quoteMatch[2]; const content = quoteMatch[2];
if (!content.trim()) { if (!content.trim()) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
return; return;
} }
e.preventDefault(); e.preventDefault();
const insert = '\n' + prefix; const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._renderGutter(); this._afterProgrammaticEdit(oldV);
this._emit('change', this._value);
} }
} }
_handleBracketAutoClose(e) { _handleBracketAutoClose(e) {
@@ -3994,6 +4193,7 @@ class MarkdownEditor {
const end = ta.selectionEnd; const end = ta.selectionEnd;
if (start !== end) { if (start !== end) {
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const selected = ta.value.slice(start, end); const selected = ta.value.slice(start, end);
const wrap = key + selected + close; const wrap = key + selected + close;
ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end);
@@ -4001,7 +4201,7 @@ class MarkdownEditor {
ta.selectionEnd = start + 1 + selected.length; ta.selectionEnd = start + 1 + selected.length;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
return; return;
} }
const nextChar = ta.value[start] || ''; const nextChar = ta.value[start] || '';
@@ -4015,12 +4215,13 @@ class MarkdownEditor {
return; return;
} }
e.preventDefault(); e.preventDefault();
const oldV = this._value;
const pair = key + close; const pair = key + close;
ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 1; ta.selectionStart = ta.selectionEnd = start + 1;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._emit('change', this._value); this._afterProgrammaticEdit(oldV);
} }
_bindDragDrop() { _bindDragDrop() {
const ta = this.textarea; const ta = this.textarea;
@@ -4062,6 +4263,39 @@ class MarkdownEditor {
} }
_scheduleHistory() { if (this._historyTimer) _scheduleHistory() { if (this._historyTimer)
clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
/**
* 程序化编辑路径命令 / 快捷键 / 插件 / API 直改 textarea的统一刷新管线
* 此前 8 条路径的 预览/行号/字数/大纲 刷新与事件发射参差不齐
* 现统一为渲染 + 行号 + 字数 + 大纲 + input/change 事件 + 回调 + before/afterChange 钩子
*/
_afterProgrammaticEdit(oldValue) {
MarkdownEditor.trigger('beforeChange', this);
this._emit('beforeChange', oldValue, this._value);
this._render();
this._renderGutter();
this._updateWordCount();
this._updateOutline();
this._emit('input', this._value);
this._emit('change', this._value);
if (typeof this.config.onInput === 'function') {
try {
this.config.onInput(this._value, this);
}
catch (e) {
console.error(e);
}
}
if (typeof this.config.onChange === 'function') {
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
}
this._emit('afterChange', this._value);
MarkdownEditor.trigger('afterChange', this);
}
_pushHistory() { _pushHistory() {
// Enforce maxLength as a last-resort guard for programmatic edit paths // Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write // (tab/indent, smart-enter, bracket auto-close, exec commands) that write
@@ -4088,23 +4322,20 @@ class MarkdownEditor {
canUndo() { return this._historyIndex > 0; } canUndo() { return this._historyIndex > 0; }
canRedo() { return this._historyIndex < this._history.length - 1; } canRedo() { return this._historyIndex < this._history.length - 1; }
_applyHistory() { _applyHistory() {
// 撤销/重做前记录预览区滚动比例,重渲染后按比例恢复,不再跳顶
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
const oldV = this._value;
this._value = this._history[this._historyIndex]; this._value = this._history[this._historyIndex];
this.textarea.value = this._value; this.textarea.value = this._value;
this._render(); this._afterProgrammaticEdit(oldV);
this._renderGutter();
this._updateWordCount();
if (this.gutter) if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop; this.gutter.scrollTop = this.textarea.scrollTop;
if (this.previewPane && this.config.syncScroll) if (pv && pvRatio != null) {
this.previewPane.scrollTop = 0; const max = pv.scrollHeight - pv.clientHeight;
this._emit('change', this._value); if (max > 0)
if (typeof this.config.onChange === 'function') { pv.scrollTop = pvRatio * max;
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
} }
} }
_captureCursor() { _captureCursor() {
@@ -4263,6 +4494,23 @@ class MarkdownEditor {
setWordWrap(on) { this._wordWrap = !!on; if (this.textarea) setWordWrap(on) { this._wordWrap = !!on; if (this.textarea)
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; } this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
isWordWrap() { return this._wordWrap; } isWordWrap() { return this._wordWrap; }
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on) {
this.config.outline = !!on;
if (this._destroyed || !this.el)
return this;
if (this.config.outline) {
this._buildOutline();
this._trackOutlineScroll();
}
else {
const panel = this.el.querySelector('.me-outline');
if (panel)
panel.remove();
}
return this;
}
isOutline() { return !!this.config.outline; }
_bindToolbarKeyboard() { _bindToolbarKeyboard() {
if (!this.toolbarEl) if (!this.toolbarEl)
return; return;
@@ -4454,12 +4702,11 @@ class MarkdownEditor {
if (!matches) if (!matches)
return 0; return 0;
// 函数式替换:替换文本按字面处理,$ 无特殊语义 // 函数式替换:替换文本按字面处理,$ 无特殊语义
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, () => replace)); this._value = this._limitLength(this._value.replace(re, () => replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
replaceAllRegex(pattern, replace) { replaceAllRegex(pattern, replace) {
@@ -4469,12 +4716,11 @@ class MarkdownEditor {
const matches = this._value.match(re); const matches = this._value.match(re);
if (!matches) if (!matches)
return 0; return 0;
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, replace)); this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return matches.length; return matches.length;
} }
lineCount() { lineCount() {
@@ -4542,9 +4788,17 @@ class MarkdownEditor {
copyAsHTML() { copyAsHTML() {
const html = this.getHTML(); const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) { if (typeof navigator !== 'undefined' && navigator.clipboard) {
// ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
if (typeof ClipboardItem !== 'undefined') {
try {
const blob = new Blob([html], { type: 'text/html' }); const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) }); const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { }); navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
return this;
}
catch (_) { /* fallthrough 降级 */ }
}
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
} }
return this; return this;
} }
@@ -4556,11 +4810,10 @@ class MarkdownEditor {
ta.value = inserted; ta.value = inserted;
ta.focus(); ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length); ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
const oldV = this._value;
this._value = ta.value; this._value = ta.value;
this._pushHistory(); this._pushHistory();
this._render(); this._afterProgrammaticEdit(oldV);
this._updateWordCount();
this._emit('change', this._value);
return this; return this;
} }
wrap(before, after) { this._wrapSelection(before, after || before); return this; } wrap(before, after) { this._wrapSelection(before, after || before); return this; }
@@ -4746,6 +4999,9 @@ class MarkdownEditor {
if (this._floatingToolbar && this._floatingToolbar.parentNode) if (this._floatingToolbar && this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null; this._floatingToolbar = null;
if (this._floatMirror && this._floatMirror.parentNode)
this._floatMirror.parentNode.removeChild(this._floatMirror);
this._floatMirror = null;
if (this.el) if (this.el)
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove()); this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { this._plugins.forEach((p) => { if (typeof p.destroy === 'function') {
@@ -5035,9 +5291,9 @@ const getSupportedLanguages = () => Object.keys(LANGUAGES);
/** /**
* MetonaEditor Type-safe, lightweight Markdown Editor * MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor * @module metona-editor
* @version 0.4.1 * @version 0.4.3
*/ */
const VERSION = '0.4.1'; const VERSION = '0.4.3';
const globalPlugins = []; const globalPlugins = [];
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件 // 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。 // searchReplace / shortcutHelp / imagePaste)才能正常生效。
+1 -1
View File
File diff suppressed because one or more lines are too long
+45
View File
@@ -115,6 +115,51 @@ const check = (name, cond) => {
const status = await page.evaluate(() => window.__ed.getStatus()); const status = await page.evaluate(() => window.__ed.getStatus());
check('getStatus 返回结构', status.mode === 'split' && status.theme === 'dark' && status.locale === 'zh-CN'); 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(); await browser.close();
server.close(); server.close();
+11
View File
@@ -13,5 +13,16 @@ module.exports = {
], ],
coverageDirectory: 'coverage', coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'], coverageReporters: ['text', 'lcov'],
coverageThreshold: {
global: {
statements: 85,
branches: 70,
functions: 85,
lines: 85,
},
'src/parser.ts': {
lines: 95,
},
},
verbose: true, verbose: true,
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@metona-team/metona-editor", "name": "@metona-team/metona-editor",
"version": "0.4.1", "version": "0.4.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.cjs", "main": "dist/metona-editor.cjs",
-14
View File
@@ -7,7 +7,6 @@ import serve from 'rollup-plugin-serve';
import livereload from 'rollup-plugin-livereload'; import livereload from 'rollup-plugin-livereload';
const isDev = process.env.ROLLUP_WATCH; const isDev = process.env.ROLLUP_WATCH;
const isProd = process.env.NODE_ENV === 'production';
const basePlugins = [ const basePlugins = [
resolve(), resolve(),
@@ -29,19 +28,6 @@ const devPlugins = isDev ? [
}), }),
] : []; ] : [];
const terserPlugin = isProd ? [
terser({
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.warn'],
},
format: {
comments: false,
},
}),
] : [];
export default [ export default [
// UMD development // UMD development
{ {
+19 -15
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
<title>MetonaEditor v0.4.1 — 全功能演示</title> <title>MetonaEditor v0.4.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.4.1</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">841 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>
@@ -93,7 +93,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
</div> </div>
<footer class="footer"> <footer class="footer">
MetonaEditor v0.4.1 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT MetonaEditor v0.4.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>
@@ -101,18 +101,19 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<script> <script>
(function(){ (function(){
var demoMd=[ var demoMd=[
'# 🚀 MetonaEditor v0.4.1 全功能演示', '# 🚀 MetonaEditor v0.4.3 全功能演示',
'', '',
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。', '> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
'> 全模块 TypeScript 严格模式,17 个源文件,841 个测试全部通过。', '> 全模块 TypeScript 严格模式,17 个源文件,895 个测试全部通过。',
'', '',
'## ✨ v0.4.1 新特性', '## ✨ v0.4.3 新特性',
'', '',
'- **Zen 专注模式宽度可配置**`zenMaxWidth` 默认 960px,支持数字 / CSS 字符串 / `false` 不限宽', '- **浮动工具栏定位重做**:mirror 镜像测量像素级定位,中文混排 / 长行软换行 / 跨行选区精确跟随',
'- **修复 5 处问题**`wordWrap: false` 生效 · outline 启动即构建 · edit 模式初始行号 · exportTool 卸载清理 · 替换后统计刷新', '- **滚动跟随**:选区滚出可视区自动隐藏,滚动时节流重定位,分隔条拖拽与窗口缩放后自动隐藏',
'- **Playwright 浏览器冒烟测试**22 项断言真实 Chromium 验证', '- **键盘可达**:隐藏态移出 Tab 序列,按钮支持 Enter / Space 执行,`role="toolbar"` + aria-label',
'- **实例级 i18n**:状态栏 / 工具栏 / 右键菜单 / 大纲全部跟随实例语言', '- **程序化编辑统一刷新管线**:命令 / 快捷键 / 插件路径的预览 / 行号 / 字数 / 大纲刷新与 input / change 事件完全对齐原生输入',
'- `sideEffects: false` 优化打包 · `MeEditor.destroy()` 全面复位', '- **修复 13 处问题**:浮动工具栏死开关 · 监听器泄漏 · 销毁后 focus 崩溃 · 右键菜单注入 · copyAsHTML 在 Firefox 崩溃 · Esc 退出全屏等',
'- **测试补强**871 → 895 用例,e2e 冒烟 22 → 28 项断言(含真实浏览器布局验证)',
'', '',
'## 📐 文本样式', '## 📐 文本样式',
'', '',
@@ -141,6 +142,8 @@ var demoMd=[
'', '',
'| 版本 | 日期 | 测试 | 主题 |', '| 版本 | 日期 | 测试 | 主题 |',
'| :--- | :---: | ---: | --- |', '| :--- | :---: | ---: | --- |',
'| v0.4.3 | 2026-08 | 895 | 浮动工具栏重做+统一编辑管线 |',
'| v0.4.2 | 2026-08 | 871 | 审查清偿+大纲API+8项修复 |',
'| v0.4.1 | 2026-08 | 841 | Zen宽度可配置+5项修复 |', '| v0.4.1 | 2026-08 | 841 | Zen宽度可配置+5项修复 |',
'| v0.4.0 | 2026-08 | 826 | E2E冒烟+实例i18n |', '| v0.4.0 | 2026-08 | 826 | E2E冒烟+实例i18n |',
'| v0.3.1 | 2026-08 | 821 | 增量统计+高亮缓存 |', '| v0.3.1 | 2026-08 | 821 | 增量统计+高亮缓存 |',
@@ -270,9 +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
+52 -13
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
<title>MetonaEditor v0.4.1 · 文档</title> <title>MetonaEditor v0.4.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.4.1 · TypeScript · 完整配置、API 与使用指南</p> <p class="sub">MetonaEditor v0.4.3 · TypeScript · 完整配置、API 与使用指南</p>
</header> </header>
<div class="container"> <div class="container">
@@ -98,6 +98,7 @@ footer a{color:var(--accent);text-decoration:none}
<div class="toc-group">插件</div> <div class="toc-group">插件</div>
<a href="#plugins-convention">插件约定</a> <a href="#plugins-convention">插件约定</a>
<a href="#preset-plugins">预设插件</a> <a href="#preset-plugins">预设插件</a>
<a href="#preset-plugins">PluginManager</a>
<div class="toc-group">主题</div> <div class="toc-group">主题</div>
<a href="#themes">主题系统</a> <a href="#themes">主题系统</a>
<div class="toc-group">国际化</div> <div class="toc-group">国际化</div>
@@ -240,11 +241,13 @@ editor<span class="c-punc">.</span><span class="c-fn">selectAll</span><span clas
<tr><td><code>formatTable</code> <span class="badge">v0.2.2</span></td><td>光标所在表格按列宽对齐</td><td></td></tr> <tr><td><code>formatTable</code> <span class="badge">v0.2.2</span></td><td>光标所在表格按列宽对齐</td><td></td></tr>
<tr><td><code>hr</code></td><td>---水平线</td><td></td></tr> <tr><td><code>hr</code></td><td>---水平线</td><td></td></tr>
<tr><td><code>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab / Shift+Tab</td></tr> <tr><td><code>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab / Shift+Tab</td></tr>
<tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Y</td></tr> <tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z</td></tr>
<tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td></td></tr> <tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td></td></tr>
<tr><td><code>fullscreen</code></td><td>全屏</td><td></td></tr> <tr><td><code>fullscreen</code></td><td>全屏</td><td></td></tr>
<tr><td><code>zen</code></td><td>Zen 模式</td><td></td></tr> <tr><td><code>zen</code></td><td>Zen 模式</td><td></td></tr>
<tr><td><code>wordwrap</code></td><td>自动换行</td><td></td></tr> <tr><td><code>wordwrap</code></td><td>自动换行</td><td></td></tr>
<tr><td>智能 Enter</td><td>列表 / 引用自动延续,空行回车退出</td><td>Enter</td></tr>
<tr><td>查找 / 替换</td><td>searchReplace 插件面板</td><td>Ctrl+F / Ctrl+H</td></tr>
</table> </table>
</section> </section>
@@ -260,7 +263,8 @@ editor<span class="c-punc">.</span><span class="c-fn">toggleZen</span><span clas
editor<span class="c-punc">.</span><span class="c-fn">setZenMaxWidth</span><span class="c-punc">(</span><span class="c-num">1200</span><span class="c-punc">);</span> <span class="c-com">// 运行时调整专注模式宽度(数字 / CSS 字符串 / false</span> editor<span class="c-punc">.</span><span class="c-fn">setZenMaxWidth</span><span class="c-punc">(</span><span class="c-num">1200</span><span class="c-punc">);</span> <span class="c-com">// 运行时调整专注模式宽度(数字 / CSS 字符串 / false</span>
editor<span class="c-punc">.</span><span class="c-fn">getZenMaxWidth</span><span class="c-punc">();</span> <span class="c-com">// => number | string | false</span> editor<span class="c-punc">.</span><span class="c-fn">getZenMaxWidth</span><span class="c-punc">();</span> <span class="c-com">// => number | string | false</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleWordWrap</span><span class="c-punc">();</span> <span class="c-fn">setWordWrap</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isWordWrap</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">toggleWordWrap</span><span class="c-punc">();</span> <span class="c-fn">setWordWrap</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isWordWrap</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleFloatingToolbar</span><span class="c-punc">();</span> <span class="c-fn">isFloatingToolbar</span><span class="c-punc">();</span> <span class="c-com">// 选中文本格式栏</span></pre> editor<span class="c-punc">.</span><span class="c-fn">setOutline</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isOutline</span><span class="c-punc">();</span> <span class="c-com">// 运行时开关大纲面板(v0.4.2</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleFloatingToolbar</span><span class="c-punc">();</span> <span class="c-fn">isFloatingToolbar</span><span class="c-punc">();</span> <span class="c-com">// 选中文本格式栏(mirror 镜像像素级定位,v0.4.3</span></pre>
</section> </section>
<section id="api-stats"><h3>统计与状态</h3> <section id="api-stats"><h3>统计与状态</h3>
@@ -280,7 +284,8 @@ editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span cl
<tr><th>事件</th><th>触发时机</th><th>参数</th></tr> <tr><th>事件</th><th>触发时机</th><th>参数</th></tr>
<tr><td><code>input</code></td><td>textarea 输入</td><td><code>(value, editor)</code></td></tr> <tr><td><code>input</code></td><td>textarea 输入</td><td><code>(value, editor)</code></td></tr>
<tr><td><code>change</code></td><td>内容变化</td><td><code>(value, editor)</code></td></tr> <tr><td><code>change</code></td><td>内容变化</td><td><code>(value, editor)</code></td></tr>
<tr><td><code>beforeChange</code> / <code>afterChange</code></td><td>内容变更前</td><td><code>(oldValue, newValue, editor)</code></td></tr> <tr><td><code>beforeChange</code></td><td>内容变更前</td><td><code>(oldValue, newValue, editor)</code></td></tr>
<tr><td><code>afterChange</code></td><td>内容变更后</td><td><code>(newValue, editor)</code></td></tr>
<tr><td><code>focus</code> / <code>blur</code></td><td>聚焦/失焦</td><td><code>(editor)</code></td></tr> <tr><td><code>focus</code> / <code>blur</code></td><td>聚焦/失焦</td><td><code>(editor)</code></td></tr>
<tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(editor)</code></td></tr> <tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(editor)</code></td></tr>
<tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr> <tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr>
@@ -295,22 +300,26 @@ editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span cl
<tr><td><code>autosave</code></td><td>autoSave 触发</td><td><code>({ key, value })</code></td></tr> <tr><td><code>autosave</code></td><td>autoSave 触发</td><td><code>({ key, value })</code></td></tr>
<tr><td><code>linkClick</code></td><td>预览链接点击</td><td><code>({ href, text })</code></td></tr> <tr><td><code>linkClick</code></td><td>预览链接点击</td><td><code>({ href, text })</code></td></tr>
<tr><td><code>copy</code></td><td>复制到剪贴板</td><td><code>({ type })</code></td></tr> <tr><td><code>copy</code></td><td>复制到剪贴板</td><td><code>({ type })</code></td></tr>
<tr><td><code>fileOpened</code> / <code>fileSaved</code></td><td>fileSystem 读写</td><td><code>({ name, handle })</code></td></tr> <tr><td><code>fileOpened</code></td><td>fileSystem 打开文件</td><td><code>({ name, handle }, editor)</code></td></tr>
<tr><td><code>fileSaved</code></td><td>fileSystem 保存文件</td><td><code>({ handle }, editor)</code></td></tr>
</table> </table>
<h4>全局钩子</h4> <h4>全局钩子</h4>
<table> <table>
<tr><th>钩子</th><th>触发时机</th></tr> <tr><th>钩子</th><th>触发时机</th></tr>
<tr><td><code>beforeCreate</code> / <code>afterCreate</code></td><td>实例构造前后</td></tr> <tr><td><code>beforeCreate</code> / <code>afterCreate</code></td><td>实例构造前后</td></tr>
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>全局渲染前后</td></tr> <tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>全局渲染前后</td></tr>
<tr><td><code>beforeChange</code> / <code>afterChange</code></td><td>内容变更前后(全局)</td></tr>
<tr><td><code>beforeDestroy</code> / <code>afterDestroy</code></td><td>销毁前后</td></tr> <tr><td><code>beforeDestroy</code> / <code>afterDestroy</code></td><td>销毁前后</td></tr>
</table> </table>
</section> </section>
<section id="api-plugin"><h3>插件管理</h3> <section id="api-plugin"><h3>插件管理 / 工具栏管理</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装</span> <pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装</span>
editor<span class="c-punc">.</span><span class="c-fn">unuse</span><span class="c-punc">(</span>name<span class="c-punc">);</span> <span class="c-com">// 卸载</span> editor<span class="c-punc">.</span><span class="c-fn">unuse</span><span class="c-punc">(</span>name<span class="c-punc">);</span> <span class="c-com">// 卸载</span>
editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 已安装列表</span> editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 已安装列表</span>
editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加按钮</span></pre> editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加按钮</span>
editor<span class="c-punc">.</span><span class="c-fn">configureToolbar</span><span class="c-punc">(</span><span class="c-punc">[</span><span class="c-str">'bold'</span><span class="c-punc">,</span> <span class="c-str">'italic'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'undo'</span><span class="c-punc">]);</span> <span class="c-com">// 运行时重建工具栏</span>
editor<span class="c-punc">.</span><span class="c-fn">removeToolbarButton</span><span class="c-punc">(</span><span class="c-str">'hr'</span><span class="c-punc">);</span> <span class="c-com">// 移除单个按钮</span></pre>
</section> </section>
<section id="api-shortcuts"><h3>快捷键管理</h3> <section id="api-shortcuts"><h3>快捷键管理</h3>
@@ -344,11 +353,18 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span> MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span> MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span>
<span class="c-com">// 状态查询</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span>
<span class="c-com">// => { version, theme, locale, globalPlugins, presetPlugins }</span>
<span class="c-com">// 销毁全局资源(主题/语言监听、全局插件、全局钩子全面复位,之后 use() 仍可用)</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span>
<span class="c-com">// 解析器独立使用</span> <span class="c-com">// 解析器独立使用</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span> <span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens<span class="c-punc">,</span> safeUrl<span class="c-punc">,</span> slugify<span class="c-punc">,</span> clearRenderCache<span class="c-punc">,</span> getRenderCacheSize<span class="c-punc">,</span> registerBlockHandler <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 内置语法高亮</span> <span class="c-com">// 内置语法高亮</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> highlight<span class="c-punc">,</span> registerLanguage <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span></pre> <span class="c-kw">import</span> <span class="c-punc">{</span> highlight<span class="c-punc">,</span> normalizeLanguage<span class="c-punc">,</span> registerLanguage<span class="c-punc">,</span> getSupportedLanguages <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span></pre>
</section> </section>
<!-- 语法高亮 --> <!-- 语法高亮 -->
@@ -403,9 +419,11 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><
<tr><td>定义列表</td><td><code>Term\n: def</code></td><td>&lt;dl&gt;&lt;dt&gt;&lt;dd&gt;</td></tr> <tr><td>定义列表</td><td><code>Term\n: def</code></td><td>&lt;dl&gt;&lt;dt&gt;&lt;dd&gt;</td></tr>
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+</td></tr> <tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+</td></tr>
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr> <tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
<tr><td>HTML 注释</td><td><code>&lt;!-- note --&gt;</code></td><td>透传</td></tr>
<tr><td>实体引用保护</td><td><code>&amp;amp;</code> <code>&amp;#169;</code></td><td>不被二次转义</td></tr>
</table> </table>
<h4>XSS 防护</h4> <h4>XSS 防护</h4>
<p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,属性级注入防护(<code>href</code> / <code>src</code> / <code>alt</code> / <code>title</code> / 脚注 id 引号转义),白名单裸标签 <code>&lt;u&gt;</code> 透传,提供 <code>sanitize</code> 钩子</p> <p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,属性级注入防护(<code>href</code> / <code>src</code> / <code>alt</code> / <code>title</code> / 脚注 id 引号转义),白名单裸标签 <code>&lt;u&gt;</code> 透传,<code>data:image</code> 限制最大 500KB,提供 <code>sanitize</code> 钩子(可接 DOMPurify 等),<code>highlight</code> 钩子异常自动回退纯文本</p>
</section> </section>
<section id="parser-api"><h2>解析器 API</h2> <section id="parser-api"><h2>解析器 API</h2>
@@ -451,6 +469,20 @@ editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span
<h4>fileSystem</h4> <h4>fileSystem</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'fileSystem'</span><span class="c-punc">);</span> <pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'fileSystem'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">openFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFileAs</span><span class="c-punc">();</span></pre> editor<span class="c-punc">.</span><span class="c-fn">openFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFileAs</span><span class="c-punc">();</span></pre>
<h3>PluginManager &amp; pluginUtils</h3>
<pre><span class="c-kw">import</span> <span class="c-punc">{</span> PluginManager<span class="c-punc">,</span> pluginUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 独立插件注册表</span>
<span class="c-kw">const</span> pm <span class="c-punc">=</span> <span class="c-kw">new</span> <span class="c-fn">PluginManager</span><span class="c-punc">();</span>
pm<span class="c-punc">.</span><span class="c-fn">register</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">,</span> myPlugin<span class="c-punc">);</span>
pm<span class="c-punc">.</span><span class="c-fn">has</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">);</span> pm<span class="c-punc">.</span><span class="c-fn">get</span><span class="c-punc">(</span><span class="c-str">'my'</span><span class="c-punc">);</span> pm<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span>
<span class="c-com">// 工具集(作用于默认全局管理器)</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">validatePlugin</span><span class="c-punc">(</span>plugin<span class="c-punc">);</span> <span class="c-com">// => { valid, errors }</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">createPlugin</span><span class="c-punc">({</span> name<span class="c-punc">:</span> <span class="c-str">'x'</span> <span class="c-punc">});</span> <span class="c-com">// 工厂函数,补齐默认字段</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">getPreset</span><span class="c-punc">(</span><span class="c-str">'autoSave'</span><span class="c-punc">);</span> <span class="c-com">// 预设副本(不污染原对象)</span>
pluginUtils<span class="c-punc">.</span><span class="c-fn">getAllPresets</span><span class="c-punc">();</span> <span class="c-com">// 全部预设副本</span></pre>
</section> </section>
<!-- 主题 --> <!-- 主题 -->
@@ -461,10 +493,11 @@ editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class
<span class="c-com">// 注册自定义</span> <span class="c-com">// 注册自定义</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span> <span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span> extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span> <span class="c-punc">});</span> themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span> extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span> <span class="c-punc">});</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">switchTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">);</span> <span class="c-com">// 全局切换到自定义主题</span>
<span class="c-com">// 外部跟随</span> <span class="c-com">// 外部跟随</span>
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">adopt</span><span class="c-punc">();</span> <span class="c-com">// 从父容器继承</span> editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">adopt</span><span class="c-punc">();</span> <span class="c-com">// 从父容器继承</span>
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">syncWithElement</span><span class="c-punc">(</span>document<span class="c-punc">.</span><span class="c-fn">body</span><span class="c-punc">());</span> <span class="c-com">// 跟随外部元素</span> editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">syncWithElement</span><span class="c-punc">(</span>document<span class="c-punc">.</span><span class="c-kw">body</span><span class="c-punc">);</span> <span class="c-com">// 跟随外部元素body 是属性,不是函数)</span>
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">dispose</span><span class="c-punc">();</span> <span class="c-com">// 手动断开跟随(destroy 时自动断开)</span> editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">dispose</span><span class="c-punc">();</span> <span class="c-com">// 手动断开跟随(destroy 时自动断开)</span>
<span class="c-com">// CSS 变量导出</span> <span class="c-com">// CSS 变量导出</span>
@@ -483,9 +516,15 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">addTranslations</span><
<span class="c-com">// 远程加载</span> <span class="c-com">// 远程加载</span>
<span class="c-kw">await</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">loadRemote</span><span class="c-punc">(</span><span class="c-str">'/locales/ja.json'</span><span class="c-punc">,</span> <span class="c-str">'ja'</span><span class="c-punc">);</span> <span class="c-kw">await</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">loadRemote</span><span class="c-punc">(</span><span class="c-str">'/locales/ja.json'</span><span class="c-punc">,</span> <span class="c-str">'ja'</span><span class="c-punc">);</span>
<span class="c-com">// 实例级翻译(跟随实例 locale,状态栏/工具栏/插件面板均使用它)</span>
editor<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">);</span> <span class="c-com">// => '粗体'</span>
editor<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'imageTooLarge'</span><span class="c-punc">,</span> <span class="c-punc">{</span> size<span class="c-punc">:</span> <span class="c-num">800</span><span class="c-punc">,</span> max<span class="c-punc">:</span> <span class="c-num">500</span> <span class="c-punc">});</span> <span class="c-com">// 插值</span>
<span class="c-com">// 格式化</span> <span class="c-com">// 格式化</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatCurrency</span><span class="c-punc">(</span><span class="c-num">99.99</span><span class="c-punc">,</span> <span class="c-str">'USD'</span><span class="c-punc">);</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span></pre> i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span></pre>
<p>RTL 语言(阿拉伯语、希伯来语等)自动应用从右到左布局;分屏分隔条拖拽比例按实例 id 持久化到 localStorage。</p>
</section> </section>
</main> </main>
@@ -494,7 +533,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
<footer> <footer>
<div class="container"> <div class="container">
MetonaEditor v0.4.1 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License MetonaEditor v0.4.3 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
</div> </div>
</footer> </footer>
+8 -8
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>"/> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>"/>
<title>MetonaEditor v0.4.1 · TypeScript · 桌面端 Markdown 编辑器</title> <title>MetonaEditor v0.4.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.4.1</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">841 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,7 +120,7 @@ footer a:hover{text-decoration:underline}
<div class="stats"> <div class="stats">
<div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div> <div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div>
<div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div> <div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div>
<div class="stat"><div class="stat-num">841</div><div class="stat-label">单元测试</div></div> <div class="stat"><div class="stat-num">895</div><div class="stat-label">单元测试</div></div>
<div class="stat"><div class="stat-num">~42KB</div><div class="stat-label">gzip 体积</div></div> <div class="stat"><div class="stat-num">~42KB</div><div class="stat-label">gzip 体积</div></div>
</div> </div>
</header> </header>
@@ -147,7 +147,7 @@ footer a:hover{text-decoration:underline}
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg></div><h3>主题 & i18n</h3><p>4 套预设主题 + CSS 变量定制 + 实例级隔离。六种语言开箱即用(中 / 英 / 日 / 韩 / 法 / 德),支持远程加载翻译包。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg></div><h3>主题 & i18n</h3><p>4 套预设主题 + CSS 变量定制 + 实例级隔离。六种语言开箱即用(中 / 英 / 日 / 韩 / 法 / 德),支持远程加载翻译包。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></div><h3>内置语法高亮</h3><p>零依赖轻量高亮器,js / ts / python / bash / css 等 16 个语言标识,代码块一行配置即自动着色。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></div><h3>内置语法高亮</h3><p>零依赖轻量高亮器,js / ts / python / bash / css 等 16 个语言标识,代码块一行配置即自动着色。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><h3>XSS 安全防护</h3><p>HTML 转义、危险协议过滤、属性级注入防护、sanitize 钩子、highlight 异常回退。多层防护,安全可靠。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><h3>XSS 安全防护</h3><p>HTML 转义、危险协议过滤、属性级注入防护、sanitize 钩子、highlight 异常回退。多层防护,安全可靠。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div><h3>性能与工程</h3><p>字数/词数/行数差异增量统计、高亮规则缓存、大纲 O(n) 构建;841 单测 + Playwright 真实浏览器冒烟,CI 并行 4 job。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div><h3>性能与工程</h3><p>字数/词数/行数差异增量统计、高亮规则缓存、大纲 O(n) 构建;895 单测 + Playwright 真实浏览器冒烟,CI 并行 5 job。</p></div>
</div> </div>
</div> </div>
</section> </section>
@@ -193,7 +193,7 @@ editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-
<div class="api-card"><h4>内容操作</h4><ul><li>getValue() <span class="ret">string</span></li><li>setValue(md) <span class="ret">this</span></li><li>getHTML() <span class="ret">string</span></li><li>insert(text) <span class="ret">this</span></li><li>wrap(before, after) <span class="ret">this</span></li><li>replaceAll(search, rep) <span class="ret">number</span></li></ul></div> <div class="api-card"><h4>内容操作</h4><ul><li>getValue() <span class="ret">string</span></li><li>setValue(md) <span class="ret">this</span></li><li>getHTML() <span class="ret">string</span></li><li>insert(text) <span class="ret">this</span></li><li>wrap(before, after) <span class="ret">this</span></li><li>replaceAll(search, rep) <span class="ret">number</span></li></ul></div>
<div class="api-card"><h4>光标与选区</h4><ul><li>getSelectedText() <span class="ret">string</span></li><li>getCursorPosition() <span class="ret">{line,column}</span></li><li>setCursorPosition(l, c) <span class="ret">this</span></li><li>selectLine(n) / selectAll() <span class="ret">this</span></li><li>toggleFloatingToolbar() <span class="ret">this</span></li></ul></div> <div class="api-card"><h4>光标与选区</h4><ul><li>getSelectedText() <span class="ret">string</span></li><li>getCursorPosition() <span class="ret">{line,column}</span></li><li>setCursorPosition(l, c) <span class="ret">this</span></li><li>selectLine(n) / selectAll() <span class="ret">this</span></li><li>toggleFloatingToolbar() <span class="ret">this</span></li></ul></div>
<div class="api-card"><h4>命令执行</h4><ul><li>exec(action) <span class="ret">this</span></li><li>undo() / redo() <span class="ret">this</span></li><li>canUndo() / canRedo() <span class="ret">boolean</span></li></ul></div> <div class="api-card"><h4>命令执行</h4><ul><li>exec(action) <span class="ret">this</span></li><li>undo() / redo() <span class="ret">this</span></li><li>canUndo() / canRedo() <span class="ret">boolean</span></li></ul></div>
<div class="api-card"><h4>模式与全屏</h4><ul><li>setMode(mode) <span class="ret">this</span></li><li>getMode() <span class="ret">string</span></li><li>toggleFullscreen() <span class="ret">this</span></li><li>toggleZen() <span class="ret">this</span></li></ul></div> <div class="api-card"><h4>模式与全屏</h4><ul><li>setMode(mode) <span class="ret">this</span></li><li>getMode() <span class="ret">string</span></li><li>toggleFullscreen() <span class="ret">this</span></li><li>toggleZen() <span class="ret">this</span></li><li>setOutline(on) <span class="ret">this</span></li></ul></div>
<div class="api-card"><h4>事件与插件</h4><ul><li>on(event, fn) <span class="ret">unsub</span></li><li>off(event, fn) <span class="ret">this</span></li><li>use(plugin) <span class="ret">this</span></li><li>unuse(name) <span class="ret">this</span></li><li>addToolbarButton(cfg) <span class="ret">this</span></li></ul></div> <div class="api-card"><h4>事件与插件</h4><ul><li>on(event, fn) <span class="ret">unsub</span></li><li>off(event, fn) <span class="ret">this</span></li><li>use(plugin) <span class="ret">this</span></li><li>unuse(name) <span class="ret">this</span></li><li>addToolbarButton(cfg) <span class="ret">this</span></li></ul></div>
<div class="api-card"><h4>主题与语言</h4><ul><li>setTheme(name) <span class="ret">this</span></li><li>getTheme() <span class="ret">string</span></li><li>setLocale(loc) <span class="ret">this</span></li><li>getLocale() <span class="ret">string</span></li><li>t(key, params) <span class="ret">string</span></li></ul></div> <div class="api-card"><h4>主题与语言</h4><ul><li>setTheme(name) <span class="ret">this</span></li><li>getTheme() <span class="ret">string</span></li><li>setLocale(loc) <span class="ret">this</span></li><li>getLocale() <span class="ret">string</span></li><li>t(key, params) <span class="ret">string</span></li></ul></div>
<div class="api-card"><h4>统计与状态</h4><ul><li>getStats() <span class="ret">Stats</span></li><li>getStatus() <span class="ret">Status</span></li><li>enable() / disable() <span class="ret">this</span></li><li>refresh() <span class="ret">this</span></li><li>destroy() <span class="ret">void</span></li></ul></div> <div class="api-card"><h4>统计与状态</h4><ul><li>getStats() <span class="ret">Stats</span></li><li>getStatus() <span class="ret">Status</span></li><li>enable() / disable() <span class="ret">this</span></li><li>refresh() <span class="ret">this</span></li><li>destroy() <span class="ret">void</span></li></ul></div>
@@ -230,7 +230,7 @@ editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a>
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor/issues" target="_blank" rel="noopener">问题反馈</a> <a href="https://git.metona.cn/MetonaTeam/MetonaEditor/issues" target="_blank" rel="noopener">问题反馈</a>
</div> </div>
<div>MetonaEditor v0.4.1 · TypeScript · MIT License · MetonaTeam</div> <div>MetonaEditor v0.4.3 · TypeScript · MIT License · MetonaTeam</div>
</div> </div>
</footer> </footer>
@@ -240,7 +240,7 @@ MeEditor.themes.switchTheme('dark');
MeEditor.create('#editor', { MeEditor.create('#editor', {
mode: 'split', height: 480, theme: 'dark', mode: 'split', height: 480, theme: 'dark',
value: [ value: [
'# 欢迎使用 MetonaEditor v0.4.1', '# 欢迎使用 MetonaEditor v0.4.3',
'', '',
'一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。', '一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。',
'', '',
+6 -6
View File
@@ -16,7 +16,7 @@ export function wrapSelection(this: MarkdownEditor, before: string, after: strin
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end); ta.focus(); 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; } if (selected) { ta.selectionStart = start + before.length; ta.selectionEnd = start + before.length + text.length; }
else { ta.selectionStart = ta.selectionEnd = start + before.length; } else { ta.selectionStart = ta.selectionEnd = start + before.length; }
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
} }
// ============ Line prefix toggling ============ // ============ Line prefix toggling ============
@@ -33,7 +33,7 @@ export function toggleLinePrefix(this: MarkdownEditor, prefix: string): void {
else newLine = prefix + line; else newLine = prefix + line;
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus(); ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
} }
// ============ Block insertion ============ // ============ Block insertion ============
@@ -44,7 +44,7 @@ export function insertBlock(this: MarkdownEditor, text: string): void {
const insert = (needNL ? '\n' : '') + text; const insert = (needNL ? '\n' : '') + text;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd); ta.focus(); 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 pos = start + insert.length; ta.selectionStart = ta.selectionEnd = pos;
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
} }
export function insertLink(this: MarkdownEditor): void { export function insertLink(this: MarkdownEditor): void {
@@ -54,7 +54,7 @@ export function insertLink(this: MarkdownEditor): void {
const insert = `[${sel}](${url})`; const insert = `[${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length; ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length;
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
} }
export function insertImage(this: MarkdownEditor): void { export function insertImage(this: MarkdownEditor): void {
@@ -64,7 +64,7 @@ export function insertImage(this: MarkdownEditor): void {
const insert = `![${sel}](${url})`; const insert = `![${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length; ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length;
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
} }
export function insertTable(this: MarkdownEditor, rows = 3, cols = 3): void { export function insertTable(this: MarkdownEditor, rows = 3, cols = 3): void {
@@ -107,7 +107,7 @@ export function formatTable(this: MarkdownEditor): void {
return '| ' + padded.join(' | ') + ' |'; return '| ' + padded.join(' | ') + ' |';
}); });
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
} }
// ============ Prototype installation ============ // ============ Prototype installation ============
+9 -2
View File
@@ -5,6 +5,7 @@
*/ */
import { t as i18nT } from './i18n'; import { t as i18nT } from './i18n';
import { escapeHTML } from './utils';
import type { MarkdownEditor } from './core'; import type { MarkdownEditor } from './core';
export function registerContextMenu(this: MarkdownEditor, items: any[] = []): MarkdownEditor { export function registerContextMenu(this: MarkdownEditor, items: any[] = []): MarkdownEditor {
@@ -56,7 +57,8 @@ export function showContextMenu(this: MarkdownEditor, e: MouseEvent): void {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'me-context-menu-item'; el.className = 'me-context-menu-item';
if (item.disabled) el.classList.add('me-disabled'); if (item.disabled) el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`; // 自定义菜单项的 label / shortcut 经转义后拼接(防 HTML 注入)
el.innerHTML = `<span>${escapeHTML(item.label)}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${escapeHTML(item.shortcut)}</span>` : ''}`;
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
if (item.disabled) return; if (item.disabled) return;
@@ -87,7 +89,12 @@ export function execContextAction(this: MarkdownEditor, action: string): void {
case 'redo': this.redo(); break; case 'redo': this.redo(); break;
case 'cut': document.execCommand('cut'); break; case 'cut': document.execCommand('cut'); break;
case 'copy': document.execCommand('copy'); break; case 'copy': document.execCommand('copy'); break;
case 'paste': document.execCommand('paste'); break; case 'paste': {
// 现代浏览器出于安全限制,execCommand('paste') 通常静默失败 —— 提示改用 Ctrl+V
const ok = document.execCommand('paste');
if (!ok) this.toast(this.t('pasteBlocked') || '无法直接粘贴,请使用 Ctrl+V', { type: 'info' });
break;
}
case 'selectAll': ta.focus(); ta.select(); break; case 'selectAll': ta.focus(); ta.select(); break;
default: this.exec(action); break; default: this.exec(action); break;
} }
+75 -15
View File
@@ -92,6 +92,7 @@ export class MarkdownEditor {
_i18nCtx!: InstanceI18n; _i18nCtx!: InstanceI18n;
_floatingToolbar!: HTMLElement | null; _floatingToolbar!: HTMLElement | null;
_floatingEnabled!: boolean; _floatingEnabled!: boolean;
_floatMirror!: HTMLElement | null;
// Prototype-installed methods (see installers at bottom of file) // Prototype-installed methods (see installers at bottom of file)
_wrapSelection!: (before: string, after: string) => void; _wrapSelection!: (before: string, after: string) => void;
@@ -106,6 +107,7 @@ export class MarkdownEditor {
isFloatingToolbar!: () => boolean; isFloatingToolbar!: () => boolean;
_buildFloatingToolbar!: () => void; _buildFloatingToolbar!: () => void;
_hideFloatingToolbar!: () => void; _hideFloatingToolbar!: () => void;
_measureSelectionAnchor!: () => { x: number; y: number } | null;
registerContextMenu!: (items: any[]) => this; registerContextMenu!: (items: any[]) => this;
_bindContextMenu!: () => void; _bindContextMenu!: () => void;
_showContextMenu!: (e: MouseEvent) => void; _showContextMenu!: (e: MouseEvent) => void;
@@ -139,6 +141,7 @@ export class MarkdownEditor {
this._zenMouseHandler = null; this._zenMouseHandler = null;
this._floatingToolbar = null; this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false; this._floatingEnabled = this.config.floatingToolbar !== false;
this._floatMirror = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown; this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null; this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
@@ -164,7 +167,9 @@ export class MarkdownEditor {
this._bindEvents(); this._bindEvents();
this._bindContextMenu(); this._bindContextMenu();
this._trackOutlineScroll(); this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) this._initFloatingToolbar(); // 无条件绑定事件(show 内部检查 _floatingEnabled):
// floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
this._initFloatingToolbar();
this.textarea.value = this._value; this.textarea.value = this._value;
if (!this._wordWrap) this.textarea.style.whiteSpace = 'pre'; if (!this._wordWrap) this.textarea.style.whiteSpace = 'pre';
@@ -326,6 +331,11 @@ export class MarkdownEditor {
this.toolbarEl.addEventListener('click', onToolbarClick); this.toolbarEl.addEventListener('click', onToolbarClick);
const onDividerDown = (e: PointerEvent) => this._bindDividerDrag(e); const onDividerDown = (e: PointerEvent) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown); this.dividerEl.addEventListener('pointerdown', onDividerDown);
// 全屏模式按 Esc 退出(与 shortcutHelp 面板的 Esc 关闭互不冲突:条件互斥)
const onDocKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this._fullscreen) this.exitFullscreen();
};
document.addEventListener('keydown', onDocKeydown);
this._bindDragDrop(); this._bindDragDrop();
this._cleanups.push(() => { this._cleanups.push(() => {
ta.removeEventListener('input', onInput); ta.removeEventListener('keydown', onKeydown); ta.removeEventListener('input', onInput); ta.removeEventListener('keydown', onKeydown);
@@ -335,6 +345,7 @@ export class MarkdownEditor {
ta.removeEventListener('focus', onFocus); ta.removeEventListener('blur', onBlur); ta.removeEventListener('focus', onFocus); ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick); this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown); this.dividerEl.removeEventListener('pointerdown', onDividerDown);
document.removeEventListener('keydown', onDocKeydown);
}); });
} }
@@ -377,7 +388,8 @@ export class MarkdownEditor {
ta.value = ta.value.slice(0, lineStart) + newBlock + ta.value.slice(end); ta.value = ta.value.slice(0, lineStart) + newBlock + ta.value.slice(end);
ta.selectionStart = lineStart; ta.selectionEnd = lineStart + newBlock.length; ta.selectionStart = lineStart; ta.selectionEnd = lineStart + newBlock.length;
} }
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); const old = this._value;
this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(old);
} }
_bindDividerDrag(e: PointerEvent): void { _bindDividerDrag(e: PointerEvent): void {
@@ -400,6 +412,8 @@ export class MarkdownEditor {
window.removeEventListener('pointermove', onMove); window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp); window.removeEventListener('pointerup', onUp);
this._saveDividerPosition(); this._saveDividerPosition();
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
this._hideFloatingToolbar();
}; };
window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp);
} }
@@ -481,22 +495,22 @@ export class MarkdownEditor {
const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s(.*)/); const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s(.*)/);
if (listMatch) { if (listMatch) {
const indent = listMatch[1]; const marker = listMatch[2]; const content = listMatch[3]; const indent = listMatch[1]; const marker = listMatch[2]; const content = listMatch[3];
if (!content.trim()) { e.preventDefault(); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; } if (!content.trim()) { e.preventDefault(); const oldV = this._value; ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return; }
let nextMarker = marker; let nextMarker = marker;
if (/^\d+\.$/.test(marker)) { const num = parseInt(marker, 10); if (!isNaN(num)) nextMarker = (num + 1) + '.'; } if (/^\d+\.$/.test(marker)) { const num = parseInt(marker, 10); if (!isNaN(num)) nextMarker = (num + 1) + '.'; }
e.preventDefault(); const insert = '\n' + indent + nextMarker + ' '; e.preventDefault(); const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return;
} }
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/); const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
if (quoteMatch) { if (quoteMatch) {
const prefix = quoteMatch[1]; const content = quoteMatch[2]; const prefix = quoteMatch[1]; const content = quoteMatch[2];
if (!content.trim()) { e.preventDefault(); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; } if (!content.trim()) { e.preventDefault(); const oldV = this._value; ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return; }
e.preventDefault(); const insert = '\n' + prefix; e.preventDefault(); const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length; ta.selectionStart = ta.selectionEnd = start + insert.length;
this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
} }
} }
@@ -505,10 +519,10 @@ export class MarkdownEditor {
const ta = this.textarea; const key = e.key; const close = BRACKET_PAIRS[key]; const ta = this.textarea; const key = e.key; const close = BRACKET_PAIRS[key];
if (!close) return; if (!close) return;
const start = ta.selectionStart; const end = ta.selectionEnd; const start = ta.selectionStart; const end = ta.selectionEnd;
if (start !== end) { e.preventDefault(); const selected = ta.value.slice(start, end); const wrap = key + selected + close; ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.selectionStart = start + 1; ta.selectionEnd = start + 1 + selected.length; this._value = ta.value; this._pushHistory(); this._emit('change', this._value); return; } if (start !== end) { e.preventDefault(); const oldV = this._value; const selected = ta.value.slice(start, end); const wrap = key + selected + close; ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.selectionStart = start + 1; ta.selectionEnd = start + 1 + selected.length; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return; }
const nextChar = ta.value[start] || ''; const nextChar = ta.value[start] || '';
if (key === close) { if (nextChar === close) { e.preventDefault(); ta.selectionStart = ta.selectionEnd = start + 1; return; } if (/\w/.test(nextChar)) return; } if (key === close) { if (nextChar === close) { e.preventDefault(); ta.selectionStart = ta.selectionEnd = start + 1; return; } if (/\w/.test(nextChar)) return; }
e.preventDefault(); const pair = key + close; ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = start + 1; this._value = ta.value; this._pushHistory(); this._emit('change', this._value); e.preventDefault(); const oldV = this._value; const pair = key + close; ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = start + 1; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
} }
_bindDragDrop(): void { _bindDragDrop(): void {
@@ -539,6 +553,20 @@ export class MarkdownEditor {
_scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } _scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
/**
* / / / API textarea线
* 8 ///
* + + + + input/change + + before/afterChange
*/
_afterProgrammaticEdit(oldValue: string): void {
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', oldValue, this._value);
this._render(); this._renderGutter(); this._updateWordCount(); this._updateOutline();
this._emit('input', this._value); this._emit('change', this._value);
if (typeof this.config.onInput === 'function') { try { this.config.onInput(this._value, this); } catch (e) { console.error(e); } }
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } }
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this);
}
_pushHistory(): void { _pushHistory(): void {
// Enforce maxLength as a last-resort guard for programmatic edit paths // Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write // (tab/indent, smart-enter, bracket auto-close, exec commands) that write
@@ -560,12 +588,18 @@ export class MarkdownEditor {
canRedo(): boolean { return this._historyIndex < this._history.length - 1; } canRedo(): boolean { return this._historyIndex < this._history.length - 1; }
_applyHistory(): void { _applyHistory(): void {
// 撤销/重做前记录预览区滚动比例,重渲染后按比例恢复,不再跳顶
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
const oldV = this._value;
this._value = this._history[this._historyIndex]; this.textarea.value = this._value; this._value = this._history[this._historyIndex]; this.textarea.value = this._value;
this._render(); this._renderGutter(); this._updateWordCount(); this._afterProgrammaticEdit(oldV);
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop; if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
if (this.previewPane && this.config.syncScroll) this.previewPane.scrollTop = 0; if (pv && pvRatio != null) {
this._emit('change', this._value); const max = pv.scrollHeight - pv.clientHeight;
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } } if (max > 0) pv.scrollTop = pvRatio * max;
}
} }
_captureCursor(): void { _captureCursor(): void {
@@ -669,6 +703,21 @@ export class MarkdownEditor {
setWordWrap(on: boolean): this { this._wordWrap = !!on; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; } setWordWrap(on: boolean): this { this._wordWrap = !!on; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
isWordWrap(): boolean { return this._wordWrap; } isWordWrap(): boolean { return this._wordWrap; }
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on: boolean): this {
this.config.outline = !!on;
if (this._destroyed || !this.el) return this;
if (this.config.outline) {
this._buildOutline();
this._trackOutlineScroll();
} else {
const panel = this.el.querySelector('.me-outline');
if (panel) panel.remove();
}
return this;
}
isOutline(): boolean { return !!this.config.outline; }
_bindToolbarKeyboard(): void { _bindToolbarKeyboard(): void {
if (!this.toolbarEl) return; if (!this.toolbarEl) return;
const onKeydown = (e: KeyboardEvent) => { const onKeydown = (e: KeyboardEvent) => {
@@ -815,9 +864,10 @@ export class MarkdownEditor {
const matches = this._value.match(re); const matches = this._value.match(re);
if (!matches) return 0; if (!matches) return 0;
// 函数式替换:替换文本按字面处理,$ 无特殊语义 // 函数式替换:替换文本按字面处理,$ 无特殊语义
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, () => replace)); this._value = this._limitLength(this._value.replace(re, () => replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); this._pushHistory(); this._afterProgrammaticEdit(oldV);
return matches.length; return matches.length;
} }
@@ -826,9 +876,10 @@ export class MarkdownEditor {
const re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g'); const re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
const matches = this._value.match(re); const matches = this._value.match(re);
if (!matches) return 0; if (!matches) return 0;
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, replace)); this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); this._pushHistory(); this._afterProgrammaticEdit(oldV);
return matches.length; return matches.length;
} }
@@ -873,9 +924,16 @@ export class MarkdownEditor {
copyAsHTML(): this { copyAsHTML(): this {
const html = this.getHTML(); const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) { if (typeof navigator !== 'undefined' && navigator.clipboard) {
// ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
if (typeof ClipboardItem !== 'undefined') {
try {
const blob = new Blob([html], { type: 'text/html' }); const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) }); const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => {}); navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => {});
return this;
} catch (_) { /* fallthrough 降级 */ }
}
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'html' })).catch(() => {});
} }
return this; return this;
} }
@@ -885,7 +943,7 @@ export class MarkdownEditor {
const inserted = this._limitLength(ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start)); const inserted = this._limitLength(ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start));
ta.value = inserted; ta.focus(); ta.value = inserted; ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length); ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return this; const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return this;
} }
wrap(before: string, after: string): this { this._wrapSelection(before, after || before); return this; } wrap(before: string, after: string): this { this._wrapSelection(before, after || before); return this; }
focus(): this { if (this.textarea) this.textarea.focus(); return this; } focus(): this { if (this.textarea) this.textarea.focus(); return this; }
@@ -978,6 +1036,8 @@ export class MarkdownEditor {
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {}; this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null; this._floatingToolbar = null;
if (this._floatMirror && this._floatMirror.parentNode) this._floatMirror.parentNode.removeChild(this._floatMirror);
this._floatMirror = null;
if (this.el) this.el.querySelectorAll('.me-toast').forEach((t) => t.remove()); if (this.el) this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { try { p.destroy(this); } catch (_) {} } }); this._plugins = []; this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { try { p.destroy(this); } catch (_) {} } }); this._plugins = [];
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el); if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
+131 -36
View File
@@ -1,23 +1,31 @@
/** /**
* MetonaEditor Floating Toolbar selection format toolbar * MetonaEditor Floating Toolbar selection format toolbar
* @module floating-toolbar * @module floating-toolbar
* @version 0.4.0 * @version 0.4.3
*
* v0.4.3 mirror
* textarea
* marker offsetLeft/offsetTop
* CJKtab
* fallback textarea
* rAF divider / resize
*/ */
import type { MarkdownEditor } from './core'; import type { MarkdownEditor } from './core';
/** 浮动工具栏与选区锚点之间的间距(px) */
const ANCHOR_GAP = 6;
export function initFloatingToolbar(this: MarkdownEditor): void { export function initFloatingToolbar(this: MarkdownEditor): void {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
// Inject floating toolbar CSS once globally // Inject floating toolbar CSS once globally
if (!document.getElementById('me-float-style')) { if (!document.getElementById('me-float-style')) {
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'me-float-style'; style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`; style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;visibility:hidden;transform:translateY(4px);transition:opacity .15s,transform .15s,visibility .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}.me-float-mirror{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;z-index:-1;border:0;margin:0}`;
document.head.appendChild(style); document.head.appendChild(style);
} }
if (!this._floatingEnabled) return;
const ta = this.textarea; const ta = this.textarea;
const show = () => { const show = () => {
@@ -27,51 +35,130 @@ export function initFloatingToolbar(this: MarkdownEditor): void {
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar(); if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect(); const anchor = this._measureSelectionAnchor();
const paneRect = this.editorPane.getBoundingClientRect(); 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 taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22; const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16; const paneW = this.editorPane.getBoundingClientRect().width;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
const fontSize = parseFloat(taStyle.fontSize) || 13.5;
const charWidth = fontSize * 0.6;
const textBefore = ta.value.substring(0, start); let top = anchor.y - barH - ANCHOR_GAP;
const lines = textBefore.split('\n'); if (top < ANCHOR_GAP) top = anchor.y + lineHeight + ANCHOR_GAP;
const currentLine = lines.length - 1; let left = anchor.x - barW / 2;
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1; left = Math.max(4, Math.min(left, paneW - barW - 4));
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1)); if (left < 4) left = 4; // 极窄面板兜底
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop; bar.style.top = top + 'px';
const lineLocalTop = lineViewportTop - paneRect.top; bar.style.left = left + 'px';
const toolbarHeight = 36; bar.classList.add('me-visible');
const gap = 4;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap) top = lineLocalTop + lineHeight + gap;
const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft;
let left = colX - paneRect.left - 80;
left = Math.max(4, Math.min(left, paneRect.width - 200));
this._floatingToolbar!.style.top = top + 'px';
this._floatingToolbar!.style.left = left + 'px';
this._floatingToolbar!.classList.add('me-visible');
}; };
const hide = () => { this._hideFloatingToolbar(); }; const hide = () => { this._hideFloatingToolbar(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0)); // 滚动跟随:rAF 节流重定位(保持纯事件驱动,不做轮询)
ta.addEventListener('keyup', () => { 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 统一发射 // selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0); if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0);
else setTimeout(hide, 0); else setTimeout(hide, 0);
}); };
ta.addEventListener('blur', () => setTimeout(hide, 300)); const onBlur = () => setTimeout(() => {
ta.addEventListener('click', () => { // 焦点快速抖动场景(如自动化 fill 后还原焦点再 focus 回来、
// 宿主脚本焦点切换):延迟到期时焦点已回到 textarea 则不隐藏
if (document.activeElement === ta) return;
hide();
}, 300);
const onClick = () => {
if (ta.selectionStart === ta.selectionEnd) hide(); if (ta.selectionStart === ta.selectionEnd) hide();
};
const onScroll = reposition;
// 布局变化后旧坐标失效,直接隐藏(下次选区交互重新显示)
const onResize = () => hide();
ta.addEventListener('mouseup', onMouseUp);
ta.addEventListener('keyup', onKeyup);
ta.addEventListener('blur', onBlur);
ta.addEventListener('click', onClick);
ta.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);
// 事件绑定与启用状态解耦:floatingToolbar:false 构造的实例,
// toggleFloatingToolbar() 打开后仍可正常显示(修复“死开关”)。
this._cleanups.push(() => {
ta.removeEventListener('mouseup', onMouseUp);
ta.removeEventListener('keyup', onKeyup);
ta.removeEventListener('blur', onBlur);
ta.removeEventListener('click', onClick);
ta.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
}); });
} }
/**
* editorPane
* =
*/
export function measureSelectionAnchor(this: MarkdownEditor): { x: number; y: number } | null {
const ta = this.textarea;
if (!ta || !this.editorPane) return null;
const start = ta.selectionStart; const end = ta.selectionEnd;
if (start === end) return null;
const anchor = Math.floor((start + end) / 2);
// 懒创建镜像层并复制 textarea 排版样式(font/行高/padding/换行/tab
let mirror = this._floatMirror;
if (!mirror || !mirror.parentNode) {
mirror = document.createElement('div');
mirror.className = 'me-float-mirror';
this.editorPane.appendChild(mirror);
this._floatMirror = mirror;
}
const cs = getComputedStyle(ta);
const props: Array<[string, string]> = [
['fontFamily', cs.fontFamily], ['fontSize', cs.fontSize], ['fontWeight', cs.fontWeight],
['fontStyle', cs.fontStyle], ['letterSpacing', cs.letterSpacing], ['lineHeight', cs.lineHeight],
['paddingTop', cs.paddingTop], ['paddingRight', cs.paddingRight],
['paddingBottom', cs.paddingBottom], ['paddingLeft', cs.paddingLeft],
['whiteSpace', cs.whiteSpace], ['overflowWrap', cs.overflowWrap],
['wordBreak', cs.wordBreak], ['tabSize', cs.tabSize], ['boxSizing', 'border-box'],
];
props.forEach(([k, v]) => { (mirror.style as any)[k] = v; });
// 宽度对齐 textarea 文本排版区(clientWidth 含 padding 不含滚动条)
mirror.style.width = ta.clientWidth + 'px';
// 仅需锚点前缀文本:其后内容不影响锚点位置(折行由前缀决定)
mirror.textContent = '';
mirror.appendChild(document.createTextNode(ta.value.slice(0, anchor)));
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
// marker 坐标相对 mirrorposition:absolute 的 offsetParentborder 0),
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
const x = marker.offsetLeft - ta.scrollLeft;
const y = marker.offsetTop - ta.scrollTop;
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
}
export function toggleFloatingToolbar(this: MarkdownEditor): MarkdownEditor { export function toggleFloatingToolbar(this: MarkdownEditor): MarkdownEditor {
this._floatingEnabled = !this._floatingEnabled; this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) { if (!this._floatingEnabled) {
@@ -88,14 +175,21 @@ export function isFloatingToolbar(this: MarkdownEditor): boolean { return this._
export function buildFloatingToolbar(this: MarkdownEditor): void { export function buildFloatingToolbar(this: MarkdownEditor): void {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.className = 'me-float-toolbar'; bar.className = 'me-float-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', this.t('formatToolbar') || '格式化选区');
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough']; const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => { actions.forEach((action) => {
const btn = this._createBtn(action); const btn = this._createBtn(action);
// mousedown 阻止夺焦(textarea 选区与焦点保持);命令执行统一走 click,
// 键盘 Enter/Space 触发的 click 同样生效(可访问性)
btn.addEventListener('mousedown', (e) => { btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
});
btn.addEventListener('click', () => {
this.exec(action); this.exec(action);
// Keep selection after exec this._hideFloatingToolbar();
setTimeout(() => this.textarea.focus(), 0); // Keep selection after exec(延迟回调时实例可能已销毁)
setTimeout(() => { if (!this._destroyed && this.textarea) this.textarea.focus(); }, 0);
}); });
bar.appendChild(btn); bar.appendChild(btn);
}); });
@@ -117,4 +211,5 @@ export const installFloatingToolbar = (proto: any): void => {
proto.isFloatingToolbar = isFloatingToolbar; proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar; proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar; proto._hideFloatingToolbar = hideFloatingToolbar;
proto._measureSelectionAnchor = measureSelectionAnchor;
}; };
+6
View File
@@ -241,6 +241,12 @@ export const createInstanceI18n = (editor: any): InstanceI18n => {
} }
}); });
} }
// 语言切换即时刷新已渲染的 UI(状态栏统计标签、大纲标题),无需等待下一次输入
if (typeof editor._updateWordCount === 'function') { try { editor._updateWordCount(); } catch (_) {} }
if (editor.el && typeof editor.el.querySelector === 'function') {
const outlineTitle = editor.el.querySelector('.me-outline-title');
if (outlineTitle) outlineTitle.textContent = instanceT('outline') || 'Outline';
}
return instanceLocale; return instanceLocale;
}; };
+2 -2
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Type-safe, lightweight Markdown Editor * MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor * @module metona-editor
* @version 0.4.1 * @version 0.4.3
*/ */
import { MarkdownEditor } from './core'; import { MarkdownEditor } from './core';
@@ -14,7 +14,7 @@ import { highlight, normalizeLanguage, registerLanguage, getSupportedLanguages }
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants'; import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants';
import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants'; import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants';
const VERSION = '0.4.1'; const VERSION = '0.4.3';
const globalPlugins: any[] = []; const globalPlugins: any[] = [];
+7 -1
View File
@@ -31,6 +31,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: '大纲', outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选', cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB', 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',
@@ -58,6 +59,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
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)', 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: '打ち消し線',
@@ -85,6 +87,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: 'アウトライン', outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択', cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB', regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
}, },
ko: { ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -111,7 +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', 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é',
@@ -139,6 +143,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
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)', 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',
@@ -166,5 +171,6 @@ export const LOCALES: Record<string, Record<string, string>> = {
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)', 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',
}, },
}; };
+48 -5
View File
@@ -8,16 +8,45 @@ import { t as i18nT } from './i18n';
import { escapeHTML } from './utils'; import { escapeHTML } from './utils';
import type { MarkdownEditor } from './core'; import type { MarkdownEditor } from './core';
/** querySelector 用的 id 转义:优先原生 CSS.escape,环境缺失时退化为简易转义 */
const cssEscape = (id: string): string => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(id);
return id.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
export function buildOutline(this: MarkdownEditor): void { export function buildOutline(this: MarkdownEditor): void {
if (!this.config.outline || !this.previewEl) return; if (!this.config.outline || !this.previewEl) return;
const old = this.el.querySelector('.me-outline'); if (old) old.remove(); const old = this.el.querySelector('.me-outline'); if (old) old.remove();
const headings: Array<{ level: number; id: string; text: string }> = []; const headings: Array<{ level: number; id: string; text: string; srcLine: number }> = [];
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi; const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) { while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') }); headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, ''), srcLine: -1 });
} }
if (!headings.length) return; if (!headings.length) return;
// 计算每个渲染标题在源码中的行号(跳过围栏代码块内部),
// 点击时按行号精确定位光标,重复标题文本也不会串位。
const srcLines: number[] = [];
const lines = this._value.split('\n');
let inFence = false; let fenceChar = '';
const isSetextText = (l: string): boolean =>
/\S/.test(l) && !/^\s{0,3}([-*+]|\d+\.)\s/.test(l) && !/^\s{0,3}>/.test(l) && !/^\s{0,3}(#{1,6}\s|`{3,}|~{3,})/.test(l);
for (let li = 0; li < lines.length; li++) {
const l = lines[li];
const fence = l.match(/^\s{0,3}(`{3,}|~{3,})/);
if (fence) {
if (!inFence) { inFence = true; fenceChar = fence[1][0]; }
else if (fence[1][0] === fenceChar) inFence = false;
continue;
}
if (inFence) continue;
if (/^ {0,3}(?:>\s*)*#{1,6}\s+\S/.test(l)) { srcLines.push(li); continue; }
const next = lines[li + 1] || '';
if (li + 1 < lines.length && /^ {0,3}(={3,}|-{3,})\s*$/.test(next) && isSetextText(l)) srcLines.push(li);
}
headings.forEach((h, i) => { h.srcLine = i < srcLines.length ? srcLines[i] : -1; });
const panel = document.createElement('div'); panel.className = 'me-outline'; const panel = document.createElement('div'); panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`; panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`;
// 迭代式树构建(栈模拟嵌套 ul),O(n) 而非递归的 O(n²) // 迭代式树构建(栈模拟嵌套 ul),O(n) 而非递归的 O(n²)
@@ -34,7 +63,7 @@ export function buildOutline(this: MarkdownEditor): void {
if (item.level < minLevel) continue; if (item.level < minLevel) continue;
closeTo(item.level); closeTo(item.level);
h += '<ul>'; h += '<ul>';
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`; h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.srcLine >= 0 ? item.srcLine + 1 : ''}">${escapeHTML(item.text)}</a>`;
stack.push(item.level); stack.push(item.level);
} }
closeTo(-1); closeTo(-1);
@@ -45,8 +74,19 @@ export function buildOutline(this: MarkdownEditor): void {
panel.addEventListener('click', (e) => { panel.addEventListener('click', (e) => {
const a = (e.target as HTMLElement).closest('a'); if (!a) return; e.preventDefault(); const a = (e.target as HTMLElement).closest('a'); if (!a) return; e.preventDefault();
const id = a.getAttribute('href')!.slice(1); const id = a.getAttribute('href')!.slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(id)); const target = this.previewEl.querySelector('#' + cssEscape(id));
if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); const idx = this._value.indexOf(target.textContent || ''); if (idx !== -1) { this.textarea.focus(); this.textarea.setSelectionRange(idx, idx); } } if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
// 光标定位:优先用构建时记录的源码行号(重复标题也正确),兜底 indexOf
const ord = Array.prototype.indexOf.call(panel.querySelectorAll('a'), a);
const lineIdx = ord > -1 && ord < headings.length ? headings[ord].srcLine : -1;
if (lineIdx >= 0) {
const lineText = lines[lineIdx] || '';
const col = (lineText.match(/^ {0,3}(?:>\s*)*#{1,6}\s+/) || lineText.match(/^ {0,3}/) || [''])[0].length;
this.setCursorPosition(lineIdx + 1, col);
} else {
const idx = this._value.indexOf(target?.textContent || '');
if (idx !== -1) { this.textarea.focus(); this.textarea.setSelectionRange(idx, idx); }
}
}); });
} }
@@ -58,6 +98,9 @@ export function updateOutline(this: MarkdownEditor): void {
export function trackOutlineScroll(this: MarkdownEditor): void { export function trackOutlineScroll(this: MarkdownEditor): void {
if (!this.config.outline || !this.previewPane) return; if (!this.config.outline || !this.previewPane) return;
// 防重复绑定:构造时绑定过、setOutline(true) 再次调用时跳过
if ((this as any)._outlineScrollBound) return;
(this as any)._outlineScrollBound = true;
let ticking = false; let ticking = false;
const onScroll = () => { const onScroll = () => {
if (ticking) return; if (ticking) return;
+4 -1
View File
@@ -134,6 +134,9 @@ const cachedRenderInline = (text: string, env: RenderEnv): string => {
const clearRenderCache = (): void => { renderCache.clear(); }; const clearRenderCache = (): void => { renderCache.clear(); };
/** 当前行内渲染缓存条目数(FIFO 上限 MAX_CACHE_SIZE),供观测/测试缓存淘汰 */
const getRenderCacheSize = (): number => renderCache.size;
// ============ Security utils ============ // ============ Security utils ============
const safeUrl = (url: string | null | undefined): string => { const safeUrl = (url: string | null | undefined): string => {
@@ -796,6 +799,6 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
export { export {
parseMarkdown, parseTokens, renderTokens, parseMarkdown, parseTokens, renderTokens,
safeUrl, slugify, clearRenderCache, registerBlockHandler, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler,
}; };
export default parseMarkdown; export default parseMarkdown;
+46 -22
View File
@@ -17,12 +17,15 @@ export interface EditorLike {
el: HTMLElement; el: HTMLElement;
textarea: HTMLTextAreaElement; textarea: HTMLTextAreaElement;
config?: Record<string, any>; config?: Record<string, any>;
t?: (key: string, params?: Record<string, any>) => string;
on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void; on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void;
off?: (name: string, fn: (...args: any[]) => void) => unknown; off?: (name: string, fn: (...args: any[]) => void) => unknown;
_emit?: (name: string, ...args: any[]) => void; _emit?: (name: string, ...args: any[]) => void;
_pushHistory?: () => void; _pushHistory?: () => void;
_render?: () => void; _render?: () => void;
_updateWordCount?: () => void; _updateWordCount?: () => void;
/** 程序化编辑统一刷新管线(渲染+行号+字数+大纲+事件+钩子) */
_afterProgrammaticEdit?: (oldValue: string) => void;
insert?: (text: string, opts?: { replace?: boolean }) => unknown; insert?: (text: string, opts?: { replace?: boolean }) => unknown;
setValue?: (value: string, opts?: { silent?: boolean }) => unknown; setValue?: (value: string, opts?: { silent?: boolean }) => unknown;
getValue?: () => string; getValue?: () => string;
@@ -145,6 +148,11 @@ export const validateConfig = (schema: PluginSchema = {}, config: Record<string,
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** editor.t locale退 t
* */
const instanceT = (editor: EditorLike): ((key: string, params?: Record<string, any>) => string) =>
(key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
const autoSavePlugin: Plugin = { const autoSavePlugin: Plugin = {
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100, name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
install(editor, options?: Record<string, any>) { install(editor, options?: Record<string, any>) {
@@ -270,6 +278,7 @@ const searchReplacePlugin: Plugin = {
name: 'searchReplace', version: '0.2.1', description: 'Search & Replace (Ctrl+F/H)', priority: 80, name: 'searchReplace', version: '0.2.1', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
install(editor) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!editor || !editor.textarea || typeof document === 'undefined') return;
const tt = instanceT(editor);
// Inject style once globally // Inject style once globally
if (!document.getElementById('me-search-style')) { if (!document.getElementById('me-search-style')) {
const style = document.createElement('style'); style.id = 'me-search-style'; const style = document.createElement('style'); style.id = 'me-search-style';
@@ -325,7 +334,7 @@ const searchReplacePlugin: Plugin = {
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd); const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
const panel = document.createElement('div'); panel.className = 'me-search'; const panel = document.createElement('div'); panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0'; panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${t('regex')||'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`; panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${tt('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${tt('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${tt('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${tt('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${tt('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${tt('regex')||'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${tt('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${tt('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${tt('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${tt('replaceAll')||'All'}</button></div>`;
editor.el.appendChild(panel); state._panel = panel; editor.el.appendChild(panel); state._panel = panel;
_updateReplaceVisible(); _updateReplaceVisible();
state._regexMode = false; state._matchCase = true; state._wholeWord = false; state._regexMode = false; state._matchCase = true; state._wholeWord = false;
@@ -413,10 +422,16 @@ const searchReplacePlugin: Plugin = {
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 || '';
editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
} else {
if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render(); if (typeof editor._render === 'function') editor._render();
if (typeof editor._updateWordCount === 'function') editor._updateWordCount(); if (typeof editor._updateWordCount === 'function') editor._updateWordCount();
if (typeof editor._emit === 'function') editor._emit('change', editor._value); if (typeof editor._emit === 'function') editor._emit('change', editor._value);
}
findNext(); findNext();
}; };
const replaceAll = () => { const replaceAll = () => {
@@ -424,12 +439,17 @@ const searchReplacePlugin: Plugin = {
const ta = editor.textarea; const ta = editor.textarea;
const re = buildPattern(q); const re = buildPattern(q);
if (!re) return; if (!re) return;
const oldV = editor._value || '';
ta.value = ta.value.replace(re, () => 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._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
} else {
if (typeof editor._pushHistory === 'function') editor._pushHistory(); if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render(); if (typeof editor._render === 'function') editor._render();
if (typeof editor._updateWordCount === 'function') editor._updateWordCount(); if (typeof editor._updateWordCount === 'function') editor._updateWordCount();
if (typeof editor._emit === 'function') editor._emit('change', editor._value); if (typeof editor._emit === 'function') editor._emit('change', editor._value);
}
findAll(); findAll();
}; };
fi.addEventListener('input', () => { lastIdxs = findAll(); }); fi.addEventListener('input', () => { lastIdxs = findAll(); });
@@ -477,6 +497,7 @@ const imagePastePlugin: Plugin = {
name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60, name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60,
install(editor, options: any = {}) { install(editor, options: any = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!editor || !editor.textarea || typeof document === 'undefined') return;
const tt = instanceT(editor);
const maxSizeKB: number = options.maxSizeKB || 500; const maxSizeKB: number = options.maxSizeKB || 500;
const _onPaste = (e: ClipboardEvent) => { const _onPaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items; if (!items) return; const items = e.clipboardData?.items; if (!items) return;
@@ -487,7 +508,7 @@ const imagePastePlugin: Plugin = {
const sizeKB = file.size / 1024; const sizeKB = file.size / 1024;
if (maxSizeKB > 0 && sizeKB > maxSizeKB) { if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault(); e.preventDefault();
if (typeof editor.toast === 'function') editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' }); if (typeof editor.toast === 'function') editor.toast(tt('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break; break;
} }
e.preventDefault(); e.preventDefault();
@@ -510,48 +531,51 @@ const shortcutHelpPlugin: Plugin = {
name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200, name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200,
install(editor) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!editor || !editor.textarea || typeof document === 'undefined') return;
const tt = instanceT(editor);
if (!document.getElementById('me-shortcut-style')) { if (!document.getElementById('me-shortcut-style')) {
const s = document.createElement('style'); s.id = 'me-shortcut-style'; const s = document.createElement('style'); s.id = 'me-shortcut-style';
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`; s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
document.head.appendChild(s); document.head.appendChild(s);
} }
let _panel: HTMLElement | null = null; // 面板引用放入 holder:install 时无法预知未来打开的面板,
// 卸载时经 holder 取实时引用,避免面板残留 DOM。
const st = { panel: null as HTMLElement | null };
const _close = () => { if (_panel) { _panel.remove(); _panel = null; } }; const _close = () => { if (st.panel) { st.panel.remove(); st.panel = null; } };
const _open = () => { const _open = () => {
if (_panel) { _close(); return; } if (st.panel) { _close(); return; }
// i18n-aware shortcut labels // i18n-aware shortcut labels(跟随实例语言)
const builtin: [string, string][] = [ const builtin: [string, string][] = [
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'], ['Ctrl+B', tt('bold') || 'Bold'], ['Ctrl+I', tt('italic') || 'Italic'],
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'], ['Ctrl+U', tt('underline') || 'Underline'], ['Ctrl+K', tt('link') || 'Link'],
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'], ['Ctrl+E', tt('code') || 'Code'], ['Ctrl+1/2/3', tt('h1') || 'Heading'],
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'], ['Ctrl+Q', tt('quote') || 'Quote'], ['Ctrl+Z', tt('undo') || 'Undo'],
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'], ['Ctrl+Y', tt('redo') || 'Redo'], ['Ctrl+S', tt('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'], ['Ctrl+F', tt('search') || 'Search'], ['Ctrl+H', tt('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'], ['Tab', tt('indent') || 'Indent'], ['Shift+Tab', tt('outdent') || 'Outdent'],
['?', t('shortcuts') || 'Shortcuts'], ['?', tt('shortcuts') || 'Shortcuts'],
]; ];
let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; }); let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay'; const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`; overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${tt('shortcuts') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); });
document.body.appendChild(overlay); _panel = overlay; document.body.appendChild(overlay); st.panel = overlay;
}; };
const _onKeydown = (e: KeyboardEvent) => { const _onKeydown = (e: KeyboardEvent) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); _open(); } if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); _open(); }
if (e.key === 'Escape' && _panel) _close(); if (e.key === 'Escape' && st.panel) _close();
}; };
editor.textarea.addEventListener('keydown', _onKeydown); editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown }); setPluginState(editor, K_SHORTCUT, { state: st, _open, _close, _onKeydown });
}, },
destroy(editor: EditorLike) { destroy(editor: EditorLike) {
const state = pluginState<{ _panel: HTMLElement | null; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT); const exposed = pluginState<{ state: { panel: HTMLElement | null }; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT);
if (state) { if (state._panel) { state._panel.remove(); } } if (exposed) { if (exposed.state && exposed.state.panel) { exposed.state.panel.remove(); exposed.state.panel = null; } }
const _onKeydown = state?._onKeydown; const _onKeydown = exposed?._onKeydown;
if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown); if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT); deletePluginState(editor, K_SHORTCUT);
}, },
+627 -3
View File
@@ -17,6 +17,11 @@ if (typeof window !== 'undefined' && typeof window.PointerEvent === 'undefined')
}; };
} }
// jsdom 未实现 scrollIntoView(大纲点击跳转依赖),提供 no-op stub
if (typeof Element !== 'undefined' && typeof (Element.prototype as any).scrollIntoView !== 'function') {
(Element.prototype as any).scrollIntoView = () => {};
}
describe('MarkdownEditor - 构造', () => { describe('MarkdownEditor - 构造', () => {
let container; let container;
@@ -781,10 +786,32 @@ describe('MarkdownEditor - 同步滚动', () => {
document.body.appendChild(c); document.body.appendChild(c);
const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n'); const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true }); const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true });
ed.textarea.scrollTop = 100; // jsdom 的 scrollHeight 恒为 0mock 出可滚动尺寸以驱动比例联动
Object.defineProperty(ed.textarea, 'scrollHeight', { configurable: true, value: 2000 });
Object.defineProperty(ed.textarea, 'clientHeight', { configurable: true, value: 500 });
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
ed.textarea.scrollTop = 750; // textarea 滚动比例 0.5
ed.textarea.dispatchEvent(new Event('scroll')); ed.textarea.dispatchEvent(new Event('scroll'));
// preview 应被同步滚动(scrollTop > 0 或为 0,取决于尺寸) // preview 按同比例滚动:0.5 * (1000 - 500) = 250
expect(typeof ed.previewPane.scrollTop).toBe('number'); expect(ed.previewPane.scrollTop).toBe(250);
ed.destroy();
});
test('分屏模式下 preview 滚动反向联动 textarea', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true });
Object.defineProperty(ed.textarea, 'scrollHeight', { configurable: true, value: 2000 });
Object.defineProperty(ed.textarea, 'clientHeight', { configurable: true, value: 500 });
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
ed.previewPane.scrollTop = 250; // preview 滚动比例 0.5
ed.previewPane.dispatchEvent(new Event('scroll'));
// textarea 按同比例滚动:0.5 * (2000 - 500) = 750
expect(ed.textarea.scrollTop).toBe(750);
ed.destroy(); ed.destroy();
}); });
@@ -3009,3 +3036,600 @@ describe('MarkdownEditor - v0.2.5 maxLength 默认值', () => {
ed.destroy(); ed.destroy();
}); });
}); });
// ============ v0.4.2 事件补齐 ============
describe('MarkdownEditor - v0.4.2 事件补齐', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('渲染路径触发 beforeRender / afterRender 事件', () => {
const ed = new MarkdownEditor(container, { value: '# hi', mode: 'split' });
const before = jest.fn(); const after = jest.fn();
ed.on('beforeRender', before); ed.on('afterRender', after);
ed.setValue('# changed');
expect(before).toHaveBeenCalledTimes(1);
expect(after).toHaveBeenCalledTimes(1);
ed.destroy();
});
test('getHTML 触发 beforeRender / afterRender 事件', () => {
const ed = new MarkdownEditor(container, { value: '# hi' });
const before = jest.fn(); const after = jest.fn();
ed.on('beforeRender', before); ed.on('afterRender', after);
ed.getHTML();
expect(before).toHaveBeenCalledTimes(1);
expect(after).toHaveBeenCalledTimes(1);
ed.destroy();
});
test('toggleZen 触发 zenChange 事件', () => {
const ed = new MarkdownEditor(container, { value: 'test' });
const onZen = jest.fn();
ed.on('zenChange', onZen);
ed.toggleZen();
expect(onZen).toHaveBeenCalledWith(true, ed);
ed.toggleZen();
expect(onZen).toHaveBeenLastCalledWith(false, ed);
ed.destroy();
});
test('toggleFullscreen / exitFullscreen 触发 fullscreen 事件', () => {
const ed = new MarkdownEditor(container, { value: 'test' });
const onFs = jest.fn();
ed.on('fullscreen', onFs);
ed.toggleFullscreen();
expect(ed.isFullscreen()).toBe(true);
expect(onFs).toHaveBeenCalledWith(true, ed);
ed.exitFullscreen();
expect(ed.isFullscreen()).toBe(false);
expect(onFs).toHaveBeenLastCalledWith(false, ed);
ed.destroy();
});
test('全局钩子 beforeCreate / beforeDestroy / afterDestroy 触发', () => {
const beforeCreate = jest.fn(); const beforeDestroy = jest.fn(); const afterDestroy = jest.fn();
MarkdownEditor.on('beforeCreate', beforeCreate);
MarkdownEditor.on('beforeDestroy', beforeDestroy);
MarkdownEditor.on('afterDestroy', afterDestroy);
const ed = new MarkdownEditor(container, {});
expect(beforeCreate).toHaveBeenCalledTimes(1);
ed.destroy();
expect(beforeDestroy).toHaveBeenCalledTimes(1);
expect(afterDestroy).toHaveBeenCalledTimes(1);
MarkdownEditor.off('beforeCreate', beforeCreate);
MarkdownEditor.off('beforeDestroy', beforeDestroy);
MarkdownEditor.off('afterDestroy', afterDestroy);
});
test('copyAsMarkdown 写剪贴板并触发 copy 事件', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write: jest.fn() } });
const ed = new MarkdownEditor(container, { value: '# copy test' });
const onCopy = jest.fn();
ed.on('copy', onCopy);
ed.copyAsMarkdown();
await new Promise((r) => setTimeout(r, 0));
expect(writeText).toHaveBeenCalledWith('# copy test');
expect(onCopy).toHaveBeenCalledWith({ type: 'markdown' }, ed);
delete (navigator as any).clipboard;
ed.destroy();
});
test('copyAsHTML 写富文本剪贴板并触发 copy 事件', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
const write = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write } });
// jsdom 无 ClipboardItem,注入 stub 供富文本写入
const items: any[] = [];
(global as any).ClipboardItem = class { constructor(it: any) { items.push(it); } };
const ed = new MarkdownEditor(container, { value: '**bold**' });
const onCopy = jest.fn();
ed.on('copy', onCopy);
ed.copyAsHTML();
await new Promise((r) => setTimeout(r, 0));
expect(write).toHaveBeenCalledTimes(1);
expect(write.mock.calls[0][0]).toHaveLength(1);
expect(onCopy).toHaveBeenCalledWith({ type: 'html' }, ed);
delete (global as any).ClipboardItem;
delete (navigator as any).clipboard;
ed.destroy();
});
});
// ============ v0.4.2 配置项补齐 ============
describe('MarkdownEditor - v0.4.2 配置项补齐', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('placeholder 配置生效', () => {
const ed = new MarkdownEditor(container, { placeholder: '自定义占位' });
expect(ed.textarea.placeholder).toBe('自定义占位');
ed.destroy();
});
test('未配置 placeholder 时使用翻译默认值', () => {
const ed = new MarkdownEditor(container, {});
expect(ed.textarea.placeholder).toBe(ed.t('placeholder'));
ed.destroy();
});
test('height 数字转为 px', () => {
const ed = new MarkdownEditor(container, { height: 520 });
expect(ed.el.style.height).toBe('520px');
ed.destroy();
});
test('height 字符串原样使用', () => {
const ed = new MarkdownEditor(container, { height: '100%' });
expect(ed.el.style.height).toBe('100%');
ed.destroy();
});
test('spellcheck 默认关闭', () => {
const ed = new MarkdownEditor(container, {});
expect(ed.textarea.spellcheck).toBe(false);
ed.destroy();
});
test('spellcheck: true 开启拼写检查', () => {
const ed = new MarkdownEditor(container, { spellcheck: true });
expect(ed.textarea.spellcheck).toBe(true);
ed.destroy();
});
test('tabSize 自定义值生效且 Tab 插入对应空格数', () => {
const ed = new MarkdownEditor(container, { tabSize: 4 });
expect(ed.textarea.style.tabSize).toBe('4');
ed.textarea.setSelectionRange(0, 0);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe(' ');
ed.destroy();
});
test('tabSize: 0 时 Tab 插入制表符', () => {
const ed = new MarkdownEditor(container, { tabSize: 0 });
ed.textarea.setSelectionRange(0, 0);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe('\t');
ed.destroy();
});
test('historyDebounce 自定义防抖间隔生效', () => {
jest.useFakeTimers();
const ed = new MarkdownEditor(container, { historyDebounce: 100 });
const before = ed._history.length;
ed.textarea.value = 'typed';
ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
jest.advanceTimersByTime(50);
expect(ed._history.length).toBe(before);
jest.advanceTimersByTime(60);
expect(ed._history.length).toBe(before + 1);
jest.useRealTimers();
ed.destroy();
});
});
// ============ v0.4.2 undo/redo 滚动保持 ============
describe('MarkdownEditor - v0.4.2 undo/redo 滚动保持', () => {
test('undo/redo 后预览区滚动位置按比例保持', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'v1', mode: 'split' });
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
ed.setValue('v2\n\nmore content');
ed.previewPane.scrollTop = 250; // 滚动比例 50%
ed.undo();
expect(ed.previewPane.scrollTop).toBe(250);
ed.redo();
expect(ed.previewPane.scrollTop).toBe(250);
ed.destroy();
});
});
// ============ v0.4.2 大纲光标定位 ============
describe('MarkdownEditor - v0.4.2 大纲光标定位', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('点击大纲按源码行号定位光标(重复标题不串位)', () => {
const md = '# alpha\n\ntext\n\n# beta\n\n# alpha\n';
const ed = new MarkdownEditor(container, { value: md, outline: true, mode: 'split' });
const links = ed.el.querySelectorAll('.me-outline a');
expect(links.length).toBe(3);
// 第三个链接对应第二个 'alpha' 标题(源码第 7 行)
(links[2] as HTMLElement).click();
expect(ed.getCursorPosition().line).toBe(7);
ed.destroy();
});
test('data-line 记录源码行号(1 基)', () => {
const ed = new MarkdownEditor(container, { value: '# a\n\n## b\n', outline: true, mode: 'split' });
const links = ed.el.querySelectorAll('.me-outline a');
expect(links[0].getAttribute('data-line')).toBe('1');
expect(links[1].getAttribute('data-line')).toBe('3');
ed.destroy();
});
test('setOutline 运行时开关大纲面板', () => {
const ed = new MarkdownEditor(container, { value: '# title\n\nbody', outline: false, mode: 'split' });
expect(ed.isOutline()).toBe(false);
expect(ed.el.querySelector('.me-outline')).toBeNull();
ed.setOutline(true);
expect(ed.isOutline()).toBe(true);
expect(ed.el.querySelector('.me-outline')).not.toBeNull();
ed.setOutline(false);
expect(ed.el.querySelector('.me-outline')).toBeNull();
ed.destroy();
});
});
// ============ v0.4.2 setLocale 即时刷新 ============
describe('MarkdownEditor - v0.4.2 setLocale 即时刷新', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('setLocale 即时刷新状态栏统计标签', () => {
const ed = new MarkdownEditor(container, { value: 'hello', locale: 'zh-CN' });
expect(ed.statusEl!.textContent).toContain('字符');
ed.setLocale('en-US');
expect(ed.statusEl!.textContent).toContain('Characters');
expect(ed.statusEl!.textContent).not.toContain('字符');
ed.destroy();
});
test('setLocale 即时刷新大纲标题', () => {
const ed = new MarkdownEditor(container, { value: '# t', outline: true, locale: 'zh-CN', mode: 'split' });
expect(ed.el.querySelector('.me-outline-title')!.textContent).toBe('大纲');
ed.setLocale('en-US');
expect(ed.el.querySelector('.me-outline-title')!.textContent).toBe('Outline');
ed.destroy();
});
});
// ============ v0.4.2 浮动工具栏中文选区 ============
describe('MarkdownEditor - v0.4.2 浮动工具栏中文选区', () => {
test('中文选区触发浮动工具栏显示且定位计算不抛错', async () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '这是一段中文文本内容' });
ed.textarea.setSelectionRange(0, 6);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
expect(ed._floatingToolbar).not.toBeNull();
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
ed.destroy();
});
});
// ============ v0.4.3 浮动工具栏定位引擎重做 ============
describe('MarkdownEditor - v0.4.3 浮动工具栏重做', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
/** 触发选区显示浮动工具栏(keyup 路径) */
const showToolbar = async (ed: MarkdownEditor, start: number, end: number) => {
ed.textarea.setSelectionRange(start, end);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
};
test('floatingToolbar:false 构造后 toggle 重开可用(死开关修复)', async () => {
const ed = new MarkdownEditor(container, { value: 'hello world', floatingToolbar: false });
expect(ed._floatingEnabled).toBe(false);
ed.toggleFloatingToolbar();
expect(ed._floatingEnabled).toBe(true);
await showToolbar(ed, 0, 5);
expect(ed._floatingToolbar).not.toBeNull();
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
ed.destroy();
});
test('选区触发时懒创建镜像层且后续复用不重建', async () => {
const ed = new MarkdownEditor(container, { value: 'first line\nsecond line' });
expect(ed._floatMirror).toBeNull();
await showToolbar(ed, 0, 5);
expect(ed._floatMirror).not.toBeNull();
expect(ed._floatMirror!.classList.contains('me-float-mirror')).toBe(true);
const m1 = ed._floatMirror;
await showToolbar(ed, 11, 17);
expect(ed._floatMirror).toBe(m1);
ed.destroy();
});
test('destroy 清理镜像层 DOM', async () => {
const ed = new MarkdownEditor(container, { value: 'hello' });
await showToolbar(ed, 0, 5);
expect(ed._floatMirror).not.toBeNull();
ed.destroy();
expect(ed._floatMirror).toBeNull();
expect(document.querySelector('.me-float-mirror')).toBeNull();
});
test('textarea 滚动触发重定位不抛错(选区仍在视口内保持显示)', async () => {
const ed = new MarkdownEditor(container, { value: 'hello world' });
await showToolbar(ed, 0, 5);
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
ed.textarea.dispatchEvent(new Event('scroll', { bubbles: true }));
await new Promise((r) => setTimeout(r, 20));
// jsdom 无真实布局:验证 rAF 重定位路径执行不抛错、状态未破坏
expect(ed._floatingToolbar).not.toBeNull();
ed.destroy();
});
test('工具栏 a11y 属性(role / aria-label', async () => {
const ed = new MarkdownEditor(container, { value: 'hello' });
await showToolbar(ed, 0, 5);
const bar = ed._floatingToolbar!;
expect(bar.getAttribute('role')).toBe('toolbar');
expect(bar.getAttribute('aria-label')).toBe('格式化选区');
ed.destroy();
});
test('隐藏态 CSS 使用 visibility(移出 Tab 序列)', () => {
new MarkdownEditor(container, { value: 'hello' }); // 触发样式注入
const style = document.getElementById('me-float-style')!;
expect(style.textContent).toContain('visibility:hidden');
expect(style.textContent).toContain('visibility:visible');
});
test('按钮 click 触发 exec 并隐藏工具栏(键盘可达)', async () => {
const ed = new MarkdownEditor(container, { value: 'hello world' });
await showToolbar(ed, 0, 5);
const btn = ed._floatingToolbar!.querySelector('.me-btn') as HTMLElement;
btn.click();
expect(ed.getValue()).toBe('**hello** world');
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(false);
ed.destroy();
});
test('window resize 后隐藏浮动工具栏', async () => {
const ed = new MarkdownEditor(container, { value: 'hello' });
await showToolbar(ed, 0, 5);
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
window.dispatchEvent(new Event('resize'));
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(false);
ed.destroy();
});
test('_measureSelectionAnchor 无选区返回 null', () => {
const ed = new MarkdownEditor(container, { value: 'hello' });
ed.textarea.setSelectionRange(0, 0);
expect(ed._measureSelectionAnchor()).toBeNull();
ed.destroy();
});
test('blur 延迟隐藏带焦点回归校验(焦点抖动防护)', async () => {
const ed = new MarkdownEditor(container, { value: 'hello world' });
ed.textarea.focus();
ed.textarea.setSelectionRange(0, 5);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
// 合成 blur(焦点未真实转移):延迟到期时焦点仍在 textarea,不应隐藏
ed.textarea.dispatchEvent(new Event('blur'));
await new Promise((r) => setTimeout(r, 350));
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
// 真实失焦(activeElement 离开 textarea):延迟到期后隐藏
ed.textarea.blur();
await new Promise((r) => setTimeout(r, 350));
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(false);
ed.destroy();
});
});
// ============ v0.4.3 程序化编辑统一刷新管线 ============
describe('MarkdownEditor - v0.4.3 程序化编辑统一刷新管线', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('Smart Enter 延续列表后预览同步刷新', () => {
const ed = new MarkdownEditor(container, { value: '- item', mode: 'split' });
expect(ed.previewEl.querySelectorAll('li').length).toBe(1);
ed.textarea.setSelectionRange(6, 6);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe('- item\n- ');
expect(ed.previewEl.querySelectorAll('li').length).toBe(2);
ed.destroy();
});
test('Smart Enter 后触发 input 事件', () => {
const ed = new MarkdownEditor(container, { value: '- item' });
const onInput = jest.fn();
ed.on('input', onInput);
ed.textarea.setSelectionRange(6, 6);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(onInput).toHaveBeenCalledTimes(1);
expect(onInput).toHaveBeenCalledWith('- item\n- ', ed);
ed.destroy();
});
test('括号自动闭合后预览与字数统计刷新', () => {
const ed = new MarkdownEditor(container, { value: 'abc', mode: 'split' });
ed.textarea.setSelectionRange(3, 3);
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '(', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe('abc()');
expect(ed.getStats().characters).toBe(5);
expect(ed.previewEl.textContent).toBe('abc()');
ed.destroy();
});
test('Tab 缩进后字数统计刷新', () => {
const ed = new MarkdownEditor(container, { value: 'ab' });
ed.textarea.setSelectionRange(2, 2);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe('ab ');
expect(ed.getStats().characters).toBe(4);
ed.destroy();
});
test('insert 后行号装订线刷新', () => {
const ed = new MarkdownEditor(container, { value: 'one' });
expect(ed.gutter.children.length).toBe(1);
ed.insert('\ntwo');
expect(ed.gutter.children.length).toBe(2);
ed.destroy();
});
test('exec h1 后大纲面板延时刷新', async () => {
const ed = new MarkdownEditor(container, { value: '标题', outline: true, mode: 'split' });
expect(ed.el.querySelectorAll('.me-outline a').length).toBe(0);
ed.textarea.setSelectionRange(0, 0);
ed.exec('h1');
await new Promise((r) => setTimeout(r, 400));
expect(ed.el.querySelectorAll('.me-outline a').length).toBe(1);
ed.destroy();
});
test('undo 后触发 afterChange 事件', () => {
const ed = new MarkdownEditor(container, { value: '' });
ed.setValue('v2');
ed.setValue('v3');
const after = jest.fn();
ed.on('afterChange', after);
ed.undo();
expect(ed.getValue()).toBe('v2');
expect(after).toHaveBeenCalledTimes(1);
ed.destroy();
});
test('replaceAll 后触发 afterChange 事件', () => {
const ed = new MarkdownEditor(container, { value: 'abc abc' });
const after = jest.fn();
ed.on('afterChange', after);
ed.replaceAll('abc', 'xyz');
expect(ed.getValue()).toBe('xyz xyz');
expect(after).toHaveBeenCalledTimes(1);
ed.destroy();
});
test('exec bold 触发 beforeChange(oldValue, newValue)', () => {
const ed = new MarkdownEditor(container, { value: 'hello' });
let oldV = ''; let newV = '';
ed.on('beforeChange', (o, n) => { oldV = o; newV = n; });
ed.textarea.setSelectionRange(0, 5);
ed.exec('bold');
expect(oldV).toBe('hello');
expect(newV).toBe('**hello**');
ed.destroy();
});
test('onInput 回调在 exec 后触发', () => {
const onInput = jest.fn();
const ed = new MarkdownEditor(container, { value: 'hello', onInput });
ed.textarea.setSelectionRange(0, 5);
ed.exec('bold');
expect(onInput).toHaveBeenCalledTimes(1);
expect(onInput).toHaveBeenCalledWith('**hello**', ed);
ed.destroy();
});
});
// ============ v0.4.3 周边修复 ============
describe('MarkdownEditor - v0.4.3 周边修复', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('copyAsHTML 无 ClipboardItem 环境降级为纯文本写入', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
const write = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write } });
delete (global as any).ClipboardItem;
const ed = new MarkdownEditor(container, { value: '**bold**' });
const onCopy = jest.fn();
ed.on('copy', onCopy);
ed.copyAsHTML();
await new Promise((r) => setTimeout(r, 0));
expect(write).not.toHaveBeenCalled();
expect(writeText).toHaveBeenCalledWith('**bold**');
expect(onCopy).toHaveBeenCalledWith({ type: 'html' }, ed);
delete (navigator as any).clipboard;
ed.destroy();
});
test('右键 paste 被浏览器拒绝时 toast 提示改用 Ctrl+V', () => {
// jsdom 未实现 execCommand,直接替换为可控 stub
const orig = (document as any).execCommand;
(document as any).execCommand = jest.fn().mockReturnValue(false);
const ed = new MarkdownEditor(container, { value: 'text' });
ed._execContextAction('paste');
expect(ed.el.querySelector('.me-toast')).not.toBeNull();
expect(ed.el.querySelector('.me-toast')!.textContent).toContain('Ctrl+V');
(document as any).execCommand = orig;
ed.destroy();
});
test('全屏模式按 Esc 退出', () => {
const ed = new MarkdownEditor(container, { value: 'text' });
ed.toggleFullscreen();
expect(ed.isFullscreen()).toBe(true);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(ed.isFullscreen()).toBe(false);
// 非全屏时 Esc 无副作用
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(ed.isFullscreen()).toBe(false);
ed.destroy();
});
test('右键菜单自定义 label 经 HTML 转义(防注入)', () => {
const ed = new MarkdownEditor(container, { value: 'text' });
ed.registerContextMenu([{ label: '<img src=x onerror=alert(1)>' }]);
const e = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
ed.el.dispatchEvent(e);
// 自定义项追加在默认项(6 个,sep 不算)之后,取最后一个菜单项
const items = document.querySelectorAll('.me-context-menu-item');
expect(items.length).toBe(7);
const span = items[items.length - 1].querySelector('span') as HTMLElement;
expect(span).not.toBeNull();
expect(span.innerHTML).not.toContain('<img');
expect(span.textContent).toBe('<img src=x onerror=alert(1)>');
ed._hideContextMenu();
ed.destroy();
});
});
+9 -6
View File
@@ -29,7 +29,7 @@ describe('index.ts - 全局 API', () => {
test('VERSION 是字符串', () => { test('VERSION 是字符串', () => {
expect(typeof VERSION).toBe('string'); expect(typeof VERSION).toBe('string');
expect(VERSION).toBe('0.4.1'); expect(VERSION).toBe('0.4.3');
}); });
test('api.default 是 api 本身', () => { test('api.default 是 api 本身', () => {
@@ -193,14 +193,17 @@ describe('index.ts - 解析器导出', () => {
}); });
test('registerBlockHandler 注册自定义块', () => { test('registerBlockHandler 注册自定义块', () => {
let matched = false;
registerBlockHandler({ registerBlockHandler({
name: 'testBlock', name: 'testBlockIndex',
priority: 50, priority: 50,
test: () => null, test: (line) => { if (line === '!!!custom!!!') { matched = true; return true; } return null; },
parse: (_l, i) => ({ token: null, newIndex: i }), parse: (_l, i) => ({ token: { type: 'testBlockIndex', content: 'hit' }, newIndex: i + 1 }),
}); });
// 不抛错即通过 const html = parseMarkdown('!!!custom!!!\nafter');
expect(true).toBe(true); // 自定义 handler 被真实命中并产出 token(回退渲染为 me-block-* div
expect(matched).toBe(true);
expect(html).toContain('me-block-testBlockIndex');
}); });
}); });
+9 -3
View File
@@ -3,7 +3,7 @@
* Markdown * Markdown
*/ */
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from '../src/parser'; import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler } from '../src/parser';
describe('parseMarkdown - 基础', () => { describe('parseMarkdown - 基础', () => {
test('空输入返回空字符串', () => { test('空输入返回空字符串', () => {
@@ -909,12 +909,18 @@ describe('parseMarkdown - v0.1.15 registerBlockHandler', () => {
describe('parseMarkdown - 覆盖率:渲染缓存淘汰', () => { describe('parseMarkdown - 覆盖率:渲染缓存淘汰', () => {
test('缓存超过上限触发 FIFO 淘汰', () => { test('缓存超过上限触发 FIFO 淘汰', () => {
// MAX_CACHE_SIZE = 300,填充 301 个极短文本触发淘汰(避免 CI 超时) // MAX_CACHE_SIZE = 300,填充 301 个极短文本触发淘汰(避免 CI 超时)
clearRenderCache();
const short = 'x'; const short = 'x';
for (let i = 0; i < 301; i++) { for (let i = 0; i < 301; i++) {
parseMarkdown(short + i); parseMarkdown(short + i);
} }
// 不应抛错,且至少有一次淘汰发生 // 缓存条目数被 FIFO 上限约束在 300,证明淘汰真实发生
expect(true).toBe(true); expect(getRenderCacheSize()).toBe(300);
// 超限后再写入仍维持上限
parseMarkdown('overflow-entry');
expect(getRenderCacheSize()).toBe(300);
clearRenderCache();
expect(getRenderCacheSize()).toBe(0);
}); });
}); });
+112 -4
View File
@@ -570,7 +570,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo'; input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click(); ed.el.querySelector('.me-search-next').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); // 首个匹配位于 0,且选中内容即搜索词
expect(ed.textarea.selectionStart).toBe(0);
expect(ed.getSelectedText()).toBe('foo');
}); });
test('查找上一个高亮匹配', () => { test('查找上一个高亮匹配', () => {
@@ -580,7 +582,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click(); ed.el.querySelector('.me-search-next').click();
ed.el.querySelector('.me-search-prev').click(); ed.el.querySelector('.me-search-prev').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); // next 选中第一处(0),prev 回绕到最后一处('foo bar foo baz foo' 中 16
expect(ed.textarea.selectionStart).toBe(16);
expect(ed.getSelectedText()).toBe('foo');
}); });
test('空查询不抛错', () => { test('空查询不抛错', () => {
@@ -600,7 +604,8 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo'; input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); expect(ed.textarea.selectionStart).toBe(0);
expect(ed.getSelectedText()).toBe('foo');
}); });
test('Shift+Enter 在 findInput 上查找上一个', () => { test('Shift+Enter 在 findInput 上查找上一个', () => {
@@ -609,7 +614,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo'; input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); // 初始光标在 0,prev 无更早匹配时回绕到最后一处(16)
expect(ed.textarea.selectionStart).toBe(16);
expect(ed.getSelectedText()).toBe('foo');
}); });
test('替换行 display 切换(none ↔ flex', () => { test('替换行 display 切换(none ↔ flex', () => {
@@ -1301,3 +1308,104 @@ describe('v0.2.5 imagePaste 尺寸限制', () => {
ed.destroy(); ed.destroy();
}); });
}); });
// ============ v0.4.2 fileSystem 事件补齐 ============
describe('fileSystem 插件 - fileOpened / fileSaved 事件', () => {
afterEach(() => {
delete (window as any).showOpenFilePicker;
delete (window as any).showSaveFilePicker;
});
test('openFile 触发 fileOpened 事件并载入内容', async () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
const onOpened = jest.fn();
ed.on('fileOpened', onOpened);
const fakeFile = { name: 'doc.md', text: async () => '# opened content' };
const handle = { getFile: async () => fakeFile };
(window as any).showOpenFilePicker = async () => [handle];
ed.use('fileSystem');
const result = await (ed as any).openFile();
expect(result).toEqual({ name: 'doc.md', content: '# opened content', handle });
expect(ed.getValue()).toBe('# opened content');
expect(onOpened).toHaveBeenCalledWith({ name: 'doc.md', handle }, ed);
ed.destroy();
});
test('saveFile 触发 fileSaved 事件', async () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# save me' });
const onSaved = jest.fn();
ed.on('fileSaved', onSaved);
const writable = { write: jest.fn().mockResolvedValue(undefined), close: jest.fn().mockResolvedValue(undefined) };
const handle = { createWritable: async () => writable };
(window as any).showOpenFilePicker = async () => [];
(window as any).showSaveFilePicker = async () => handle;
ed.use('fileSystem');
const ok = await (ed as any).saveFile();
expect(ok).toBe(true);
expect(writable.write).toHaveBeenCalledWith('# save me');
expect(onSaved).toHaveBeenCalledWith({ handle }, ed);
ed.destroy();
});
});
// ============ v0.4.2 插件面板实例级 i18n ============
describe('v0.4.2 插件面板实例级 i18n', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('searchReplace 面板文案跟随实例 localeen-US', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
ed.use('searchReplace');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
expect(input.placeholder).toBe('Find');
const replaceBtn = ed.el.querySelector('.me-search-replace-all');
expect(replaceBtn.textContent).toBe('Replace All');
ed.el.querySelector('.me-search-close').click();
ed.destroy();
});
test('searchReplace 面板文案跟随实例 localezh-CN', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'zh-CN' });
ed.use('searchReplace');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
expect(input.placeholder).toBe('查找内容');
ed.el.querySelector('.me-search-close').click();
ed.destroy();
});
test('shortcutHelp 面板文案跟随实例 localeen-US', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
ed.use('shortcutHelp');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
const overlay = document.querySelector('.me-shortcut-overlay') as HTMLElement;
expect(overlay).not.toBeNull();
expect(overlay.querySelector('h3')!.textContent).toContain('Shortcuts');
expect(overlay.textContent).toContain('Bold');
ed.destroy();
});
test('unuse shortcutHelp 移除已打开的面板(不残留 DOM)', () => {
const ed = new MarkdownEditor(container, { value: 'foo' });
ed.use('shortcutHelp');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
expect(document.querySelector('.me-shortcut-overlay')).not.toBeNull();
ed.unuse('shortcutHelp');
expect(document.querySelector('.me-shortcut-overlay')).toBeNull();
ed.destroy();
});
});