diff --git a/CHANGELOG.md b/CHANGELOG.md index 490cbaa..a7fa0cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`,`` 可注入;现经 `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 diff --git a/README.md b/README.md index a8da152..437fe64 100644 --- a/README.md +++ b/README.md @@ -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 / auto,CSS 变量可定制,实例级隔离,主题继承,外部跟随 - **国际化** — 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 + jsdom(871 用例,覆盖率阈值门禁) | -| 浏览器冒烟 | Playwright + Chromium(22 项断言) | +| 测试 | Jest 29 + jsdom(895 用例,覆盖率阈值门禁) | +| 浏览器冒烟 | Playwright + Chromium(28 项断言) | | 类型生成 | rollup-plugin-dts | | 零运行时依赖 | ✅ | diff --git a/e2e/smoke.cjs b/e2e/smoke.cjs index 1d565b4..78c5e79 100644 --- a/e2e/smoke.cjs +++ b/e2e/smoke.cjs @@ -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(); diff --git a/package.json b/package.json index 5109e98..8f9161a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/site/demo.html b/site/demo.html index aa25dca..3b159df 100644 --- a/site/demo.html +++ b/site/demo.html @@ -4,7 +4,7 @@ -MetonaEditor v0.4.2 — 全功能演示 +MetonaEditor v0.4.3 — 全功能演示