Compare commits
4
Commits
5966ac7500
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f234676c | ||
|
|
d6a618af5d | ||
|
|
ac6638247f | ||
|
|
5f4915222e |
@@ -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
|
||||||
|
|||||||
@@ -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 / afterDestroy;5 个零测试配置项:placeholder / height / spellcheck / tabSize(含 0 与 4)/ historyDebounce;修复 3 处恒真/弱断言:同步滚动(含新增反向联动验证)、渲染缓存 FIFO 真实淘汰、搜索 findNext/prev 选中内容),总数 841 → 871。
|
||||||
|
- jest 覆盖率阈值:全局 lines 85% / branches 70%,parser lines 95%(基线 94.95% / 75.72% / 98.93%)。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **CI lint 改为阻断**: 移除 verify job 中 lint 步骤的 `continue-on-error`,lint 失败不再静默放行。
|
||||||
|
- **build.sh 依赖安装**: `npm install` → `npm ci`,保证构建可重现。
|
||||||
|
- **rollup.config.js 死代码清理**: 移除从未被引用的 `terserPlugin` / `isProd` 变量。
|
||||||
|
- 文档全面同步:README / docs.html / index.html 修正 "CI 4 job"(实际 5 个)、`document.body()` 错误写法、`afterChange` / `fileSaved` 事件参数(以源码为准)、docs.html 补齐约 15 处缺失 API(`editor.t()`、`MeEditor.getStatus()` / `destroy()`、`configureToolbar` / `removeToolbarButton`、PluginManager & pluginUtils、`safeUrl` / `slugify` / `getSupportedLanguages`、`formatCurrency`、全局 beforeChange / afterChange 钩子、`data:image` 500KB 限制等);demo.html 私有 API hack 改用 `setOutline()` 公开 API。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.4.1] - 2026-08-11
|
## [0.4.1] - 2026-08-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
|
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
|
||||||
|
|
||||||
[](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
|
[](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](./tests)
|
[](./tests)
|
||||||
[](./tests)
|
[](./tests)
|
||||||
[](./tsconfig.json)
|
[](./tsconfig.json)
|
||||||
|
|
||||||
@@ -17,11 +17,11 @@
|
|||||||
- **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步
|
- **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步
|
||||||
- **主题系统** — light / dark / warm / auto,CSS 变量可定制,实例级隔离,主题继承,外部跟随
|
- **主题系统** — light / dark / warm / auto,CSS 变量可定制,实例级隔离,主题继承,外部跟随
|
||||||
- **国际化** — 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 + jsdom(841 用例) |
|
| 测试 | Jest 29 + jsdom(895 用例,覆盖率阈值门禁) |
|
||||||
| 浏览器冒烟 | Playwright + Chromium(22 项断言) |
|
| 浏览器冒烟 | Playwright + Chromium(28 项断言) |
|
||||||
| 类型生成 | rollup-plugin-dts |
|
| 类型生成 | rollup-plugin-dts |
|
||||||
| 零运行时依赖 | ✅ |
|
| 零运行时依赖 | ✅ |
|
||||||
|
|
||||||
|
|||||||
@@ -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 "❌ 依赖安装失败"
|
||||||
|
|||||||
Vendored
+377
-121
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
/** 实例级翻译:优先用 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 坐标相对 mirror(position:absolute 的 offsetParent,border 0),
|
||||||
|
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
|
||||||
|
const x = marker.offsetLeft - ta.scrollLeft;
|
||||||
|
const y = marker.offsetTop - ta.scrollTop;
|
||||||
|
const taRect = ta.getBoundingClientRect();
|
||||||
|
const paneRect = this.editorPane.getBoundingClientRect();
|
||||||
|
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
|
||||||
|
}
|
||||||
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)才能正常生效。
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+18
-1
@@ -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;
|
||||||
|
|||||||
Vendored
+377
-121
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
/** 实例级翻译:优先用 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 坐标相对 mirror(position:absolute 的 offsetParent,border 0),
|
||||||
|
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
|
||||||
|
const x = marker.offsetLeft - ta.scrollLeft;
|
||||||
|
const y = marker.offsetTop - ta.scrollTop;
|
||||||
|
const taRect = ta.getBoundingClientRect();
|
||||||
|
const paneRect = this.editorPane.getBoundingClientRect();
|
||||||
|
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
|
||||||
|
}
|
||||||
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)才能正常生效。
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+377
-121
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
/** 实例级翻译:优先用 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 坐标相对 mirror(position:absolute 的 offsetParent,border 0),
|
||||||
|
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
|
||||||
|
const x = marker.offsetLeft - ta.scrollLeft;
|
||||||
|
const y = marker.offsetTop - ta.scrollTop;
|
||||||
|
const taRect = ta.getBoundingClientRect();
|
||||||
|
const paneRect = this.editorPane.getBoundingClientRect();
|
||||||
|
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
|
||||||
|
}
|
||||||
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)才能正常生效。
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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><dl><dt><dd></td></tr>
|
<tr><td>定义列表</td><td><code>Term\n: def</code></td><td><dl><dt><dd></td></tr>
|
||||||
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+)</td></tr>
|
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+)</td></tr>
|
||||||
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
|
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
|
||||||
|
<tr><td>HTML 注释</td><td><code><!-- note --></code></td><td>透传</td></tr>
|
||||||
|
<tr><td>实体引用保护</td><td><code>&amp;</code> <code>&#169;</code></td><td>不被二次转义</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<h4>XSS 防护</h4>
|
<h4>XSS 防护</h4>
|
||||||
<p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,属性级注入防护(<code>href</code> / <code>src</code> / <code>alt</code> / <code>title</code> / 脚注 id 引号转义),白名单裸标签 <code><u></code> 透传,提供 <code>sanitize</code> 钩子。</p>
|
<p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,属性级注入防护(<code>href</code> / <code>src</code> / <code>alt</code> / <code>title</code> / 脚注 id 引号转义),白名单裸标签 <code><u></code> 透传,<code>data:image</code> 限制最大 500KB,提供 <code>sanitize</code> 钩子(可接 DOMPurify 等),<code>highlight</code> 钩子异常自动回退纯文本。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="parser-api"><h2>解析器 API</h2>
|
<section id="parser-api"><h2>解析器 API</h2>
|
||||||
@@ -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 & 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
@@ -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
@@ -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 = ``;
|
const insert = ``;
|
||||||
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
@@ -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
@@ -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
@@ -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 得到像素级精确坐标,
|
||||||
|
* 彻底替代旧版按字符宽度估算的方案(CJK、软换行、tab 宽度、字体
|
||||||
|
* 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 坐标相对 mirror(position:absolute 的 offsetParent,border 0),
|
||||||
|
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
|
||||||
|
const x = marker.offsetLeft - ta.scrollLeft;
|
||||||
|
const y = marker.offsetTop - ta.scrollTop;
|
||||||
|
const taRect = ta.getBoundingClientRect();
|
||||||
|
const paneRect = this.editorPane.getBoundingClientRect();
|
||||||
|
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
|
||||||
|
}
|
||||||
|
|
||||||
export function toggleFloatingToolbar(this: MarkdownEditor): MarkdownEditor {
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
/** 实例级翻译:优先用 editor.t(实例 locale),否则退回全局 t。
|
||||||
|
* 插件面板文案跟随实例语言而非全局语言。 */
|
||||||
|
const instanceT = (editor: EditorLike): ((key: string, params?: Record<string, any>) => string) =>
|
||||||
|
(key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
|
||||||
|
|
||||||
const autoSavePlugin: Plugin = {
|
const autoSavePlugin: Plugin = {
|
||||||
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
|
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
|
||||||
install(editor, options?: Record<string, any>) {
|
install(editor, options?: Record<string, any>) {
|
||||||
@@ -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
@@ -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 恒为 0,mock 出可滚动尺寸以驱动比例联动
|
||||||
|
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
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 面板文案跟随实例 locale(en-US)', () => {
|
||||||
|
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
|
||||||
|
ed.use('searchReplace');
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
|
||||||
|
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
|
||||||
|
expect(input.placeholder).toBe('Find');
|
||||||
|
const replaceBtn = ed.el.querySelector('.me-search-replace-all');
|
||||||
|
expect(replaceBtn.textContent).toBe('Replace All');
|
||||||
|
ed.el.querySelector('.me-search-close').click();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchReplace 面板文案跟随实例 locale(zh-CN)', () => {
|
||||||
|
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'zh-CN' });
|
||||||
|
ed.use('searchReplace');
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
|
||||||
|
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
|
||||||
|
expect(input.placeholder).toBe('查找内容');
|
||||||
|
ed.el.querySelector('.me-search-close').click();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shortcutHelp 面板文案跟随实例 locale(en-US)', () => {
|
||||||
|
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
|
||||||
|
ed.use('shortcutHelp');
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
|
||||||
|
const overlay = document.querySelector('.me-shortcut-overlay') as HTMLElement;
|
||||||
|
expect(overlay).not.toBeNull();
|
||||||
|
expect(overlay.querySelector('h3')!.textContent).toContain('Shortcuts');
|
||||||
|
expect(overlay.textContent).toContain('Bold');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unuse shortcutHelp 移除已打开的面板(不残留 DOM)', () => {
|
||||||
|
const ed = new MarkdownEditor(container, { value: 'foo' });
|
||||||
|
ed.use('shortcutHelp');
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
|
||||||
|
expect(document.querySelector('.me-shortcut-overlay')).not.toBeNull();
|
||||||
|
ed.unuse('shortcutHelp');
|
||||||
|
expect(document.querySelector('.me-shortcut-overlay')).toBeNull();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user