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 项断言(含真实浏览器浮动工具栏布局验证)
This commit is contained in:
2026-08-20 20:43:01 +08:00
parent ac6638247f
commit d6a618af5d
16 changed files with 646 additions and 115 deletions
+28
View File
@@ -2,6 +2,34 @@
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
+12 -9
View File
@@ -2,9 +2,9 @@
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
[![version](https://img.shields.io/badge/version-0.4.2-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
[![version](https://img.shields.io/badge/version-0.4.3-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
[![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-871%20passed-brightgreen)](./tests)
[![tests](https://img.shields.io/badge/tests-895%20passed-brightgreen)](./tests)
[![coverage](https://img.shields.io/badge/coverage-parser%2099%25-brightgreen)](./tests)
[![types](https://img.shields.io/badge/types-TypeScript%20strict-blue)](./tsconfig.json)
@@ -17,11 +17,11 @@
- **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步
- **主题系统** — light / dark / warm / autoCSS 变量可定制,实例级隔离,主题继承,外部跟随
- **国际化** — 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` 即插即用
- **性能** — 字数 / 词数 / 行数差异增量统计,击键零全量扫描;大纲 O(n) 线性构建,长文档即时响应
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护(含脚注 id),白名单裸标签透传,sanitize 钩子
- **工程化** — 871 个单元测试 + Playwright 真实浏览器冒烟测试,CI 并行 5 job(含 e2e),覆盖率阈值门禁,`sideEffects: false` 便于 tree-shaking
- **工程化** — 895 个单元测试 + Playwright 真实浏览器冒烟测试28 项断言)CI 并行 5 job(含 e2e),覆盖率阈值门禁,`sideEffects: false` 便于 tree-shaking
- **桌面端优先** — 纯电脑端设计,无移动端冗余代码
- **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性
- **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等)
@@ -256,6 +256,8 @@ editor.toggleFloatingToolbar(): this // 开关选中文本格式栏
editor.isFloatingToolbar(): boolean
```
选中文本时自动浮现粗体 / 斜体 / 代码 / 链接 / 删除线按钮。定位采用 mirror 镜像测量(隐藏镜像层复制 textarea 排版样式,选区锚点插入零宽字符读像素坐标),中文混排、长行软换行、跨行选区均精确跟随;选区滚出可视区自动隐藏,滚动时节流重定位。按钮键盘可达(Tab 聚焦 + Enter 执行),隐藏态移出 Tab 序列。已知限制:RTL 布局下定位未做镜像适配。
### 命令执行
```typescript
@@ -430,7 +432,7 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team
| 事件 | 触发时机 | 参数 |
|------|---------|------|
| `input` | textarea 原生 input | `(value, editor)` |
| `input` | 内容变化(原生输入与程序化编辑路径均触发) | `(value, editor)` |
| `change` | 内容变化 | `(value, editor)` |
| `focus` | 聚焦 | `(editor)` |
| `blur` | 失焦 | `(editor)` |
@@ -447,8 +449,8 @@ import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team
| `linkClick` | 预览区链接点击 | `({ href, text })` |
| `fileOpened` | fileSystem 打开文件 | `({ name, handle })` |
| `fileSaved` | fileSystem 保存文件 | `({ handle })` |
| `beforeChange` | 内容变更前 | `(oldValue, newValue, editor)` |
| `afterChange` | 内容变更后 | `(newValue, editor)` |
| `beforeChange` | 内容变更前(原生输入与程序化编辑路径均触发) | `(oldValue, newValue, editor)` |
| `afterChange` | 内容变更后(原生输入与程序化编辑路径均触发) | `(newValue, editor)` |
| `copy` | 复制到剪贴板 | `({ type })` |
| `selectionChange` | 选区变化 | `({ start, end, text })` |
| `cursorMove` | 光标移动 | `({ line, column })` |
@@ -746,6 +748,7 @@ i18nUtils.formatDate('2024-01-15') // 本地化日期
| `Tab` / `Shift+Tab` | 缩进 / 反缩进 |
| `?` | 快捷键帮助 |
| `Enter` | 智能 Enter(列表/引用延续) |
| `Esc` | 退出全屏 |
---
@@ -820,8 +823,8 @@ src/
|------|------|
| 语言 | TypeScript 5 (strict) |
| 构建 | Rollup 3 |
| 测试 | Jest 29 + jsdom871 用例,覆盖率阈值门禁) |
| 浏览器冒烟 | Playwright + Chromium22 项断言) |
| 测试 | Jest 29 + jsdom895 用例,覆盖率阈值门禁) |
| 浏览器冒烟 | Playwright + Chromium28 项断言) |
| 类型生成 | rollup-plugin-dts |
| 零运行时依赖 | ✅ |
+45
View File
@@ -115,6 +115,51 @@ const check = (name, cond) => {
const status = await page.evaluate(() => window.__ed.getStatus());
check('getStatus 返回结构', status.mode === 'split' && status.theme === 'dark' && status.locale === 'zh-CN');
// 12. 浮动工具栏(真实浏览器布局验证:mirror 镜像测量定位)
// 放在依赖旧值/历史栈的用例之后执行(本节自行填充测试内容)
await page.locator('.me-textarea').fill('# 标题\n\n第二行中文文本内容\n\n第三行 more text');
await page.waitForTimeout(50);
await page.locator('.me-textarea').evaluate((el) => {
el.focus();
const start = el.value.indexOf('第二行');
el.setSelectionRange(start, start + 6);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await page.waitForSelector('.me-float-toolbar.me-visible', { timeout: 3000 });
check('浮动工具栏选区后显示', true);
const pos = await page.evaluate(() => {
const bar = document.querySelector('.me-float-toolbar');
const ta = document.querySelector('.me-textarea');
const bb = bar.getBoundingClientRect();
const tb = ta.getBoundingClientRect();
return { barL: bb.left, barR: bb.right, barT: bb.top, barB: bb.bottom, taL: tb.left, taR: tb.right, taT: tb.top, taB: tb.bottom };
});
check('浮动工具栏水平定位在编辑区内', pos.barR > pos.taL && pos.barL < pos.taR);
check('浮动工具栏垂直定位在选区行附近', pos.barT >= pos.taT - 50 && pos.barB <= pos.taB + 50);
// 等待显示过渡动画结束后再点击(避免 actionability 等待期间竞态)
await page.waitForTimeout(300);
await page.locator('.me-float-toolbar .me-btn').first().click();
check('浮动工具栏 bold 命令', (await page.locator('.me-textarea').inputValue()).includes('**第二行中文文**'));
check('命令执行后浮动工具栏隐藏', (await page.locator('.me-float-toolbar.me-visible').count()) === 0);
// 12.5 长行折行选区(软换行场景下工具栏不跑出可视区)
await page.locator('.me-textarea').fill('# T\n\n' + '很长的中文内容用于触发自动折行的测试文本'.repeat(20));
await page.waitForTimeout(50);
await page.locator('.me-textarea').evaluate((el) => {
el.focus();
el.setSelectionRange(el.value.length - 20, el.value.length);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await page.waitForTimeout(100);
check('长行折行选区工具栏保持可视区内', (await page.evaluate(() => {
const bar = document.querySelector('.me-float-toolbar');
const ta = document.querySelector('.me-textarea');
if (!bar || !bar.classList.contains('me-visible')) return false;
const bb = bar.getBoundingClientRect();
const tb = ta.getBoundingClientRect();
return bb.top >= tb.top - 50 && bb.bottom <= tb.bottom + 50;
})));
await browser.close();
server.close();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@metona-team/metona-editor",
"version": "0.4.2",
"version": "0.4.3",
"description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.",
"type": "module",
"main": "dist/metona-editor.cjs",
+14 -13
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
<title>MetonaEditor v0.4.2 — 全功能演示</title>
<title>MetonaEditor v0.4.3 — 全功能演示</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
@@ -45,11 +45,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<body>
<header class="header">
<h1><span class="grad">Metona</span>Editor v0.4.2</h1>
<h1><span class="grad">Metona</span>Editor v0.4.3</h1>
<div class="badges">
<span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span>
<span class="badge">871 tests</span>
<span class="badge">895 tests</span>
<span class="badge">6 个插件</span>
<span class="badge">桌面端优先</span>
</div>
@@ -93,7 +93,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
</div>
<footer class="footer">
MetonaEditor v0.4.2 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
MetonaEditor v0.4.3 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
</footer>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
@@ -101,19 +101,19 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<script>
(function(){
var demoMd=[
'# 🚀 MetonaEditor v0.4.2 全功能演示',
'# 🚀 MetonaEditor v0.4.3 全功能演示',
'',
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
'> 全模块 TypeScript 严格模式,17 个源文件,871 个测试全部通过。',
'> 全模块 TypeScript 严格模式,17 个源文件,895 个测试全部通过。',
'',
'## ✨ v0.4.2 新特性',
'## ✨ v0.4.3 新特性',
'',
'- **运行时大纲 API**`editor.setOutline(on)` / `isOutline()` 运行时开关大纲面板',
'- **插件面板实例级 i18n**:查找替换 / 快捷键面板文案跟随实例语言',
'- **撤销不再跳顶**:undo / redo 后预览区按滚动比例保持位置',
'- **大纲光标精确定位**:按源码行号定位,重复标题不串位',
'- **修复 8 处问题**shortcutHelp 卸载残留 · 无 CSS.escape 环境点击大纲崩溃 · setLocale 即时刷新 UI · 中文选区浮动工具栏偏移等',
'- **测试补强**841 → 871 用例(补齐 7 个事件断言 / 5 个配置项测试 / 修复弱断言',
'- **浮动工具栏定位重做**:mirror 镜像测量像素级定位,中文混排 / 长行软换行 / 跨行选区精确跟随',
'- **滚动跟随**:选区滚出可视区自动隐藏,滚动时节流重定位,分隔条拖拽与窗口缩放后自动隐藏',
'- **键盘可达**:隐藏态移出 Tab 序列,按钮支持 Enter / Space 执行,`role="toolbar"` + aria-label',
'- **程序化编辑统一刷新管线**:命令 / 快捷键 / 插件路径的预览 / 行号 / 字数 / 大纲刷新与 input / change 事件完全对齐原生输入',
'- **修复 13 处问题**浮动工具栏死开关 · 监听器泄漏 · 销毁后 focus 崩溃 · 右键菜单注入 · copyAsHTML 在 Firefox 崩溃 · Esc 退出全屏等',
'- **测试补强**871 → 895 用例,e2e 冒烟 22 → 28 项断言(含真实浏览器布局验证',
'',
'## 📐 文本样式',
'',
@@ -142,6 +142,7 @@ 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.0 | 2026-08 | 826 | E2E冒烟+实例i18n |',
+4 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
<title>MetonaEditor v0.4.2 · 文档</title>
<title>MetonaEditor v0.4.3 · 文档</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)}
@@ -65,7 +65,7 @@ footer a{color:var(--accent);text-decoration:none}
<header class="docs-header">
<h1><span class="grad">API 文档</span></h1>
<p class="sub">MetonaEditor v0.4.2 · TypeScript · 完整配置、API 与使用指南</p>
<p class="sub">MetonaEditor v0.4.3 · TypeScript · 完整配置、API 与使用指南</p>
</header>
<div class="container">
@@ -264,7 +264,7 @@ editor<span class="c-punc">.</span><span class="c-fn">setZenMaxWidth</span><span
editor<span class="c-punc">.</span><span class="c-fn">getZenMaxWidth</span><span class="c-punc">();</span> <span class="c-com">// => number | string | false</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleWordWrap</span><span class="c-punc">();</span> <span class="c-fn">setWordWrap</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isWordWrap</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">setOutline</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isOutline</span><span class="c-punc">();</span> <span class="c-com">// 运行时开关大纲面板(v0.4.2)</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleFloatingToolbar</span><span class="c-punc">();</span> <span class="c-fn">isFloatingToolbar</span><span class="c-punc">();</span> <span class="c-com">// 选中文本格式栏</span></pre>
editor<span class="c-punc">.</span><span class="c-fn">toggleFloatingToolbar</span><span class="c-punc">();</span> <span class="c-fn">isFloatingToolbar</span><span class="c-punc">();</span> <span class="c-com">// 选中文本格式栏mirror 镜像像素级定位,v0.4.3</span></pre>
</section>
<section id="api-stats"><h3>统计与状态</h3>
@@ -533,7 +533,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
<footer>
<div class="container">
MetonaEditor v0.4.2 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
MetonaEditor v0.4.3 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
</div>
</footer>
+7 -7
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>"/>
<title>MetonaEditor v0.4.2 · TypeScript · 桌面端 Markdown 编辑器</title>
<title>MetonaEditor v0.4.3 · TypeScript · 桌面端 Markdown 编辑器</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-3:#ec4899;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%);--shadow:0 20px 50px -20px rgba(0,0,0,.5)}
@@ -106,10 +106,10 @@ footer a:hover{text-decoration:underline}
<h1>TypeScript 重构 · 零依赖<br/><span class="grad">桌面端 Markdown 编辑器</span></h1>
<p class="tagline">全模块 TypeScript 严格模式,内置自研解析器(99% 行覆盖率),6 个预设插件,中文优先。为桌面端现代 Web 应用而生。</p>
<div class="badges">
<span class="badge accent">v0.4.2</span>
<span class="badge accent">v0.4.3</span>
<span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span>
<span class="badge">871 tests</span>
<span class="badge">895 tests</span>
<span class="badge">桌面端优先</span>
<span class="badge">MIT</span>
</div>
@@ -120,7 +120,7 @@ footer a:hover{text-decoration:underline}
<div class="stats">
<div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div>
<div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div>
<div class="stat"><div class="stat-num">871</div><div class="stat-label">单元测试</div></div>
<div class="stat"><div class="stat-num">895</div><div class="stat-label">单元测试</div></div>
<div class="stat"><div class="stat-num">~42KB</div><div class="stat-label">gzip 体积</div></div>
</div>
</header>
@@ -147,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"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></div><h3>内置语法高亮</h3><p>零依赖轻量高亮器,js / ts / python / bash / css 等 16 个语言标识,代码块一行配置即自动着色。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><h3>XSS 安全防护</h3><p>HTML 转义、危险协议过滤、属性级注入防护、sanitize 钩子、highlight 异常回退。多层防护,安全可靠。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div><h3>性能与工程</h3><p>字数/词数/行数差异增量统计、高亮规则缓存、大纲 O(n) 构建;871 单测 + Playwright 真实浏览器冒烟,CI 并行 5 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>
</section>
@@ -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/issues" target="_blank" rel="noopener">问题反馈</a>
</div>
<div>MetonaEditor v0.4.2 · TypeScript · MIT License · MetonaTeam</div>
<div>MetonaEditor v0.4.3 · TypeScript · MIT License · MetonaTeam</div>
</div>
</footer>
@@ -240,7 +240,7 @@ MeEditor.themes.switchTheme('dark');
MeEditor.create('#editor', {
mode: 'split', height: 480, theme: 'dark',
value: [
'# 欢迎使用 MetonaEditor v0.4.2',
'# 欢迎使用 MetonaEditor v0.4.3',
'',
'一个 **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();
if (selected) { ta.selectionStart = start + before.length; ta.selectionEnd = start + before.length + text.length; }
else { ta.selectionStart = ta.selectionEnd = start + before.length; }
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
// ============ Line prefix toggling ============
@@ -33,7 +33,7 @@ export function toggleLinePrefix(this: MarkdownEditor, prefix: string): void {
else newLine = prefix + line;
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
// ============ Block insertion ============
@@ -44,7 +44,7 @@ export function insertBlock(this: MarkdownEditor, text: string): void {
const insert = (needNL ? '\n' : '') + text;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd); ta.focus();
const pos = start + insert.length; ta.selectionStart = ta.selectionEnd = pos;
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
export function insertLink(this: MarkdownEditor): void {
@@ -54,7 +54,7 @@ export function insertLink(this: MarkdownEditor): void {
const insert = `[${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length;
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
export function insertImage(this: MarkdownEditor): void {
@@ -64,7 +64,7 @@ export function insertImage(this: MarkdownEditor): void {
const insert = `![${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length;
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
export function insertTable(this: MarkdownEditor, rows = 3, cols = 3): void {
@@ -107,7 +107,7 @@ export function formatTable(this: MarkdownEditor): void {
return '| ' + padded.join(' | ') + ' |';
});
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
// ============ Prototype installation ============
+9 -2
View File
@@ -5,6 +5,7 @@
*/
import { t as i18nT } from './i18n';
import { escapeHTML } from './utils';
import type { MarkdownEditor } from './core';
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');
el.className = 'me-context-menu-item';
if (item.disabled) el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`;
// 自定义菜单项的 label / shortcut 经转义后拼接(防 HTML 注入)
el.innerHTML = `<span>${escapeHTML(item.label)}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${escapeHTML(item.shortcut)}</span>` : ''}`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
if (item.disabled) return;
@@ -87,7 +89,12 @@ export function execContextAction(this: MarkdownEditor, action: string): void {
case 'redo': this.redo(); break;
case 'cut': document.execCommand('cut'); break;
case 'copy': document.execCommand('copy'); break;
case 'paste': document.execCommand('paste'); break;
case 'paste': {
// 现代浏览器出于安全限制,execCommand('paste') 通常静默失败 —— 提示改用 Ctrl+V
const ok = document.execCommand('paste');
if (!ok) this.toast(this.t('pasteBlocked') || '无法直接粘贴,请使用 Ctrl+V', { type: 'info' });
break;
}
case 'selectAll': ta.focus(); ta.select(); break;
default: this.exec(action); break;
}
+52 -14
View File
@@ -92,6 +92,7 @@ export class MarkdownEditor {
_i18nCtx!: InstanceI18n;
_floatingToolbar!: HTMLElement | null;
_floatingEnabled!: boolean;
_floatMirror!: HTMLElement | null;
// Prototype-installed methods (see installers at bottom of file)
_wrapSelection!: (before: string, after: string) => void;
@@ -106,6 +107,7 @@ export class MarkdownEditor {
isFloatingToolbar!: () => boolean;
_buildFloatingToolbar!: () => void;
_hideFloatingToolbar!: () => void;
_measureSelectionAnchor!: () => { x: number; y: number } | null;
registerContextMenu!: (items: any[]) => this;
_bindContextMenu!: () => void;
_showContextMenu!: (e: MouseEvent) => void;
@@ -139,6 +141,7 @@ export class MarkdownEditor {
this._zenMouseHandler = null;
this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false;
this._floatMirror = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
@@ -164,7 +167,9 @@ export class MarkdownEditor {
this._bindEvents();
this._bindContextMenu();
this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) this._initFloatingToolbar();
// 无条件绑定事件(show 内部检查 _floatingEnabled):
// floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
this._initFloatingToolbar();
this.textarea.value = this._value;
if (!this._wordWrap) this.textarea.style.whiteSpace = 'pre';
@@ -326,6 +331,11 @@ export class MarkdownEditor {
this.toolbarEl.addEventListener('click', onToolbarClick);
const onDividerDown = (e: PointerEvent) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown);
// 全屏模式按 Esc 退出(与 shortcutHelp 面板的 Esc 关闭互不冲突:条件互斥)
const onDocKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this._fullscreen) this.exitFullscreen();
};
document.addEventListener('keydown', onDocKeydown);
this._bindDragDrop();
this._cleanups.push(() => {
ta.removeEventListener('input', onInput); ta.removeEventListener('keydown', onKeydown);
@@ -335,6 +345,7 @@ export class MarkdownEditor {
ta.removeEventListener('focus', onFocus); ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown);
document.removeEventListener('keydown', onDocKeydown);
});
}
@@ -377,7 +388,8 @@ export class MarkdownEditor {
ta.value = ta.value.slice(0, lineStart) + newBlock + ta.value.slice(end);
ta.selectionStart = lineStart; ta.selectionEnd = lineStart + newBlock.length;
}
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
const old = this._value;
this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(old);
}
_bindDividerDrag(e: PointerEvent): void {
@@ -400,6 +412,8 @@ export class MarkdownEditor {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
this._saveDividerPosition();
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
this._hideFloatingToolbar();
};
window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp);
}
@@ -481,22 +495,22 @@ export class MarkdownEditor {
const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s(.*)/);
if (listMatch) {
const indent = listMatch[1]; const marker = listMatch[2]; const content = listMatch[3];
if (!content.trim()) { e.preventDefault(); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; }
if (!content.trim()) { e.preventDefault(); const oldV = this._value; ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return; }
let nextMarker = marker;
if (/^\d+\.$/.test(marker)) { const num = parseInt(marker, 10); if (!isNaN(num)) nextMarker = (num + 1) + '.'; }
e.preventDefault(); const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length;
this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return;
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return;
}
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
if (quoteMatch) {
const prefix = quoteMatch[1]; const content = quoteMatch[2];
if (!content.trim()) { e.preventDefault(); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; }
if (!content.trim()) { e.preventDefault(); const oldV = this._value; ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return; }
e.preventDefault(); const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length;
this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value);
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
}
@@ -505,10 +519,10 @@ export class MarkdownEditor {
const ta = this.textarea; const key = e.key; const close = BRACKET_PAIRS[key];
if (!close) return;
const start = ta.selectionStart; const end = ta.selectionEnd;
if (start !== end) { e.preventDefault(); const selected = ta.value.slice(start, end); const wrap = key + selected + close; ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.selectionStart = start + 1; ta.selectionEnd = start + 1 + selected.length; this._value = ta.value; this._pushHistory(); this._emit('change', this._value); return; }
if (start !== end) { e.preventDefault(); const oldV = this._value; const selected = ta.value.slice(start, end); const wrap = key + selected + close; ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.selectionStart = start + 1; ta.selectionEnd = start + 1 + selected.length; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return; }
const nextChar = ta.value[start] || '';
if (key === close) { if (nextChar === close) { e.preventDefault(); ta.selectionStart = ta.selectionEnd = start + 1; return; } if (/\w/.test(nextChar)) return; }
e.preventDefault(); const pair = key + close; ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = start + 1; this._value = ta.value; this._pushHistory(); this._emit('change', this._value);
e.preventDefault(); const oldV = this._value; const pair = key + close; ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = start + 1; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV);
}
_bindDragDrop(): void {
@@ -539,6 +553,20 @@ export class MarkdownEditor {
_scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
/**
* / / / API textarea线
* 8 ///
* + + + + input/change + + before/afterChange
*/
_afterProgrammaticEdit(oldValue: string): void {
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', oldValue, this._value);
this._render(); this._renderGutter(); this._updateWordCount(); this._updateOutline();
this._emit('input', this._value); this._emit('change', this._value);
if (typeof this.config.onInput === 'function') { try { this.config.onInput(this._value, this); } catch (e) { console.error(e); } }
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } }
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this);
}
_pushHistory(): void {
// Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write
@@ -564,15 +592,14 @@ export class MarkdownEditor {
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
const oldV = this._value;
this._value = this._history[this._historyIndex]; this.textarea.value = this._value;
this._render(); this._renderGutter(); this._updateWordCount();
this._afterProgrammaticEdit(oldV);
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
if (pv && pvRatio != null) {
const max = pv.scrollHeight - pv.clientHeight;
if (max > 0) pv.scrollTop = pvRatio * max;
}
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } }
}
_captureCursor(): void {
@@ -837,9 +864,10 @@ export class MarkdownEditor {
const matches = this._value.match(re);
if (!matches) return 0;
// 函数式替换:替换文本按字面处理,$ 无特殊语义
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, () => replace));
this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
this._pushHistory(); this._afterProgrammaticEdit(oldV);
return matches.length;
}
@@ -848,9 +876,10 @@ export class MarkdownEditor {
const re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
const matches = this._value.match(re);
if (!matches) return 0;
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
this._pushHistory(); this._afterProgrammaticEdit(oldV);
return matches.length;
}
@@ -895,9 +924,16 @@ export class MarkdownEditor {
copyAsHTML(): this {
const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) {
// ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
if (typeof ClipboardItem !== 'undefined') {
try {
const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => {});
return this;
} catch (_) { /* fallthrough 降级 */ }
}
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'html' })).catch(() => {});
}
return this;
}
@@ -907,7 +943,7 @@ export class MarkdownEditor {
const inserted = this._limitLength(ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start));
ta.value = inserted; ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return this;
const oldV = this._value; this._value = ta.value; this._pushHistory(); this._afterProgrammaticEdit(oldV); return this;
}
wrap(before: string, after: string): this { this._wrapSelection(before, after || before); return this; }
focus(): this { if (this.textarea) this.textarea.focus(); return this; }
@@ -1000,6 +1036,8 @@ export class MarkdownEditor {
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
if (this._floatMirror && this._floatMirror.parentNode) this._floatMirror.parentNode.removeChild(this._floatMirror);
this._floatMirror = null;
if (this.el) this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { try { p.destroy(this); } catch (_) {} } }); this._plugins = [];
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
+131 -45
View File
@@ -1,23 +1,31 @@
/**
* MetonaEditor Floating Toolbar selection format 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';
/** 浮动工具栏与选区锚点之间的间距(px) */
const ANCHOR_GAP = 6;
export function initFloatingToolbar(this: MarkdownEditor): void {
if (typeof document === 'undefined') return;
// Inject floating toolbar CSS once globally
if (!document.getElementById('me-float-style')) {
const style = document.createElement('style');
style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`;
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;visibility:hidden;transform:translateY(4px);transition:opacity .15s,transform .15s,visibility .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}.me-float-mirror{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;z-index:-1;border:0;margin:0}`;
document.head.appendChild(style);
}
if (!this._floatingEnabled) return;
const ta = this.textarea;
const show = () => {
@@ -27,60 +35,130 @@ export function initFloatingToolbar(this: MarkdownEditor): void {
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
const anchor = this._measureSelectionAnchor();
if (!anchor) { this._hideFloatingToolbar(); return; }
// 选区锚点行已滚出 textarea 可视区时隐藏(跟随滚动的重定位会再次显示)
if (anchor.y < -parseFloat(getComputedStyle(ta).lineHeight || '22') || anchor.y > ta.clientHeight) {
this._hideFloatingToolbar();
return;
}
const bar = this._floatingToolbar!;
// 实测尺寸定位(替代旧版 -80 / 36 / 200 等魔法数字)
const barRect = bar.getBoundingClientRect();
const barW = barRect.width || 170;
const barH = barRect.height || 38;
const taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
const fontSize = parseFloat(taStyle.fontSize) || 13.5;
const paneW = this.editorPane.getBoundingClientRect().width;
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1;
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1));
let top = anchor.y - barH - ANCHOR_GAP;
if (top < ANCHOR_GAP) top = anchor.y + lineHeight + ANCHOR_GAP;
let left = anchor.x - barW / 2;
left = Math.max(4, Math.min(left, paneW - barW - 4));
if (left < 4) left = 4; // 极窄面板兜底
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop;
const lineLocalTop = lineViewportTop - paneRect.top;
const toolbarHeight = 36;
const gap = 4;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap) top = lineLocalTop + lineHeight + gap;
// 逐字符估算列偏移:CJK 全角字符宽约 1em,其余按 0.6em,
// 纯 ASCII 选区回退到原 0.6em 估算,中文选区不再整体偏左。
const CJK_RE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/;
const lineText = lines[lines.length - 1] || '';
const measureX = (col: number): number => {
let w = 0;
const n = Math.max(0, Math.min(col, lineText.length));
for (let i = 0; i < n; i++) w += CJK_RE.test(lineText[i]) ? fontSize : fontSize * 0.6;
return w;
};
const colX = taRect.left + paddingLeft + measureX(midCol) - ta.scrollLeft;
let left = colX - paneRect.left - 80;
left = Math.max(4, Math.min(left, paneRect.width - 200));
this._floatingToolbar!.style.top = top + 'px';
this._floatingToolbar!.style.left = left + 'px';
this._floatingToolbar!.classList.add('me-visible');
bar.style.top = top + 'px';
bar.style.left = left + 'px';
bar.classList.add('me-visible');
};
const hide = () => { this._hideFloatingToolbar(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0));
ta.addEventListener('keyup', () => {
// 滚动跟随:rAF 节流重定位(保持纯事件驱动,不做轮询)
let rafPending = false;
const reposition = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => { rafPending = false; show(); });
};
const onMouseUp = () => setTimeout(show, 0);
const onKeyup = () => {
// selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0);
else setTimeout(hide, 0);
});
ta.addEventListener('blur', () => setTimeout(hide, 300));
ta.addEventListener('click', () => {
};
const onBlur = () => setTimeout(() => {
// 焦点快速抖动场景(如自动化 fill 后还原焦点再 focus 回来、
// 宿主脚本焦点切换):延迟到期时焦点已回到 textarea 则不隐藏
if (document.activeElement === ta) return;
hide();
}, 300);
const onClick = () => {
if (ta.selectionStart === ta.selectionEnd) hide();
};
const onScroll = reposition;
// 布局变化后旧坐标失效,直接隐藏(下次选区交互重新显示)
const onResize = () => hide();
ta.addEventListener('mouseup', onMouseUp);
ta.addEventListener('keyup', onKeyup);
ta.addEventListener('blur', onBlur);
ta.addEventListener('click', onClick);
ta.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);
// 事件绑定与启用状态解耦:floatingToolbar:false 构造的实例,
// toggleFloatingToolbar() 打开后仍可正常显示(修复“死开关”)。
this._cleanups.push(() => {
ta.removeEventListener('mouseup', onMouseUp);
ta.removeEventListener('keyup', onKeyup);
ta.removeEventListener('blur', onBlur);
ta.removeEventListener('click', onClick);
ta.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
});
}
/**
* editorPane
* =
*/
export function measureSelectionAnchor(this: MarkdownEditor): { x: number; y: number } | null {
const ta = this.textarea;
if (!ta || !this.editorPane) return null;
const start = ta.selectionStart; const end = ta.selectionEnd;
if (start === end) return null;
const anchor = Math.floor((start + end) / 2);
// 懒创建镜像层并复制 textarea 排版样式(font/行高/padding/换行/tab
let mirror = this._floatMirror;
if (!mirror || !mirror.parentNode) {
mirror = document.createElement('div');
mirror.className = 'me-float-mirror';
this.editorPane.appendChild(mirror);
this._floatMirror = mirror;
}
const cs = getComputedStyle(ta);
const props: Array<[string, string]> = [
['fontFamily', cs.fontFamily], ['fontSize', cs.fontSize], ['fontWeight', cs.fontWeight],
['fontStyle', cs.fontStyle], ['letterSpacing', cs.letterSpacing], ['lineHeight', cs.lineHeight],
['paddingTop', cs.paddingTop], ['paddingRight', cs.paddingRight],
['paddingBottom', cs.paddingBottom], ['paddingLeft', cs.paddingLeft],
['whiteSpace', cs.whiteSpace], ['overflowWrap', cs.overflowWrap],
['wordBreak', cs.wordBreak], ['tabSize', cs.tabSize], ['boxSizing', 'border-box'],
];
props.forEach(([k, v]) => { (mirror.style as any)[k] = v; });
// 宽度对齐 textarea 文本排版区(clientWidth 含 padding 不含滚动条)
mirror.style.width = ta.clientWidth + 'px';
// 仅需锚点前缀文本:其后内容不影响锚点位置(折行由前缀决定)
mirror.textContent = '';
mirror.appendChild(document.createTextNode(ta.value.slice(0, anchor)));
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
// marker 坐标相对 mirrorposition:absolute 的 offsetParentborder 0),
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
const x = marker.offsetLeft - ta.scrollLeft;
const y = marker.offsetTop - ta.scrollTop;
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
}
export function toggleFloatingToolbar(this: MarkdownEditor): MarkdownEditor {
this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) {
@@ -97,14 +175,21 @@ export function isFloatingToolbar(this: MarkdownEditor): boolean { return this._
export function buildFloatingToolbar(this: MarkdownEditor): void {
const bar = document.createElement('div');
bar.className = 'me-float-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', this.t('formatToolbar') || '格式化选区');
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => {
const btn = this._createBtn(action);
// mousedown 阻止夺焦(textarea 选区与焦点保持);命令执行统一走 click,
// 键盘 Enter/Space 触发的 click 同样生效(可访问性)
btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.stopPropagation();
});
btn.addEventListener('click', () => {
this.exec(action);
// Keep selection after exec
setTimeout(() => this.textarea.focus(), 0);
this._hideFloatingToolbar();
// Keep selection after exec(延迟回调时实例可能已销毁)
setTimeout(() => { if (!this._destroyed && this.textarea) this.textarea.focus(); }, 0);
});
bar.appendChild(btn);
});
@@ -126,4 +211,5 @@ export const installFloatingToolbar = (proto: any): void => {
proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar;
proto._measureSelectionAnchor = measureSelectionAnchor;
};
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor
* @version 0.4.2
* @version 0.4.3
*/
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 type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants';
const VERSION = '0.4.2';
const VERSION = '0.4.3';
const globalPlugins: any[] = [];
+6
View File
@@ -31,6 +31,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB',
formatToolbar: '格式化选区', pasteBlocked: '无法直接粘贴,请使用 Ctrl+V',
},
'en-US': {
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
@@ -58,6 +59,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: 'Outline',
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)',
formatToolbar: 'Format selection', pasteBlocked: 'Cannot paste directly, use Ctrl+V',
},
ja: {
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
@@ -85,6 +87,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
},
ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -112,6 +115,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
formatToolbar: '선택 영역 서식', pasteBlocked: '직접 붙여넣을 수 없습니다. Ctrl+V를 사용하세요',
},
fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -139,6 +143,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: 'Plan',
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)',
formatToolbar: 'Formater la sélection', pasteBlocked: 'Impossible de coller directement, utilisez Ctrl+V',
},
de: {
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
@@ -166,5 +171,6 @@ export const LOCALES: Record<string, Record<string, string>> = {
outline: 'Gliederung',
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)',
formatToolbar: 'Auswahl formatieren', pasteBlocked: 'Direktes Einfügen nicht möglich, verwenden Sie Strg+V',
},
};
+14 -1
View File
@@ -24,6 +24,8 @@ export interface EditorLike {
_pushHistory?: () => void;
_render?: () => void;
_updateWordCount?: () => void;
/** 程序化编辑统一刷新管线(渲染+行号+字数+大纲+事件+钩子) */
_afterProgrammaticEdit?: (oldValue: string) => void;
insert?: (text: string, opts?: { replace?: boolean }) => unknown;
setValue?: (value: string, opts?: { silent?: boolean }) => unknown;
getValue?: () => string;
@@ -420,10 +422,16 @@ const searchReplacePlugin: Plugin = {
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
ta.setSelectionRange(s, s + r.length);
}
editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory();
const oldV = editor._value || '';
editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
} else {
if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render();
if (typeof editor._updateWordCount === 'function') editor._updateWordCount();
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
}
findNext();
};
const replaceAll = () => {
@@ -431,12 +439,17 @@ const searchReplacePlugin: Plugin = {
const ta = editor.textarea;
const re = buildPattern(q);
if (!re) return;
const oldV = editor._value || '';
ta.value = ta.value.replace(re, () => r);
ta.setSelectionRange(0,0); editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
} else {
if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render();
if (typeof editor._updateWordCount === 'function') editor._updateWordCount();
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
}
findAll();
};
fi.addEventListener('input', () => { lastIdxs = findAll(); });
+304
View File
@@ -3329,3 +3329,307 @@ describe('MarkdownEditor - v0.4.2 浮动工具栏中文选区', () => {
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();
});
});
+1 -1
View File
@@ -29,7 +29,7 @@ describe('index.ts - 全局 API', () => {
test('VERSION 是字符串', () => {
expect(typeof VERSION).toBe('string');
expect(VERSION).toBe('0.4.2');
expect(VERSION).toBe('0.4.3');
});
test('api.default 是 api 本身', () => {