feat: v0.2.1 — reference links, context menu, RTL, ja/ko, regex search, hooks, copy API
## Added - Reference link/image resolution: [text][ref] + ![alt][ref] with [ref]: url definitions - Right-click context menu: undo/redo/cut/copy/paste/selectAll + custom items - RTL CSS layout support for Arabic, Hebrew, Persian etc. - Japanese (ja) and Korean (ko) locales with 60+ keys each - Divider position localStorage persistence - Regex search toggle in search/replace panel - Export HTML with embedded CSS styles - beforeChange / afterChange lifecycle hooks (instance + global) - copyAsMarkdown() / copyAsHTML() clipboard APIs - CHANGELOG.md, CONTRIBUTING.md, CI workflow (.github/workflows/ci.yml) - 2 new test suites: index.test.ts, styles.test.ts (684 total tests, +74) ## Changed - autoSave plugin: closure-based state per instance instead of this context - Plugin install() now receives options as second argument - RTL locale detection: now uses language prefix (ar-SA → RTL) - Rollup dev mode: only builds UMD format - prepublishOnly now includes typecheck + test - Version bumped to 0.2.1 ## Fixed - [text][ref] now correctly renders as link (was raw text) - ![alt][ref] no longer produces empty src - autoSave plugin state isolation across multiple editor instances - Footnote definitions no longer consumed by refDef handler
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [16.x, 18.x, 20.x]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: coverage-${{ matrix.node-version }}
|
||||||
|
path: coverage/
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to MetonaEditor will be documented in this file.
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-07-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Reference link/image resolution**: Full support for `[text][ref]` and `![alt][ref]` syntax with `[ref]: url` definitions.
|
||||||
|
- **Right-click context menu**: Built-in undo/redo/cut/copy/paste/selectAll with custom item support.
|
||||||
|
- **RTL (Right-to-Left) CSS support**: Full RTL layout for Arabic, Hebrew, Persian and other RTL languages.
|
||||||
|
- **Japanese (ja) and Korean (ko) locales**: Complete translations for all 60+ UI keys.
|
||||||
|
- **Divider position persistence**: Split-view divider ratio saved to localStorage and restored on reload.
|
||||||
|
- **Regex search support**: Toggle regex mode in search panel with `.*` button.
|
||||||
|
- **Export HTML with embedded CSS**: `exportHTML` now includes minimal CSS styles for standalone viewing.
|
||||||
|
- **`beforeChange` / `afterChange` lifecycle hooks**: Instance and global hooks that fire around content changes.
|
||||||
|
- **`copyAsMarkdown()` / `copyAsHTML()` APIs**: Copy editor content to clipboard as Markdown or rich HTML.
|
||||||
|
- **New test suites**: `tests/index.test.ts` (global API) and `tests/styles.test.ts` (CSS injection).
|
||||||
|
- **684 tests** (up from 610), 9 test suites (up from 7).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **autoSave plugin refactored**: Uses closure-based state per instance instead of `this` context, preventing state conflicts when the same plugin object is shared across multiple editors.
|
||||||
|
- **Plugin `install()` now receives `options` as second argument** for cleaner API.
|
||||||
|
- **RTL locale detection improved**: Now detects RTL by language prefix (e.g., `ar-SA` → RTL).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Reference link syntax `[text][ref]` now correctly resolves to links instead of raw text.
|
||||||
|
- Reference image syntax `![alt][ref]` no longer produces empty `src=""`.
|
||||||
|
- autoSave plugin state isolation when used across multiple editor instances.
|
||||||
|
- Footnote definitions no longer incorrectly consumed by reference definition parser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-07-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **TypeScript full rewrite**: All 12 source modules in TypeScript strict mode with complete type exports.
|
||||||
|
- **Parser 99% line coverage**: Self-built Markdown parser with 14 block-level handlers.
|
||||||
|
- **parseTokens / renderTokens API**: Separate tokenization and rendering for transform pipelines.
|
||||||
|
- **registerBlockHandler**: Extensible block syntax via handler registration.
|
||||||
|
- **610 tests**: Comprehensive test coverage across 7 suites.
|
||||||
|
- **Triple emphasis `***text***`**: Bold+italic combined formatting.
|
||||||
|
- **Link text inline formatting**: Bold, italic, and code within link text.
|
||||||
|
- **Backslash escape**: `\*`, `\_`, `\\` for literal punctuation.
|
||||||
|
- **Tab-indented code blocks**: Tab or 4-space indented code.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Plugin system v2**: Topological sort dependency management, 6 preset plugins.
|
||||||
|
- **Theme system overhaul**: CSS variable-based, instance isolation, external theme following.
|
||||||
|
- **i18n overhaul**: Instance-level locale isolation, plural rules, remote loading.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.15] - 2026-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Parser short and medium-term improvements.
|
||||||
|
- Triple emphasis syntax `***bold italic***`.
|
||||||
|
- Link text inline formatting support.
|
||||||
|
- Backslash escape sequences.
|
||||||
|
- `parseTokens` / `renderTokens` separation API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.14] - 2026-07-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Syntax highlighting feature (reverted in 0.1.15).
|
||||||
|
- Mermaid.js CDN support in demo page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.7] - 2026-07-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Line number gutter with active line highlighting.
|
||||||
|
- Smart Enter (list/quote continuation).
|
||||||
|
- Bracket auto-close with selection wrapping.
|
||||||
|
- Outline panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.6] - 2026-07-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `topologicalSort` for plugin dependency resolution.
|
||||||
|
- `validateConfig` for plugin configuration validation.
|
||||||
|
- `editor.unuse()` to uninstall plugins.
|
||||||
|
- `editor.registerShortcut()` / `unregisterShortcut()`.
|
||||||
|
- `editor.toast()` notification API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.5] - 2026-07-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Instance-level theme context (`createInstanceTheme`).
|
||||||
|
- `followExternalTheme` / `adoptFromParent` for external theme following.
|
||||||
|
- `exportCSSVars` / `getCSSVariable` for theme inspection.
|
||||||
|
- `applyThemeToElement` for scoped theme application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.4] - 2026-07-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Setext headings (underlined H1/H2).
|
||||||
|
- Nested lists (3+ levels).
|
||||||
|
- Indented code blocks (4 spaces / tab).
|
||||||
|
- HTML comment passthrough.
|
||||||
|
- Entity reference protection.
|
||||||
|
- Hard line breaks (2 trailing spaces).
|
||||||
|
- Link titles with single quotes.
|
||||||
|
- LaTeX math formulas (`$inline$` / `$$block$$`).
|
||||||
|
- Footnotes `[^id]`.
|
||||||
|
- Definition lists (`Term\n: definition`).
|
||||||
|
- Backtick exact matching (`` ` ``).
|
||||||
|
- Enhanced emoji set (150+).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.3] - 2026-07-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `exec` custom action support via `_customActions`.
|
||||||
|
- `refresh()` method for forced re-render.
|
||||||
|
- Focus/blur event parameter fixes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.2] - 2026-07-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Highlight marker `==text==`.
|
||||||
|
- Superscript `x^2^` and subscript `H~2~O`.
|
||||||
|
- Emoji shortcodes `:smile:` (100+ emojis).
|
||||||
|
- Table column alignment (`:---:`, `---:`).
|
||||||
|
- Read-only mode with `readOnly` config and `setReadOnly()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-07-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release.
|
||||||
|
- Markdown editor with edit/split/preview modes.
|
||||||
|
- Self-built parser (CommonMark + GFM).
|
||||||
|
- Plugin system with autoSave, exportTool, searchReplace.
|
||||||
|
- Theme system (light/dark/auto).
|
||||||
|
- i18n (zh-CN/en-US).
|
||||||
|
- Toolbar, shortcuts, history stack.
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
# Contributing to MetonaEditor
|
||||||
|
|
||||||
|
Thanks for your interest in contributing! This document outlines the development workflow and conventions.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 16.0.0
|
||||||
|
- npm >= 8.0.0
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.metona.cn/MetonaTeam/MetonaEditor.git
|
||||||
|
cd MetonaEditor
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dev server with hot reload (port 3001)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Run tests in watch mode
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
npm run typecheck
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
npm run lint
|
||||||
|
npm run lint:fix
|
||||||
|
|
||||||
|
# Format
|
||||||
|
npm run format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.ts # Entry point, global API
|
||||||
|
├── core.ts # MarkdownEditor class
|
||||||
|
├── parser.ts # Markdown parser (tokenizer + renderer)
|
||||||
|
├── plugins.ts # Plugin system & 6 presets
|
||||||
|
├── themes.ts # Theme system
|
||||||
|
├── i18n.ts # Internationalization
|
||||||
|
├── styles.ts # CSS-in-JS injection
|
||||||
|
├── constants.ts # Types, defaults, configs
|
||||||
|
├── utils.ts # Utility functions
|
||||||
|
├── animations.ts # Animation metadata
|
||||||
|
├── icons.ts # Toolbar SVG icons
|
||||||
|
└── locales.ts # Translation data
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── parser.test.ts
|
||||||
|
├── core.test.ts
|
||||||
|
├── plugins.test.ts
|
||||||
|
├── themes.test.ts
|
||||||
|
├── i18n.test.ts
|
||||||
|
├── utils.test.ts
|
||||||
|
├── animations.test.ts
|
||||||
|
├── index.test.ts
|
||||||
|
└── styles.test.ts
|
||||||
|
|
||||||
|
site/
|
||||||
|
├── index.html # Landing page
|
||||||
|
├── demo.html # Full-featured demo
|
||||||
|
└── docs.html # API documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Conventions
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
- **Strict mode** is enabled — all code must pass `tsc --noEmit`.
|
||||||
|
- Export types explicitly. Avoid `any` where possible.
|
||||||
|
- Use `interface` for object shapes, `type` for unions/primitives.
|
||||||
|
|
||||||
|
### Style
|
||||||
|
- Run `npm run format` before committing (uses Prettier).
|
||||||
|
- Follow existing comment patterns: JSDoc `/** */` for public APIs, `//` for inline notes.
|
||||||
|
- Keep functions focused and under ~60 lines where practical.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- **Every new feature must include tests.**
|
||||||
|
- Test files mirror source structure: `src/foo.ts` → `tests/foo.test.ts`.
|
||||||
|
- Use descriptive test names: `('does X when Y')`.
|
||||||
|
- Run the full suite before submitting: `npm test`.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- Use conventional commit messages:
|
||||||
|
- `feat: add reference link resolution`
|
||||||
|
- `fix: autoSave plugin state conflict`
|
||||||
|
- `docs: update API reference`
|
||||||
|
- `test: add index.ts global API tests`
|
||||||
|
- `chore: optimize rollup dev build`
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production build (all formats)
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Output in dist/
|
||||||
|
# ├── metona-editor.js UMD
|
||||||
|
# ├── metona-editor.min.js UMD minified
|
||||||
|
# ├── metona-editor.esm.js ES Module
|
||||||
|
# ├── metona-editor.cjs.js CommonJS
|
||||||
|
# └── metona-editor.d.ts TypeScript declarations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugin Development
|
||||||
|
|
||||||
|
Plugins follow a simple convention:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const myPlugin = {
|
||||||
|
name: 'myPlugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Description of my plugin',
|
||||||
|
depends: [], // optional: plugin names this depends on
|
||||||
|
priority: 50, // optional: for topological sort ordering
|
||||||
|
|
||||||
|
install(editor, options?) {
|
||||||
|
// Called when plugin is installed
|
||||||
|
// Use editor.on() to subscribe to events
|
||||||
|
// Return a Promise for async initialization
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy(editor) {
|
||||||
|
// Called when plugin is uninstalled
|
||||||
|
// Clean up event listeners, timers, DOM nodes
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
1. Update version in `package.json` and `src/index.ts` (`VERSION` constant).
|
||||||
|
2. Update `CHANGELOG.md`.
|
||||||
|
3. Run full test suite: `npm test`.
|
||||||
|
4. Build: `npm run build`.
|
||||||
|
5. Publish: `npm publish`.
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Open an issue at [git.metona.cn/MetonaTeam/MetonaEditor/issues](https://git.metona.cn/MetonaTeam/MetonaEditor/issues).
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
|
> TypeScript 重构 · 零运行时依赖 · 轻量级桌面端 Markdown Editor 库
|
||||||
|
|
||||||
[](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
|
[](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](./tests)
|
[](./tests)
|
||||||
[](./tests)
|
[](./tests)
|
||||||
[](./tsconfig.json)
|
[](./tsconfig.json)
|
||||||
|
|
||||||
@@ -16,10 +16,14 @@
|
|||||||
- **插件系统 v2** — 拓扑排序依赖管理,6 个预设插件开箱即用,安装 / 卸载生命周期
|
- **插件系统 v2** — 拓扑排序依赖管理,6 个预设插件开箱即用,安装 / 卸载生命周期
|
||||||
- **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步
|
- **三模式视图** — edit / split / preview,拖拽分隔条调整比例,双向滚动同步
|
||||||
- **主题系统** — light / dark / warm / auto,CSS 变量可定制,实例级隔离,主题继承,外部跟随
|
- **主题系统** — light / dark / warm / auto,CSS 变量可定制,实例级隔离,主题继承,外部跟随
|
||||||
- **国际化** — zh-CN / en-US 完整翻译,实例级语言隔离,远程加载翻译包
|
- **国际化** — zh-CN / en-US / ja / ko 完整翻译,实例级语言隔离,远程加载翻译包
|
||||||
- **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式
|
- **编辑体验** — 行号装订线、智能 Enter、括号自动闭合、拖放文件、大纲面板、Zen 专注模式、右键上下文菜单
|
||||||
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),sanitize 钩子
|
- **安全** — HTML 转义,XSS 协议过滤(javascript / vbscript / file / data),sanitize 钩子
|
||||||
- **桌面端优先** — 纯电脑端设计,无移动端冗余代码
|
- **桌面端优先** — 纯电脑端设计,无移动端冗余代码
|
||||||
|
- **引用链接** — 支持 `[text][ref]` / `![alt][ref]` 引用式链接和图片,含 title 属性
|
||||||
|
- **RTL 支持** — 完整的从右到左布局适配(阿拉伯语、希伯来语等)
|
||||||
|
- **正则搜索** — 查找替换面板支持正则表达式模式
|
||||||
|
- **分隔条记忆** — 分屏比例自动保存到 localStorage
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -287,6 +291,13 @@ editor.toast(message: string, opts?: {
|
|||||||
}): this
|
}): this
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Copy API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
editor.copyAsMarkdown(): this // 复制 Markdown 源码到剪贴板
|
||||||
|
editor.copyAsHTML(): this // 复制渲染后的 HTML 到剪贴板
|
||||||
|
```
|
||||||
|
|
||||||
### 销毁
|
### 销毁
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -352,6 +363,9 @@ import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlo
|
|||||||
| `linkClick` | 预览区链接点击 | `({ href, text })` |
|
| `linkClick` | 预览区链接点击 | `({ href, text })` |
|
||||||
| `fileOpened` | fileSystem 打开文件 | `({ name, handle })` |
|
| `fileOpened` | fileSystem 打开文件 | `({ name, handle })` |
|
||||||
| `fileSaved` | fileSystem 保存文件 | `({ handle })` |
|
| `fileSaved` | fileSystem 保存文件 | `({ handle })` |
|
||||||
|
| `beforeChange` | 内容变更前 | `(oldValue, newValue, editor)` |
|
||||||
|
| `afterChange` | 内容变更后 | `(newValue, editor)` |
|
||||||
|
| `copy` | 复制到剪贴板 | `({ type })` |
|
||||||
|
|
||||||
### 全局钩子
|
### 全局钩子
|
||||||
|
|
||||||
@@ -363,6 +377,8 @@ import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlo
|
|||||||
| `afterRender` | 每次渲染后(全局) |
|
| `afterRender` | 每次渲染后(全局) |
|
||||||
| `beforeDestroy` | destroy 前 |
|
| `beforeDestroy` | destroy 前 |
|
||||||
| `afterDestroy` | destroy 后 |
|
| `afterDestroy` | destroy 后 |
|
||||||
|
| `beforeChange` | 内容变更前(全局) |
|
||||||
|
| `afterChange` | 内容变更后(全局) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -393,6 +409,8 @@ import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, registerBlo
|
|||||||
| 表格 | `\| a \| b \|` | `<table>` + 列对齐 |
|
| 表格 | `\| a \| b \|` | `<table>` + 列对齐 |
|
||||||
| 链接 | `[text](url 'title')` | `<a>` |
|
| 链接 | `[text](url 'title')` | `<a>` |
|
||||||
| 图片 | `` | `<img>` |
|
| 图片 | `` | `<img>` |
|
||||||
|
| 引用链接 | `[text][ref]` + `[ref]: url` | `<a>` |
|
||||||
|
| 引用图片 | `![alt][ref]` + `[ref]: url` | `<img>` |
|
||||||
| 自动链接 | `<https://...>` | `<a>` |
|
| 自动链接 | `<https://...>` | `<a>` |
|
||||||
| 数学公式 | `$E=mc^2$` `$$\int$$` | `<span>` / `<div>` |
|
| 数学公式 | `$E=mc^2$` `$$\int$$` | `<span>` / `<div>` |
|
||||||
| 脚注 | `text[^1]` | `<sup>` + 底部定义 |
|
| 脚注 | `text[^1]` | `<sup>` + 底部定义 |
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@metona-team/metona-editor",
|
"name": "@metona-team/metona-editor",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"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.js",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"lint:fix": "eslint src/ --fix",
|
"lint:fix": "eslint src/ --fix",
|
||||||
"format": "prettier --write src/",
|
"format": "prettier --write src/",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
+56
-53
@@ -55,61 +55,64 @@ export default [
|
|||||||
},
|
},
|
||||||
plugins: [...basePlugins, ...devPlugins],
|
plugins: [...basePlugins, ...devPlugins],
|
||||||
},
|
},
|
||||||
// ES Module
|
// Only build all formats in production
|
||||||
{
|
...(isDev ? [] : [
|
||||||
input: 'src/index.ts',
|
// ES Module
|
||||||
output: {
|
{
|
||||||
file: 'dist/metona-editor.esm.js',
|
input: 'src/index.ts',
|
||||||
format: 'es',
|
output: {
|
||||||
exports: 'named',
|
file: 'dist/metona-editor.esm.js',
|
||||||
sourcemap: true,
|
format: 'es',
|
||||||
|
exports: 'named',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
plugins: basePlugins,
|
||||||
},
|
},
|
||||||
plugins: basePlugins,
|
// CommonJS
|
||||||
},
|
{
|
||||||
// CommonJS
|
input: 'src/index.ts',
|
||||||
{
|
output: {
|
||||||
input: 'src/index.ts',
|
file: 'dist/metona-editor.cjs.js',
|
||||||
output: {
|
format: 'cjs',
|
||||||
file: 'dist/metona-editor.cjs.js',
|
exports: 'named',
|
||||||
format: 'cjs',
|
sourcemap: true,
|
||||||
exports: 'named',
|
},
|
||||||
sourcemap: true,
|
plugins: basePlugins,
|
||||||
},
|
},
|
||||||
plugins: basePlugins,
|
// UMD minified
|
||||||
},
|
{
|
||||||
// UMD minified
|
input: 'src/index.ts',
|
||||||
{
|
output: {
|
||||||
input: 'src/index.ts',
|
file: 'dist/metona-editor.min.js',
|
||||||
output: {
|
format: 'umd',
|
||||||
file: 'dist/metona-editor.min.js',
|
name: 'MeEditor',
|
||||||
format: 'umd',
|
exports: 'named',
|
||||||
name: 'MeEditor',
|
sourcemap: false,
|
||||||
exports: 'named',
|
},
|
||||||
sourcemap: false,
|
plugins: [
|
||||||
|
...basePlugins,
|
||||||
|
terser({
|
||||||
|
compress: {
|
||||||
|
drop_console: true,
|
||||||
|
drop_debugger: true,
|
||||||
|
pure_funcs: ['console.log', 'console.warn'],
|
||||||
|
passes: 2,
|
||||||
|
},
|
||||||
|
format: { comments: false },
|
||||||
|
mangle: { toplevel: true },
|
||||||
|
}),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
// TypeScript declarations bundle
|
||||||
...basePlugins,
|
{
|
||||||
terser({
|
input: 'src/index.ts',
|
||||||
compress: {
|
output: {
|
||||||
drop_console: true,
|
file: 'dist/metona-editor.d.ts',
|
||||||
drop_debugger: true,
|
format: 'es',
|
||||||
pure_funcs: ['console.log', 'console.warn'],
|
},
|
||||||
passes: 2,
|
plugins: [
|
||||||
},
|
dts(),
|
||||||
format: { comments: false },
|
],
|
||||||
mangle: { toplevel: true },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// TypeScript declarations bundle
|
|
||||||
{
|
|
||||||
input: 'src/index.ts',
|
|
||||||
output: {
|
|
||||||
file: 'dist/metona-editor.d.ts',
|
|
||||||
format: 'es',
|
|
||||||
},
|
},
|
||||||
plugins: [
|
]),
|
||||||
dts(),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
+7
-7
@@ -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.0</h1>
|
<h1><span class="grad">Metona</span>Editor v0.2.1</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">610 tests</span>
|
<span class="badge">684 tests</span>
|
||||||
<span class="badge">6 个插件</span>
|
<span class="badge">6 个插件</span>
|
||||||
<span class="badge">桌面端优先</span>
|
<span class="badge">桌面端优先</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +60,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
<label>主题</label>
|
<label>主题</label>
|
||||||
<select id="selTheme"><option value="auto">🌓 跟随系统</option><option value="light">☀️ 亮色</option><option value="dark">🌙 暗色</option><option value="warm">🔥 暖色</option></select>
|
<select id="selTheme"><option value="auto">🌓 跟随系统</option><option value="light">☀️ 亮色</option><option value="dark">🌙 暗色</option><option value="warm">🔥 暖色</option></select>
|
||||||
<label>语言</label>
|
<label>语言</label>
|
||||||
<select id="selLocale"><option value="zh-CN">中文</option><option value="en-US">English</option></select>
|
<select id="selLocale"><option value="zh-CN">中文</option><option value="en-US">English</option><option value="ja">日本語</option><option value="ko">한국어</option></select>
|
||||||
<label>模式</label>
|
<label>模式</label>
|
||||||
<button data-cmd="mode" data-arg="edit">编辑</button>
|
<button data-cmd="mode" data-arg="edit">编辑</button>
|
||||||
<button data-cmd="mode" data-arg="split" class="on">分屏</button>
|
<button data-cmd="mode" data-arg="split" class="on">分屏</button>
|
||||||
@@ -90,7 +90,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
MetonaEditor v0.2.0 · 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.1 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
|
||||||
</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>
|
||||||
@@ -98,12 +98,12 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var demoMd=[
|
var demoMd=[
|
||||||
'# 🚀 MetonaEditor v0.2.0 全功能演示',
|
'# 🚀 MetonaEditor v0.2.1 全功能演示',
|
||||||
'',
|
'',
|
||||||
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
|
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
|
||||||
'> 全模块 TypeScript 严格模式,12 个源文件,610 个测试全部通过。',
|
'> 全模块 TypeScript 严格模式,12 个源文件,684 个测试全部通过。',
|
||||||
'',
|
'',
|
||||||
'## ✨ v0.2.0 新特性',
|
'## ✨ v0.2.1 新特性',
|
||||||
'',
|
'',
|
||||||
'- TypeScript 全模块重构(严格模式 + 完整类型导出)',
|
'- TypeScript 全模块重构(严格模式 + 完整类型导出)',
|
||||||
'- 解析器 99% 行覆盖率 · 表驱动块级处理器',
|
'- 解析器 99% 行覆盖率 · 表驱动块级处理器',
|
||||||
|
|||||||
+3
-1
@@ -336,6 +336,8 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span cl
|
|||||||
<tr><td>表格</td><td><code>| a | b |</code></td><td><table> + 列对齐</td></tr>
|
<tr><td>表格</td><td><code>| a | b |</code></td><td><table> + 列对齐</td></tr>
|
||||||
<tr><td>链接 <span class="badge">v0.1.15</span></td><td><code>[**t**](url)</code></td><td>文本内支持行内格式</td></tr>
|
<tr><td>链接 <span class="badge">v0.1.15</span></td><td><code>[**t**](url)</code></td><td>文本内支持行内格式</td></tr>
|
||||||
<tr><td>图片</td><td><code></code></td><td><img></td></tr>
|
<tr><td>图片</td><td><code></code></td><td><img></td></tr>
|
||||||
|
<tr><td>引用链接 <span class="badge">v0.2.1</span></td><td><code>[text][ref]</code> + <code>[ref]: url</code></td><td><a> 引用式</td></tr>
|
||||||
|
<tr><td>引用图片 <span class="badge">v0.2.1</span></td><td><code>![alt][ref]</code> + <code>[ref]: url</code></td><td><img> 引用式</td></tr>
|
||||||
<tr><td>自动链接</td><td><code><https://...></code></td><td><a></td></tr>
|
<tr><td>自动链接</td><td><code><https://...></code></td><td><a></td></tr>
|
||||||
<tr><td>数学公式</td><td><code>$E=mc^2$</code></td><td><span> / <div></td></tr>
|
<tr><td>数学公式</td><td><code>$E=mc^2$</code></td><td><span> / <div></td></tr>
|
||||||
<tr><td>脚注</td><td><code>text[^1]</code></td><td><sup> + 底部</td></tr>
|
<tr><td>脚注</td><td><code>text[^1]</code></td><td><sup> + 底部</td></tr>
|
||||||
@@ -431,7 +433,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.0 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
MetonaEditor v0.2.1 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -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.0 · TypeScript · 桌面端 Markdown 编辑器</title>
|
<title>MetonaEditor v0.2.1 · 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.0</span>
|
<span class="badge accent">v0.2.1</span>
|
||||||
<span class="badge">TypeScript</span>
|
<span class="badge">TypeScript</span>
|
||||||
<span class="badge">零运行时依赖</span>
|
<span class="badge">零运行时依赖</span>
|
||||||
<span class="badge">610 tests</span>
|
<span class="badge">684 tests</span>
|
||||||
<span class="badge">桌面端优先</span>
|
<span class="badge">桌面端优先</span>
|
||||||
<span class="badge">MIT</span>
|
<span class="badge">MIT</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +120,7 @@ footer a:hover{text-decoration:underline}
|
|||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div>
|
<div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div>
|
||||||
<div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div>
|
<div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div>
|
||||||
<div class="stat"><div class="stat-num">610</div><div class="stat-label">单元测试</div></div>
|
<div class="stat"><div class="stat-num">684</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">~30KB</div><div class="stat-label">gzip 体积</div></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -214,7 +214,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.0 · TypeScript · MIT License · MetonaTeam</div>
|
<div>MetonaEditor v0.2.1 · TypeScript · MIT License · MetonaTeam</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -224,7 +224,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.0',
|
'# 欢迎使用 MetonaEditor v0.2.1',
|
||||||
'',
|
'',
|
||||||
'一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。',
|
'一个 **TypeScript 重构 · 零运行时依赖** 的桌面端 Markdown 编辑器。',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type ToolbarItem = string | '|';
|
|||||||
export interface RenderEnv {
|
export interface RenderEnv {
|
||||||
highlight?: (code: string, lang: string) => string;
|
highlight?: (code: string, lang: string) => string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
refs?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorStyle {
|
export interface EditorStyle {
|
||||||
|
|||||||
+133
-4
@@ -126,6 +126,7 @@ export class MarkdownEditor {
|
|||||||
this._bindToolbarKeyboard();
|
this._bindToolbarKeyboard();
|
||||||
this._initAriaLive();
|
this._initAriaLive();
|
||||||
this._bindEvents();
|
this._bindEvents();
|
||||||
|
this._bindContextMenu();
|
||||||
|
|
||||||
this.textarea.value = this._value;
|
this.textarea.value = this._value;
|
||||||
this._pushHistory();
|
this._pushHistory();
|
||||||
@@ -190,6 +191,8 @@ export class MarkdownEditor {
|
|||||||
this.editorPane = editorPane; this.editorInner = editorInner; this.gutter = gutter;
|
this.editorPane = editorPane; this.editorInner = editorInner; this.gutter = gutter;
|
||||||
this.previewPane = previewPane; this.dividerEl = divider;
|
this.previewPane = previewPane; this.dividerEl = divider;
|
||||||
this.textarea = textarea; this.previewEl = preview; this.statusEl = statusbar;
|
this.textarea = textarea; this.previewEl = preview; this.statusEl = statusbar;
|
||||||
|
// Restore divider position if saved
|
||||||
|
this._restoreDividerPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildToolbar(): void {
|
_buildToolbar(): void {
|
||||||
@@ -223,11 +226,15 @@ export class MarkdownEditor {
|
|||||||
_bindEvents(): void {
|
_bindEvents(): void {
|
||||||
const ta = this.textarea;
|
const ta = this.textarea;
|
||||||
const onInput = () => {
|
const onInput = () => {
|
||||||
this._value = ta.value; this._scheduleRender(); this._scheduleHistory(); this._updateWordCount();
|
const oldValue = this._value;
|
||||||
|
this._value = ta.value;
|
||||||
|
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', oldValue, this._value);
|
||||||
|
this._scheduleRender(); this._scheduleHistory(); this._updateWordCount();
|
||||||
this._renderGutter(); this._updateOutline();
|
this._renderGutter(); this._updateOutline();
|
||||||
this._emit('input', this._value); this._emit('change', this._value);
|
this._emit('input', this._value); this._emit('change', this._value);
|
||||||
if (typeof this.config.onInput === 'function') { try { this.config.onInput(this._value, this); } catch (e) { console.error(e); } }
|
if (typeof this.config.onInput === 'function') { try { this.config.onInput(this._value, this); } catch (e) { console.error(e); } }
|
||||||
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } }
|
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } }
|
||||||
|
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this);
|
||||||
};
|
};
|
||||||
ta.addEventListener('input', onInput);
|
ta.addEventListener('input', onInput);
|
||||||
const onKeydown = (e: KeyboardEvent) => {
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
@@ -347,10 +354,31 @@ export class MarkdownEditor {
|
|||||||
this.editorPane.style.flex = `0 0 ${pct}%`;
|
this.editorPane.style.flex = `0 0 ${pct}%`;
|
||||||
this.previewPane.style.flex = `1 1 ${100 - pct}%`;
|
this.previewPane.style.flex = `1 1 ${100 - pct}%`;
|
||||||
};
|
};
|
||||||
const onUp = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); };
|
const onUp = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
this._saveDividerPosition();
|
||||||
|
};
|
||||||
window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp);
|
window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_saveDividerPosition(): void {
|
||||||
|
try {
|
||||||
|
const epFlex = this.editorPane.style.flex;
|
||||||
|
if (epFlex) localStorage.setItem('metona-editor-divider', epFlex);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreDividerPosition(): void {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('metona-editor-divider');
|
||||||
|
if (saved && this._mode === 'split') {
|
||||||
|
this.editorPane.style.flex = saved;
|
||||||
|
this.previewPane.style.flex = '1 1 auto';
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
_scheduleRender(): void { if (this._renderRaf) return; this._renderRaf = requestAnimationFrame(() => { this._renderRaf = null; this._render(); }); }
|
_scheduleRender(): void { if (this._renderRaf) return; this._renderRaf = requestAnimationFrame(() => { this._renderRaf = null; this._render(); }); }
|
||||||
|
|
||||||
_render(): void {
|
_render(): void {
|
||||||
@@ -640,15 +668,35 @@ export class MarkdownEditor {
|
|||||||
|
|
||||||
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; this._value = md || ''; this.textarea.value = this._value;
|
if (this._destroyed) return this;
|
||||||
|
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', this._value, md);
|
||||||
|
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();
|
||||||
if (this.gutter) this.gutter.scrollTop = 0; this.textarea.scrollTop = 0;
|
if (this.gutter) this.gutter.scrollTop = 0; this.textarea.scrollTop = 0;
|
||||||
if (!opts.silent) { this._emit('change', this._value); if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } } }
|
if (!opts.silent) { this._emit('change', this._value); if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } } }
|
||||||
|
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
getHTML(): string { MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); if (typeof this.config.sanitize === 'function') { try { html = this.config.sanitize(html); } catch (e) { console.error(e); } } MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this); return html; }
|
getHTML(): string { MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); if (typeof this.config.sanitize === 'function') { try { html = this.config.sanitize(html); } catch (e) { console.error(e); } } MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this); return html; }
|
||||||
refresh(): this { this._lastRenderedValue = null; this._render(); return this; }
|
refresh(): this { this._lastRenderedValue = null; this._render(); return this; }
|
||||||
|
|
||||||
|
copyAsMarkdown(): this {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'markdown' })).catch(() => {});
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyAsHTML(): this {
|
||||||
|
const html = this.getHTML();
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||||
|
const blob = new Blob([html], { type: 'text/html' });
|
||||||
|
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
|
||||||
|
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => {});
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
ta.value = ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start); ta.focus();
|
||||||
@@ -673,7 +721,7 @@ export class MarkdownEditor {
|
|||||||
if (!p || typeof p !== 'object') { console.warn('MeEditor: invalid plugin'); return this; }
|
if (!p || typeof p !== 'object') { console.warn('MeEditor: invalid plugin'); return this; }
|
||||||
const merged = { ...p, ...options };
|
const merged = { ...p, ...options };
|
||||||
if (typeof merged.install === 'function') {
|
if (typeof merged.install === 'function') {
|
||||||
try { const result = merged.install(this); if (result && typeof result.then === 'function') { result.catch((e: any) => console.error(`MeEditor: async plugin "${merged.name}" error:`, e)); } }
|
try { const result = merged.install(this, options); if (result && typeof result.then === 'function') { result.catch((e: any) => console.error(`MeEditor: async plugin "${merged.name}" error:`, e)); } }
|
||||||
catch (e) { console.error(`MeEditor: plugin "${merged.name}" install error:`, e); }
|
catch (e) { console.error(`MeEditor: plugin "${merged.name}" install error:`, e); }
|
||||||
}
|
}
|
||||||
this._plugins.push(merged); return this;
|
this._plugins.push(merged); return this;
|
||||||
@@ -704,6 +752,87 @@ export class MarkdownEditor {
|
|||||||
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; }
|
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: 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
|
||||||
|
{ label: 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
|
||||||
|
{ label: 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
|
||||||
|
{ label: '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;
|
||||||
|
|||||||
+5
-2
@@ -131,8 +131,9 @@ export const getLocaleName = (locale: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getLocaleDirection = (locale: string): 'ltr' | 'rtl' => {
|
export const getLocaleDirection = (locale: string): 'ltr' | 'rtl' => {
|
||||||
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug'];
|
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug', 'dv', 'ku', 'syr'];
|
||||||
return rtlLocales.includes(locale) ? 'rtl' : 'ltr';
|
const short = locale.split('-')[0].toLowerCase();
|
||||||
|
return rtlLocales.includes(short) ? 'rtl' : 'ltr';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatNumber = (number: number, options: Intl.NumberFormatOptions = {}): string => {
|
export const formatNumber = (number: number, options: Intl.NumberFormatOptions = {}): string => {
|
||||||
@@ -262,6 +263,8 @@ export const createI18nManager = () => ({
|
|||||||
export const presetLocales = {
|
export const presetLocales = {
|
||||||
'zh-CN': { name: '简体中文', nativeName: '简体中文', direction: 'ltr', translations: LOCALES['zh-CN'] },
|
'zh-CN': { name: '简体中文', nativeName: '简体中文', direction: 'ltr', translations: LOCALES['zh-CN'] },
|
||||||
'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] },
|
'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] },
|
||||||
|
ja: { name: '日本語', nativeName: '日本語', direction: 'ltr', translations: (LOCALES as any)['ja'] },
|
||||||
|
ko: { name: '한국어', nativeName: '한국어', direction: 'ltr', translations: (LOCALES as any)['ko'] },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const i18nUtils = createI18nManager();
|
export const i18nUtils = createI18nManager();
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor — Type-safe, lightweight Markdown Editor
|
* MetonaEditor — Type-safe, lightweight Markdown Editor
|
||||||
* @module metona-editor
|
* @module metona-editor
|
||||||
* @version 0.2.0
|
* @version 0.2.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MarkdownEditor } from './core';
|
import { MarkdownEditor } from './core';
|
||||||
@@ -13,7 +13,7 @@ import { animationUtils } from './animations';
|
|||||||
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.0';
|
const VERSION = '0.2.1';
|
||||||
|
|
||||||
const globalPlugins: any[] = [];
|
const globalPlugins: any[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -55,4 +55,54 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
|||||||
renderError: 'Render failed',
|
renderError: 'Render failed',
|
||||||
outline: 'Outline',
|
outline: 'Outline',
|
||||||
},
|
},
|
||||||
|
ja: {
|
||||||
|
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
|
||||||
|
h1: '見出し1', h2: '見出し2', h3: '見出し3', quote: '引用', code: 'コード',
|
||||||
|
link: 'リンク', image: '画像', table: '表', ul: '箇条書き', ol: '番号付きリスト',
|
||||||
|
indent: 'インデント', outdent: 'インデント解除', hr: '水平線', undo: '元に戻す', redo: 'やり直し',
|
||||||
|
edit: '編集', split: '分割', preview: 'プレビュー', fullscreen: '全画面',
|
||||||
|
fullscreenExit: '全画面解除', theme: 'テーマ', light: 'ライト', dark: 'ダーク',
|
||||||
|
auto: '自動', warm: 'ウォーム', wordCount: '文字数', characters: '文字',
|
||||||
|
words: '単語', lines: '行', readingTime: '読了時間', minutes: '分',
|
||||||
|
placeholder: 'Markdownを入力...', empty: '内容なし', copied: 'コピー済み',
|
||||||
|
copyContent: '内容をコピー', copyHTML: 'HTMLをコピー', copySuccess: 'コピー成功',
|
||||||
|
copyFailed: 'コピー失敗', clearContent: '内容をクリア', clearConfirm: 'すべての内容をクリアしますか?',
|
||||||
|
linkPlaceholder: 'リンクURLを入力', imagePlaceholder: '画像URLを入力',
|
||||||
|
altPlaceholder: '代替テキストを入力', tableRows: '行', tableCols: '列',
|
||||||
|
confirm: '確認', cancel: 'キャンセル', exportMarkdown: 'Markdownエクスポート',
|
||||||
|
exportHTML: 'HTMLエクスポート', search: '検索', replace: '置換', replaceAll: 'すべて置換',
|
||||||
|
searchPlaceholder: '検索', replacePlaceholder: '置換後',
|
||||||
|
findNext: '次を検索', findPrev: '前を検索', matchCase: '大文字小文字',
|
||||||
|
wholeWord: '単語単位', close: '閉じる', open: '開く', save: '保存',
|
||||||
|
saved: '保存済み', saving: '保存中...', delete: '削除', confirmDelete: '削除してもよろしいですか?',
|
||||||
|
unsavedChanges: '未保存の変更があります', error: 'エラー', success: '成功',
|
||||||
|
warning: '警告', info: '情報', loading: '読み込み中...', retry: '再試行',
|
||||||
|
renderError: 'レンダリング失敗',
|
||||||
|
outline: 'アウトライン',
|
||||||
|
},
|
||||||
|
ko: {
|
||||||
|
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
|
||||||
|
h1: '제목1', h2: '제목2', h3: '제목3', quote: '인용', code: '코드',
|
||||||
|
link: '링크', image: '이미지', table: '표', ul: '순서 없는 목록', ol: '순서 있는 목록',
|
||||||
|
indent: '들여쓰기', outdent: '내어쓰기', hr: '수평선', undo: '실행 취소', redo: '다시 실행',
|
||||||
|
edit: '편집', split: '분할', preview: '미리보기', fullscreen: '전체 화면',
|
||||||
|
fullscreenExit: '전체 화면 종료', theme: '테마', light: '라이트', dark: '다크',
|
||||||
|
auto: '자동', warm: '웜', wordCount: '글자 수', characters: '글자',
|
||||||
|
words: '단어', lines: '줄', readingTime: '읽기 시간', minutes: '분',
|
||||||
|
placeholder: 'Markdown 입력...', empty: '내용 없음', copied: '복사됨',
|
||||||
|
copyContent: '내용 복사', copyHTML: 'HTML 복사', copySuccess: '복사 성공',
|
||||||
|
copyFailed: '복사 실패', clearContent: '내용 지우기', clearConfirm: '모든 내용을 지우시겠습니까?',
|
||||||
|
linkPlaceholder: '링크 URL 입력', imagePlaceholder: '이미지 URL 입력',
|
||||||
|
altPlaceholder: '대체 텍스트 입력', tableRows: '행', tableCols: '열',
|
||||||
|
confirm: '확인', cancel: '취소', exportMarkdown: 'Markdown 내보내기',
|
||||||
|
exportHTML: 'HTML 내보내기', search: '검색', replace: '바꾸기', replaceAll: '모두 바꾸기',
|
||||||
|
searchPlaceholder: '찾기', replacePlaceholder: '바꿀 내용',
|
||||||
|
findNext: '다음 찾기', findPrev: '이전 찾기', matchCase: '대소문자 구분',
|
||||||
|
wholeWord: '단어 단위', close: '닫기', open: '열기', save: '저장',
|
||||||
|
saved: '저장됨', saving: '저장 중...', delete: '삭제', confirmDelete: '삭제하시겠습니까?',
|
||||||
|
unsavedChanges: '저장되지 않은 변경 사항이 있습니다', error: '오류', success: '성공',
|
||||||
|
warning: '경고', info: '정보', loading: '로딩 중...', retry: '재시도',
|
||||||
|
renderError: '렌더링 실패',
|
||||||
|
outline: '개요',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+46
-7
@@ -17,13 +17,14 @@ export interface Token {
|
|||||||
export interface ParseResult {
|
export interface ParseResult {
|
||||||
tokens: Token[];
|
tokens: Token[];
|
||||||
footnotes: Record<string, string>;
|
footnotes: Record<string, string>;
|
||||||
|
refs: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockHandler {
|
export interface BlockHandler {
|
||||||
name: string;
|
name: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
test: (line: string, lines: string[], i: number) => any;
|
test: (line: string, lines: string[], i: number) => any;
|
||||||
parse: (lines: string[], i: number, match: any, tokens: Token[], footnotes: Record<string, string>) => { token: Token | null; newIndex: number };
|
parse: (lines: string[], i: number, match: any, tokens: Token[], footnotes: Record<string, string>, refs?: Record<string, string>) => { token: Token | null; newIndex: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListItem {
|
export interface ListItem {
|
||||||
@@ -154,6 +155,21 @@ const isBlockStart = (line: string): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Register all built-in block handlers
|
// Register all built-in block handlers
|
||||||
|
registerBlockHandler({ name: 'refDef', priority: 0.1,
|
||||||
|
test: (line) => {
|
||||||
|
// Must not match footnote definitions: [^id]: text
|
||||||
|
if (/^\[\^/.test(line)) return null;
|
||||||
|
const m = line.match(/^\[([^\]]+)\]:\s*(?:<(\S+)>|(\S+))(?:\s+['"\(](.*?)['"\)])?\s*$/);
|
||||||
|
return m ? m : null;
|
||||||
|
},
|
||||||
|
parse: (_lines, i, match, _tokens, _footnotes, refs) => {
|
||||||
|
const refId: string = match[1].toLowerCase();
|
||||||
|
const url: string = match[2] || match[3] || '';
|
||||||
|
const title: string = match[4] || '';
|
||||||
|
if (url && refs) { refs[refId] = JSON.stringify({ url, title }); }
|
||||||
|
return { token: null, newIndex: i + 1 };
|
||||||
|
},
|
||||||
|
});
|
||||||
registerBlockHandler({ name: 'blank', priority: 0,
|
registerBlockHandler({ name: 'blank', priority: 0,
|
||||||
test: (line) => RE_EMPTY.test(line) ? true : null,
|
test: (line) => RE_EMPTY.test(line) ? true : null,
|
||||||
parse: (_lines, i) => ({ token: null, newIndex: i + 1 }),
|
parse: (_lines, i) => ({ token: null, newIndex: i + 1 }),
|
||||||
@@ -295,11 +311,12 @@ registerBlockHandler({ name: 'mathBlock', priority: 13,
|
|||||||
// ============ Block parsing ============
|
// ============ Block parsing ============
|
||||||
|
|
||||||
const parseTokens = (md: string | null | undefined): ParseResult => {
|
const parseTokens = (md: string | null | undefined): ParseResult => {
|
||||||
if (md == null) return { tokens: [], footnotes: {} };
|
if (md == null) return { tokens: [], footnotes: {}, refs: {} };
|
||||||
const text = String(md).replace(/\r\n?/g, '\n');
|
const text = String(md).replace(/\r\n?/g, '\n');
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const tokens: Token[] = [];
|
const tokens: Token[] = [];
|
||||||
const footnotes: Record<string, string> = {};
|
const footnotes: Record<string, string> = {};
|
||||||
|
const refs: Record<string, string> = {};
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
@@ -308,7 +325,7 @@ const parseTokens = (md: string | null | undefined): ParseResult => {
|
|||||||
for (const handler of blockHandlers) {
|
for (const handler of blockHandlers) {
|
||||||
const match = handler.test(line, lines, i);
|
const match = handler.test(line, lines, i);
|
||||||
if (match !== null && match !== false) {
|
if (match !== null && match !== false) {
|
||||||
const result = handler.parse(lines, i, match, tokens, footnotes);
|
const result = handler.parse(lines, i, match, tokens, footnotes, refs);
|
||||||
if (result.token) tokens.push(result.token);
|
if (result.token) tokens.push(result.token);
|
||||||
i = result.newIndex;
|
i = result.newIndex;
|
||||||
handled = true;
|
handled = true;
|
||||||
@@ -329,7 +346,7 @@ const parseTokens = (md: string | null | undefined): ParseResult => {
|
|||||||
}
|
}
|
||||||
if (para.length) tokens.push({ type: 'paragraph', text: para.join('\n') });
|
if (para.length) tokens.push({ type: 'paragraph', text: para.join('\n') });
|
||||||
}
|
}
|
||||||
return { tokens, footnotes };
|
return { tokens, footnotes, refs };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ Token rendering ============
|
// ============ Token rendering ============
|
||||||
@@ -348,8 +365,8 @@ const renderTokens = (tokens: Token[], env: RenderEnv = {}, footnotes: Record<st
|
|||||||
};
|
};
|
||||||
|
|
||||||
const parseMarkdown = (md: string | null | undefined, env: RenderEnv = {}): string => {
|
const parseMarkdown = (md: string | null | undefined, env: RenderEnv = {}): string => {
|
||||||
const { tokens, footnotes } = parseTokens(md);
|
const { tokens, footnotes, refs } = parseTokens(md);
|
||||||
return renderTokens(tokens, env, footnotes);
|
return renderTokens(tokens, { ...env, refs }, footnotes);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ List parsing ============
|
// ============ List parsing ============
|
||||||
@@ -623,6 +640,15 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
|||||||
if (match.startsWith('![') && match.includes('][')) {
|
if (match.startsWith('![') && match.includes('][')) {
|
||||||
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
|
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
|
||||||
if (!m) return match;
|
if (!m) return match;
|
||||||
|
const refKey = (m[2] || m[1]).toLowerCase();
|
||||||
|
if (env.refs && env.refs[refKey]) {
|
||||||
|
try {
|
||||||
|
const ref = JSON.parse(env.refs[refKey]);
|
||||||
|
const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
|
||||||
|
const t = ref.title ? ` title="${ref.title}"` : '';
|
||||||
|
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
|
||||||
|
} 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="${m[1]}" class="me-img-ref"/>`;
|
||||||
}
|
}
|
||||||
if (match.startsWith('[') && match.includes('](')) {
|
if (match.startsWith('[') && match.includes('](')) {
|
||||||
@@ -633,7 +659,20 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
|
|||||||
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="${u}"${t} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
|
||||||
}
|
}
|
||||||
if (match.startsWith('[') && match.includes('][')) return match;
|
if (match.startsWith('[') && match.includes('][')) {
|
||||||
|
const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/);
|
||||||
|
if (!m) return match;
|
||||||
|
const refKey = (m[2] || m[1]).toLowerCase();
|
||||||
|
if (env.refs && env.refs[refKey]) {
|
||||||
|
try {
|
||||||
|
const ref = JSON.parse(env.refs[refKey]);
|
||||||
|
const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
|
||||||
|
const t = ref.title ? ` title="${ref.title}"` : '';
|
||||||
|
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
|
||||||
|
} catch (_) { return escapeHTML(match); }
|
||||||
|
}
|
||||||
|
return escapeHTML(match);
|
||||||
|
}
|
||||||
if (match.startsWith('<http')) {
|
if (match.startsWith('<http')) {
|
||||||
const url = match.slice(4, -4);
|
const url = match.slice(4, -4);
|
||||||
const u = safeUrl(url);
|
const u = safeUrl(url);
|
||||||
|
|||||||
+136
-24
@@ -109,32 +109,39 @@ export const validateConfig = (schema: PluginSchema = {}, config: Record<string,
|
|||||||
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
const autoSavePlugin: Plugin = {
|
const autoSavePlugin: Plugin = {
|
||||||
name: 'autoSave', version: '0.1.0', description: 'Auto-save to localStorage', priority: 100,
|
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
|
||||||
install(editor) {
|
install(editor, options?: Record<string, any>) {
|
||||||
if (!editor || typeof editor.getValue !== 'function') return;
|
if (!editor || typeof editor.getValue !== 'function') return;
|
||||||
const key = (this as any).key || ('me-draft-' + (editor.id || ''));
|
const opts = options || (this as any);
|
||||||
|
const key: string = opts.key || ('me-draft-' + (editor.id || ''));
|
||||||
|
const delay: number = opts.delay || 1000;
|
||||||
|
const state = { _timer: null as ReturnType<typeof setTimeout> | null };
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if ((this as any)._timer) { clearTimeout((this as any)._timer); (this as any)._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); }
|
||||||
};
|
};
|
||||||
(this as any)._save = save;
|
const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); };
|
||||||
(this as any)._onInput = () => { if ((this as any)._timer) clearTimeout((this as any)._timer); (this as any)._timer = setTimeout(save, (this as any).delay || 1000); };
|
const _onBlur = save;
|
||||||
(this as any)._onBlur = save;
|
const _onSave = save;
|
||||||
(this as any)._onSave = save;
|
editor.on('change', _onInput);
|
||||||
editor.on('change', (this as any)._onInput);
|
editor.on('blur', _onBlur);
|
||||||
editor.on('blur', (this as any)._onBlur);
|
editor.on('save', _onSave);
|
||||||
editor.on('save', (this as any)._onSave);
|
|
||||||
editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null) editor.setValue(v); return v; } catch (_) { return null; } };
|
editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null) editor.setValue(v); return v; } catch (_) { return null; } };
|
||||||
editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; };
|
editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; };
|
||||||
editor.getDraftKey = () => key;
|
editor.getDraftKey = () => key;
|
||||||
|
(editor as any).__autoSaveCleanup = { state, _onInput, _onBlur, _onSave, save };
|
||||||
},
|
},
|
||||||
destroy(editor) {
|
destroy(editor) {
|
||||||
if ((this as any)._timer) { clearTimeout((this as any)._timer); (this as any)._timer = null; }
|
const cleanup = (editor as any).__autoSaveCleanup;
|
||||||
if (editor && typeof editor.off === 'function') {
|
if (cleanup) {
|
||||||
if ((this as any)._onInput) editor.off('change', (this as any)._onInput);
|
if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; }
|
||||||
if ((this as any)._onBlur) editor.off('blur', (this as any)._onBlur);
|
if (editor && typeof editor.off === 'function') {
|
||||||
if ((this as any)._onSave) editor.off('save', (this as any)._onSave);
|
if (cleanup._onInput) editor.off('change', cleanup._onInput);
|
||||||
|
if (cleanup._onBlur) editor.off('blur', cleanup._onBlur);
|
||||||
|
if (cleanup._onSave) editor.off('save', cleanup._onSave);
|
||||||
|
}
|
||||||
|
delete (editor as any).__autoSaveCleanup;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -157,7 +164,42 @@ const exportToolPlugin: Plugin = {
|
|||||||
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() : '';
|
||||||
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>`:''}\n</head>\n<body>\n${body}\n</body>\n</html>`, 'text/html');
|
// Build minimal embedded CSS for the exported HTML
|
||||||
|
const embedCSS = opts.embedCSS !== false ? `<style>
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;font-size:14px;line-height:1.6;color:#1f2937;max-width:860px;margin:0 auto;padding:20px}
|
||||||
|
h1,h2,h3,h4,h5,h6{margin:1.4em 0 .6em;font-weight:650;line-height:1.3}
|
||||||
|
h1{font-size:1.9em;padding-bottom:.3em;border-bottom:1px solid #e5e7eb}
|
||||||
|
h2{font-size:1.55em;padding-bottom:.3em;border-bottom:1px solid #e5e7eb}
|
||||||
|
h3{font-size:1.3em}h4{font-size:1.12em}h5{font-size:1em}h6{font-size:.9em;color:#6b7280}
|
||||||
|
p{margin:.7em 0}a{color:#3b82f6;text-decoration:none}a:hover{text-decoration:underline}
|
||||||
|
strong{font-weight:650}em{font-style:italic}del{text-decoration:line-through;opacity:.75}
|
||||||
|
ul,ol{margin:.6em 0;padding-left:1.6em}li{margin:.25em 0}
|
||||||
|
li.me-task-item{list-style:none;margin-left:-1.4em}
|
||||||
|
li.me-task-item input{margin-right:.5em;vertical-align:middle}
|
||||||
|
blockquote{margin:.8em 0;padding:.4em 1em;border-left:3px solid #3b82f6;background:#f3f4f6;border-radius:0 6px 6px 0}
|
||||||
|
hr{border:0;height:1px;background:#e5e7eb;margin:1.6em 0}
|
||||||
|
code{font-family:"SF Mono",Consolas,monospace;font-size:.88em;padding:.15em .4em;background:#f3f4f6;border-radius:4px}
|
||||||
|
pre{margin:.9em 0;padding:14px 16px;background:#f3f4f6;border-radius:8px;overflow-x:auto;border:1px solid #e5e7eb}
|
||||||
|
pre code{padding:0;background:transparent;font-size:.9em;line-height:1.6;border-radius:0}
|
||||||
|
img{max-width:100%;height:auto;border-radius:6px}
|
||||||
|
table{border-collapse:collapse;width:100%;font-size:.93em;display:block;overflow-x:auto}
|
||||||
|
th,td{border:1px solid #e5e7eb;padding:7px 12px;text-align:left}
|
||||||
|
th{background:#f3f4f6;font-weight:600}
|
||||||
|
tr:nth-child(even) td{background:#f9fafb}
|
||||||
|
mark{background:rgba(250,204,21,.3);color:inherit;padding:.1em .2em;border-radius:3px}
|
||||||
|
sup{font-size:.75em}sub{font-size:.75em}
|
||||||
|
.me-math-block{display:block;margin:1.2em 0;padding:12px 16px;background:#f3f4f6;border-radius:8px;overflow-x:auto;font-family:monospace;text-align:center}
|
||||||
|
.me-math-inline{font-family:monospace}
|
||||||
|
dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;color:#4b5563}
|
||||||
|
.me-footnote-ref a{font-size:.75em;vertical-align:super;text-decoration:none;color:#3b82f6}
|
||||||
|
.me-footnotes{margin-top:2em;border-top:1px solid #e5e7eb;padding-top:.8em;font-size:.9em;color:#6b7280}
|
||||||
|
.me-footnotes hr{display:none}
|
||||||
|
.me-footnotes ol{padding-left:1.2em}
|
||||||
|
.me-footnote-item{margin:.3em 0}
|
||||||
|
.me-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em}
|
||||||
|
.me-table-wrap{overflow-x:auto;margin:.9em 0}
|
||||||
|
</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 editor;
|
return editor;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -197,19 +239,89 @@ 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-prev">↑</button><button class="me-search-next">↓</button><span class="me-search-count"></span><button class="me-search-close">×</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-replace-one">${t('replace')||'Replace'}</button><button class="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-prev">↑</button><button class="me-search-next">↓</button><span class="me-search-count"></span><button class="me-search-regex" title="Regex">.*</button><button class="me-search-close">×</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-replace-one">${t('replace')||'Replace'}</button><button class="me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
|
||||||
editor.el.appendChild(panel); self._panel = panel;
|
editor.el.appendChild(panel); self._panel = panel;
|
||||||
self._updateReplaceVisible();
|
self._updateReplaceVisible();
|
||||||
|
self._regexMode = 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 findAll = () => { const q = fi.value; if (!q) { ce.textContent = ''; return []; } const idxs: number[] = []; let from = 0; 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'; return idxs; };
|
const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement;
|
||||||
|
const toggleRegex = () => {
|
||||||
|
self._regexMode = !self._regexMode;
|
||||||
|
regexBtn.classList.toggle('me-active', self._regexMode);
|
||||||
|
lastIdxs = findAll();
|
||||||
|
};
|
||||||
|
regexBtn.addEventListener('click', toggleRegex);
|
||||||
|
const findAll = () => {
|
||||||
|
const q = fi.value; if (!q) { ce.textContent = ''; return []; }
|
||||||
|
const idxs: number[] = []; let from = 0;
|
||||||
|
if (self._regexMode) {
|
||||||
|
try {
|
||||||
|
const re = new RegExp(q, 'g'); let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(editor.textarea.value)) !== null) {
|
||||||
|
idxs.push(m.index);
|
||||||
|
if (m[0].length === 0) re.lastIndex++;
|
||||||
|
}
|
||||||
|
} catch (_) { ce.textContent = 'err'; return []; }
|
||||||
|
} else {
|
||||||
|
const lower = editor.textarea.value;
|
||||||
|
while (true) { const idx = lower.indexOf(q, from); if (idx === -1) break; idxs.push(idx); from = idx + q.length; }
|
||||||
|
}
|
||||||
|
ce.textContent = idxs.length ? `${idxs.length}` : '0';
|
||||||
|
return idxs;
|
||||||
|
};
|
||||||
let lastIdxs: number[] = [];
|
let lastIdxs: number[] = [];
|
||||||
const findNext = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionEnd; let next = lastIdxs.find((i: number) => i >= cur); if (next == null) next = lastIdxs[0]; selectAt(next); };
|
const findNext = () => {
|
||||||
const findPrev = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionStart; let prev = -1; 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]; selectAt(prev); };
|
lastIdxs = findAll(); if (!lastIdxs.length) return;
|
||||||
const selectAt = (idx: number) => { editor.textarea.focus(); editor.textarea.setSelectionRange(idx, idx + fi.value.length); };
|
const cur = editor.textarea.selectionEnd;
|
||||||
const replaceOne = () => { const q = fi.value, r = ri.value; if (!q) return; const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd; if (ta.value.substring(s, e) === q) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory(); if (typeof editor._render === 'function') editor._render(); if (typeof editor._emit === 'function') editor._emit('change', editor._value); } findNext(); };
|
const qlen = self._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;
|
||||||
const replaceAll = () => { const q = fi.value, r = ri.value; if (!q) return; const ta = editor.textarea; const before = ta.value; const after = before.split(q).join(r); if (before === after) return; ta.value = after; ta.setSelectionRange(0,0); editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory(); if (typeof editor._render === 'function') editor._render(); if (typeof editor._emit === 'function') editor._emit('change', editor._value); findAll(); };
|
let next = lastIdxs.find((i: number) => i >= cur);
|
||||||
|
if (next == null) next = lastIdxs[0];
|
||||||
|
selectAt(next, qlen);
|
||||||
|
};
|
||||||
|
const findPrev = () => {
|
||||||
|
lastIdxs = findAll(); if (!lastIdxs.length) return;
|
||||||
|
const cur = editor.textarea.selectionStart;
|
||||||
|
const qlen = self._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;
|
||||||
|
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];
|
||||||
|
selectAt(prev, qlen);
|
||||||
|
};
|
||||||
|
const selectAt = (idx: number, len?: number) => {
|
||||||
|
editor.textarea.focus();
|
||||||
|
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
|
||||||
|
};
|
||||||
|
const replaceOne = () => {
|
||||||
|
const q = fi.value, r = ri.value; if (!q) return;
|
||||||
|
const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd;
|
||||||
|
const matchText = ta.value.substring(s, e);
|
||||||
|
if (self._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 (_) {}
|
||||||
|
} else if (matchText === q) {
|
||||||
|
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
|
||||||
|
ta.setSelectionRange(s, s + r.length);
|
||||||
|
}
|
||||||
|
editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory();
|
||||||
|
if (typeof editor._render === 'function') editor._render();
|
||||||
|
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
|
||||||
|
findNext();
|
||||||
|
};
|
||||||
|
const replaceAll = () => {
|
||||||
|
const q = fi.value, r = ri.value; if (!q) return;
|
||||||
|
const ta = editor.textarea;
|
||||||
|
if (self._regexMode) {
|
||||||
|
try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; }
|
||||||
|
} else {
|
||||||
|
ta.value = ta.value.split(q).join(r);
|
||||||
|
}
|
||||||
|
ta.setSelectionRange(0,0); editor._value = ta.value;
|
||||||
|
if (typeof editor._pushHistory === 'function') editor._pushHistory();
|
||||||
|
if (typeof editor._render === 'function') editor._render();
|
||||||
|
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
|
||||||
|
findAll();
|
||||||
|
};
|
||||||
fi.addEventListener('input', () => { lastIdxs = findAll(); });
|
fi.addEventListener('input', () => { lastIdxs = findAll(); });
|
||||||
fi.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); } if (e.key === 'Escape') { e.preventDefault(); this._close!(editor); } });
|
fi.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); } if (e.key === 'Escape') { e.preventDefault(); this._close!(editor); } });
|
||||||
ri.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); replaceOne(); } if (e.key === 'Escape') { e.preventDefault(); this._close!(editor); } });
|
ri.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); replaceOne(); } if (e.key === 'Escape') { e.preventDefault(); this._close!(editor); } });
|
||||||
|
|||||||
+19
-1
@@ -113,6 +113,7 @@ const generateCSS = (): string => {
|
|||||||
.me-context-menu-item:hover{background:var(--md-code-bg);color:var(--md-accent)}
|
.me-context-menu-item:hover{background:var(--md-code-bg);color:var(--md-accent)}
|
||||||
.me-context-menu-sep{height:1px;background:var(--md-border);margin:4px 0}
|
.me-context-menu-sep{height:1px;background:var(--md-border);margin:4px 0}
|
||||||
.me-context-menu-shortcut{color:var(--md-muted);font-size:11px;margin-left:24px}
|
.me-context-menu-shortcut{color:var(--md-muted);font-size:11px;margin-left:24px}
|
||||||
|
.me-context-menu-item.me-disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
|
||||||
.me-outline{position:absolute;top:0;right:0;width:220px;height:100%;overflow-y:auto;background:var(--md-preview-bg);border-left:1px solid var(--md-border);padding:12px 14px;font-size:13px;z-index:15}
|
.me-outline{position:absolute;top:0;right:0;width:220px;height:100%;overflow-y:auto;background:var(--md-preview-bg);border-left:1px solid var(--md-border);padding:12px 14px;font-size:13px;z-index:15}
|
||||||
.me-outline-title{font-weight:650;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--md-border);color:var(--md-text)}
|
.me-outline-title{font-weight:650;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--md-border);color:var(--md-text)}
|
||||||
.me-outline ul{list-style:none;padding:0;margin:0}
|
.me-outline ul{list-style:none;padding:0;margin:0}
|
||||||
@@ -131,7 +132,24 @@ const generateCSS = (): string => {
|
|||||||
.me-body.me-mode-split .me-editor-pane{display:none}
|
.me-body.me-mode-split .me-editor-pane{display:none}
|
||||||
.me-body.me-mode-split .me-preview-pane{flex:1 1 100%!important}
|
.me-body.me-mode-split .me-preview-pane{flex:1 1 100%!important}
|
||||||
.me-preview{padding:0!important;font-size:11pt}
|
.me-preview{padding:0!important;font-size:11pt}
|
||||||
.me-wrapper.me-fullscreen{position:static!important;width:auto!important;height:auto!important}}`;
|
.me-wrapper.me-fullscreen{position:static!important;width:auto!important;height:auto!important}}
|
||||||
|
[dir=rtl] .me-gutter{border-right:none;border-left:1px solid var(--md-border);text-align:left}
|
||||||
|
[dir=rtl] .me-gutter-line{padding-right:0;padding-left:4px}
|
||||||
|
[dir=rtl] .me-toolbar-group{margin-left:0;margin-right:auto;padding-left:0;padding-right:6px;border-left:none;border-right:1px solid var(--md-border)}
|
||||||
|
[dir=rtl] .me-outline{right:auto;left:0;border-left:none;border-right:1px solid var(--md-border)}
|
||||||
|
[dir=rtl] .me-outline-l2 a{padding-left:0;padding-right:12px}
|
||||||
|
[dir=rtl] .me-outline-l3 a{padding-left:0;padding-right:20px}
|
||||||
|
[dir=rtl] .me-outline-l4 a{padding-left:0;padding-right:28px}
|
||||||
|
[dir=rtl] .me-preview blockquote{border-left:none;border-right:3px solid var(--md-accent);border-radius:6px 0 0 6px}
|
||||||
|
[dir=rtl] .me-preview ul,[dir=rtl] .me-preview ol{padding-left:0;padding-right:1.6em}
|
||||||
|
[dir=rtl] .me-preview li.me-task-item{margin-left:0;margin-right:-1.4em}
|
||||||
|
[dir=rtl] .me-preview dd{margin-left:0;margin-right:1.6em}
|
||||||
|
[dir=rtl] .me-preview .me-footnotes ol{padding-left:0;padding-right:1.2em}
|
||||||
|
[dir=rtl] .me-preview .me-footnote-backref{margin-right:0;margin-left:.4em}
|
||||||
|
[dir=rtl] .me-search{right:auto;left:12px}
|
||||||
|
[dir=rtl] .me-toast{right:auto;left:16px}
|
||||||
|
[dir=rtl] .me-context-menu-shortcut{margin-left:0;margin-right:24px}
|
||||||
|
[dir=rtl] .me-statusbar{justify-content:flex-start}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const injectStyles = (): void => {
|
export const injectStyles = (): void => {
|
||||||
|
|||||||
+217
-1
@@ -536,7 +536,7 @@ describe('MarkdownEditor - 构造边界', () => {
|
|||||||
destroy: jest.fn(),
|
destroy: jest.fn(),
|
||||||
};
|
};
|
||||||
const ed = new MarkdownEditor(document.createElement('div'), { plugins: [plugin] });
|
const ed = new MarkdownEditor(document.createElement('div'), { plugins: [plugin] });
|
||||||
expect(plugin.install).toHaveBeenCalledWith(ed);
|
expect(plugin.install).toHaveBeenCalledWith(ed, {});
|
||||||
ed.destroy();
|
ed.destroy();
|
||||||
expect(plugin.destroy).toHaveBeenCalledWith(ed);
|
expect(plugin.destroy).toHaveBeenCalledWith(ed);
|
||||||
});
|
});
|
||||||
@@ -1731,3 +1731,219 @@ describe('MarkdownEditor - v0.2.0 getStatus plugins', () => {
|
|||||||
ed.destroy();
|
ed.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ v0.2.1 覆盖率补齐测试 ============
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.1 工具栏键盘导航', () => {
|
||||||
|
test('ArrowRight 聚焦下一个按钮', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
|
||||||
|
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
|
||||||
|
const first = btns[0] as HTMLButtonElement;
|
||||||
|
first.focus();
|
||||||
|
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
// 焦点应移动
|
||||||
|
expect(document.activeElement).not.toBe(first);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ArrowLeft 聚焦上一个按钮', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
|
||||||
|
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
|
||||||
|
(btns[1] as HTMLButtonElement).focus();
|
||||||
|
(btns[1] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(btns[0]);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Home 聚焦第一个按钮', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
|
||||||
|
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
|
||||||
|
(btns[2] as HTMLButtonElement).focus();
|
||||||
|
(btns[2] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(btns[0]);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('End 聚焦最后一个按钮', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
|
||||||
|
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
|
||||||
|
(btns[0] as HTMLButtonElement).focus();
|
||||||
|
(btns[0] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(btns[btns.length - 1]);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.1 ARIA live', () => {
|
||||||
|
test('_initAriaLive 创建 sr-only 元素', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
const live = ed.el.querySelector('.me-sr-only');
|
||||||
|
expect(live).not.toBeNull();
|
||||||
|
expect(live!.getAttribute('aria-live')).toBe('polite');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_announce 设置消息', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
ed._announce('test message');
|
||||||
|
const live = ed.el.querySelector('.me-sr-only') as HTMLElement;
|
||||||
|
expect(live).not.toBeNull();
|
||||||
|
// jsdom 中 requestAnimationFrame 可能不被支持,但不应抛错
|
||||||
|
expect(() => ed._announce('hello')).not.toThrow();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.1 当前行高亮', () => {
|
||||||
|
test('_updateCurrentLine 高亮当前行', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'line1\nline2\nline3', lineNumbers: true });
|
||||||
|
ed.textarea.setSelectionRange(7, 7); // 第二行
|
||||||
|
ed._updateCurrentLine();
|
||||||
|
const active = ed.gutter.querySelector('.me-gutter-active');
|
||||||
|
expect(active).not.toBeNull();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_updateCurrentLine 无 gutter 时不抛错', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'test', lineNumbers: false });
|
||||||
|
expect(() => ed._updateCurrentLine()).not.toThrow();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.1 右键上下文菜单', () => {
|
||||||
|
test('contextmenu 事件显示菜单', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'hello' });
|
||||||
|
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 100, clientY: 100, bubbles: true }));
|
||||||
|
const menu = document.querySelector('.me-context-menu');
|
||||||
|
expect(menu).not.toBeNull();
|
||||||
|
// 清理
|
||||||
|
if (menu) menu.remove();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('上下文菜单点击外部关闭', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'hello' });
|
||||||
|
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 100, clientY: 100, bubbles: true }));
|
||||||
|
expect(document.querySelector('.me-context-menu')).not.toBeNull();
|
||||||
|
document.body.click();
|
||||||
|
// once listener removes it
|
||||||
|
expect(document.querySelector('.me-context-menu')).toBeNull();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerContextMenu 添加自定义菜单项', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
ed.registerContextMenu([{ label: 'Custom Action', onClick: jest.fn() }]);
|
||||||
|
expect(ed._contextMenuItems.length).toBe(1);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.1 copy API', () => {
|
||||||
|
test('copyAsMarkdown 不抛错', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '# hello' });
|
||||||
|
expect(() => ed.copyAsMarkdown()).not.toThrow();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyAsHTML 不抛错', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '# hello' });
|
||||||
|
expect(() => ed.copyAsHTML()).not.toThrow();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.1 beforeChange/afterChange 钩子', () => {
|
||||||
|
test('setValue 触发 beforeChange', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'old' });
|
||||||
|
const handler = jest.fn();
|
||||||
|
ed.on('beforeChange', handler);
|
||||||
|
ed.setValue('new');
|
||||||
|
expect(handler).toHaveBeenCalledWith('old', 'new', ed);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setValue 触发 afterChange', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'old' });
|
||||||
|
const handler = jest.fn();
|
||||||
|
ed.on('afterChange', handler);
|
||||||
|
ed.setValue('new');
|
||||||
|
expect(handler).toHaveBeenCalledWith('new', ed);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('全局 beforeChange/afterChange 钩子', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const beforeFn = jest.fn();
|
||||||
|
const afterFn = jest.fn();
|
||||||
|
MarkdownEditor.on('beforeChange', beforeFn);
|
||||||
|
MarkdownEditor.on('afterChange', afterFn);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'test' });
|
||||||
|
ed.setValue('updated');
|
||||||
|
expect(beforeFn).toHaveBeenCalled();
|
||||||
|
expect(afterFn).toHaveBeenCalled();
|
||||||
|
MarkdownEditor.off('beforeChange', beforeFn);
|
||||||
|
MarkdownEditor.off('afterChange', afterFn);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.1 分隔条持久化', () => {
|
||||||
|
test('_saveDividerPosition / _restoreDividerPosition 不抛错', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { mode: 'split' });
|
||||||
|
expect(() => ed._saveDividerPosition()).not.toThrow();
|
||||||
|
expect(() => ed._restoreDividerPosition()).not.toThrow();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* index.ts 单元测试
|
||||||
|
* 覆盖全局 API:create / use / on / off / setTheme / setLocale / destroy / getStatus
|
||||||
|
*/
|
||||||
|
|
||||||
|
import MeEditor, {
|
||||||
|
create, use, on, off, setTheme, setLocale, destroy, getStatus,
|
||||||
|
parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache,
|
||||||
|
registerBlockHandler, topologicalSort, validateConfig,
|
||||||
|
VERSION,
|
||||||
|
} from '../src/index';
|
||||||
|
import { MarkdownEditor } from '../src/core';
|
||||||
|
|
||||||
|
describe('index.ts - 全局 API', () => {
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.id = 'test-host';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
// Set MeEditor on window as index.ts does
|
||||||
|
(window as any).MeEditor = MeEditor;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VERSION 是字符串', () => {
|
||||||
|
expect(typeof VERSION).toBe('string');
|
||||||
|
expect(VERSION).toBe('0.2.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api.default 是 api 本身', () => {
|
||||||
|
expect(MeEditor).toBeDefined();
|
||||||
|
expect(MeEditor.VERSION).toBe(VERSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create 创建编辑器实例', () => {
|
||||||
|
const editor = create('#test-host', { value: '# Hello' });
|
||||||
|
expect(editor).toBeInstanceOf(MarkdownEditor);
|
||||||
|
expect(editor.getValue()).toBe('# Hello');
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create 接受 HTMLElement', () => {
|
||||||
|
const editor = create(container, { value: 'text' });
|
||||||
|
expect(editor.getValue()).toBe('text');
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('use 注册全局插件(通过字符串)', () => {
|
||||||
|
const result = use('autoSave', { delay: 100 });
|
||||||
|
expect(result).toBe(MeEditor); // 返回 api(链式)
|
||||||
|
const status = getStatus();
|
||||||
|
expect(status.globalPlugins).toContain('autoSave');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('use 未知预设插件 warn', () => {
|
||||||
|
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
use('non-existent-preset');
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('use 无效插件对象 warn', () => {
|
||||||
|
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
use(null);
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('全局插件被注入到新实例', () => {
|
||||||
|
// Reset global plugins by destroying
|
||||||
|
destroy();
|
||||||
|
use('autoSave');
|
||||||
|
const ed = create(container, { value: 'test' });
|
||||||
|
const plugins = ed.getPlugins();
|
||||||
|
expect(plugins.some((p: any) => p.name === 'autoSave')).toBe(true);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on / off 全局钩子', () => {
|
||||||
|
const fn = jest.fn();
|
||||||
|
const unsub = on('afterCreate', fn);
|
||||||
|
expect(typeof unsub).toBe('function');
|
||||||
|
const ed = create(container, {});
|
||||||
|
expect(fn).toHaveBeenCalled();
|
||||||
|
off('afterCreate', fn);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setTheme 切换全局主题', () => {
|
||||||
|
expect(() => setTheme('dark')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setLocale 切换全局语言', () => {
|
||||||
|
expect(() => setLocale('en-US')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getStatus 返回状态对象', () => {
|
||||||
|
const status = getStatus();
|
||||||
|
expect(status).toHaveProperty('version');
|
||||||
|
expect(status).toHaveProperty('theme');
|
||||||
|
expect(status).toHaveProperty('locale');
|
||||||
|
expect(status).toHaveProperty('globalPlugins');
|
||||||
|
expect(status).toHaveProperty('presetPlugins');
|
||||||
|
expect(Array.isArray(status.globalPlugins)).toBe(true);
|
||||||
|
expect(Array.isArray(status.presetPlugins)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy 清理全局资源', () => {
|
||||||
|
expect(() => destroy()).not.toThrow();
|
||||||
|
const status = getStatus();
|
||||||
|
expect(status.globalPlugins.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('window.MeEditor 被设置', () => {
|
||||||
|
expect((window as any).MeEditor).toBeDefined();
|
||||||
|
expect((window as any).MeEditor.VERSION).toBe(VERSION);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('index.ts - 解析器导出', () => {
|
||||||
|
test('parseMarkdown 可直接调用', () => {
|
||||||
|
const html = parseMarkdown('# Hello');
|
||||||
|
expect(html).toContain('<h1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseTokens 可直接调用', () => {
|
||||||
|
const { tokens, footnotes, refs } = parseTokens('# Hi\n\n**bold**');
|
||||||
|
expect(Array.isArray(tokens)).toBe(true);
|
||||||
|
expect(tokens.length).toBeGreaterThan(0);
|
||||||
|
expect(typeof footnotes).toBe('object');
|
||||||
|
expect(typeof refs).toBe('object');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderTokens 可直接调用', () => {
|
||||||
|
const { tokens } = parseTokens('**hello**');
|
||||||
|
const html = renderTokens(tokens);
|
||||||
|
expect(html).toContain('<strong>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('safeUrl 过滤危险协议', () => {
|
||||||
|
expect(safeUrl('javascript:alert(1)')).toBe('');
|
||||||
|
expect(safeUrl('https://example.com')).toBe('https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slugify 生成锚点 id', () => {
|
||||||
|
expect(slugify('Hello World')).toBe('hello-world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearRenderCache 不抛错', () => {
|
||||||
|
expect(() => clearRenderCache()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerBlockHandler 注册自定义块', () => {
|
||||||
|
registerBlockHandler({
|
||||||
|
name: 'testBlock',
|
||||||
|
priority: 50,
|
||||||
|
test: () => null,
|
||||||
|
parse: (_l, i) => ({ token: null, newIndex: i }),
|
||||||
|
});
|
||||||
|
// 不抛错即通过
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('index.ts - 具名导出', () => {
|
||||||
|
test('create 是函数', () => expect(typeof create).toBe('function'));
|
||||||
|
test('use 是函数', () => expect(typeof use).toBe('function'));
|
||||||
|
test('on 是函数', () => expect(typeof on).toBe('function'));
|
||||||
|
test('off 是函数', () => expect(typeof off).toBe('function'));
|
||||||
|
test('setTheme 是函数', () => expect(typeof setTheme).toBe('function'));
|
||||||
|
test('setLocale 是函数', () => expect(typeof setLocale).toBe('function'));
|
||||||
|
test('destroy 是函数', () => expect(typeof destroy).toBe('function'));
|
||||||
|
test('getStatus 是函数', () => expect(typeof getStatus).toBe('function'));
|
||||||
|
test('topologicalSort 是函数', () => expect(typeof topologicalSort).toBe('function'));
|
||||||
|
test('validateConfig 是函数', () => expect(typeof validateConfig).toBe('function'));
|
||||||
|
});
|
||||||
@@ -1118,3 +1118,110 @@ describe('parseMarkdown - 分支:嵌套列表子项渲染', () => {
|
|||||||
expect(innerUlCount).toBeGreaterThanOrEqual(2);
|
expect(innerUlCount).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ v0.2.1 引用链接/图片测试 ============
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.2.1 引用链接', () => {
|
||||||
|
test('引用链接 [text][ref] 解析为链接', () => {
|
||||||
|
const md = 'Click [here][1]\n\n[1]: https://example.com';
|
||||||
|
const html = parseMarkdown(md);
|
||||||
|
expect(html).toContain('<a href="https://example.com"');
|
||||||
|
expect(html).toContain('>here</a>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用链接忽略大小写', () => {
|
||||||
|
const md = 'Visit [Site][MyRef]\n\n[myref]: https://example.com';
|
||||||
|
const html = parseMarkdown(md);
|
||||||
|
expect(html).toContain('<a href="https://example.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用链接无定义时保留原文', () => {
|
||||||
|
const md = 'Click [here][undef]';
|
||||||
|
const html = parseMarkdown(md);
|
||||||
|
expect(html).not.toContain('<a href=');
|
||||||
|
expect(html).toContain('here');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用链接使用隐式引用(同文本)', () => {
|
||||||
|
const md = 'Visit [example][]\n\n[example]: https://example.com';
|
||||||
|
const html = parseMarkdown(md);
|
||||||
|
expect(html).toContain('<a href="https://example.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用图片 ![alt][ref] 解析', () => {
|
||||||
|
const md = '![logo][img1]\n\n[img1]: https://example.com/logo.png';
|
||||||
|
const html = parseMarkdown(md);
|
||||||
|
expect(html).toContain('<img src="https://example.com/logo.png"');
|
||||||
|
expect(html).toContain('alt="logo"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用图片带 title 通过 parseMarkdown', () => {
|
||||||
|
clearRenderCache();
|
||||||
|
const md = '![logo][img1]\n\n[img1]: https://example.com/logo.png "MyLogo"';
|
||||||
|
const html = parseMarkdown(md);
|
||||||
|
expect(html).toContain('src="https://example.com/logo.png"');
|
||||||
|
expect(html).toContain('title="MyLogo"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用链接带 title 通过 parseMarkdown', () => {
|
||||||
|
clearRenderCache();
|
||||||
|
const md = '[link][1]\n\n[1]: https://example.com "MyTitle"';
|
||||||
|
const html = parseMarkdown(md);
|
||||||
|
expect(html).toContain('href="https://example.com"');
|
||||||
|
expect(html).toContain('title="MyTitle"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用链接危险协议被过滤', () => {
|
||||||
|
const md = 'Click [bad][1]\n\n[1]: javascript:alert(1)';
|
||||||
|
const html = parseMarkdown(md);
|
||||||
|
expect(html).not.toContain('href="javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用图片无定义时使用空 src', () => {
|
||||||
|
const md = '![logo][nope]';
|
||||||
|
const html = parseMarkdown(md);
|
||||||
|
expect(html).toContain('src=""');
|
||||||
|
expect(html).toContain('me-img-ref');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用定义不匹配脚注', () => {
|
||||||
|
const md = 'Text[^1]\n\n[^1]: footnote text\n\n[link][lnk]\n\n[lnk]: https://example.com';
|
||||||
|
const html = parseMarkdown(md);
|
||||||
|
expect(html).toContain('me-footnotes');
|
||||||
|
expect(html).toContain('footnote text');
|
||||||
|
expect(html).toContain('<a href="https://example.com"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ v0.2.1 data:image 超限测试 ============
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.2.1 safeUrl 增强', () => {
|
||||||
|
test('超长 data:image URL 被过滤', () => {
|
||||||
|
// 构造一个超过 500000 字符的 data:image URL
|
||||||
|
const longData = 'data:image/png;base64,' + 'A'.repeat(500001);
|
||||||
|
const url = safeUrl(longData);
|
||||||
|
expect(url).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('正常 data:image 通过', () => {
|
||||||
|
const url = safeUrl('data:image/png;base64,abc123');
|
||||||
|
expect(url).toBe('data:image/png;base64,abc123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ v0.2.1 引用链接格式错误容错 ============
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.2.1 引用链接容错', () => {
|
||||||
|
test('格式错误的引用图片不崩溃', () => {
|
||||||
|
const html = parseMarkdown('![broken ref');
|
||||||
|
expect(html).toContain('broken ref');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用定义中 JSON 解析错误被容错', () => {
|
||||||
|
// 正常情况下 refs 中的值是 JSON 字符串
|
||||||
|
const { refs } = parseTokens('[link][test]\n\n[test]: https://example.com');
|
||||||
|
expect(refs['test']).toBeDefined();
|
||||||
|
// 不应抛错
|
||||||
|
expect(() => renderTokens([{ type: 'paragraph', text: '[x][bad]' }], { refs: { bad: 'not-json' } })).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -792,8 +792,9 @@ describe('零散分支补全', () => {
|
|||||||
ed.use('autoSave');
|
ed.use('autoSave');
|
||||||
// 让 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 plugin = ed.getPlugins().find((p) => p.name === 'autoSave');
|
const cleanup = (ed as any).__autoSaveCleanup;
|
||||||
expect(() => plugin._save()).not.toThrow();
|
expect(cleanup).toBeDefined();
|
||||||
|
expect(() => cleanup.save()).not.toThrow();
|
||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
ed.destroy();
|
ed.destroy();
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* styles.ts 单元测试
|
||||||
|
* 覆盖 injectStyles / updateStyles / removeStyles
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { injectStyles, updateStyles, removeStyles, getSystemTheme, watchSystemTheme } from '../src/styles';
|
||||||
|
|
||||||
|
describe('styles.ts - injectStyles', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
removeStyles();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('injectStyles 创建 <style> 元素', () => {
|
||||||
|
injectStyles();
|
||||||
|
const el = document.getElementById('metona-editor-styles');
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
expect(el!.tagName).toBe('STYLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('injectStyles 重复调用不创建重复元素', () => {
|
||||||
|
injectStyles();
|
||||||
|
injectStyles();
|
||||||
|
const els = document.querySelectorAll('#metona-editor-styles');
|
||||||
|
expect(els.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注入的 CSS 包含编辑器样式规则', () => {
|
||||||
|
injectStyles();
|
||||||
|
const el = document.getElementById('metona-editor-styles');
|
||||||
|
const css = el!.textContent || '';
|
||||||
|
expect(css).toContain('.me-wrapper');
|
||||||
|
expect(css).toContain('.me-toolbar');
|
||||||
|
expect(css).toContain('.me-textarea');
|
||||||
|
expect(css).toContain('.me-preview');
|
||||||
|
expect(css).toContain('--md-bg');
|
||||||
|
expect(css).toContain('--md-accent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注入的 CSS 包含 RTL 规则', () => {
|
||||||
|
injectStyles();
|
||||||
|
const el = document.getElementById('metona-editor-styles');
|
||||||
|
const css = el!.textContent || '';
|
||||||
|
expect(css).toContain('[dir=rtl]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注入的 CSS 包含打印样式', () => {
|
||||||
|
injectStyles();
|
||||||
|
const el = document.getElementById('metona-editor-styles');
|
||||||
|
const css = el!.textContent || '';
|
||||||
|
expect(css).toContain('@media print');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注入的 CSS 包含 reduced-motion', () => {
|
||||||
|
injectStyles();
|
||||||
|
const el = document.getElementById('metona-editor-styles');
|
||||||
|
const css = el!.textContent || '';
|
||||||
|
expect(css).toContain('prefers-reduced-motion');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('styles.ts - updateStyles', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
removeStyles();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateStyles 更新已注入的样式', () => {
|
||||||
|
injectStyles();
|
||||||
|
const el = document.getElementById('metona-editor-styles')!;
|
||||||
|
const original = el.textContent;
|
||||||
|
updateStyles();
|
||||||
|
expect(el.textContent).toBe(original); // 内容一致
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateStyles 在未注入时自动注入', () => {
|
||||||
|
removeStyles();
|
||||||
|
expect(document.getElementById('metona-editor-styles')).toBeNull();
|
||||||
|
updateStyles();
|
||||||
|
expect(document.getElementById('metona-editor-styles')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('styles.ts - removeStyles', () => {
|
||||||
|
test('removeStyles 移除样式元素', () => {
|
||||||
|
injectStyles();
|
||||||
|
expect(document.getElementById('metona-editor-styles')).not.toBeNull();
|
||||||
|
removeStyles();
|
||||||
|
expect(document.getElementById('metona-editor-styles')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeStyles 重复调用不抛错', () => {
|
||||||
|
removeStyles();
|
||||||
|
expect(() => removeStyles()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('styles.ts - getSystemTheme', () => {
|
||||||
|
test('返回 light 或 dark', () => {
|
||||||
|
const theme = getSystemTheme();
|
||||||
|
expect(['light', 'dark']).toContain(theme);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('styles.ts - watchSystemTheme', () => {
|
||||||
|
test('返回取消函数', () => {
|
||||||
|
const unsub = watchSystemTheme(() => {});
|
||||||
|
expect(typeof unsub).toBe('function');
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user