Compare commits

...
6 Commits
Author SHA1 Message Date
thzxx 64aedb9546 fix(core): maxLength 默认值不再赋值 -1,修复浏览器 IndexSizeError
CI / test-parser (push) Successful in 9m26s
CI / test-core (push) Successful in 9m33s
CI / test-rest (push) Successful in 9m29s
CI / verify (18.x) (push) Successful in 9m50s
CI / verify (20.x) (push) Successful in 9m52s
CI / verify (24.x) (push) Successful in 9m45s
- textarea.maxLength 的 setter 不接受负值(-1 仅能通过不设置来
  保持),显式赋 -1 在真实浏览器抛出 IndexSizeError
- 改为仅当 maxLength > 0 时才设置属性
- 补防回归测试(mock setter 抛错 + 环境无关断言;
  jsdom 默认 maxLength 为 0,浏览器为 -1)
2026-08-09 10:22:22 +08:00
thzxx d22380ac7a docs: 全面更新 README 与站点到 v0.2.5 最终状态
README:
- badge 测试数 768→780,目录补语法高亮章节
- 特性修正:解析器 99% 行覆盖、六语言、16 个高亮语言标识
- 事件表补 selectionChange / cursorMove
- 插件表补 exportPDF / searchReplace 大小写全字 / imagePaste maxSizeKB
- XSS 章节补属性级注入防护说明
- 构建章节补 npm run bench
- 源码结构补 5 个新模块(commands/floating-toolbar/context-menu/outline/highlight)

site:
- index.html: 测试数/gzip 体积更正(42KB)、新增语法高亮特性卡、
  光标选区 API 速览卡、六语言文案
- demo.html: 启用内置高亮(highlight: MeEditor.highlight)、
  新增 python/bash 代码块演示、v0.2.5 真实新特性列表、17 源文件/780 测试
- docs.html: 高亮章节补 16 标识与 CSS 定制示例、exportPDF/maxSizeKB/
  大小写全字说明、XSS 属性注入、静态 API 补 highlight 导入

src:
- styles.ts: 新增 me-hl-* 高亮配色(深浅色自适应)
- 修复复查引入的 TS 错误(普通函数 this 返回类型非法)
2026-08-09 09:33:48 +08:00
thzxx e5f99aee83 fix: 复查修复 5 处问题 — URL 双重转义/zen 泄漏/高亮环境不一致等
- parser: href/src/alt 改最小属性转义(escapeAttr 会把已实体化的 &
  再转义成 &,导致链接 URL 双重转义错误);新增 URL/alt 防回归测试
- core: _pushHistory 增加 maxLength 兜底截断(exec/Tab/Smart Enter/括号
  自动闭合等程序化路径此前绕过 maxlength 限制)
- core: destroy 时清理 Zen 模式的 document mousemove 监听(此前泄漏)
- highlight: 自写环境无关转义(仅 & < >),修复 Node 环境下字符串高亮
  失效、浏览器与 SSR 输出不一致的问题
- floating-toolbar/context-menu: toggleFloatingToolbar/registerContextMenu
  恢复链式 return this(拆分时丢失)
- plugins: 补齐 3 个硬编码文案的 i18n(regex/shortcuts/imageTooLarge),
  6 种语言同步
- 测试 768 → 780
2026-08-09 09:26:20 +08:00
thzxx 5919dd9f66 feat: v0.2.5 — 内置语法高亮 + exportPDF + benchmark + CI 并行
- 新增 src/highlight.ts 零依赖轻量高亮器:13 种语言,先转义后
  着色保证安全,输出 me-hl-* 类,MeEditor.highlight 即插即用
- exportTool 新增 editor.exportPDF()(隐藏 iframe + window.print),
  exportHTML/exportPDF 共用 HTML 构建逻辑
- imagePaste 支持 maxSizeKB 限制,超限 toast 警告
- npm run bench 性能基准:parseMarkdown 1MB ≈ 51ms
- CI 测试拆分为 parser/core/rest 三个并行 job + 跨版本 verify job
- docs.html/README 补齐新 API、事件、高亮文档;取消跟踪 dist 产物;
  版本 0.2.4 → 0.2.5,测试 725 → 768
2026-08-09 08:59:55 +08:00
thzxx fae31bba60 refactor(core): 拆分 4 模块 + 插件状态 Symbol 化 + eslint type-aware
- core.ts 从 1162 行降至 865 行:命令/浮动工具栏/右键菜单/大纲
  拆至 commands.ts / floating-toolbar.ts / context-menu.ts / outline.ts
  (原型安装,API 与测试完全兼容)
- 6 个预设插件状态改用 Symbol 键存储,定义 EditorLike 契约接口,
  消灭 (editor as any).__xxx 魔法属性
- 实现 maxLength(textarea maxlength + 程序化入口截断)与
  zenMode 初始状态(此前为 dead config)
- 搜索面板补齐 matchCase/wholeWord(翻译键已有但未实现,
  中文全字边界感知)
- 分隔条 localStorage key 加入实例 id 隔离
- eslint 启用 type-aware 规则(consistent-type-imports /
  no-unnecessary-type-assertion),0 errors
2026-08-09 08:59:37 +08:00
thzxx ddc650feeb fix(security): 属性级转义防注入 + 发布产物扩展名修复
- 链接/图片 href/src/alt/title 与代码块 language 属性改用属性级转义
  (浏览器环境 escapeHTML 不转义双引号,存在属性注入面)
- 修复 title 中反斜杠转义还原,引用链接 title 输出与行内一致
- 产物改用 .mjs/.cjs 扩展名(type:module 下 .js 被 Node 按 ESM 解析,
  require() 拿不到导出);exports 指向 dist 而非 src TS 源码
- 新增 .gitattributes 强制 LF,避免 Windows CRLF 污染 diff
- 配套测试:title/URL/alt/language 注入防护
2026-08-09 08:59:19 +08:00
42 changed files with 2004 additions and 14818 deletions
+4 -1
View File
@@ -9,7 +9,8 @@
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"parserOptions": { "parserOptions": {
"ecmaVersion": 2020, "ecmaVersion": 2020,
"sourceType": "module" "sourceType": "module",
"project": "./tsconfig.json"
}, },
"plugins": ["@typescript-eslint"], "plugins": ["@typescript-eslint"],
"extends": [ "extends": [
@@ -19,6 +20,8 @@
"no-console": "warn", "no-console": "warn",
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/consistent-type-imports": ["warn", { "prefer": "type-imports", "fixStyle": "inline-type-imports" }],
"@typescript-eslint/no-unnecessary-type-assertion": "warn",
"no-undef": "off", "no-undef": "off",
"no-empty": "off", "no-empty": "off",
"no-useless-escape": "off", "no-useless-escape": "off",
+7
View File
@@ -0,0 +1,7 @@
* text=auto eol=lf
*.sh text eol=lf
*.png binary
*.jpg binary
*.gif binary
*.ico binary
+54 -15
View File
@@ -7,37 +7,76 @@ on:
branches: [master] branches: [master]
jobs: jobs:
test: test-parser:
runs-on: debian-latest runs-on: debian-latest
timeout-minutes: 15 timeout-minutes: 15
strategy:
matrix:
node-version: [18.x, 20.x, 22.x, 24.x]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run parser tests
run: npx jest tests/parser.test.ts tests/highlight.test.ts --forceExit --maxWorkers=1 --testTimeout=15000
env:
NODE_OPTIONS: --max-old-space-size=4096
test-core:
runs-on: debian-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run core tests
run: npx jest tests/core.test.ts tests/plugins.test.ts tests/index.test.ts --forceExit --maxWorkers=1 --testTimeout=15000
env:
NODE_OPTIONS: --max-old-space-size=4096
test-rest:
runs-on: debian-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run remaining tests
run: npx jest tests/themes.test.ts tests/i18n.test.ts tests/utils.test.ts tests/animations.test.ts tests/styles.test.ts --forceExit --maxWorkers=1 --testTimeout=15000
env:
NODE_OPTIONS: --max-old-space-size=4096
verify:
runs-on: debian-latest
timeout-minutes: 15
strategy:
matrix:
node-version: [18.x, 20.x, 24.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Type check - name: Type check
run: npm run typecheck run: npm run typecheck
- name: Lint - name: Lint
run: npm run lint run: npm run lint
continue-on-error: true continue-on-error: true
- name: Run tests
run: npx jest --forceExit --maxWorkers=1 --testTimeout=15000
env:
NODE_OPTIONS: --max-old-space-size=4096
- name: Build - name: Build
run: npm run build run: npm run build
+1
View File
@@ -2,6 +2,7 @@ node_modules/
dist/ dist/
coverage/ coverage/
.DS_Store .DS_Store
.zcode/
# 本地 npm 发布凭据(含 _auth,勿提交) # 本地 npm 发布凭据(含 _auth,勿提交)
.npmrc .npmrc
+26
View File
@@ -2,6 +2,32 @@
All notable changes to MetonaEditor will be documented in this file. All notable changes to MetonaEditor will be documented in this file.
## [0.2.5] - 2026-08-09
### Fixed
- **npm 发布配置**: `module` / `exports.import` 不再指向 TS 源码,产物改用标准 `.mjs` / `.cjs` 扩展名(此前 `"type": "module"` 导致 Node 将 `.js` 产物按 ESM 解析,`require()` 拿不到任何导出)。
- **XSS 属性注入**: 链接/图片的 `href`/`src`/`alt`/`title` 及代码块 `language-*` 类与标题属性现经属性级转义(`escapeHTML` 在浏览器环境不转义双引号,此前的属性拼接存在注入面)。
- **本地存储串扰**: 分隔条比例键改为 `metona-editor-divider-${id}`,多实例互不覆盖。
### Added
- **maxLength 生效**: 之前仅有类型与默认值,现绑定 textarea `maxlength` 并对 `setValue` / `insert` / `replaceAll` / `replaceAllRegex` 截断。
- **zenMode 初始状态**: 配置 `zenMode: true` 启动即进入专注模式(此前仅 `toggleZen()` 可进入)。
- **搜索面板大小写 / 全字匹配**: `matchCase` / `wholeWord` 翻译键此前存在但未实现,现补齐 UI 开关与匹配逻辑(普通与正则模式均支持,中文全字边界感知)。
- **内置轻量语法高亮**: 新模块 `src/highlight.ts`,零依赖 tokenizer13 种语言,`MeEditor.highlight` 即插即用,输出 `me-hl-*` 类。
- **exportPDF**: exportTool 新增 `editor.exportPDF()`(隐藏 iframe + window.print)。
- **imagePaste 尺寸限制**: `editor.use('imagePaste', { maxSizeKB: 500 })`,超限 toast 警告。
- **性能基准**: `npm run bench`parseMarkdown 1MB ≈ 51mshighlight 200 函数 ≈ 1.2ms)。
### Changed
- **core.ts 拆分**: 命令、浮动工具栏、右键菜单、大纲 4 组方法拆至独立模块(`commands.ts` / `floating-toolbar.ts` / `context-menu.ts` / `outline.ts`),core 从 1162 行降至 865 行,API 完全兼容。
- **插件状态类型收紧**: 6 个预设插件的实例状态改用 Symbol 键存储(`EditorLike` 接口 + `pluginState` 助手),消灭 `(editor as any).__xxx` 魔法属性。
- **eslint type-aware**: 启用 `parserOptions.project``consistent-type-imports` / `no-unnecessary-type-assertion` 规则,0 errors。
- **CI 并行化**: 测试拆分为 parser / core / rest 三个并行 job,另设跨 Node 18/20/24 的 verify jobtypecheck + lint + build)。
- **docs.html / README 补齐**: 光标选区 API、浮动工具栏、replaceAll、新事件、内置高亮文档。
- 版本同步 0.2.4 → 0.2.5,测试 725 → 768。
---
## [0.2.4] - 2026-07-25 ## [0.2.4] - 2026-07-25
### Changed ### Changed
+2 -2
View File
@@ -104,8 +104,8 @@ npm run build
# Output in dist/ # Output in dist/
# ├── metona-editor.js UMD # ├── metona-editor.js UMD
# ├── metona-editor.min.js UMD minified # ├── metona-editor.min.js UMD minified
# ├── metona-editor.esm.js ES Module # ├── metona-editor.mjs ES Module
# ├── metona-editor.cjs.js CommonJS # ├── metona-editor.cjs CommonJS
# └── metona-editor.d.ts TypeScript declarations # └── metona-editor.d.ts TypeScript declarations
``` ```
+92 -30
View File
@@ -2,9 +2,9 @@
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库 > TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
[![version](https://img.shields.io/badge/version-0.2.4-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor) [![version](https://img.shields.io/badge/version-0.2.5-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
[![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-725%20passed-brightgreen)](./tests) [![tests](https://img.shields.io/badge/tests-780%20passed-brightgreen)](./tests)
[![coverage](https://img.shields.io/badge/coverage-parser%2099%25-brightgreen)](./tests) [![coverage](https://img.shields.io/badge/coverage-parser%2099%25-brightgreen)](./tests)
[![types](https://img.shields.io/badge/types-TypeScript%20strict-blue)](./tsconfig.json) [![types](https://img.shields.io/badge/types-TypeScript%20strict-blue)](./tsconfig.json)
@@ -12,13 +12,14 @@
- **TypeScript 源码** — 全模块 TypeScript 严格模式,完整类型导出,IDE 智能提示 - **TypeScript 源码** — 全模块 TypeScript 严格模式,完整类型导出,IDE 智能提示
- **零运行时依赖** — 打包后单文件,无任何第三方库,UMD / ESM / CJS 三种格式 - **零运行时依赖** — 打包后单文件,无任何第三方库,UMD / ESM / CJS 三种格式
- **自研解析器** — CommonMark + GFM 扩展,96% 语句覆盖率,parseTokens / renderTokens 分离 APIregisterBlockHandler 自定义块语法 - **自研解析器** — CommonMark + GFM 扩展,99% 覆盖率,parseTokens / renderTokens 分离 APIregisterBlockHandler 自定义块语法
- **插件系统 v2** — 拓扑排序依赖管理,6 个预设插件开箱即用,安装 / 卸载生命周期 - **插件系统 v2** — 拓扑排序依赖管理,6 个预设插件开箱即用,安装 / 卸载生命周期
- **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步 - **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步
- **主题系统** — light / dark / warm / autoCSS 变量可定制,实例级隔离,主题继承,外部跟随 - **主题系统** — light / dark / warm / autoCSS 变量可定制,实例级隔离,主题继承,外部跟随
- **国际化** — zh-CN / en-US / ja / ko 完整翻译,实例级语言隔离,远程加载翻译包 - **国际化** — zh-CN / en-US / ja / ko / fr / de 六种语言完整翻译,实例级语言隔离,远程加载翻译包
- **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式、右键上下文菜单 - **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式、右键上下文菜单
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),sanitize 钩子 - **内置语法高亮** — 零依赖轻量高亮器,js/ts/python/bash/css/html 等 13 种语言,`MeEditor.highlight` 即插即用
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),属性级注入防护,sanitize 钩子
- **桌面端优先** — 纯电脑端设计,无移动端冗余代码 - **桌面端优先** — 纯电脑端设计,无移动端冗余代码
- **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性 - **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性
- **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等) - **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等)
@@ -37,6 +38,7 @@
- [事件系统](#事件系统) - [事件系统](#事件系统)
- [Markdown 解析器](#markdown-解析器) - [Markdown 解析器](#markdown-解析器)
- [插件系统](#插件系统) - [插件系统](#插件系统)
- [语法高亮](#语法高亮)
- [主题系统](#主题系统) - [主题系统](#主题系统)
- [国际化](#国际化) - [国际化](#国际化)
- [构建与测试](#构建与测试) - [构建与测试](#构建与测试)
@@ -75,10 +77,10 @@ const editor = MeEditor.create('#editor', { mode: 'split' });
```html ```html
<!-- jsDelivr CDN (推荐) --> <!-- jsDelivr CDN (推荐) -->
<script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.2.4/dist/metona-editor.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.2.5/dist/metona-editor.min.js"></script>
<!-- unpkg CDN --> <!-- unpkg CDN -->
<script src="https://unpkg.com/@metona-team/metona-editor@0.2.4/dist/metona-editor.min.js"></script> <script src="https://unpkg.com/@metona-team/metona-editor@0.2.5/dist/metona-editor.min.js"></script>
<!-- Gitea 源 --> <!-- Gitea 源 -->
<script src="https://git.metona.cn/MetonaTeam/MetonaEditor/raw/branch/master/dist/metona-editor.js"></script> <script src="https://git.metona.cn/MetonaTeam/MetonaEditor/raw/branch/master/dist/metona-editor.js"></script>
@@ -95,8 +97,8 @@ const editor = MeEditor.create('#editor', { mode: 'split' });
# 下载产物文件到项目中 # 下载产物文件到项目中
# dist/metona-editor.js → UMD (浏览器) # dist/metona-editor.js → UMD (浏览器)
# dist/metona-editor.min.js → UMD 压缩 # dist/metona-editor.min.js → UMD 压缩
# dist/metona-editor.esm.js → ES Module # dist/metona-editor.mjs → ES Module
# dist/metona-editor.cjs.js → CommonJS # dist/metona-editor.cjs → CommonJS
``` ```
```html ```html
@@ -106,9 +108,9 @@ const editor = MeEditor.create('#editor', { mode: 'split' });
```javascript ```javascript
// Node.js ESM // Node.js ESM
import MeEditor from './dist/metona-editor.esm.js'; import MeEditor from './dist/metona-editor.mjs';
// Node.js CJS // Node.js CJS
const MeEditor = require('./dist/metona-editor.cjs.js'); const MeEditor = require('./dist/metona-editor.cjs');
``` ```
--- ---
@@ -138,13 +140,14 @@ MeEditor.create(container, {
historyDebounce: 400, // 历史栈防抖(ms historyDebounce: 400, // 历史栈防抖(ms
syncScroll: true, // 分屏同步滚动 syncScroll: true, // 分屏同步滚动
autoBrackets: true, // 括号自动闭合 autoBrackets: true, // 括号自动闭合
zenMode: false, // Zen 专注模式 zenMode: false, // 启动即进入 Zen 专注模式
wordWrap: true, // 自动换行 wordWrap: true, // 自动换行
maxLength: 0, // 最大字符数(0=不限) maxLength: 0, // 最大字符数(0=不限)
floatingToolbar: true, // 选中文本浮动格式栏
// 主题与语言 // 主题与语言
theme: 'auto', // 'light' | 'dark' | 'warm' | 'auto' theme: 'auto', // 'light' | 'dark' | 'warm' | 'auto'
locale: 'zh-CN', // 'zh-CN' | 'en-US' locale: 'zh-CN', // 'zh-CN' | 'en-US' | 'ja' | 'ko' | 'fr' | 'de'
// 渲染钩子 // 渲染钩子
render: null, // (md: string, env: RenderEnv) => string render: null, // (md: string, env: RenderEnv) => string
@@ -217,6 +220,30 @@ editor.getHTML(): string
editor.refresh(): this // 强制重渲染(内容未变时) editor.refresh(): this // 强制重渲染(内容未变时)
editor.insert(text: string, opts?: { replace?: boolean }): this editor.insert(text: string, opts?: { replace?: boolean }): this
editor.wrap(before: string, after?: string): this editor.wrap(before: string, after?: string): this
editor.replaceAll(search: string, replace: string, caseSensitive?: boolean): number
editor.replaceAllRegex(pattern: RegExp, replace: string): number
editor.lineCount(): number
editor.getLine(line: number): string
editor.copyAsMarkdown(): this // 复制源码到剪贴板
editor.copyAsHTML(): this // 复制渲染 HTML 到剪贴板
```
### 光标与选区
```typescript
editor.getSelectedText(): string
editor.getCursorPosition(): { line: number; column: number }
editor.setCursorPosition(line: number, column?: number): this
editor.scrollToLine(line: number): this
editor.selectLine(line: number): this
editor.selectAll(): this
```
### 浮动工具栏
```typescript
editor.toggleFloatingToolbar(): this // 开关选中文本格式栏
editor.isFloatingToolbar(): boolean
``` ```
### 命令执行 ### 命令执行
@@ -375,6 +402,9 @@ MeEditor.destroy()
// 解析器独立使用 // 解析器独立使用
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlockHandler } from '@metona-team/metona-editor'; import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlockHandler } from '@metona-team/metona-editor';
// 内置语法高亮
import { highlight, registerLanguage, getSupportedLanguages } from '@metona-team/metona-editor';
``` ```
--- ---
@@ -405,6 +435,8 @@ import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlo
| `beforeChange` | 内容变更前 | `(oldValue, newValue, editor)` | | `beforeChange` | 内容变更前 | `(oldValue, newValue, editor)` |
| `afterChange` | 内容变更后 | `(newValue, editor)` | | `afterChange` | 内容变更后 | `(newValue, editor)` |
| `copy` | 复制到剪贴板 | `({ type })` | | `copy` | 复制到剪贴板 | `({ type })` |
| `selectionChange` | 选区变化 | `({ start, end, text })` |
| `cursorMove` | 光标移动 | `({ line, column })` |
### 全局钩子 ### 全局钩子
@@ -500,6 +532,7 @@ registerBlockHandler({
- 所有文本经 `escapeHTML` 转义 - 所有文本经 `escapeHTML` 转义
- 链接 URL 过滤 `javascript:` / `vbscript:` / `file:` / 非图片 `data:` - 链接 URL 过滤 `javascript:` / `vbscript:` / `file:` / 非图片 `data:`
- 属性级注入防护:`href` / `src` / `alt` / `title` / `language-*` 引号与换行均被转义
- `data:image` 限制最大 500KB - `data:image` 限制最大 500KB
- `sanitize` 钩子供外部净化(如 DOMPurify) - `sanitize` 钩子供外部净化(如 DOMPurify)
- `highlight` 钩子异常自动回退为纯文本 - `highlight` 钩子异常自动回退为纯文本
@@ -536,9 +569,9 @@ editor.unuse('myPlugin');
| 插件 | 说明 | 用法 | | 插件 | 说明 | 用法 |
|------|------|------| |------|------|------|
| `autoSave` | localStorage 自动保存草稿 | `editor.use('autoSave', { delay: 1000 })` | | `autoSave` | localStorage 自动保存草稿 | `editor.use('autoSave', { delay: 1000 })` |
| `exportTool` | 导出 .md / .html 文件 | `editor.use('exportTool'); editor.exportMarkdown()` | | `exportTool` | 导出 .md / .html 文件,支持 PDF(打印) | `editor.use('exportTool'); editor.exportMarkdown()` |
| `searchReplace` | Ctrl+F 查找 / Ctrl+H 替换 | `editor.use('searchReplace')` | | `searchReplace` | Ctrl+F 查找 / Ctrl+H 替换,支持正则、大小写、全字匹配 | `editor.use('searchReplace')` |
| `imagePaste` | 粘贴剪贴板图片转 base64 | `editor.use('imagePaste')` | | `imagePaste` | 粘贴剪贴板图片转 base64,可限尺寸 | `editor.use('imagePaste', { maxSizeKB: 500 })` |
| `shortcutHelp` | 按 ? 弹出快捷键面板 | `editor.use('shortcutHelp')` | | `shortcutHelp` | 按 ? 弹出快捷键面板 | `editor.use('shortcutHelp')` |
| `fileSystem` | File System Access API 读写磁盘 | `editor.use('fileSystem'); editor.openFile()` | | `fileSystem` | File System Access API 读写磁盘 | `editor.use('fileSystem'); editor.openFile()` |
@@ -559,6 +592,27 @@ pluginUtils.getPreset('autoSave') // 预设副本
--- ---
## 语法高亮
v0.2.5 起内置零依赖轻量高亮器,支持 js / ts / tsx / jsx / python / bash / css / html / json / yaml / markdown / java / go / rust(含 shell / md 别名共 16 个语言标识)。
```typescript
import MeEditor from '@metona-team/metona-editor';
// 作为 highlight 钩子(代码块自动高亮)
MeEditor.create('#editor', { highlight: MeEditor.highlight });
// 独立调用
const html = MeEditor.highlight(code, 'typescript');
// 注册自定义语言
MeEditor.registerLanguage('myLang', { keywords: ['kw'], builtins: [] });
```
高亮类名:`me-hl-keyword` / `me-hl-string` / `me-hl-comment` / `me-hl-number` / `me-hl-builtin` / `me-hl-function`,内置浅色 / 深色自适应配色,也可用 CSS 覆盖定制。
---
## 主题系统 ## 主题系统
### 内置主题 ### 内置主题
@@ -695,6 +749,9 @@ npm test
# 测试覆盖率 # 测试覆盖率
npm run test -- --coverage npm run test -- --coverage
# 性能基准(先构建再运行)
npm run build && npm run bench
# 代码格式化 # 代码格式化
npm run format npm run format
``` ```
@@ -705,8 +762,8 @@ npm run format
dist/ dist/
├── metona-editor.js UMD(浏览器直接引入) ├── metona-editor.js UMD(浏览器直接引入)
├── metona-editor.min.js UMD 压缩版(CDN ├── metona-editor.min.js UMD 压缩版(CDN
├── metona-editor.esm.js ES Module ├── metona-editor.mjs ES Module
├── metona-editor.cjs.js CommonJS ├── metona-editor.cjs CommonJS
└── metona-editor.d.ts TypeScript 类型声明 └── metona-editor.d.ts TypeScript 类型声明
``` ```
@@ -714,18 +771,23 @@ dist/
``` ```
src/ src/
├── index.ts 入口 / 全局API ├── index.ts 入口 / 全局API
├── core.ts MarkdownEditor 类 ├── core.ts MarkdownEditor 类(构造 / 事件 / 历史 / 模式)
├── parser.ts 自研 Markdown 解析器 ├── commands.ts 编辑命令实现(包裹 / 前缀 / 插入 / 表格格式化)
├── plugins.ts 插件系统 & 6 个预设 ├── floating-toolbar.ts 选中文本浮动格式栏
├── themes.ts 主题系统 ├── context-menu.ts 右键上下文菜单
├── i18n.ts 国际化 ├── outline.ts 大纲面板
├── styles.ts CSS-in-JS ├── parser.ts 自研 Markdown 解析器
├── constants.ts 常量 / 类型定义 ├── highlight.ts 内置轻量语法高亮器
├── utils.ts 工具函数 ├── plugins.ts 插件系统 & 6 个预设
├── animations.ts 动画元数据 ├── themes.ts 主题系统
├── icons.ts 工具栏 SVG 图标 ├── i18n.ts 国际化
── locales.ts 中英文翻译数据 ── styles.ts CSS-in-JS
├── constants.ts 常量 / 类型定义
├── utils.ts 工具函数
├── animations.ts 动画元数据
├── icons.ts 工具栏 SVG 图标
└── locales.ts 六语言翻译数据
``` ```
### 技术栈 ### 技术栈
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* MetonaEditor 性能基准 — 解析器吞吐测试
* 用法: npm run build && npm run bench
*/
const MeEditor = require('../dist/metona-editor.cjs');
const { parseMarkdown } = MeEditor;
const buildSample = (kb) => {
const chunk = [
'# 性能基准文档',
'',
'这是一个 **粗体** 与 *斜体* 的段落,包含 `inline code` 和 [链接](https://example.com)。',
'',
'- 列表项一',
'- 列表项二',
' - 嵌套项',
' - 嵌套项二',
'- 列表项三',
'',
'1. 有序一',
'2. 有序二',
'',
'> 引用内容',
'> 继续引用',
'',
'```js',
'function hello(name) {',
' const msg = `Hello, ${name}!`;',
' return msg;',
'}',
'```',
'',
'| 列一 | 列二 | 列三 |',
'| --- | :---: | ---: |',
'| A | B | C |',
'| D | E | F |',
'',
'---',
'',
'引用链接:[文档][ref]',
'',
'[ref]: https://example.com/docs "参考"',
'',
'脚注示例[^1]',
'',
'[^1]: 脚注内容',
'',
'## 二级标题',
'',
'$$E = mc^2$$',
'',
'H~2~O 与 x^2^,还有 :rocket: emoji。',
'',
].join('\n');
const repeat = Math.max(1, Math.ceil((kb * 1024) / chunk.length));
return chunk.repeat(repeat);
};
const median = (arr) => {
const s = [...arr].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
};
const benchmark = (label, fn, iterations = 5) => {
// warmup
fn();
const times = [];
for (let i = 0; i < iterations; i++) {
const t0 = process.hrtime.bigint();
fn();
const t1 = process.hrtime.bigint();
times.push(Number(t1 - t0) / 1e6);
}
const ms = median(times);
return { label, ms: ms.toFixed(1), opsPerSec: (1000 / ms).toFixed(1) };
};
console.log('MetonaEditor 性能基准');
console.log('=====================');
console.log(`版本: ${MeEditor.VERSION}`);
console.log('');
for (const size of [64, 256, 1024]) {
const sample = buildSample(size);
const r = benchmark(`parseMarkdown ${size}KB (${sample.length.toLocaleString()} chars)`, () => {
const html = parseMarkdown(sample);
if (!html) throw new Error('empty output');
});
console.log(` ${r.label}: ${r.ms} ms (${r.opsPerSec} ops/s)`);
}
console.log('');
// 语法高亮基准
try {
const code = Array.from({ length: 200 }, (_, i) =>
`function f${i}(a, b) { const s = "str${i}"; // comment\n return a + b + ${i}; }`,
).join('\n');
const r = benchmark('highlight 200 functions (js)', () => {
MeEditor.highlight(code, 'js');
});
console.log(` ${r.label}: ${r.ms} ms (${r.opsPerSec} ops/s)`);
} catch (_) {
console.log(' (highlight benchmark skipped)');
}
console.log('');
console.log('完成。');
+4 -4
View File
@@ -61,15 +61,15 @@ echo "✅ 构建成功完成!"
echo "" echo ""
echo "📁 文件结构:" echo "📁 文件结构:"
echo " - dist/metona-editor.js (UMD格式)" echo " - dist/metona-editor.js (UMD格式)"
echo " - dist/metona-editor.esm.js (ES Module格式)" echo " - dist/metona-editor.mjs (ES Module格式)"
echo " - dist/metona-editor.cjs.js (CommonJS格式)" echo " - dist/metona-editor.cjs (CommonJS格式)"
echo " - dist/metona-editor.min.js (压缩版本)" echo " - dist/metona-editor.min.js (压缩版本)"
echo " - dist/metona-editor.d.ts (TypeScript声明)" echo " - dist/metona-editor.d.ts (TypeScript声明)"
echo "" echo ""
echo "🚀 使用方法:" echo "🚀 使用方法:"
echo " 1. 浏览器: <script src='dist/metona-editor.js'></script>" echo " 1. 浏览器: <script src='dist/metona-editor.js'></script>"
echo " 2. ES Module: import MeEditor from 'dist/metona-editor.esm.js'" echo " 2. ES Module: import MeEditor from 'dist/metona-editor.mjs'"
echo " 3. CommonJS: const MeEditor = require('dist/metona-editor.cjs.js')" echo " 3. CommonJS: const MeEditor = require('dist/metona-editor.cjs')"
echo "" echo ""
echo "📝 示例:" echo "📝 示例:"
echo " 打开 site/index.html 查看官网" echo " 打开 site/index.html 查看官网"
-4536
View File
File diff suppressed because it is too large Load Diff
-1
View File
File diff suppressed because one or more lines are too long
-677
View File
@@ -1,677 +0,0 @@
/**
* MetonaEditor Plugins — plugin system v2
* @module plugins
* @version 0.2.0
*/
interface Plugin {
name: string;
version?: string;
description?: string;
depends?: string[];
priority?: number;
install?: (editor: any) => void | Promise<void>;
destroy?: (editor: any) => void;
[key: string]: any;
}
interface PluginSchema {
[key: string]: {
type?: string;
required?: boolean;
default?: any;
enum?: any[];
validator?: (val: any) => boolean | string;
};
}
declare class PluginManager {
plugins: Map<string, Plugin & {
enabled: boolean;
}>;
register(name: string, plugin: Plugin): this;
unregister(name: string): this;
get(name: string): Plugin | null;
has(name: string): boolean;
getAll(): Plugin[];
getNames(): string[];
enable(name: string): this;
disable(name: string): this;
isEnabled(name: string): boolean;
destroy(): void;
}
declare const topologicalSort: (plugins: Plugin[]) => Plugin[];
declare const validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
declare const presetPlugins: Record<string, Plugin>;
declare const pluginUtils: {
createManager: () => PluginManager;
manager: PluginManager;
register: (name: string, plugin: Plugin) => PluginManager;
unregister: (name: string) => PluginManager;
get: (name: string) => Plugin | null;
has: (name: string) => boolean;
getAll: () => Plugin[];
getNames: () => string[];
enable: (name: string) => PluginManager;
disable: (name: string) => PluginManager;
isEnabled: (name: string) => boolean;
getPreset: (name: string) => Plugin | null;
getAllPresets: () => Record<string, Plugin>;
createPlugin: (config?: Partial<Plugin>) => Plugin;
validatePlugin: (plugin: any) => {
valid: boolean;
errors: string[];
};
topologicalSort: (plugins: Plugin[]) => Plugin[];
validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
};
declare const loadRemote: (url: string, locale: string) => Promise<boolean>;
interface InstanceI18n {
set: (locale: string) => string;
get: () => string;
getDirection: () => 'ltr' | 'rtl';
t: (key: string, params?: Record<string, any>) => string;
formatNumber: (n: number, opts?: Intl.NumberFormatOptions) => string;
formatDate: (d: Date | string, opts?: Intl.DateTimeFormatOptions) => string;
}
declare const createInstanceI18n: (editor: any) => InstanceI18n;
declare const i18nUtils: {
t: (key: string, params?: Record<string, any>, locale?: string) => string;
getCurrentLocale: () => string;
setCurrentLocale: (locale: string) => void;
switchLocale: (locale: string) => void;
getFallbackLocale: () => string;
setFallbackLocale: (locale: string) => void;
hasTranslation: (key: string) => boolean;
getTranslations: (locale: string) => Record<string, any>;
addTranslations: (locale: string, translations: Record<string, any>) => void;
loadRemote: (url: string, locale: string) => Promise<boolean>;
getSupportedLocales: () => string[];
isLocaleSupported: (locale: string) => boolean;
getLocaleName: (locale: string) => string;
getLocaleDirection: (locale: string) => "ltr" | "rtl";
formatNumber: (number: number, options?: Intl.NumberFormatOptions) => string;
formatCurrency: (amount: number, currency?: string, options?: Intl.NumberFormatOptions) => string;
formatDate: (date: Date | string, options?: Intl.DateTimeFormatOptions) => string;
addLocaleListener: (fn: (locale: string) => void) => () => void;
removeLocaleListener: (fn: (locale: string) => void) => void;
clearLocaleListeners: () => void;
initI18n: () => void;
saveLocale: (locale: string) => void;
loadLocale: () => string;
getDefaultLocale: () => string;
createInstanceI18n: (editor: any) => InstanceI18n;
};
/**
* MetonaEditor Icons — SVG toolbar icons
* @module icons
* @version 0.2.0
*/
declare const ICONS: Record<string, string>;
/**
* MetonaEditor Constants — default configs, themes, animations
* @module constants
* @version 0.2.0
*/
type EditMode = 'edit' | 'split' | 'preview';
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string;
type ToolbarItem = string | '|';
interface RenderEnv {
highlight?: (code: string, lang: string) => string;
locale?: string;
refs?: Record<string, string>;
}
interface EditorStyle {
color?: string;
backgroundColor?: string;
[key: string]: string | undefined;
}
interface EditorOptions {
value?: string;
placeholder?: string;
mode?: EditMode;
height?: number | string;
toolbar?: ToolbarItem[] | false;
wordCount?: boolean;
autofocus?: boolean;
spellcheck?: boolean;
historyLimit?: number;
historyDebounce?: number;
syncScroll?: boolean;
tabSize?: number;
readOnly?: boolean;
lineNumbers?: boolean;
outline?: boolean;
autoBrackets?: boolean;
zenMode?: boolean;
wordWrap?: boolean;
maxLength?: number;
theme?: ThemeName;
locale?: string;
render?: ((markdown: string, env: RenderEnv) => string) | null;
highlight?: ((code: string, lang: string) => string) | null;
sanitize?: ((html: string) => string) | null;
className?: string;
style?: EditorStyle;
plugins?: any[];
id?: string;
onChange?: ((value: string, editor: any) => void) | null;
onInput?: ((value: string, editor: any) => void) | null;
onFocus?: ((editor: any) => void) | null;
onBlur?: ((editor: any) => void) | null;
onSave?: ((value: string, editor: any) => void) | null;
onModeChange?: ((mode: string, editor: any) => void) | null;
onFullscreen?: ((fullscreen: boolean, editor: any) => void) | null;
onCreate?: ((editor: any) => void) | null;
onDestroy?: ((editor: any) => void) | null;
onLinkClick?: ((href: string, text: string, editor: any) => void) | null;
floatingToolbar?: boolean;
}
interface ThemeConfig {
bg: string;
text: string;
border: string;
shadow: string;
hoverShadow?: string;
toolbarBg?: string;
textareaBg?: string;
previewBg?: string;
codeBg?: string;
codeText?: string;
accent?: string;
muted?: string;
progressBg?: string;
closeHoverBg?: string;
}
interface AnimationConfig {
name?: string;
enter: Record<string, string>;
leave: Record<string, string>;
duration: number;
easing: string;
}
/** Default toolbar button sequence */
declare const DEFAULT_TOOLBAR: ToolbarItem[];
/** Default options */
declare const DEFAULTS: Readonly<EditorOptions>;
declare const THEMES: Record<string, ThemeConfig | string>;
declare const EDIT_MODES: EditMode[];
declare const TOOLBAR_ACTIONS: string[];
/**
* MetonaEditor Parser — lightweight Markdown parser (TypeScript)
* @module parser
* @version 0.2.0
*/
interface Token {
type: string;
[key: string]: any;
}
interface ParseResult {
tokens: Token[];
footnotes: Record<string, string>;
refs: Record<string, string>;
}
interface BlockHandler {
name: string;
priority: number;
test: (line: string, lines: string[], i: number) => any;
parse: (lines: string[], i: number, match: any, tokens: Token[], footnotes: Record<string, string>, refs?: Record<string, string>) => {
token: Token | null;
newIndex: number;
};
}
declare const clearRenderCache: () => void;
declare const safeUrl: (url: string | null | undefined) => string;
declare const slugify: (text: string) => string;
declare const registerBlockHandler: (handler: BlockHandler) => void;
declare const parseTokens: (md: string | null | undefined) => ParseResult;
declare const renderTokens: (tokens: Token[], env?: RenderEnv, footnotes?: Record<string, string>) => string;
declare const parseMarkdown: (md: string | null | undefined, env?: RenderEnv) => string;
/**
* MetonaEditor Core — MarkdownEditor class (TypeScript)
* @module core
* @version 0.2.0
*/
declare class MarkdownEditor {
static _hooks: Map<string, ((editor: MarkdownEditor) => void)[]>;
static on(name: string, fn: (editor: MarkdownEditor) => void): () => void;
static off(name: string, fn: (editor: MarkdownEditor) => void): void;
static trigger(name: string, instance: MarkdownEditor): void;
id: string;
container: HTMLElement;
config: EditorOptions;
el: HTMLElement;
toolbarEl: HTMLElement;
bodyEl: HTMLElement;
editorPane: HTMLElement;
editorInner: HTMLElement;
previewPane: HTMLElement;
dividerEl: HTMLElement;
textarea: HTMLTextAreaElement;
previewEl: HTMLElement;
gutter: HTMLElement;
statusEl: HTMLElement | null;
_value: string;
_mode: EditMode;
_history: string[];
_historyIndex: number;
_cleanups: Array<() => void>;
_plugins: any[];
_listeners: Record<string, Array<(...args: any[]) => void>>;
_renderRaf: number | null;
_historyTimer: ReturnType<typeof setTimeout> | null;
_fullscreen: boolean;
_destroyed: boolean;
_lastRenderedValue: string | null;
_shortcuts: any[];
_contextMenuItems: any[];
_customActions: Record<string, (...args: any[]) => void>;
_outlineTimer: ReturnType<typeof setTimeout> | null;
_zenMode: boolean;
_wordWrap: boolean;
_syncing: boolean;
_zenMouseHandler: ((e: MouseEvent) => void) | null;
_ariaLive: HTMLElement;
_renderFn: (md: string, env: RenderEnv) => string;
_highlightFn: ((code: string, lang: string) => string) | null;
_themeCtx: any;
_i18nCtx: InstanceI18n;
_floatingToolbar: HTMLElement | null;
_floatingEnabled: boolean;
constructor(container: string | HTMLElement, options?: EditorOptions);
_buildDOM(): void;
_buildToolbar(): void;
_createBtn(item: string): HTMLButtonElement;
_bindEvents(): void;
_handleKeydown(e: KeyboardEvent): void;
_handleTab(shift: boolean): void;
_bindDividerDrag(e: PointerEvent): void;
_saveDividerPosition(): void;
_restoreDividerPosition(): void;
_scheduleRender(): void;
_render(): void;
_renderGutter(): void;
_updateCurrentLine(): void;
_handleSmartEnter(e: KeyboardEvent): void;
_handleBracketAutoClose(e: KeyboardEvent): void;
_bindDragDrop(): void;
_buildOutline(): void;
_updateOutline(): void;
_trackOutlineScroll(): void;
_scheduleHistory(): void;
_pushHistory(): void;
undo(): this;
redo(): this;
canUndo(): boolean;
canRedo(): boolean;
_applyHistory(): void;
exec(action: string, ...args: any[]): this;
_wrapSelection(before: string, after: string): void;
_toggleLinePrefix(prefix: string): void;
_insertBlock(text: string): void;
_insertLink(): void;
_insertImage(): void;
_insertTable(rows?: number, cols?: number): void;
_formatTable(): void;
setMode(mode: EditMode): this;
getMode(): EditMode;
_updateModeButtons(): void;
toggleFullscreen(): this;
isFullscreen(): boolean;
exitFullscreen(): this;
toggleZen(): this;
isZen(): boolean;
toggleWordWrap(): this;
setWordWrap(on: boolean): this;
isWordWrap(): boolean;
_initFloatingToolbar(): void;
toggleFloatingToolbar(): this;
isFloatingToolbar(): boolean;
_buildFloatingToolbar(): void;
_hideFloatingToolbar(): void;
_bindToolbarKeyboard(): void;
_initAriaLive(): void;
_announce(msg: string): void;
_syncScroll(): void;
_updateWordCount(): void;
getStats(): {
characters: number;
words: number;
chineseChars: number;
englishWords: number;
lines: number;
readingTime: number;
};
getSelectedText(): string;
getCursorPosition(): {
line: number;
column: number;
};
setCursorPosition(line: number, column?: number): this;
scrollToLine(line: number): this;
selectLine(line: number): this;
selectAll(): this;
replaceAll(search: string, replace: string, caseSensitive?: boolean): number;
replaceAllRegex(pattern: RegExp, replace: string): number;
lineCount(): number;
getLine(line: number): string;
getValue(): string;
setValue(md: string, opts?: {
silent?: boolean;
}): this;
getHTML(): string;
refresh(): this;
copyAsMarkdown(): this;
copyAsHTML(): this;
insert(text: string, opts?: {
replace?: boolean;
}): this;
wrap(before: string, after: string): this;
focus(): this;
blur(): this;
enable(): this;
disable(): this;
isDisabled(): boolean;
setReadOnly(readOnly: boolean): this;
isReadOnly(): boolean;
on(name: string, fn: (...args: any[]) => void): () => void;
off(name: string, fn: (...args: any[]) => void): this;
_emit(name: string, ...args: any[]): void;
use(plugin: string | any, options?: any): this;
unuse(name: string): this;
getPlugins(): any[];
addToolbarButton(config: any): this;
registerShortcut(combo: string, handler: Function | string, description?: string): this;
unregisterShortcut(combo: string): this;
getShortcuts(): any[];
configureToolbar(tools: ToolbarItem[]): this;
removeToolbarButton(action: string): this;
registerContextMenu(items?: any[]): this;
_bindContextMenu(): void;
_showContextMenu(e: MouseEvent): void;
_hideContextMenu(): void;
_execContextAction(action: string): void;
toast(message: string, opts?: {
type?: string;
duration?: number;
animation?: string;
}): this;
setLocale(locale: string): this;
getLocale(): string;
t(key: string, params?: Record<string, any>): string;
setTheme(theme: ThemeName): this;
getTheme(): string;
getThemeContext(): any;
getStatus(): {
id: string;
mode: EditMode;
theme: string;
locale: string;
fullscreen: boolean;
readOnly: boolean;
disabled: boolean;
destroyed: boolean;
plugins: any[];
};
destroy(): void;
isDestroyed(): boolean;
}
/**
* MetonaEditor Themes — theme system
* @module themes
* @version 0.2.0
*/
declare const exportCSSVars: (el?: HTMLElement) => Record<string, string>;
declare const getCSSVariable: (name: string, el?: HTMLElement) => string;
declare const followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
declare const adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
declare const createInstanceTheme: (editor: any) => any;
declare const themeUtils: {
getSystemTheme: () => "light" | "dark";
resolveTheme: (theme: string) => string;
getThemeConfig: (theme: string) => ThemeConfig;
watchSystemTheme: () => void;
unwatchSystemTheme: () => void;
applyTheme: (theme: string) => void;
getCurrentTheme: () => string;
getResolvedTheme: () => string;
switchTheme: (theme: string) => void;
toggleTheme: () => void;
resetToAuto: () => void;
initTheme: () => void;
saveTheme: (theme: string) => void;
loadTheme: () => string;
addThemeListener: (fn: (theme: string, resolved: string) => void) => () => void;
removeThemeListener: (fn: (theme: string, resolved: string) => void) => void;
clearThemeListeners: () => void;
registerTheme: (name: string, config?: Partial<ThemeConfig> & {
extends?: string;
}) => void;
unregisterTheme: (name: string) => void;
getAllThemes: () => Record<string, ThemeConfig | string>;
getThemeNames: () => string[];
hasTheme: (name: string) => boolean;
setThemeVariables: (config: ThemeConfig, target?: HTMLElement) => void;
exportCSSVars: (el?: HTMLElement) => Record<string, string>;
getCSSVariable: (name: string, el?: HTMLElement) => string;
applyThemeToElement: (theme: string, target: HTMLElement) => void;
followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
watch: (source: Function | string, onChange: (theme: string) => void) => () => void;
createInstanceTheme: (editor: any) => any;
};
/**
* MetonaEditor Animations — animation metadata
* @module animations
* @version 0.2.0
*/
declare const animationUtils: {
register(name: string, config: Partial<AnimationConfig> & {
enter?: Record<string, string>;
leave?: Record<string, string>;
}): void;
unregister(name: string): void;
get(name: string): AnimationConfig | null;
getAnimationNames(): string[];
getActiveCount(): number;
cancelAll(): void;
reset(): void;
destroy(): void;
};
declare const VERSION = "0.2.4";
declare function create(container: string | HTMLElement, options?: EditorOptions): MarkdownEditor;
declare function use(plugin: string | any, options?: any): typeof api;
declare function on(name: string, fn: (editor: MarkdownEditor) => void): () => void;
declare function off(name: string, fn: (editor: MarkdownEditor) => void): typeof api;
declare function setTheme(theme: ThemeName, _options?: any): typeof api;
declare function setLocale(locale: string): typeof api;
declare function destroy(): void;
declare function getStatus(): {
version: string;
theme: string;
locale: string;
globalPlugins: any[];
presetPlugins: string[];
};
declare const api: {
VERSION: string;
version: string;
MarkdownEditor: typeof MarkdownEditor;
Editor: typeof MarkdownEditor;
create: typeof create;
use: typeof use;
on: typeof on;
off: typeof off;
setTheme: typeof setTheme;
setLocale: typeof setLocale;
destroy: typeof destroy;
getStatus: typeof getStatus;
parseMarkdown: (md: string | null | undefined, env?: RenderEnv) => string;
parseTokens: (md: string | null | undefined) => ParseResult;
renderTokens: (tokens: Token[], env?: RenderEnv, footnotes?: Record<string, string>) => string;
safeUrl: (url: string | null | undefined) => string;
slugify: (text: string) => string;
clearRenderCache: () => void;
registerBlockHandler: (handler: BlockHandler) => void;
themes: {
getSystemTheme: () => "light" | "dark";
resolveTheme: (theme: string) => string;
getThemeConfig: (theme: string) => ThemeConfig;
watchSystemTheme: () => void;
unwatchSystemTheme: () => void;
applyTheme: (theme: string) => void;
getCurrentTheme: () => string;
getResolvedTheme: () => string;
switchTheme: (theme: string) => void;
toggleTheme: () => void;
resetToAuto: () => void;
initTheme: () => void;
saveTheme: (theme: string) => void;
loadTheme: () => string;
addThemeListener: (fn: (theme: string, resolved: string) => void) => () => void;
removeThemeListener: (fn: (theme: string, resolved: string) => void) => void;
clearThemeListeners: () => void;
registerTheme: (name: string, config?: Partial<ThemeConfig> & {
extends?: string;
}) => void;
unregisterTheme: (name: string) => void;
getAllThemes: () => Record<string, ThemeConfig | string>;
getThemeNames: () => string[];
hasTheme: (name: string) => boolean;
setThemeVariables: (config: ThemeConfig, target?: HTMLElement) => void;
exportCSSVars: (el?: HTMLElement) => Record<string, string>;
getCSSVariable: (name: string, el?: HTMLElement) => string;
applyThemeToElement: (theme: string, target: HTMLElement) => void;
followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
watch: (source: Function | string, onChange: (theme: string) => void) => () => void;
createInstanceTheme: (editor: any) => any;
};
i18n: {
t: (key: string, params?: Record<string, any>, locale?: string) => string;
getCurrentLocale: () => string;
setCurrentLocale: (locale: string) => void;
switchLocale: (locale: string) => void;
getFallbackLocale: () => string;
setFallbackLocale: (locale: string) => void;
hasTranslation: (key: string) => boolean;
getTranslations: (locale: string) => Record<string, any>;
addTranslations: (locale: string, translations: Record<string, any>) => void;
loadRemote: (url: string, locale: string) => Promise<boolean>;
getSupportedLocales: () => string[];
isLocaleSupported: (locale: string) => boolean;
getLocaleName: (locale: string) => string;
getLocaleDirection: (locale: string) => "ltr" | "rtl";
formatNumber: (number: number, options?: Intl.NumberFormatOptions) => string;
formatCurrency: (amount: number, currency?: string, options?: Intl.NumberFormatOptions) => string;
formatDate: (date: Date | string, options?: Intl.DateTimeFormatOptions) => string;
addLocaleListener: (fn: (locale: string) => void) => () => void;
removeLocaleListener: (fn: (locale: string) => void) => void;
clearLocaleListeners: () => void;
initI18n: () => void;
saveLocale: (locale: string) => void;
loadLocale: () => string;
getDefaultLocale: () => string;
createInstanceI18n: (editor: any) => InstanceI18n;
};
animations: {
register(name: string, config: Partial<AnimationConfig> & {
enter?: Record<string, string>;
leave?: Record<string, string>;
}): void;
unregister(name: string): void;
get(name: string): AnimationConfig | null;
getAnimationNames(): string[];
getActiveCount(): number;
cancelAll(): void;
reset(): void;
destroy(): void;
};
plugins: {
createManager: () => PluginManager;
manager: PluginManager;
register: (name: string, plugin: Plugin) => PluginManager;
unregister: (name: string) => PluginManager;
get: (name: string) => Plugin | null;
has: (name: string) => boolean;
getAll: () => Plugin[];
getNames: () => string[];
enable: (name: string) => PluginManager;
disable: (name: string) => PluginManager;
isEnabled: (name: string) => boolean;
getPreset: (name: string) => Plugin | null;
getAllPresets: () => Record<string, Plugin>;
createPlugin: (config?: Partial<Plugin>) => Plugin;
validatePlugin: (plugin: any) => {
valid: boolean;
errors: string[];
};
topologicalSort: (plugins: Plugin[]) => Plugin[];
validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
};
presetPlugins: Record<string, Plugin>;
topologicalSort: (plugins: Plugin[]) => Plugin[];
validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
createInstanceI18n: (editor: any) => InstanceI18n;
loadRemote: (url: string, locale: string) => Promise<boolean>;
exportCSSVars: (el?: HTMLElement) => Record<string, string>;
getCSSVariable: (name: string, el?: HTMLElement) => string;
followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
createInstanceTheme: (editor: any) => any;
DEFAULTS: Readonly<EditorOptions>;
ICONS: Record<string, string>;
THEMES: Record<string, string | ThemeConfig>;
EDIT_MODES: EditMode[];
DEFAULT_TOOLBAR: string[];
TOOLBAR_ACTIONS: string[];
};
export { DEFAULTS, DEFAULT_TOOLBAR, EDIT_MODES, MarkdownEditor as Editor, ICONS, MarkdownEditor, api as MeEditor, THEMES, TOOLBAR_ACTIONS, VERSION, adoptFromParent, animationUtils, api, clearRenderCache, create, createInstanceI18n, createInstanceTheme, api as default, destroy, exportCSSVars, followExternalTheme, getCSSVariable, getStatus, i18nUtils, loadRemote, api as meEditor, off, on, parseMarkdown, parseTokens, pluginUtils, presetPlugins, registerBlockHandler, renderTokens, safeUrl, setLocale, setTheme, slugify, themeUtils, topologicalSort, use, validateConfig };
-4491
View File
File diff suppressed because it is too large Load Diff
-1
View File
File diff suppressed because one or more lines are too long
-4542
View File
File diff suppressed because it is too large Load Diff
-1
View File
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
+11 -8
View File
@@ -1,19 +1,21 @@
{ {
"name": "@metona-team/metona-editor", "name": "@metona-team/metona-editor",
"version": "0.2.4", "version": "0.2.5",
"description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.", "description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.",
"type": "module", "type": "module",
"main": "dist/metona-editor.js", "main": "dist/metona-editor.cjs",
"module": "src/index.ts", "module": "dist/metona-editor.mjs",
"unpkg": "dist/metona-editor.min.js", "unpkg": "dist/metona-editor.min.js",
"jsdelivr": "dist/metona-editor.min.js", "jsdelivr": "dist/metona-editor.min.js",
"types": "dist/metona-editor.d.ts", "types": "dist/metona-editor.d.ts",
"exports": { "exports": {
".": { ".": {
"import": "./src/index.ts", "types": "./dist/metona-editor.d.ts",
"require": "./dist/metona-editor.js", "import": "./dist/metona-editor.mjs",
"types": "./dist/metona-editor.d.ts" "require": "./dist/metona-editor.cjs",
} "default": "./dist/metona-editor.js"
},
"./package.json": "./package.json"
}, },
"files": [ "files": [
"dist/", "dist/",
@@ -27,9 +29,10 @@
"test": "jest --coverage", "test": "jest --coverage",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"lint": "eslint \"src/**/*.ts\"", "lint": "eslint \"src/**/*.ts\"",
"lint:fix": "eslint src/ --fix", "lint:fix": "eslint \"src/**/*.ts\" --fix",
"format": "prettier --write src/", "format": "prettier --write src/",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"bench": "node bench/benchmark.cjs",
"prepublishOnly": "npm run typecheck && npm test && npm run build" "prepublishOnly": "npm run typecheck && npm test && npm run build"
}, },
"repository": { "repository": {
+2 -2
View File
@@ -61,7 +61,7 @@ export default [
{ {
input: 'src/index.ts', input: 'src/index.ts',
output: { output: {
file: 'dist/metona-editor.esm.js', file: 'dist/metona-editor.mjs',
format: 'es', format: 'es',
exports: 'named', exports: 'named',
sourcemap: true, sourcemap: true,
@@ -72,7 +72,7 @@ export default [
{ {
input: 'src/index.ts', input: 'src/index.ts',
output: { output: {
file: 'dist/metona-editor.cjs.js', file: 'dist/metona-editor.cjs',
format: 'cjs', format: 'cjs',
exports: 'named', exports: 'named',
sourcemap: true, sourcemap: true,
+33 -14
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
<title>MetonaEditor v0.2.4 — 全功能演示</title> <title>MetonaEditor v0.2.5 — 全功能演示</title>
<style> <style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0} :root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
@@ -45,11 +45,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<body> <body>
<header class="header"> <header class="header">
<h1><span class="grad">Metona</span>Editor v0.2.4</h1> <h1><span class="grad">Metona</span>Editor v0.2.5</h1>
<div class="badges"> <div class="badges">
<span class="badge">TypeScript</span> <span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span> <span class="badge">零运行时依赖</span>
<span class="badge">725 tests</span> <span class="badge">780 tests</span>
<span class="badge">6 个插件</span> <span class="badge">6 个插件</span>
<span class="badge">桌面端优先</span> <span class="badge">桌面端优先</span>
</div> </div>
@@ -80,8 +80,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
</div> </div>
<div class="info-grid"> <div class="info-grid">
<div class="info-card"><div class="icon">🏗️</div><h4>TypeScript 全模块重构</h4><p>12 个 .ts 源文件,严格模式,完整类型导出</p></div> <div class="info-card"><div class="icon">🏗️</div><h4>TypeScript 全模块重构</h4><p>17 个 .ts 源文件,严格模式,完整类型导出</p></div>
<div class="info-card"><div class="icon">📝</div><h4>自研解析器 99% 行覆盖</h4><p>CommonMark + GFM:粗斜体、链接内格式、脚注、数学公式等</p></div> <div class="info-card"><div class="icon">📝</div><h4>自研解析器 99% 行覆盖</h4><p>CommonMark + GFM:粗斜体、链接内格式、脚注、数学公式等</p></div>
<div class="info-card"><div class="icon">🎨</div><h4>内置语法高亮</h4><p>零依赖 tokenizerjs / ts / python / bash 等 16 个语言标识</p></div>
<div class="info-card"><div class="icon">📂</div><h4>磁盘文件读写</h4><p>File System Access API 打开 / 保存真实 .md 文件</p></div> <div class="info-card"><div class="icon">📂</div><h4>磁盘文件读写</h4><p>File System Access API 打开 / 保存真实 .md 文件</p></div>
<div class="info-card"><div class="icon">🔢</div><h4>行号装订线</h4><p>当前行高亮,与编辑区滚动同步</p></div> <div class="info-card"><div class="icon">🔢</div><h4>行号装订线</h4><p>当前行高亮,与编辑区滚动同步</p></div>
<div class="info-card"><div class="icon">🧘</div><h4>Zen 专注模式</h4><p>工具栏自动隐藏,鼠标移到顶部滑入</p></div> <div class="info-card"><div class="icon">🧘</div><h4>Zen 专注模式</h4><p>工具栏自动隐藏,鼠标移到顶部滑入</p></div>
@@ -91,7 +92,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
</div> </div>
<footer class="footer"> <footer class="footer">
MetonaEditor v0.2.4 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT MetonaEditor v0.2.5 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
</footer> </footer>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
@@ -99,18 +100,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<script> <script>
(function(){ (function(){
var demoMd=[ var demoMd=[
'# 🚀 MetonaEditor v0.2.4 全功能演示', '# 🚀 MetonaEditor v0.2.5 全功能演示',
'', '',
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。', '> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
'> 全模块 TypeScript 严格模式,12 个源文件,725 个测试全部通过。', '> 全模块 TypeScript 严格模式,17 个源文件,780 个测试全部通过。',
'', '',
'## ✨ v0.2.4 新特性', '## ✨ v0.2.5 新特性',
'', '',
'- 浮动格式工具栏:选中文本自动弹出 bold/italic/code/link', '- 内置**零依赖语法高亮**js / ts / python / bash 等 16 个语言标识',
'- 新增 `getSelectedText()` `getCursorPosition()` 等 10+ API', '- `exportPDF()` 打印导出 · `imagePaste` 尺寸限制(maxSizeKB',
'- `replaceAll` / `replaceAllRegex` 批量替换', '- 搜索面板支持**大小写 / 全字匹配**开关(Aa / ab)',
'- `selectionChange` / `cursorMove` 事件', '- `maxLength` 限制文档大小 · `zenMode: true` 启动即专注',
'- 德语(de)翻译 · 6 种语言支持', '- 安全加固:属性级注入防护 · 发布产物 .mjs / .cjs 规范',
'', '',
'## 📐 文本样式', '## 📐 文本样式',
'', '',
@@ -139,7 +140,7 @@ var demoMd=[
'', '',
'| 版本 | 日期 | 测试 | 主题 |', '| 版本 | 日期 | 测试 | 主题 |',
'| :--- | :---: | ---: | --- |', '| :--- | :---: | ---: | --- |',
'| v0.2.4 | 2026-07 | 725 | 浮动工具栏+新API |', '| v0.2.5 | 2026-08 | 780 | 内置高亮+安全修复 |',
'| v0.2.2 | 2026-07 | 694 | 法语+表格格式化 |', '| v0.2.2 | 2026-07 | 694 | 法语+表格格式化 |',
'| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |', '| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |', '| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
@@ -185,6 +186,23 @@ var demoMd=[
'editor.exec("bold").exec("h1").focus();', 'editor.exec("bold").exec("h1").focus();',
'```', '```',
'', '',
'```python',
'def fib(n):',
' """返回斐波那契数列第 n 项"""',
' a, b = 0, 1',
' for _ in range(n):',
' a, b = b, a + b',
' return a',
'',
'print(fib(10)) # 55',
'```',
'',
'```bash',
'# 安装与启动',
'npm install @metona-team/metona-editor',
'npm run dev',
'```',
'',
'## 📌 脚注', '## 📌 脚注',
'', '',
'这是一个带脚注的句子[^1],另一个脚注[^note]。', '这是一个带脚注的句子[^1],另一个脚注[^note]。',
@@ -204,6 +222,7 @@ var editor = MeEditor.create('#editor',{
mode: 'split', height: 620, mode: 'split', height: 620,
lineNumbers: true, autoBrackets: true, wordCount: true, lineNumbers: true, autoBrackets: true, wordCount: true,
theme: 'auto', locale: 'zh-CN', outline: false, theme: 'auto', locale: 'zh-CN', outline: false,
highlight: MeEditor.highlight,
plugins: ['autoSave','exportTool','searchReplace','imagePaste','shortcutHelp','fileSystem'], plugins: ['autoSave','exportTool','searchReplace','imagePaste','shortcutHelp','fileSystem'],
onLinkClick: function(uri, text, ed){ onLinkClick: function(uri, text, ed){
ed.toast('链接: ' + uri, { type: 'info', duration: 2000 }); ed.toast('链接: ' + uri, { type: 'info', duration: 2000 });
+58 -11
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
<title>MetonaEditor v0.2.4 · 文档</title> <title>MetonaEditor v0.2.5 · 文档</title>
<style> <style>
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)} :root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)}
@@ -65,7 +65,7 @@ footer a{color:var(--accent);text-decoration:none}
<header class="docs-header"> <header class="docs-header">
<h1><span class="grad">API 文档</span></h1> <h1><span class="grad">API 文档</span></h1>
<p class="sub">MetonaEditor v0.2.4 · TypeScript · 完整配置、API 与使用指南</p> <p class="sub">MetonaEditor v0.2.5 · TypeScript · 完整配置、API 与使用指南</p>
</header> </header>
<div class="container"> <div class="container">
@@ -78,6 +78,7 @@ footer a{color:var(--accent);text-decoration:none}
<a href="#toolbar">工具栏</a> <a href="#toolbar">工具栏</a>
<div class="toc-group">实例 API</div> <div class="toc-group">实例 API</div>
<a href="#api-content">内容操作</a> <a href="#api-content">内容操作</a>
<a href="#api-cursor">光标与选区</a>
<a href="#api-exec">命令执行</a> <a href="#api-exec">命令执行</a>
<a href="#api-history">历史栈</a> <a href="#api-history">历史栈</a>
<a href="#api-mode">模式 / 全屏 / Zen</a> <a href="#api-mode">模式 / 全屏 / Zen</a>
@@ -89,6 +90,8 @@ footer a{color:var(--accent);text-decoration:none}
<a href="#api-destroy">生命周期</a> <a href="#api-destroy">生命周期</a>
<div class="toc-group">静态 API</div> <div class="toc-group">静态 API</div>
<a href="#static-api">全局函数</a> <a href="#static-api">全局函数</a>
<div class="toc-group">语法高亮</div>
<a href="#highlight">内置高亮器</a>
<div class="toc-group">解析器</div> <div class="toc-group">解析器</div>
<a href="#parser-syntax">语法参考</a> <a href="#parser-syntax">语法参考</a>
<a href="#parser-api">解析器 API</a> <a href="#parser-api">解析器 API</a>
@@ -142,6 +145,8 @@ footer a{color:var(--accent);text-decoration:none}
historyLimit<span class="c-punc">:</span> <span class="c-num">100</span><span class="c-punc">,</span> historyDebounce<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span> historyLimit<span class="c-punc">:</span> <span class="c-num">100</span><span class="c-punc">,</span> historyDebounce<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
syncScroll<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> autoBrackets<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> syncScroll<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> autoBrackets<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
zenMode<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> wordWrap<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> zenMode<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> wordWrap<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
maxLength<span class="c-punc">:</span> <span class="c-num">0</span><span class="c-punc">,</span> <span class="c-com">// 最大字符数(0=不限)</span>
floatingToolbar<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> <span class="c-com">// 选中文本浮动格式栏</span>
<span class="c-com">// 主题 / 语言</span> <span class="c-com">// 主题 / 语言</span>
theme<span class="c-punc">:</span> <span class="c-str">'auto'</span><span class="c-punc">,</span> locale<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span> theme<span class="c-punc">:</span> <span class="c-str">'auto'</span><span class="c-punc">,</span> locale<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span>
@@ -194,9 +199,23 @@ editor<span class="c-punc">.</span><span class="c-fn">getHTML</span><span class=
editor<span class="c-punc">.</span><span class="c-fn">refresh</span><span class="c-punc">();</span> <span class="c-com">// 强制重渲染</span> editor<span class="c-punc">.</span><span class="c-fn">refresh</span><span class="c-punc">();</span> <span class="c-com">// 强制重渲染</span>
editor<span class="c-punc">.</span><span class="c-fn">insert</span><span class="c-punc">(</span>text<span class="c-punc">,</span> <span class="c-punc">{</span> replace <span class="c-punc">});</span> <span class="c-com">// => this</span> editor<span class="c-punc">.</span><span class="c-fn">insert</span><span class="c-punc">(</span>text<span class="c-punc">,</span> <span class="c-punc">{</span> replace <span class="c-punc">});</span> <span class="c-com">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">wrap</span><span class="c-punc">(</span>before<span class="c-punc">,</span> after<span class="c-punc">);</span> <span class="c-com">// => this</span> editor<span class="c-punc">.</span><span class="c-fn">wrap</span><span class="c-punc">(</span>before<span class="c-punc">,</span> after<span class="c-punc">);</span> <span class="c-com">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">replaceAll</span><span class="c-punc">(</span>search<span class="c-punc">,</span> replace<span class="c-punc">,</span> caseSensitive<span class="c-punc">)</span> <span class="c-com">// => 替换数</span>
editor<span class="c-punc">.</span><span class="c-fn">replaceAllRegex</span><span class="c-punc">(</span>pattern<span class="c-punc">,</span> replace<span class="c-punc">)</span> <span class="c-com">// => 替换数</span>
editor<span class="c-punc">.</span><span class="c-fn">lineCount</span><span class="c-punc">();</span> <span class="c-fn">getLine</span><span class="c-punc">(</span>n<span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">copyAsMarkdown</span><span class="c-punc">();</span> <span class="c-fn">copyAsHTML</span><span class="c-punc">();</span> <span class="c-com">// 剪贴板</span>
editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c-punc">();</span> <span class="c-fn">blur</span><span class="c-punc">();</span></pre> editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c-punc">();</span> <span class="c-fn">blur</span><span class="c-punc">();</span></pre>
</section> </section>
<section id="api-cursor"><h3>光标与选区</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">getSelectedText</span><span class="c-punc">();</span> <span class="c-com">// => string</span>
editor<span class="c-punc">.</span><span class="c-fn">getCursorPosition</span><span class="c-punc">();</span> <span class="c-com">// => { line, column }</span>
editor<span class="c-punc">.</span><span class="c-fn">setCursorPosition</span><span class="c-punc">(</span>line<span class="c-punc">,</span> column<span class="c-punc">);</span> <span class="c-com">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">scrollToLine</span><span class="c-punc">(</span>line<span class="c-punc">);</span> <span class="c-com">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">selectLine</span><span class="c-punc">(</span>line<span class="c-punc">);</span> <span class="c-com">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">selectAll</span><span class="c-punc">();</span> <span class="c-com">// => this</span></pre>
<p>伴随事件 <code>selectionChange</code><code>{ start, end, text }</code>)与 <code>cursorMove</code><code>{ line, column }</code>)。</p>
</section>
<section id="api-exec"><h3>命令执行</h3> <section id="api-exec"><h3>命令执行</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-punc">(</span>action<span class="c-punc">)</span> <span class="c-com">// => this, 支持链式</span></pre> <pre>editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-punc">(</span>action<span class="c-punc">)</span> <span class="c-com">// => this, 支持链式</span></pre>
<table> <table>
@@ -227,11 +246,12 @@ editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c
editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">canRedo</span><span class="c-punc">();</span></pre> editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">canRedo</span><span class="c-punc">();</span></pre>
</section> </section>
<section id="api-mode"><h3>模式 / 全屏 / Zen</h3> <section id="api-mode"><h3>模式 / 全屏 / Zen / 浮动工具栏</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">setMode</span><span class="c-punc">(</span><span class="c-str">'split'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">getMode</span><span class="c-punc">();</span> <pre>editor<span class="c-punc">.</span><span class="c-fn">setMode</span><span class="c-punc">(</span><span class="c-str">'split'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">getMode</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleFullscreen</span><span class="c-punc">();</span> <span class="c-fn">isFullscreen</span><span class="c-punc">();</span> <span class="c-fn">exitFullscreen</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">toggleFullscreen</span><span class="c-punc">();</span> <span class="c-fn">isFullscreen</span><span class="c-punc">();</span> <span class="c-fn">exitFullscreen</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleZen</span><span class="c-punc">();</span> <span class="c-fn">isZen</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">toggleZen</span><span class="c-punc">();</span> <span class="c-fn">isZen</span><span class="c-punc">();</span> <span class="c-com">// 或配置 zenMode: true 启动即进入</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleWordWrap</span><span class="c-punc">();</span> <span class="c-fn">setWordWrap</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isWordWrap</span><span class="c-punc">();</span></pre> editor<span class="c-punc">.</span><span class="c-fn">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>
</section> </section>
<section id="api-stats"><h3>统计与状态</h3> <section id="api-stats"><h3>统计与状态</h3>
@@ -251,6 +271,7 @@ 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>focus</code> / <code>blur</code></td><td>聚焦/失焦</td><td><code>(editor)</code></td></tr> <tr><td><code>focus</code> / <code>blur</code></td><td>聚焦/失焦</td><td><code>(editor)</code></td></tr>
<tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(editor)</code></td></tr> <tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(editor)</code></td></tr>
<tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr> <tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr>
@@ -258,10 +279,14 @@ editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span cl
<tr><td><code>themeChange</code></td><td>主题变更</td><td><code>({ theme, resolved, config })</code></td></tr> <tr><td><code>themeChange</code></td><td>主题变更</td><td><code>({ theme, resolved, config })</code></td></tr>
<tr><td><code>localeChange</code></td><td>语言变更</td><td><code>({ locale, direction })</code></td></tr> <tr><td><code>localeChange</code></td><td>语言变更</td><td><code>({ locale, direction })</code></td></tr>
<tr><td><code>zenChange</code></td><td>Zen 切换</td><td><code>(zen, editor)</code></td></tr> <tr><td><code>zenChange</code></td><td>Zen 切换</td><td><code>(zen, editor)</code></td></tr>
<tr><td><code>selectionChange</code></td><td>选区变化</td><td><code>({ start, end, text })</code></td></tr>
<tr><td><code>cursorMove</code></td><td>光标移动</td><td><code>({ line, column })</code></td></tr>
<tr><td><code>destroy</code></td><td>销毁</td><td><code>()</code></td></tr> <tr><td><code>destroy</code></td><td>销毁</td><td><code>()</code></td></tr>
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>渲染前后</td><td><code>(editor)</code></td></tr> <tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>渲染前后</td><td><code>(editor)</code></td></tr>
<tr><td><code>autosave</code></td><td>autoSave 触发</td><td><code>({ key, value })</code></td></tr> <tr><td><code>autosave</code></td><td>autoSave 触发</td><td><code>({ key, value })</code></td></tr>
<tr><td><code>linkClick</code></td><td>预览链接点击</td><td><code>({ href, text })</code></td></tr> <tr><td><code>linkClick</code></td><td>预览链接点击</td><td><code>({ href, text })</code></td></tr>
<tr><td><code>copy</code></td><td>复制到剪贴板</td><td><code>({ type })</code></td></tr>
<tr><td><code>fileOpened</code> / <code>fileSaved</code></td><td>fileSystem 读写</td><td><code>({ name, handle })</code></td></tr>
</table> </table>
<h4>全局钩子</h4> <h4>全局钩子</h4>
<table> <table>
@@ -310,7 +335,29 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span cla
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> <span class="c-com">// 解析器独立使用</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span></pre> <span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 内置语法高亮</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> highlight<span class="c-punc">,</span> 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>
</section>
<!-- 语法高亮 -->
<section id="highlight"><h2>内置语法高亮</h2>
<p>v0.2.5 起内置零依赖轻量高亮器,支持 js / ts / tsx / jsx / python / bash / css / html / json / yaml / markdown / java / go / rust(含 shell / md 别名共 16 个语言标识)。</p>
<pre><span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 方式一:作为 highlight 钩子(代码块自动高亮)</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">,</span> <span class="c-punc">{</span> highlight<span class="c-punc">:</span> MeEditor<span class="c-punc">.</span><span class="c-fn">highlight</span> <span class="c-punc">});</span>
<span class="c-com">// 方式二:独立调用</span>
<span class="c-kw">const</span> html <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">highlight</span><span class="c-punc">(</span>code<span class="c-punc">,</span> <span class="c-str">'typescript'</span><span class="c-punc">);</span>
<span class="c-com">// 注册自定义语言</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><span class="c-punc">(</span><span class="c-str">'myLang'</span><span class="c-punc">,</span> <span class="c-punc">{</span> keywords<span class="c-punc">:</span> <span class="c-punc">[</span><span class="c-str">'kw'</span><span class="c-punc">]</span><span class="c-punc">,</span> builtins<span class="c-punc">:</span> <span class="c-punc">[]</span> <span class="c-punc">});</span></pre>
<p>高亮输出使用 <code>me-hl-keyword</code> / <code>me-hl-string</code> / <code>me-hl-comment</code> / <code>me-hl-number</code> / <code>me-hl-builtin</code> / <code>me-hl-function</code> 类。内置浅色 / 深色自适应配色,可通过 CSS 覆盖定制:</p>
<pre><span class="c-com">/* 自定义配色(覆盖内置默认) */</span>
<span class="c-kw">.me-preview</span> <span class="c-punc">.</span><span class="c-kw">me-hl-keyword</span><span class="c-punc">{</span> color<span class="c-punc">:</span> <span class="c-str">#a855f7</span> <span class="c-punc">}</span>
<span class="c-kw">.me-preview</span> <span class="c-punc">.</span><span class="c-kw">me-hl-string</span><span class="c-punc">{</span> color<span class="c-punc">:</span> <span class="c-str">#22c55e</span> <span class="c-punc">}</span></pre>
</section> </section>
<!-- 解析器语法 --> <!-- 解析器语法 -->
@@ -346,7 +393,7 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span cl
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</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>
</table> </table>
<h4>XSS 防护</h4> <h4>XSS 防护</h4>
<p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,提供 <code>sanitize</code> 钩子。</p> <p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>属性级注入防护(<code>href</code> / <code>src</code> / <code>alt</code> / <code>title</code> 引号转义),提供 <code>sanitize</code> 钩子。</p>
</section> </section>
<section id="parser-api"><h2>解析器 API</h2> <section id="parser-api"><h2>解析器 API</h2>
@@ -381,12 +428,12 @@ tokens<span class="c-punc">.</span><span class="c-fn">unshift</span><span class=
editor<span class="c-punc">.</span><span class="c-fn">restoreDraft</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">clearDraft</span><span class="c-punc">();</span></pre> editor<span class="c-punc">.</span><span class="c-fn">restoreDraft</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">clearDraft</span><span class="c-punc">();</span></pre>
<h4>exportTool</h4> <h4>exportTool</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'exportTool'</span><span class="c-punc">);</span> <pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'exportTool'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span class="c-punc">(</span><span class="c-str">'doc.md'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">exportHTML</span><span class="c-punc">(</span><span class="c-str">'doc.html'</span><span class="c-punc">);</span></pre> editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span class="c-punc">(</span><span class="c-str">'doc.md'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">exportHTML</span><span class="c-punc">(</span><span class="c-str">'doc.html'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">exportPDF</span><span class="c-punc">();</span></pre>
<h4>searchReplace</h4> <h4>searchReplace</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'searchReplace'</span><span class="c-punc">);</span> <pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'searchReplace'</span><span class="c-punc">);</span>
<span class="c-com">// Ctrl+F 查找 / Ctrl+H 替换</span></pre> <span class="c-com">// Ctrl+F 查找 / Ctrl+H 替换,支持正则 .* 、大小写 Aa、全字 ab 开关</span></pre>
<h4>imagePaste</h4> <h4>imagePaste</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'imagePaste'</span><span class="c-punc">);</span> <span class="c-com">// 粘贴图片自动转 base64</span></pre> <pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'imagePaste'</span><span class="c-punc">,</span> <span class="c-punc">{</span> maxSizeKB<span class="c-punc">:</span> <span class="c-num">500</span> <span class="c-punc">});</span> <span class="c-com">// 粘贴图片转 base64,超限警告</span></pre>
<h4>shortcutHelp</h4> <h4>shortcutHelp</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'shortcutHelp'</span><span class="c-punc">);</span> <span class="c-com">// 按 ? 弹出快捷键面板</span></pre> <pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'shortcutHelp'</span><span class="c-punc">);</span> <span class="c-com">// 按 ? 弹出快捷键面板</span></pre>
<h4>fileSystem</h4> <h4>fileSystem</h4>
@@ -433,7 +480,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
<footer> <footer>
<div class="container"> <div class="container">
MetonaEditor v0.2.4 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License MetonaEditor v0.2.5 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
</div> </div>
</footer> </footer>
+13 -11
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>"/> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>"/>
<title>MetonaEditor v0.2.4 · TypeScript · 桌面端 Markdown 编辑器</title> <title>MetonaEditor v0.2.5 · TypeScript · 桌面端 Markdown 编辑器</title>
<style> <style>
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-3:#ec4899;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%);--shadow:0 20px 50px -20px rgba(0,0,0,.5)} :root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-3:#ec4899;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%);--shadow:0 20px 50px -20px rgba(0,0,0,.5)}
@@ -106,10 +106,10 @@ footer a:hover{text-decoration:underline}
<h1>TypeScript 重构 · 零依赖<br/><span class="grad">桌面端 Markdown 编辑器</span></h1> <h1>TypeScript 重构 · 零依赖<br/><span class="grad">桌面端 Markdown 编辑器</span></h1>
<p class="tagline">全模块 TypeScript 严格模式,内置自研解析器(99% 行覆盖率),6 个预设插件,中文优先。为桌面端现代 Web 应用而生。</p> <p class="tagline">全模块 TypeScript 严格模式,内置自研解析器(99% 行覆盖率),6 个预设插件,中文优先。为桌面端现代 Web 应用而生。</p>
<div class="badges"> <div class="badges">
<span class="badge accent">v0.2.4</span> <span class="badge accent">v0.2.5</span>
<span class="badge">TypeScript</span> <span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span> <span class="badge">零运行时依赖</span>
<span class="badge">725 tests</span> <span class="badge">780 tests</span>
<span class="badge">桌面端优先</span> <span class="badge">桌面端优先</span>
<span class="badge">MIT</span> <span class="badge">MIT</span>
</div> </div>
@@ -120,8 +120,8 @@ footer a:hover{text-decoration:underline}
<div class="stats"> <div class="stats">
<div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div> <div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div>
<div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div> <div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div>
<div class="stat"><div class="stat-num">725</div><div class="stat-label">单元测试</div></div> <div class="stat"><div class="stat-num">780</div><div class="stat-label">单元测试</div></div>
<div class="stat"><div class="stat-num">~30KB</div><div class="stat-label">gzip 体积</div></div> <div class="stat"><div class="stat-num">~42KB</div><div class="stat-label">gzip 体积</div></div>
</div> </div>
</header> </header>
@@ -144,8 +144,9 @@ footer a:hover{text-decoration:underline}
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></div><h3>分屏实时预览</h3><p>edit / split / preview 三模式切换,拖拽分隔条调整比例,双向比例同步滚动。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></div><h3>分屏实时预览</h3><p>edit / split / preview 三模式切换,拖拽分隔条调整比例,双向比例同步滚动。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div><h3>零运行时依赖</h3><p>不依赖任何第三方运行时库,打包后单文件,UMD / ESM / CJS 三种格式开箱即用。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div><h3>零运行时依赖</h3><p>不依赖任何第三方运行时库,打包后单文件,UMD / ESM / CJS 三种格式开箱即用。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></div><h3>6 个预设插件</h3><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem,插件 v2 支持拓扑排序、异步、卸载。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></div><h3>6 个预设插件</h3><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem,插件 v2 支持拓扑排序、异步、卸载。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg></div><h3>主题 & i18n</h3><p>4 套预设主题 + CSS 变量定制 + 实例级隔离。中英双语开箱即用,支持远程加载翻译包。</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg></div><h3>主题 & i18n</h3><p>4 套预设主题 + CSS 变量定制 + 实例级隔离。六种语言开箱即用(中 / 英 / 日 / 韩 / 法 / 德),支持远程加载翻译包。</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><h3>XSS 安全防护</h3><p>HTML 转义、危险协议过滤、sanitize 钩子、highlight 异常回退。多层防护,安全可靠</p></div> <div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></div><h3>内置语法高亮</h3><p>零依赖轻量高亮器,js / ts / python / bash / css 等 16 个语言标识,代码块一行配置即自动着色</p></div>
<div class="feature"><div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><h3>XSS 安全防护</h3><p>HTML 转义、危险协议过滤、属性级注入防护、sanitize 钩子、highlight 异常回退。多层防护,安全可靠。</p></div>
</div> </div>
</div> </div>
</section> </section>
@@ -165,7 +166,7 @@ footer a:hover{text-decoration:underline}
<span class="c-kw">const</span> MeEditor <span class="c-punc">=</span> <span class="c-fn">require</span><span class="c-punc">(</span><span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">);</span> <span class="c-kw">const</span> MeEditor <span class="c-punc">=</span> <span class="c-fn">require</span><span class="c-punc">(</span><span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">);</span>
<span class="c-com">// 方式二:CDN 引入</span> <span class="c-com">// 方式二:CDN 引入</span>
<span class="c-com">&lt;script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.2.4/dist/metona-editor.min.js"&gt;&lt;/script&gt;</span> <span class="c-com">&lt;script src="https://cdn.jsdelivr.net/npm/@metona-team/metona-editor@0.2.5/dist/metona-editor.min.js"&gt;&lt;/script&gt;</span>
<span class="c-com">// 方式三:本地文件</span> <span class="c-com">// 方式三:本地文件</span>
<span class="c-com">&lt;script src="./dist/metona-editor.min.js"&gt;&lt;/script&gt;</span> <span class="c-com">&lt;script src="./dist/metona-editor.min.js"&gt;&lt;/script&gt;</span>
@@ -188,7 +189,8 @@ editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-
<span class="section-eyebrow">API Reference</span> <span class="section-eyebrow">API Reference</span>
<h2 class="section-title">API 速览</h2> <h2 class="section-title">API 速览</h2>
<div class="api-grid"> <div class="api-grid">
<div class="api-card"><h4>内容操作</h4><ul><li>getValue() <span class="ret">string</span></li><li>setValue(md) <span class="ret">this</span></li><li>getHTML() <span class="ret">string</span></li><li>insert(text) <span class="ret">this</span></li><li>wrap(before, after) <span class="ret">this</span></li><li>focus() / blur() <span class="ret">this</span></li></ul></div> <div class="api-card"><h4>内容操作</h4><ul><li>getValue() <span class="ret">string</span></li><li>setValue(md) <span class="ret">this</span></li><li>getHTML() <span class="ret">string</span></li><li>insert(text) <span class="ret">this</span></li><li>wrap(before, after) <span class="ret">this</span></li><li>replaceAll(search, rep) <span class="ret">number</span></li></ul></div>
<div class="api-card"><h4>光标与选区</h4><ul><li>getSelectedText() <span class="ret">string</span></li><li>getCursorPosition() <span class="ret">{line,column}</span></li><li>setCursorPosition(l, c) <span class="ret">this</span></li><li>selectLine(n) / selectAll() <span class="ret">this</span></li><li>toggleFloatingToolbar() <span class="ret">this</span></li></ul></div>
<div class="api-card"><h4>命令执行</h4><ul><li>exec(action) <span class="ret">this</span></li><li>undo() / redo() <span class="ret">this</span></li><li>canUndo() / canRedo() <span class="ret">boolean</span></li></ul></div> <div class="api-card"><h4>命令执行</h4><ul><li>exec(action) <span class="ret">this</span></li><li>undo() / redo() <span class="ret">this</span></li><li>canUndo() / canRedo() <span class="ret">boolean</span></li></ul></div>
<div class="api-card"><h4>模式与全屏</h4><ul><li>setMode(mode) <span class="ret">this</span></li><li>getMode() <span class="ret">string</span></li><li>toggleFullscreen() <span class="ret">this</span></li><li>toggleZen() <span class="ret">this</span></li></ul></div> <div class="api-card"><h4>模式与全屏</h4><ul><li>setMode(mode) <span class="ret">this</span></li><li>getMode() <span class="ret">string</span></li><li>toggleFullscreen() <span class="ret">this</span></li><li>toggleZen() <span class="ret">this</span></li></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>
@@ -227,7 +229,7 @@ editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a>
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor/issues" target="_blank" rel="noopener">问题反馈</a> <a href="https://git.metona.cn/MetonaTeam/MetonaEditor/issues" target="_blank" rel="noopener">问题反馈</a>
</div> </div>
<div>MetonaEditor v0.2.4 · TypeScript · MIT License · MetonaTeam</div> <div>MetonaEditor v0.2.5 · TypeScript · MIT License · MetonaTeam</div>
</div> </div>
</footer> </footer>
@@ -237,7 +239,7 @@ MeEditor.themes.switchTheme('dark');
MeEditor.create('#editor', { MeEditor.create('#editor', {
mode: 'split', height: 480, theme: 'dark', mode: 'split', height: 480, theme: 'dark',
value: [ value: [
'# 欢迎使用 MetonaEditor v0.2.4', '# 欢迎使用 MetonaEditor v0.2.5',
'', '',
'一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。', '一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。',
'', '',
+123
View File
@@ -0,0 +1,123 @@
/**
* MetonaEditor Commands — editing command implementations
* @module commands
* @version 0.2.5
*/
import { t as i18nT } from './i18n';
import type { MarkdownEditor } from './core';
// ============ Selection wrapping ============
export function wrapSelection(this: MarkdownEditor, before: string, after: string): void {
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
const selected = ta.value.slice(start, end); const text = selected || 'text';
const inserted = before + text + after;
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end); ta.focus();
if (selected) { ta.selectionStart = start + before.length; ta.selectionEnd = start + before.length + text.length; }
else { ta.selectionStart = ta.selectionEnd = start + before.length; }
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
}
// ============ Line prefix toggling ============
export function toggleLinePrefix(this: MarkdownEditor, prefix: string): void {
const ta = this.textarea; const start = ta.selectionStart;
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
const lineEndPos = ta.value.indexOf('\n', start); const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos;
const line = ta.value.slice(lineStart, lineEnd);
const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/);
let newLine: string;
if (existingMatch && existingMatch[0] === prefix) newLine = line.slice(prefix.length);
else if (existingMatch) newLine = prefix + line.slice(existingMatch[0].length);
else newLine = prefix + line;
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
}
// ============ Block insertion ============
export function insertBlock(this: MarkdownEditor, text: string): void {
const ta = this.textarea; const start = ta.selectionStart;
const before = ta.value.slice(0, start); const needNL = before && !before.endsWith('\n');
const insert = (needNL ? '\n' : '') + text;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd); ta.focus();
const pos = start + insert.length; ta.selectionStart = ta.selectionEnd = pos;
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
}
export function insertLink(this: MarkdownEditor): void {
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || i18nT('link') || 'link';
const url = 'https://';
const insert = `[${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length;
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
}
export function insertImage(this: MarkdownEditor): void {
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || i18nT('image') || 'image';
const url = 'https://';
const insert = `![${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus();
ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length;
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
}
export function insertTable(this: MarkdownEditor, rows = 3, cols = 3): void {
const header = Array.from({ length: cols }, (_, i) => `${i18nT('tableCols') || '列'}${i + 1}`).join(' | ');
const sep = Array.from({ length: cols }, () => '---').join(' | ');
let md = `| ${header} |\n| ${sep} |\n`;
for (let r = 1; r < rows; r++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`;
insertBlock.call(this, '\n' + md);
}
// ============ Table auto-format ============
export function formatTable(this: MarkdownEditor): void {
const ta = this.textarea;
const start = ta.selectionStart;
const before = ta.value.substring(0, start);
const after = ta.value.substring(start);
const blockStart = before.lastIndexOf('\n\n');
const blockEnd = after.indexOf('\n\n');
const tableStart = blockStart === -1 ? 0 : blockStart + 2;
const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd;
const tableText = ta.value.substring(tableStart, tableEnd);
const lines = tableText.split('\n').filter((l) => l.includes('|'));
if (lines.length < 2) return;
const splitRow = (r: string) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
const allCells = lines.map(splitRow);
const colCount = Math.max(...allCells.map((c) => c.length));
const colWidths: number[] = Array(colCount).fill(3);
allCells.forEach((cells) => {
cells.forEach((cell, ci) => {
colWidths[ci] = Math.max(colWidths[ci], cell.trim().length);
});
});
const pad = (s: string, w: number) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); };
const formatted = allCells.map((cells) => {
const padded = [];
for (let ci = 0; ci < colCount; ci++) {
padded.push(pad((cells[ci] || '').trim(), colWidths[ci]));
}
return '| ' + padded.join(' | ') + ' |';
});
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
}
// ============ Prototype installation ============
export const installCommands = (proto: any): void => {
proto._wrapSelection = wrapSelection;
proto._toggleLinePrefix = toggleLinePrefix;
proto._insertBlock = insertBlock;
proto._insertLink = insertLink;
proto._insertImage = insertImage;
proto._insertTable = insertTable;
proto._formatTable = formatTable;
};
+3 -3
View File
@@ -104,9 +104,9 @@ export const DEFAULT_TOOLBAR: ToolbarItem[] = [
export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({ export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
value: '', value: '',
placeholder: '', placeholder: '',
mode: 'split' as EditMode, mode: 'split',
height: 400, height: 400,
toolbar: DEFAULT_TOOLBAR as ToolbarItem[] | false, toolbar: DEFAULT_TOOLBAR,
wordCount: true, wordCount: true,
autofocus: false, autofocus: false,
spellcheck: false, spellcheck: false,
@@ -121,7 +121,7 @@ export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
zenMode: false, zenMode: false,
wordWrap: true, wordWrap: true,
maxLength: 0, maxLength: 0,
theme: 'auto' as ThemeName, theme: 'auto',
locale: 'zh-CN', locale: 'zh-CN',
render: null, render: null,
highlight: null, highlight: null,
+104
View File
@@ -0,0 +1,104 @@
/**
* MetonaEditor Context Menu — right-click menu
* @module context-menu
* @version 0.2.5
*/
import { t as i18nT } from './i18n';
import type { MarkdownEditor } from './core';
export function registerContextMenu(this: MarkdownEditor, items: any[] = []): MarkdownEditor {
this._contextMenuItems = items;
return this;
}
export function bindContextMenu(this: MarkdownEditor): void {
if (!this.el) return;
const onContextMenu = (e: MouseEvent) => {
const existing = document.querySelector('.me-context-menu');
if (existing) existing.remove();
this._showContextMenu(e);
};
this.el.addEventListener('contextmenu', onContextMenu);
this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu));
}
export function showContextMenu(this: MarkdownEditor, e: MouseEvent): void {
e.preventDefault();
const ta = this.textarea;
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
const defaultItems: Array<{ label?: string; action?: string; shortcut?: string; sep?: boolean; disabled?: boolean; onClick?: () => void }> = [
{ label: i18nT('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
{ label: i18nT('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
{ sep: true },
{ label: i18nT('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
{ label: i18nT('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
{ label: i18nT('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
{ label: i18nT('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' },
];
const items = [...defaultItems];
if (this._contextMenuItems.length) {
items.push({ sep: true });
this._contextMenuItems.forEach((item) => items.push(item));
}
const menu = document.createElement('div');
menu.className = 'me-context-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
// Adjust if off-screen
requestAnimationFrame(() => {
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) menu.style.left = (e.clientX - rect.width) + 'px';
if (rect.bottom > window.innerHeight) menu.style.top = (e.clientY - rect.height) + 'px';
});
items.forEach((item) => {
if ((item as any).sep) { const sep = document.createElement('div'); sep.className = 'me-context-menu-sep'; menu.appendChild(sep); return; }
const el = document.createElement('div');
el.className = 'me-context-menu-item';
if (item.disabled) el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
if (item.disabled) return;
if (item.onClick) { item.onClick(); }
else if (item.action) this._execContextAction(item.action);
this._hideContextMenu();
});
menu.appendChild(el);
});
document.body.appendChild(menu);
const close = (ev: Event) => {
if (!menu.contains(ev.target as Node)) { this._hideContextMenu(); }
};
document.addEventListener('click', close, { once: true });
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') this._hideContextMenu(); }, { once: true });
}
export function hideContextMenu(this: MarkdownEditor): void {
const menu = document.querySelector('.me-context-menu');
if (menu) menu.remove();
}
export function execContextAction(this: MarkdownEditor, action: string): void {
const ta = this.textarea;
if (!ta) return;
switch (action) {
case 'undo': this.undo(); break;
case 'redo': this.redo(); break;
case 'cut': document.execCommand('cut'); break;
case 'copy': document.execCommand('copy'); break;
case 'paste': document.execCommand('paste'); break;
case 'selectAll': ta.focus(); ta.select(); break;
default: this.exec(action); break;
}
}
// ============ Prototype installation ============
export const installContextMenu = (proto: any): void => {
proto.registerContextMenu = registerContextMenu;
proto._bindContextMenu = bindContextMenu;
proto._showContextMenu = showContextMenu;
proto._hideContextMenu = hideContextMenu;
proto._execContextAction = execContextAction;
};
+69 -351
View File
@@ -13,6 +13,10 @@ import type { InstanceI18n } from './i18n';
import { parseMarkdown } from './parser'; import { parseMarkdown } from './parser';
import { presetPlugins, topologicalSort } from './plugins'; import { presetPlugins, topologicalSort } from './plugins';
import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes'; import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes';
import { installCommands } from './commands';
import { installFloatingToolbar } from './floating-toolbar';
import { installContextMenu } from './context-menu';
import { installOutline } from './outline';
const MODES: EditMode[] = ['edit', 'split', 'preview']; const MODES: EditMode[] = ['edit', 'split', 'preview'];
const TOAST_ICONS: Record<string, string> = { success: '✓', error: '✗', warning: '⚠', info: '' }; const TOAST_ICONS: Record<string, string> = { success: '✓', error: '✗', warning: '⚠', info: '' };
@@ -85,15 +89,37 @@ export class MarkdownEditor {
_floatingToolbar!: HTMLElement | null; _floatingToolbar!: HTMLElement | null;
_floatingEnabled!: boolean; _floatingEnabled!: boolean;
// Prototype-installed methods (see installers at bottom of file)
_wrapSelection!: (before: string, after: string) => void;
_toggleLinePrefix!: (prefix: string) => void;
_insertBlock!: (text: string) => void;
_insertLink!: () => void;
_insertImage!: () => void;
_insertTable!: (rows?: number, cols?: number) => void;
_formatTable!: () => void;
_initFloatingToolbar!: () => void;
toggleFloatingToolbar!: () => this;
isFloatingToolbar!: () => boolean;
_buildFloatingToolbar!: () => void;
_hideFloatingToolbar!: () => void;
registerContextMenu!: (items: any[]) => this;
_bindContextMenu!: () => void;
_showContextMenu!: (e: MouseEvent) => void;
_hideContextMenu!: () => void;
_execContextAction!: (action: string) => void;
_buildOutline!: () => void;
_updateOutline!: () => void;
_trackOutlineScroll!: () => void;
constructor(container: string | HTMLElement, options: EditorOptions = {}) { constructor(container: string | HTMLElement, options: EditorOptions = {}) {
if (!isBrowser()) { if (!isBrowser()) {
this._destroyed = true; this._value = options.value || ''; return this as any; this._destroyed = true; this._value = options.value || ''; return this;
} }
this.id = options.id || generateId(); this.id = options.id || generateId();
this.container = typeof container === 'string' ? document.querySelector(container) as HTMLElement : container; this.container = typeof container === 'string' ? document.querySelector(container) as HTMLElement : container;
if (!this.container) { if (!this.container) {
console.error('MeEditor: container not found:', container); console.error('MeEditor: container not found:', container);
this._destroyed = true; return this as any; this._destroyed = true; return this;
} }
this.config = { ...DEFAULTS, ...options }; this.config = { ...DEFAULTS, ...options };
if (options.style && typeof options.style === 'object') { if (options.style && typeof options.style === 'object') {
@@ -105,7 +131,7 @@ export class MarkdownEditor {
this._listeners = {}; this._renderRaf = null; this._historyTimer = null; this._listeners = {}; this._renderRaf = null; this._historyTimer = null;
this._fullscreen = false; this._destroyed = false; this._lastRenderedValue = null; this._fullscreen = false; this._destroyed = false; this._lastRenderedValue = null;
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {}; this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
this._outlineTimer = null; this._zenMode = false; this._wordWrap = true; this._syncing = false; this._outlineTimer = null; this._zenMode = this.config.zenMode === true; this._wordWrap = true; this._syncing = false;
this._zenMouseHandler = null; this._zenMouseHandler = null;
this._floatingToolbar = null; this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false; this._floatingEnabled = this.config.floatingToolbar !== false;
@@ -138,6 +164,7 @@ export class MarkdownEditor {
this._pushHistory(); this._pushHistory();
this._render(); this._render();
this._updateWordCount(); this._updateWordCount();
if (this._zenMode) this._setZen(true);
if (Array.isArray(this.config.plugins)) { if (Array.isArray(this.config.plugins)) {
const plugins = this.config.plugins.map((p: any) => { const plugins = this.config.plugins.map((p: any) => {
@@ -179,6 +206,7 @@ export class MarkdownEditor {
editorInner.appendChild(gutter); editorInner.appendChild(gutter);
const textarea = document.createElement('textarea'); textarea.className = 'me-textarea'; const textarea = document.createElement('textarea'); textarea.className = 'me-textarea';
textarea.spellcheck = !!this.config.spellcheck; textarea.readOnly = !!this.config.readOnly; textarea.spellcheck = !!this.config.spellcheck; textarea.readOnly = !!this.config.readOnly;
if (this.config.maxLength && this.config.maxLength > 0) textarea.maxLength = this.config.maxLength;
textarea.style.tabSize = String(this.config.tabSize || 2); textarea.style.tabSize = String(this.config.tabSize || 2);
textarea.placeholder = this.config.placeholder || i18nT('placeholder'); textarea.placeholder = this.config.placeholder || i18nT('placeholder');
textarea.setAttribute('aria-label', i18nT('edit')); textarea.setAttribute('aria-label', i18nT('edit'));
@@ -371,13 +399,13 @@ export class MarkdownEditor {
_saveDividerPosition(): void { _saveDividerPosition(): void {
try { try {
const epFlex = this.editorPane.style.flex; const epFlex = this.editorPane.style.flex;
if (epFlex) localStorage.setItem('metona-editor-divider', epFlex); if (epFlex) localStorage.setItem(`metona-editor-divider-${this.id}`, epFlex);
} catch (_) {} } catch (_) {}
} }
_restoreDividerPosition(): void { _restoreDividerPosition(): void {
try { try {
const saved = localStorage.getItem('metona-editor-divider'); const saved = localStorage.getItem(`metona-editor-divider-${this.id}`);
if (saved && this._mode === 'split') { if (saved && this._mode === 'split') {
this.editorPane.style.flex = saved; this.editorPane.style.flex = saved;
this.previewPane.style.flex = '1 1 auto'; this.previewPane.style.flex = '1 1 auto';
@@ -481,72 +509,16 @@ export class MarkdownEditor {
this._cleanups.push(() => { ta.removeEventListener('dragover', onDragOver); ta.removeEventListener('drop', onDrop); }); this._cleanups.push(() => { ta.removeEventListener('dragover', onDragOver); ta.removeEventListener('drop', onDrop); });
} }
_buildOutline(): void {
if (!this.config.outline || !this.previewEl) return;
const old = this.el.querySelector('.me-outline'); if (old) old.remove();
const headings: Array<{ level: number; id: string; text: string }> = [];
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m: RegExpExecArray | null;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') });
}
if (!headings.length) return;
const panel = document.createElement('div'); panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${i18nT('outline') || '大纲'}</div>`;
const buildTree = (items: typeof headings, minLevel: number): string => {
let h = '<ul>'; let i = 0;
while (i < items.length) {
const item = items[i]; if (item.level < minLevel) break;
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
const sub: typeof headings = []; let j = i + 1; while (j < items.length && items[j].level > item.level) { sub.push(items[j]); j++; }
if (sub.length) h += buildTree(sub, item.level + 1);
h += '</li>'; i = j > i + 1 ? j : i + 1;
}
return h + '</ul>';
};
panel.innerHTML += buildTree(headings, 1);
this.el.appendChild(panel);
panel.addEventListener('click', (e) => {
const a = (e.target as HTMLElement).closest('a'); if (!a) return; e.preventDefault();
const id = a.getAttribute('href')!.slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(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); } }
});
}
_updateOutline(): void { if (!this.config.outline) return; if (this._outlineTimer) clearTimeout(this._outlineTimer); this._outlineTimer = setTimeout(() => this._buildOutline(), 300); }
_trackOutlineScroll(): void {
if (!this.config.outline || !this.previewPane) return;
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
if (!this.el) return;
const panel = this.el.querySelector('.me-outline');
if (!panel) return;
const headings = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
let activeId = '';
const scrollTop = this.previewPane.scrollTop + 80; // offset for better UX
headings.forEach((h) => {
if ((h as HTMLElement).offsetTop <= scrollTop) {
activeId = h.id;
}
});
panel.querySelectorAll('a').forEach((a) => {
a.classList.toggle('me-outline-active', a.getAttribute('href') === '#' + activeId);
});
});
};
this.previewPane.addEventListener('scroll', onScroll, { passive: true });
this._cleanups.push(() => this.previewPane.removeEventListener('scroll', onScroll));
}
_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); }
_pushHistory(): void { _pushHistory(): void {
// Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write
// to the textarea directly and bypass the native maxlength attribute.
if (this.config.maxLength && this.config.maxLength > 0 && this._value.length > this.config.maxLength) {
this._value = this._value.slice(0, this.config.maxLength);
if (this.textarea) this.textarea.value = this._value;
}
const cur = this._history[this._historyIndex]; if (cur === this._value) return; const cur = this._history[this._historyIndex]; if (cur === this._value) return;
this._history = this._history.slice(0, this._historyIndex + 1); this._history.push(this._value); this._history = this._history.slice(0, this._historyIndex + 1); this._history.push(this._value);
const limit = this.config.historyLimit! > 0 ? this.config.historyLimit! : 100; const limit = this.config.historyLimit! > 0 ? this.config.historyLimit! : 100;
@@ -592,82 +564,6 @@ export class MarkdownEditor {
return this; return this;
} }
_wrapSelection(before: string, after: string): void {
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
const selected = ta.value.slice(start, end); const text = selected || 'text';
const inserted = before + text + after;
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end); ta.focus();
if (selected) { ta.selectionStart = start + before.length; ta.selectionEnd = start + before.length + text.length; }
else { ta.selectionStart = ta.selectionEnd = start + before.length; }
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
}
_toggleLinePrefix(prefix: string): void {
const ta = this.textarea; const start = ta.selectionStart;
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
const lineEndPos = ta.value.indexOf('\n', start); const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos;
const line = ta.value.slice(lineStart, lineEnd);
const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/);
let newLine: string;
if (existingMatch && existingMatch[0] === prefix) newLine = line.slice(prefix.length);
else if (existingMatch) newLine = prefix + line.slice(existingMatch[0].length);
else newLine = prefix + line;
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
}
_insertBlock(text: string): void {
const ta = this.textarea; const start = ta.selectionStart;
const before = ta.value.slice(0, start); const needNL = before && !before.endsWith('\n');
const insert = (needNL ? '\n' : '') + text;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd); ta.focus();
const pos = start + insert.length; ta.selectionStart = ta.selectionEnd = pos;
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
}
_insertLink(): void { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || i18nT('link') || 'link'; const url = 'https://'; const insert = `[${sel}](${url})`; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); }
_insertImage(): void { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || i18nT('image') || 'image'; const url = 'https://'; const insert = `![${sel}](${url})`; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); }
_insertTable(rows = 3, cols = 3): void { const header = Array.from({ length: cols }, (_, i) => `${i18nT('tableCols') || '列'}${i + 1}`).join(' | '); const sep = Array.from({ length: cols }, () => '---').join(' | '); let md = `| ${header} |\n| ${sep} |\n`; for (let r = 1; r < rows; r++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`; this._insertBlock('\n' + md); }
_formatTable(): void {
const ta = this.textarea;
const start = ta.selectionStart;
// Find table boundaries around cursor
const before = ta.value.substring(0, start);
const after = ta.value.substring(start);
const blockStart = before.lastIndexOf('\n\n');
const blockEnd = after.indexOf('\n\n');
const tableStart = blockStart === -1 ? 0 : blockStart + 2;
const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd;
const tableText = ta.value.substring(tableStart, tableEnd);
const lines = tableText.split('\n').filter((l) => l.includes('|'));
if (lines.length < 2) return; // need at least header + separator
// Parse columns
const splitRow = (r: string) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
const allCells = lines.map(splitRow);
const colCount = Math.max(...allCells.map((c) => c.length));
// Calculate max width per column
const colWidths: number[] = Array(colCount).fill(3);
allCells.forEach((cells) => {
cells.forEach((cell, ci) => {
colWidths[ci] = Math.max(colWidths[ci], cell.trim().length);
});
});
// Rebuild table
const pad = (s: string, w: number) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); };
const formatted = allCells.map((cells) => {
const padded = [];
for (let ci = 0; ci < colCount; ci++) {
padded.push(pad((cells[ci] || '').trim(), colWidths[ci]));
}
return '| ' + padded.join(' | ') + ' |';
});
// Replace in value
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);
}
setMode(mode: EditMode): this { if (!MODES.includes(mode) || mode === this._mode) return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit') this._render(); this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${i18nT(mode) || mode} mode`); if (typeof this.config.onModeChange === 'function') { try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); } } return this; } setMode(mode: EditMode): this { if (!MODES.includes(mode) || mode === this._mode) return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit') this._render(); this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${i18nT(mode) || mode} mode`); if (typeof this.config.onModeChange === 'function') { try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); } } return this; }
getMode(): EditMode { return this._mode; } getMode(): EditMode { return this._mode; }
@@ -687,19 +583,24 @@ export class MarkdownEditor {
isFullscreen(): boolean { return this._fullscreen; } isFullscreen(): boolean { return this._fullscreen; }
exitFullscreen(): this { if (this._fullscreen) this.toggleFullscreen(); return this; } exitFullscreen(): this { if (this._fullscreen) this.toggleFullscreen(); return this; }
toggleZen(): this { toggleZen(): this { this._setZen(!this._zenMode); return this; }
this._zenMode = !this._zenMode; if (this.el) this.el.classList.toggle('me-zen', this._zenMode);
_setZen(on: boolean): void {
this._zenMode = !!on; if (!this.el) return;
this.el.classList.toggle('me-zen', this._zenMode);
if (this._zenMode && this.toolbarEl) { if (this._zenMode && this.toolbarEl) {
this._zenMouseHandler = (e: MouseEvent) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; }; if (!this._zenMouseHandler) {
this._zenMouseHandler = (e: MouseEvent) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; };
document.addEventListener('mousemove', this._zenMouseHandler);
}
this.toolbarEl.style.transition = 'opacity 0.2s'; this.toolbarEl.style.opacity = '0'; this.toolbarEl.style.pointerEvents = 'none'; this.toolbarEl.style.transition = 'opacity 0.2s'; this.toolbarEl.style.opacity = '0'; this.toolbarEl.style.pointerEvents = 'none';
document.addEventListener('mousemove', this._zenMouseHandler);
if (this.statusEl) this.statusEl.style.display = 'none'; if (this.statusEl) this.statusEl.style.display = 'none';
} else { } else {
if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; } if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; }
if (this.toolbarEl) { this.toolbarEl.style.opacity = ''; this.toolbarEl.style.pointerEvents = ''; this.toolbarEl.style.transition = ''; } if (this.toolbarEl) { this.toolbarEl.style.opacity = ''; this.toolbarEl.style.pointerEvents = ''; this.toolbarEl.style.transition = ''; }
if (this.statusEl) this.statusEl.style.display = ''; if (this.statusEl) this.statusEl.style.display = '';
} }
this._emit('zenChange', this._zenMode); return this; this._emit('zenChange', this._zenMode);
} }
isZen(): boolean { return this._zenMode; } isZen(): boolean { return this._zenMode; }
@@ -707,121 +608,6 @@ 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; }
_initFloatingToolbar(): void {
if (typeof document === 'undefined') return;
// Inject floating toolbar CSS once globally
if (!document.getElementById('me-float-style')) {
const style = document.createElement('style');
style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`;
document.head.appendChild(style);
}
if (!this._floatingEnabled) return;
const ta = this.textarea;
let _lastSelStart = ta.selectionStart;
let _lastSelEnd = ta.selectionEnd;
const emitSelectionChange = () => {
const s = ta.selectionStart; const e = ta.selectionEnd;
if (s !== _lastSelStart || e !== _lastSelEnd) {
_lastSelStart = s; _lastSelEnd = e;
this._emit('selectionChange', { start: s, end: e, text: this._value.slice(s, e) });
}
};
const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return;
emitSelectionChange();
const start = ta.selectionStart; const end = ta.selectionEnd;
if (start === end) { this._hideFloatingToolbar(); return; }
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
const taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
const fontSize = parseFloat(taStyle.fontSize) || 13.5;
const charWidth = fontSize * 0.6;
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1;
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1));
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop;
const lineLocalTop = lineViewportTop - paneRect.top;
const toolbarHeight = 36;
const gap = 4;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap) top = lineLocalTop + lineHeight + gap;
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(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0));
ta.addEventListener('keyup', () => {
emitSelectionChange();
if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0);
else setTimeout(hide, 0);
this._emit('cursorMove', this.getCursorPosition());
});
ta.addEventListener('blur', () => setTimeout(hide, 300));
ta.addEventListener('click', () => {
emitSelectionChange();
if (ta.selectionStart === ta.selectionEnd) hide();
});
}
toggleFloatingToolbar(): this {
this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) {
if (this._floatingToolbar) {
if (this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
}
}
return this;
}
isFloatingToolbar(): boolean { return this._floatingEnabled; }
_buildFloatingToolbar(): void {
const bar = document.createElement('div');
bar.className = 'me-float-toolbar';
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => {
const btn = this._createBtn(action);
btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.stopPropagation();
this.exec(action);
// Keep selection after exec
setTimeout(() => this.textarea.focus(), 0);
});
bar.appendChild(btn);
});
this.editorPane.appendChild(bar);
this._floatingToolbar = bar;
}
_hideFloatingToolbar(): void {
if (this._floatingToolbar) {
this._floatingToolbar.classList.remove('me-visible');
}
}
_bindToolbarKeyboard(): void { _bindToolbarKeyboard(): void {
if (!this.toolbarEl) return; if (!this.toolbarEl) return;
const onKeydown = (e: KeyboardEvent) => { const onKeydown = (e: KeyboardEvent) => {
@@ -921,7 +707,7 @@ export class MarkdownEditor {
const re = new RegExp(escaped, flags); const re = new RegExp(escaped, flags);
const matches = this._value.match(re); const matches = this._value.match(re);
if (!matches) return 0; if (!matches) return 0;
this._value = 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._render(); this._updateWordCount(); this._emit('change', this._value);
return matches.length; return matches.length;
@@ -932,7 +718,7 @@ 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;
this._value = 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._render(); this._updateWordCount(); this._emit('change', this._value);
return matches.length; return matches.length;
@@ -949,9 +735,15 @@ export class MarkdownEditor {
return lines[idx]; return lines[idx];
} }
_limitLength(value: string): string {
const max = this.config.maxLength;
return max && max > 0 ? value.slice(0, max) : value;
}
getValue(): string { return this._destroyed ? '' : this._value; } getValue(): string { return this._destroyed ? '' : this._value; }
setValue(md: string, opts: { silent?: boolean } = {}): this { setValue(md: string, opts: { silent?: boolean } = {}): this {
if (this._destroyed) return this; if (this._destroyed) return this;
md = this._limitLength(md || '');
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', this._value, md); MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', this._value, md);
this._value = md || ''; this.textarea.value = this._value; this._value = md || ''; this.textarea.value = this._value;
if (!opts.silent) this._pushHistory(); this._render(); this._renderGutter(); this._updateWordCount(); this._updateOutline(); if (!opts.silent) this._pushHistory(); this._render(); this._renderGutter(); this._updateWordCount(); this._updateOutline();
@@ -982,8 +774,9 @@ export class MarkdownEditor {
insert(text: string, opts: { replace?: boolean } = {}): this { insert(text: string, opts: { replace?: boolean } = {}): this {
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
ta.value = ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start); ta.focus(); const inserted = this._limitLength(ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start));
ta.selectionStart = ta.selectionEnd = start + text.length; ta.value = inserted; ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return this; this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); 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; }
@@ -1033,89 +826,6 @@ export class MarkdownEditor {
configureToolbar(tools: ToolbarItem[]): this { if (!this.toolbarEl) return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; } configureToolbar(tools: ToolbarItem[]): this { if (!this.toolbarEl) return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; }
removeToolbarButton(action: string): this { if (!this.toolbarEl) return this; const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`); if (btn) btn.remove(); return this; } removeToolbarButton(action: string): this { if (!this.toolbarEl) return this; const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`); if (btn) btn.remove(); return this; }
registerContextMenu(items: any[] = []): this { this._contextMenuItems = items; return this; }
_bindContextMenu(): void {
if (!this.el) return;
const onContextMenu = (e: MouseEvent) => {
const existing = document.querySelector('.me-context-menu');
if (existing) existing.remove();
this._showContextMenu(e);
};
this.el.addEventListener('contextmenu', onContextMenu);
this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu));
}
_showContextMenu(e: MouseEvent): void {
e.preventDefault();
const ta = this.textarea;
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
const defaultItems: Array<{ label?: string; action?: string; shortcut?: string; sep?: boolean; disabled?: boolean; onClick?: () => void }> = [
{ label: i18nT('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
{ label: i18nT('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
{ sep: true },
{ label: i18nT('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
{ label: i18nT('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
{ label: i18nT('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
{ label: i18nT('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' },
];
const items = [...defaultItems];
if (this._contextMenuItems.length) {
items.push({ sep: true });
this._contextMenuItems.forEach((item) => items.push(item));
}
const menu = document.createElement('div');
menu.className = 'me-context-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
// Adjust if off-screen
requestAnimationFrame(() => {
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) menu.style.left = (e.clientX - rect.width) + 'px';
if (rect.bottom > window.innerHeight) menu.style.top = (e.clientY - rect.height) + 'px';
});
items.forEach((item) => {
if ((item as any).sep) { const sep = document.createElement('div'); sep.className = 'me-context-menu-sep'; menu.appendChild(sep); return; }
const el = document.createElement('div');
el.className = 'me-context-menu-item';
if (item.disabled) el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
if (item.disabled) return;
if (item.onClick) { item.onClick(); }
else if (item.action) this._execContextAction(item.action);
this._hideContextMenu();
});
menu.appendChild(el);
});
document.body.appendChild(menu);
const close = (ev: Event) => {
if (!menu.contains(ev.target as Node)) { this._hideContextMenu(); }
};
document.addEventListener('click', close, { once: true });
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') this._hideContextMenu(); }, { once: true });
}
_hideContextMenu(): void {
const menu = document.querySelector('.me-context-menu');
if (menu) menu.remove();
}
_execContextAction(action: string): void {
const ta = this.textarea;
if (!ta) return;
switch (action) {
case 'undo': this.undo(); break;
case 'redo': this.redo(); break;
case 'cut': document.execCommand('cut'); break;
case 'copy': document.execCommand('copy'); break;
case 'paste': document.execCommand('paste'); break;
case 'selectAll': ta.focus(); ta.select(); break;
default: this.exec(action); break;
}
}
toast(message: string, opts: { type?: string; duration?: number; animation?: string } = {}): this { toast(message: string, opts: { type?: string; duration?: number; animation?: string } = {}): this {
if (!this.el || typeof document === 'undefined') return this; if (!this.el || typeof document === 'undefined') return this;
const { type = 'info', duration = 3000, animation = 'fade' } = opts; const { type = 'info', duration = 3000, animation = 'fade' } = opts;
@@ -1143,6 +853,7 @@ export class MarkdownEditor {
if (this._historyTimer) clearTimeout(this._historyTimer); if (this._historyTimer) clearTimeout(this._historyTimer);
if (this._outlineTimer) clearTimeout(this._outlineTimer); if (this._outlineTimer) clearTimeout(this._outlineTimer);
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = []; this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = [];
if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; }
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;
@@ -1158,5 +869,12 @@ export class MarkdownEditor {
isDestroyed(): boolean { return this._destroyed; } isDestroyed(): boolean { return this._destroyed; }
} }
// Install split-out modules onto the prototype (keeps `this` binding and
// preserves the existing instance API surface for consumers and tests)
installCommands(MarkdownEditor.prototype);
installFloatingToolbar(MarkdownEditor.prototype);
installContextMenu(MarkdownEditor.prototype);
installOutline(MarkdownEditor.prototype);
export { MarkdownEditor as Editor }; export { MarkdownEditor as Editor };
export default MarkdownEditor; export default MarkdownEditor;
+133
View File
@@ -0,0 +1,133 @@
/**
* MetonaEditor Floating Toolbar selection format toolbar
* @module floating-toolbar
* @version 0.2.5
*/
import type { MarkdownEditor } from './core';
export function initFloatingToolbar(this: MarkdownEditor): void {
if (typeof document === 'undefined') return;
// Inject floating toolbar CSS once globally
if (!document.getElementById('me-float-style')) {
const style = document.createElement('style');
style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`;
document.head.appendChild(style);
}
if (!this._floatingEnabled) return;
const ta = this.textarea;
let _lastSelStart = ta.selectionStart;
let _lastSelEnd = ta.selectionEnd;
const emitSelectionChange = () => {
const s = ta.selectionStart; const e = ta.selectionEnd;
if (s !== _lastSelStart || e !== _lastSelEnd) {
_lastSelStart = s; _lastSelEnd = e;
this._emit('selectionChange', { start: s, end: e, text: this._value.slice(s, e) });
}
};
const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return;
emitSelectionChange();
const start = ta.selectionStart; const end = ta.selectionEnd;
if (start === end) { this._hideFloatingToolbar(); return; }
if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar();
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
const taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paddingTop = parseFloat(taStyle.paddingTop) || 16;
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
const fontSize = parseFloat(taStyle.fontSize) || 13.5;
const charWidth = fontSize * 0.6;
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1;
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1));
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop;
const lineLocalTop = lineViewportTop - paneRect.top;
const toolbarHeight = 36;
const gap = 4;
let top = lineLocalTop - toolbarHeight - gap;
if (top < gap) top = lineLocalTop + lineHeight + gap;
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(); };
ta.addEventListener('mouseup', () => setTimeout(show, 0));
ta.addEventListener('keyup', () => {
emitSelectionChange();
if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0);
else setTimeout(hide, 0);
this._emit('cursorMove', this.getCursorPosition());
});
ta.addEventListener('blur', () => setTimeout(hide, 300));
ta.addEventListener('click', () => {
emitSelectionChange();
if (ta.selectionStart === ta.selectionEnd) hide();
});
}
export function toggleFloatingToolbar(this: MarkdownEditor): MarkdownEditor {
this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) {
if (this._floatingToolbar) {
if (this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
}
}
return this;
}
export function isFloatingToolbar(this: MarkdownEditor): boolean { return this._floatingEnabled; }
export function buildFloatingToolbar(this: MarkdownEditor): void {
const bar = document.createElement('div');
bar.className = 'me-float-toolbar';
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => {
const btn = this._createBtn(action);
btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.stopPropagation();
this.exec(action);
// Keep selection after exec
setTimeout(() => this.textarea.focus(), 0);
});
bar.appendChild(btn);
});
this.editorPane.appendChild(bar);
this._floatingToolbar = bar;
}
export function hideFloatingToolbar(this: MarkdownEditor): void {
if (this._floatingToolbar) {
this._floatingToolbar.classList.remove('me-visible');
}
}
// ============ Prototype installation ============
export const installFloatingToolbar = (proto: any): void => {
proto._initFloatingToolbar = initFloatingToolbar;
proto.toggleFloatingToolbar = toggleFloatingToolbar;
proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar;
};
+224
View File
@@ -0,0 +1,224 @@
/**
* MetonaEditor Highlight built-in lightweight syntax highlighter
* @module highlight
* @version 0.2.5
*
* Zero-dependency tokenizer for common languages. Safe by construction:
* input is HTML-escaped first, then annotated with <span> classes on the
* escaped text (no raw HTML can leak through).
*/
/**
* Environment-agnostic escaping for tokenizer input.
* Unlike the `escapeHTML` util (whose quote handling differs between browser
* DOM and Node), we only escape `& < >` here so string/comment token rules
* behave identically in both environments. Quote characters are safe inside
* text nodes and do not need escaping.
*/
const escapeText = (s: string): string => {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
};
// ============ Token rules ============
interface HighlightRule {
cls: string;
re: RegExp;
}
export interface LanguageDef {
keywords: string[];
builtins: string[];
hasBlocks?: boolean; // /* */ block comments
hasHashComments?: boolean; // # comments (python/bash)
hasTemplateStrings?: boolean; // `...` (js/ts)
singleQuoteStrings?: boolean;
}
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildRules = (lang: LanguageDef): HighlightRule[] => {
const rules: HighlightRule[] = [];
// Comments (highest priority; protects keywords/strings inside)
if (lang.hasHashComments) {
rules.push({ cls: 'me-hl-comment', re: /#[^\n]*/y });
} else {
rules.push({ cls: 'me-hl-comment', re: /\/\/[^\n]*/y });
}
if (lang.hasBlocks) rules.push({ cls: 'me-hl-comment', re: /\/\*[\s\S]*?\*\//y });
// Strings (double + single quotes with backslash escapes)
const strRe = lang.singleQuoteStrings === false
? /"(?:\\.|[^"\\\n])*"/y
: /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/y;
rules.push({ cls: 'me-hl-string', re: strRe });
if (lang.hasTemplateStrings) rules.push({ cls: 'me-hl-string', re: /`(?:\\.|[^`\\])*`/y });
// Numbers
rules.push({ cls: 'me-hl-number', re: /\b0x[\da-fA-F_]+\b|\b\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?\b/y });
// Keywords
if (lang.keywords.length) {
rules.push({ cls: 'me-hl-keyword', re: new RegExp(`\\b(?:${lang.keywords.map(escapeRe).join('|')})\\b`, 'y') });
}
// Builtins / globals
if (lang.builtins.length) {
rules.push({ cls: 'me-hl-builtin', re: new RegExp(`\\b(?:${lang.builtins.map(escapeRe).join('|')})\\b`, 'y') });
}
// Function calls: identifier immediately followed by (
rules.push({ cls: 'me-hl-function', re: /[A-Za-z_$][\w$]*(?=\s*\()/y });
return rules;
};
const tokenize = (text: string, rules: HighlightRule[]): string => {
let out = '';
let pos = 0;
const len = text.length;
while (pos < len) {
let matched = false;
for (const rule of rules) {
rule.re.lastIndex = pos;
const m = rule.re.exec(text);
if (m && m.index === pos && m[0].length > 0) {
out += `<span class="${rule.cls}">${m[0]}</span>`;
pos += m[0].length;
matched = true;
break;
}
}
if (!matched) {
out += text[pos];
pos++;
}
}
return out;
};
// ============ Language definitions ============
const LANGUAGES: Record<string, LanguageDef> = {
javascript: {
keywords: ['async','await','break','case','catch','class','const','continue','debugger','default','delete','do','else','export','extends','finally','for','from','function','get','if','import','in','instanceof','let','new','of','return','set','static','super','switch','this','throw','try','typeof','var','void','while','with','yield'],
builtins: ['console','document','window','globalThis','Math','JSON','Promise','Object','Array','String','Number','Boolean','Map','Set','Symbol','RegExp','Date','Error','parseInt','parseFloat','isNaN','setTimeout','setInterval','fetch','require','module','exports','process','Buffer'],
hasBlocks: true,
hasTemplateStrings: true,
},
typescript: {
keywords: ['abstract','any','as','async','await','boolean','break','case','catch','class','const','continue','debugger','declare','default','delete','do','else','enum','export','extends','finally','for','from','function','get','if','implements','import','in','infer','instanceof','interface','is','keyof','let','namespace','never','new','of','override','private','protected','public','readonly','return','satisfies','set','static','string','super','switch','symbol','this','throw','try','type','typeof','undefined','unique','unknown','var','void','while','with','yield'],
builtins: ['console','document','window','globalThis','Math','JSON','Promise','Object','Array','String','Number','Boolean','Map','Set','Symbol','RegExp','Date','Error','parseInt','parseFloat','setTimeout','setInterval','fetch','require','module','exports','process','Buffer'],
hasBlocks: true,
hasTemplateStrings: true,
},
jsx: {
keywords: ['async','await','break','case','catch','class','const','continue','default','do','else','export','extends','finally','for','from','function','if','import','in','instanceof','let','new','of','return','static','super','switch','this','throw','try','typeof','var','void','while','with','yield','useState','useEffect','useRef','useMemo','useCallback'],
builtins: ['console','document','window','Math','JSON','Promise','Object','Array','String','Number','Map','Set','React'],
hasBlocks: true,
hasTemplateStrings: true,
},
tsx: {
keywords: ['abstract','any','as','async','await','boolean','break','case','catch','class','const','continue','default','declare','do','else','enum','export','extends','finally','for','from','function','if','implements','import','in','infer','instanceof','interface','is','keyof','let','namespace','never','new','of','override','private','protected','public','readonly','return','static','string','super','switch','symbol','this','throw','try','type','typeof','undefined','unknown','var','void','while','with','yield','useState','useEffect','useRef'],
builtins: ['console','document','window','Math','JSON','Promise','Object','Array','String','Number','Map','Set','React'],
hasBlocks: true,
hasTemplateStrings: true,
},
python: {
keywords: ['and','as','assert','async','await','break','class','continue','def','del','elif','else','except','False','finally','for','from','global','if','import','in','is','lambda','None','nonlocal','not','or','pass','raise','return','True','try','while','with','yield'],
builtins: ['print','len','range','type','str','int','float','list','dict','set','tuple','bool','enumerate','zip','map','filter','sum','min','max','abs','round','open','input','repr','sorted','object','super','self'],
hasHashComments: true,
},
bash: {
keywords: ['if','then','else','elif','fi','for','while','until','do','done','case','esac','function','in','return','break','continue','export','local','readonly','set','unset','shift','source','alias','declare','echo','exit'],
builtins: ['echo','printf','cd','pwd','ls','mkdir','rm','cp','mv','cat','grep','sed','awk','curl','wget','npm','node','git','docker','sudo','find','xargs','tar','zip','unzip','chmod','chown','touch','head','tail','wc','sort','uniq','cut','tee','ps','kill','top','clear','history','man','which'],
hasHashComments: true,
singleQuoteStrings: false,
},
shell: { hasHashComments: true, keywords: ['if','then','else','elif','fi','for','while','do','done','case','in','function','return','break','continue','export','local','echo','exit'], builtins: ['echo','cd','pwd','ls','rm','cp','mv','cat','grep','sed','awk','curl','wget','git','sudo','find','chmod','chown','touch','head','tail','wc','sort','tee','ps','kill','clear','man','which'], singleQuoteStrings: false },
css: {
keywords: ['important','inherit','initial','unset','none','auto','absolute','relative','fixed','static','sticky','flex','grid','block','inline','inline-block','hidden','visible','bold','normal','italic','solid','dashed','dotted','transparent','center','left','right','top','bottom','wrap','nowrap','repeat','cover','contain','pointer','auto'],
builtins: ['var','calc','min','max','clamp','rgb','rgba','hsl','hsla','url','linear-gradient','radial-gradient','translate','scale','rotate','repeat','minmax','fit-content','attr','counter'],
hasBlocks: true,
singleQuoteStrings: false,
},
html: {
keywords: ['html','head','body','div','span','p','a','img','ul','ol','li','table','thead','tbody','tr','th','td','h1','h2','h3','h4','h5','h6','section','article','nav','header','footer','main','aside','form','input','button','select','option','textarea','label','script','style','link','meta','title','iframe','video','audio','canvas','svg','strong','em','code','pre','blockquote','br','hr','template'],
builtins: [],
singleQuoteStrings: false,
},
json: {
keywords: ['true','false','null'],
builtins: [],
singleQuoteStrings: false,
},
yaml: {
keywords: ['true','false','null','yes','no','on','off'],
builtins: [],
hasHashComments: true,
singleQuoteStrings: false,
},
markdown: {
keywords: [],
builtins: [],
hasHashComments: true,
singleQuoteStrings: false,
},
md: { keywords: [], builtins: [], hasHashComments: true, singleQuoteStrings: false },
java: {
keywords: ['abstract','boolean','break','byte','case','catch','char','class','const','continue','default','do','double','else','enum','extends','final','finally','float','for','goto','if','implements','import','instanceof','int','interface','long','native','new','package','private','protected','public','return','short','static','strictfp','super','switch','synchronized','this','throw','throws','transient','try','void','volatile','while','var'],
builtins: ['System','String','Integer','Double','Math','Object','Class','Exception','Thread','Arrays','List','ArrayList','Map','HashMap','Set','HashSet','Optional','Stream','Collectors','Objects'],
hasBlocks: true,
},
go: {
keywords: ['break','case','chan','const','continue','default','defer','else','fallthrough','for','func','go','goto','if','import','interface','map','package','range','return','select','struct','switch','type','var'],
builtins: ['fmt','len','cap','make','new','append','copy','panic','recover','error','string','int','int64','uint','bool','byte','rune','float64','nil','print','println','Close','Error'],
hasBlocks: true,
singleQuoteStrings: false,
},
rust: {
keywords: ['as','async','await','break','const','continue','crate','dyn','else','enum','extern','false','fn','for','if','impl','in','let','loop','match','mod','move','mut','pub','ref','return','self','Self','static','struct','super','trait','true','type','unsafe','use','where','while'],
builtins: ['println','print','eprintln','format','vec','String','str','Option','Result','Some','None','Ok','Err','Vec','Box','Rc','Arc','HashMap','Iterator','match'],
hasBlocks: true,
singleQuoteStrings: false,
},
};
// ============ Public API ============
const aliasMap: Record<string, string> = {
js: 'javascript', ts: 'typescript', tsx: 'tsx', jsx: 'jsx',
py: 'python', sh: 'bash', shell: 'shell', zsh: 'bash',
yml: 'yaml', mjs: 'javascript', cjs: 'javascript', md: 'markdown',
htm: 'html', c: 'go', cpp: 'go', cs: 'java',
};
/** Map a code-block language hint to the canonical language name */
export const normalizeLanguage = (lang: string): string => {
const l = (lang || '').trim().toLowerCase();
if (!l) return '';
return aliasMap[l] || (LANGUAGES[l] ? l : '');
};
/** Highlight code with the built-in tokenizer. Falls back to escaped plain text. */
export const highlight = (code: string, lang: string): string => {
const canonical = normalizeLanguage(lang);
const def = LANGUAGES[canonical];
const escaped = escapeText(code);
if (!def) return escaped;
return tokenize(escaped, buildRules(def));
};
/** Register or override a language definition */
export const registerLanguage = (name: string, def: LanguageDef): void => {
LANGUAGES[name.toLowerCase()] = def;
};
export const getSupportedLanguages = (): string[] => Object.keys(LANGUAGES);
export const highlightUtils = {
highlight, normalizeLanguage, registerLanguage, getSupportedLanguages,
};
export default highlight;
+1 -1
View File
@@ -27,7 +27,7 @@ const pluralRules: Record<string, PluralRule> = {
const getPluralForm = (locale: string, count: number): string => { const getPluralForm = (locale: string, count: number): string => {
const lang = locale.split('-')[0].toLowerCase(); const lang = locale.split('-')[0].toLowerCase();
const rule = pluralRules[lang] || pluralRules.en!; const rule = pluralRules[lang] || pluralRules.en;
return rule(Math.abs(count)); return rule(Math.abs(count));
}; };
+6 -3
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Type-safe, lightweight Markdown Editor * MetonaEditor Type-safe, lightweight Markdown Editor
* @module metona-editor * @module metona-editor
* @version 0.2.4 * @version 0.2.5
*/ */
import { MarkdownEditor } from './core'; import { MarkdownEditor } from './core';
@@ -10,10 +10,11 @@ import { themeUtils, exportCSSVars, getCSSVariable, followExternalTheme, adoptFr
import { i18nUtils, createInstanceI18n, loadRemote } from './i18n'; import { i18nUtils, createInstanceI18n, loadRemote } from './i18n';
import { pluginUtils, presetPlugins, topologicalSort, validateConfig } from './plugins'; import { pluginUtils, presetPlugins, topologicalSort, validateConfig } from './plugins';
import { animationUtils } from './animations'; import { animationUtils } from './animations';
import { highlight, normalizeLanguage, registerLanguage, getSupportedLanguages } from './highlight';
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants'; import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants';
import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants'; import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants';
const VERSION = '0.2.4'; const VERSION = '0.2.5';
const globalPlugins: any[] = []; const globalPlugins: any[] = [];
@@ -61,6 +62,7 @@ const api = {
MarkdownEditor, Editor: MarkdownEditor, MarkdownEditor, Editor: MarkdownEditor,
create, use, on, off, setTheme, setLocale, destroy, getStatus, create, use, on, off, setTheme, setLocale, destroy, getStatus,
parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler, parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler,
highlight, normalizeLanguage, registerLanguage, getSupportedLanguages,
themes: themeUtils, i18n: i18nUtils, animations: animationUtils, plugins: pluginUtils, presetPlugins, themes: themeUtils, i18n: i18nUtils, animations: animationUtils, plugins: pluginUtils, presetPlugins,
topologicalSort, validateConfig, createInstanceI18n, loadRemote, topologicalSort, validateConfig, createInstanceI18n, loadRemote,
exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme,
@@ -70,4 +72,5 @@ const api = {
if (typeof window !== 'undefined') { (window as any).MeEditor = api; } if (typeof window !== 'undefined') { (window as any).MeEditor = api; }
export default api; export default api;
export { api, api as meEditor, api as MeEditor, MarkdownEditor, MarkdownEditor as Editor, create, use, on, off, setTheme, setLocale, destroy, getStatus, parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler, themeUtils, i18nUtils, pluginUtils, presetPlugins, animationUtils, topologicalSort, validateConfig, createInstanceI18n, loadRemote, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme, DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS, VERSION, }; export { api, api as meEditor, api as MeEditor, MarkdownEditor, MarkdownEditor as Editor, create, use, on, off, setTheme, setLocale, destroy, getStatus, parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler, highlight, normalizeLanguage, registerLanguage, getSupportedLanguages, themeUtils, i18nUtils, pluginUtils, presetPlugins, animationUtils, topologicalSort, validateConfig, createInstanceI18n, loadRemote, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme, DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS, VERSION, };
+6
View File
@@ -30,6 +30,7 @@ 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',
}, },
'en-US': { 'en-US': {
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
@@ -56,6 +57,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: 'Render failed', renderError: 'Render failed',
outline: 'Outline', outline: 'Outline',
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All', cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)',
}, },
ja: { ja: {
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線', bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
@@ -82,6 +84,7 @@ 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',
}, },
ko: { ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -108,6 +111,7 @@ 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',
}, },
fr: { fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
@@ -134,6 +138,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: 'Échec du rendu', renderError: 'Échec du rendu',
outline: 'Plan', outline: 'Plan',
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner', cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)',
}, },
de: { de: {
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen', bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
@@ -160,5 +165,6 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: 'Rendern fehlgeschlagen', renderError: 'Rendern fehlgeschlagen',
outline: 'Gliederung', outline: 'Gliederung',
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen', cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)',
}, },
}; };
+84
View File
@@ -0,0 +1,84 @@
/**
* MetonaEditor Outline heading navigation panel
* @module outline
* @version 0.2.5
*/
import { t as i18nT } from './i18n';
import { escapeHTML } from './utils';
import type { MarkdownEditor } from './core';
export function buildOutline(this: MarkdownEditor): void {
if (!this.config.outline || !this.previewEl) return;
const old = this.el.querySelector('.me-outline'); if (old) old.remove();
const headings: Array<{ level: number; id: string; text: string }> = [];
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
let m: RegExpExecArray | null;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') });
}
if (!headings.length) return;
const panel = document.createElement('div'); panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${i18nT('outline') || '大纲'}</div>`;
const buildTree = (items: typeof headings, minLevel: number): string => {
let h = '<ul>'; let i = 0;
while (i < items.length) {
const item = items[i]; if (item.level < minLevel) break;
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
const sub: typeof headings = []; let j = i + 1; while (j < items.length && items[j].level > item.level) { sub.push(items[j]); j++; }
if (sub.length) h += buildTree(sub, item.level + 1);
h += '</li>'; i = j > i + 1 ? j : i + 1;
}
return h + '</ul>';
};
panel.innerHTML += buildTree(headings, 1);
this.el.appendChild(panel);
panel.addEventListener('click', (e) => {
const a = (e.target as HTMLElement).closest('a'); if (!a) return; e.preventDefault();
const id = a.getAttribute('href')!.slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(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); } }
});
}
export function updateOutline(this: MarkdownEditor): void {
if (!this.config.outline) return;
if (this._outlineTimer) clearTimeout(this._outlineTimer);
this._outlineTimer = setTimeout(() => this._buildOutline(), 300);
}
export function trackOutlineScroll(this: MarkdownEditor): void {
if (!this.config.outline || !this.previewPane) return;
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
if (!this.el) return;
const panel = this.el.querySelector('.me-outline');
if (!panel) return;
const headings = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
let activeId = '';
const scrollTop = this.previewPane.scrollTop + 80; // offset for better UX
headings.forEach((h) => {
if ((h as HTMLElement).offsetTop <= scrollTop) {
activeId = h.id;
}
});
panel.querySelectorAll('a').forEach((a) => {
a.classList.toggle('me-outline-active', a.getAttribute('href') === '#' + activeId);
});
});
};
this.previewPane.addEventListener('scroll', onScroll, { passive: true });
this._cleanups.push(() => this.previewPane.removeEventListener('scroll', onScroll));
}
// ============ Prototype installation ============
export const installOutline = (proto: any): void => {
proto._buildOutline = buildOutline;
proto._updateOutline = updateOutline;
proto._trackOutlineScroll = trackOutlineScroll;
};
+32 -17
View File
@@ -4,7 +4,7 @@
* @version 0.2.0 * @version 0.2.0
*/ */
import { escapeHTML } from './utils'; import { escapeHTML, escapeAttr } from './utils';
import type { RenderEnv } from './constants'; import type { RenderEnv } from './constants';
// ============ Types ============ // ============ Types ============
@@ -137,6 +137,25 @@ const slugify = (text: string): string => {
return slug || 'heading'; return slug || 'heading';
}; };
/** Restore backslash-escaped punctuation in title text: \" -> " */
const unescapePunct = (text: string): string => text.replace(/\\([!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])/g, '$1');
/**
* Safe double-quoted attribute value. Inline-sourced text is already HTML-escaped
* (entities are safe inside quoted attributes and decode correctly), so only the
* quote character that would terminate the attribute needs escaping.
*/
const attrSafe = (v: string): string => v.replace(/"/g, '&quot;').replace(/\r?\n/g, ' ');
/**
* Same as `attrSafe` but also restores backslash-escaped punctuation first
* (used for title attributes where \" is a markdown escape).
*/
const titleAttr = (title: string | undefined): string => {
if (!title) return '';
return ` title="${unescapePunct(title).replace(/"/g, '&quot;').replace(/\r?\n/g, ' ')}"`;
};
// ============ Block handler registry ============ // ============ Block handler registry ============
const blockHandlers: BlockHandler[] = []; const blockHandlers: BlockHandler[] = [];
@@ -449,9 +468,9 @@ const parseList = (
const nOl = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`)); const nOl = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`));
if (nUl || nOl) { if (nUl || nOl) {
const isNOl = !!nOl; const isNOl = !!nOl;
const nM = isNOl ? nOl![2] : nUl![2]; const nM = isNOl ? nOl[2] : nUl![2];
const nI = isNOl ? nOl![1].length : nUl![1].length; const nI = isNOl ? nOl[1].length : nUl![1].length;
const nS = isNOl ? parseInt(nOl![2], 10) : undefined; const nS = isNOl ? parseInt(nOl[2], 10) : undefined;
const nested = parseList(lines, i, nM, nI, isNOl, nS); const nested = parseList(lines, i, nM, nI, isNOl, nS);
subTokens.push({ type: isNOl ? 'ol' : 'ul', items: nested.items, start: nS }); subTokens.push({ type: isNOl ? 'ol' : 'ul', items: nested.items, start: nS });
i = nested.endIdx; i = nested.endIdx;
@@ -538,8 +557,8 @@ const renderListItem = (it: ListItem, env: RenderEnv, idx?: number): string => {
const renderCode = (code: string, lang: string, env: RenderEnv, attrs?: Record<string, string>): string => { const renderCode = (code: string, lang: string, env: RenderEnv, attrs?: Record<string, string>): string => {
if (lang === 'mermaid') return `<div class="me-mermaid"><pre class="mermaid">${escapeHTML(code)}</pre></div>`; if (lang === 'mermaid') return `<div class="me-mermaid"><pre class="mermaid">${escapeHTML(code)}</pre></div>`;
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : ''; const langClass = lang ? ` class="language-${escapeAttr(lang)}"` : '';
const titleHtml = attrs?.title ? `<div class="me-code-title">${escapeHTML(attrs.title)}</div>` : ''; const titleHtml = attrs?.title ? `<div class="me-code-title">${escapeAttr(attrs.title)}</div>` : '';
if (env.highlight && typeof env.highlight === 'function' && lang) { if (env.highlight && typeof env.highlight === 'function' && lang) {
try { try {
const highlighted = env.highlight(code, lang); const highlighted = env.highlight(code, lang);
@@ -682,8 +701,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/); const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m) return match; if (!m) return match;
const u = safeUrl(m[2]); if (!u) return escapeHTML(match); const u = safeUrl(m[2]); if (!u) return escapeHTML(match);
const t = m[3] ? ` title="${m[3]}"` : ''; return `<img src="${attrSafe(u)}" alt="${attrSafe(m[1])}"${titleAttr(m[3])} loading="lazy"/>`;
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
} }
if (match.startsWith('![') && match.includes('][')) { if (match.startsWith('![') && match.includes('][')) {
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/); const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
@@ -693,19 +711,17 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
try { try {
const ref = JSON.parse(env.refs[refKey]); const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url); if (!u) return escapeHTML(match); const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
const t = ref.title ? ` title="${ref.title}"` : ''; return `<img src="${attrSafe(u)}" alt="${attrSafe(m[1])}"${titleAttr(escapeHTML(ref.title))} loading="lazy"/>`;
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`; } catch (_) { return `<img src="" alt="${attrSafe(m[1])}" class="me-img-ref"/>`; }
} catch (_) { return `<img src="" alt="${m[1]}" class="me-img-ref"/>`; }
} }
return `<img src="" alt="${m[1]}" class="me-img-ref"/>`; return `<img src="" alt="${attrSafe(m[1])}" class="me-img-ref"/>`;
} }
if (match.startsWith('[') && match.includes('](')) { if (match.startsWith('[') && match.includes('](')) {
const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/); const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m) return match; if (!m) return match;
const u = safeUrl(m[2]); if (!u) return match; const u = safeUrl(m[2]); if (!u) return match;
const t = m[3] ? ` title="${m[3]}"` : '';
const linkText = renderInline(m[1], env); const linkText = renderInline(m[1], env);
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${linkText}</a>`; return `<a href="${attrSafe(u)}"${titleAttr(m[3])} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
} }
if (match.startsWith('[') && match.includes('][')) { if (match.startsWith('[') && match.includes('][')) {
const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/); const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/);
@@ -715,8 +731,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
try { try {
const ref = JSON.parse(env.refs[refKey]); const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url); if (!u) return escapeHTML(match); const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
const t = ref.title ? ` title="${ref.title}"` : ''; return `<a href="${attrSafe(u)}"${titleAttr(escapeHTML(ref.title))} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
} catch (_) { return escapeHTML(match); } } catch (_) { return escapeHTML(match); }
} }
return escapeHTML(match); return escapeHTML(match);
@@ -724,7 +739,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
if (match.startsWith('&lt;http')) { if (match.startsWith('&lt;http')) {
const url = match.slice(4, -4); const url = match.slice(4, -4);
const u = safeUrl(url); const u = safeUrl(url);
return `<a href="${u}" target="_blank" rel="noopener noreferrer">${url}</a>`; return `<a href="${attrSafe(u)}" target="_blank" rel="noopener noreferrer">${url}</a>`;
} }
if (match.startsWith('**')) return `<strong>${match.slice(2, -2)}</strong>`; if (match.startsWith('**')) return `<strong>${match.slice(2, -2)}</strong>`;
if (match.startsWith('__')) return `<strong>${match.slice(2, -2)}</strong>`; if (match.startsWith('__')) return `<strong>${match.slice(2, -2)}</strong>`;
+194 -87
View File
@@ -6,14 +6,51 @@
import { t } from './i18n'; import { t } from './i18n';
// ============ Editor contract ============
/** The editor surface plugins may rely on. Keeps plugin code type-checked
* without importing the full MarkdownEditor class (avoids circular imports). */
export interface EditorLike {
id?: string;
value?: string;
_value?: string;
el: HTMLElement;
textarea: HTMLTextAreaElement;
config?: Record<string, any>;
on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void;
off?: (name: string, fn: (...args: any[]) => void) => unknown;
_emit?: (name: string, ...args: any[]) => void;
_pushHistory?: () => void;
_render?: () => void;
_updateWordCount?: () => void;
insert?: (text: string, opts?: { replace?: boolean }) => unknown;
setValue?: (value: string, opts?: { silent?: boolean }) => unknown;
getValue?: () => string;
getHTML?: () => string;
focus?: () => unknown;
toast?: (message: string, opts?: { type?: string; duration?: number; animation?: string }) => unknown;
}
// ============ Plugin state keys (Symbols avoid property-name collisions) ============
const K_AUTOSAVE = Symbol('me-plugin:autoSave');
const K_SEARCH = Symbol('me-plugin:searchReplace');
const K_IMAGE_PASTE = Symbol('me-plugin:imagePaste');
const K_SHORTCUT = Symbol('me-plugin:shortcutHelp');
const K_FILESYSTEM = Symbol('me-plugin:fileSystem');
const pluginState = <T>(editor: EditorLike, key: symbol): T | undefined => (editor as any)[key] as T | undefined;
const setPluginState = <T>(editor: EditorLike, key: symbol, state: T): void => { (editor as any)[key] = state; };
const deletePluginState = (editor: EditorLike, key: symbol): void => { delete (editor as any)[key]; };
export interface Plugin { export interface Plugin {
name: string; name: string;
version?: string; version?: string;
description?: string; description?: string;
depends?: string[]; depends?: string[];
priority?: number; priority?: number;
install?: (editor: any) => void | Promise<void>; install?: (editor: EditorLike, options?: any) => void | Promise<void>;
destroy?: (editor: any) => void; destroy?: (editor: EditorLike) => void;
[key: string]: any; [key: string]: any;
} }
@@ -118,22 +155,24 @@ const autoSavePlugin: Plugin = {
const state = { _timer: null as ReturnType<typeof setTimeout> | null }; const state = { _timer: null as ReturnType<typeof setTimeout> | null };
const save = () => { const save = () => {
if (state._timer) { clearTimeout(state._timer); state._timer = null; } if (state._timer) { clearTimeout(state._timer); state._timer = null; }
try { localStorage.setItem(key, editor.getValue()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue() }); } try { localStorage.setItem(key, editor.getValue!()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue!() }); }
catch (e) { console.warn('MeEditor autoSave:', e); } catch (e) { console.warn('MeEditor autoSave:', e); }
}; };
const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); }; const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); };
const _onBlur = save; const _onBlur = save;
const _onSave = save; const _onSave = save;
editor.on('change', _onInput); if (typeof editor.on === 'function') {
editor.on('blur', _onBlur); editor.on('change', _onInput);
editor.on('save', _onSave); editor.on('blur', _onBlur);
editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null) editor.setValue(v); return v; } catch (_) { return null; } }; editor.on('save', _onSave);
editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; }; }
editor.getDraftKey = () => key; (editor as any).restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null && typeof editor.setValue === 'function') editor.setValue(v); return v; } catch (_) { return null; } };
(editor as any).__autoSaveCleanup = { state, _onInput, _onBlur, _onSave, save }; (editor as any).clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; };
(editor as any).getDraftKey = () => key;
setPluginState(editor, K_AUTOSAVE, { state, _onInput, _onBlur, _onSave, save });
}, },
destroy(editor) { destroy(editor) {
const cleanup = (editor as any).__autoSaveCleanup; const cleanup = pluginState<any>(editor, K_AUTOSAVE);
if (cleanup) { if (cleanup) {
if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; } if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; }
if (editor && typeof editor.off === 'function') { if (editor && typeof editor.off === 'function') {
@@ -141,7 +180,7 @@ const autoSavePlugin: Plugin = {
if (cleanup._onBlur) editor.off('blur', cleanup._onBlur); if (cleanup._onBlur) editor.off('blur', cleanup._onBlur);
if (cleanup._onSave) editor.off('save', cleanup._onSave); if (cleanup._onSave) editor.off('save', cleanup._onSave);
} }
delete (editor as any).__autoSaveCleanup; deletePluginState(editor, K_AUTOSAVE);
} }
}, },
}; };
@@ -159,8 +198,8 @@ const exportToolPlugin: Plugin = {
setTimeout(() => URL.revokeObjectURL(url), 0); setTimeout(() => URL.revokeObjectURL(url), 0);
}; };
const stamp = () => { const d = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; }; const stamp = () => { const d = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; };
editor.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; }; const e = editor as any;
editor.exportHTML = (filename?: string, opts: any = {}) => { const buildHTML = (opts: any = {}): string => {
const title = opts.title || 'Document'; const title = opts.title || 'Document';
const css = opts.css || ''; const css = opts.css || '';
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : ''; const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
@@ -199,7 +238,26 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
.me-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em} .me-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em}
.me-table-wrap{overflow-x:auto;margin:.9em 0} .me-table-wrap{overflow-x:auto;margin:.9em 0}
</style>` : ''; </style>` : '';
download(filename || `metona-${stamp()}.html`, `<!DOCTYPE html>\n<html lang="${opts.lang||'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css?`<style>${css}</style>`:''}${embedCSS}\n</head>\n<body>\n${body}\n</body>\n</html>`, 'text/html'); return `<!DOCTYPE html>\n<html lang="${opts.lang||'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css?`<style>${css}</style>`:''}${embedCSS}\n</head>\n<body>\n${body}\n</body>\n</html>`;
};
e.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue!(), 'text/markdown'); return editor; };
e.exportHTML = (filename?: string, opts: any = {}) => {
download(filename || `metona-${stamp()}.html`, buildHTML(opts), 'text/html');
return editor;
};
e.exportPDF = (opts: any = {}) => {
if (typeof document === 'undefined' || !editor.el) return editor;
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed'; iframe.style.right = '0'; iframe.style.bottom = '0';
iframe.style.width = '0'; iframe.style.height = '0'; iframe.style.border = '0';
document.body.appendChild(iframe);
const doc = iframe.contentDocument;
if (!doc) { iframe.remove(); return editor; }
doc.open(); doc.write(buildHTML(opts)); doc.close();
setTimeout(() => {
try { iframe.contentWindow?.print(); } catch (_) {}
setTimeout(() => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe); }, 1000);
}, 50);
return editor; return editor;
}; };
}, },
@@ -207,7 +265,7 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
}; };
const searchReplacePlugin: Plugin = { const searchReplacePlugin: Plugin = {
name: 'searchReplace', version: '0.2.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80, name: 'searchReplace', version: '0.2.1', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
install(editor) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!editor || !editor.textarea || typeof document === 'undefined') return;
// Inject style once globally // Inject style once globally
@@ -220,9 +278,27 @@ const searchReplacePlugin: Plugin = {
const state = { const state = {
_panel: null as HTMLElement | null, _panel: null as HTMLElement | null,
_regexMode: false, _regexMode: false,
_matchCase: true,
_wholeWord: false,
_cleanup: null as (() => void) | null, _cleanup: null as (() => void) | null,
}; };
const WORD_CLASS = '[\\w\\u4e00-\\u9fff]';
const escapeRegExpText = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildPattern = (q: string): RegExp | null => {
if (!q) return null;
let src = state._regexMode ? q : escapeRegExpText(q);
if (state._wholeWord) src = `(?<!${WORD_CLASS})(?:${src})(?!${WORD_CLASS})`;
const flags = 'g' + (state._matchCase ? '' : 'i');
try { return new RegExp(src, flags); } catch (_) { return null; }
};
const hasWordBoundary = (text: string, idx: number, len: number): boolean => {
const isWord = (c: string | undefined) => !!c && /[\w\u4e00-\u9fff]/.test(c);
return !isWord(text[idx - 1]) && !isWord(text[idx + len]);
};
const _updateReplaceVisible = () => { const _updateReplaceVisible = () => {
if (!state._panel) return; if (!state._panel) return;
const show = state._panel.dataset.replace === '1'; const show = state._panel.dataset.replace === '1';
@@ -247,32 +323,40 @@ const searchReplacePlugin: Plugin = {
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd); const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
const panel = document.createElement('div'); panel.className = 'me-search'; const panel = document.createElement('div'); panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0'; panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-regex" title="Regex">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`; panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${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>`;
editor.el.appendChild(panel); state._panel = panel; editor.el.appendChild(panel); state._panel = panel;
_updateReplaceVisible(); _updateReplaceVisible();
state._regexMode = false; state._regexMode = false; state._matchCase = true; state._wholeWord = false;
const fi = panel.querySelector('.me-search-find') as HTMLInputElement; const fi = panel.querySelector('.me-search-find') as HTMLInputElement;
const ri = panel.querySelector('.me-search-replace') as HTMLInputElement; const ri = panel.querySelector('.me-search-replace') as HTMLInputElement;
const ce = panel.querySelector('.me-search-count') as HTMLElement; const ce = panel.querySelector('.me-search-count') as HTMLElement;
const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement; const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement;
const caseBtn = panel.querySelector('.me-search-case') as HTMLButtonElement;
const wordBtn = panel.querySelector('.me-search-word') as HTMLButtonElement;
regexBtn.addEventListener('click', () => { regexBtn.addEventListener('click', () => {
state._regexMode = !state._regexMode; state._regexMode = !state._regexMode;
regexBtn.classList.toggle('me-active', state._regexMode); regexBtn.classList.toggle('me-active', state._regexMode);
lastIdxs = findAll(); lastIdxs = findAll();
}); });
caseBtn.addEventListener('click', () => {
state._matchCase = !state._matchCase;
caseBtn.classList.toggle('me-active', state._matchCase);
lastIdxs = findAll();
});
wordBtn.addEventListener('click', () => {
state._wholeWord = !state._wholeWord;
wordBtn.classList.toggle('me-active', state._wholeWord);
lastIdxs = findAll();
});
const findAll = () => { const findAll = () => {
const q = fi.value; if (!q) { ce.textContent = ''; return []; } const q = fi.value; if (!q) { ce.textContent = ''; return []; }
const idxs: number[] = []; let from = 0; const idxs: number[] = [];
if (state._regexMode) { const re = buildPattern(q);
try { if (!re) { ce.textContent = 'err'; return []; }
const re = new RegExp(q, 'g'); let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = re.exec(editor.textarea.value)) !== null) { while ((m = re.exec(editor.textarea.value)) !== null) {
idxs.push(m.index); idxs.push(m.index);
if (m[0].length === 0) re.lastIndex++; if (m[0].length === 0) re.lastIndex++;
}
} catch (_) { ce.textContent = 'err'; return []; }
} else {
while (true) { const idx = editor.textarea.value.indexOf(q, from); if (idx === -1) break; idxs.push(idx); from = idx + q.length; }
} }
ce.textContent = idxs.length ? `${idxs.length}` : '0'; ce.textContent = idxs.length ? `${idxs.length}` : '0';
return idxs; return idxs;
@@ -282,30 +366,48 @@ const searchReplacePlugin: Plugin = {
editor.textarea.focus(); editor.textarea.focus();
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length)); editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
}; };
const matchLenAt = (idx: number): number => {
const text = editor.textarea.value;
const q = fi.value; if (!q) return 0;
if (!state._regexMode) {
if (state._matchCase && text.slice(idx, idx + q.length) !== q) return 0;
if (!state._matchCase && text.slice(idx, idx + q.length).toLowerCase() !== q.toLowerCase()) return 0;
if (state._wholeWord && !hasWordBoundary(text, idx, q.length)) return 0;
return q.length;
}
const re = buildPattern(q); if (!re) return 0;
re.lastIndex = idx;
const m = re.exec(text);
return m && m.index === idx ? m[0].length : 0;
};
const findNext = () => { const findNext = () => {
lastIdxs = findAll(); if (!lastIdxs.length) return; lastIdxs = findAll(); if (!lastIdxs.length) return;
const cur = editor.textarea.selectionEnd; const cur = editor.textarea.selectionEnd;
const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(cur)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length;
let next = lastIdxs.find((i: number) => i >= cur); let next = lastIdxs.find((i: number) => i >= cur);
if (next == null) next = lastIdxs[0]; if (next == null) next = lastIdxs[0];
selectAt(next, qlen); selectAt(next, matchLenAt(next) || fi.value.length);
}; };
const findPrev = () => { const findPrev = () => {
lastIdxs = findAll(); if (!lastIdxs.length) return; lastIdxs = findAll(); if (!lastIdxs.length) return;
const cur = editor.textarea.selectionStart; const cur = editor.textarea.selectionStart;
const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(Math.max(0, cur - 100), cur + 100)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length;
let prev = -1; let prev = -1;
for (let i = lastIdxs.length-1; i>=0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } } for (let i = lastIdxs.length-1; i>=0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } }
if (prev === -1) prev = lastIdxs[lastIdxs.length-1]; if (prev === -1) prev = lastIdxs[lastIdxs.length-1];
selectAt(prev, qlen); selectAt(prev, matchLenAt(prev) || fi.value.length);
}; };
const replaceOne = () => { const replaceOne = () => {
const q = fi.value, r = ri.value; if (!q) return; const q = fi.value, r = ri.value; if (!q) return;
const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd; const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd;
const matchText = ta.value.substring(s, e); const matchText = ta.value.substring(s, e);
let matched = false;
if (state._regexMode) { if (state._regexMode) {
try { if (new RegExp(q).test(matchText)) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } } catch (_) {} const re = buildPattern(q);
} else if (matchText === q) { if (re) { re.lastIndex = s; const m = re.exec(ta.value); matched = !!(m && m.index === s); }
} else {
const sameCase = state._matchCase ? matchText === q : matchText.toLowerCase() === q.toLowerCase();
matched = sameCase && (!state._wholeWord || hasWordBoundary(ta.value, s, q.length));
}
if (matched) {
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
ta.setSelectionRange(s, s + r.length); ta.setSelectionRange(s, s + r.length);
} }
@@ -317,11 +419,9 @@ const searchReplacePlugin: Plugin = {
const replaceAll = () => { const replaceAll = () => {
const q = fi.value, r = ri.value; if (!q) return; const q = fi.value, r = ri.value; if (!q) return;
const ta = editor.textarea; const ta = editor.textarea;
if (state._regexMode) { const re = buildPattern(q);
try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; } if (!re) return;
} else { ta.value = ta.value.replace(re, () => r);
ta.value = ta.value.split(q).join(r);
}
ta.setSelectionRange(0,0); editor._value = ta.value; ta.setSelectionRange(0,0); editor._value = ta.value;
if (typeof editor._pushHistory === 'function') editor._pushHistory(); if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render(); if (typeof editor._render === 'function') editor._render();
@@ -350,49 +450,55 @@ const searchReplacePlugin: Plugin = {
}; };
editor.textarea.addEventListener('keydown', _onKeydown); editor.textarea.addEventListener('keydown', _onKeydown);
// Expose for tests // Expose for tests via Symbol-keyed state (no public-property pollution)
(editor as any).__srState = state; setPluginState(editor, K_SEARCH, { state, _open, _close, _onKeydown });
(editor as any).__srOpen = _open;
(editor as any).__srClose = _close;
(editor as any).__srKeydown = _onKeydown;
}, },
destroy(editor: any) { destroy(editor: EditorLike) {
const state = (editor as any).__srState; const exposed = pluginState<{ state: any; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SEARCH);
if (state) { if (exposed) {
if (state._cleanup) { state._cleanup(); state._cleanup = null; } const state = exposed.state;
state._panel = null; if (state) {
if (state._cleanup) { state._cleanup(); state._cleanup = null; }
state._panel = null;
}
if (exposed._onKeydown && editor && editor.textarea) {
editor.textarea.removeEventListener('keydown', exposed._onKeydown);
}
deletePluginState(editor, K_SEARCH);
} }
const _onKeydown = (editor as any).__srKeydown;
if (_onKeydown && editor && editor.textarea) {
editor.textarea.removeEventListener('keydown', _onKeydown);
}
delete (editor as any).__srState;
delete (editor as any).__srOpen;
delete (editor as any).__srClose;
delete (editor as any).__srKeydown;
}, },
}; };
const imagePastePlugin: Plugin = { const imagePastePlugin: Plugin = {
name: 'imagePaste', version: '0.2.0', description: 'Paste image as base64', priority: 60, name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60,
install(editor) { install(editor, options: any = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!editor || !editor.textarea || typeof document === 'undefined') return;
const maxSizeKB: number = options.maxSizeKB || 500;
const _onPaste = (e: ClipboardEvent) => { const _onPaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items; if (!items) return; const items = e.clipboardData?.items; if (!items) return;
for (const item of items) { for (const item of items) {
if (item.type?.startsWith('image/')) { e.preventDefault(); if (item.type?.startsWith('image/')) {
const reader = new FileReader(); reader.onload = () => { editor.insert(`![image-${Date.now().toString(36)}.png](${reader.result})\n`); }; const file = item.getAsFile();
reader.readAsDataURL(item.getAsFile()!); break; if (!file) continue;
const sizeKB = file.size / 1024;
if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault();
if (typeof editor.toast === 'function') editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break;
}
e.preventDefault();
const reader = new FileReader(); reader.onload = () => { if (typeof editor.insert === 'function') editor.insert(`![image-${Date.now().toString(36)}.png](${reader.result})\n`); };
reader.readAsDataURL(file); break;
} }
} }
}; };
editor.textarea.addEventListener('paste', _onPaste); editor.textarea.addEventListener('paste', _onPaste);
(editor as any).__ipOnPaste = _onPaste; setPluginState(editor, K_IMAGE_PASTE, _onPaste);
}, },
destroy(editor: any) { destroy(editor: EditorLike) {
const _onPaste = (editor as any).__ipOnPaste; const _onPaste = pluginState<(e: ClipboardEvent) => void>(editor, K_IMAGE_PASTE);
if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste); if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste);
delete (editor as any).__ipOnPaste; deletePluginState(editor, K_IMAGE_PASTE);
}, },
}; };
@@ -421,11 +527,11 @@ const shortcutHelpPlugin: Plugin = {
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'], ['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'], ['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'], ['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'],
['?', t('close') || 'Shortcuts'], ['?', t('shortcuts') || 'Shortcuts'],
]; ];
let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; }); let rows = ''; builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay'; const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('close') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`; overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('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); _panel = overlay;
}; };
@@ -436,14 +542,14 @@ const shortcutHelpPlugin: Plugin = {
}; };
editor.textarea.addEventListener('keydown', _onKeydown); editor.textarea.addEventListener('keydown', _onKeydown);
(editor as any).__shState = { _panel, _open, _close, _onKeydown }; setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown });
}, },
destroy(editor: any) { destroy(editor: EditorLike) {
const state = (editor as any).__shState; const state = pluginState<{ _panel: HTMLElement | null; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT);
if (state) { if (state._panel) { state._panel.remove(); } } if (state) { if (state._panel) { state._panel.remove(); } }
const _onKeydown = (editor as any).__shState?._onKeydown; const _onKeydown = state?._onKeydown;
if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown); if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown);
delete (editor as any).__shState; deletePluginState(editor, K_SHORTCUT);
}, },
}; };
@@ -454,31 +560,32 @@ const fileSystemPlugin: Plugin = {
const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function'; const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function';
let _fileHandle: any = null; let _fileHandle: any = null;
editor.openFile = async (opts: any = {}) => { (editor as any).openFile = async (opts: any = {}) => {
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; } if (!hasAPI) { if (typeof editor.toast === 'function') editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; }
try { try {
const [handle] = await (window as any).showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md','.txt','.markdown'] } }], ...opts }); const [handle] = await (window as any).showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md','.txt','.markdown'] } }], ...opts });
_fileHandle = handle; const file = await handle.getFile(); const content = await file.text(); _fileHandle = handle; const file = await handle.getFile(); const content = await file.text();
editor.setValue(content); editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle }; if (typeof editor.setValue === 'function') editor.setValue(content);
editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle };
} catch (e: any) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; } } catch (e: any) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; }
}; };
editor.saveFile = async (opts: any = {}) => { (editor as any).saveFile = async (opts: any = {}) => {
let handle = _fileHandle; let handle = _fileHandle;
if (!handle || opts.saveAs) { if (!handle || opts.saveAs) {
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; } if (!hasAPI) { if (typeof editor.toast === 'function') editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; }
try { handle = await (window as any).showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); _fileHandle = handle; } try { handle = await (window as any).showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); _fileHandle = handle; }
catch (e: any) { if (e.name !== 'AbortError') console.error('Save error:', e); return false; } catch (e: any) { if (e.name !== 'AbortError') console.error('Save error:', e); return false; }
} }
try { const w = await handle.createWritable(); await w.write(editor.getValue()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; } try { const w = await handle.createWritable(); await w.write(editor.getValue!()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; }
catch (e) { _fileHandle = null; if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; } catch (e) { _fileHandle = null; if (!opts.saveAs) return (editor as any).saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; }
}; };
editor.saveFileAs = (name?: string) => editor.saveFile({ saveAs: true, name }); (editor as any).saveFileAs = (name?: string) => (editor as any).saveFile({ saveAs: true, name });
editor.getFileHandle = () => _fileHandle; (editor as any).getFileHandle = () => _fileHandle;
(editor as any).__fsCleanup = { getHandle: () => _fileHandle }; setPluginState(editor, K_FILESYSTEM, { getHandle: () => _fileHandle });
}, },
destroy(editor: any) { destroy(editor: EditorLike) {
if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; } if (editor) { delete (editor as any).openFile; delete (editor as any).saveFile; delete (editor as any).saveFileAs; delete (editor as any).getFileHandle; }
delete (editor as any).__fsCleanup; deletePluginState(editor, K_FILESYSTEM);
}, },
}; };
+12
View File
@@ -72,6 +72,18 @@ const generateCSS = (): string => {
.me-code-title{font-family:var(--md-mono);font-size:.85em;padding:8px 14px;background:var(--md-border);border-radius:8px 8px 0 0;color:var(--md-text);margin:.9em 0 -0.9em;font-weight:600} .me-code-title{font-family:var(--md-mono);font-size:.85em;padding:8px 14px;background:var(--md-border);border-radius:8px 8px 0 0;color:var(--md-text);margin:.9em 0 -0.9em;font-weight:600}
.me-code-title+pre{margin-top:0;border-radius:0 0 8px 8px;border-top:none} .me-code-title+pre{margin-top:0;border-radius:0 0 8px 8px;border-top:none}
.me-preview pre code{padding:0;background:transparent;color:var(--md-code-text);font-size:.9em;line-height:1.6;border-radius:0} .me-preview pre code{padding:0;background:transparent;color:var(--md-code-text);font-size:.9em;line-height:1.6;border-radius:0}
.me-hl-keyword{color:#c678dd}
.me-hl-string{color:#98c379}
.me-hl-comment{color:#7f848e;font-style:italic}
.me-hl-number{color:#d19a66}
.me-hl-builtin{color:#e5c07b}
.me-hl-function{color:#61afef}
.me-theme-light .me-hl-keyword{color:#9c4cc0}
.me-theme-light .me-hl-string{color:#4f9d4f}
.me-theme-light .me-hl-comment{color:#8b8b8b}
.me-theme-light .me-hl-number{color:#b06a22}
.me-theme-light .me-hl-builtin{color:#9a7d0f}
.me-theme-light .me-hl-function{color:#2a7fd4}
.me-preview img{max-width:100%;height:auto;border-radius:6px;vertical-align:middle} .me-preview img{max-width:100%;height:auto;border-radius:6px;vertical-align:middle}
.me-table-wrap{overflow-x:auto;margin:.9em 0} .me-table-wrap{overflow-x:auto;margin:.9em 0}
.me-preview table{border-collapse:collapse;width:100%;font-size:.93em;display:block} .me-preview table{border-collapse:collapse;width:100%;font-size:.93em;display:block}
+1 -1
View File
@@ -283,7 +283,7 @@ export const presetThemes = {
// Ensure built-in themes in THEMES // Ensure built-in themes in THEMES
for (const [name, theme] of Object.entries(presetThemes)) { for (const [name, theme] of Object.entries(presetThemes)) {
if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config as ThemeConfig; if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config;
} }
export const themeUtils = { export const themeUtils = {
+10 -1
View File
@@ -25,6 +25,15 @@ export const escapeHTML = (s: unknown): string => {
return div.innerHTML; return div.innerHTML;
}; };
/** HTML-escape a string for use inside a double-quoted attribute value */
export const escapeAttr = (s: unknown): string => {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
};
/** Detect dark mode preference */ /** Detect dark mode preference */
export const prefersDark = (): boolean => { export const prefersDark = (): boolean => {
if (typeof window === 'undefined' || !window.matchMedia) return false; if (typeof window === 'undefined' || !window.matchMedia) return false;
@@ -69,7 +78,7 @@ export function throttle<T extends (...args: any[]) => void>(
/** Deep-merge two objects */ /** Deep-merge two objects */
export const deepMerge = <T extends Record<string, unknown>>(target: T, source: Partial<T>): T => { export const deepMerge = <T extends Record<string, unknown>>(target: T, source: Partial<T>): T => {
const output: Record<string, unknown> = { ...target }; const output: Record<string, unknown> = { ...target };
for (const key of Object.keys(source as Record<string, unknown>)) { for (const key of Object.keys(source)) {
const sv = (source as Record<string, unknown>)[key]; const sv = (source as Record<string, unknown>)[key];
const tv = (target as Record<string, unknown>)[key]; const tv = (target as Record<string, unknown>)[key];
if (sv instanceof Object && key in target && tv instanceof Object) { if (sv instanceof Object && key in target && tv instanceof Object) {
+228
View File
@@ -2301,3 +2301,231 @@ describe('MarkdownEditor - v0.2.3 getStatus', () => {
ed.destroy(); ed.destroy();
}); });
}); });
// ============ v0.2.5 maxLength / zenMode / 分隔条隔离 ============
describe('MarkdownEditor - v0.2.5 maxLength', () => {
test('setValue 超长内容被截断', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 10 });
ed.setValue('0123456789ABC');
expect(ed.getValue()).toBe('0123456789');
expect(ed.textarea.value).toBe('0123456789');
ed.destroy();
});
test('insert 超长内容被截断', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 5, value: 'ab' });
ed.focus();
ed.textarea.setSelectionRange(2, 2);
ed.insert('CDEFG');
expect(ed.getValue()).toBe('abCDE');
ed.destroy();
});
test('textarea maxlength 属性已绑定', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 42 });
expect(ed.textarea.maxLength).toBe(42);
ed.destroy();
});
test('maxLength 为 0 时不限制', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.textarea.maxLength).toBeGreaterThanOrEqual(0);
const long = 'x'.repeat(10000);
ed.setValue(long);
expect(ed.getValue().length).toBe(10000);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.5 zenMode 初始状态', () => {
test('zenMode: true 启动即进入 Zen 模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
expect(ed.isZen()).toBe(true);
expect(ed.el.classList.contains('me-zen')).toBe(true);
expect(ed.toolbarEl.style.opacity).toBe('0');
ed.destroy();
});
test('默认不进入 Zen 模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.isZen()).toBe(false);
expect(ed.el.classList.contains('me-zen')).toBe(false);
ed.destroy();
});
test('zenMode 初始开启后 toggleZen 可关闭', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
ed.toggleZen();
expect(ed.isZen()).toBe(false);
expect(ed.el.classList.contains('me-zen')).toBe(false);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.5 分隔条实例隔离', () => {
test('分隔条位置按实例 id 保存', () => {
localStorage.clear();
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { id: 'divider-test-1' });
ed.editorPane.style.flex = '0 0 30%';
ed._saveDividerPosition();
expect(localStorage.getItem('metona-editor-divider-divider-test-1')).toBe('0 0 30%');
expect(localStorage.getItem('metona-editor-divider')).toBeNull();
ed.destroy();
});
test('不同实例互不覆盖分隔条位置', () => {
localStorage.clear();
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const a = new MarkdownEditor(c, { id: 'dva' });
const b = new MarkdownEditor(c, { id: 'dvb' });
a.editorPane.style.flex = '0 0 20%';
b.editorPane.style.flex = '0 0 70%';
a._saveDividerPosition();
b._saveDividerPosition();
expect(localStorage.getItem('metona-editor-divider-dva')).toBe('0 0 20%');
expect(localStorage.getItem('metona-editor-divider-dvb')).toBe('0 0 70%');
a.destroy(); b.destroy();
});
});
// ============ v0.2.5 复查:maxLength 兜底 + zen destroy 清理 ============
describe('MarkdownEditor - v0.2.5 maxLength 兜底', () => {
test('exec 命令路径不突破 maxLength', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 20, value: '12345678901234567890' });
ed.focus();
ed.textarea.setSelectionRange(0, 0);
ed.exec('indent');
expect(ed.getValue().length).toBeLessThanOrEqual(20);
ed.destroy();
});
test('Smart Enter 不突破 maxLength', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 30, value: '- item 123456789012345678901234' });
ed.focus();
ed.textarea.setSelectionRange(ed.getValue().length, ed.getValue().length);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.getValue().length).toBeLessThanOrEqual(30);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.5 zen destroy 清理', () => {
test('destroy 移除 zen mousemove 监听', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
expect(ed._zenMouseHandler).not.toBeNull();
const spy = jest.spyOn(document, 'removeEventListener');
ed.destroy();
expect(spy).toHaveBeenCalledWith('mousemove', expect.any(Function));
spy.mockRestore();
});
test('destroy 后 mousemove 不再修改 toolbar', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
const toolbar = ed.toolbarEl;
ed.destroy();
toolbar.style.opacity = '1';
document.dispatchEvent(new MouseEvent('mousemove', { clientY: 10 }));
expect(toolbar.style.opacity).toBe('1');
});
});
// ============ v0.2.5 复查:链式 API 契约 ============
describe('MarkdownEditor - v0.2.5 链式返回', () => {
test('toggleFloatingToolbar 返回 this', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.toggleFloatingToolbar()).toBe(ed);
ed.destroy();
});
test('registerContextMenu 返回 this', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.registerContextMenu([{ label: 'x', onClick: () => {} }])).toBe(ed);
ed.destroy();
});
});
// ============ v0.2.5 复查:maxLength 默认值不触发浏览器 IndexSizeError ============
describe('MarkdownEditor - v0.2.5 maxLength 默认值', () => {
test('不配置 maxLength 时保持默认 -1 且不显式赋值', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
// 模拟浏览器 setter 行为:负值抛 IndexSizeError
const textareaProto = HTMLTextAreaElement.prototype;
const originalDesc = Object.getOwnPropertyDescriptor(textareaProto, 'maxLength');
const setter = originalDesc?.set;
let threw = false;
if (setter) {
Object.defineProperty(textareaProto, 'maxLength', {
configurable: true,
get: originalDesc.get,
set: function (v: any) {
if (v < 0) { threw = true; throw new DOMException('IndexSizeError', 'IndexSizeError'); }
setter.call(this, v);
},
});
}
const ed = new MarkdownEditor(c, {});
expect(threw).toBe(false);
expect(ed.textarea.maxLength).toBeGreaterThanOrEqual(0);
ed.destroy();
if (setter) Object.defineProperty(textareaProto, 'maxLength', originalDesc);
});
test('maxLength 配置为 0 时不触发 setter 报错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 0 });
expect(ed.textarea.maxLength).toBeGreaterThanOrEqual(0);
ed.destroy();
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* highlight.ts
*
*/
import { highlight, normalizeLanguage, registerLanguage, getSupportedLanguages } from '../src/highlight';
describe('highlight - 基础', () => {
test('未知语言返回转义纯文本', () => {
const out = highlight('const x = 1;', 'nonexistent');
expect(out).toBe('const x = 1;');
});
test('支持的语言别名归一化', () => {
expect(normalizeLanguage('js')).toBe('javascript');
expect(normalizeLanguage('ts')).toBe('typescript');
expect(normalizeLanguage('py')).toBe('python');
expect(normalizeLanguage('sh')).toBe('bash');
expect(normalizeLanguage('yml')).toBe('yaml');
expect(normalizeLanguage('Java')).toBe('java');
expect(normalizeLanguage('unknown')).toBe('');
expect(normalizeLanguage('')).toBe('');
});
test('关键词被标记', () => {
const out = highlight('const x = 1', 'js');
expect(out).toContain('<span class="me-hl-keyword">const</span>');
});
test('字符串被标记', () => {
const out = highlight('const s = "hello"', 'js');
expect(out).toContain('<span class="me-hl-string">"hello"</span>');
});
test('注释被标记', () => {
const out = highlight('// comment\nconst x = 1', 'js');
expect(out).toContain('<span class="me-hl-comment">// comment</span>');
});
test('数字被标记', () => {
const out = highlight('const n = 42', 'js');
expect(out).toContain('<span class="me-hl-number">42</span>');
});
test('函数调用被标记', () => {
const out = highlight('foo(bar)', 'js');
expect(out).toContain('<span class="me-hl-function">foo</span>');
});
test('python 井号注释与 def 关键词', () => {
const out = highlight('# note\ndef main():', 'python');
expect(out).toContain('<span class="me-hl-comment"># note</span>');
expect(out).toContain('<span class="me-hl-keyword">def</span>');
});
test('内置全局对象被标记', () => {
const out = highlight('console.log("x")', 'js');
expect(out).toContain('<span class="me-hl-builtin">console</span>');
});
});
describe('highlight - 安全性', () => {
test('HTML 特殊字符被转义,不产生裸标签', () => {
const out = highlight('<script>alert(1)</script>', 'js');
expect(out).not.toContain('<script>');
expect(out).not.toContain('<script');
expect(out).toContain('&lt;script&gt;');
});
test('字符串中的引号安全', () => {
const out = highlight('const s = "a<b>&"', 'js');
expect(out).not.toContain('a<b');
expect(out).toContain('a&lt;b&gt;');
});
test('恶意输入不破坏 span 结构', () => {
const out = highlight('"</span><script>x</script>"', 'js');
// 所有 span 配对(每开一个 <span 就有一个 </span>
const opens = (out.match(/<span/g) || []).length;
const closes = (out.match(/<\/span>/g) || []).length;
expect(opens).toBe(closes);
expect(out).not.toContain('<script');
});
});
describe('highlight - 语言注册', () => {
test('注册自定义语言', () => {
registerLanguage('foo', { keywords: ['frobnicate'], builtins: [] });
expect(normalizeLanguage('foo')).toBe('foo');
const out = highlight('frobnicate the widget', 'foo');
expect(out).toContain('<span class="me-hl-keyword">frobnicate</span>');
});
test('getSupportedLanguages 返回语言列表', () => {
const langs = getSupportedLanguages();
expect(Array.isArray(langs)).toBe(true);
expect(langs).toContain('javascript');
expect(langs).toContain('typescript');
expect(langs).toContain('python');
});
});
describe('highlight - 环境一致性', () => {
test('字符串高亮与引号转义环境无关(jsdom 断言)', () => {
const out = highlight('const s = "hi"', 'js');
expect(out).toContain('<span class="me-hl-string">"hi"</span>');
});
test('引号不被实体化,span 文本保持原样', () => {
const out = highlight('"a" + \'b\'', 'js');
expect(out).not.toContain('&quot;');
// 双引号字符串应被完整标记
expect(out).toContain('<span class="me-hl-string">"a"</span>');
expect(out).toContain("<span class=\"me-hl-string\">'b'</span>");
});
});
+1 -1
View File
@@ -29,7 +29,7 @@ describe('index.ts - 全局 API', () => {
test('VERSION 是字符串', () => { test('VERSION 是字符串', () => {
expect(typeof VERSION).toBe('string'); expect(typeof VERSION).toBe('string');
expect(VERSION).toBe('0.2.4'); expect(VERSION).toBe('0.2.5');
}); });
test('api.default 是 api 本身', () => { test('api.default 是 api 本身', () => {
+84
View File
@@ -1270,3 +1270,87 @@ describe('parseMarkdown - v0.2.2 自定义 token 渲染', () => {
expect(html).toContain('<strong>Important!</strong>'); expect(html).toContain('<strong>Important!</strong>');
}); });
}); });
// ============ v0.2.5 链接 title 属性转义 ============
describe('parseMarkdown - v0.2.5 title 转义', () => {
test('链接 title 中的引号被转义', () => {
const html = parseMarkdown('[text](https://example.com "say \\"hi\\"")');
expect(html).toContain('title="say &quot;hi&quot;"');
expect(html).not.toContain('title="say "hi""');
});
test('链接 title 中的尖括号被转义', () => {
const html = parseMarkdown('[text](https://example.com "a<b>c")');
expect(html).toContain('title="a&lt;b&gt;c"');
});
test('图片 title 被转义', () => {
const html = parseMarkdown('![alt](https://example.com/i.png "x\\"y")');
expect(html).toContain('title="x&quot;y"');
});
test('引用链接 title 被转义', () => {
const html = parseMarkdown('[ref]: https://example.com "a\\"b"\n\n[text][ref]');
expect(html).toContain('title="a&quot;b"');
});
test('引用图片 title 被转义', () => {
const html = parseMarkdown('[img]: https://example.com/i.png "c<d>"\n\n![alt][img]');
expect(html).toContain('title="c&lt;d&gt;"');
});
test('链接 URL 中的引号不能注入属性', () => {
const html = parseMarkdown('[x](https://example.com/"onclick="alert(1))');
expect(html).not.toContain(' onclick=');
expect(html).toContain('&quot;onclick');
});
test('图片 alt 中的引号不能注入属性', () => {
const html = parseMarkdown('![a" onerror="alert(1)](https://example.com/i.png)');
expect(html).not.toContain(' onerror="');
expect(html).toContain('&quot;');
});
test('代码块语言属性中的引号不能注入', () => {
const html = parseMarkdown('```js" onclick="x\ncode\n```');
expect(html).not.toContain('language-js');
expect(html).not.toContain('onclick="x"');
});
test('代码块 title 属性中的引号不能注入', () => {
const html = parseMarkdown('```js title="a" onload="x"\ncode\n```');
expect(html).not.toContain('onload');
});
test('链接 title 中的反斜杠转义被还原', () => {
const html = parseMarkdown('[text](https://example.com "say \\"hi\\"")');
expect(html).toContain('title="say &quot;hi&quot;"');
});
});
describe('parseMarkdown - v0.2.5 URL 属性无双重转义', () => {
test('链接 URL 中的 & 不双重转义', () => {
const html = parseMarkdown('[x](https://example.com/?a=1&b=2)');
expect(html).toContain('href="https://example.com/?a=1&amp;b=2"');
expect(html).not.toContain('&amp;amp;');
});
test('图片 alt 中的 & 不双重转义', () => {
const html = parseMarkdown('![a & b](https://e.com/i.png)');
expect(html).toContain('alt="a &amp; b"');
expect(html).not.toContain('&amp;amp;');
});
test('引用链接 URL 中的 & 不被破坏', () => {
const html = parseMarkdown('[r]: https://example.com/?a=1&b=2\n\n[x][r]');
expect(html).toContain('href="https://example.com/?a=1&b=2"');
expect(html).not.toContain('&amp;amp;');
});
test('URL 中的引号仍被转义防注入', () => {
const html = parseMarkdown('[x](https://example.com/"onclick="alert(1))');
expect(html).not.toContain(' onclick="');
expect(html).toContain('&quot;onclick');
});
});
+155 -5
View File
@@ -794,20 +794,21 @@ describe('零散分支补全', () => {
}); });
test('autoSave save 内部抛错时 warn 不崩溃', () => { test('autoSave save 内部抛错时 warn 不崩溃', () => {
jest.useFakeTimers();
document.body.innerHTML = ''; document.body.innerHTML = '';
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const c = document.createElement('div'); const c = document.createElement('div');
document.body.appendChild(c); document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test' }); const ed = new MarkdownEditor(c, { value: 'test' });
ed.use('autoSave'); ed.use('autoSave', { delay: 50 });
// 让 editor.getValue 抛错,触发 save 的 catch 分支 // 让 editor.getValue 抛错,触发 save 的 catch 分支
jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); }); jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); });
const cleanup = (ed as any).__autoSaveCleanup; ed._emit('change', 'x');
expect(cleanup).toBeDefined(); expect(() => jest.advanceTimersByTime(60)).not.toThrow();
expect(() => cleanup.save()).not.toThrow();
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
ed.destroy(); ed.destroy();
spy.mockRestore(); spy.mockRestore();
jest.useRealTimers();
}); });
test('restoreDraft localStorage.getItem 抛错时返回 null', () => { test('restoreDraft localStorage.getItem 抛错时返回 null', () => {
@@ -833,7 +834,6 @@ describe('零散分支补全', () => {
// Ctrl+F 打开面板 // Ctrl+F 打开面板
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true })); ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
expect(ed.el.querySelector('.me-search')).not.toBeNull(); expect(ed.el.querySelector('.me-search')).not.toBeNull();
expect((ed as any).__srState._panel).toBeDefined();
// Ctrl+Escape 关闭面板(带 Ctrl 才会进入分支) // Ctrl+Escape 关闭面板(带 Ctrl 才会进入分支)
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true })); ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true }));
expect(ed.el.querySelector('.me-search')).toBeNull(); expect(ed.el.querySelector('.me-search')).toBeNull();
@@ -1125,3 +1125,153 @@ describe('v0.2.0 exportTool 插件', () => {
ed.destroy(); ed.destroy();
}); });
}); });
// ============ v0.2.5 searchReplace 大小写 / 全字匹配 ============
describe('v0.2.5 searchReplace 大小写与全字匹配', () => {
const setup = (value: string) => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value });
ed.use('searchReplace');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
const fi = ed.el.querySelector('.me-search-find') as HTMLInputElement;
const ce = ed.el.querySelector('.me-search-count') as HTMLElement;
const caseBtn = ed.el.querySelector('.me-search-case') as HTMLButtonElement;
const wordBtn = ed.el.querySelector('.me-search-word') as HTMLButtonElement;
return { ed, fi, ce, caseBtn, wordBtn };
};
const teardown = (ed: any) => {
const panel = ed.el.querySelector('.me-search');
if (panel) panel.remove();
ed.destroy();
};
test('默认区分大小写', () => {
const { ed, fi, ce, caseBtn, wordBtn } = setup('Foo foo FOO');
fi.value = 'foo';
fi.dispatchEvent(new Event('input'));
expect(ce.textContent).toBe('1');
expect(caseBtn.classList.contains('me-active')).toBe(true);
teardown(ed);
});
test('关闭大小写后匹配全部', () => {
const { ed, fi, ce, caseBtn } = setup('Foo foo FOO');
fi.value = 'foo';
caseBtn.click();
fi.dispatchEvent(new Event('input'));
expect(caseBtn.classList.contains('me-active')).toBe(false);
expect(ce.textContent).toBe('3');
teardown(ed);
});
test('全字匹配排除子串', () => {
const { ed, fi, ce, wordBtn } = setup('cat category scatter cat');
fi.value = 'cat';
fi.dispatchEvent(new Event('input'));
expect(ce.textContent).toBe('4');
wordBtn.click();
fi.dispatchEvent(new Event('input'));
expect(ce.textContent).toBe('2');
teardown(ed);
});
test('全字匹配支持中文边界', () => {
const { ed, fi, ce, wordBtn } = setup('测试test测试 test');
fi.value = 'test';
wordBtn.click();
fi.dispatchEvent(new Event('input'));
expect(ce.textContent).toBe('1');
teardown(ed);
});
test('replaceAll 大小写不敏感替换', () => {
const { ed, fi, caseBtn } = setup('Foo foo FOO');
fi.value = 'foo';
caseBtn.click();
const ri = ed.el.querySelector('.me-search-replace') as HTMLInputElement;
ri.value = 'bar';
const allBtn = ed.el.querySelector('.me-search-replace-all') as HTMLButtonElement;
allBtn.click();
expect(ed.getValue()).toBe('bar bar bar');
teardown(ed);
});
test('正则模式 + 全字匹配', () => {
const { ed, fi, ce, wordBtn, caseBtn } = setup('cat category scatter cat');
fi.value = 'ca[nt]';
const regexBtn = ed.el.querySelector('.me-search-regex') as HTMLButtonElement;
regexBtn.click();
caseBtn.click();
wordBtn.click();
fi.dispatchEvent(new Event('input'));
expect(ce.textContent).toBe('2');
teardown(ed);
});
});
// ============ v0.2.5 exportPDF / imagePaste 尺寸限制 ============
describe('v0.2.5 exportTool exportPDF', () => {
test('exportPDF 创建隐藏 iframe 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# pdf test' });
ed.use('exportTool');
const printSpy = jest.spyOn(window, 'print').mockImplementation(() => {});
expect(() => ed.exportPDF({ title: 'T' })).not.toThrow();
printSpy.mockRestore();
ed.destroy();
});
test('exportHTML 与 exportPDF 共用构建逻辑', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# hi' });
ed.use('exportTool');
expect(typeof (ed as any).exportPDF).toBe('function');
ed.destroy();
});
});
describe('v0.2.5 imagePaste 尺寸限制', () => {
test('超出 maxSizeKB 时 toast 警告且不插入', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
ed.use('imagePaste', { maxSizeKB: 1 });
const toastSpy = jest.spyOn(ed, 'toast').mockImplementation(() => ed);
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
const file = new File([new ArrayBuffer(4096)], 'big.png', { type: 'image/png' });
const evt = new Event('paste', { bubbles: true });
Object.defineProperty(evt, 'clipboardData', { value: { items: [{ type: 'image/png', getAsFile: () => file }] } });
ed.textarea.dispatchEvent(evt as ClipboardEvent);
expect(toastSpy).toHaveBeenCalled();
expect(insertSpy).not.toHaveBeenCalled();
toastSpy.mockRestore();
insertSpy.mockRestore();
ed.destroy();
});
test('默认 500KB 内正常插入', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
ed.use('imagePaste');
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
const file = new File([new ArrayBuffer(1024)], 'ok.png', { type: 'image/png' });
const evt = new Event('paste', { bubbles: true });
Object.defineProperty(evt, 'clipboardData', { value: { items: [{ type: 'image/png', getAsFile: () => file }] } });
ed.textarea.dispatchEvent(evt as ClipboardEvent);
expect(insertSpy).not.toHaveBeenCalled(); // FileReader 异步,同步阶段仅 preventDefault
insertSpy.mockRestore();
ed.destroy();
});
});