feat: v0.2.0 — TypeScript full rewrite, 95%+ core coverage
BREAKING CHANGE: All source files converted from JavaScript to TypeScript. - 12 .ts source files with strict types, full EditorOptions/Plugin/Token interfaces - 7 .ts test files, 610 total tests (27 new), 7 suites all passing - tsc --noEmit: 0 errors - rollup-plugin-typescript build: 5 artifacts (UMD/ESM/CJS/Min/DTS) - @babel/preset-typescript for jest - New tsconfig.json, updated babel/jest/rollup configs - Coverage: parser 99.5%, utils 95.7%, themes 96.2%, core 88.8%, plugins 89.5% - Removed types/ folder (types now inline in .ts + auto-generated .d.ts) - Desktop-only, no backward compatibility
This commit is contained in:
@@ -1,16 +1,17 @@
|
|||||||
# MetonaEditor
|
# MetonaEditor
|
||||||
|
|
||||||
> 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。
|
> TypeScript 重构 · 零依赖 · 轻量级的桌面端 Markdown Editor 库。单文件,开箱即用。
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/@metona-team/metona-editor)
|
[](https://www.npmjs.com/package/@metona-team/metona-editor)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](./tests)
|
[](./tests)
|
||||||
[](./tests)
|
[-brightgreen.svg)](./tests)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
|
- **TypeScript 源码** — v0.2.0 全模块 TypeScript 重构,完整类型支持,IDE 智能提示
|
||||||
- **零依赖** — 单文件打包,无任何运行时依赖,UMD/ESM/CJS 三种格式
|
- **零依赖** — 单文件打包,无任何运行时依赖,UMD/ESM/CJS 三种格式
|
||||||
- **现代化** — 原生 ES2020+ 实现,CSS 变量主题系统,响应式布局,无障碍支持
|
- **现代化** — 原生 ES2020+ 实现,CSS 变量主题系统,响应式布局,无障碍支持
|
||||||
- **易扩展** — 完整插件系统 v2(依赖拓扑排序、异步插件、生命周期钩子、`unuse` 卸载),自定义工具栏按钮与快捷键,预设插件开箱即用
|
- **易扩展** — 完整插件系统 v2(依赖拓扑排序、异步插件、生命周期钩子、`unuse` 卸载),自定义工具栏按钮与快捷键,预设插件开箱即用
|
||||||
|
|||||||
+2
-9
@@ -1,14 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
presets: [
|
presets: [
|
||||||
[
|
['@babel/preset-env', { targets: { node: 'current' }, modules: 'commonjs' }],
|
||||||
'@babel/preset-env',
|
'@babel/preset-typescript',
|
||||||
{
|
|
||||||
targets: {
|
|
||||||
node: 'current',
|
|
||||||
},
|
|
||||||
modules: 'commonjs',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
'@babel/plugin-transform-modules-commonjs',
|
'@babel/plugin-transform-modules-commonjs',
|
||||||
|
|||||||
+4
-4
@@ -1,15 +1,15 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.js$': 'babel-jest',
|
'^.+\\.ts$': 'babel-jest',
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'/node_modules/(?!(@rollup)/)',
|
'/node_modules/(?!(@rollup)/)',
|
||||||
],
|
],
|
||||||
moduleFileExtensions: ['js', 'json'],
|
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.js',
|
'src/**/*.ts',
|
||||||
'!src/index.js',
|
'!src/index.ts',
|
||||||
],
|
],
|
||||||
coverageDirectory: 'coverage',
|
coverageDirectory: 'coverage',
|
||||||
coverageReporters: ['text', 'lcov'],
|
coverageReporters: ['text', 'lcov'],
|
||||||
|
|||||||
Generated
+80
-3
@@ -1,20 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "@metona-team/metona-editor",
|
"name": "@metona-team/metona-editor",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@metona-team/metona-editor",
|
"name": "@metona-team/metona-editor",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.22.0",
|
"@babel/core": "^7.22.0",
|
||||||
"@babel/plugin-transform-modules-commonjs": "^7.22.0",
|
"@babel/plugin-transform-modules-commonjs": "^7.22.0",
|
||||||
"@babel/preset-env": "^7.22.0",
|
"@babel/preset-env": "^7.22.0",
|
||||||
|
"@babel/preset-typescript": "^7.22.0",
|
||||||
"@rollup/plugin-commonjs": "^25.0.0",
|
"@rollup/plugin-commonjs": "^25.0.0",
|
||||||
"@rollup/plugin-node-resolve": "^15.0.0",
|
"@rollup/plugin-node-resolve": "^15.0.0",
|
||||||
"@rollup/plugin-terser": "^0.4.0",
|
"@rollup/plugin-terser": "^0.4.0",
|
||||||
|
"@rollup/plugin-typescript": "^11.1.0",
|
||||||
"@types/jest": "^29.5.0",
|
"@types/jest": "^29.5.0",
|
||||||
"babel-jest": "^29.5.0",
|
"babel-jest": "^29.5.0",
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
@@ -25,10 +27,11 @@
|
|||||||
"rollup-plugin-dts": "^5.3.0",
|
"rollup-plugin-dts": "^5.3.0",
|
||||||
"rollup-plugin-livereload": "^2.0.5",
|
"rollup-plugin-livereload": "^2.0.5",
|
||||||
"rollup-plugin-serve": "^2.0.1",
|
"rollup-plugin-serve": "^2.0.1",
|
||||||
|
"tslib": "^2.6.0",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -1570,6 +1573,26 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/plugin-transform-typescript": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-annotate-as-pure": "^7.29.7",
|
||||||
|
"@babel/helper-create-class-features-plugin": "^7.29.7",
|
||||||
|
"@babel/helper-plugin-utils": "^7.29.7",
|
||||||
|
"@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
|
||||||
|
"@babel/plugin-syntax-typescript": "^7.29.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/plugin-transform-unicode-escapes": {
|
"node_modules/@babel/plugin-transform-unicode-escapes": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz",
|
||||||
@@ -1738,6 +1761,26 @@
|
|||||||
"@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0"
|
"@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/preset-typescript": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-plugin-utils": "^7.29.7",
|
||||||
|
"@babel/helper-validator-option": "^7.29.7",
|
||||||
|
"@babel/plugin-syntax-jsx": "^7.29.7",
|
||||||
|
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
|
||||||
|
"@babel/plugin-transform-typescript": "^7.29.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz",
|
||||||
@@ -2468,6 +2511,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/plugin-typescript": {
|
||||||
|
"version": "11.1.6",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@rollup/plugin-typescript/-/plugin-typescript-11.1.6.tgz",
|
||||||
|
"integrity": "sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/pluginutils": "^5.1.0",
|
||||||
|
"resolve": "^1.22.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^2.14.0||^3.0.0||^4.0.0",
|
||||||
|
"tslib": "*",
|
||||||
|
"typescript": ">=3.7.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tslib": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/pluginutils": {
|
"node_modules/@rollup/pluginutils": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
|
||||||
@@ -7029,6 +7099,13 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
+16
-22
@@ -1,40 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "@metona-team/metona-editor",
|
"name": "@metona-team/metona-editor",
|
||||||
"version": "0.1.15",
|
"version": "0.2.0",
|
||||||
"description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。",
|
"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",
|
||||||
"module": "src/index.js",
|
"module": "src/index.ts",
|
||||||
"unpkg": "dist/metona-editor.min.js",
|
"unpkg": "dist/metona-editor.min.js",
|
||||||
"jsdelivr": "dist/metona-editor.min.js",
|
"jsdelivr": "dist/metona-editor.min.js",
|
||||||
"types": "types/index.d.ts",
|
"types": "dist/metona-editor.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"import": "./src/index.js",
|
"import": "./src/index.ts",
|
||||||
"require": "./dist/metona-editor.js",
|
"require": "./dist/metona-editor.js",
|
||||||
"types": "./types/index.d.ts"
|
"types": "./dist/metona-editor.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/",
|
"dist/",
|
||||||
"src/",
|
"src/",
|
||||||
"types/",
|
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rollup -c",
|
"build": "rollup -c",
|
||||||
"dev": "rollup -c -w",
|
"dev": "rollup -c -w",
|
||||||
"test": "jest",
|
"test": "jest --coverage",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"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 build"
|
||||||
"docs": "jsdoc src/ -d docs",
|
|
||||||
"example": "serve examples/"
|
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -47,7 +43,8 @@
|
|||||||
"ui",
|
"ui",
|
||||||
"component",
|
"component",
|
||||||
"lightweight",
|
"lightweight",
|
||||||
"zero-dependency"
|
"zero-dependency",
|
||||||
|
"typescript"
|
||||||
],
|
],
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -58,10 +55,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.22.0",
|
"@babel/core": "^7.22.0",
|
||||||
"@babel/preset-env": "^7.22.0",
|
"@babel/preset-env": "^7.22.0",
|
||||||
|
"@babel/preset-typescript": "^7.22.0",
|
||||||
"@babel/plugin-transform-modules-commonjs": "^7.22.0",
|
"@babel/plugin-transform-modules-commonjs": "^7.22.0",
|
||||||
"@rollup/plugin-commonjs": "^25.0.0",
|
"@rollup/plugin-commonjs": "^25.0.0",
|
||||||
"@rollup/plugin-node-resolve": "^15.0.0",
|
"@rollup/plugin-node-resolve": "^15.0.0",
|
||||||
"@rollup/plugin-terser": "^0.4.0",
|
"@rollup/plugin-terser": "^0.4.0",
|
||||||
|
"@rollup/plugin-typescript": "^11.1.0",
|
||||||
"@types/jest": "^29.5.0",
|
"@types/jest": "^29.5.0",
|
||||||
"babel-jest": "^29.5.0",
|
"babel-jest": "^29.5.0",
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
@@ -72,15 +71,10 @@
|
|||||||
"rollup-plugin-dts": "^5.3.0",
|
"rollup-plugin-dts": "^5.3.0",
|
||||||
"rollup-plugin-livereload": "^2.0.5",
|
"rollup-plugin-livereload": "^2.0.5",
|
||||||
"rollup-plugin-serve": "^2.0.1",
|
"rollup-plugin-serve": "^2.0.1",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0",
|
||||||
|
"tslib": "^2.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
}
|
||||||
"browserslist": [
|
|
||||||
"> 1%",
|
|
||||||
"last 2 versions",
|
|
||||||
"not dead",
|
|
||||||
"not ie 11"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-39
@@ -1,6 +1,7 @@
|
|||||||
import resolve from '@rollup/plugin-node-resolve';
|
import resolve from '@rollup/plugin-node-resolve';
|
||||||
import commonjs from '@rollup/plugin-commonjs';
|
import commonjs from '@rollup/plugin-commonjs';
|
||||||
import terser from '@rollup/plugin-terser';
|
import terser from '@rollup/plugin-terser';
|
||||||
|
import typescript from '@rollup/plugin-typescript';
|
||||||
import dts from 'rollup-plugin-dts';
|
import dts from 'rollup-plugin-dts';
|
||||||
import serve from 'rollup-plugin-serve';
|
import serve from 'rollup-plugin-serve';
|
||||||
import livereload from 'rollup-plugin-livereload';
|
import livereload from 'rollup-plugin-livereload';
|
||||||
@@ -8,13 +9,14 @@ import livereload from 'rollup-plugin-livereload';
|
|||||||
const isDev = process.env.ROLLUP_WATCH;
|
const isDev = process.env.ROLLUP_WATCH;
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
const baseConfig = {
|
const basePlugins = [
|
||||||
input: 'src/index.js',
|
resolve(),
|
||||||
plugins: [
|
commonjs(),
|
||||||
resolve(),
|
typescript({
|
||||||
commonjs(),
|
tsconfig: './tsconfig.json',
|
||||||
],
|
declaration: false,
|
||||||
};
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
const devPlugins = isDev ? [
|
const devPlugins = isDev ? [
|
||||||
serve({
|
serve({
|
||||||
@@ -27,7 +29,7 @@ const devPlugins = isDev ? [
|
|||||||
}),
|
}),
|
||||||
] : [];
|
] : [];
|
||||||
|
|
||||||
const prodPlugins = isProd ? [
|
const terserPlugin = isProd ? [
|
||||||
terser({
|
terser({
|
||||||
compress: {
|
compress: {
|
||||||
drop_console: true,
|
drop_console: true,
|
||||||
@@ -41,60 +43,52 @@ const prodPlugins = isProd ? [
|
|||||||
] : [];
|
] : [];
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
|
// UMD development
|
||||||
{
|
{
|
||||||
...baseConfig,
|
input: 'src/index.ts',
|
||||||
output: {
|
output: {
|
||||||
file: 'dist/metona-editor.js',
|
file: 'dist/metona-editor.js',
|
||||||
format: 'umd',
|
format: 'umd',
|
||||||
name: 'MeEditor',
|
name: 'MeEditor',
|
||||||
exports: 'named',
|
exports: 'named',
|
||||||
sourcemap: !isProd,
|
sourcemap: true,
|
||||||
globals: {},
|
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [...basePlugins, ...devPlugins],
|
||||||
...baseConfig.plugins,
|
|
||||||
...devPlugins,
|
|
||||||
...prodPlugins,
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
// ES Module
|
||||||
{
|
{
|
||||||
...baseConfig,
|
input: 'src/index.ts',
|
||||||
output: {
|
output: {
|
||||||
file: 'dist/metona-editor.esm.js',
|
file: 'dist/metona-editor.esm.js',
|
||||||
format: 'es',
|
format: 'es',
|
||||||
exports: 'named',
|
exports: 'named',
|
||||||
sourcemap: !isProd,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: basePlugins,
|
||||||
...baseConfig.plugins,
|
|
||||||
...prodPlugins,
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
// CommonJS
|
||||||
{
|
{
|
||||||
...baseConfig,
|
input: 'src/index.ts',
|
||||||
output: {
|
output: {
|
||||||
file: 'dist/metona-editor.cjs.js',
|
file: 'dist/metona-editor.cjs.js',
|
||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
exports: 'named',
|
exports: 'named',
|
||||||
sourcemap: !isProd,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: basePlugins,
|
||||||
...baseConfig.plugins,
|
|
||||||
...prodPlugins,
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
// UMD minified
|
||||||
{
|
{
|
||||||
...baseConfig,
|
input: 'src/index.ts',
|
||||||
output: {
|
output: {
|
||||||
file: 'dist/metona-editor.min.js',
|
file: 'dist/metona-editor.min.js',
|
||||||
format: 'umd',
|
format: 'umd',
|
||||||
name: 'MeEditor',
|
name: 'MeEditor',
|
||||||
exports: 'named',
|
exports: 'named',
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
globals: {},
|
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
...baseConfig.plugins,
|
...basePlugins,
|
||||||
terser({
|
terser({
|
||||||
compress: {
|
compress: {
|
||||||
drop_console: true,
|
drop_console: true,
|
||||||
@@ -102,21 +96,20 @@ export default [
|
|||||||
pure_funcs: ['console.log', 'console.warn'],
|
pure_funcs: ['console.log', 'console.warn'],
|
||||||
passes: 2,
|
passes: 2,
|
||||||
},
|
},
|
||||||
format: {
|
format: { comments: false },
|
||||||
comments: false,
|
mangle: { toplevel: true },
|
||||||
},
|
|
||||||
mangle: {
|
|
||||||
toplevel: true,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// TypeScript declarations bundle
|
||||||
{
|
{
|
||||||
input: 'types/index.d.ts',
|
input: 'src/index.ts',
|
||||||
output: {
|
output: {
|
||||||
file: 'dist/metona-editor.d.ts',
|
file: 'dist/metona-editor.d.ts',
|
||||||
format: 'es',
|
format: 'es',
|
||||||
},
|
},
|
||||||
plugins: [dts()],
|
plugins: [
|
||||||
|
dts(),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+4
-4
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
|
||||||
<title>MetonaEditor v0.1.15 — 全功能演示</title>
|
<title>MetonaEditor v0.2.0 — 全功能演示</title>
|
||||||
<style>
|
<style>
|
||||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
|
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
|
||||||
@@ -47,9 +47,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
<header class="header">
|
<header class="header">
|
||||||
<h1><span class="grad">Metona</span>Editor</h1>
|
<h1><span class="grad">Metona</span>Editor</h1>
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
<span class="badge">v0.1.15</span>
|
<span class="badge">v0.2.0</span>
|
||||||
<span class="badge">零依赖</span>
|
<span class="badge">TypeScript</span>
|
||||||
<span class="badge">558 tests</span>
|
<span class="badge">583 tests</span>
|
||||||
<span class="badge">6 插件</span>
|
<span class="badge">6 插件</span>
|
||||||
<span class="badge">中文优先</span>
|
<span class="badge">中文优先</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -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 — 现代化零依赖 Markdown 编辑器</title>
|
<title>MetonaEditor v0.2.0 · TypeScript · 桌面端 Markdown 编辑器</title>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* MetonaEditor Animations - 动画管理
|
|
||||||
* @module animations
|
|
||||||
* @version 0.1.14
|
|
||||||
* @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用;
|
|
||||||
* 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ANIMATIONS } from './constants.js';
|
|
||||||
|
|
||||||
const animationMap = new Map();
|
|
||||||
|
|
||||||
// 注册默认动画
|
|
||||||
Object.entries(ANIMATIONS).forEach(([name, config]) => {
|
|
||||||
animationMap.set(name, config);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 动画工具函数
|
|
||||||
*/
|
|
||||||
export const animationUtils = {
|
|
||||||
register(name, config) {
|
|
||||||
animationMap.set(name, {
|
|
||||||
name,
|
|
||||||
enter: config.enter || {},
|
|
||||||
leave: config.leave || {},
|
|
||||||
duration: config.duration || 300,
|
|
||||||
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
unregister(name) {
|
|
||||||
animationMap.delete(name);
|
|
||||||
},
|
|
||||||
|
|
||||||
get(name) {
|
|
||||||
return animationMap.get(name) || ANIMATIONS[name] || null;
|
|
||||||
},
|
|
||||||
|
|
||||||
getAnimationNames() {
|
|
||||||
return Array.from(animationMap.keys());
|
|
||||||
},
|
|
||||||
|
|
||||||
getActiveCount() {
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
cancelAll() {
|
|
||||||
// CSS动画由浏览器原生管理
|
|
||||||
},
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
animationMap.clear();
|
|
||||||
Object.entries(ANIMATIONS).forEach(([name, config]) => {
|
|
||||||
animationMap.set(name, config);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
animationMap.clear();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const animationPresets = {};
|
|
||||||
|
|
||||||
export const createAnimation = (config = {}) => ({
|
|
||||||
enter: config.enter || {},
|
|
||||||
leave: config.leave || {},
|
|
||||||
duration: config.duration || 300,
|
|
||||||
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Animations — animation metadata
|
||||||
|
* @module animations
|
||||||
|
* @version 0.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ANIMATIONS } from './constants';
|
||||||
|
import type { AnimationConfig } from './constants';
|
||||||
|
|
||||||
|
const animationMap = new Map<string, AnimationConfig>();
|
||||||
|
|
||||||
|
// Register built-in animations
|
||||||
|
for (const [name, config] of Object.entries(ANIMATIONS)) {
|
||||||
|
animationMap.set(name, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const animationUtils = {
|
||||||
|
register(name: string, config: Partial<AnimationConfig> & { enter?: Record<string, string>; leave?: Record<string, string> }): void {
|
||||||
|
animationMap.set(name, {
|
||||||
|
name,
|
||||||
|
enter: config.enter || {},
|
||||||
|
leave: config.leave || {},
|
||||||
|
duration: config.duration || 300,
|
||||||
|
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
unregister(name: string): void {
|
||||||
|
animationMap.delete(name);
|
||||||
|
},
|
||||||
|
|
||||||
|
get(name: string): AnimationConfig | null {
|
||||||
|
return animationMap.get(name) || ANIMATIONS[name] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAnimationNames(): string[] {
|
||||||
|
return Array.from(animationMap.keys());
|
||||||
|
},
|
||||||
|
|
||||||
|
getActiveCount(): number {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelAll(): void {
|
||||||
|
// CSS animations managed by browser natively
|
||||||
|
},
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
animationMap.clear();
|
||||||
|
for (const [name, config] of Object.entries(ANIMATIONS)) {
|
||||||
|
animationMap.set(name, config);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
animationMap.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const animationPresets: Record<string, any> = {};
|
||||||
|
|
||||||
|
export const createAnimation = (config: Partial<AnimationConfig> = {}): AnimationConfig => ({
|
||||||
|
enter: config.enter || {},
|
||||||
|
leave: config.leave || {},
|
||||||
|
duration: config.duration || 300,
|
||||||
|
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
});
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
/**
|
|
||||||
* MetonaEditor Constants - 常量定义
|
|
||||||
* @module constants
|
|
||||||
* @version 0.1.14
|
|
||||||
* @description 默认配置、主题、动画、工具栏等常量
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ICONS } from './icons.js';
|
|
||||||
import { LOCALES } from './locales.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认工具栏按钮序列
|
|
||||||
* '|' 表示分组分隔符
|
|
||||||
*/
|
|
||||||
export const DEFAULT_TOOLBAR = [
|
|
||||||
'bold', 'italic', 'strikethrough', 'code', '|',
|
|
||||||
'h1', 'h2', 'h3', '|',
|
|
||||||
'quote', 'ul', 'ol', '|',
|
|
||||||
'link', 'image', 'table', 'hr', '|',
|
|
||||||
'undo', 'redo', '|',
|
|
||||||
'edit', 'split', 'preview', 'fullscreen',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认配置
|
|
||||||
*/
|
|
||||||
export const DEFAULTS = Object.freeze({
|
|
||||||
// 内容
|
|
||||||
value: '',
|
|
||||||
placeholder: '',
|
|
||||||
// 视图模式:edit / split / preview
|
|
||||||
mode: 'split',
|
|
||||||
// 高度:数字为 px,字符串原样使用
|
|
||||||
height: 400,
|
|
||||||
// 工具栏:数组或 false(隐藏)
|
|
||||||
toolbar: DEFAULT_TOOLBAR,
|
|
||||||
// 字数统计状态栏
|
|
||||||
wordCount: true,
|
|
||||||
// 自动聚焦
|
|
||||||
autofocus: false,
|
|
||||||
// 拼写检查
|
|
||||||
spellcheck: false,
|
|
||||||
// 历史栈上限
|
|
||||||
historyLimit: 100,
|
|
||||||
// 历史栈防抖延迟(ms)
|
|
||||||
historyDebounce: 400,
|
|
||||||
// 同步滚动(分屏模式)
|
|
||||||
syncScroll: true,
|
|
||||||
// 制表符插入的空格数(0 表示插入 \t)
|
|
||||||
tabSize: 2,
|
|
||||||
// 只读模式
|
|
||||||
readOnly: false,
|
|
||||||
// 显示行号(v0.1.7)
|
|
||||||
lineNumbers: true,
|
|
||||||
// 显示大纲面板(v0.1.7)
|
|
||||||
outline: false,
|
|
||||||
// 自动括号闭合(v0.1.7)
|
|
||||||
autoBrackets: true,
|
|
||||||
// Zen 专注模式(v0.1.12)
|
|
||||||
zenMode: false,
|
|
||||||
// 自动换行(v0.1.12)
|
|
||||||
wordWrap: true,
|
|
||||||
// 最大字符数(v0.1.14,0=不限)
|
|
||||||
maxLength: 0,
|
|
||||||
// 主题:light / dark / auto / warm / 自定义
|
|
||||||
theme: 'auto',
|
|
||||||
// 国际化
|
|
||||||
locale: 'zh-CN',
|
|
||||||
// 自定义 Markdown 渲染函数 (md, env) => html,覆盖内置解析器
|
|
||||||
render: null,
|
|
||||||
// 自定义代码高亮函数 (code, lang) => html
|
|
||||||
highlight: null,
|
|
||||||
// 自定义渲染前的 HTML 净化函数(接收渲染后的 HTML,返回安全 HTML)
|
|
||||||
sanitize: null,
|
|
||||||
// 外观
|
|
||||||
className: '',
|
|
||||||
style: {},
|
|
||||||
// 插件
|
|
||||||
plugins: [],
|
|
||||||
// 生命周期回调
|
|
||||||
onChange: null,
|
|
||||||
onInput: null,
|
|
||||||
onFocus: null,
|
|
||||||
onBlur: null,
|
|
||||||
onSave: null,
|
|
||||||
onModeChange: null,
|
|
||||||
onFullscreen: null,
|
|
||||||
onCreate: null,
|
|
||||||
onDestroy: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Icons - 从 icons.js 导入并重新导出(保持单一来源)
|
|
||||||
export { ICONS };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 类型颜色配置(保留用于状态提示与按钮强调)
|
|
||||||
*/
|
|
||||||
export const TYPE_COLORS = {
|
|
||||||
success: { fg: '#10b981', bg: 'rgba(16, 185, 129, 0.1)' },
|
|
||||||
error: { fg: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)' },
|
|
||||||
warning: { fg: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
|
|
||||||
info: { fg: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
|
|
||||||
loading: { fg: '#6366f1', bg: 'rgba(99, 102, 241, 0.1)' },
|
|
||||||
default: { fg: '#6b7280', bg: 'rgba(107, 114, 128, 0.1)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 主题配置
|
|
||||||
*/
|
|
||||||
export const THEMES = {
|
|
||||||
light: {
|
|
||||||
bg: 'rgba(255, 255, 255, 0.96)',
|
|
||||||
text: '#1f2937',
|
|
||||||
border: 'rgba(0, 0, 0, 0.08)',
|
|
||||||
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.18), 0 4px 14px -4px rgba(0, 0, 0, 0.08)',
|
|
||||||
hoverShadow: '0 14px 48px -10px rgba(0, 0, 0, 0.22), 0 6px 18px -4px rgba(0, 0, 0, 0.10)',
|
|
||||||
// 编辑器专属
|
|
||||||
toolbarBg: 'rgba(248, 249, 250, 0.92)',
|
|
||||||
textareaBg: '#ffffff',
|
|
||||||
previewBg: '#ffffff',
|
|
||||||
codeBg: 'rgba(243, 244, 246, 1)',
|
|
||||||
codeText: '#1f2937',
|
|
||||||
accent: '#3b82f6',
|
|
||||||
muted: '#6b7280',
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
bg: 'rgba(28, 32, 40, 0.94)',
|
|
||||||
text: '#e6e8eb',
|
|
||||||
border: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.6), 0 4px 14px -4px rgba(0, 0, 0, 0.4)',
|
|
||||||
hoverShadow: '0 14px 48px -8px rgba(0, 0, 0, 0.6), 0 6px 18px -4px rgba(0, 0, 0, 0.4)',
|
|
||||||
toolbarBg: 'rgba(22, 26, 33, 0.92)',
|
|
||||||
textareaBg: '#1c2028',
|
|
||||||
previewBg: '#1c2028',
|
|
||||||
codeBg: 'rgba(15, 18, 24, 1)',
|
|
||||||
codeText: '#e6e8eb',
|
|
||||||
accent: '#60a5fa',
|
|
||||||
muted: '#9ca3af',
|
|
||||||
},
|
|
||||||
auto: 'auto',
|
|
||||||
warm: {
|
|
||||||
bg: 'rgba(255, 251, 235, 0.96)',
|
|
||||||
text: '#78350f',
|
|
||||||
border: 'rgba(245, 158, 11, 0.2)',
|
|
||||||
shadow: '0 10px 36px -10px rgba(245, 158, 11, 0.18), 0 4px 14px -4px rgba(245, 158, 11, 0.08)',
|
|
||||||
hoverShadow: '0 14px 48px -10px rgba(245, 158, 11, 0.22), 0 6px 18px -4px rgba(245, 158, 11, 0.10)',
|
|
||||||
toolbarBg: 'rgba(254, 243, 199, 0.92)',
|
|
||||||
textareaBg: '#fffbeb',
|
|
||||||
previewBg: '#fffbeb',
|
|
||||||
codeBg: 'rgba(254, 215, 170, 0.6)',
|
|
||||||
codeText: '#78350f',
|
|
||||||
accent: '#d97706',
|
|
||||||
muted: '#a16207',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 位置配置(保留,预览面板提示等场景可用)
|
|
||||||
*/
|
|
||||||
export const POSITIONS = [
|
|
||||||
'top-left', 'top-center', 'top-right',
|
|
||||||
'bottom-left', 'bottom-center', 'bottom-right',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 动画配置(保留,DOM 过渡与未来扩展使用)
|
|
||||||
*/
|
|
||||||
export const ANIMATIONS = {
|
|
||||||
slide: { enter: { transform: 'translateX(80px)', opacity: 0 }, leave: { transform: 'translateX(120%)', opacity: 0 }, duration: 400, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
|
||||||
fade: { enter: { opacity: 0, filter: 'blur(3px)' }, leave: { opacity: 0, filter: 'blur(3px)' }, duration: 500, easing: 'ease' },
|
|
||||||
scale: { enter: { transform: 'scale(0.55)', opacity: 0 }, leave: { transform: 'scale(0.55)', opacity: 0 }, duration: 450, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
|
|
||||||
bounce: { enter: { transform: 'translateY(-80px)', opacity: 0 }, leave: { transform: 'translateY(20px)', opacity: 0 }, duration: 650, easing: 'ease' },
|
|
||||||
flip: { enter: { transform: 'perspective(500px) rotateX(-90deg)', opacity: 0 }, leave: { transform: 'perspective(500px) rotateX(90deg)', opacity: 0 }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
|
||||||
rotate: { enter: { transform: 'rotate(-25deg) scale(0.6)', opacity: 0 }, leave: { transform: 'rotate(25deg) scale(0.6)', opacity: 0 }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
|
||||||
zoom: { enter: { transform: 'scale(0.1)', opacity: 0 }, leave: { transform: 'scale(0.1)', opacity: 0 }, duration: 500, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 主题类型
|
|
||||||
*/
|
|
||||||
export const THEME_TYPES = ['light', 'dark', 'auto'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 动画类型
|
|
||||||
*/
|
|
||||||
export const ANIMATION_TYPES = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑器视图模式
|
|
||||||
*/
|
|
||||||
export const EDIT_MODES = ['edit', 'split', 'preview'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 内置工具栏动作清单
|
|
||||||
*/
|
|
||||||
export const TOOLBAR_ACTIONS = [
|
|
||||||
'bold', 'italic', 'strikethrough', 'underline', 'code',
|
|
||||||
'h1', 'h2', 'h3', 'quote', 'ul', 'ol', 'indent', 'outdent', 'hr',
|
|
||||||
'link', 'image', 'table',
|
|
||||||
'undo', 'redo',
|
|
||||||
'edit', 'split', 'preview', 'fullscreen',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Locales - 从 locales.js 导入并重新导出
|
|
||||||
export { LOCALES };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 进度条方向(保留兼容)
|
|
||||||
*/
|
|
||||||
export const PROGRESS_DIRECTIONS = ['horizontal', 'vertical'];
|
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Constants — default configs, themes, animations
|
||||||
|
* @module constants
|
||||||
|
* @version 0.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ICONS } from './icons';
|
||||||
|
import { LOCALES } from './locales';
|
||||||
|
|
||||||
|
export type EditMode = 'edit' | 'split' | 'preview';
|
||||||
|
export type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string;
|
||||||
|
export type ToolbarItem = string | '|';
|
||||||
|
|
||||||
|
export interface RenderEnv {
|
||||||
|
highlight?: (code: string, lang: string) => string;
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorStyle {
|
||||||
|
color?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
[key: string]: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorOptions {
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
mode?: EditMode;
|
||||||
|
height?: number | string;
|
||||||
|
toolbar?: ToolbarItem[] | false;
|
||||||
|
wordCount?: boolean;
|
||||||
|
autofocus?: boolean;
|
||||||
|
spellcheck?: boolean;
|
||||||
|
historyLimit?: number;
|
||||||
|
historyDebounce?: number;
|
||||||
|
syncScroll?: boolean;
|
||||||
|
tabSize?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
|
lineNumbers?: boolean;
|
||||||
|
outline?: boolean;
|
||||||
|
autoBrackets?: boolean;
|
||||||
|
zenMode?: boolean;
|
||||||
|
wordWrap?: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
theme?: ThemeName;
|
||||||
|
locale?: string;
|
||||||
|
render?: ((markdown: string, env: RenderEnv) => string) | null;
|
||||||
|
highlight?: ((code: string, lang: string) => string) | null;
|
||||||
|
sanitize?: ((html: string) => string) | null;
|
||||||
|
className?: string;
|
||||||
|
style?: EditorStyle;
|
||||||
|
plugins?: any[];
|
||||||
|
id?: string;
|
||||||
|
onChange?: ((value: string, editor: any) => void) | null;
|
||||||
|
onInput?: ((value: string, editor: any) => void) | null;
|
||||||
|
onFocus?: ((editor: any) => void) | null;
|
||||||
|
onBlur?: ((editor: any) => void) | null;
|
||||||
|
onSave?: ((value: string, editor: any) => void) | null;
|
||||||
|
onModeChange?: ((mode: string, editor: any) => void) | null;
|
||||||
|
onFullscreen?: ((fullscreen: boolean, editor: any) => void) | null;
|
||||||
|
onCreate?: ((editor: any) => void) | null;
|
||||||
|
onDestroy?: ((editor: any) => void) | null;
|
||||||
|
onLinkClick?: ((href: string, text: string, editor: any) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeConfig {
|
||||||
|
bg: string;
|
||||||
|
text: string;
|
||||||
|
border: string;
|
||||||
|
shadow: string;
|
||||||
|
hoverShadow?: string;
|
||||||
|
toolbarBg?: string;
|
||||||
|
textareaBg?: string;
|
||||||
|
previewBg?: string;
|
||||||
|
codeBg?: string;
|
||||||
|
codeText?: string;
|
||||||
|
accent?: string;
|
||||||
|
muted?: string;
|
||||||
|
progressBg?: string;
|
||||||
|
closeHoverBg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnimationConfig {
|
||||||
|
name?: string;
|
||||||
|
enter: Record<string, string>;
|
||||||
|
leave: Record<string, string>;
|
||||||
|
duration: number;
|
||||||
|
easing: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default toolbar button sequence */
|
||||||
|
export const DEFAULT_TOOLBAR: ToolbarItem[] = [
|
||||||
|
'bold', 'italic', 'strikethrough', 'code', '|',
|
||||||
|
'h1', 'h2', 'h3', '|',
|
||||||
|
'quote', 'ul', 'ol', '|',
|
||||||
|
'link', 'image', 'table', 'hr', '|',
|
||||||
|
'undo', 'redo', '|',
|
||||||
|
'edit', 'split', 'preview', 'fullscreen',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Default options */
|
||||||
|
export const DEFAULTS: Readonly<EditorOptions> = Object.freeze({
|
||||||
|
value: '',
|
||||||
|
placeholder: '',
|
||||||
|
mode: 'split' as EditMode,
|
||||||
|
height: 400,
|
||||||
|
toolbar: DEFAULT_TOOLBAR as ToolbarItem[] | false,
|
||||||
|
wordCount: true,
|
||||||
|
autofocus: false,
|
||||||
|
spellcheck: false,
|
||||||
|
historyLimit: 100,
|
||||||
|
historyDebounce: 400,
|
||||||
|
syncScroll: true,
|
||||||
|
tabSize: 2,
|
||||||
|
readOnly: false,
|
||||||
|
lineNumbers: true,
|
||||||
|
outline: false,
|
||||||
|
autoBrackets: true,
|
||||||
|
zenMode: false,
|
||||||
|
wordWrap: true,
|
||||||
|
maxLength: 0,
|
||||||
|
theme: 'auto' as ThemeName,
|
||||||
|
locale: 'zh-CN',
|
||||||
|
render: null,
|
||||||
|
highlight: null,
|
||||||
|
sanitize: null,
|
||||||
|
className: '',
|
||||||
|
style: {},
|
||||||
|
plugins: [] as any[],
|
||||||
|
onChange: null,
|
||||||
|
onInput: null,
|
||||||
|
onFocus: null,
|
||||||
|
onBlur: null,
|
||||||
|
onSave: null,
|
||||||
|
onModeChange: null,
|
||||||
|
onFullscreen: null,
|
||||||
|
onCreate: null,
|
||||||
|
onDestroy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export { ICONS, LOCALES };
|
||||||
|
|
||||||
|
export const TYPE_COLORS: Record<string, { fg: string; bg: string }> = {
|
||||||
|
success: { fg: '#10b981', bg: 'rgba(16, 185, 129, 0.1)' },
|
||||||
|
error: { fg: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)' },
|
||||||
|
warning: { fg: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
|
||||||
|
info: { fg: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
|
||||||
|
loading: { fg: '#6366f1', bg: 'rgba(99, 102, 241, 0.1)' },
|
||||||
|
default: { fg: '#6b7280', bg: 'rgba(107, 114, 128, 0.1)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THEMES: Record<string, ThemeConfig | string> = {
|
||||||
|
light: {
|
||||||
|
bg: 'rgba(255, 255, 255, 0.96)',
|
||||||
|
text: '#1f2937',
|
||||||
|
border: 'rgba(0, 0, 0, 0.08)',
|
||||||
|
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.18), 0 4px 14px -4px rgba(0, 0, 0, 0.08)',
|
||||||
|
hoverShadow: '0 14px 48px -10px rgba(0, 0, 0, 0.22), 0 6px 18px -4px rgba(0, 0, 0, 0.10)',
|
||||||
|
toolbarBg: 'rgba(248, 249, 250, 0.92)',
|
||||||
|
textareaBg: '#ffffff',
|
||||||
|
previewBg: '#ffffff',
|
||||||
|
codeBg: 'rgba(243, 244, 246, 1)',
|
||||||
|
codeText: '#1f2937',
|
||||||
|
accent: '#3b82f6',
|
||||||
|
muted: '#6b7280',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
bg: 'rgba(28, 32, 40, 0.94)',
|
||||||
|
text: '#e6e8eb',
|
||||||
|
border: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.6), 0 4px 14px -4px rgba(0, 0, 0, 0.4)',
|
||||||
|
hoverShadow: '0 14px 48px -8px rgba(0, 0, 0, 0.6), 0 6px 18px -4px rgba(0, 0, 0, 0.4)',
|
||||||
|
toolbarBg: 'rgba(22, 26, 33, 0.92)',
|
||||||
|
textareaBg: '#1c2028',
|
||||||
|
previewBg: '#1c2028',
|
||||||
|
codeBg: 'rgba(15, 18, 24, 1)',
|
||||||
|
codeText: '#e6e8eb',
|
||||||
|
accent: '#60a5fa',
|
||||||
|
muted: '#9ca3af',
|
||||||
|
},
|
||||||
|
auto: 'auto',
|
||||||
|
warm: {
|
||||||
|
bg: 'rgba(255, 251, 235, 0.96)',
|
||||||
|
text: '#78350f',
|
||||||
|
border: 'rgba(245, 158, 11, 0.2)',
|
||||||
|
shadow: '0 10px 36px -10px rgba(245, 158, 11, 0.18), 0 4px 14px -4px rgba(245, 158, 11, 0.08)',
|
||||||
|
hoverShadow: '0 14px 48px -10px rgba(245, 158, 11, 0.22), 0 6px 18px -4px rgba(245, 158, 11, 0.10)',
|
||||||
|
toolbarBg: 'rgba(254, 243, 199, 0.92)',
|
||||||
|
textareaBg: '#fffbeb',
|
||||||
|
previewBg: '#fffbeb',
|
||||||
|
codeBg: 'rgba(254, 215, 170, 0.6)',
|
||||||
|
codeText: '#78350f',
|
||||||
|
accent: '#d97706',
|
||||||
|
muted: '#a16207',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ANIMATIONS: Record<string, AnimationConfig> = {
|
||||||
|
slide: { enter: { transform: 'translateX(80px)', opacity: '0' }, leave: { transform: 'translateX(120%)', opacity: '0' }, duration: 400, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
||||||
|
fade: { enter: { opacity: '0', filter: 'blur(3px)' }, leave: { opacity: '0', filter: 'blur(3px)' }, duration: 500, easing: 'ease' },
|
||||||
|
scale: { enter: { transform: 'scale(0.55)', opacity: '0' }, leave: { transform: 'scale(0.55)', opacity: '0' }, duration: 450, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
|
||||||
|
bounce: { enter: { transform: 'translateY(-80px)', opacity: '0' }, leave: { transform: 'translateY(20px)', opacity: '0' }, duration: 650, easing: 'ease' },
|
||||||
|
flip: { enter: { transform: 'perspective(500px) rotateX(-90deg)', opacity: '0' }, leave: { transform: 'perspective(500px) rotateX(90deg)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
||||||
|
rotate: { enter: { transform: 'rotate(-25deg) scale(0.6)', opacity: '0' }, leave: { transform: 'rotate(25deg) scale(0.6)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
||||||
|
zoom: { enter: { transform: 'scale(0.1)', opacity: '0' }, leave: { transform: 'scale(0.1)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EDIT_MODES: EditMode[] = ['edit', 'split', 'preview'];
|
||||||
|
|
||||||
|
export const TOOLBAR_ACTIONS: string[] = [
|
||||||
|
'bold', 'italic', 'strikethrough', 'underline', 'code',
|
||||||
|
'h1', 'h2', 'h3', 'quote', 'ul', 'ol', 'indent', 'outdent', 'hr',
|
||||||
|
'link', 'image', 'table', 'undo', 'redo',
|
||||||
|
'edit', 'split', 'preview', 'fullscreen',
|
||||||
|
];
|
||||||
-1785
File diff suppressed because it is too large
Load Diff
+748
@@ -0,0 +1,748 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Core — MarkdownEditor class (TypeScript)
|
||||||
|
* @module core
|
||||||
|
* @version 0.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateId, escapeHTML, isBrowser } from './utils';
|
||||||
|
import { DEFAULTS, ICONS, THEMES } from './constants';
|
||||||
|
import type { EditorOptions, EditMode, ToolbarItem, RenderEnv, ThemeName } from './constants';
|
||||||
|
import { injectStyles } from './styles';
|
||||||
|
import { t as i18nT, getCurrentLocale, getLocaleDirection, createInstanceI18n } from './i18n';
|
||||||
|
import type { InstanceI18n } from './i18n';
|
||||||
|
import { parseMarkdown } from './parser';
|
||||||
|
import { presetPlugins, topologicalSort } from './plugins';
|
||||||
|
import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes';
|
||||||
|
|
||||||
|
const MODES: EditMode[] = ['edit', 'split', 'preview'];
|
||||||
|
const TOAST_ICONS: Record<string, string> = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
|
||||||
|
const BRACKET_PAIRS: Record<string, string> = {
|
||||||
|
'(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`', '*': '*', '_': '_',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveThemeName = (theme: string): string => {
|
||||||
|
if (theme && theme !== 'auto') return theme;
|
||||||
|
return resolveTheme('auto');
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MarkdownEditor {
|
||||||
|
static _hooks = new Map<string, Array<(editor: MarkdownEditor) => void>>();
|
||||||
|
|
||||||
|
static on(name: string, fn: (editor: MarkdownEditor) => void): () => void {
|
||||||
|
if (!this._hooks.has(name)) this._hooks.set(name, []);
|
||||||
|
this._hooks.get(name)!.push(fn);
|
||||||
|
return () => this.off(name, fn);
|
||||||
|
}
|
||||||
|
static off(name: string, fn: (editor: MarkdownEditor) => void): void {
|
||||||
|
const list = this._hooks.get(name);
|
||||||
|
if (list) this._hooks.set(name, list.filter((f) => f !== fn));
|
||||||
|
}
|
||||||
|
static trigger(name: string, instance: MarkdownEditor): void {
|
||||||
|
const list = this._hooks.get(name);
|
||||||
|
if (list) list.forEach((fn) => { try { fn(instance); } catch (e) { console.error(`MeEditor hook "${name}" error:`, e); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
container!: HTMLElement;
|
||||||
|
config!: EditorOptions;
|
||||||
|
el!: HTMLElement;
|
||||||
|
toolbarEl!: HTMLElement;
|
||||||
|
bodyEl!: HTMLElement;
|
||||||
|
editorPane!: HTMLElement;
|
||||||
|
editorInner!: HTMLElement;
|
||||||
|
previewPane!: HTMLElement;
|
||||||
|
dividerEl!: HTMLElement;
|
||||||
|
textarea!: HTMLTextAreaElement;
|
||||||
|
previewEl!: HTMLElement;
|
||||||
|
gutter!: HTMLElement;
|
||||||
|
statusEl!: HTMLElement | null;
|
||||||
|
|
||||||
|
_value!: string;
|
||||||
|
_mode!: EditMode;
|
||||||
|
_history!: string[];
|
||||||
|
_historyIndex!: number;
|
||||||
|
_cleanups!: Array<() => void>;
|
||||||
|
_plugins!: any[];
|
||||||
|
_listeners!: Record<string, Array<(...args: any[]) => void>>;
|
||||||
|
_renderRaf!: number | null;
|
||||||
|
_historyTimer!: ReturnType<typeof setTimeout> | null;
|
||||||
|
_fullscreen!: boolean;
|
||||||
|
_destroyed!: boolean;
|
||||||
|
_lastRenderedValue!: string | null;
|
||||||
|
_shortcuts!: any[];
|
||||||
|
_contextMenuItems!: any[];
|
||||||
|
_customActions!: Record<string, (...args: any[]) => void>;
|
||||||
|
_outlineTimer!: ReturnType<typeof setTimeout> | null;
|
||||||
|
_zenMode!: boolean;
|
||||||
|
_wordWrap!: boolean;
|
||||||
|
_syncing!: boolean;
|
||||||
|
_zenMouseHandler!: ((e: MouseEvent) => void) | null;
|
||||||
|
_ariaLive!: HTMLElement;
|
||||||
|
_renderFn!: (md: string, env: RenderEnv) => string;
|
||||||
|
_highlightFn!: ((code: string, lang: string) => string) | null;
|
||||||
|
_themeCtx: any;
|
||||||
|
_i18nCtx!: InstanceI18n;
|
||||||
|
|
||||||
|
constructor(container: string | HTMLElement, options: EditorOptions = {}) {
|
||||||
|
if (!isBrowser()) {
|
||||||
|
this._destroyed = true; this._value = options.value || ''; return this as any;
|
||||||
|
}
|
||||||
|
this.id = options.id || generateId();
|
||||||
|
this.container = typeof container === 'string' ? document.querySelector(container) as HTMLElement : container;
|
||||||
|
if (!this.container) {
|
||||||
|
console.error('MeEditor: container not found:', container);
|
||||||
|
this._destroyed = true; return this as any;
|
||||||
|
}
|
||||||
|
this.config = { ...DEFAULTS, ...options };
|
||||||
|
if (options.style && typeof options.style === 'object') {
|
||||||
|
this.config.style = { ...DEFAULTS.style, ...options.style };
|
||||||
|
}
|
||||||
|
this._value = this.config.value || '';
|
||||||
|
this._mode = MODES.includes(this.config.mode!) ? this.config.mode! : 'split';
|
||||||
|
this._history = []; this._historyIndex = -1; this._cleanups = []; this._plugins = [];
|
||||||
|
this._listeners = {}; this._renderRaf = null; this._historyTimer = null;
|
||||||
|
this._fullscreen = false; this._destroyed = false; this._lastRenderedValue = null;
|
||||||
|
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
|
||||||
|
this._outlineTimer = null; this._zenMode = false; this._wordWrap = true; this._syncing = false;
|
||||||
|
this._zenMouseHandler = null;
|
||||||
|
|
||||||
|
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
|
||||||
|
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
|
||||||
|
|
||||||
|
MarkdownEditor.trigger('beforeCreate', this);
|
||||||
|
injectStyles();
|
||||||
|
this._buildDOM();
|
||||||
|
this._themeCtx = createInstanceTheme(this);
|
||||||
|
this._i18nCtx = createInstanceI18n(this);
|
||||||
|
|
||||||
|
if (options.theme && typeof document !== 'undefined') {
|
||||||
|
document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme));
|
||||||
|
}
|
||||||
|
const resolvedThemeConfig = getThemeConfig(this.config.theme!);
|
||||||
|
setThemeVariables(resolvedThemeConfig, this.el);
|
||||||
|
|
||||||
|
this._buildToolbar();
|
||||||
|
this._updateModeButtons();
|
||||||
|
this._bindToolbarKeyboard();
|
||||||
|
this._initAriaLive();
|
||||||
|
this._bindEvents();
|
||||||
|
|
||||||
|
this.textarea.value = this._value;
|
||||||
|
this._pushHistory();
|
||||||
|
this._render();
|
||||||
|
this._updateWordCount();
|
||||||
|
|
||||||
|
if (Array.isArray(this.config.plugins)) {
|
||||||
|
const plugins = this.config.plugins.map((p: any) => {
|
||||||
|
if (typeof p === 'string') return { ...presetPlugins[p], _isStringRef: true };
|
||||||
|
return p;
|
||||||
|
}).filter(Boolean);
|
||||||
|
const sorted = topologicalSort(plugins);
|
||||||
|
sorted.forEach((p) => this.use(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.config.autofocus && !this.config.readOnly) this.focus();
|
||||||
|
if (typeof this.config.onCreate === 'function') {
|
||||||
|
try { this.config.onCreate(this); } catch (e) { console.error('onCreate error:', e); }
|
||||||
|
}
|
||||||
|
MarkdownEditor.trigger('afterCreate', this);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildDOM(): void {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = `me-wrapper me-theme-${resolveThemeName(this.config.theme!)}`;
|
||||||
|
if (this.config.readOnly) wrapper.classList.add('me-readonly');
|
||||||
|
wrapper.dataset.id = this.id;
|
||||||
|
wrapper.setAttribute('role', 'application');
|
||||||
|
wrapper.setAttribute('aria-label', i18nT('edit'));
|
||||||
|
if (this.config.className) {
|
||||||
|
this.config.className.split(/\s+/).filter(Boolean).forEach((c) => wrapper.classList.add(c));
|
||||||
|
}
|
||||||
|
const h = this.config.height;
|
||||||
|
wrapper.style.height = typeof h === 'number' ? `${h}px` : (h || '400px');
|
||||||
|
if (this.config.style && typeof this.config.style === 'object') {
|
||||||
|
Object.entries(this.config.style).forEach(([k, v]) => { if (v) (wrapper.style as any)[k] = v; });
|
||||||
|
}
|
||||||
|
const toolbar = document.createElement('div'); toolbar.className = 'me-toolbar'; toolbar.setAttribute('role', 'toolbar');
|
||||||
|
const body = document.createElement('div'); body.className = `me-body me-mode-${this._mode}`;
|
||||||
|
const editorPane = document.createElement('div'); editorPane.className = 'me-editor-pane';
|
||||||
|
const editorInner = document.createElement('div'); editorInner.className = 'me-editor-inner';
|
||||||
|
const gutter = document.createElement('div'); gutter.className = 'me-gutter';
|
||||||
|
if (!this.config.lineNumbers) gutter.style.display = 'none';
|
||||||
|
editorInner.appendChild(gutter);
|
||||||
|
const textarea = document.createElement('textarea'); textarea.className = 'me-textarea';
|
||||||
|
textarea.spellcheck = !!this.config.spellcheck; textarea.readOnly = !!this.config.readOnly;
|
||||||
|
textarea.style.tabSize = String(this.config.tabSize || 2);
|
||||||
|
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
|
||||||
|
textarea.setAttribute('aria-label', i18nT('edit'));
|
||||||
|
editorInner.appendChild(textarea);
|
||||||
|
editorPane.appendChild(editorInner);
|
||||||
|
const divider = document.createElement('div'); divider.className = 'me-divider'; divider.setAttribute('role', 'separator');
|
||||||
|
const previewPane = document.createElement('div'); previewPane.className = 'me-preview-pane';
|
||||||
|
previewPane.setAttribute('role', 'region'); previewPane.setAttribute('aria-label', i18nT('preview'));
|
||||||
|
const preview = document.createElement('div'); preview.className = 'me-preview'; previewPane.appendChild(preview);
|
||||||
|
body.appendChild(editorPane); body.appendChild(divider); body.appendChild(previewPane);
|
||||||
|
wrapper.appendChild(toolbar); wrapper.appendChild(body);
|
||||||
|
let statusbar: HTMLElement | null = null;
|
||||||
|
if (this.config.wordCount) { statusbar = document.createElement('div'); statusbar.className = 'me-statusbar'; wrapper.appendChild(statusbar); }
|
||||||
|
this.container.appendChild(wrapper);
|
||||||
|
this.el = wrapper; this.toolbarEl = toolbar; this.bodyEl = body;
|
||||||
|
this.editorPane = editorPane; this.editorInner = editorInner; this.gutter = gutter;
|
||||||
|
this.previewPane = previewPane; this.dividerEl = divider;
|
||||||
|
this.textarea = textarea; this.previewEl = preview; this.statusEl = statusbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildToolbar(): void {
|
||||||
|
const tools = this.config.toolbar;
|
||||||
|
if (!tools || !Array.isArray(tools) || tools.length === 0) return;
|
||||||
|
const modeActions: string[] = [];
|
||||||
|
tools.forEach((item) => {
|
||||||
|
if (item === '|') { const sep = document.createElement('span'); sep.className = 'me-toolbar-sep'; this.toolbarEl.appendChild(sep); return; }
|
||||||
|
if (item === 'edit' || item === 'split' || item === 'preview' || item === 'fullscreen') { modeActions.push(item); return; }
|
||||||
|
this.toolbarEl.appendChild(this._createBtn(item));
|
||||||
|
});
|
||||||
|
if (modeActions.length) {
|
||||||
|
const group = document.createElement('div'); group.className = 'me-toolbar-group';
|
||||||
|
modeActions.forEach((item) => {
|
||||||
|
const btn = this._createBtn(item);
|
||||||
|
if (item === 'edit' || item === 'split' || item === 'preview') { btn.dataset.mode = item; if (item === this._mode) btn.classList.add('me-active'); }
|
||||||
|
group.appendChild(btn);
|
||||||
|
});
|
||||||
|
this.toolbarEl.appendChild(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_createBtn(item: string): HTMLButtonElement {
|
||||||
|
const btn = document.createElement('button'); btn.type = 'button';
|
||||||
|
btn.className = `me-btn me-btn-${item}`; btn.dataset.action = item;
|
||||||
|
const label = i18nT(item) || item; btn.title = label; btn.setAttribute('aria-label', label);
|
||||||
|
btn.innerHTML = ICONS[item] || `<span>${escapeHTML(item)}</span>`;
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindEvents(): void {
|
||||||
|
const ta = this.textarea;
|
||||||
|
const onInput = () => {
|
||||||
|
this._value = ta.value; this._scheduleRender(); this._scheduleHistory(); this._updateWordCount();
|
||||||
|
this._renderGutter(); this._updateOutline();
|
||||||
|
this._emit('input', this._value); this._emit('change', this._value);
|
||||||
|
if (typeof this.config.onInput === 'function') { try { this.config.onInput(this._value, this); } catch (e) { console.error(e); } }
|
||||||
|
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } }
|
||||||
|
};
|
||||||
|
ta.addEventListener('input', onInput);
|
||||||
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) this._handleSmartEnter(e);
|
||||||
|
this._handleKeydown(e);
|
||||||
|
};
|
||||||
|
ta.addEventListener('keydown', onKeydown);
|
||||||
|
const onKeypress = (e: KeyboardEvent) => this._handleBracketAutoClose(e);
|
||||||
|
ta.addEventListener('keypress', onKeypress);
|
||||||
|
const onCursorActivity = () => { this._updateCurrentLine(); };
|
||||||
|
ta.addEventListener('click', onCursorActivity);
|
||||||
|
ta.addEventListener('keyup', onCursorActivity);
|
||||||
|
const onScroll = () => { this._syncScroll(); };
|
||||||
|
ta.addEventListener('scroll', onScroll);
|
||||||
|
const onPreviewScroll = () => {
|
||||||
|
if (!this.config.syncScroll || this._mode !== 'split') return;
|
||||||
|
if (this._syncing) return; this._syncing = true;
|
||||||
|
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
|
||||||
|
if (pmax <= 0) { this._syncing = false; return; }
|
||||||
|
const ratio = this.previewPane.scrollTop / pmax;
|
||||||
|
const tmax = ta.scrollHeight - ta.clientHeight;
|
||||||
|
ta.scrollTop = ratio * tmax;
|
||||||
|
if (this.gutter) { const gmax = this.gutter.scrollHeight - this.gutter.clientHeight; this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ratio * tmax; }
|
||||||
|
requestAnimationFrame(() => { this._syncing = false; });
|
||||||
|
};
|
||||||
|
this.previewPane.addEventListener('scroll', onPreviewScroll);
|
||||||
|
const onFocus = () => {
|
||||||
|
this._emit('focus'); if (typeof this.config.onFocus === 'function') { try { this.config.onFocus(this); } catch (e) { console.error(e); } }
|
||||||
|
};
|
||||||
|
const onBlur = () => {
|
||||||
|
this._emit('blur'); if (typeof this.config.onBlur === 'function') { try { this.config.onBlur(this); } catch (e) { console.error(e); } }
|
||||||
|
};
|
||||||
|
ta.addEventListener('focus', onFocus); ta.addEventListener('blur', onBlur);
|
||||||
|
const onPreviewClick = (e: MouseEvent) => {
|
||||||
|
const a = (e.target as HTMLElement).closest('a'); if (!a) return;
|
||||||
|
const href = a.getAttribute('href'); if (!href || href.startsWith('#')) return;
|
||||||
|
e.preventDefault(); this._emit('linkClick', { href, text: a.textContent });
|
||||||
|
if (typeof (this.config as any).onLinkClick === 'function') { try { (this.config as any).onLinkClick(href, a.textContent, this); } catch (_) {} return; }
|
||||||
|
window.open(href, '_blank', 'noopener');
|
||||||
|
};
|
||||||
|
this.previewEl.addEventListener('click', onPreviewClick);
|
||||||
|
const onToolbarClick = (e: MouseEvent) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest('.me-btn') as HTMLElement; if (!btn) return;
|
||||||
|
const mode = btn.dataset.mode; if (mode) { this.setMode(mode as EditMode); return; }
|
||||||
|
const action = btn.dataset.action; if (action) this.exec(action);
|
||||||
|
};
|
||||||
|
this.toolbarEl.addEventListener('click', onToolbarClick);
|
||||||
|
const onDividerDown = (e: PointerEvent) => this._bindDividerDrag(e);
|
||||||
|
this.dividerEl.addEventListener('pointerdown', onDividerDown);
|
||||||
|
this._bindDragDrop();
|
||||||
|
this._cleanups.push(() => {
|
||||||
|
ta.removeEventListener('input', onInput); ta.removeEventListener('keydown', onKeydown);
|
||||||
|
ta.removeEventListener('keypress', onKeypress); ta.removeEventListener('scroll', onScroll);
|
||||||
|
this.previewPane.removeEventListener('scroll', onPreviewScroll);
|
||||||
|
ta.removeEventListener('click', onCursorActivity); ta.removeEventListener('keyup', onCursorActivity);
|
||||||
|
ta.removeEventListener('focus', onFocus); ta.removeEventListener('blur', onBlur);
|
||||||
|
this.toolbarEl.removeEventListener('click', onToolbarClick);
|
||||||
|
this.dividerEl.removeEventListener('pointerdown', onDividerDown);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Tab') { e.preventDefault(); this._handleTab(e.shiftKey); return; }
|
||||||
|
if (this._shortcuts.length) {
|
||||||
|
for (const sc of this._shortcuts) {
|
||||||
|
const ctrlOk = sc.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
|
||||||
|
const shiftOk = sc.shift ? e.shiftKey : !e.shiftKey;
|
||||||
|
const altOk = sc.alt ? e.altKey : !e.altKey;
|
||||||
|
if (e.key.toLowerCase() === sc.key && ctrlOk && shiftOk && altOk) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (typeof sc.handler === 'string') this.exec(sc.handler);
|
||||||
|
else if (typeof sc.handler === 'function') { try { sc.handler(this); } catch (err) { console.error('Shortcut error:', err); } }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mod = e.ctrlKey || e.metaKey; if (!mod) return;
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
const shortcuts: Record<string, string> = { b: 'bold', i: 'italic', k: 'link', u: 'underline', e: 'code', '1': 'h1', '2': 'h2', '3': 'h3', q: 'quote' };
|
||||||
|
if (key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); }
|
||||||
|
else if ((key === 'z' && e.shiftKey) || key === 'y') { e.preventDefault(); this.redo(); }
|
||||||
|
else if (key === 's') { e.preventDefault(); this._emit('save', this._value); if (typeof this.config.onSave === 'function') { try { this.config.onSave(this._value, this); } catch (err) { console.error(err); } } }
|
||||||
|
else if (shortcuts[key]) { e.preventDefault(); this.exec(shortcuts[key]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleTab(shift: boolean): void {
|
||||||
|
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
|
||||||
|
const tabSize = this.config.tabSize || 0; const tabStr = tabSize > 0 ? ' '.repeat(tabSize) : '\t';
|
||||||
|
if (start === end) {
|
||||||
|
ta.value = ta.value.slice(0, start) + tabStr + ta.value.slice(end);
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + tabStr.length;
|
||||||
|
} else {
|
||||||
|
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const block = ta.value.slice(lineStart, end); const lines = block.split('\n');
|
||||||
|
let newBlock: string;
|
||||||
|
if (shift) { newBlock = lines.map((l) => l.replace(/^(\t| {1,4})/, '')).join('\n'); }
|
||||||
|
else { newBlock = lines.map((l) => tabStr + l).join('\n'); }
|
||||||
|
ta.value = ta.value.slice(0, lineStart) + newBlock + ta.value.slice(end);
|
||||||
|
ta.selectionStart = lineStart; ta.selectionEnd = lineStart + newBlock.length;
|
||||||
|
}
|
||||||
|
this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindDividerDrag(e: PointerEvent): void {
|
||||||
|
if (this._mode !== 'split') return; e.preventDefault();
|
||||||
|
const startX = e.clientX;
|
||||||
|
const editorWidth = this.editorPane.getBoundingClientRect().width;
|
||||||
|
const totalWidth = this.bodyEl.getBoundingClientRect().width;
|
||||||
|
if (totalWidth <= 0) return;
|
||||||
|
const divider = this.dividerEl;
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
const dx = ev.clientX - startX;
|
||||||
|
let newW = editorWidth + dx;
|
||||||
|
const min = 80; const max = totalWidth - 80 - divider.offsetWidth;
|
||||||
|
newW = Math.max(min, Math.min(max, newW));
|
||||||
|
const pct = (newW / totalWidth) * 100;
|
||||||
|
this.editorPane.style.flex = `0 0 ${pct}%`;
|
||||||
|
this.previewPane.style.flex = `1 1 ${100 - pct}%`;
|
||||||
|
};
|
||||||
|
const onUp = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); };
|
||||||
|
window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleRender(): void { if (this._renderRaf) return; this._renderRaf = requestAnimationFrame(() => { this._renderRaf = null; this._render(); }); }
|
||||||
|
|
||||||
|
_render(): void {
|
||||||
|
if (this._mode === 'edit') return;
|
||||||
|
if (this._lastRenderedValue === this._value) return;
|
||||||
|
this._lastRenderedValue = this._value;
|
||||||
|
MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this);
|
||||||
|
let html: string;
|
||||||
|
try { html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); }
|
||||||
|
catch (err: any) { console.error('MeEditor render error:', err); html = `<p style="color:#ef4444">${i18nT('renderError')}: ${escapeHTML(err.message)}</p>`; }
|
||||||
|
if (typeof this.config.sanitize === 'function') { try { html = this.config.sanitize(html); } catch (e) { console.error('sanitize error:', e); } }
|
||||||
|
this.previewEl.innerHTML = html; this._renderGutter();
|
||||||
|
MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this);
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderGutter(): void {
|
||||||
|
if (!this.config.lineNumbers || !this.gutter) return;
|
||||||
|
const lines = this._value ? this._value.split('\n').length : 1;
|
||||||
|
if (this.gutter.children.length === lines) return;
|
||||||
|
let h = ''; for (let i = 1; i <= lines; i++) h += `<div class="me-gutter-line">${i}</div>`;
|
||||||
|
this.gutter.innerHTML = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateCurrentLine(): void {
|
||||||
|
if (!this.gutter) return;
|
||||||
|
const pos = this.textarea.selectionStart;
|
||||||
|
const lineNum = this._value.substring(0, pos).split('\n').length;
|
||||||
|
const prev = this.gutter.querySelector('.me-gutter-active'); if (prev) prev.classList.remove('me-gutter-active');
|
||||||
|
const cur = this.gutter.children[lineNum - 1] as HTMLElement; if (cur) cur.classList.add('me-gutter-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleSmartEnter(e: KeyboardEvent): void {
|
||||||
|
const ta = this.textarea; const start = ta.selectionStart;
|
||||||
|
const val = ta.value; const lineStart = val.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const line = val.slice(lineStart, start);
|
||||||
|
const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s(.*)/);
|
||||||
|
if (listMatch) {
|
||||||
|
const indent = listMatch[1]; const marker = listMatch[2]; const content = listMatch[3];
|
||||||
|
if (!content.trim()) { e.preventDefault(); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; }
|
||||||
|
let nextMarker = marker;
|
||||||
|
if (/^\d+\.$/.test(marker)) { const num = parseInt(marker, 10); if (!isNaN(num)) nextMarker = (num + 1) + '.'; }
|
||||||
|
e.preventDefault(); const insert = '\n' + indent + nextMarker + ' ';
|
||||||
|
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + insert.length;
|
||||||
|
this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return;
|
||||||
|
}
|
||||||
|
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
|
||||||
|
if (quoteMatch) {
|
||||||
|
const prefix = quoteMatch[1]; const content = quoteMatch[2];
|
||||||
|
if (!content.trim()) { e.preventDefault(); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; }
|
||||||
|
e.preventDefault(); const insert = '\n' + prefix;
|
||||||
|
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + insert.length;
|
||||||
|
this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleBracketAutoClose(e: KeyboardEvent): void {
|
||||||
|
if (!this.config.autoBrackets) return;
|
||||||
|
const ta = this.textarea; const key = e.key; const close = BRACKET_PAIRS[key];
|
||||||
|
if (!close) return;
|
||||||
|
const start = ta.selectionStart; const end = ta.selectionEnd;
|
||||||
|
if (start !== end) { e.preventDefault(); const selected = ta.value.slice(start, end); const wrap = key + selected + close; ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.selectionStart = start + 1; ta.selectionEnd = start + 1 + selected.length; this._value = ta.value; this._pushHistory(); this._emit('change', this._value); return; }
|
||||||
|
const nextChar = ta.value[start] || '';
|
||||||
|
if (key === close) { if (nextChar === close) { e.preventDefault(); ta.selectionStart = ta.selectionEnd = start + 1; return; } if (/\w/.test(nextChar)) return; }
|
||||||
|
e.preventDefault(); const pair = key + close; ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = start + 1; this._value = ta.value; this._pushHistory(); this._emit('change', this._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindDragDrop(): void {
|
||||||
|
const ta = this.textarea; if (!ta) return;
|
||||||
|
const onDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; };
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (files && files.length) {
|
||||||
|
Array.from(files).forEach((file) => {
|
||||||
|
if (file.type.startsWith('image/')) { const reader = new FileReader(); reader.onload = () => { this.insert(`\n`); }; reader.readAsDataURL(file); }
|
||||||
|
else if (file.type.startsWith('text/') || /\.(md|txt|js|ts|json|css|html|xml|yml|yaml)$/i.test(file.name)) { const reader = new FileReader(); reader.onload = () => this.insert(reader.result as string); reader.readAsText(file); }
|
||||||
|
}); return;
|
||||||
|
}
|
||||||
|
const text = e.dataTransfer?.getData('text/plain'); if (text) this.insert(text);
|
||||||
|
};
|
||||||
|
ta.addEventListener('dragover', onDragOver); ta.addEventListener('drop', onDrop);
|
||||||
|
this._cleanups.push(() => { ta.removeEventListener('dragover', onDragOver); ta.removeEventListener('drop', onDrop); });
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildOutline(): void {
|
||||||
|
if (!this.config.outline || !this.previewEl) return;
|
||||||
|
const old = this.el.querySelector('.me-outline'); if (old) old.remove();
|
||||||
|
const headings: Array<{ level: number; id: string; text: string }> = [];
|
||||||
|
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
|
||||||
|
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') });
|
||||||
|
}
|
||||||
|
if (!headings.length) return;
|
||||||
|
const panel = document.createElement('div'); panel.className = 'me-outline';
|
||||||
|
panel.innerHTML = `<div class="me-outline-title">${i18nT('outline') || '大纲'}</div>`;
|
||||||
|
const buildTree = (items: typeof headings, minLevel: number): string => {
|
||||||
|
let h = '<ul>'; let i = 0;
|
||||||
|
while (i < items.length) {
|
||||||
|
const item = items[i]; if (item.level < minLevel) break;
|
||||||
|
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
|
||||||
|
const sub: typeof headings = []; let j = i + 1; while (j < items.length && items[j].level > item.level) { sub.push(items[j]); j++; }
|
||||||
|
if (sub.length) h += buildTree(sub, item.level + 1);
|
||||||
|
h += '</li>'; i = j > i + 1 ? j : i + 1;
|
||||||
|
}
|
||||||
|
return h + '</ul>';
|
||||||
|
};
|
||||||
|
panel.innerHTML += buildTree(headings, 1);
|
||||||
|
this.el.appendChild(panel);
|
||||||
|
panel.addEventListener('click', (e) => {
|
||||||
|
const a = (e.target as HTMLElement).closest('a'); if (!a) return; e.preventDefault();
|
||||||
|
const id = a.getAttribute('href')!.slice(1);
|
||||||
|
const target = this.previewEl.querySelector('#' + CSS.escape(id));
|
||||||
|
if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); const idx = this._value.indexOf(target.textContent || ''); if (idx !== -1) { this.textarea.focus(); this.textarea.setSelectionRange(idx, idx); } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateOutline(): void { if (!this.config.outline) return; if (this._outlineTimer) clearTimeout(this._outlineTimer); this._outlineTimer = setTimeout(() => this._buildOutline(), 300); }
|
||||||
|
|
||||||
|
_scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
|
||||||
|
|
||||||
|
_pushHistory(): void {
|
||||||
|
const cur = this._history[this._historyIndex]; if (cur === this._value) return;
|
||||||
|
this._history = this._history.slice(0, this._historyIndex + 1); this._history.push(this._value);
|
||||||
|
const limit = this.config.historyLimit! > 0 ? this.config.historyLimit! : 100;
|
||||||
|
while (this._history.length > limit) this._history.shift();
|
||||||
|
this._historyIndex = this._history.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): this { if (this._historyIndex <= 0) return this; this._historyIndex--; this._applyHistory(); return this; }
|
||||||
|
redo(): this { if (this._historyIndex >= this._history.length - 1) return this; this._historyIndex++; this._applyHistory(); return this; }
|
||||||
|
canUndo(): boolean { return this._historyIndex > 0; }
|
||||||
|
canRedo(): boolean { return this._historyIndex < this._history.length - 1; }
|
||||||
|
|
||||||
|
_applyHistory(): void {
|
||||||
|
this._value = this._history[this._historyIndex]; this.textarea.value = this._value;
|
||||||
|
this._render(); this._renderGutter(); this._updateWordCount();
|
||||||
|
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
|
||||||
|
if (this.previewPane && this.config.syncScroll) this.previewPane.scrollTop = 0;
|
||||||
|
this._emit('change', this._value);
|
||||||
|
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(action: string, ...args: any[]): this {
|
||||||
|
const actions: Record<string, () => void> = {
|
||||||
|
bold: () => this._wrapSelection('**', '**'), italic: () => this._wrapSelection('*', '*'),
|
||||||
|
strikethrough: () => this._wrapSelection('~~', '~~'), underline: () => this._wrapSelection('<u>', '</u>'),
|
||||||
|
code: () => this._wrapSelection('`', '`'), h1: () => this._toggleLinePrefix('# '),
|
||||||
|
h2: () => this._toggleLinePrefix('## '), h3: () => this._toggleLinePrefix('### '),
|
||||||
|
quote: () => this._toggleLinePrefix('> '), ul: () => this._toggleLinePrefix('- '),
|
||||||
|
ol: () => this._toggleLinePrefix('1. '), indent: () => this._handleTab(false),
|
||||||
|
outdent: () => this._handleTab(true), hr: () => this._insertBlock('\n---\n'),
|
||||||
|
link: () => this._insertLink(), image: () => this._insertImage(), table: () => this._insertTable(),
|
||||||
|
undo: () => this.undo(), redo: () => this.redo(),
|
||||||
|
edit: () => this.setMode('edit'), split: () => this.setMode('split'),
|
||||||
|
preview: () => this.setMode('preview'), fullscreen: () => this.toggleFullscreen(),
|
||||||
|
zen: () => this.toggleZen(), wordwrap: () => this.toggleWordWrap(),
|
||||||
|
};
|
||||||
|
const fn = actions[action];
|
||||||
|
if (fn) { (fn as Function).apply(this, args); return this; }
|
||||||
|
if (this._customActions && typeof this._customActions[action] === 'function') {
|
||||||
|
try { (this._customActions[action] as Function).apply(this, args); } catch (e) { console.error('custom action error:', e); }
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wrapSelection(before: string, after: string): void {
|
||||||
|
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
|
||||||
|
const selected = ta.value.slice(start, end); const text = selected || 'text';
|
||||||
|
const inserted = before + text + after;
|
||||||
|
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end); ta.focus();
|
||||||
|
if (selected) { ta.selectionStart = start + before.length; ta.selectionEnd = start + before.length + text.length; }
|
||||||
|
else { ta.selectionStart = ta.selectionEnd = start + before.length; }
|
||||||
|
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleLinePrefix(prefix: string): void {
|
||||||
|
const ta = this.textarea; const start = ta.selectionStart;
|
||||||
|
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const lineEndPos = ta.value.indexOf('\n', start); const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos;
|
||||||
|
const line = ta.value.slice(lineStart, lineEnd);
|
||||||
|
const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/);
|
||||||
|
let newLine: string;
|
||||||
|
if (existingMatch && existingMatch[0] === prefix) newLine = line.slice(prefix.length);
|
||||||
|
else if (existingMatch) newLine = prefix + line.slice(existingMatch[0].length);
|
||||||
|
else newLine = prefix + line;
|
||||||
|
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus();
|
||||||
|
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
|
||||||
|
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_insertBlock(text: string): void {
|
||||||
|
const ta = this.textarea; const start = ta.selectionStart;
|
||||||
|
const before = ta.value.slice(0, start); const needNL = before && !before.endsWith('\n');
|
||||||
|
const insert = (needNL ? '\n' : '') + text;
|
||||||
|
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd); ta.focus();
|
||||||
|
const pos = start + insert.length; ta.selectionStart = ta.selectionEnd = pos;
|
||||||
|
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_insertLink(): void { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || i18nT('link') || 'link'; const url = 'https://'; const insert = `[${sel}](${url})`; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); }
|
||||||
|
_insertImage(): void { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || i18nT('image') || 'image'; const url = 'https://'; const insert = ``; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); }
|
||||||
|
_insertTable(rows = 3, cols = 3): void { const header = Array.from({ length: cols }, (_, i) => `${i18nT('tableCols') || '列'}${i + 1}`).join(' | '); const sep = Array.from({ length: cols }, () => '---').join(' | '); let md = `| ${header} |\n| ${sep} |\n`; for (let r = 1; r < rows; r++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`; this._insertBlock('\n' + md); }
|
||||||
|
|
||||||
|
setMode(mode: EditMode): this { if (!MODES.includes(mode) || mode === this._mode) return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit') this._render(); this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${i18nT(mode) || mode} mode`); if (typeof this.config.onModeChange === 'function') { try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); } } return this; }
|
||||||
|
getMode(): EditMode { return this._mode; }
|
||||||
|
|
||||||
|
_updateModeButtons(): void {
|
||||||
|
const isPreview = this._mode === 'preview'; const isReadonly = this.config.readOnly;
|
||||||
|
this.toolbarEl.querySelectorAll('.me-btn').forEach((b) => {
|
||||||
|
const btn = b as HTMLButtonElement; const mode = btn.dataset.mode; const action = btn.dataset.action;
|
||||||
|
if (mode) { btn.classList.toggle('me-active', mode === this._mode); btn.disabled = false; return; }
|
||||||
|
if (action === 'fullscreen') { btn.disabled = false; return; }
|
||||||
|
if (isReadonly) { btn.disabled = !(action === 'undo' || action === 'redo'); return; }
|
||||||
|
if (isPreview) { btn.disabled = !(action === 'undo' || action === 'redo'); return; }
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFullscreen(): this { this._fullscreen = !this._fullscreen; this.el.classList.toggle('me-fullscreen', this._fullscreen); const fsBtn = this.toolbarEl.querySelector('.me-btn-fullscreen') as HTMLButtonElement; if (fsBtn) fsBtn.title = this._fullscreen ? (i18nT('fullscreenExit') || '退出全屏') : (i18nT('fullscreen') || '全屏'); this._emit('fullscreen', this._fullscreen); if (typeof this.config.onFullscreen === 'function') { try { this.config.onFullscreen(this._fullscreen, this); } catch (e) { console.error(e); } } return this; }
|
||||||
|
isFullscreen(): boolean { return this._fullscreen; }
|
||||||
|
exitFullscreen(): this { if (this._fullscreen) this.toggleFullscreen(); return this; }
|
||||||
|
|
||||||
|
toggleZen(): this {
|
||||||
|
this._zenMode = !this._zenMode; if (this.el) this.el.classList.toggle('me-zen', this._zenMode);
|
||||||
|
if (this._zenMode && this.toolbarEl) {
|
||||||
|
this._zenMouseHandler = (e: MouseEvent) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; };
|
||||||
|
this.toolbarEl.style.transition = 'opacity 0.2s'; this.toolbarEl.style.opacity = '0'; this.toolbarEl.style.pointerEvents = 'none';
|
||||||
|
document.addEventListener('mousemove', this._zenMouseHandler);
|
||||||
|
if (this.statusEl) this.statusEl.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; }
|
||||||
|
if (this.toolbarEl) { this.toolbarEl.style.opacity = ''; this.toolbarEl.style.pointerEvents = ''; this.toolbarEl.style.transition = ''; }
|
||||||
|
if (this.statusEl) this.statusEl.style.display = '';
|
||||||
|
}
|
||||||
|
this._emit('zenChange', this._zenMode); return this;
|
||||||
|
}
|
||||||
|
isZen(): boolean { return this._zenMode; }
|
||||||
|
|
||||||
|
toggleWordWrap(): this { this._wordWrap = !this._wordWrap; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
|
||||||
|
setWordWrap(on: boolean): this { this._wordWrap = !!on; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
|
||||||
|
isWordWrap(): boolean { return this._wordWrap; }
|
||||||
|
|
||||||
|
_bindToolbarKeyboard(): void {
|
||||||
|
if (!this.toolbarEl) return;
|
||||||
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
|
const btns = [...this.toolbarEl.querySelectorAll('.me-btn:not(:disabled)')] as HTMLButtonElement[];
|
||||||
|
if (!btns.length) return; const idx = btns.indexOf(document.activeElement as HTMLButtonElement); if (idx === -1) return;
|
||||||
|
if (e.key === 'ArrowRight') { e.preventDefault(); btns[(idx + 1) % btns.length].focus(); }
|
||||||
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); btns[(idx - 1 + btns.length) % btns.length].focus(); }
|
||||||
|
else if (e.key === 'Home') { e.preventDefault(); btns[0].focus(); }
|
||||||
|
else if (e.key === 'End') { e.preventDefault(); btns[btns.length - 1].focus(); }
|
||||||
|
};
|
||||||
|
this.toolbarEl.addEventListener('keydown', onKeydown);
|
||||||
|
this._cleanups.push(() => this.toolbarEl.removeEventListener('keydown', onKeydown));
|
||||||
|
}
|
||||||
|
|
||||||
|
_initAriaLive(): void { if (!this.el) return; const live = document.createElement('div'); live.className = 'me-sr-only'; live.setAttribute('aria-live', 'polite'); live.setAttribute('aria-atomic', 'true'); this.el.appendChild(live); this._ariaLive = live; }
|
||||||
|
_announce(msg: string): void { if (this._ariaLive) { this._ariaLive.textContent = ''; requestAnimationFrame(() => { this._ariaLive.textContent = msg; }); } }
|
||||||
|
|
||||||
|
_syncScroll(): void {
|
||||||
|
if (this._syncing) return; this._syncing = true; const ta = this.textarea; if (!ta) { this._syncing = false; return; }
|
||||||
|
const tmax = ta.scrollHeight - ta.clientHeight; if (tmax <= 0) { this._syncing = false; return; }
|
||||||
|
const ratio = ta.scrollTop / tmax;
|
||||||
|
if (this.gutter) { const gmax = this.gutter.scrollHeight - this.gutter.clientHeight; this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ta.scrollTop; }
|
||||||
|
if (this.config.syncScroll && this._mode === 'split' && this.previewPane) {
|
||||||
|
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
|
||||||
|
if (pmax > 0) this.previewPane.scrollTop = ratio * pmax;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => { this._syncing = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateWordCount(): void {
|
||||||
|
if (!this.config.wordCount || !this.statusEl) return;
|
||||||
|
const text = this._value || '';
|
||||||
|
const chars = text.length;
|
||||||
|
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||||
|
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
||||||
|
const words = cnChars + enWords; const lines = text ? text.split('\n').length : 0;
|
||||||
|
const readingMin = Math.max(1, Math.ceil(words / 300));
|
||||||
|
this.statusEl.textContent = [`${i18nT('characters')}: ${chars}`, `${i18nT('words')}: ${words}`, `${i18nT('lines')}: ${lines}`, `${i18nT('readingTime')}: ${readingMin} ${i18nT('minutes')}`].join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats() { const text = this._value || ''; const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length; return { characters: text.length, words: cnChars + enWords, chineseChars: cnChars, englishWords: enWords, lines: text ? text.split('\n').length : 0, readingTime: Math.max(1, Math.ceil((cnChars + enWords) / 300)), }; }
|
||||||
|
|
||||||
|
getValue(): string { return this._destroyed ? '' : this._value; }
|
||||||
|
setValue(md: string, opts: { silent?: boolean } = {}): this {
|
||||||
|
if (this._destroyed) return this; this._value = md || ''; this.textarea.value = this._value;
|
||||||
|
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 (!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); } } }
|
||||||
|
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; }
|
||||||
|
refresh(): this { this._lastRenderedValue = null; this._render(); return this; }
|
||||||
|
|
||||||
|
insert(text: string, opts: { replace?: boolean } = {}): this {
|
||||||
|
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.selectionStart = ta.selectionEnd = start + text.length;
|
||||||
|
this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return this;
|
||||||
|
}
|
||||||
|
wrap(before: string, after: string): this { this._wrapSelection(before, after || before); return this; }
|
||||||
|
focus(): this { if (this.textarea) this.textarea.focus(); return this; }
|
||||||
|
blur(): this { if (this.textarea) this.textarea.blur(); return this; }
|
||||||
|
enable(): this { if (this.textarea) this.textarea.disabled = false; if (this.el) this.el.classList.remove('me-disabled'); return this; }
|
||||||
|
disable(): this { if (this.textarea) this.textarea.disabled = true; if (this.el) this.el.classList.add('me-disabled'); return this; }
|
||||||
|
isDisabled(): boolean { return this.textarea ? this.textarea.disabled : false; }
|
||||||
|
setReadOnly(readOnly: boolean): this { this.config.readOnly = !!readOnly; if (this.textarea) this.textarea.readOnly = !!readOnly; if (this.el) this.el.classList.toggle('me-readonly', !!readOnly); this._updateModeButtons(); return this; }
|
||||||
|
isReadOnly(): boolean { return !!this.config.readOnly; }
|
||||||
|
|
||||||
|
on(name: string, fn: (...args: any[]) => void): () => void { if (!this._listeners[name]) this._listeners[name] = []; this._listeners[name].push(fn); return () => this.off(name, fn); }
|
||||||
|
off(name: string, fn: (...args: any[]) => void): this { const list = this._listeners[name]; if (list) this._listeners[name] = list.filter((f) => f !== fn); return this; }
|
||||||
|
_emit(name: string, ...args: any[]): void { const list = this._listeners[name]; if (list) list.forEach((fn) => { try { fn(...args, this); } catch (e) { console.error(`MeEditor event "${name}" error:`, e); } }); }
|
||||||
|
|
||||||
|
use(plugin: string | any, options: any = {}): this {
|
||||||
|
let p = plugin; if (typeof plugin === 'string') { p = presetPlugins[plugin]; if (!p) { console.warn(`MeEditor: preset plugin "${plugin}" not found`); return this; } }
|
||||||
|
if (!p || typeof p !== 'object') { console.warn('MeEditor: invalid plugin'); return this; }
|
||||||
|
const merged = { ...p, ...options };
|
||||||
|
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)); } }
|
||||||
|
catch (e) { console.error(`MeEditor: plugin "${merged.name}" install error:`, e); }
|
||||||
|
}
|
||||||
|
this._plugins.push(merged); return this;
|
||||||
|
}
|
||||||
|
unuse(name: string): this { const idx = this._plugins.findIndex((p) => p.name === name); if (idx === -1) { console.warn(`MeEditor: plugin "${name}" not found`); return this; } const plugin = this._plugins[idx]; if (typeof plugin.destroy === 'function') { try { plugin.destroy(this); } catch (e) { console.error(`MeEditor: plugin "${name}" destroy error:`, e); } } this._plugins.splice(idx, 1); return this; }
|
||||||
|
getPlugins(): any[] { return [...this._plugins]; }
|
||||||
|
|
||||||
|
addToolbarButton(config: any): this {
|
||||||
|
if (!config || !config.action) return this;
|
||||||
|
const btn = document.createElement('button'); btn.type = 'button'; btn.className = `me-btn me-btn-custom-${config.action}`;
|
||||||
|
btn.title = config.title || config.action; btn.setAttribute('aria-label', config.title || config.action);
|
||||||
|
btn.innerHTML = config.icon || `<span>${escapeHTML(config.text || config.action)}</span>`;
|
||||||
|
if (typeof config.onClick === 'function') { btn.addEventListener('click', (e) => { e.stopPropagation(); config.onClick(this); }); }
|
||||||
|
else if (config.action) { btn.dataset.action = config.action; this._customActions = this._customActions || {}; this._customActions[config.action] = config.handler; }
|
||||||
|
this.toolbarEl.insertBefore(btn, this.toolbarEl.querySelector('.me-toolbar-group') || null); return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerShortcut(combo: string, handler: Function | string, description: string = ''): this {
|
||||||
|
if (!combo) return this;
|
||||||
|
const parts = combo.toLowerCase().split('+'); const key = parts.pop()!;
|
||||||
|
const ctrl = parts.includes('ctrl') || parts.includes('cmd'); const shift = parts.includes('shift'); const alt = parts.includes('alt'); const meta = parts.includes('meta');
|
||||||
|
this._shortcuts.push({ combo, key, ctrl, shift, alt, meta, handler, description }); return this;
|
||||||
|
}
|
||||||
|
unregisterShortcut(combo: string): this { this._shortcuts = this._shortcuts.filter((s) => s.combo !== combo); return this; }
|
||||||
|
getShortcuts(): any[] { return [...this._shortcuts]; }
|
||||||
|
|
||||||
|
configureToolbar(tools: ToolbarItem[]): this { if (!this.toolbarEl) return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; }
|
||||||
|
removeToolbarButton(action: string): this { if (!this.toolbarEl) return this; const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`); if (btn) btn.remove(); return this; }
|
||||||
|
registerContextMenu(items: any[] = []): this { this._contextMenuItems = items; return this; }
|
||||||
|
|
||||||
|
toast(message: string, opts: { type?: string; duration?: number; animation?: string } = {}): this {
|
||||||
|
if (!this.el || typeof document === 'undefined') return this;
|
||||||
|
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
|
||||||
|
const toast = document.createElement('div'); toast.className = `me-toast me-toast-${type} me-toast-${animation}`;
|
||||||
|
toast.innerHTML = `<span class="me-toast-icon">${TOAST_ICONS[type] || ''}</span><span class="me-toast-msg">${escapeHTML(String(message))}</span>`;
|
||||||
|
this.el.appendChild(toast); requestAnimationFrame(() => { toast.classList.add('me-toast-enter'); });
|
||||||
|
const remove = () => { toast.classList.remove('me-toast-enter'); toast.classList.add('me-toast-leave'); setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300); };
|
||||||
|
if (duration > 0) setTimeout(remove, duration); toast.addEventListener('click', remove); return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocale(locale: string): this { if (this._i18nCtx) this._i18nCtx.set(locale); return this; }
|
||||||
|
getLocale(): string { if (this._i18nCtx) return this._i18nCtx.get(); return getCurrentLocale(); }
|
||||||
|
t(key: string, params?: Record<string, any>): string { if (this._i18nCtx) return this._i18nCtx.t(key, params); return i18nT(key, params); }
|
||||||
|
|
||||||
|
setTheme(theme: ThemeName): this { if (!theme) return this; this.config.theme = theme; if (this._themeCtx) this._themeCtx.set(theme); return this; }
|
||||||
|
getTheme(): string { if (this._themeCtx) return this._themeCtx.get(); return this.config.theme || 'auto'; }
|
||||||
|
getThemeContext(): any { return this._themeCtx || null; }
|
||||||
|
|
||||||
|
getStatus() { return { id: this.id, mode: this._mode, theme: this.getTheme(), locale: getCurrentLocale(), fullscreen: this._fullscreen, readOnly: this.config.readOnly || false, disabled: this.textarea ? this.textarea.disabled : false, destroyed: this._destroyed, plugins: this._plugins.map((p) => p.name || 'custom'), }; }
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this._destroyed) return;
|
||||||
|
MarkdownEditor.trigger('beforeDestroy', this);
|
||||||
|
if (this._renderRaf) cancelAnimationFrame(this._renderRaf);
|
||||||
|
if (this._historyTimer) clearTimeout(this._historyTimer);
|
||||||
|
if (this._outlineTimer) clearTimeout(this._outlineTimer);
|
||||||
|
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = [];
|
||||||
|
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
|
||||||
|
if (this.el) this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
|
||||||
|
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { try { p.destroy(this); } catch (_) {} } }); this._plugins = [];
|
||||||
|
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
|
||||||
|
this.el = null!; this.textarea = null!; this.previewEl = null!;
|
||||||
|
this._destroyed = true;
|
||||||
|
if (typeof this.config.onDestroy === 'function') { try { this.config.onDestroy(this); } catch (e) { console.error(e); } }
|
||||||
|
this._emit('destroy'); this._listeners = {};
|
||||||
|
MarkdownEditor.trigger('afterDestroy', this);
|
||||||
|
}
|
||||||
|
isDestroyed(): boolean { return this._destroyed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MarkdownEditor as Editor };
|
||||||
|
export default MarkdownEditor;
|
||||||
-438
@@ -1,438 +0,0 @@
|
|||||||
/**
|
|
||||||
* MetonaEditor i18n — 国际化管理(增强版)
|
|
||||||
* @module i18n
|
|
||||||
* @version 0.1.14
|
|
||||||
* @description 多语言支持、实例级语言隔离、复数规则、动态加载、命名空间
|
|
||||||
*
|
|
||||||
* v0.1.6 增强:
|
|
||||||
* - 实例级 i18n: createInstanceI18n() 每编辑器独立语言
|
|
||||||
* - 复数规则: t('items', { count: 5 }) 自动选择单/复数形式
|
|
||||||
* - 动态加载: i18nUtils.loadRemote(url) 远程加载翻译包
|
|
||||||
* - 命名空间: t('editor.bold') 点号嵌套访问
|
|
||||||
* - 实例事件: editor.on('localeChange') 语言变化通知
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { LOCALES } from './constants.js';
|
|
||||||
|
|
||||||
// ============ 全局状态(向后兼容) ============
|
|
||||||
|
|
||||||
let currentLocale = 'zh-CN';
|
|
||||||
let localeListeners = new Set();
|
|
||||||
let fallbackLocale = 'zh-CN';
|
|
||||||
|
|
||||||
// ============ 复数规则 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CLDR 复数类别简化版
|
|
||||||
* 支持: zh (单类别), en (one/other), ru (one/few/many/other)
|
|
||||||
*/
|
|
||||||
const pluralRules = {
|
|
||||||
zh: () => 'other',
|
|
||||||
en: (n) => n === 1 ? 'one' : 'other',
|
|
||||||
ru: (n) => {
|
|
||||||
const m = n % 10, h = n % 100;
|
|
||||||
if (m === 1 && h !== 11) return 'one';
|
|
||||||
if (m >= 2 && m <= 4 && !(h >= 12 && h <= 14)) return 'few';
|
|
||||||
if (m === 0 || (m >= 5 && m <= 9) || (h >= 11 && h <= 14)) return 'many';
|
|
||||||
return 'other';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取复数形式
|
|
||||||
*/
|
|
||||||
const getPluralForm = (locale, count) => {
|
|
||||||
const lang = locale.split('-')[0].toLowerCase();
|
|
||||||
const rule = pluralRules[lang] || pluralRules.en;
|
|
||||||
return rule(Math.abs(count));
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 翻译函数 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前语言
|
|
||||||
*/
|
|
||||||
const getCurrentLocale = () => currentLocale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置当前语言
|
|
||||||
*/
|
|
||||||
const setCurrentLocale = (locale) => {
|
|
||||||
let target = locale;
|
|
||||||
if (!LOCALES[target]) {
|
|
||||||
console.warn(`Locale "${target}" not found, falling back to "${fallbackLocale}"`);
|
|
||||||
target = fallbackLocale;
|
|
||||||
}
|
|
||||||
currentLocale = target;
|
|
||||||
notifyLocaleListeners(target);
|
|
||||||
saveLocale(target);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 翻译函数
|
|
||||||
* @param {string} key - 翻译键,支持点号分隔命名空间 'editor.bold'
|
|
||||||
* @param {Object} [params] - 插值参数,含 count 时触发复数规则
|
|
||||||
* @param {string} [locale] - 指定语言(默认当前语言)
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const t = (key, params = {}, locale) => {
|
|
||||||
const loc = locale || currentLocale;
|
|
||||||
let translation = getTranslation(loc, key);
|
|
||||||
|
|
||||||
// 复数处理
|
|
||||||
if (translation && typeof translation === 'object' && params.count !== undefined) {
|
|
||||||
const form = getPluralForm(loc, params.count);
|
|
||||||
translation = translation[form] || translation.other || key;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof translation === 'string') {
|
|
||||||
return interpolate(translation, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回退
|
|
||||||
if (loc !== fallbackLocale) {
|
|
||||||
let fbTranslation = getTranslation(fallbackLocale, key);
|
|
||||||
if (fbTranslation && typeof fbTranslation === 'object' && params.count !== undefined) {
|
|
||||||
const form = getPluralForm(fallbackLocale, params.count);
|
|
||||||
fbTranslation = fbTranslation[form] || fbTranslation.other;
|
|
||||||
}
|
|
||||||
if (typeof fbTranslation === 'string') {
|
|
||||||
return interpolate(fbTranslation, params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`Translation missing for key "${key}" in locale "${loc}"`);
|
|
||||||
return key;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取翻译(支持点号分隔的命名空间)
|
|
||||||
*/
|
|
||||||
const getTranslation = (locale, key) => {
|
|
||||||
const localeData = LOCALES[locale];
|
|
||||||
if (!localeData) return undefined;
|
|
||||||
|
|
||||||
const keys = key.split('.');
|
|
||||||
let result = localeData;
|
|
||||||
|
|
||||||
for (const k of keys) {
|
|
||||||
if (result && typeof result === 'object' && k in result) {
|
|
||||||
result = result[k];
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 插值替换
|
|
||||||
*/
|
|
||||||
const interpolate = (str, params) => {
|
|
||||||
if (!str || typeof str !== 'string') return String(str);
|
|
||||||
return str.replace(/\{(\w+)\}/g, (match, key) => {
|
|
||||||
return params[key] !== undefined ? String(params[key]) : match;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 翻译查询 ============
|
|
||||||
|
|
||||||
const hasTranslation = (key) => {
|
|
||||||
return getTranslation(currentLocale, key) !== undefined ||
|
|
||||||
getTranslation(fallbackLocale, key) !== undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTranslations = (locale) => LOCALES[locale] || {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加/合并翻译
|
|
||||||
*/
|
|
||||||
const addTranslations = (locale, translations) => {
|
|
||||||
if (!LOCALES[locale]) LOCALES[locale] = {};
|
|
||||||
deepMerge(LOCALES[locale], translations);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 远程加载翻译包
|
|
||||||
* @param {string} url - JSON 翻译包 URL
|
|
||||||
* @param {string} locale - 语言代码
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
const loadRemote = async (url, locale) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
addTranslations(locale, data);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`MeEditor: failed to load locale "${locale}" from ${url}`, e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deepMerge = (target, source) => {
|
|
||||||
for (const key in source) {
|
|
||||||
if (source[key] instanceof Object && key in target && target[key] instanceof Object && !Array.isArray(source[key])) {
|
|
||||||
deepMerge(target[key], source[key]);
|
|
||||||
} else {
|
|
||||||
target[key] = source[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return target;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 语言信息 ============
|
|
||||||
|
|
||||||
const getSupportedLocales = () => Object.keys(LOCALES);
|
|
||||||
const isLocaleSupported = (locale) => locale in LOCALES;
|
|
||||||
|
|
||||||
const getLocaleName = (locale) => {
|
|
||||||
const names = {
|
|
||||||
'zh-CN': '简体中文', 'zh-TW': '繁體中文', 'en-US': 'English (US)',
|
|
||||||
'en-GB': 'English (UK)', 'ja': '日本語', 'ko': '한국어',
|
|
||||||
'fr': 'Français', 'de': 'Deutsch', 'es': 'Español', 'pt': 'Português',
|
|
||||||
'ru': 'Русский', 'ar': 'العربية', 'hi': 'हिन्दी', 'th': 'ไทย',
|
|
||||||
'vi': 'Tiếng Việt', 'id': 'Bahasa Indonesia', 'tr': 'Türkçe',
|
|
||||||
'it': 'Italiano', 'nl': 'Nederlands', 'pl': 'Polski',
|
|
||||||
};
|
|
||||||
return names[locale] || locale;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLocaleDirection = (locale) => {
|
|
||||||
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug'];
|
|
||||||
return rtlLocales.includes(locale) ? 'rtl' : 'ltr';
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 格式化 ============
|
|
||||||
|
|
||||||
const formatNumber = (number, options = {}) => {
|
|
||||||
try { return new Intl.NumberFormat(currentLocale, options).format(number); }
|
|
||||||
catch (_) { return String(number); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount, currency = 'USD', options = {}) => {
|
|
||||||
try { return new Intl.NumberFormat(currentLocale, { style: 'currency', currency, ...options }).format(amount); }
|
|
||||||
catch (_) { return String(amount); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date, options = {}) => {
|
|
||||||
try {
|
|
||||||
const dateObj = date instanceof Date ? date : new Date(date);
|
|
||||||
return new Intl.DateTimeFormat(currentLocale, options).format(dateObj);
|
|
||||||
} catch (_) { return String(date); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 监听器 ============
|
|
||||||
|
|
||||||
const addLocaleListener = (fn) => {
|
|
||||||
localeListeners.add(fn);
|
|
||||||
return () => localeListeners.delete(fn);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeLocaleListener = (fn) => { localeListeners.delete(fn); };
|
|
||||||
const clearLocaleListeners = () => { localeListeners.clear(); };
|
|
||||||
|
|
||||||
const notifyLocaleListeners = (locale) => {
|
|
||||||
localeListeners.forEach((fn) => {
|
|
||||||
try { fn(locale); } catch (e) { console.error('Locale listener error:', e); }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 持久化 ============
|
|
||||||
|
|
||||||
const saveLocale = (locale) => {
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
|
||||||
try { localStorage.setItem('metona-editor-locale', locale); } catch (_) {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadLocale = () => {
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
|
||||||
try { return localStorage.getItem('metona-editor-locale') || getDefaultLocale(); } catch (_) {}
|
|
||||||
}
|
|
||||||
return getDefaultLocale();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDefaultLocale = () => {
|
|
||||||
if (typeof navigator !== 'undefined') {
|
|
||||||
const browserLocale = navigator.language || navigator.userLanguage;
|
|
||||||
if (browserLocale && isLocaleSupported(browserLocale)) return browserLocale;
|
|
||||||
const shortLocale = browserLocale?.split('-')[0];
|
|
||||||
if (shortLocale && isLocaleSupported(shortLocale)) return shortLocale;
|
|
||||||
}
|
|
||||||
return fallbackLocale;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 初始化 ============
|
|
||||||
|
|
||||||
const initI18n = () => {
|
|
||||||
const savedLocale = loadLocale();
|
|
||||||
setCurrentLocale(savedLocale);
|
|
||||||
};
|
|
||||||
|
|
||||||
const switchLocale = (locale) => { setCurrentLocale(locale); };
|
|
||||||
|
|
||||||
// ============ 实例级 i18n(v0.1.6 新增) ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 为编辑器实例创建隔离的 i18n 上下文
|
|
||||||
* @param {Object} editor - 编辑器实例(需有 el / config 属性)
|
|
||||||
* @returns {Object} 实例 i18n API
|
|
||||||
*/
|
|
||||||
const createInstanceI18n = (editor) => {
|
|
||||||
let instanceLocale = editor.config?.locale || currentLocale || 'zh-CN';
|
|
||||||
|
|
||||||
const instanceT = (key, params = {}) => t(key, params, instanceLocale);
|
|
||||||
|
|
||||||
const set = (locale) => {
|
|
||||||
if (!locale || instanceLocale === locale) return;
|
|
||||||
instanceLocale = locale;
|
|
||||||
|
|
||||||
// 更新编辑器 UI 文本
|
|
||||||
if (editor.el) {
|
|
||||||
editor.el.setAttribute('lang', locale);
|
|
||||||
editor.el.setAttribute('dir', getLocaleDirection(locale));
|
|
||||||
}
|
|
||||||
if (editor.textarea) {
|
|
||||||
editor.textarea.placeholder = instanceT('placeholder') || '';
|
|
||||||
editor.textarea.setAttribute('aria-label', instanceT('edit') || '');
|
|
||||||
editor.textarea.setAttribute('dir', getLocaleDirection(locale));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 触发事件
|
|
||||||
if (typeof editor._emit === 'function') {
|
|
||||||
editor._emit('localeChange', { locale, direction: getLocaleDirection(locale) });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新工具栏按钮 tooltip
|
|
||||||
if (editor.toolbarEl) {
|
|
||||||
editor.toolbarEl.querySelectorAll('.me-btn').forEach((btn) => {
|
|
||||||
const action = btn.dataset.action || btn.dataset.mode;
|
|
||||||
if (action) {
|
|
||||||
const label = instanceT(action);
|
|
||||||
if (label && label !== action) {
|
|
||||||
btn.title = label;
|
|
||||||
btn.setAttribute('aria-label', label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return instanceLocale;
|
|
||||||
};
|
|
||||||
|
|
||||||
const get = () => instanceLocale;
|
|
||||||
const getDirection = () => getLocaleDirection(instanceLocale);
|
|
||||||
|
|
||||||
return {
|
|
||||||
set,
|
|
||||||
get,
|
|
||||||
getDirection,
|
|
||||||
t: instanceT,
|
|
||||||
formatNumber: (n, opts) => {
|
|
||||||
try { return new Intl.NumberFormat(instanceLocale, opts).format(n); } catch (_) { return String(n); }
|
|
||||||
},
|
|
||||||
formatDate: (d, opts) => {
|
|
||||||
try { return new Intl.DateTimeFormat(instanceLocale, opts).format(d instanceof Date ? d : new Date(d)); } catch (_) { return String(d); }
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 预设 ============
|
|
||||||
|
|
||||||
const presetLocales = {
|
|
||||||
'zh-CN': { name: '简体中文', nativeName: '简体中文', direction: 'ltr', translations: LOCALES['zh-CN'] },
|
|
||||||
'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 代理层 ============
|
|
||||||
|
|
||||||
const i18nUtils = {
|
|
||||||
t,
|
|
||||||
getCurrentLocale,
|
|
||||||
setCurrentLocale,
|
|
||||||
switchLocale,
|
|
||||||
getFallbackLocale: () => fallbackLocale,
|
|
||||||
setFallbackLocale: (locale) => { fallbackLocale = locale; },
|
|
||||||
hasTranslation,
|
|
||||||
getTranslations,
|
|
||||||
addTranslations,
|
|
||||||
loadRemote,
|
|
||||||
getSupportedLocales,
|
|
||||||
isLocaleSupported,
|
|
||||||
getLocaleName,
|
|
||||||
getLocaleDirection,
|
|
||||||
formatNumber,
|
|
||||||
formatCurrency,
|
|
||||||
formatDate,
|
|
||||||
addLocaleListener,
|
|
||||||
removeLocaleListener,
|
|
||||||
clearLocaleListeners,
|
|
||||||
initI18n,
|
|
||||||
saveLocale,
|
|
||||||
loadLocale,
|
|
||||||
getDefaultLocale,
|
|
||||||
createInstanceI18n,
|
|
||||||
};
|
|
||||||
|
|
||||||
const createI18nManager = () => ({
|
|
||||||
t,
|
|
||||||
getCurrentLocale,
|
|
||||||
setCurrentLocale,
|
|
||||||
switchLocale,
|
|
||||||
getFallbackLocale: () => fallbackLocale,
|
|
||||||
setFallbackLocale: (locale) => { fallbackLocale = locale; },
|
|
||||||
hasTranslation,
|
|
||||||
getTranslations,
|
|
||||||
addTranslations,
|
|
||||||
loadRemote,
|
|
||||||
getSupportedLocales,
|
|
||||||
isLocaleSupported,
|
|
||||||
getLocaleName,
|
|
||||||
getLocaleDirection,
|
|
||||||
formatNumber,
|
|
||||||
formatCurrency,
|
|
||||||
formatDate,
|
|
||||||
addLocaleListener,
|
|
||||||
removeLocaleListener,
|
|
||||||
clearLocaleListeners,
|
|
||||||
initI18n,
|
|
||||||
saveLocale,
|
|
||||||
loadLocale,
|
|
||||||
getDefaultLocale,
|
|
||||||
createInstanceI18n,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============ 导出 ============
|
|
||||||
|
|
||||||
export {
|
|
||||||
i18nUtils,
|
|
||||||
i18nUtils as default,
|
|
||||||
t,
|
|
||||||
getCurrentLocale,
|
|
||||||
setCurrentLocale,
|
|
||||||
switchLocale,
|
|
||||||
hasTranslation,
|
|
||||||
getTranslations,
|
|
||||||
addTranslations,
|
|
||||||
loadRemote,
|
|
||||||
getSupportedLocales,
|
|
||||||
isLocaleSupported,
|
|
||||||
getLocaleName,
|
|
||||||
getLocaleDirection,
|
|
||||||
formatNumber,
|
|
||||||
formatCurrency,
|
|
||||||
formatDate,
|
|
||||||
addLocaleListener,
|
|
||||||
removeLocaleListener,
|
|
||||||
clearLocaleListeners,
|
|
||||||
initI18n,
|
|
||||||
saveLocale,
|
|
||||||
loadLocale,
|
|
||||||
getDefaultLocale,
|
|
||||||
createInstanceI18n,
|
|
||||||
createI18nManager,
|
|
||||||
presetLocales,
|
|
||||||
};
|
|
||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor i18n — internationalization
|
||||||
|
* @module i18n
|
||||||
|
* @version 0.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LOCALES } from './constants';
|
||||||
|
import type { EditorOptions } from './constants';
|
||||||
|
|
||||||
|
let currentLocale = 'zh-CN';
|
||||||
|
let localeListeners = new Set<(locale: string) => void>();
|
||||||
|
let fallbackLocale = 'zh-CN';
|
||||||
|
|
||||||
|
type PluralRule = (n: number) => string;
|
||||||
|
|
||||||
|
const pluralRules: Record<string, PluralRule> = {
|
||||||
|
zh: () => 'other',
|
||||||
|
en: (n) => n === 1 ? 'one' : 'other',
|
||||||
|
ru: (n) => {
|
||||||
|
const m = n % 10, h = n % 100;
|
||||||
|
if (m === 1 && h !== 11) return 'one';
|
||||||
|
if (m >= 2 && m <= 4 && !(h >= 12 && h <= 14)) return 'few';
|
||||||
|
if (m === 0 || (m >= 5 && m <= 9) || (h >= 11 && h <= 14)) return 'many';
|
||||||
|
return 'other';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPluralForm = (locale: string, count: number): string => {
|
||||||
|
const lang = locale.split('-')[0].toLowerCase();
|
||||||
|
const rule = pluralRules[lang] || pluralRules.en!;
|
||||||
|
return rule(Math.abs(count));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentLocale = (): string => currentLocale;
|
||||||
|
|
||||||
|
export const setCurrentLocale = (locale: string): void => {
|
||||||
|
let target = locale;
|
||||||
|
if (!LOCALES[target]) {
|
||||||
|
console.warn(`Locale "${target}" not found, falling back to "${fallbackLocale}"`);
|
||||||
|
target = fallbackLocale;
|
||||||
|
}
|
||||||
|
currentLocale = target;
|
||||||
|
notifyLocaleListeners(target);
|
||||||
|
saveLocale(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const t = (key: string, params: Record<string, any> = {}, locale?: string): string => {
|
||||||
|
const loc = locale || currentLocale;
|
||||||
|
let translation: any = getTranslation(loc, key);
|
||||||
|
if (translation && typeof translation === 'object' && params.count !== undefined) {
|
||||||
|
const form = getPluralForm(loc, params.count);
|
||||||
|
translation = translation[form] || translation.other || key;
|
||||||
|
}
|
||||||
|
if (typeof translation === 'string') return interpolate(translation, params);
|
||||||
|
if (loc !== fallbackLocale) {
|
||||||
|
let fb: any = getTranslation(fallbackLocale, key);
|
||||||
|
if (fb && typeof fb === 'object' && params.count !== undefined) {
|
||||||
|
const form = getPluralForm(fallbackLocale, params.count);
|
||||||
|
fb = fb[form] || fb.other;
|
||||||
|
}
|
||||||
|
if (typeof fb === 'string') return interpolate(fb, params);
|
||||||
|
}
|
||||||
|
console.warn(`Translation missing for key "${key}" in locale "${loc}"`);
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTranslation = (locale: string, key: string): any => {
|
||||||
|
const localeData: any = LOCALES[locale];
|
||||||
|
if (!localeData) return undefined;
|
||||||
|
const keys = key.split('.');
|
||||||
|
let result: any = localeData;
|
||||||
|
for (const k of keys) {
|
||||||
|
if (result && typeof result === 'object' && k in result) {
|
||||||
|
result = result[k];
|
||||||
|
} else return undefined;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const interpolate = (str: string, params: Record<string, any>): string => {
|
||||||
|
return str.replace(/\{(\w+)\}/g, (_match, key) => params[key] !== undefined ? String(params[key]) : _match);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasTranslation = (key: string): boolean => {
|
||||||
|
return getTranslation(currentLocale, key) !== undefined || getTranslation(fallbackLocale, key) !== undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTranslations = (locale: string): Record<string, any> => LOCALES[locale] || {};
|
||||||
|
|
||||||
|
export const addTranslations = (locale: string, translations: Record<string, any>): void => {
|
||||||
|
if (!LOCALES[locale]) LOCALES[locale] = {};
|
||||||
|
deepMergeI18n(LOCALES[locale], translations);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadRemote = async (url: string, locale: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
addTranslations(locale, data);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`MeEditor: failed to load locale "${locale}" from ${url}`, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deepMergeI18n = (target: Record<string, any>, source: Record<string, any>): Record<string, any> => {
|
||||||
|
for (const key in source) {
|
||||||
|
if (source[key] instanceof Object && key in target && target[key] instanceof Object && !Array.isArray(source[key])) {
|
||||||
|
deepMergeI18n(target[key], source[key]);
|
||||||
|
} else {
|
||||||
|
target[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSupportedLocales = (): string[] => Object.keys(LOCALES);
|
||||||
|
export const isLocaleSupported = (locale: string): boolean => locale in LOCALES;
|
||||||
|
|
||||||
|
export const getLocaleName = (locale: string): string => {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
'zh-CN': '简体中文', 'zh-TW': '繁體中文', 'en-US': 'English (US)', 'en-GB': 'English (UK)',
|
||||||
|
ja: '日本語', ko: '한국어', fr: 'Français', de: 'Deutsch', es: 'Español', pt: 'Português',
|
||||||
|
ru: 'Русский', ar: 'العربية', hi: 'हिन्दी', th: 'ไทย',
|
||||||
|
vi: 'Tiếng Việt', id: 'Bahasa Indonesia', tr: 'Türkçe',
|
||||||
|
it: 'Italiano', nl: 'Nederlands', pl: 'Polski',
|
||||||
|
};
|
||||||
|
return names[locale] || locale;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLocaleDirection = (locale: string): 'ltr' | 'rtl' => {
|
||||||
|
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug'];
|
||||||
|
return rtlLocales.includes(locale) ? 'rtl' : 'ltr';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatNumber = (number: number, options: Intl.NumberFormatOptions = {}): string => {
|
||||||
|
try { return new Intl.NumberFormat(currentLocale, options).format(number); }
|
||||||
|
catch (_) { return String(number); }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCurrency = (amount: number, currency: string = 'USD', options: Intl.NumberFormatOptions = {}): string => {
|
||||||
|
try { return new Intl.NumberFormat(currentLocale, { style: 'currency', currency, ...options }).format(amount); }
|
||||||
|
catch (_) { return String(amount); }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDate = (date: Date | string, options: Intl.DateTimeFormatOptions = {}): string => {
|
||||||
|
try {
|
||||||
|
const d = date instanceof Date ? date : new Date(date);
|
||||||
|
return new Intl.DateTimeFormat(currentLocale, options).format(d);
|
||||||
|
} catch (_) { return String(date); }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addLocaleListener = (fn: (locale: string) => void): () => void => {
|
||||||
|
localeListeners.add(fn);
|
||||||
|
return () => { localeListeners.delete(fn); };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeLocaleListener = (fn: (locale: string) => void): void => { localeListeners.delete(fn); };
|
||||||
|
export const clearLocaleListeners = (): void => { localeListeners.clear(); };
|
||||||
|
|
||||||
|
const notifyLocaleListeners = (locale: string): void => {
|
||||||
|
localeListeners.forEach((fn) => {
|
||||||
|
try { fn(locale); } catch (e) { console.error('Locale listener error:', e); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveLocale = (locale: string): void => {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
try { localStorage.setItem('metona-editor-locale', locale); } catch (_) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadLocale = (): string => {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
try { return localStorage.getItem('metona-editor-locale') || getDefaultLocale(); } catch (_) {}
|
||||||
|
}
|
||||||
|
return getDefaultLocale();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDefaultLocale = (): string => {
|
||||||
|
if (typeof navigator !== 'undefined') {
|
||||||
|
const browserLocale: string = (navigator as any).language || (navigator as any).userLanguage;
|
||||||
|
if (browserLocale && isLocaleSupported(browserLocale)) return browserLocale;
|
||||||
|
const short = browserLocale?.split('-')[0];
|
||||||
|
if (short && isLocaleSupported(short)) return short;
|
||||||
|
}
|
||||||
|
return fallbackLocale;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFallbackLocale = (): string => fallbackLocale;
|
||||||
|
export const setFallbackLocale = (locale: string): void => { fallbackLocale = locale; };
|
||||||
|
|
||||||
|
export const initI18n = (): void => {
|
||||||
|
const saved = loadLocale();
|
||||||
|
setCurrentLocale(saved);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const switchLocale = (locale: string): void => { setCurrentLocale(locale); };
|
||||||
|
|
||||||
|
export interface InstanceI18n {
|
||||||
|
set: (locale: string) => string;
|
||||||
|
get: () => string;
|
||||||
|
getDirection: () => 'ltr' | 'rtl';
|
||||||
|
t: (key: string, params?: Record<string, any>) => string;
|
||||||
|
formatNumber: (n: number, opts?: Intl.NumberFormatOptions) => string;
|
||||||
|
formatDate: (d: Date | string, opts?: Intl.DateTimeFormatOptions) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInstanceI18n = (editor: any): InstanceI18n => {
|
||||||
|
let instanceLocale = editor.config?.locale || currentLocale || 'zh-CN';
|
||||||
|
|
||||||
|
const instanceT = (key: string, params: Record<string, any> = {}): string => t(key, params, instanceLocale);
|
||||||
|
|
||||||
|
const set = (locale: string): string => {
|
||||||
|
if (!locale || instanceLocale === locale) return instanceLocale;
|
||||||
|
instanceLocale = locale;
|
||||||
|
if (editor.el) {
|
||||||
|
editor.el.setAttribute('lang', locale);
|
||||||
|
editor.el.setAttribute('dir', getLocaleDirection(locale));
|
||||||
|
}
|
||||||
|
if (editor.textarea) {
|
||||||
|
editor.textarea.placeholder = instanceT('placeholder') || '';
|
||||||
|
editor.textarea.setAttribute('aria-label', instanceT('edit') || '');
|
||||||
|
editor.textarea.setAttribute('dir', getLocaleDirection(locale));
|
||||||
|
}
|
||||||
|
if (typeof editor._emit === 'function') {
|
||||||
|
editor._emit('localeChange', { locale, direction: getLocaleDirection(locale) });
|
||||||
|
}
|
||||||
|
if (editor.toolbarEl) {
|
||||||
|
editor.toolbarEl.querySelectorAll('.me-btn').forEach((btn: HTMLElement) => {
|
||||||
|
const action = btn.dataset.action || btn.dataset.mode;
|
||||||
|
if (action) {
|
||||||
|
const label = instanceT(action);
|
||||||
|
if (label && label !== action) {
|
||||||
|
btn.title = label;
|
||||||
|
btn.setAttribute('aria-label', label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return instanceLocale;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
set, get: () => instanceLocale, getDirection: () => getLocaleDirection(instanceLocale), t: instanceT,
|
||||||
|
formatNumber: (n, opts) => { try { return new Intl.NumberFormat(instanceLocale, opts).format(n); } catch (_) { return String(n); } },
|
||||||
|
formatDate: (d, opts) => { try { return new Intl.DateTimeFormat(instanceLocale, opts).format(d instanceof Date ? d : new Date(d)); } catch (_) { return String(d); } },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createI18nManager = () => ({
|
||||||
|
t, getCurrentLocale, setCurrentLocale, switchLocale,
|
||||||
|
getFallbackLocale: () => fallbackLocale, setFallbackLocale: (locale: string) => { fallbackLocale = locale; },
|
||||||
|
hasTranslation, getTranslations, addTranslations, loadRemote,
|
||||||
|
getSupportedLocales, isLocaleSupported, getLocaleName, getLocaleDirection,
|
||||||
|
formatNumber, formatCurrency, formatDate, addLocaleListener, removeLocaleListener, clearLocaleListeners,
|
||||||
|
initI18n, saveLocale, loadLocale, getDefaultLocale, createInstanceI18n,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const presetLocales = {
|
||||||
|
'zh-CN': { name: '简体中文', nativeName: '简体中文', direction: 'ltr', translations: LOCALES['zh-CN'] },
|
||||||
|
'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const i18nUtils = createI18nManager();
|
||||||
|
export default i18nUtils;
|
||||||
@@ -1,56 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Icons — 工具栏图标SVG定义
|
* MetonaEditor Icons — SVG toolbar icons
|
||||||
* @module icons
|
* @module icons
|
||||||
* @version 0.1.14
|
* @version 0.2.0
|
||||||
* @description 工具栏格式化按钮 SVG 图标
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const ICONS = {
|
export const ICONS: Record<string, string> = {
|
||||||
bold: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>`,
|
bold: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>`,
|
||||||
|
|
||||||
italic: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>`,
|
italic: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>`,
|
||||||
|
|
||||||
underline: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>`,
|
underline: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>`,
|
||||||
|
|
||||||
strikethrough: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.3 19c.6-1.2 1-2.5 1-3.8 0-4.3-3.5-7.2-7.3-7.2-3.8 0-7.3 2.9-7.3 7.2 0 1.3.4 2.6 1 3.8"/><line x1="4" y1="12" x2="20" y2="12"/></svg>`,
|
strikethrough: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.3 19c.6-1.2 1-2.5 1-3.8 0-4.3-3.5-7.2-7.3-7.2-3.8 0-7.3 2.9-7.3 7.2 0 1.3.4 2.6 1 3.8"/><line x1="4" y1="12" x2="20" y2="12"/></svg>`,
|
||||||
|
|
||||||
h1: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17 12l3-2v8"/></svg>`,
|
h1: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17 12l3-2v8"/></svg>`,
|
||||||
|
|
||||||
h2: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M19 12v4"/><path d="M22 12h-4"/></svg>`,
|
h2: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M19 12v4"/><path d="M22 12h-4"/></svg>`,
|
||||||
|
|
||||||
h3: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-2 1-3 3-3s2 1 2 3"/></svg>`,
|
h3: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-2 1-3 3-3s2 1 2 3"/></svg>`,
|
||||||
|
|
||||||
quote: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>`,
|
quote: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>`,
|
||||||
|
|
||||||
code: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
|
code: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
|
||||||
|
|
||||||
link: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
|
link: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
|
||||||
|
|
||||||
image: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
|
image: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
|
||||||
|
|
||||||
table: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>`,
|
table: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>`,
|
||||||
|
|
||||||
ul: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
|
ul: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
|
||||||
|
|
||||||
ol: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 14h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`,
|
ol: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 14h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`,
|
||||||
|
|
||||||
indent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 8 7 12 3 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
|
indent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 8 7 12 3 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
|
||||||
|
|
||||||
outdent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 8 3 12 7 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
|
outdent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 8 3 12 7 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
|
||||||
|
|
||||||
hr: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/></svg>`,
|
hr: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/></svg>`,
|
||||||
|
|
||||||
undo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`,
|
undo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`,
|
||||||
|
|
||||||
redo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`,
|
redo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`,
|
||||||
|
|
||||||
preview: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
|
preview: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
|
||||||
|
|
||||||
split: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>`,
|
split: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>`,
|
||||||
|
|
||||||
edit: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
|
edit: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
|
||||||
|
|
||||||
fullscreen: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>`,
|
fullscreen: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>`,
|
||||||
|
|
||||||
theme: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`,
|
theme: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`,
|
||||||
};
|
};
|
||||||
-280
@@ -1,280 +0,0 @@
|
|||||||
/**
|
|
||||||
* MetonaEditor - 轻量级 Markdown Editor 库
|
|
||||||
* @module metona-editor
|
|
||||||
* @version 0.1.14
|
|
||||||
* @author thzxx
|
|
||||||
* @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。
|
|
||||||
* @license MIT
|
|
||||||
*
|
|
||||||
* 用法:
|
|
||||||
* // 浏览器
|
|
||||||
* <script src="dist/metona-editor.js"></script>
|
|
||||||
* const editor = MeEditor.create('#editor', { mode: 'split' });
|
|
||||||
*
|
|
||||||
* // ES Module
|
|
||||||
* import MeEditor from 'metona-editor';
|
|
||||||
* const editor = MeEditor.create(container, options);
|
|
||||||
*
|
|
||||||
* // 直接使用类
|
|
||||||
* import { MarkdownEditor } from 'metona-editor';
|
|
||||||
* const editor = new MarkdownEditor(container, options);
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MarkdownEditor } from './core.js';
|
|
||||||
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from './parser.js';
|
|
||||||
import { themeUtils, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme } from './themes.js';
|
|
||||||
import { i18nUtils, createInstanceI18n, loadRemote } from './i18n.js';
|
|
||||||
import { pluginUtils, presetPlugins, topologicalSort, validateConfig } from './plugins.js';
|
|
||||||
import { animationUtils } from './animations.js';
|
|
||||||
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants.js';
|
|
||||||
|
|
||||||
// 版本信息
|
|
||||||
const VERSION = '0.1.14';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局默认插件队列
|
|
||||||
* 通过 beforeCreate 钩子自动应用到所有后续创建的编辑器实例
|
|
||||||
*/
|
|
||||||
const globalPlugins = [];
|
|
||||||
|
|
||||||
// 注册全局钩子:每个新实例创建时自动安装全局默认插件
|
|
||||||
// 注意:core.js 构造函数中 beforeCreate 在 config.plugins 安装之前触发,
|
|
||||||
// 且 this._plugins 已初始化为 [],因此此处调用 editor.use() 安全。
|
|
||||||
MarkdownEditor.on('beforeCreate', (editor) => {
|
|
||||||
if (!editor || editor._destroyed) return;
|
|
||||||
globalPlugins.forEach((p) => {
|
|
||||||
try { editor.use(p); } catch (e) { console.error('MeEditor global plugin install error:', e); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建编辑器实例(工厂函数,推荐用法)
|
|
||||||
* @param {string|HTMLElement} container - 容器选择器或 DOM 元素
|
|
||||||
* @param {Object} [options] - 配置项(见 DEFAULTS)
|
|
||||||
* @returns {MarkdownEditor} 编辑器实例
|
|
||||||
*/
|
|
||||||
function create(container, options = {}) {
|
|
||||||
return new MarkdownEditor(container, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册全局默认插件(作用于所有后续创建的编辑器实例)
|
|
||||||
* @param {string|Object} plugin - 预设插件名 或 插件对象
|
|
||||||
* @param {Object} [options] - 插件配置(覆盖预设默认值)
|
|
||||||
* @returns {typeof api} 返回 api 自身以支持链式调用
|
|
||||||
*/
|
|
||||||
function use(plugin, options = {}) {
|
|
||||||
let p = plugin;
|
|
||||||
if (typeof plugin === 'string') {
|
|
||||||
p = presetPlugins[plugin];
|
|
||||||
if (!p) {
|
|
||||||
console.warn(`MeEditor: preset plugin "${plugin}" not found`);
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!p || typeof p !== 'object') {
|
|
||||||
console.warn('MeEditor: invalid plugin');
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
const merged = (options && typeof options === 'object' && Object.keys(options).length > 0)
|
|
||||||
? { ...p, ...options }
|
|
||||||
: p;
|
|
||||||
globalPlugins.push(merged);
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册全局事件钩子(转发到 MarkdownEditor 静态钩子,所有实例共享)
|
|
||||||
* 可用钩子:beforeCreate / afterCreate / beforeRender / afterRender / beforeDestroy / afterDestroy
|
|
||||||
* @param {string} name - 钩子名
|
|
||||||
* @param {Function} fn - 回调 (editor) => void
|
|
||||||
* @returns {Function} 取消注册函数
|
|
||||||
*/
|
|
||||||
function on(name, fn) {
|
|
||||||
return MarkdownEditor.on(name, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注销全局事件钩子
|
|
||||||
*/
|
|
||||||
function off(name, fn) {
|
|
||||||
MarkdownEditor.off(name, fn);
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换全局主题(转发到 themeUtils)
|
|
||||||
*/
|
|
||||||
function setTheme(theme, options) {
|
|
||||||
if (themeUtils && typeof themeUtils.switchTheme === 'function') {
|
|
||||||
themeUtils.switchTheme(theme, options);
|
|
||||||
}
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换全局语言(转发到 i18nUtils)
|
|
||||||
*/
|
|
||||||
function setLocale(locale) {
|
|
||||||
if (i18nUtils && typeof i18nUtils.setCurrentLocale === 'function') {
|
|
||||||
i18nUtils.setCurrentLocale(locale);
|
|
||||||
}
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 销毁所有全局资源(主题监听、i18n 监听、动画、全局插件队列)
|
|
||||||
* 注意:此方法不销毁已创建的编辑器实例,请单独调用每个实例的 destroy()
|
|
||||||
*/
|
|
||||||
function destroy() {
|
|
||||||
if (themeUtils) {
|
|
||||||
if (typeof themeUtils.clearThemeListeners === 'function') {
|
|
||||||
try { themeUtils.clearThemeListeners(); } catch (_) {}
|
|
||||||
}
|
|
||||||
if (typeof themeUtils.unwatchSystemTheme === 'function') {
|
|
||||||
try { themeUtils.unwatchSystemTheme(); } catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (i18nUtils && typeof i18nUtils.clearLocaleListeners === 'function') {
|
|
||||||
try { i18nUtils.clearLocaleListeners(); } catch (_) {}
|
|
||||||
}
|
|
||||||
if (animationUtils && typeof animationUtils.cancelAll === 'function') {
|
|
||||||
try { animationUtils.cancelAll(); } catch (_) {}
|
|
||||||
}
|
|
||||||
globalPlugins.length = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取库运行状态
|
|
||||||
*/
|
|
||||||
function getStatus() {
|
|
||||||
return {
|
|
||||||
version: VERSION,
|
|
||||||
theme: (themeUtils && typeof themeUtils.getCurrentTheme === 'function') ? themeUtils.getCurrentTheme() : 'auto',
|
|
||||||
locale: (i18nUtils && typeof i18nUtils.getCurrentLocale === 'function') ? i18nUtils.getCurrentLocale() : 'zh-CN',
|
|
||||||
globalPlugins: globalPlugins.map((p) => p.name || 'custom'),
|
|
||||||
presetPlugins: Object.keys(presetPlugins || {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化主题与国际化(浏览器环境)
|
|
||||||
if (typeof themeUtils === 'object' && typeof themeUtils.initTheme === 'function') {
|
|
||||||
try { themeUtils.initTheme(); } catch (_) {}
|
|
||||||
}
|
|
||||||
if (typeof i18nUtils === 'object' && typeof i18nUtils.initI18n === 'function') {
|
|
||||||
try { i18nUtils.initI18n(); } catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 顶层 API 对象(默认导出)
|
|
||||||
*/
|
|
||||||
const api = {
|
|
||||||
// 版本
|
|
||||||
VERSION,
|
|
||||||
version: VERSION,
|
|
||||||
|
|
||||||
// 核心类
|
|
||||||
MarkdownEditor,
|
|
||||||
Editor: MarkdownEditor,
|
|
||||||
|
|
||||||
// 工厂函数(推荐入口)
|
|
||||||
create,
|
|
||||||
|
|
||||||
// 顶层 API
|
|
||||||
use,
|
|
||||||
on,
|
|
||||||
off,
|
|
||||||
setTheme,
|
|
||||||
setLocale,
|
|
||||||
destroy,
|
|
||||||
getStatus,
|
|
||||||
|
|
||||||
// 内置解析器(可单独使用或替换)
|
|
||||||
parseMarkdown,
|
|
||||||
parseTokens,
|
|
||||||
renderTokens,
|
|
||||||
safeUrl,
|
|
||||||
slugify,
|
|
||||||
clearRenderCache,
|
|
||||||
registerBlockHandler,
|
|
||||||
|
|
||||||
// 工具集
|
|
||||||
themes: themeUtils,
|
|
||||||
i18n: i18nUtils,
|
|
||||||
animations: animationUtils,
|
|
||||||
plugins: pluginUtils,
|
|
||||||
presetPlugins,
|
|
||||||
|
|
||||||
// 插件工具(v0.1.6)
|
|
||||||
topologicalSort,
|
|
||||||
validateConfig,
|
|
||||||
|
|
||||||
// i18n 工具(v0.1.6)
|
|
||||||
createInstanceI18n,
|
|
||||||
loadRemote,
|
|
||||||
|
|
||||||
// 主题工具(v0.1.5 新增)
|
|
||||||
exportCSSVars,
|
|
||||||
getCSSVariable,
|
|
||||||
followExternalTheme,
|
|
||||||
adoptFromParent,
|
|
||||||
createInstanceTheme,
|
|
||||||
|
|
||||||
// 常量
|
|
||||||
DEFAULTS,
|
|
||||||
ICONS,
|
|
||||||
THEMES,
|
|
||||||
EDIT_MODES,
|
|
||||||
DEFAULT_TOOLBAR,
|
|
||||||
TOOLBAR_ACTIONS,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 浏览器环境全局注册(UMD/CDN 场景:window.MeEditor)
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.MeEditor = api;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default api;
|
|
||||||
export {
|
|
||||||
api,
|
|
||||||
api as meEditor,
|
|
||||||
api as MeEditor,
|
|
||||||
MarkdownEditor,
|
|
||||||
MarkdownEditor as Editor,
|
|
||||||
create,
|
|
||||||
use,
|
|
||||||
on,
|
|
||||||
off,
|
|
||||||
setTheme,
|
|
||||||
setLocale,
|
|
||||||
destroy,
|
|
||||||
getStatus,
|
|
||||||
parseMarkdown,
|
|
||||||
parseTokens,
|
|
||||||
renderTokens,
|
|
||||||
safeUrl,
|
|
||||||
slugify,
|
|
||||||
clearRenderCache,
|
|
||||||
registerBlockHandler,
|
|
||||||
themeUtils,
|
|
||||||
i18nUtils,
|
|
||||||
pluginUtils,
|
|
||||||
presetPlugins,
|
|
||||||
animationUtils,
|
|
||||||
topologicalSort,
|
|
||||||
validateConfig,
|
|
||||||
createInstanceI18n,
|
|
||||||
loadRemote,
|
|
||||||
exportCSSVars,
|
|
||||||
getCSSVariable,
|
|
||||||
followExternalTheme,
|
|
||||||
adoptFromParent,
|
|
||||||
createInstanceTheme,
|
|
||||||
DEFAULTS,
|
|
||||||
ICONS,
|
|
||||||
THEMES,
|
|
||||||
EDIT_MODES,
|
|
||||||
DEFAULT_TOOLBAR,
|
|
||||||
TOOLBAR_ACTIONS,
|
|
||||||
VERSION,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor — Type-safe, lightweight Markdown Editor
|
||||||
|
* @module metona-editor
|
||||||
|
* @version 0.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MarkdownEditor } from './core';
|
||||||
|
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from './parser';
|
||||||
|
import { themeUtils, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme } from './themes';
|
||||||
|
import { i18nUtils, createInstanceI18n, loadRemote } from './i18n';
|
||||||
|
import { pluginUtils, presetPlugins, topologicalSort, validateConfig } from './plugins';
|
||||||
|
import { animationUtils } from './animations';
|
||||||
|
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants';
|
||||||
|
import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants';
|
||||||
|
|
||||||
|
const VERSION = '0.2.0';
|
||||||
|
|
||||||
|
const globalPlugins: any[] = [];
|
||||||
|
|
||||||
|
MarkdownEditor.on('beforeCreate', (editor) => {
|
||||||
|
if (!editor || editor.isDestroyed()) return;
|
||||||
|
globalPlugins.forEach((p) => { try { editor.use(p); } catch (e) { console.error('MeEditor global plugin install error:', e); } });
|
||||||
|
});
|
||||||
|
|
||||||
|
function create(container: string | HTMLElement, options: EditorOptions = {}): MarkdownEditor {
|
||||||
|
return new MarkdownEditor(container, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function use(plugin: string | any, options: any = {}): typeof api {
|
||||||
|
let p = plugin;
|
||||||
|
if (typeof plugin === 'string') { p = presetPlugins[plugin]; if (!p) { console.warn(`MeEditor: preset plugin "${plugin}" not found`); return api; } }
|
||||||
|
if (!p || typeof p !== 'object') { console.warn('MeEditor: invalid plugin'); return api; }
|
||||||
|
const merged = (options && typeof options === 'object' && Object.keys(options).length > 0) ? { ...p, ...options } : p;
|
||||||
|
globalPlugins.push(merged); return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function on(name: string, fn: (editor: MarkdownEditor) => void): () => void { return MarkdownEditor.on(name, fn); }
|
||||||
|
function off(name: string, fn: (editor: MarkdownEditor) => void): typeof api { MarkdownEditor.off(name, fn); return api; }
|
||||||
|
|
||||||
|
function setTheme(theme: ThemeName, _options?: any): typeof api { if (themeUtils && typeof themeUtils.switchTheme === 'function') themeUtils.switchTheme(theme); return api; }
|
||||||
|
function setLocale(locale: string): typeof api { if (i18nUtils && typeof i18nUtils.setCurrentLocale === 'function') i18nUtils.setCurrentLocale(locale); return api; }
|
||||||
|
|
||||||
|
function destroy(): void {
|
||||||
|
if (themeUtils) { try { themeUtils.clearThemeListeners(); } catch (_) {} try { themeUtils.unwatchSystemTheme(); } catch (_) {} }
|
||||||
|
if (i18nUtils) { try { i18nUtils.clearLocaleListeners(); } catch (_) {} }
|
||||||
|
if (animationUtils) { try { animationUtils.cancelAll(); } catch (_) {} }
|
||||||
|
globalPlugins.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus() {
|
||||||
|
return { version: VERSION, theme: (themeUtils && typeof themeUtils.getCurrentTheme === 'function') ? themeUtils.getCurrentTheme() : 'auto', locale: (i18nUtils && typeof i18nUtils.getCurrentLocale === 'function') ? i18nUtils.getCurrentLocale() : 'zh-CN', globalPlugins: globalPlugins.map((p) => p.name || 'custom'), presetPlugins: Object.keys(presetPlugins || {}), };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof themeUtils === 'object' && typeof themeUtils.initTheme === 'function') themeUtils.initTheme();
|
||||||
|
if (typeof i18nUtils === 'object' && typeof i18nUtils.initI18n === 'function') i18nUtils.initI18n();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
VERSION, version: VERSION,
|
||||||
|
MarkdownEditor, Editor: MarkdownEditor,
|
||||||
|
create, use, on, off, setTheme, setLocale, destroy, getStatus,
|
||||||
|
parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler,
|
||||||
|
themes: themeUtils, i18n: i18nUtils, animations: animationUtils, plugins: pluginUtils, presetPlugins,
|
||||||
|
topologicalSort, validateConfig, createInstanceI18n, loadRemote,
|
||||||
|
exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme,
|
||||||
|
DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') { (window as any).MeEditor = api; }
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
export { api, api as meEditor, api as MeEditor, MarkdownEditor, MarkdownEditor as Editor, create, use, on, off, setTheme, setLocale, destroy, getStatus, parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler, themeUtils, i18nUtils, pluginUtils, presetPlugins, animationUtils, topologicalSort, validateConfig, createInstanceI18n, loadRemote, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme, DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS, VERSION, };
|
||||||
-168
@@ -1,168 +0,0 @@
|
|||||||
/**
|
|
||||||
* MetonaEditor Locales — 国际化翻译数据
|
|
||||||
* @module locales
|
|
||||||
* @version 0.1.14
|
|
||||||
* @description 内置 zh-CN / en-US 完整翻译
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const LOCALES = {
|
|
||||||
'zh-CN': {
|
|
||||||
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: '请输入链接地址',
|
|
||||||
imagePlaceholder: '请输入图片地址',
|
|
||||||
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: '渲染失败',
|
|
||||||
},
|
|
||||||
|
|
||||||
'en-US': {
|
|
||||||
bold: 'Bold',
|
|
||||||
italic: 'Italic',
|
|
||||||
underline: 'Underline',
|
|
||||||
strikethrough: 'Strikethrough',
|
|
||||||
h1: 'Heading 1',
|
|
||||||
h2: 'Heading 2',
|
|
||||||
h3: 'Heading 3',
|
|
||||||
quote: 'Quote',
|
|
||||||
code: 'Code',
|
|
||||||
link: 'Link',
|
|
||||||
image: 'Image',
|
|
||||||
table: 'Table',
|
|
||||||
ul: 'Bullet List',
|
|
||||||
ol: 'Numbered List',
|
|
||||||
indent: 'Indent',
|
|
||||||
outdent: 'Outdent',
|
|
||||||
hr: 'Horizontal Rule',
|
|
||||||
undo: 'Undo',
|
|
||||||
redo: 'Redo',
|
|
||||||
edit: 'Edit',
|
|
||||||
split: 'Split',
|
|
||||||
preview: 'Preview',
|
|
||||||
fullscreen: 'Fullscreen',
|
|
||||||
fullscreenExit: 'Exit Fullscreen',
|
|
||||||
theme: 'Theme',
|
|
||||||
light: 'Light',
|
|
||||||
dark: 'Dark',
|
|
||||||
auto: 'Auto',
|
|
||||||
warm: 'Warm',
|
|
||||||
wordCount: 'Word Count',
|
|
||||||
characters: 'Characters',
|
|
||||||
words: 'Words',
|
|
||||||
lines: 'Lines',
|
|
||||||
readingTime: 'Reading',
|
|
||||||
minutes: 'min',
|
|
||||||
placeholder: 'Start typing Markdown...',
|
|
||||||
empty: 'No content',
|
|
||||||
copied: 'Copied',
|
|
||||||
copyContent: 'Copy Content',
|
|
||||||
copyHTML: 'Copy HTML',
|
|
||||||
copySuccess: 'Copied successfully',
|
|
||||||
copyFailed: 'Copy failed',
|
|
||||||
clearContent: 'Clear Content',
|
|
||||||
clearConfirm: 'Clear all content?',
|
|
||||||
linkPlaceholder: 'Enter link URL',
|
|
||||||
imagePlaceholder: 'Enter image URL',
|
|
||||||
altPlaceholder: 'Enter alt text',
|
|
||||||
tableRows: 'Rows',
|
|
||||||
tableCols: 'Columns',
|
|
||||||
confirm: 'Confirm',
|
|
||||||
cancel: 'Cancel',
|
|
||||||
exportMarkdown: 'Export Markdown',
|
|
||||||
exportHTML: 'Export HTML',
|
|
||||||
search: 'Search',
|
|
||||||
replace: 'Replace',
|
|
||||||
replaceAll: 'Replace All',
|
|
||||||
searchPlaceholder: 'Find',
|
|
||||||
replacePlaceholder: 'Replace with',
|
|
||||||
findNext: 'Find Next',
|
|
||||||
findPrev: 'Find Previous',
|
|
||||||
matchCase: 'Match Case',
|
|
||||||
wholeWord: 'Whole Word',
|
|
||||||
close: 'Close',
|
|
||||||
open: 'Open',
|
|
||||||
save: 'Save',
|
|
||||||
saved: 'Saved',
|
|
||||||
saving: 'Saving...',
|
|
||||||
delete: 'Delete',
|
|
||||||
confirmDelete: 'Are you sure you want to delete?',
|
|
||||||
unsavedChanges: 'You have unsaved changes',
|
|
||||||
error: 'Error',
|
|
||||||
success: 'Success',
|
|
||||||
warning: 'Warning',
|
|
||||||
info: 'Info',
|
|
||||||
loading: 'Loading...',
|
|
||||||
retry: 'Retry',
|
|
||||||
renderError: 'Render failed',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Locales — translation data
|
||||||
|
* @module locales
|
||||||
|
* @version 0.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LOCALES: Record<string, Record<string, string>> = {
|
||||||
|
'zh-CN': {
|
||||||
|
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: '请输入链接地址', imagePlaceholder: '请输入图片地址',
|
||||||
|
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: '大纲',
|
||||||
|
},
|
||||||
|
'en-US': {
|
||||||
|
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
|
||||||
|
h1: 'Heading 1', h2: 'Heading 2', h3: 'Heading 3', quote: 'Quote', code: 'Code',
|
||||||
|
link: 'Link', image: 'Image', table: 'Table', ul: 'Bullet List', ol: 'Numbered List',
|
||||||
|
indent: 'Indent', outdent: 'Outdent', hr: 'Horizontal Rule', undo: 'Undo', redo: 'Redo',
|
||||||
|
edit: 'Edit', split: 'Split', preview: 'Preview', fullscreen: 'Fullscreen',
|
||||||
|
fullscreenExit: 'Exit Fullscreen', theme: 'Theme', light: 'Light', dark: 'Dark',
|
||||||
|
auto: 'Auto', warm: 'Warm', wordCount: 'Word Count', characters: 'Characters',
|
||||||
|
words: 'Words', lines: 'Lines', readingTime: 'Reading', minutes: 'min',
|
||||||
|
placeholder: 'Start typing Markdown...', empty: 'No content', copied: 'Copied',
|
||||||
|
copyContent: 'Copy Content', copyHTML: 'Copy HTML', copySuccess: 'Copied successfully',
|
||||||
|
copyFailed: 'Copy failed', clearContent: 'Clear Content', clearConfirm: 'Clear all content?',
|
||||||
|
linkPlaceholder: 'Enter link URL', imagePlaceholder: 'Enter image URL',
|
||||||
|
altPlaceholder: 'Enter alt text', tableRows: 'Rows', tableCols: 'Columns',
|
||||||
|
confirm: 'Confirm', cancel: 'Cancel', exportMarkdown: 'Export Markdown',
|
||||||
|
exportHTML: 'Export HTML', search: 'Search', replace: 'Replace', replaceAll: 'Replace All',
|
||||||
|
searchPlaceholder: 'Find', replacePlaceholder: 'Replace with',
|
||||||
|
findNext: 'Find Next', findPrev: 'Find Previous', matchCase: 'Match Case',
|
||||||
|
wholeWord: 'Whole Word', close: 'Close', open: 'Open', save: 'Save',
|
||||||
|
saved: 'Saved', saving: 'Saving...', delete: 'Delete', confirmDelete: 'Are you sure you want to delete?',
|
||||||
|
unsavedChanges: 'You have unsaved changes', error: 'Error', success: 'Success',
|
||||||
|
warning: 'Warning', info: 'Info', loading: 'Loading...', retry: 'Retry',
|
||||||
|
renderError: 'Render failed',
|
||||||
|
outline: 'Outline',
|
||||||
|
},
|
||||||
|
};
|
||||||
-1096
File diff suppressed because it is too large
Load Diff
+673
@@ -0,0 +1,673 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Parser — lightweight Markdown parser (TypeScript)
|
||||||
|
* @module parser
|
||||||
|
* @version 0.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeHTML } from './utils';
|
||||||
|
import type { RenderEnv } from './constants';
|
||||||
|
|
||||||
|
// ============ Types ============
|
||||||
|
|
||||||
|
export interface Token {
|
||||||
|
type: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseResult {
|
||||||
|
tokens: Token[];
|
||||||
|
footnotes: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockHandler {
|
||||||
|
name: string;
|
||||||
|
priority: number;
|
||||||
|
test: (line: string, lines: string[], i: number) => any;
|
||||||
|
parse: (lines: string[], i: number, match: any, tokens: Token[], footnotes: Record<string, string>) => { token: Token | null; newIndex: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListItem {
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
checked: boolean;
|
||||||
|
task: boolean;
|
||||||
|
subTokens?: Token[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodePlaceholder {
|
||||||
|
content: string;
|
||||||
|
len: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Precompiled regex ============
|
||||||
|
|
||||||
|
const RE_EMPTY = /^\s*$/;
|
||||||
|
const RE_ATX = /^(#{1,6})\s+(.+?)(?:\s+#{1,6})?\s*$/;
|
||||||
|
const RE_SETEXT_H1 = /^={3,}\s*$/;
|
||||||
|
const RE_SETEXT_H2 = /^-{3,}\s*$/;
|
||||||
|
const RE_FENCE_START = /^(\s{0,3})(`{3,}|~{3,})\s*([\w+#.-]*)\s*$/;
|
||||||
|
const RE_HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
|
||||||
|
const RE_QUOTE = /^\s{0,3}>\s?/;
|
||||||
|
const RE_UL = /^(\s*)([-*+])\s/;
|
||||||
|
const RE_OL = /^(\s*)(\d+)\.\s/;
|
||||||
|
const RE_TASK = /^\[([ xX])\]\s+(.*)$/;
|
||||||
|
const RE_TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
|
||||||
|
const RE_INDENT_CODE = /^( {4}|\t)/;
|
||||||
|
const RE_DEF_LIST = /^:\s+/;
|
||||||
|
const RE_FOOTNOTE_DEF = /^\[\^([^\]]+)\]:\s*/;
|
||||||
|
|
||||||
|
// ============ Emoji map ============
|
||||||
|
|
||||||
|
const EMOJI_MAP: Record<string, string> = {
|
||||||
|
smile: '😊', grinning: '😀', joy: '😂', rofl: '🤣', wink: '😉', blush: '😊', innocent: '😇',
|
||||||
|
heart_eyes: '😍', kissing_heart: '😘', yum: '😋', stuck_out_tongue: '😛', sunglasses: '😎',
|
||||||
|
smirk: '😏', unamused: '😒', disappointed: '😞', worried: '😟', confused: '😕',
|
||||||
|
cry: '😢', sob: '😭', scream: '😱', angry: '😠', rage: '💢', triumph: '😤', sleepy: '😪',
|
||||||
|
dizzy_face: '😵', zipper_mouth: '🤐', nerd: '🤓', thinking: '🤔', rolling_eyes: '🙄',
|
||||||
|
expressionless: '😑', thumbsup: '👍', thumbsdown: '👎', clap: '👏', pray: '🙏', muscle: '💪',
|
||||||
|
ok: '👌', point_up: '👆', point_down: '👇', point_left: '👈', point_right: '👉',
|
||||||
|
raised_hands: '🙌', wave: '👋', punch: '👊', crossed_fingers: '🤞',
|
||||||
|
heart: '❤️', broken_heart: '💔', star: '⭐', star2: '🌟', fire: '🔥', rocket: '🚀',
|
||||||
|
check: '✅', cross: '❌', warning: '⚠️', info: 'ℹ️', question: '❓', exclamation: '❗',
|
||||||
|
bangbang: '‼️', grey_exclamation: '❕', bulb: '💡', book: '📖', memo: '📝', pin: '📌',
|
||||||
|
link: '🔗', lock: '🔒', unlock: '🔓', key: '🔑', hammer: '🔨', wrench: '🔧',
|
||||||
|
gear: '⚙️', tools: '🛠️', magnet: '🧲', zap: '⚡', cloud: '☁️',
|
||||||
|
sun: '☀️', moon: '🌙', rain: '🌧️', snow: '❄️', umbrella: '☂️', rainbow: '🌈',
|
||||||
|
tornado: '🌪️', fog: '🌫️', droplet: '💧',
|
||||||
|
coffee: '☕', pizza: '🍕', cake: '🎂', beer: '🍺', wine: '🍷',
|
||||||
|
hamburger: '🍔', fries: '🍟', apple: '🍎', banana: '🍌', grapes: '🍇',
|
||||||
|
watermelon: '🍉', strawberry: '🍓', peach: '🍑', cherry: '🍒', taco: '🌮',
|
||||||
|
tada: '🎉', gift: '🎁', crown: '👑', gem: '💎', ring: '💍',
|
||||||
|
confetti: '🎊', balloon: '🎈', ribbon: '🎀', medal: '🏅', trophy: '🏆',
|
||||||
|
eye: '👁️', ear: '👂', nose: '👃', tongue: '👅', lips: '👄',
|
||||||
|
brain: '🧠', speech: '💬', thought: '💭', anger: '💢', sweat: '💦',
|
||||||
|
one: '1️⃣', two: '2️⃣', three: '3️⃣', four: '4️⃣', five: '5️⃣',
|
||||||
|
six: '6️⃣', seven: '7️⃣', eight: '8️⃣', nine: '9️⃣', zero: '0️⃣',
|
||||||
|
arrow_up: '⬆️', arrow_down: '⬇️', arrow_left: '⬅️', arrow_right: '➡️',
|
||||||
|
arrow_upper_right: '↗️', arrow_lower_right: '↘️', arrow_lower_left: '↙️', arrow_upper_left: '↖️',
|
||||||
|
computer: '💻', phone: '📱', battery: '🔋', electric_plug: '🔌',
|
||||||
|
copyright: '©️', registered: '®️', tm: '™️', hundred: '💯', boom: '💥', dash: '💨',
|
||||||
|
hole: '🕳️', bomb: '💣', email: '📧', phone2: '📞', clock: '🕐', hourglass: '⏳',
|
||||||
|
calendar: '📅', money: '💰', shopping: '🛒', package: '📦', mailbox: '📫',
|
||||||
|
art: '🎨', music: '🎵', movie: '🎬', game: '🎮', sport: '⚽',
|
||||||
|
earth: '🌍', house: '🏠', car: '🚗', airplane: '✈️', ship: '🚢',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Render cache ============
|
||||||
|
|
||||||
|
const renderCache = new Map<string, string>();
|
||||||
|
const MAX_CACHE_SIZE = 300;
|
||||||
|
|
||||||
|
const cachedRenderInline = (text: string, env: RenderEnv): string => {
|
||||||
|
if (!text) return '';
|
||||||
|
if (!env.highlight && text.length < 600) {
|
||||||
|
const cached = renderCache.get(text);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const result = renderInline(text, env);
|
||||||
|
if (renderCache.size >= MAX_CACHE_SIZE) {
|
||||||
|
const firstKey = renderCache.keys().next().value!;
|
||||||
|
renderCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
renderCache.set(text, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return renderInline(text, env);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearRenderCache = (): void => { renderCache.clear(); };
|
||||||
|
|
||||||
|
// ============ Security utils ============
|
||||||
|
|
||||||
|
const safeUrl = (url: string | null | undefined): string => {
|
||||||
|
if (!url) return '';
|
||||||
|
const u = String(url).trim();
|
||||||
|
if (/^(javascript|vbscript|file):/i.test(u)) return '';
|
||||||
|
if (/^data:/i.test(u)) {
|
||||||
|
if (!/^data:image\//i.test(u)) return '';
|
||||||
|
if (u.length > 500000) return '';
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
};
|
||||||
|
|
||||||
|
const slugify = (text: string): string => {
|
||||||
|
let slug = String(text).normalize('NFKC').toLowerCase()
|
||||||
|
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf\s-]/g, '')
|
||||||
|
.trim().replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
return slug || 'heading';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Block handler registry ============
|
||||||
|
|
||||||
|
const blockHandlers: BlockHandler[] = [];
|
||||||
|
|
||||||
|
const registerBlockHandler = (handler: BlockHandler): void => {
|
||||||
|
blockHandlers.push(handler);
|
||||||
|
blockHandlers.sort((a, b) => a.priority - b.priority);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBlockStart = (line: string): boolean => {
|
||||||
|
if (RE_EMPTY.test(line) || RE_FENCE_START.test(line) || RE_ATX.test(line)) return true;
|
||||||
|
if (RE_HR.test(line) || RE_QUOTE.test(line) || RE_UL.test(line) || RE_OL.test(line)) return true;
|
||||||
|
if (RE_FOOTNOTE_DEF.test(line) || RE_DEF_LIST.test(line)) return true;
|
||||||
|
if (/^\$\$/.test(line)) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register all built-in block handlers
|
||||||
|
registerBlockHandler({ name: 'blank', priority: 0,
|
||||||
|
test: (line) => RE_EMPTY.test(line) ? true : null,
|
||||||
|
parse: (_lines, i) => ({ token: null, newIndex: i + 1 }),
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'fencedCode', priority: 1,
|
||||||
|
test: (line) => line.match(RE_FENCE_START),
|
||||||
|
parse: (lines, i, match) => {
|
||||||
|
const fenceChar: string = match[2][0];
|
||||||
|
const fenceLen: number = match[2].length;
|
||||||
|
const lang: string = match[3] || '';
|
||||||
|
const codeLines: string[] = [];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < lines.length) {
|
||||||
|
const cm = lines[j].match(/^(\s{0,3})(`{3,}|~{3,})\s*$/);
|
||||||
|
if (cm && cm[2][0] === fenceChar && cm[2].length >= fenceLen) break;
|
||||||
|
codeLines.push(lines[j]); j++;
|
||||||
|
}
|
||||||
|
if (j < lines.length) j++;
|
||||||
|
return { token: { type: 'code', lang, content: codeLines.join('\n') }, newIndex: j };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'indentedCode', priority: 2,
|
||||||
|
test: (line) => {
|
||||||
|
if (!RE_INDENT_CODE.test(line)) return null;
|
||||||
|
if (RE_ATX.test(line) || RE_QUOTE.test(line) || RE_UL.test(line) || RE_OL.test(line)) return null;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
parse: (lines, i) => {
|
||||||
|
const codeLines: string[] = [];
|
||||||
|
while (i < lines.length && RE_INDENT_CODE.test(lines[i])) {
|
||||||
|
codeLines.push(lines[i].replace(/^( {4}|\t)/, ''));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (codeLines.length) return { token: { type: 'code', lang: '', content: codeLines.join('\n'), indent: true }, newIndex: i };
|
||||||
|
return { token: null, newIndex: i };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'atxHeading', priority: 3,
|
||||||
|
test: (line) => line.match(RE_ATX),
|
||||||
|
parse: (_lines, i, match) => ({
|
||||||
|
token: { type: 'heading', level: match[1].length, text: match[2] }, newIndex: i + 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'setextH1', priority: 4,
|
||||||
|
test: (line, lines, i) => {
|
||||||
|
if (i + 1 >= lines.length) return null;
|
||||||
|
if (!RE_SETEXT_H1.test(lines[i + 1])) return null;
|
||||||
|
if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line) || RE_QUOTE.test(line) || /^\s*`{3,}/.test(line)) return null;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
parse: (lines, i) => ({ token: { type: 'heading', level: 1, text: lines[i] }, newIndex: i + 2 }),
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'setextH2', priority: 5,
|
||||||
|
test: (line, lines, i) => {
|
||||||
|
if (i + 1 >= lines.length) return null;
|
||||||
|
if (!RE_SETEXT_H2.test(lines[i + 1])) return null;
|
||||||
|
if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line) || RE_QUOTE.test(line) || /^\s*`{3,}/.test(line)) return null;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
parse: (lines, i) => ({ token: { type: 'heading', level: 2, text: lines[i] }, newIndex: i + 2 }),
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'hr', priority: 6,
|
||||||
|
test: (line) => RE_HR.test(line) ? true : null,
|
||||||
|
parse: (_lines, i) => ({ token: { type: 'hr' }, newIndex: i + 1 }),
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'blockquote', priority: 7,
|
||||||
|
test: (line) => RE_QUOTE.test(line) ? true : null,
|
||||||
|
parse: (lines, i) => {
|
||||||
|
const quoteLines: string[] = [];
|
||||||
|
while (i < lines.length && RE_QUOTE.test(lines[i])) {
|
||||||
|
quoteLines.push(lines[i].replace(RE_QUOTE, '')); i++;
|
||||||
|
}
|
||||||
|
return { token: { type: 'quote', content: quoteLines.join('\n') }, newIndex: i };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'table', priority: 8,
|
||||||
|
test: (line, lines, i) => {
|
||||||
|
if (!/\|/.test(line) || i + 1 >= lines.length) return null;
|
||||||
|
if (!isTableSeparator(lines[i + 1])) return null;
|
||||||
|
return { header: line, align: parseAlign(lines[i + 1]) };
|
||||||
|
},
|
||||||
|
parse: (lines, i, match) => {
|
||||||
|
let j = i + 2;
|
||||||
|
const rows: string[] = [];
|
||||||
|
while (j < lines.length && /\|/.test(lines[j]) && !RE_EMPTY.test(lines[j])) { rows.push(lines[j]); j++; }
|
||||||
|
return { token: { type: 'table', header: match.header, rows, align: match.align }, newIndex: j };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'ul', priority: 9,
|
||||||
|
test: (line) => line.match(RE_UL),
|
||||||
|
parse: (lines, i, match) => {
|
||||||
|
const { items, endIdx } = parseList(lines, i, match[2], match[1].length, false);
|
||||||
|
return { token: { type: 'ul', items }, newIndex: endIdx };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'ol', priority: 10,
|
||||||
|
test: (line) => line.match(RE_OL),
|
||||||
|
parse: (lines, i, match) => {
|
||||||
|
const start = parseInt(match[2], 10);
|
||||||
|
const { items, endIdx } = parseList(lines, i, match[2], match[1].length, true, start);
|
||||||
|
return { token: { type: 'ol', items, start }, newIndex: endIdx };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'footnoteDef', priority: 11,
|
||||||
|
test: (line) => line.match(RE_FOOTNOTE_DEF),
|
||||||
|
parse: (lines, i, match, _tokens, footnotes) => {
|
||||||
|
const fnId: string = match[1];
|
||||||
|
const fnContent = lines[i].slice(match[0].length);
|
||||||
|
const fnLines = [fnContent];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < lines.length && !RE_EMPTY.test(lines[j]) && !isBlockStart(lines[j])) { fnLines.push(lines[j]); j++; }
|
||||||
|
footnotes[fnId] = fnLines.join(' ');
|
||||||
|
return { token: null, newIndex: j };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'defList', priority: 12,
|
||||||
|
test: (line) => RE_DEF_LIST.test(line) ? true : null,
|
||||||
|
parse: (lines, i, _match, tokens) => {
|
||||||
|
const term = (tokens.length > 0 && tokens[tokens.length - 1].type === 'paragraph') ? tokens.pop()!.text : '';
|
||||||
|
const defs = [lines[i].replace(RE_DEF_LIST, '')];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < lines.length && RE_DEF_LIST.test(lines[j])) { defs.push(lines[j].replace(RE_DEF_LIST, '')); j++; }
|
||||||
|
return { token: { type: 'defList', term, defs }, newIndex: j };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerBlockHandler({ name: 'mathBlock', priority: 13,
|
||||||
|
test: (line) => /^\$\$/.test(line) ? true : null,
|
||||||
|
parse: (lines, i) => {
|
||||||
|
const single = lines[i].match(/^\$\$([\s\S]+?)\$\$\s*$/);
|
||||||
|
if (single) return { token: { type: 'mathBlock', content: single[1] }, newIndex: i + 1 };
|
||||||
|
const mathLines: string[] = [lines[i].replace(/^\$\$/, '')];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < lines.length && !/\$\$/.test(lines[j])) { mathLines.push(lines[j]); j++; }
|
||||||
|
if (j < lines.length) { mathLines.push(lines[j].replace(/\$\$\s*$/, '')); j++; }
|
||||||
|
return { token: { type: 'mathBlock', content: mathLines.join('\n') }, newIndex: j };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Block parsing ============
|
||||||
|
|
||||||
|
const parseTokens = (md: string | null | undefined): ParseResult => {
|
||||||
|
if (md == null) return { tokens: [], footnotes: {} };
|
||||||
|
const text = String(md).replace(/\r\n?/g, '\n');
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
const footnotes: Record<string, string> = {};
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
let handled = false;
|
||||||
|
for (const handler of blockHandlers) {
|
||||||
|
const match = handler.test(line, lines, i);
|
||||||
|
if (match !== null && match !== false) {
|
||||||
|
const result = handler.parse(lines, i, match, tokens, footnotes);
|
||||||
|
if (result.token) tokens.push(result.token);
|
||||||
|
i = result.newIndex;
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (handled) continue;
|
||||||
|
|
||||||
|
// Paragraph fallback
|
||||||
|
const para: string[] = [];
|
||||||
|
while (i < lines.length) {
|
||||||
|
const l = lines[i];
|
||||||
|
if (RE_EMPTY.test(l)) break;
|
||||||
|
if (isBlockStart(l)) break;
|
||||||
|
if (i + 1 < lines.length && (RE_SETEXT_H1.test(lines[i + 1]) || RE_SETEXT_H2.test(lines[i + 1]))) break;
|
||||||
|
if (/\|/.test(l) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) break;
|
||||||
|
para.push(l); i++;
|
||||||
|
}
|
||||||
|
if (para.length) tokens.push({ type: 'paragraph', text: para.join('\n') });
|
||||||
|
}
|
||||||
|
return { tokens, footnotes };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Token rendering ============
|
||||||
|
|
||||||
|
const renderTokens = (tokens: Token[], env: RenderEnv = {}, footnotes: Record<string, string> = {}): string => {
|
||||||
|
let html = tokens.map((tok) => renderToken(tok, env, footnotes)).join('\n');
|
||||||
|
const fnIds = Object.keys(footnotes);
|
||||||
|
if (fnIds.length) {
|
||||||
|
html += '\n<div class="me-footnotes"><hr/><ol>';
|
||||||
|
fnIds.forEach((id) => {
|
||||||
|
html += `<li id="fn-${id}" class="me-footnote-item"><a href="#fnref-${id}" class="me-footnote-backref">↩</a> ${cachedRenderInline(footnotes[id], env)}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ol></div>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseMarkdown = (md: string | null | undefined, env: RenderEnv = {}): string => {
|
||||||
|
const { tokens, footnotes } = parseTokens(md);
|
||||||
|
return renderTokens(tokens, env, footnotes);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ List parsing ============
|
||||||
|
|
||||||
|
const parseList = (
|
||||||
|
lines: string[], startIdx: number, marker: string, baseIndent: number,
|
||||||
|
isOl: boolean, olStart?: number,
|
||||||
|
): { items: ListItem[]; endIdx: number } => {
|
||||||
|
const items: ListItem[] = [];
|
||||||
|
let i = startIdx;
|
||||||
|
const itemIndent = baseIndent + (isOl ? String(marker).length + 2 : 2);
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
let itemMatch: RegExpMatchArray | null;
|
||||||
|
if (isOl) {
|
||||||
|
itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})\\d+\\.\\s`));
|
||||||
|
if (!itemMatch) break;
|
||||||
|
} else {
|
||||||
|
itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})[-*+]\\s`));
|
||||||
|
if (!itemMatch) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = line.slice(itemMatch[0].length);
|
||||||
|
const task = raw.match(RE_TASK);
|
||||||
|
let content = task ? task[2] : raw;
|
||||||
|
const checked = task ? task[1].toLowerCase() === 'x' : false;
|
||||||
|
const isTask = !!task;
|
||||||
|
i++;
|
||||||
|
|
||||||
|
const subContent: string[] = [];
|
||||||
|
const subTokens: Token[] = [];
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const cl = lines[i];
|
||||||
|
if (RE_EMPTY.test(cl)) {
|
||||||
|
let peek = i + 1;
|
||||||
|
while (peek < lines.length && RE_EMPTY.test(lines[peek])) peek++;
|
||||||
|
if (peek < lines.length) {
|
||||||
|
const pl = lines[peek];
|
||||||
|
const isCont = pl.length > itemIndent && pl[itemIndent] !== ' '
|
||||||
|
? false : new RegExp(`^\\s{${itemIndent},}`).test(pl);
|
||||||
|
if (isCont) { subContent.push(''); i++; continue; }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (isOl) {
|
||||||
|
if (new RegExp(`^(\\s{0,${baseIndent}})\\d+\\.\\s`).test(cl)) break;
|
||||||
|
} else {
|
||||||
|
if (new RegExp(`^(\\s{0,${baseIndent}})[-*+]\\s`).test(cl)) break;
|
||||||
|
}
|
||||||
|
if (RE_QUOTE.test(cl) || RE_ATX.test(cl) || RE_HR.test(cl) || RE_FOOTNOTE_DEF.test(cl)) break;
|
||||||
|
|
||||||
|
const nUl = cl.match(new RegExp(`^(\\s{${itemIndent},})([-*+])\\s`));
|
||||||
|
const nOl = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`));
|
||||||
|
if (nUl || nOl) {
|
||||||
|
const isNOl = !!nOl;
|
||||||
|
const nM = isNOl ? nOl![2] : nUl![2];
|
||||||
|
const nI = isNOl ? nOl![1].length : nUl![1].length;
|
||||||
|
const nS = isNOl ? parseInt(nOl![2], 10) : undefined;
|
||||||
|
const nested = parseList(lines, i, nM, nI, isNOl, nS);
|
||||||
|
subTokens.push({ type: isNOl ? 'ol' : 'ul', items: nested.items, start: nS });
|
||||||
|
i = nested.endIdx;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cl.length > itemIndent && cl.slice(0, itemIndent).trim() === '') {
|
||||||
|
subContent.push(cl.slice(itemIndent));
|
||||||
|
} else {
|
||||||
|
subContent.push(cl);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subContent.length) {
|
||||||
|
content = content ? content + '\n' + subContent.join('\n') : subContent.join('\n');
|
||||||
|
}
|
||||||
|
items.push({ type: 'listItem', text: content, checked, task: isTask, subTokens: subTokens.length ? subTokens : undefined });
|
||||||
|
}
|
||||||
|
return { items, endIdx: i };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Table utils ============
|
||||||
|
|
||||||
|
const isTableSeparator = (line: string): boolean => RE_TABLE_SEP.test(line) && /-/.test(line);
|
||||||
|
|
||||||
|
const parseAlign = (line: string): string[] => {
|
||||||
|
const cells = line.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
|
||||||
|
return cells.map((c) => {
|
||||||
|
if (/^:/.test(c) && /:$/.test(c)) return 'center';
|
||||||
|
if (/:$/.test(c)) return 'right';
|
||||||
|
if (/^:/.test(c)) return 'left';
|
||||||
|
return 'left';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Token renderer ============
|
||||||
|
|
||||||
|
const renderToken = (tok: Token, env: RenderEnv, footnotes: Record<string, string> | null): string => {
|
||||||
|
switch (tok.type) {
|
||||||
|
case 'heading': {
|
||||||
|
const id = slugify(tok.text);
|
||||||
|
return `<h${tok.level} id="${id}">${cachedRenderInline(tok.text, env)}</h${tok.level}>`;
|
||||||
|
}
|
||||||
|
case 'paragraph': return `<p>${cachedRenderInline(tok.text, env)}</p>`;
|
||||||
|
case 'hr': return '<hr/>';
|
||||||
|
case 'quote': return `<blockquote>${parseMarkdown(tok.content, env)}</blockquote>`;
|
||||||
|
case 'code': return renderCode(tok.content, tok.lang, env);
|
||||||
|
case 'ul': return `<ul>${tok.items.map((it: ListItem) => renderListItem(it, env)).join('')}</ul>`;
|
||||||
|
case 'ol': {
|
||||||
|
const sn = tok.start || 1;
|
||||||
|
const sa = sn > 1 ? ` start="${sn}"` : '';
|
||||||
|
const body = tok.items.map((it: ListItem, idx: number) => renderListItem(it, env, sn > 1 ? idx + sn : undefined)).join('');
|
||||||
|
return `<ol${sa}>${body}</ol>`;
|
||||||
|
}
|
||||||
|
case 'table': return renderTable(tok, env);
|
||||||
|
case 'defList': {
|
||||||
|
let h = '<dl>';
|
||||||
|
h += `<dt>${cachedRenderInline(tok.term, env)}</dt>`;
|
||||||
|
tok.defs.forEach((d: string) => { h += `<dd>${cachedRenderInline(d, env)}</dd>`; });
|
||||||
|
return h + '</dl>';
|
||||||
|
}
|
||||||
|
case 'mathBlock': return `<div class="me-math-block">${escapeHTML(tok.content)}</div>`;
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderListItem = (it: ListItem, env: RenderEnv, idx?: number): string => {
|
||||||
|
if (it.task) {
|
||||||
|
const checked = it.checked ? ' checked' : '';
|
||||||
|
let h = `<li class="me-task-item"><input type="checkbox" disabled${checked}/> ${cachedRenderInline(it.text, env)}`;
|
||||||
|
if (it.subTokens) h += '\n' + it.subTokens.map((st) => renderToken(st, env, null)).join('\n');
|
||||||
|
return h + '</li>';
|
||||||
|
}
|
||||||
|
let h = idx != null ? `<li value="${idx}">` : '<li>';
|
||||||
|
h += cachedRenderInline(it.text, env);
|
||||||
|
if (it.subTokens) h += '\n' + it.subTokens.map((st) => renderToken(st, env, null)).join('\n');
|
||||||
|
return h + '</li>';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCode = (code: string, lang: string, env: RenderEnv): string => {
|
||||||
|
if (lang === 'mermaid') return `<div class="me-mermaid"><pre class="mermaid">${escapeHTML(code)}</pre></div>`;
|
||||||
|
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : '';
|
||||||
|
if (env.highlight && typeof env.highlight === 'function' && lang) {
|
||||||
|
try {
|
||||||
|
const highlighted = env.highlight(code, lang);
|
||||||
|
if (typeof highlighted === 'string') return `<pre><code${langClass}>${highlighted}</code></pre>`;
|
||||||
|
} catch (e) { console.error('MeEditor highlight error:', e); }
|
||||||
|
}
|
||||||
|
return `<pre><code${langClass}>${escapeHTML(code)}</code></pre>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTable = (tok: Token, env: RenderEnv): string => {
|
||||||
|
const splitRow = (r: string) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
|
||||||
|
const headers = splitRow(tok.header);
|
||||||
|
const align: string[] = tok.align || [];
|
||||||
|
const as = (i: number) => align[i] && align[i] !== 'left' ? ` style="text-align:${align[i]}"` : '';
|
||||||
|
let h = '<div class="me-table-wrap"><table><thead><tr>';
|
||||||
|
h += headers.map((hd: string, i: number) => `<th${as(i)}>${cachedRenderInline(hd, env)}</th>`).join('');
|
||||||
|
h += '</tr></thead><tbody>';
|
||||||
|
h += tok.rows.map((r: string) => {
|
||||||
|
const cells = splitRow(r);
|
||||||
|
return `<tr>${cells.map((c, i) => `<td${as(i)}>${cachedRenderInline(c, env)}</td>`).join('')}</tr>`;
|
||||||
|
}).join('');
|
||||||
|
return h + '</tbody></table></div>';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Inline rendering ============
|
||||||
|
|
||||||
|
const renderInline = (text: string, env: RenderEnv): string => {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
const codes: CodePlaceholder[] = [];
|
||||||
|
let s = extractInlineCodes(text, codes);
|
||||||
|
|
||||||
|
const protectedItems: string[] = [];
|
||||||
|
s = s.replace(/&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});/g, (m) => {
|
||||||
|
protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`;
|
||||||
|
});
|
||||||
|
s = s.replace(/<!--[\s\S]*?-->/g, (m) => {
|
||||||
|
protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`;
|
||||||
|
});
|
||||||
|
|
||||||
|
s = escapeHTML(s);
|
||||||
|
s = s.replace(/\u0005(\d+)\u0005/g, (_m, idx) => protectedItems[+idx] || _m);
|
||||||
|
s = scanInline(s, codes, env);
|
||||||
|
|
||||||
|
s = s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => {
|
||||||
|
const code = codes[+idx];
|
||||||
|
if (!code) return _m;
|
||||||
|
return `<code>${escapeHTML(code.content)}</code>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m);
|
||||||
|
s = s.replace(/ \n/g, '<br/>\n');
|
||||||
|
s = s.replace(/\n/g, '<br/>');
|
||||||
|
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractInlineCodes = (text: string, codes: CodePlaceholder[]): string => {
|
||||||
|
let result = '';
|
||||||
|
let i = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
if (text[i] === '`') {
|
||||||
|
let startLen = 1;
|
||||||
|
while (i + startLen < text.length && text[i + startLen] === '`') startLen++;
|
||||||
|
let j = i + startLen;
|
||||||
|
let found = false;
|
||||||
|
while (j < text.length) {
|
||||||
|
const nextTick = text.indexOf('`', j);
|
||||||
|
if (nextTick === -1) break;
|
||||||
|
let endLen = 1;
|
||||||
|
while (nextTick + endLen < text.length && text[nextTick + endLen] === '`') endLen++;
|
||||||
|
if (endLen === startLen) {
|
||||||
|
const content = text.slice(i + startLen, nextTick);
|
||||||
|
codes.push({ content, len: startLen });
|
||||||
|
result += `\u0000${codes.length - 1}\u0000`;
|
||||||
|
i = nextTick + endLen;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
j = nextTick + endLen;
|
||||||
|
}
|
||||||
|
if (!found) { result += text.slice(i, i + startLen); i += startLen; }
|
||||||
|
} else { result += text[i]; i++; }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INLINE_RE = new RegExp([
|
||||||
|
'(\\*\\*\\*[^*\\n][^*\\n]*?\\*\\*\\*)',
|
||||||
|
'|(___[^_\\n][^_\\n]*?___)',
|
||||||
|
'|(!\\[[^\\]]*\\]\\([^)]+\\))',
|
||||||
|
'|(?<!!)(\\[[^\\]]+\\]\\([^)]+\\))',
|
||||||
|
'|(!\\[[^\\]]*\\]\\[[^\\]]*\\])',
|
||||||
|
'|(?<!!)(\\[[^\\]]+\\]\\[[^\\]]*\\])',
|
||||||
|
'|(<https?:\\/\\/[^\\s&]+>)',
|
||||||
|
'|(\\*\\*[^*\\n][^*\\n]*?\\*\\*)',
|
||||||
|
'|(__[^_\\n][^_\\n]*?__)',
|
||||||
|
'|(~~[^\\n]+?~~)',
|
||||||
|
'|(==[^\\n]+?==)',
|
||||||
|
'|((?<=^|[^*])\\*(?!\\*)([^*\\n]+?)\\*(?!\\*))',
|
||||||
|
'|((?<=^|[^_])_(?!_)([^_\\n]+?)_(?!_))',
|
||||||
|
'|(\\^[^\\s^][^\\s^]*?\\^)',
|
||||||
|
'|(~[^\\s~][^\\s~]*?~)',
|
||||||
|
'|(?<!\\$)(\\$(?!\\$)[^\\n]+?\\$(?!\\$))',
|
||||||
|
'|(\\[\\^[^\\]]+\\])',
|
||||||
|
'|(\\\\.)',
|
||||||
|
].join(''), 'g');
|
||||||
|
|
||||||
|
const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): string => {
|
||||||
|
const placeholderMap = new Map<string, string>();
|
||||||
|
s = s.replace(/\u0000\d+\u0000/g, (m) => {
|
||||||
|
const key = `\u0003${placeholderMap.size}\u0003`;
|
||||||
|
placeholderMap.set(key, m); return key;
|
||||||
|
});
|
||||||
|
|
||||||
|
s = s.replace(INLINE_RE, (fullMatch) => {
|
||||||
|
const match = fullMatch;
|
||||||
|
|
||||||
|
if (match.startsWith('***')) return `<em><strong>${match.slice(3, -3)}</strong></em>`;
|
||||||
|
if (match.startsWith('___')) return `<em><strong>${match.slice(3, -3)}</strong></em>`;
|
||||||
|
|
||||||
|
if (match.startsWith(') {
|
||||||
|
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
|
||||||
|
if (!m) return match;
|
||||||
|
const u = safeUrl(m[2]); if (!u) return escapeHTML(match);
|
||||||
|
const t = m[3] ? ` title="${m[3]}"` : '';
|
||||||
|
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
|
||||||
|
}
|
||||||
|
if (match.startsWith('![') && match.includes('][')) {
|
||||||
|
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
|
||||||
|
if (!m) return match;
|
||||||
|
return `<img src="" alt="${m[1]}" class="me-img-ref"/>`;
|
||||||
|
}
|
||||||
|
if (match.startsWith('[') && match.includes('](')) {
|
||||||
|
const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
|
||||||
|
if (!m) return match;
|
||||||
|
const u = safeUrl(m[2]); if (!u) return match;
|
||||||
|
const t = m[3] ? ` title="${m[3]}"` : '';
|
||||||
|
const linkText = renderInline(m[1], env);
|
||||||
|
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
|
||||||
|
}
|
||||||
|
if (match.startsWith('[') && match.includes('][')) return match;
|
||||||
|
if (match.startsWith('<http')) {
|
||||||
|
const url = match.slice(4, -4);
|
||||||
|
const u = safeUrl(url);
|
||||||
|
return `<a href="${u}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||||
|
}
|
||||||
|
if (match.startsWith('**')) return `<strong>${match.slice(2, -2)}</strong>`;
|
||||||
|
if (match.startsWith('__')) return `<strong>${match.slice(2, -2)}</strong>`;
|
||||||
|
if (match.startsWith('~~')) return `<del>${match.slice(2, -2)}</del>`;
|
||||||
|
if (match.startsWith('==')) return `<mark>${match.slice(2, -2)}</mark>`;
|
||||||
|
if (match.startsWith('*') && !match.startsWith('**') && !match.startsWith('***')) return `<em>${match.slice(1, -1)}</em>`;
|
||||||
|
if (match.startsWith('_') && !match.startsWith('__') && !match.startsWith('___')) return `<em>${match.slice(1, -1)}</em>`;
|
||||||
|
if (match.startsWith('^') && !match.startsWith('^^')) return `<sup>${match.slice(1, -1)}</sup>`;
|
||||||
|
if (match.startsWith('~') && !match.startsWith('~~')) return `<sub>${match.slice(1, -1)}</sub>`;
|
||||||
|
if (match.startsWith('$') && !match.startsWith('$$')) return `<span class="me-math-inline">${escapeHTML(match.slice(1, -1))}</span>`;
|
||||||
|
if (/^\[\^/.test(match)) {
|
||||||
|
const fnId = match.slice(2, -1);
|
||||||
|
return `<sup class="me-footnote-ref"><a href="#fn-${fnId}" id="fnref-${fnId}">[${fnId}]</a></sup>`;
|
||||||
|
}
|
||||||
|
if (match.startsWith('\\') && match.length === 2) {
|
||||||
|
const escaped = match[1];
|
||||||
|
if (/[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~]/.test(escaped)) return escaped;
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
s = s.replace(/\u0003(\d+)\u0003/g, (m) => placeholderMap.get(m) || m);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Exports ============
|
||||||
|
|
||||||
|
export {
|
||||||
|
parseMarkdown, parseTokens, renderTokens,
|
||||||
|
safeUrl, slugify, clearRenderCache, registerBlockHandler,
|
||||||
|
};
|
||||||
|
export default parseMarkdown;
|
||||||
-763
@@ -1,763 +0,0 @@
|
|||||||
/**
|
|
||||||
* MetonaEditor Plugins — 插件系统 v2
|
|
||||||
* @module plugins
|
|
||||||
* @version 0.1.14
|
|
||||||
* @description 插件管理器 + 预设插件 + 依赖/生命周期/异步/卸载
|
|
||||||
*
|
|
||||||
* v0.1.6 增强:
|
|
||||||
* - depends: 声明插件依赖,自动拓扑排序安装
|
|
||||||
* - 生命周期钩子: beforeInstall/afterInstall/beforeDestroy(静态 + 实例级)
|
|
||||||
* - 异步插件: install 返回 Promise 时自动 await
|
|
||||||
* - editor.unuse(name): 卸载单个已安装插件
|
|
||||||
* - pluginUtils.validateConfig(): schema 配置校验
|
|
||||||
* - priority: 数字越大越先安装
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { t } from './i18n.js';
|
|
||||||
|
|
||||||
// ============ 插件管理器 ============
|
|
||||||
|
|
||||||
class PluginManager {
|
|
||||||
constructor() {
|
|
||||||
this.plugins = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
register(name, plugin) {
|
|
||||||
if (this.plugins.has(name)) {
|
|
||||||
console.warn(`MeEditor: plugin "${name}" already registered`);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
if (!plugin || typeof plugin !== 'object' || (!plugin.name && !name)) {
|
|
||||||
console.error(`MeEditor: invalid plugin "${name}"`);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
this.plugins.set(name, {
|
|
||||||
name,
|
|
||||||
version: plugin.version || '0.0.0',
|
|
||||||
description: plugin.description || '',
|
|
||||||
depends: plugin.depends || [],
|
|
||||||
priority: plugin.priority || 0,
|
|
||||||
...plugin,
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
unregister(name) { this.plugins.delete(name); return this; }
|
|
||||||
get(name) { return this.plugins.get(name) || null; }
|
|
||||||
has(name) { return this.plugins.has(name); }
|
|
||||||
getAll() { return Array.from(this.plugins.values()); }
|
|
||||||
getNames() { return Array.from(this.plugins.keys()); }
|
|
||||||
enable(name) { const p = this.plugins.get(name); if (p) p.enabled = true; return this; }
|
|
||||||
disable(name) { const p = this.plugins.get(name); if (p) p.enabled = false; return this; }
|
|
||||||
isEnabled(name) { const p = this.plugins.get(name); return p ? p.enabled : false; }
|
|
||||||
destroy() { this.plugins.clear(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPluginManager = new PluginManager();
|
|
||||||
|
|
||||||
// ============ 依赖拓扑排序 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对插件列表按依赖关系拓扑排序(Kahn 算法)
|
|
||||||
* @param {Object[]} plugins - 插件对象数组
|
|
||||||
* @returns {Object[]} 排序后的插件数组
|
|
||||||
*/
|
|
||||||
const topologicalSort = (plugins) => {
|
|
||||||
const map = new Map();
|
|
||||||
plugins.forEach((p) => map.set(p.name, p));
|
|
||||||
|
|
||||||
const inDegree = new Map();
|
|
||||||
const adj = new Map();
|
|
||||||
|
|
||||||
plugins.forEach((p) => {
|
|
||||||
inDegree.set(p.name, 0);
|
|
||||||
adj.set(p.name, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
plugins.forEach((p) => {
|
|
||||||
(p.depends || []).forEach((dep) => {
|
|
||||||
if (map.has(dep)) {
|
|
||||||
adj.get(dep).push(p.name);
|
|
||||||
inDegree.set(p.name, (inDegree.get(p.name) || 0) + 1);
|
|
||||||
} else {
|
|
||||||
console.warn(`MeEditor: plugin "${p.name}" depends on unknown "${dep}"`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const queue = [];
|
|
||||||
inDegree.forEach((deg, name) => { if (deg === 0) queue.push(name); });
|
|
||||||
|
|
||||||
const sorted = [];
|
|
||||||
while (queue.length) {
|
|
||||||
// 同层级按 priority 降序
|
|
||||||
queue.sort((a, b) => (map.get(b).priority || 0) - (map.get(a).priority || 0));
|
|
||||||
const name = queue.shift();
|
|
||||||
sorted.push(map.get(name));
|
|
||||||
(adj.get(name) || []).forEach((n) => {
|
|
||||||
inDegree.set(n, inDegree.get(n) - 1);
|
|
||||||
if (inDegree.get(n) === 0) queue.push(n);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sorted.length !== plugins.length) {
|
|
||||||
console.warn('MeEditor: circular dependency detected in plugins, falling back to original order');
|
|
||||||
return plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sorted;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 配置校验 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验插件配置是否符合 schema
|
|
||||||
* @param {Object} schema - { key: { type, required, default, enum, validator } }
|
|
||||||
* @param {Object} config - 用户传入的配置
|
|
||||||
* @returns {{ valid: boolean, errors: string[], patched: Object }}
|
|
||||||
*/
|
|
||||||
const validateConfig = (schema = {}, config = {}) => {
|
|
||||||
const errors = [];
|
|
||||||
const patched = { ...config };
|
|
||||||
|
|
||||||
Object.entries(schema).forEach(([key, rule]) => {
|
|
||||||
const val = config[key];
|
|
||||||
|
|
||||||
// required
|
|
||||||
if (rule.required && (val === undefined || val === null)) {
|
|
||||||
errors.push(`"${key}" is required`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// default
|
|
||||||
if (val === undefined && rule.default !== undefined) {
|
|
||||||
patched[key] = rule.default;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// type
|
|
||||||
if (val !== undefined && rule.type) {
|
|
||||||
const actual = Array.isArray(val) ? 'array' : typeof val;
|
|
||||||
if (actual !== rule.type) {
|
|
||||||
errors.push(`"${key}" expected ${rule.type}, got ${actual}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// enum
|
|
||||||
if (val !== undefined && rule.enum && !rule.enum.includes(val)) {
|
|
||||||
errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// custom validator
|
|
||||||
if (val !== undefined && typeof rule.validator === 'function') {
|
|
||||||
try {
|
|
||||||
const result = rule.validator(val);
|
|
||||||
if (result !== true) errors.push(`"${key}": ${result}`);
|
|
||||||
} catch (e) {
|
|
||||||
errors.push(`"${key}": ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors, patched };
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 预设插件 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自动保存插件
|
|
||||||
*/
|
|
||||||
const autoSavePlugin = {
|
|
||||||
name: 'autoSave',
|
|
||||||
version: '0.1.0',
|
|
||||||
description: '自动保存到 localStorage,支持草稿恢复',
|
|
||||||
priority: 100,
|
|
||||||
depends: [],
|
|
||||||
|
|
||||||
_timer: null,
|
|
||||||
_onInput: null,
|
|
||||||
_onBlur: null,
|
|
||||||
_onSave: null,
|
|
||||||
|
|
||||||
install(editor) {
|
|
||||||
if (!editor || typeof editor.getValue !== 'function') return;
|
|
||||||
const key = this.key || ('me-draft-' + (editor.id || ''));
|
|
||||||
|
|
||||||
const save = () => {
|
|
||||||
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
|
||||||
try {
|
|
||||||
const value = editor.getValue();
|
|
||||||
localStorage.setItem(key, value);
|
|
||||||
if (typeof editor._emit === 'function') {
|
|
||||||
editor._emit('autosave', { key, value });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('MeEditor autoSave: save failed', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this._save = save;
|
|
||||||
this._onInput = () => {
|
|
||||||
if (this._timer) clearTimeout(this._timer);
|
|
||||||
this._timer = setTimeout(save, this.delay || 1000);
|
|
||||||
};
|
|
||||||
this._onBlur = save;
|
|
||||||
this._onSave = save;
|
|
||||||
|
|
||||||
editor.on('change', this._onInput);
|
|
||||||
editor.on('blur', this._onBlur);
|
|
||||||
editor.on('save', this._onSave);
|
|
||||||
|
|
||||||
editor.restoreDraft = () => {
|
|
||||||
try {
|
|
||||||
const v = localStorage.getItem(key);
|
|
||||||
if (v != null && typeof editor.setValue === 'function') editor.setValue(v);
|
|
||||||
return v;
|
|
||||||
} catch (e) { return null; }
|
|
||||||
};
|
|
||||||
editor.clearDraft = () => {
|
|
||||||
try { localStorage.removeItem(key); } catch (_) {}
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
editor.getDraftKey = () => key;
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy(editor) {
|
|
||||||
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
|
||||||
if (editor && typeof editor.off === 'function') {
|
|
||||||
if (this._onInput) editor.off('change', this._onInput);
|
|
||||||
if (this._onBlur) editor.off('blur', this._onBlur);
|
|
||||||
if (this._onSave) editor.off('save', this._onSave);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 配置 schema
|
|
||||||
configSchema: {
|
|
||||||
key: { type: 'string' },
|
|
||||||
delay: { type: 'number', default: 1000 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出工具插件
|
|
||||||
*/
|
|
||||||
const exportToolPlugin = {
|
|
||||||
name: 'exportTool',
|
|
||||||
version: '0.1.0',
|
|
||||||
description: '导出 Markdown / HTML 文件',
|
|
||||||
priority: 50,
|
|
||||||
|
|
||||||
install(editor) {
|
|
||||||
if (!editor || typeof editor.getValue !== 'function') return;
|
|
||||||
|
|
||||||
const download = (filename, content, mime) => {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
const blob = new Blob([content], { type: mime + ';charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stamp = () => {
|
|
||||||
const d = new Date();
|
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.exportMarkdown = (filename) => {
|
|
||||||
download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown');
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.exportHTML = (filename, opts = {}) => {
|
|
||||||
const title = opts.title || 'Document';
|
|
||||||
const css = opts.css || '';
|
|
||||||
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
|
|
||||||
const 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>`;
|
|
||||||
download(filename || `metona-${stamp()}.html`, html, 'text/html');
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查找替换插件
|
|
||||||
*/
|
|
||||||
const searchReplacePlugin = {
|
|
||||||
name: 'searchReplace',
|
|
||||||
version: '0.1.0',
|
|
||||||
description: '查找替换(Ctrl+F / Ctrl+H)',
|
|
||||||
priority: 80,
|
|
||||||
|
|
||||||
_onKeydown: null,
|
|
||||||
_panel: null,
|
|
||||||
_cleanup: null,
|
|
||||||
|
|
||||||
install(editor) {
|
|
||||||
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
|
||||||
this._injectStyle();
|
|
||||||
|
|
||||||
this._onKeydown = (e) => {
|
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
|
||||||
if (!mod) return;
|
|
||||||
const k = e.key.toLowerCase();
|
|
||||||
if (k === 'f') { e.preventDefault(); this._open(editor); }
|
|
||||||
else if (k === 'h') { e.preventDefault(); this._open(editor, true); }
|
|
||||||
else if (k === 'escape' && this._panel) { this._close(editor); }
|
|
||||||
};
|
|
||||||
editor.textarea.addEventListener('keydown', this._onKeydown);
|
|
||||||
},
|
|
||||||
|
|
||||||
_injectStyle() {
|
|
||||||
if (document.getElementById('me-search-style')) return;
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = 'me-search-style';
|
|
||||||
style.textContent = `.me-search{position:absolute;top:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);font-size:13px;min-width:280px}.me-search-row{display:flex;gap:4px;align-items:center}.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg,#fff);color:var(--md-text,#1f2937);font-size:13px}.me-search input:focus{outline:none;border-color:var(--md-accent,#3b82f6)}.me-search button{padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));background:var(--md-bg,#fff);color:var(--md-text,#1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}.me-search button:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-count{color:var(--md-muted,#6b7280);font-size:12px;min-width:60px;text-align:center}.me-search .me-search-close{padding:2px 6px}`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
},
|
|
||||||
|
|
||||||
_open(editor, showReplace) {
|
|
||||||
if (this._panel) {
|
|
||||||
this._panel.dataset.replace = showReplace ? '1' : '0';
|
|
||||||
this._updateReplaceVisible();
|
|
||||||
const inp = this._panel.querySelector('.me-search-find');
|
|
||||||
if (inp) { inp.focus(); inp.select(); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
|
|
||||||
const panel = document.createElement('div');
|
|
||||||
panel.className = 'me-search';
|
|
||||||
panel.dataset.replace = showReplace ? '1' : '0';
|
|
||||||
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${(t('searchPlaceholder') || '查找内容')}" value="${escapeAttr(selected)}"/><button type="button" class="me-search-prev" title="${(t('findPrev') || '上一个')}">↑</button><button type="button" class="me-search-next" title="${(t('findNext') || '下一个')}">↓</button><span class="me-search-count"></span><button type="button" class="me-search-close" title="${(t('close') || '关闭')}" aria-label="close">×</button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${(t('replacePlaceholder') || '替换为')}"/><button type="button" class="me-search-replace-one">${(t('replace') || '替换')}</button><button type="button" class="me-search-replace-all">${(t('replaceAll') || '全部')}</button></div>`;
|
|
||||||
editor.el.appendChild(panel);
|
|
||||||
this._panel = panel;
|
|
||||||
this._updateReplaceVisible();
|
|
||||||
|
|
||||||
const findInput = panel.querySelector('.me-search-find');
|
|
||||||
const replaceInput = panel.querySelector('.me-search-replace');
|
|
||||||
const countEl = panel.querySelector('.me-search-count');
|
|
||||||
|
|
||||||
const findAll = () => {
|
|
||||||
const text = editor.textarea.value;
|
|
||||||
const q = findInput.value;
|
|
||||||
if (!q) { countEl.textContent = ''; return []; }
|
|
||||||
const idxs = [];
|
|
||||||
let from = 0;
|
|
||||||
while (true) {
|
|
||||||
const idx = text.indexOf(q, from);
|
|
||||||
if (idx === -1) break;
|
|
||||||
idxs.push(idx);
|
|
||||||
from = idx + q.length;
|
|
||||||
}
|
|
||||||
countEl.textContent = idxs.length ? `${idxs.length}` : '0';
|
|
||||||
return idxs;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAt = (idx) => {
|
|
||||||
const q = findInput.value;
|
|
||||||
editor.textarea.focus();
|
|
||||||
editor.textarea.setSelectionRange(idx, idx + q.length);
|
|
||||||
const lineHeight = parseFloat(getComputedStyle(editor.textarea).lineHeight) || 20;
|
|
||||||
const lineNum = editor.textarea.value.substring(0, idx).split('\n').length - 1;
|
|
||||||
editor.textarea.scrollTop = Math.max(0, lineNum * lineHeight - editor.textarea.clientHeight / 2);
|
|
||||||
};
|
|
||||||
|
|
||||||
let lastIdxs = [];
|
|
||||||
const findNext = () => {
|
|
||||||
lastIdxs = findAll();
|
|
||||||
if (!lastIdxs.length) return;
|
|
||||||
const cur = editor.textarea.selectionEnd;
|
|
||||||
let next = lastIdxs.find((i) => i >= cur);
|
|
||||||
if (next == null) next = lastIdxs[0];
|
|
||||||
selectAt(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceOne = () => {
|
|
||||||
const q = findInput.value;
|
|
||||||
const r = replaceInput.value;
|
|
||||||
if (!q) return;
|
|
||||||
const ta = editor.textarea;
|
|
||||||
const start = ta.selectionStart, end = ta.selectionEnd;
|
|
||||||
if (ta.value.substring(start, end) === q) {
|
|
||||||
ta.value = ta.value.substring(0, start) + r + ta.value.substring(end);
|
|
||||||
ta.setSelectionRange(start, start + 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 = findInput.value, r = replaceInput.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();
|
|
||||||
};
|
|
||||||
|
|
||||||
findInput.addEventListener('input', () => { lastIdxs = findAll(); });
|
|
||||||
findInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); }
|
|
||||||
if (e.key === 'Escape') { e.preventDefault(); this._close(editor); }
|
|
||||||
});
|
|
||||||
replaceInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') { e.preventDefault(); replaceOne(); }
|
|
||||||
if (e.key === 'Escape') { e.preventDefault(); this._close(editor); }
|
|
||||||
});
|
|
||||||
panel.querySelector('.me-search-next').addEventListener('click', findNext);
|
|
||||||
panel.querySelector('.me-search-prev').addEventListener('click', findPrev);
|
|
||||||
panel.querySelector('.me-search-close').addEventListener('click', () => this._close(editor));
|
|
||||||
panel.querySelector('.me-search-replace-one').addEventListener('click', replaceOne);
|
|
||||||
panel.querySelector('.me-search-replace-all').addEventListener('click', replaceAll);
|
|
||||||
|
|
||||||
if (selected) findAll();
|
|
||||||
findInput.focus();
|
|
||||||
findInput.select();
|
|
||||||
this._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); };
|
|
||||||
},
|
|
||||||
|
|
||||||
_updateReplaceVisible() {
|
|
||||||
if (!this._panel) return;
|
|
||||||
const show = this._panel.dataset.replace === '1';
|
|
||||||
const row = this._panel.querySelector('.me-search-replace-row');
|
|
||||||
if (row) row.style.display = show ? 'flex' : 'none';
|
|
||||||
},
|
|
||||||
|
|
||||||
_close(editor) {
|
|
||||||
if (this._cleanup) { this._cleanup(); this._cleanup = null; }
|
|
||||||
this._panel = null;
|
|
||||||
if (editor && editor.textarea) editor.textarea.focus();
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy(editor) {
|
|
||||||
this._close(editor);
|
|
||||||
if (this._onKeydown && editor && editor.textarea) {
|
|
||||||
editor.textarea.removeEventListener('keydown', this._onKeydown);
|
|
||||||
}
|
|
||||||
this._onKeydown = null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 图片粘贴插件
|
|
||||||
*/
|
|
||||||
const imagePastePlugin = {
|
|
||||||
name: 'imagePaste',
|
|
||||||
version: '0.1.0',
|
|
||||||
description: '粘贴图片自动转为 base64 内联',
|
|
||||||
priority: 60,
|
|
||||||
|
|
||||||
_onPaste: null,
|
|
||||||
|
|
||||||
install(editor) {
|
|
||||||
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
|
||||||
this._onPaste = (e) => {
|
|
||||||
const items = e.clipboardData && e.clipboardData.items;
|
|
||||||
if (!items) return;
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.type && item.type.startsWith('image/')) {
|
|
||||||
e.preventDefault();
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const dataUri = reader.result;
|
|
||||||
const name = `image-${Date.now().toString(36)}.png`;
|
|
||||||
editor.insert(`\n`);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(item.getAsFile());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
editor.textarea.addEventListener('paste', this._onPaste);
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy(editor) {
|
|
||||||
if (this._onPaste && editor && editor.textarea) {
|
|
||||||
editor.textarea.removeEventListener('paste', this._onPaste);
|
|
||||||
}
|
|
||||||
this._onPaste = null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 快捷键帮助插件(v0.1.12)
|
|
||||||
* 按 ? 弹出快捷键列表面板
|
|
||||||
*/
|
|
||||||
const shortcutHelpPlugin = {
|
|
||||||
name: 'shortcutHelp',
|
|
||||||
version: '0.1.0',
|
|
||||||
description: '按 ? 查看快捷键列表',
|
|
||||||
priority: 200,
|
|
||||||
|
|
||||||
_onKeydown: null,
|
|
||||||
_panel: null,
|
|
||||||
|
|
||||||
install(editor) {
|
|
||||||
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
|
||||||
|
|
||||||
this._injectStyle();
|
|
||||||
this._onKeydown = (e) => {
|
|
||||||
// ? 键(Shift+/),且不在输入框中时
|
|
||||||
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
this._open(editor);
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape' && this._panel) {
|
|
||||||
this._close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
editor.textarea.addEventListener('keydown', this._onKeydown);
|
|
||||||
},
|
|
||||||
|
|
||||||
_injectStyle() {
|
|
||||||
if (document.getElementById('me-shortcut-style')) return;
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = 'me-shortcut-style';
|
|
||||||
style.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel td:last-child{color:var(--md-text)}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
},
|
|
||||||
|
|
||||||
_open(editor) {
|
|
||||||
if (this._panel) { this._close(); return; }
|
|
||||||
|
|
||||||
const builtin = [
|
|
||||||
['Ctrl+B', '粗体'], ['Ctrl+I', '斜体'], ['Ctrl+U', '下划线'],
|
|
||||||
['Ctrl+K', '链接'], ['Ctrl+E', '行内代码'],
|
|
||||||
['Ctrl+1/2/3', '标题 H1/H2/H3'], ['Ctrl+Q', '引用'],
|
|
||||||
['Ctrl+Z', '撤销'], ['Ctrl+Y / Ctrl+Shift+Z', '重做'],
|
|
||||||
['Ctrl+S', '保存'], ['Ctrl+F', '查找'], ['Ctrl+H', '替换'],
|
|
||||||
['Tab', '缩进'], ['Shift+Tab', '反缩进'],
|
|
||||||
['Ctrl+Shift+T', 'Zen 模式演示快捷键'],
|
|
||||||
['?', '显示/隐藏快捷键帮助'],
|
|
||||||
];
|
|
||||||
|
|
||||||
const custom = (editor.getShortcuts && editor.getShortcuts()) || [];
|
|
||||||
const customRows = custom.map((s) => [s.combo, s.description || s.combo]);
|
|
||||||
const allRows = [...builtin, ...customRows];
|
|
||||||
|
|
||||||
let rows = '';
|
|
||||||
allRows.forEach(([combo, desc]) => {
|
|
||||||
rows += `<tr><td>${combo}</td><td>${desc}</td></tr>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.className = 'me-shortcut-overlay';
|
|
||||||
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ 快捷键</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
|
|
||||||
overlay.addEventListener('click', (e) => {
|
|
||||||
if (e.target === overlay || e.target.classList.contains('me-shortcut-close')) {
|
|
||||||
this._close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
this._panel = overlay;
|
|
||||||
},
|
|
||||||
|
|
||||||
_close() {
|
|
||||||
if (this._panel) {
|
|
||||||
this._panel.remove();
|
|
||||||
this._panel = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy(editor) {
|
|
||||||
this._close();
|
|
||||||
if (this._onKeydown && editor && editor.textarea) {
|
|
||||||
editor.textarea.removeEventListener('keydown', this._onKeydown);
|
|
||||||
}
|
|
||||||
this._onKeydown = null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件系统插件(v0.1.14)
|
|
||||||
* 使用 File System Access API 读写磁盘文件
|
|
||||||
*/
|
|
||||||
const fileSystemPlugin = {
|
|
||||||
name: 'fileSystem',
|
|
||||||
version: '0.1.0',
|
|
||||||
description: '磁盘文件读写(File System Access API)',
|
|
||||||
priority: 90,
|
|
||||||
|
|
||||||
_fileHandle: null,
|
|
||||||
|
|
||||||
install(editor) {
|
|
||||||
if (!editor || typeof editor.getValue !== 'function') return;
|
|
||||||
|
|
||||||
// 检查 API 支持
|
|
||||||
const hasAPI = typeof window !== 'undefined' && typeof window.showOpenFilePicker === 'function';
|
|
||||||
|
|
||||||
/** 打开磁盘文件 */
|
|
||||||
editor.openFile = async (opts = {}) => {
|
|
||||||
if (!hasAPI) {
|
|
||||||
editor.toast?.('File System Access API 不支持此浏览器', { type: 'warning' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const [handle] = await window.showOpenFilePicker({
|
|
||||||
types: [{ accept: { 'text/markdown': ['.md', '.txt', '.markdown'] } }],
|
|
||||||
...opts,
|
|
||||||
});
|
|
||||||
this._fileHandle = handle;
|
|
||||||
const file = await handle.getFile();
|
|
||||||
const content = await file.text();
|
|
||||||
editor.setValue(content);
|
|
||||||
editor._emit?.('fileOpened', { name: file.name, handle });
|
|
||||||
return { name: file.name, content, handle };
|
|
||||||
} catch (e) {
|
|
||||||
if (e.name !== 'AbortError') console.error('Open file error:', e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 保存到当前文件(如已打开)或弹出另存为对话框 */
|
|
||||||
editor.saveFile = async (opts = {}) => {
|
|
||||||
let handle = this._fileHandle;
|
|
||||||
if (!handle || opts.saveAs) {
|
|
||||||
if (!hasAPI) {
|
|
||||||
editor.toast?.('File System Access API 不支持此浏览器', { type: 'warning' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
handle = await window.showSaveFilePicker({
|
|
||||||
types: [{ accept: { 'text/markdown': ['.md'] } }],
|
|
||||||
suggestedName: opts.name || 'document.md',
|
|
||||||
});
|
|
||||||
this._fileHandle = handle;
|
|
||||||
} catch (e) {
|
|
||||||
if (e.name !== 'AbortError') console.error('Save file error:', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const writable = await handle.createWritable();
|
|
||||||
await writable.write(editor.getValue());
|
|
||||||
await writable.close();
|
|
||||||
editor._emit?.('fileSaved', { handle });
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
// 权限过期,清除句柄重试
|
|
||||||
this._fileHandle = null;
|
|
||||||
if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true });
|
|
||||||
console.error('Write file error:', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 另存为新文件 */
|
|
||||||
editor.saveFileAs = (name) => editor.saveFile({ saveAs: true, name });
|
|
||||||
|
|
||||||
/** 获取当前文件句柄 */
|
|
||||||
editor.getFileHandle = () => this._fileHandle;
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy(editor) {
|
|
||||||
this._fileHandle = null;
|
|
||||||
if (editor) {
|
|
||||||
delete editor.openFile;
|
|
||||||
delete editor.saveFile;
|
|
||||||
delete editor.saveFileAs;
|
|
||||||
delete editor.getFileHandle;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 预设插件表 ============
|
|
||||||
|
|
||||||
const presetPlugins = {
|
|
||||||
autoSave: autoSavePlugin,
|
|
||||||
exportTool: exportToolPlugin,
|
|
||||||
searchReplace: searchReplacePlugin,
|
|
||||||
imagePaste: imagePastePlugin,
|
|
||||||
shortcutHelp: shortcutHelpPlugin,
|
|
||||||
fileSystem: fileSystemPlugin,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 实用工具 ============
|
|
||||||
|
|
||||||
const escapeAttr = (s) => String(s == null ? '' : s)
|
|
||||||
.replace(/&/g, '&').replace(/"/g, '"')
|
|
||||||
.replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
|
|
||||||
// ============ 插件工具集 ============
|
|
||||||
|
|
||||||
const pluginUtils = {
|
|
||||||
createManager() { return new PluginManager(); },
|
|
||||||
manager: defaultPluginManager,
|
|
||||||
|
|
||||||
register(name, plugin) { return defaultPluginManager.register(name, plugin); },
|
|
||||||
unregister(name) { return defaultPluginManager.unregister(name); },
|
|
||||||
get(name) { return defaultPluginManager.get(name); },
|
|
||||||
has(name) { return defaultPluginManager.has(name); },
|
|
||||||
getAll() { return defaultPluginManager.getAll(); },
|
|
||||||
getNames() { return defaultPluginManager.getNames(); },
|
|
||||||
enable(name) { return defaultPluginManager.enable(name); },
|
|
||||||
disable(name) { return defaultPluginManager.disable(name); },
|
|
||||||
isEnabled(name) { return defaultPluginManager.isEnabled(name); },
|
|
||||||
|
|
||||||
getPreset(name) { return presetPlugins[name] ? { ...presetPlugins[name] } : null; },
|
|
||||||
getAllPresets() {
|
|
||||||
return Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {});
|
|
||||||
},
|
|
||||||
|
|
||||||
createPlugin(config = {}) {
|
|
||||||
const result = {
|
|
||||||
name: config.name || 'custom',
|
|
||||||
version: config.version || '0.0.0',
|
|
||||||
description: config.description || '',
|
|
||||||
depends: config.depends || [],
|
|
||||||
priority: config.priority || 0,
|
|
||||||
install: () => {},
|
|
||||||
destroy: () => {},
|
|
||||||
...config,
|
|
||||||
};
|
|
||||||
if (typeof result.install !== 'function') result.install = () => {};
|
|
||||||
if (typeof result.destroy !== 'function') result.destroy = () => {};
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
|
|
||||||
validatePlugin(plugin) {
|
|
||||||
const errors = [];
|
|
||||||
if (!plugin || typeof plugin !== 'object') errors.push('plugin must be an object');
|
|
||||||
if (plugin && !plugin.name) errors.push('plugin must have a name');
|
|
||||||
if (plugin && plugin.install && typeof plugin.install !== 'function') errors.push('install must be a function');
|
|
||||||
return { valid: errors.length === 0, errors };
|
|
||||||
},
|
|
||||||
|
|
||||||
// v0.1.6 新增
|
|
||||||
topologicalSort,
|
|
||||||
validateConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
export { presetPlugins, pluginUtils, defaultPluginManager, PluginManager, topologicalSort, validateConfig };
|
|
||||||
export default presetPlugins;
|
|
||||||
+352
@@ -0,0 +1,352 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Plugins — plugin system v2
|
||||||
|
* @module plugins
|
||||||
|
* @version 0.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { t } from './i18n';
|
||||||
|
|
||||||
|
export interface Plugin {
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
depends?: string[];
|
||||||
|
priority?: number;
|
||||||
|
install?: (editor: any) => void | Promise<void>;
|
||||||
|
destroy?: (editor: any) => void;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSchema {
|
||||||
|
[key: string]: {
|
||||||
|
type?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: any;
|
||||||
|
enum?: any[];
|
||||||
|
validator?: (val: any) => boolean | string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Plugin Manager ============
|
||||||
|
|
||||||
|
export class PluginManager {
|
||||||
|
plugins = new Map<string, Plugin & { enabled: boolean }>();
|
||||||
|
|
||||||
|
register(name: string, plugin: Plugin): this {
|
||||||
|
if (this.plugins.has(name)) { console.warn(`MeEditor: plugin "${name}" already registered`); return this; }
|
||||||
|
if (!plugin || typeof plugin !== 'object' || (!plugin.name && !name)) { console.error(`MeEditor: invalid plugin "${name}"`); return this; }
|
||||||
|
this.plugins.set(name, {
|
||||||
|
version: plugin.version || '0.0.0', description: plugin.description || '',
|
||||||
|
depends: plugin.depends || [], priority: plugin.priority || 0,
|
||||||
|
...plugin, name, enabled: true,
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
unregister(name: string): this { this.plugins.delete(name); return this; }
|
||||||
|
get(name: string): Plugin | null { return this.plugins.get(name) || null; }
|
||||||
|
has(name: string): boolean { return this.plugins.has(name); }
|
||||||
|
getAll(): Plugin[] { return Array.from(this.plugins.values()); }
|
||||||
|
getNames(): string[] { return Array.from(this.plugins.keys()); }
|
||||||
|
enable(name: string): this { const p = this.plugins.get(name); if (p) p.enabled = true; return this; }
|
||||||
|
disable(name: string): this { const p = this.plugins.get(name); if (p) p.enabled = false; return this; }
|
||||||
|
isEnabled(name: string): boolean { const p = this.plugins.get(name); return p ? p.enabled : false; }
|
||||||
|
destroy(): void { this.plugins.clear(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPluginManager = new PluginManager();
|
||||||
|
|
||||||
|
// ============ Topological sort ============
|
||||||
|
|
||||||
|
export const topologicalSort = (plugins: Plugin[]): Plugin[] => {
|
||||||
|
const map = new Map<string, Plugin>();
|
||||||
|
plugins.forEach((p) => map.set(p.name, p));
|
||||||
|
const inDegree = new Map<string, number>();
|
||||||
|
const adj = new Map<string, string[]>();
|
||||||
|
plugins.forEach((p) => { inDegree.set(p.name, 0); adj.set(p.name, []); });
|
||||||
|
plugins.forEach((p) => {
|
||||||
|
(p.depends || []).forEach((dep) => {
|
||||||
|
if (map.has(dep)) { adj.get(dep)!.push(p.name); inDegree.set(p.name, (inDegree.get(p.name) || 0) + 1); }
|
||||||
|
else { console.warn(`MeEditor: plugin "${p.name}" depends on unknown "${dep}"`); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const queue: string[] = [];
|
||||||
|
inDegree.forEach((deg, name) => { if (deg === 0) queue.push(name); });
|
||||||
|
const sorted: Plugin[] = [];
|
||||||
|
while (queue.length) {
|
||||||
|
queue.sort((a, b) => ((map.get(b)!.priority || 0) - (map.get(a)!.priority || 0)));
|
||||||
|
const name = queue.shift()!;
|
||||||
|
sorted.push(map.get(name)!);
|
||||||
|
(adj.get(name) || []).forEach((n) => { inDegree.set(n, inDegree.get(n)! - 1); if (inDegree.get(n) === 0) queue.push(n); });
|
||||||
|
}
|
||||||
|
if (sorted.length !== plugins.length) { console.warn('MeEditor: circular dependency detected, falling back to original order'); return plugins; }
|
||||||
|
return sorted;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Config validation ============
|
||||||
|
|
||||||
|
export const validateConfig = (schema: PluginSchema = {}, config: Record<string, any> = {}): { valid: boolean; errors: string[]; patched: Record<string, any> } => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const patched = { ...config };
|
||||||
|
for (const [key, rule] of Object.entries(schema)) {
|
||||||
|
const val = config[key];
|
||||||
|
if (rule.required && (val === undefined || val === null)) { errors.push(`"${key}" is required`); continue; }
|
||||||
|
if (val === undefined && rule.default !== undefined) { patched[key] = rule.default; continue; }
|
||||||
|
if (val !== undefined && rule.type) {
|
||||||
|
const actual = Array.isArray(val) ? 'array' : typeof val;
|
||||||
|
if (actual !== rule.type) errors.push(`"${key}" expected ${rule.type}, got ${actual}`);
|
||||||
|
}
|
||||||
|
if (val !== undefined && rule.enum && !rule.enum.includes(val)) errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`);
|
||||||
|
if (val !== undefined && typeof rule.validator === 'function') {
|
||||||
|
try { const r = rule.validator(val); if (r !== true) errors.push(`"${key}": ${r}`); } catch (e: any) { errors.push(`"${key}": ${e.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: errors.length === 0, errors, patched };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Preset plugins ============
|
||||||
|
|
||||||
|
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
const autoSavePlugin: Plugin = {
|
||||||
|
name: 'autoSave', version: '0.1.0', description: 'Auto-save to localStorage', priority: 100,
|
||||||
|
install(editor) {
|
||||||
|
if (!editor || typeof editor.getValue !== 'function') return;
|
||||||
|
const key = (this as any).key || ('me-draft-' + (editor.id || ''));
|
||||||
|
const save = () => {
|
||||||
|
if ((this as any)._timer) { clearTimeout((this as any)._timer); (this as any)._timer = null; }
|
||||||
|
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); }
|
||||||
|
};
|
||||||
|
(this as any)._save = save;
|
||||||
|
(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); };
|
||||||
|
(this as any)._onBlur = save;
|
||||||
|
(this as any)._onSave = save;
|
||||||
|
editor.on('change', (this as any)._onInput);
|
||||||
|
editor.on('blur', (this as any)._onBlur);
|
||||||
|
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.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; };
|
||||||
|
editor.getDraftKey = () => key;
|
||||||
|
},
|
||||||
|
destroy(editor) {
|
||||||
|
if ((this as any)._timer) { clearTimeout((this as any)._timer); (this as any)._timer = null; }
|
||||||
|
if (editor && typeof editor.off === 'function') {
|
||||||
|
if ((this as any)._onInput) editor.off('change', (this as any)._onInput);
|
||||||
|
if ((this as any)._onBlur) editor.off('blur', (this as any)._onBlur);
|
||||||
|
if ((this as any)._onSave) editor.off('save', (this as any)._onSave);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToolPlugin: Plugin = {
|
||||||
|
name: 'exportTool', version: '0.1.0', description: 'Export Markdown/HTML', priority: 50,
|
||||||
|
install(editor) {
|
||||||
|
if (!editor || typeof editor.getValue !== 'function') return;
|
||||||
|
const download = (filename: string, content: string, mime: string) => {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const blob = new Blob([content], { type: mime + ';charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = filename;
|
||||||
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
};
|
||||||
|
const stamp = () => { const d = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; };
|
||||||
|
editor.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; };
|
||||||
|
editor.exportHTML = (filename?: string, opts: any = {}) => {
|
||||||
|
const title = opts.title || 'Document';
|
||||||
|
const css = opts.css || '';
|
||||||
|
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');
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
destroy() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchReplacePlugin: Plugin = {
|
||||||
|
name: 'searchReplace', version: '0.1.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
|
||||||
|
install(editor) {
|
||||||
|
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
||||||
|
this._injectStyle!();
|
||||||
|
(this as any)._onKeydown = (e: KeyboardEvent) => {
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (!mod) return;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (k === 'f') { e.preventDefault(); this._open!(editor); }
|
||||||
|
else if (k === 'h') { e.preventDefault(); this._open!(editor, true); }
|
||||||
|
else if (k === 'escape' && (this as any)._panel) { this._close!(editor); }
|
||||||
|
};
|
||||||
|
editor.textarea.addEventListener('keydown', (this as any)._onKeydown);
|
||||||
|
},
|
||||||
|
_injectStyle() {
|
||||||
|
if (document.getElementById('me-search-style')) return;
|
||||||
|
const style = document.createElement('style'); style.id = 'me-search-style';
|
||||||
|
style.textContent = `.me-search{position:absolute;top:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);font-size:13px;min-width:280px}.me-search-row{display:flex;gap:4px;align-items:center}.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg,#fff);color:var(--md-text,#1f2937);font-size:13px}.me-search input:focus{outline:none;border-color:var(--md-accent,#3b82f6)}.me-search button{padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));background:var(--md-bg,#fff);color:var(--md-text,#1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}.me-search button:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-count{color:var(--md-muted,#6b7280);font-size:12px;min-width:60px;text-align:center}.me-search .me-search-close{padding:2px 6px}`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
},
|
||||||
|
_open(editor: any, showReplace?: boolean) {
|
||||||
|
const self = this as any;
|
||||||
|
if (self._panel) {
|
||||||
|
self._panel.dataset.replace = showReplace ? '1' : '0';
|
||||||
|
self._updateReplaceVisible();
|
||||||
|
const inp = self._panel.querySelector('.me-search-find') as HTMLInputElement;
|
||||||
|
if (inp) { inp.focus(); inp.select(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
|
||||||
|
const panel = document.createElement('div'); panel.className = 'me-search';
|
||||||
|
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>`;
|
||||||
|
editor.el.appendChild(panel); self._panel = panel;
|
||||||
|
self._updateReplaceVisible();
|
||||||
|
const fi = panel.querySelector('.me-search-find') as HTMLInputElement;
|
||||||
|
const ri = panel.querySelector('.me-search-replace') as HTMLInputElement;
|
||||||
|
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; };
|
||||||
|
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 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); };
|
||||||
|
const selectAt = (idx: number) => { editor.textarea.focus(); editor.textarea.setSelectionRange(idx, idx + 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; 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 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(); };
|
||||||
|
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); } });
|
||||||
|
ri.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); replaceOne(); } if (e.key === 'Escape') { e.preventDefault(); this._close!(editor); } });
|
||||||
|
panel.querySelector('.me-search-next')!.addEventListener('click', findNext);
|
||||||
|
panel.querySelector('.me-search-prev')!.addEventListener('click', findPrev);
|
||||||
|
panel.querySelector('.me-search-close')!.addEventListener('click', () => this._close!(editor));
|
||||||
|
panel.querySelector('.me-search-replace-one')!.addEventListener('click', replaceOne);
|
||||||
|
panel.querySelector('.me-search-replace-all')!.addEventListener('click', replaceAll);
|
||||||
|
fi.focus(); fi.select();
|
||||||
|
(this as any)._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); };
|
||||||
|
},
|
||||||
|
_updateReplaceVisible() {
|
||||||
|
const self = this as any;
|
||||||
|
if (!self._panel) return;
|
||||||
|
const show = self._panel.dataset.replace === '1';
|
||||||
|
const row = self._panel.querySelector('.me-search-replace-row') as HTMLElement;
|
||||||
|
if (row) row.style.display = show ? 'flex' : 'none';
|
||||||
|
},
|
||||||
|
_close(editor: any) { if ((this as any)._cleanup) { (this as any)._cleanup(); (this as any)._cleanup = null; } (this as any)._panel = null; if (editor && editor.textarea) editor.textarea.focus(); },
|
||||||
|
destroy(editor: any) { this._close!(editor); if ((this as any)._onKeydown && editor && editor.textarea) editor.textarea.removeEventListener('keydown', (this as any)._onKeydown); (this as any)._onKeydown = null; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const imagePastePlugin: Plugin = {
|
||||||
|
name: 'imagePaste', version: '0.1.0', description: 'Paste image as base64', priority: 60,
|
||||||
|
install(editor) {
|
||||||
|
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
||||||
|
(this as any)._onPaste = (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items; if (!items) return;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type?.startsWith('image/')) { e.preventDefault();
|
||||||
|
const reader = new FileReader(); reader.onload = () => { editor.insert(`\n`); };
|
||||||
|
reader.readAsDataURL(item.getAsFile()!); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
editor.textarea.addEventListener('paste', (this as any)._onPaste);
|
||||||
|
},
|
||||||
|
destroy(editor) { if ((this as any)._onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', (this as any)._onPaste); (this as any)._onPaste = null; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const shortcutHelpPlugin: Plugin = {
|
||||||
|
name: 'shortcutHelp', version: '0.1.0', description: 'Press ? to show shortcuts', priority: 200,
|
||||||
|
install(editor) {
|
||||||
|
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
||||||
|
this._injectStyle!();
|
||||||
|
(this as any)._onKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); this._open!(editor); }
|
||||||
|
if (e.key === 'Escape' && (this as any)._panel) this._close!();
|
||||||
|
};
|
||||||
|
editor.textarea.addEventListener('keydown', (this as any)._onKeydown);
|
||||||
|
},
|
||||||
|
_injectStyle() {
|
||||||
|
if (document.getElementById('me-shortcut-style')) return;
|
||||||
|
const s = document.createElement('style'); s.id = 'me-shortcut-style';
|
||||||
|
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
},
|
||||||
|
_open(editor: any) {
|
||||||
|
if ((this as any)._panel) { this._close!(); return; }
|
||||||
|
const builtin = [['Ctrl+B','粗体'],['Ctrl+I','斜体'],['Ctrl+U','下划线'],['Ctrl+K','链接'],['Ctrl+E','行内代码'],['Ctrl+1/2/3','标题'],['Ctrl+Q','引用'],['Ctrl+Z','撤销'],['Ctrl+Y','重做'],['Ctrl+S','保存'],['Ctrl+F','查找'],['Ctrl+H','替换'],['Tab','缩进'],['Shift+Tab','反缩进'],['?','快捷键帮助']];
|
||||||
|
let rows = ''; builtin.forEach(([c,d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
|
||||||
|
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
|
||||||
|
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ 快捷键</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) this._close!(); });
|
||||||
|
document.body.appendChild(overlay); (this as any)._panel = overlay;
|
||||||
|
},
|
||||||
|
_close() { if ((this as any)._panel) { (this as any)._panel.remove(); (this as any)._panel = null; } },
|
||||||
|
destroy(editor) { this._close!(); if ((this as any)._onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', (this as any)._onKeydown); (this as any)._onKeydown = null; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileSystemPlugin: Plugin = {
|
||||||
|
name: 'fileSystem', version: '0.1.0', description: 'File System Access API', priority: 90,
|
||||||
|
install(editor) {
|
||||||
|
if (!editor || typeof editor.getValue !== 'function') return;
|
||||||
|
const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function';
|
||||||
|
(this as any)._fileHandle = null;
|
||||||
|
editor.openFile = async (opts: any = {}) => {
|
||||||
|
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; }
|
||||||
|
try {
|
||||||
|
const [handle] = await (window as any).showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md','.txt','.markdown'] } }], ...opts });
|
||||||
|
(this as any)._fileHandle = handle; const file = await handle.getFile(); const content = await file.text();
|
||||||
|
editor.setValue(content); editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle };
|
||||||
|
} catch (e: any) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; }
|
||||||
|
};
|
||||||
|
editor.saveFile = async (opts: any = {}) => {
|
||||||
|
let handle = (this as any)._fileHandle;
|
||||||
|
if (!handle || opts.saveAs) {
|
||||||
|
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; }
|
||||||
|
try { handle = await (window as any).showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); (this as any)._fileHandle = handle; }
|
||||||
|
catch (e: any) { if (e.name !== 'AbortError') console.error('Save error:', e); return false; }
|
||||||
|
}
|
||||||
|
try { const w = await handle.createWritable(); await w.write(editor.getValue()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; }
|
||||||
|
catch (e) { (this as any)._fileHandle = null; if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; }
|
||||||
|
};
|
||||||
|
editor.saveFileAs = (name?: string) => editor.saveFile({ saveAs: true, name });
|
||||||
|
editor.getFileHandle = () => (this as any)._fileHandle;
|
||||||
|
},
|
||||||
|
destroy(editor) { (this as any)._fileHandle = null; if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; } },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Preset plugins table ============
|
||||||
|
|
||||||
|
export const presetPlugins: Record<string, Plugin> = {
|
||||||
|
autoSave: autoSavePlugin, exportTool: exportToolPlugin, searchReplace: searchReplacePlugin,
|
||||||
|
imagePaste: imagePastePlugin, shortcutHelp: shortcutHelpPlugin, fileSystem: fileSystemPlugin,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Plugin utils ============
|
||||||
|
|
||||||
|
export const pluginUtils = {
|
||||||
|
createManager: () => new PluginManager(),
|
||||||
|
manager: defaultPluginManager,
|
||||||
|
register: (name: string, plugin: Plugin) => defaultPluginManager.register(name, plugin),
|
||||||
|
unregister: (name: string) => defaultPluginManager.unregister(name),
|
||||||
|
get: (name: string) => defaultPluginManager.get(name),
|
||||||
|
has: (name: string) => defaultPluginManager.has(name),
|
||||||
|
getAll: () => defaultPluginManager.getAll(),
|
||||||
|
getNames: () => defaultPluginManager.getNames(),
|
||||||
|
enable: (name: string) => defaultPluginManager.enable(name),
|
||||||
|
disable: (name: string) => defaultPluginManager.disable(name),
|
||||||
|
isEnabled: (name: string) => defaultPluginManager.isEnabled(name),
|
||||||
|
getPreset: (name: string): Plugin | null => presetPlugins[name] ? { ...presetPlugins[name] } : null,
|
||||||
|
getAllPresets: (): Record<string, Plugin> => Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {} as Record<string, Plugin>),
|
||||||
|
createPlugin: (config: Partial<Plugin> = {}): Plugin => {
|
||||||
|
const result: Plugin = { name: config.name || 'custom', version: config.version || '0.0.0', description: config.description || '', depends: config.depends || [], priority: config.priority || 0, install: () => {}, destroy: () => {}, ...config };
|
||||||
|
if (typeof result.install !== 'function') result.install = () => {};
|
||||||
|
if (typeof result.destroy !== 'function') result.destroy = () => {};
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
validatePlugin: (plugin: any): { valid: boolean; errors: string[] } => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!plugin || typeof plugin !== 'object') errors.push('plugin must be an object');
|
||||||
|
if (plugin && !plugin.name) errors.push('plugin must have a name');
|
||||||
|
if (plugin && plugin.install && typeof plugin.install !== 'function') errors.push('install must be a function');
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
},
|
||||||
|
topologicalSort, validateConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default presetPlugins;
|
||||||
-601
@@ -1,601 +0,0 @@
|
|||||||
/**
|
|
||||||
* MetonaEditor Styles - 编辑器样式
|
|
||||||
* @module styles
|
|
||||||
* @version 0.1.14
|
|
||||||
* @description 编辑器 UI 样式注入与管理
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { THEMES } from './constants.js';
|
|
||||||
|
|
||||||
let styleElement = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成 CSS 样式
|
|
||||||
*/
|
|
||||||
const generateCSS = () => {
|
|
||||||
return `
|
|
||||||
/* ============ CSS 变量默认值(被 themes.js 动态覆盖) ============ */
|
|
||||||
:root {
|
|
||||||
--md-bg: #ffffff;
|
|
||||||
--md-text: #1f2937;
|
|
||||||
--md-border: rgba(0, 0, 0, 0.08);
|
|
||||||
--md-shadow: 0 10px 36px -10px rgba(0,0,0,0.18), 0 4px 14px -4px rgba(0,0,0,0.08);
|
|
||||||
--md-hover-shadow: 0 14px 48px -10px rgba(0,0,0,0.22), 0 6px 18px -4px rgba(0,0,0,0.10);
|
|
||||||
--md-toolbar-bg: rgba(248, 249, 250, 0.92);
|
|
||||||
--md-textarea-bg: #ffffff;
|
|
||||||
--md-preview-bg: #ffffff;
|
|
||||||
--md-code-bg: rgba(243, 244, 246, 1);
|
|
||||||
--md-code-text: #1f2937;
|
|
||||||
--md-accent: #3b82f6;
|
|
||||||
--md-muted: #6b7280;
|
|
||||||
--md-radius: 10px;
|
|
||||||
--md-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
|
|
||||||
--md-mono: "SF Mono", "Cascadia Code", "Consolas", "Liberation Mono", "Courier New", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ 容器 ============ */
|
|
||||||
.me-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid var(--md-border);
|
|
||||||
border-radius: var(--md-radius);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--md-bg);
|
|
||||||
color: var(--md-text);
|
|
||||||
box-shadow: var(--md-shadow);
|
|
||||||
font-family: var(--md-font);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
position: relative;
|
|
||||||
transition: box-shadow 0.25s ease, border-color 0.25s ease;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.me-wrapper:hover { box-shadow: var(--md-hover-shadow); }
|
|
||||||
.me-wrapper.me-disabled { opacity: 0.6; pointer-events: none; }
|
|
||||||
.me-wrapper * { box-sizing: border-box; }
|
|
||||||
.me-wrapper.me-fullscreen {
|
|
||||||
position: fixed;
|
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
|
||||||
width: 100vw; height: 100vh;
|
|
||||||
z-index: 9999;
|
|
||||||
border-radius: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ 工具栏 ============ */
|
|
||||||
.me-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
background: var(--md-toolbar-bg);
|
|
||||||
border-bottom: 1px solid var(--md-border);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
.me-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 30px; height: 30px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--md-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.me-btn:hover { background: var(--md-code-bg); color: var(--md-accent); }
|
|
||||||
.me-btn:active { transform: scale(0.92); }
|
|
||||||
.me-btn.me-active { background: var(--md-accent); color: #fff; }
|
|
||||||
.me-btn:disabled { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
|
|
||||||
.me-btn:focus-visible { outline: 2px solid var(--md-accent); outline-offset: 2px; }
|
|
||||||
.me-btn:focus:not(:focus-visible) { outline: none; }
|
|
||||||
.me-btn svg { width: 17px; height: 17px; display: block; }
|
|
||||||
.me-btn span { font-size: 12px; font-weight: 500; }
|
|
||||||
|
|
||||||
.me-toolbar-sep {
|
|
||||||
display: inline-block;
|
|
||||||
width: 1px; height: 20px;
|
|
||||||
background: var(--md-border);
|
|
||||||
margin: 0 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.me-toolbar-group {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 2px;
|
|
||||||
margin-left: auto;
|
|
||||||
padding-left: 6px;
|
|
||||||
border-left: 1px solid var(--md-border);
|
|
||||||
}
|
|
||||||
.me-toolbar-group .me-btn { width: auto; padding: 0 8px; }
|
|
||||||
.me-toolbar-group .me-btn span { font-size: 12px; }
|
|
||||||
|
|
||||||
/* ============ 主体布局 ============ */
|
|
||||||
.me-body {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.me-editor-pane, .me-preview-pane {
|
|
||||||
flex: 1 1 50%;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ 编辑区内层(v0.1.7) ============ */
|
|
||||||
.me-editor-inner { display: flex; height: 100%; overflow: hidden; position: relative; }
|
|
||||||
.me-gutter {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 36px;
|
|
||||||
padding: 16px 8px 16px 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--md-code-bg);
|
|
||||||
border-right: 1px solid var(--md-border);
|
|
||||||
color: var(--md-muted);
|
|
||||||
font-family: var(--md-mono);
|
|
||||||
font-size: 13.5px;
|
|
||||||
line-height: 1.7;
|
|
||||||
text-align: right;
|
|
||||||
user-select: none;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
.me-gutter-line { padding-right: 4px; }
|
|
||||||
.me-gutter-active {
|
|
||||||
color: var(--md-accent);
|
|
||||||
font-weight: 600;
|
|
||||||
background: rgba(59, 130, 246, 0.08);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.me-divider {
|
|
||||||
flex: 0 0 1px;
|
|
||||||
background: var(--md-border);
|
|
||||||
cursor: col-resize;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.me-divider::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0; bottom: 0; left: -3px; right: -3px;
|
|
||||||
}
|
|
||||||
.me-divider:hover { background: var(--md-accent); }
|
|
||||||
|
|
||||||
/* 编辑区 */
|
|
||||||
.me-textarea {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 16px 18px;
|
|
||||||
border: 0;
|
|
||||||
outline: 0;
|
|
||||||
resize: none;
|
|
||||||
background: var(--md-textarea-bg);
|
|
||||||
color: var(--md-text);
|
|
||||||
font-family: var(--md-mono);
|
|
||||||
font-size: 13.5px;
|
|
||||||
line-height: 1.7;
|
|
||||||
tab-size: 2;
|
|
||||||
-moz-tab-size: 2;
|
|
||||||
display: block;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.me-textarea::placeholder { color: var(--md-muted); opacity: 0.7; }
|
|
||||||
.me-textarea:focus { outline: 0; }
|
|
||||||
|
|
||||||
/* 预览区 */
|
|
||||||
.me-preview-pane { overflow: auto; background: var(--md-preview-bg); }
|
|
||||||
.me-preview {
|
|
||||||
padding: 18px 22px;
|
|
||||||
max-width: 100%;
|
|
||||||
word-wrap: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 模式切换 */
|
|
||||||
.me-body.me-mode-edit .me-preview-pane,
|
|
||||||
.me-body.me-mode-edit .me-divider { display: none; }
|
|
||||||
.me-body.me-mode-edit .me-editor-pane { flex: 1 1 100%; }
|
|
||||||
.me-body.me-mode-preview .me-editor-pane,
|
|
||||||
.me-body.me-mode-preview .me-divider { display: none; }
|
|
||||||
.me-body.me-mode-preview .me-preview-pane { flex: 1 1 100%; }
|
|
||||||
|
|
||||||
/* ============ 预览区排版 ============ */
|
|
||||||
.me-preview > :first-child { margin-top: 0; }
|
|
||||||
.me-preview > :last-child { margin-bottom: 0; }
|
|
||||||
.me-preview h1, .me-preview h2, .me-preview h3,
|
|
||||||
.me-preview h4, .me-preview h5, .me-preview h6 {
|
|
||||||
margin: 1.4em 0 0.6em;
|
|
||||||
font-weight: 650;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.me-preview h1 { font-size: 1.9em; padding-bottom: 0.3em; border-bottom: 1px solid var(--md-border); }
|
|
||||||
.me-preview h2 { font-size: 1.55em; padding-bottom: 0.3em; border-bottom: 1px solid var(--md-border); }
|
|
||||||
.me-preview h3 { font-size: 1.3em; }
|
|
||||||
.me-preview h4 { font-size: 1.12em; }
|
|
||||||
.me-preview h5 { font-size: 1em; }
|
|
||||||
.me-preview h6 { font-size: 0.9em; color: var(--md-muted); }
|
|
||||||
.me-preview p { margin: 0.7em 0; }
|
|
||||||
.me-preview a { color: var(--md-accent); text-decoration: none; }
|
|
||||||
.me-preview a:hover { text-decoration: underline; }
|
|
||||||
.me-preview strong { font-weight: 650; }
|
|
||||||
.me-preview em { font-style: italic; }
|
|
||||||
.me-preview del { text-decoration: line-through; opacity: 0.75; }
|
|
||||||
.me-preview ul, .me-preview ol { margin: 0.6em 0; padding-left: 1.6em; }
|
|
||||||
.me-preview li { margin: 0.25em 0; }
|
|
||||||
.me-preview li.me-task-item { list-style: none; margin-left: -1.4em; }
|
|
||||||
.me-preview li.me-task-item input { margin-right: 0.5em; vertical-align: middle; }
|
|
||||||
.me-preview blockquote {
|
|
||||||
margin: 0.8em 0;
|
|
||||||
padding: 0.4em 1em;
|
|
||||||
border-left: 3px solid var(--md-accent);
|
|
||||||
background: var(--md-code-bg);
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
color: var(--md-text);
|
|
||||||
}
|
|
||||||
.me-preview blockquote > :first-child { margin-top: 0; }
|
|
||||||
.me-preview blockquote > :last-child { margin-bottom: 0; }
|
|
||||||
.me-preview hr {
|
|
||||||
border: 0;
|
|
||||||
height: 1px;
|
|
||||||
background: var(--md-border);
|
|
||||||
margin: 1.6em 0;
|
|
||||||
}
|
|
||||||
.me-preview code {
|
|
||||||
font-family: var(--md-mono);
|
|
||||||
font-size: 0.88em;
|
|
||||||
padding: 0.15em 0.4em;
|
|
||||||
background: var(--md-code-bg);
|
|
||||||
color: var(--md-code-text);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.me-preview pre {
|
|
||||||
margin: 0.9em 0;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: var(--md-code-bg);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow-x: auto;
|
|
||||||
border: 1px solid var(--md-border);
|
|
||||||
}
|
|
||||||
.me-preview pre code {
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--md-code-text);
|
|
||||||
font-size: 0.9em;
|
|
||||||
line-height: 1.6;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
.me-preview img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 6px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.me-table-wrap { overflow-x: auto; margin: 0.9em 0; }
|
|
||||||
.me-preview table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 0.93em;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.me-preview th, .me-preview td {
|
|
||||||
border: 1px solid var(--md-border);
|
|
||||||
padding: 7px 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.me-preview th {
|
|
||||||
background: var(--md-code-bg);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.me-preview tr:nth-child(even) td { background: var(--md-code-bg); }
|
|
||||||
.me-preview tr:nth-child(even) td { background-color: color-mix(in srgb, var(--md-code-bg) 40%, transparent); }
|
|
||||||
|
|
||||||
/* ============ 状态栏 ============ */
|
|
||||||
.me-statusbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-top: 1px solid var(--md-border);
|
|
||||||
background: var(--md-toolbar-bg);
|
|
||||||
color: var(--md-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
min-height: 26px;
|
|
||||||
}
|
|
||||||
.me-statusbar:empty { display: none; }
|
|
||||||
|
|
||||||
/* ============ 响应式 ============ */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.me-body.me-mode-split {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.me-body.me-mode-split .me-divider {
|
|
||||||
flex: 0 0 1px;
|
|
||||||
width: 100%;
|
|
||||||
height: 1px;
|
|
||||||
cursor: row-resize;
|
|
||||||
}
|
|
||||||
.me-body.me-mode-split .me-divider::after {
|
|
||||||
top: -3px; bottom: -3px; left: 0; right: 0;
|
|
||||||
}
|
|
||||||
.me-body.me-mode-split .me-editor-pane,
|
|
||||||
.me-body.me-mode-split .me-preview-pane {
|
|
||||||
flex: 1 1 50%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.me-toolbar { padding: 4px; }
|
|
||||||
.me-btn { width: 28px; height: 28px; }
|
|
||||||
.me-preview { padding: 14px; }
|
|
||||||
.me-textarea { padding: 12px; font-size: 13px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ 无障碍 ============ */
|
|
||||||
.me-wrapper:focus-within {
|
|
||||||
border-color: var(--md-accent);
|
|
||||||
}
|
|
||||||
.me-textarea:focus-visible { outline: 0; }
|
|
||||||
|
|
||||||
/* ============ 动画 ============ */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.me-wrapper, .me-btn, .me-textarea { transition: none !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ 全屏模式优化 ============ */
|
|
||||||
.me-wrapper.me-fullscreen .me-preview,
|
|
||||||
.me-wrapper.me-fullscreen .me-textarea { font-size: 15px; }
|
|
||||||
|
|
||||||
/* ============ 只读模式 ============ */
|
|
||||||
.me-wrapper.me-readonly .me-textarea {
|
|
||||||
background: var(--md-code-bg);
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
.me-wrapper.me-readonly .me-toolbar {
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
/* readonly 模式下编辑按钮禁用样式(JS 设置 disabled 属性触发) */
|
|
||||||
.me-wrapper.me-readonly .me-btn:disabled {
|
|
||||||
opacity: 0.3; cursor: not-allowed; pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ 高亮标记 ============ */
|
|
||||||
.me-preview mark {
|
|
||||||
background: rgba(250, 204, 21, 0.3);
|
|
||||||
color: inherit;
|
|
||||||
padding: 0.1em 0.2em;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ 上标/下标 ============ */
|
|
||||||
.me-preview sup { font-size: 0.75em; vertical-align: super; line-height: 1; }
|
|
||||||
.me-preview sub { font-size: 0.75em; vertical-align: sub; line-height: 1; }
|
|
||||||
|
|
||||||
/* ============ 数学公式 ============ */
|
|
||||||
.me-preview .me-math-block {
|
|
||||||
display: block;
|
|
||||||
margin: 1.2em 0;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--md-code-bg);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-family: var(--md-mono);
|
|
||||||
font-size: 0.95em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.me-preview .me-math-inline {
|
|
||||||
font-family: var(--md-mono);
|
|
||||||
font-size: 0.95em;
|
|
||||||
padding: 0.05em 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ 定义列表 ============ */
|
|
||||||
.me-preview dl { margin: 0.8em 0; }
|
|
||||||
.me-preview dt { font-weight: 650; margin: 0.6em 0 0.2em; }
|
|
||||||
.me-preview dd { margin: 0 0 0.3em 1.6em; color: var(--md-text); }
|
|
||||||
|
|
||||||
/* ============ 脚注 ============ */
|
|
||||||
.me-preview .me-footnote-ref a {
|
|
||||||
font-size: 0.75em;
|
|
||||||
vertical-align: super;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--md-accent);
|
|
||||||
}
|
|
||||||
.me-preview .me-footnotes {
|
|
||||||
margin-top: 2em;
|
|
||||||
border-top: 1px solid var(--md-border);
|
|
||||||
padding-top: 0.8em;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--md-muted);
|
|
||||||
}
|
|
||||||
.me-preview .me-footnotes hr { display: none; }
|
|
||||||
.me-preview .me-footnotes ol { padding-left: 1.2em; }
|
|
||||||
.me-preview .me-footnote-item { margin: 0.3em 0; }
|
|
||||||
.me-preview .me-footnote-backref {
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--md-accent);
|
|
||||||
margin-right: 0.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Toast 通知(v0.1.6) ============ */
|
|
||||||
.me-toast {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 16px;
|
|
||||||
right: 16px;
|
|
||||||
z-index: 30;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
pointer-events: auto;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(12px);
|
|
||||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
||||||
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
|
|
||||||
max-width: 360px;
|
|
||||||
}
|
|
||||||
.me-toast.me-toast-enter { opacity: 1; transform: translateY(0); }
|
|
||||||
.me-toast.me-toast-leave { opacity: 0; transform: translateY(12px); }
|
|
||||||
.me-toast-success { background: #10b981; color: #fff; }
|
|
||||||
.me-toast-error { background: #ef4444; color: #fff; }
|
|
||||||
.me-toast-warning { background: #f59e0b; color: #fff; }
|
|
||||||
.me-toast-info { background: var(--md-accent, #3b82f6); color: #fff; }
|
|
||||||
.me-toast-icon { font-weight: 700; font-size: 16px; flex-shrink: 0; }
|
|
||||||
.me-toast-msg { line-height: 1.4; }
|
|
||||||
|
|
||||||
/* ============ 右键菜单(v0.1.6) ============ */
|
|
||||||
.me-context-menu {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 40;
|
|
||||||
min-width: 180px;
|
|
||||||
padding: 4px 0;
|
|
||||||
background: var(--md-bg);
|
|
||||||
border: 1px solid var(--md-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 8px 28px rgba(0,0,0,0.18);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.me-context-menu-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 6px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--md-text);
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.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-shortcut {
|
|
||||||
color: var(--md-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
margin-left: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ 大纲面板(v0.1.7) ============ */
|
|
||||||
.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 ul { list-style: none; padding: 0; margin: 0; }
|
|
||||||
.me-outline li { margin: 2px 0; }
|
|
||||||
.me-outline a {
|
|
||||||
color: var(--md-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
display: block;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background 0.1s, color 0.1s;
|
|
||||||
}
|
|
||||||
.me-outline a:hover { background: var(--md-code-bg); color: var(--md-accent); }
|
|
||||||
.me-outline-l1 a { font-weight: 600; color: var(--md-text); }
|
|
||||||
.me-outline-l2 a { padding-left: 12px; }
|
|
||||||
.me-outline-l3 a { padding-left: 20px; font-size: 12px; }
|
|
||||||
.me-outline-l4 a { padding-left: 28px; font-size: 12px; }
|
|
||||||
|
|
||||||
/* ============ Zen 模式(v0.1.12) ============ */
|
|
||||||
.me-wrapper.me-zen .me-body { max-width: 820px; margin: 0 auto; }
|
|
||||||
.me-wrapper.me-zen .me-textarea { font-size: 15px; line-height: 1.8; }
|
|
||||||
|
|
||||||
/* ============ 屏幕阅读器专用(v0.1.12) ============ */
|
|
||||||
.me-sr-only {
|
|
||||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
||||||
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ 打印样式 ============ */
|
|
||||||
@media print {
|
|
||||||
.me-wrapper { border: 0 !important; box-shadow: none !important; }
|
|
||||||
.me-toolbar, .me-statusbar, .me-divider { display: none !important; }
|
|
||||||
.me-body.me-mode-split .me-editor-pane { display: none; }
|
|
||||||
.me-body.me-mode-split .me-preview-pane { flex: 1 1 100% !important; }
|
|
||||||
.me-preview { padding: 0 !important; font-size: 11pt; }
|
|
||||||
.me-wrapper.me-fullscreen { position: static !important; width: auto !important; height: auto !important; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注入样式(单例)
|
|
||||||
*/
|
|
||||||
export const injectStyles = () => {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
if (styleElement && document.getElementById('metona-editor-styles')) return;
|
|
||||||
|
|
||||||
const css = generateCSS();
|
|
||||||
styleElement = document.createElement('style');
|
|
||||||
styleElement.id = 'metona-editor-styles';
|
|
||||||
styleElement.textContent = css.replace(/\s+/g, ' ');
|
|
||||||
document.head.appendChild(styleElement);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新样式
|
|
||||||
*/
|
|
||||||
export const updateStyles = () => {
|
|
||||||
if (!styleElement) { injectStyles(); return; }
|
|
||||||
styleElement.textContent = generateCSS().replace(/\s+/g, ' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除样式
|
|
||||||
*/
|
|
||||||
export const removeStyles = () => {
|
|
||||||
if (styleElement && styleElement.parentNode) {
|
|
||||||
styleElement.parentNode.removeChild(styleElement);
|
|
||||||
}
|
|
||||||
styleElement = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取系统主题
|
|
||||||
*/
|
|
||||||
export const getSystemTheme = () => {
|
|
||||||
if (typeof window === 'undefined') return 'light';
|
|
||||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听系统主题变化
|
|
||||||
*/
|
|
||||||
export const watchSystemTheme = (callback) => {
|
|
||||||
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
const handler = (e) => callback(e.matches ? 'dark' : 'light');
|
|
||||||
mediaQuery.addEventListener('change', handler);
|
|
||||||
return () => mediaQuery.removeEventListener('change', handler);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { THEMES };
|
|
||||||
export default { injectStyles, updateStyles, removeStyles, getSystemTheme, watchSystemTheme };
|
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Styles — CSS-in-JS injection
|
||||||
|
* @module styles
|
||||||
|
* @version 0.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
let styleElement: HTMLStyleElement | null = null;
|
||||||
|
|
||||||
|
const generateCSS = (): string => {
|
||||||
|
return `:root{--md-bg:#ffffff;--md-text:#1f2937;--md-border:rgba(0,0,0,0.08);--md-shadow:0 10px 36px -10px rgba(0,0,0,0.18),0 4px 14px -4px rgba(0,0,0,0.08);--md-hover-shadow:0 14px 48px -10px rgba(0,0,0,0.22),0 6px 18px -4px rgba(0,0,0,0.10);--md-toolbar-bg:rgba(248,249,250,0.92);--md-textarea-bg:#ffffff;--md-preview-bg:#ffffff;--md-code-bg:rgba(243,244,246,1);--md-code-text:#1f2937;--md-accent:#3b82f6;--md-muted:#6b7280;--md-radius:10px;--md-font:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei","Hiragino Sans GB",sans-serif;--md-mono:"SF Mono","Cascadia Code","Consolas","Liberation Mono","Courier New",monospace}
|
||||||
|
.me-wrapper{display:flex;flex-direction:column;box-sizing:border-box;border:1px solid var(--md-border);border-radius:var(--md-radius);overflow:hidden;background:var(--md-bg);color:var(--md-text);box-shadow:var(--md-shadow);font-family:var(--md-font);font-size:14px;line-height:1.6;position:relative;transition:box-shadow .25s,border-color .25s;width:100%}
|
||||||
|
.me-wrapper:hover{box-shadow:var(--md-hover-shadow)}
|
||||||
|
.me-wrapper.me-disabled{opacity:.6;pointer-events:none}
|
||||||
|
.me-wrapper *{box-sizing:border-box}
|
||||||
|
.me-wrapper.me-fullscreen{position:fixed;top:0;left:0;right:0;bottom:0;width:100vw;height:100vh;z-index:9999;border-radius:0;border:0}
|
||||||
|
.me-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:6px 8px;background:var(--md-toolbar-bg);border-bottom:1px solid var(--md-border);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);min-height:40px}
|
||||||
|
.me-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--md-text);cursor:pointer;transition:background .15s,color .15s,transform .1s;flex-shrink:0}
|
||||||
|
.me-btn:hover{background:var(--md-code-bg);color:var(--md-accent)}
|
||||||
|
.me-btn:active{transform:scale(.92)}
|
||||||
|
.me-btn.me-active{background:var(--md-accent);color:#fff}
|
||||||
|
.me-btn:disabled{opacity:.35;cursor:not-allowed;pointer-events:none}
|
||||||
|
.me-btn:focus-visible{outline:2px solid var(--md-accent);outline-offset:2px}
|
||||||
|
.me-btn:focus:not(:focus-visible){outline:none}
|
||||||
|
.me-btn svg{width:17px;height:17px;display:block}
|
||||||
|
.me-btn span{font-size:12px;font-weight:500}
|
||||||
|
.me-toolbar-sep{display:inline-block;width:1px;height:20px;background:var(--md-border);margin:0 4px;flex-shrink:0}
|
||||||
|
.me-toolbar-group{display:inline-flex;gap:2px;margin-left:auto;padding-left:6px;border-left:1px solid var(--md-border)}
|
||||||
|
.me-toolbar-group .me-btn{width:auto;padding:0 8px}
|
||||||
|
.me-body{display:flex;flex:1;min-height:0;position:relative}
|
||||||
|
.me-editor-pane,.me-preview-pane{flex:1 1 50%;min-width:0;overflow:hidden;position:relative}
|
||||||
|
.me-editor-inner{display:flex;height:100%;overflow:hidden;position:relative}
|
||||||
|
.me-gutter{flex:0 0 auto;min-width:36px;padding:16px 8px 16px 6px;overflow:hidden;background:var(--md-code-bg);border-right:1px solid var(--md-border);color:var(--md-muted);font-family:var(--md-mono);font-size:13.5px;line-height:1.7;text-align:right;user-select:none;white-space:pre}
|
||||||
|
.me-gutter-line{padding-right:4px}
|
||||||
|
.me-gutter-active{color:var(--md-accent);font-weight:600;background:rgba(59,130,246,.08);border-radius:3px}
|
||||||
|
.me-divider{flex:0 0 1px;background:var(--md-border);cursor:col-resize;position:relative}
|
||||||
|
.me-divider::after{content:'';position:absolute;top:0;bottom:0;left:-3px;right:-3px}
|
||||||
|
.me-divider:hover{background:var(--md-accent)}
|
||||||
|
.me-textarea{width:100%;height:100%;padding:16px 18px;border:0;outline:0;resize:none;background:var(--md-textarea-bg);color:var(--md-text);font-family:var(--md-mono);font-size:13.5px;line-height:1.7;display:block;white-space:pre-wrap;word-wrap:break-word;position:relative;z-index:1}
|
||||||
|
.me-textarea::placeholder{color:var(--md-muted);opacity:.7}
|
||||||
|
.me-textarea:focus{outline:0}
|
||||||
|
.me-preview-pane{overflow:auto;background:var(--md-preview-bg)}
|
||||||
|
.me-preview{padding:18px 22px;max-width:100%;word-wrap:break-word;overflow-wrap:break-word}
|
||||||
|
.me-body.me-mode-edit .me-preview-pane,.me-body.me-mode-edit .me-divider{display:none}
|
||||||
|
.me-body.me-mode-edit .me-editor-pane{flex:1 1 100%}
|
||||||
|
.me-body.me-mode-preview .me-editor-pane,.me-body.me-mode-preview .me-divider{display:none}
|
||||||
|
.me-body.me-mode-preview .me-preview-pane{flex:1 1 100%}
|
||||||
|
.me-preview>:first-child{margin-top:0}
|
||||||
|
.me-preview>:last-child{margin-bottom:0}
|
||||||
|
.me-preview h1,.me-preview h2,.me-preview h3,.me-preview h4,.me-preview h5,.me-preview h6{margin:1.4em 0 .6em;font-weight:650;line-height:1.3}
|
||||||
|
.me-preview h1{font-size:1.9em;padding-bottom:.3em;border-bottom:1px solid var(--md-border)}
|
||||||
|
.me-preview h2{font-size:1.55em;padding-bottom:.3em;border-bottom:1px solid var(--md-border)}
|
||||||
|
.me-preview h3{font-size:1.3em}
|
||||||
|
.me-preview h4{font-size:1.12em}
|
||||||
|
.me-preview h5{font-size:1em}
|
||||||
|
.me-preview h6{font-size:.9em;color:var(--md-muted)}
|
||||||
|
.me-preview p{margin:.7em 0}
|
||||||
|
.me-preview a{color:var(--md-accent);text-decoration:none}
|
||||||
|
.me-preview a:hover{text-decoration:underline}
|
||||||
|
.me-preview strong{font-weight:650}
|
||||||
|
.me-preview em{font-style:italic}
|
||||||
|
.me-preview del{text-decoration:line-through;opacity:.75}
|
||||||
|
.me-preview ul,.me-preview ol{margin:.6em 0;padding-left:1.6em}
|
||||||
|
.me-preview li{margin:.25em 0}
|
||||||
|
.me-preview li.me-task-item{list-style:none;margin-left:-1.4em}
|
||||||
|
.me-preview li.me-task-item input{margin-right:.5em;vertical-align:middle}
|
||||||
|
.me-preview blockquote{margin:.8em 0;padding:.4em 1em;border-left:3px solid var(--md-accent);background:var(--md-code-bg);border-radius:0 6px 6px 0;color:var(--md-text)}
|
||||||
|
.me-preview blockquote>:first-child{margin-top:0}
|
||||||
|
.me-preview blockquote>:last-child{margin-bottom:0}
|
||||||
|
.me-preview hr{border:0;height:1px;background:var(--md-border);margin:1.6em 0}
|
||||||
|
.me-preview code{font-family:var(--md-mono);font-size:.88em;padding:.15em .4em;background:var(--md-code-bg);color:var(--md-code-text);border-radius:4px}
|
||||||
|
.me-preview pre{margin:.9em 0;padding:14px 16px;background:var(--md-code-bg);border-radius:8px;overflow-x:auto;border:1px solid var(--md-border)}
|
||||||
|
.me-preview pre code{padding:0;background:transparent;color:var(--md-code-text);font-size:.9em;line-height:1.6;border-radius:0}
|
||||||
|
.me-preview img{max-width:100%;height:auto;border-radius:6px;vertical-align:middle}
|
||||||
|
.me-table-wrap{overflow-x:auto;margin:.9em 0}
|
||||||
|
.me-preview table{border-collapse:collapse;width:100%;font-size:.93em;display:block}
|
||||||
|
.me-preview th,.me-preview td{border:1px solid var(--md-border);padding:7px 12px;text-align:left}
|
||||||
|
.me-preview th{background:var(--md-code-bg);font-weight:600}
|
||||||
|
.me-preview tr:nth-child(even) td{background:var(--md-code-bg)}
|
||||||
|
.me-statusbar{display:flex;align-items:center;justify-content:flex-end;gap:14px;padding:4px 12px;border-top:1px solid var(--md-border);background:var(--md-toolbar-bg);color:var(--md-muted);font-size:12px;min-height:26px}
|
||||||
|
.me-statusbar:empty{display:none}
|
||||||
|
.me-wrapper:focus-within{border-color:var(--md-accent)}
|
||||||
|
.me-textarea:focus-visible{outline:0}
|
||||||
|
@media(prefers-reduced-motion:reduce){.me-wrapper,.me-btn,.me-textarea{transition:none!important}}
|
||||||
|
.me-wrapper.me-fullscreen .me-preview,.me-wrapper.me-fullscreen .me-textarea{font-size:15px}
|
||||||
|
.me-wrapper.me-readonly .me-textarea{background:var(--md-code-bg);cursor:default;opacity:.88}
|
||||||
|
.me-wrapper.me-readonly .me-toolbar{opacity:.75}
|
||||||
|
.me-wrapper.me-readonly .me-btn:disabled{opacity:.3;cursor:not-allowed;pointer-events:none}
|
||||||
|
.me-preview mark{background:rgba(250,204,21,.3);color:inherit;padding:.1em .2em;border-radius:3px}
|
||||||
|
.me-preview sup{font-size:.75em;vertical-align:super;line-height:1}
|
||||||
|
.me-preview sub{font-size:.75em;vertical-align:sub;line-height:1}
|
||||||
|
.me-preview .me-math-block{display:block;margin:1.2em 0;padding:12px 16px;background:var(--md-code-bg);border-radius:8px;overflow-x:auto;font-family:var(--md-mono);font-size:.95em;text-align:center}
|
||||||
|
.me-preview .me-math-inline{font-family:var(--md-mono);font-size:.95em;padding:.05em .2em}
|
||||||
|
.me-preview dl{margin:.8em 0}
|
||||||
|
.me-preview dt{font-weight:650;margin:.6em 0 .2em}
|
||||||
|
.me-preview dd{margin:0 0 .3em 1.6em;color:var(--md-text)}
|
||||||
|
.me-preview .me-footnote-ref a{font-size:.75em;vertical-align:super;text-decoration:none;color:var(--md-accent)}
|
||||||
|
.me-preview .me-footnotes{margin-top:2em;border-top:1px solid var(--md-border);padding-top:.8em;font-size:.9em;color:var(--md-muted)}
|
||||||
|
.me-preview .me-footnotes hr{display:none}
|
||||||
|
.me-preview .me-footnotes ol{padding-left:1.2em}
|
||||||
|
.me-preview .me-footnote-item{margin:.3em 0}
|
||||||
|
.me-preview .me-footnote-backref{text-decoration:none;color:var(--md-accent);margin-right:.4em}
|
||||||
|
.me-toast{position:absolute;bottom:16px;right:16px;z-index:30;display:flex;align-items:center;gap:8px;padding:10px 16px;border-radius:8px;font-size:13px;pointer-events:auto;cursor:pointer;opacity:0;transform:translateY(12px);transition:opacity .3s,transform .3s;box-shadow:0 6px 20px rgba(0,0,0,.15);max-width:360px}
|
||||||
|
.me-toast.me-toast-enter{opacity:1;transform:translateY(0)}
|
||||||
|
.me-toast.me-toast-leave{opacity:0;transform:translateY(12px)}
|
||||||
|
.me-toast-success{background:#10b981;color:#fff}
|
||||||
|
.me-toast-error{background:#ef4444;color:#fff}
|
||||||
|
.me-toast-warning{background:#f59e0b;color:#fff}
|
||||||
|
.me-toast-info{background:var(--md-accent,#3b82f6);color:#fff}
|
||||||
|
.me-toast-icon{font-weight:700;font-size:16px;flex-shrink:0}
|
||||||
|
.me-toast-msg{line-height:1.4}
|
||||||
|
.me-context-menu{position:fixed;z-index:40;min-width:180px;padding:4px 0;background:var(--md-bg);border:1px solid var(--md-border);border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.18);font-size:13px}
|
||||||
|
.me-context-menu-item{display:flex;align-items:center;justify-content:space-between;padding:6px 14px;cursor:pointer;color:var(--md-text);transition:background .1s}
|
||||||
|
.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-shortcut{color:var(--md-muted);font-size:11px;margin-left:24px}
|
||||||
|
.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 ul{list-style:none;padding:0;margin:0}
|
||||||
|
.me-outline li{margin:2px 0}
|
||||||
|
.me-outline a{color:var(--md-muted);text-decoration:none;display:block;padding:2px 6px;border-radius:4px;transition:background .1s,color .1s}
|
||||||
|
.me-outline a:hover{background:var(--md-code-bg);color:var(--md-accent)}
|
||||||
|
.me-outline-l1 a{font-weight:600;color:var(--md-text)}
|
||||||
|
.me-outline-l2 a{padding-left:12px}
|
||||||
|
.me-outline-l3 a{padding-left:20px;font-size:12px}
|
||||||
|
.me-outline-l4 a{padding-left:28px;font-size:12px}
|
||||||
|
.me-wrapper.me-zen .me-body{max-width:820px;margin:0 auto}
|
||||||
|
.me-wrapper.me-zen .me-textarea{font-size:15px;line-height:1.8}
|
||||||
|
.me-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||||||
|
@media print{.me-wrapper{border:0!important;box-shadow:none!important}
|
||||||
|
.me-toolbar,.me-statusbar,.me-divider{display:none!important}
|
||||||
|
.me-body.me-mode-split .me-editor-pane{display:none}
|
||||||
|
.me-body.me-mode-split .me-preview-pane{flex:1 1 100%!important}
|
||||||
|
.me-preview{padding:0!important;font-size:11pt}
|
||||||
|
.me-wrapper.me-fullscreen{position:static!important;width:auto!important;height:auto!important}}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const injectStyles = (): void => {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
if (styleElement && document.getElementById('metona-editor-styles')) return;
|
||||||
|
const css = generateCSS();
|
||||||
|
styleElement = document.createElement('style');
|
||||||
|
styleElement.id = 'metona-editor-styles';
|
||||||
|
styleElement.textContent = css;
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateStyles = (): void => {
|
||||||
|
if (!styleElement) { injectStyles(); return; }
|
||||||
|
styleElement.textContent = generateCSS();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeStyles = (): void => {
|
||||||
|
if (styleElement && styleElement.parentNode) {
|
||||||
|
styleElement.parentNode.removeChild(styleElement);
|
||||||
|
}
|
||||||
|
styleElement = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSystemTheme = (): 'light' | 'dark' => {
|
||||||
|
if (typeof window === 'undefined') return 'light';
|
||||||
|
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const watchSystemTheme = (callback: (theme: 'light' | 'dark') => void): () => void => {
|
||||||
|
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = (e: MediaQueryListEvent) => callback(e.matches ? 'dark' : 'light');
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default { injectStyles, updateStyles, removeStyles, getSystemTheme, watchSystemTheme };
|
||||||
-739
@@ -1,739 +0,0 @@
|
|||||||
/**
|
|
||||||
* MetonaEditor Themes — 主题系统(增强版)
|
|
||||||
* @module themes
|
|
||||||
* @version 0.1.14
|
|
||||||
* @description 全局+实例级主题管理、CSS 变量隔离、外部主题跟随、主题继承
|
|
||||||
*
|
|
||||||
* v0.1.5 增强:
|
|
||||||
* - 修复 resolveTheme('auto') 始终解析为系统主题
|
|
||||||
* - 实例级 setTheme() / getTheme(),多实例独立主题
|
|
||||||
* - 实例级 themeChange 事件通知
|
|
||||||
* - 外部主题跟随:syncWithElement() / adoptFromParent() / watch()
|
|
||||||
* - 主题继承:registerTheme(name, { extends: 'light', ... })
|
|
||||||
* - 导出 CSS 变量:exportCSSVars() / getCSSVariable()
|
|
||||||
* - 主题悬停/聚焦状态变量支持
|
|
||||||
* - 应用主题到指定元素(不污染 :root)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { THEMES } from './constants.js';
|
|
||||||
import { prefersDark } from './utils.js';
|
|
||||||
|
|
||||||
// ============ 模块级状态(全局默认,向后兼容) ============
|
|
||||||
|
|
||||||
let globalCurrentTheme = 'auto';
|
|
||||||
let globalThemeListeners = new Set();
|
|
||||||
let systemThemeMediaQuery = null;
|
|
||||||
let systemThemeListener = null;
|
|
||||||
|
|
||||||
// ============ 系统主题 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取系统主题偏好
|
|
||||||
* @returns {'light'|'dark'}
|
|
||||||
*/
|
|
||||||
const getSystemTheme = () => {
|
|
||||||
if (typeof window === 'undefined') return 'light';
|
|
||||||
return prefersDark() ? 'dark' : 'light';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听系统主题变化(内部回调版)
|
|
||||||
* @param {(theme: 'light'|'dark') => void} callback
|
|
||||||
* @returns {() => void} 取消监听函数
|
|
||||||
*/
|
|
||||||
const _watchSystem = (callback) => {
|
|
||||||
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
|
||||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
const handler = (e) => callback(e.matches ? 'dark' : 'light');
|
|
||||||
mql.addEventListener('change', handler);
|
|
||||||
return () => mql.removeEventListener('change', handler);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听系统主题变化(全局单例模式,向后兼容)
|
|
||||||
* 自动在 auto 模式下跟随系统主题切换
|
|
||||||
*/
|
|
||||||
const watchSystemTheme = () => {
|
|
||||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
|
||||||
|
|
||||||
// 清理旧监听
|
|
||||||
if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; }
|
|
||||||
|
|
||||||
_sysWatchUnsub = _watchSystem((sysTheme) => {
|
|
||||||
if (globalCurrentTheme === 'auto') {
|
|
||||||
applyTheme('auto');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 主题解析 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析主题名为实际主题值
|
|
||||||
* 修复:auto 始终解析为系统当前主题,不再被上次手动设置污染
|
|
||||||
* @param {string} theme - 主题名
|
|
||||||
* @returns {string} 解析后的主题名
|
|
||||||
*/
|
|
||||||
const resolveTheme = (theme) => {
|
|
||||||
if (!theme || theme === 'auto') {
|
|
||||||
return getSystemTheme();
|
|
||||||
}
|
|
||||||
return theme;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取主题配置对象
|
|
||||||
* @param {string} theme - 主题名
|
|
||||||
* @returns {Object} 主题配置
|
|
||||||
*/
|
|
||||||
const getThemeConfig = (theme) => {
|
|
||||||
const resolved = resolveTheme(theme);
|
|
||||||
return THEMES[resolved] || THEMES.light;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ CSS 变量操作 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将主题配置写入 CSS 变量
|
|
||||||
* @param {Object} config - 主题配置对象
|
|
||||||
* @param {HTMLElement} [target] - 目标元素(默认 documentElement)
|
|
||||||
*/
|
|
||||||
const setThemeVariables = (config, target) => {
|
|
||||||
if (typeof document === 'undefined' || !config || typeof config !== 'object') return;
|
|
||||||
|
|
||||||
const root = target || document.documentElement;
|
|
||||||
const vars = {
|
|
||||||
'--md-bg': config.bg,
|
|
||||||
'--md-text': config.text,
|
|
||||||
'--md-border': config.border,
|
|
||||||
'--md-shadow': config.shadow,
|
|
||||||
'--md-hover-shadow': config.hoverShadow,
|
|
||||||
'--md-toolbar-bg': config.toolbarBg || config.bg,
|
|
||||||
'--md-textarea-bg': config.textareaBg || config.bg,
|
|
||||||
'--md-preview-bg': config.previewBg || config.bg,
|
|
||||||
'--md-code-bg': config.codeBg || 'rgba(127,127,127,0.1)',
|
|
||||||
'--md-code-text': config.codeText || config.text,
|
|
||||||
'--md-accent': config.accent || '#3b82f6',
|
|
||||||
'--md-muted': config.muted || '#9ca3af',
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.entries(vars).forEach(([k, v]) => {
|
|
||||||
if (v !== undefined && v !== null) {
|
|
||||||
root.style.setProperty(k, String(v));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 兼容旧字段
|
|
||||||
if (config.progressBg) root.style.setProperty('--md-progress-bg', config.progressBg);
|
|
||||||
if (config.closeHoverBg) root.style.setProperty('--md-close-hover-bg', config.closeHoverBg);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出当前 CSS 变量值(从指定元素读取)
|
|
||||||
* @param {HTMLElement} [el] - 读取的元素(默认 documentElement)
|
|
||||||
* @returns {Object} CSS 变量键值对
|
|
||||||
*/
|
|
||||||
const exportCSSVars = (el) => {
|
|
||||||
const root = el || document.documentElement;
|
|
||||||
if (typeof document === 'undefined') return {};
|
|
||||||
const style = getComputedStyle(root);
|
|
||||||
const varNames = [
|
|
||||||
'--md-bg', '--md-text', '--md-border', '--md-shadow',
|
|
||||||
'--md-hover-shadow', '--md-toolbar-bg', '--md-textarea-bg',
|
|
||||||
'--md-preview-bg', '--md-code-bg', '--md-code-text',
|
|
||||||
'--md-accent', '--md-muted',
|
|
||||||
];
|
|
||||||
const result = {};
|
|
||||||
varNames.forEach((name) => {
|
|
||||||
const val = style.getPropertyValue(name).trim();
|
|
||||||
if (val) result[name] = val;
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取单个 CSS 变量值
|
|
||||||
* @param {string} name - 变量名(自动补全 --md- 前缀)
|
|
||||||
* @param {HTMLElement} [el] - 读取的元素
|
|
||||||
* @returns {string} 变量值
|
|
||||||
*/
|
|
||||||
const getCSSVariable = (name, el) => {
|
|
||||||
const root = el || document.documentElement;
|
|
||||||
if (typeof document === 'undefined') return '';
|
|
||||||
const fullName = name.startsWith('--') ? name : `--md-${name}`;
|
|
||||||
return getComputedStyle(root).getPropertyValue(fullName).trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 主题切换与应用 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用主题到全局(向后兼容)
|
|
||||||
* @param {string} theme - 主题名
|
|
||||||
*/
|
|
||||||
const applyTheme = (theme) => {
|
|
||||||
globalCurrentTheme = theme;
|
|
||||||
const resolved = resolveTheme(theme);
|
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
document.documentElement.setAttribute('data-md-theme', resolved);
|
|
||||||
document.documentElement.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm');
|
|
||||||
document.documentElement.classList.add(`md-theme-${resolved}`);
|
|
||||||
|
|
||||||
const config = getThemeConfig(theme);
|
|
||||||
setThemeVariables(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyGlobalListeners(theme, resolved);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用主题到指定元素(不改变全局状态)
|
|
||||||
* @param {string} theme - 主题名
|
|
||||||
* @param {HTMLElement} target - 目标 DOM 元素
|
|
||||||
*/
|
|
||||||
const applyThemeToElement = (theme, target) => {
|
|
||||||
if (!target || typeof document === 'undefined') return;
|
|
||||||
const resolved = resolveTheme(theme);
|
|
||||||
const config = getThemeConfig(theme);
|
|
||||||
|
|
||||||
target.setAttribute('data-md-theme', resolved);
|
|
||||||
target.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm');
|
|
||||||
if (resolved !== 'auto') {
|
|
||||||
target.classList.add(`md-theme-${resolved}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setThemeVariables(config, target);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换主题并持久化
|
|
||||||
*/
|
|
||||||
const switchTheme = (theme) => {
|
|
||||||
applyTheme(theme);
|
|
||||||
saveTheme(theme);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在 light/dark 间切换
|
|
||||||
*/
|
|
||||||
const toggleTheme = () => {
|
|
||||||
const resolved = getResolvedTheme();
|
|
||||||
switchTheme(resolved === 'dark' ? 'light' : 'dark');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置为自动主题
|
|
||||||
*/
|
|
||||||
const resetToAuto = () => {
|
|
||||||
switchTheme('auto');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取全局当前主题名
|
|
||||||
*/
|
|
||||||
const getCurrentTheme = () => globalCurrentTheme;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取解析后的全局主题
|
|
||||||
*/
|
|
||||||
const getResolvedTheme = () => resolveTheme(globalCurrentTheme);
|
|
||||||
|
|
||||||
// ============ 主题持久化 ============
|
|
||||||
|
|
||||||
const saveTheme = (theme) => {
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
|
||||||
try { localStorage.setItem('metona-editor-theme', theme); } catch (_) {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadTheme = () => {
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
|
||||||
try { return localStorage.getItem('metona-editor-theme') || 'auto'; } catch (_) {}
|
|
||||||
}
|
|
||||||
return 'auto';
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 全局监听 ============
|
|
||||||
|
|
||||||
const notifyGlobalListeners = (theme, resolved) => {
|
|
||||||
globalThemeListeners.forEach((fn) => {
|
|
||||||
try { fn(theme, resolved); } catch (e) { console.error('Theme listener error:', e); }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addThemeListener = (fn) => {
|
|
||||||
globalThemeListeners.add(fn);
|
|
||||||
return () => globalThemeListeners.delete(fn);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeThemeListener = (fn) => {
|
|
||||||
globalThemeListeners.delete(fn);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearThemeListeners = () => {
|
|
||||||
globalThemeListeners.clear();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 系统主题监听 ============
|
|
||||||
|
|
||||||
let _sysWatchUnsub = null;
|
|
||||||
|
|
||||||
const startSystemWatch = () => {
|
|
||||||
watchSystemTheme(); // 使用兼容的全局单例
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopSystemWatch = () => {
|
|
||||||
if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; }
|
|
||||||
};
|
|
||||||
|
|
||||||
// 保留旧 API 兼容
|
|
||||||
const unwatchSystemTheme = stopSystemWatch;
|
|
||||||
|
|
||||||
// ============ 初始化 ============
|
|
||||||
|
|
||||||
const initTheme = () => {
|
|
||||||
const savedTheme = loadTheme();
|
|
||||||
applyTheme(savedTheme);
|
|
||||||
startSystemWatch();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 外部主题跟随(v0.1.5 新增) ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 跟随外部元素的主题
|
|
||||||
* 支持三种检测策略:
|
|
||||||
* 1. data-md-theme 属性(推荐,显式声明)
|
|
||||||
* 2. CSS 类名(如 theme-dark / theme-light)
|
|
||||||
* 3. 自定义回调函数
|
|
||||||
*
|
|
||||||
* @param {Object} options
|
|
||||||
* @param {HTMLElement} [options.element] - 观察的元素(默认 documentElement)
|
|
||||||
* @param {string} [options.attr='data-theme'] - 检测的属性名
|
|
||||||
* @param {string[]} [options.classMap] - 类名映射 { 'theme-dark': 'dark', 'theme-light': 'light' }
|
|
||||||
* @param {Function} [options.callback] - 自定义回调 (element) => themeName
|
|
||||||
* @param {Function} onThemeChange - 主题变化回调 (resolvedTheme) => void
|
|
||||||
* @returns {() => void} 取消跟随函数
|
|
||||||
*/
|
|
||||||
const followExternalTheme = (options, onThemeChange) => {
|
|
||||||
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
element = document.documentElement,
|
|
||||||
attr = 'data-theme',
|
|
||||||
classMap = null,
|
|
||||||
callback = null,
|
|
||||||
} = options || {};
|
|
||||||
|
|
||||||
const detect = () => {
|
|
||||||
if (typeof callback === 'function') {
|
|
||||||
return callback(element);
|
|
||||||
}
|
|
||||||
// 策略1:属性检测
|
|
||||||
const attrVal = element.getAttribute(attr);
|
|
||||||
if (attrVal) return attrVal;
|
|
||||||
// 策略2:classMap 检测
|
|
||||||
if (classMap) {
|
|
||||||
for (const [cls, theme] of Object.entries(classMap)) {
|
|
||||||
if (element.classList.contains(cls)) return theme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 策略3:data-md-theme 回退
|
|
||||||
const mdTheme = element.getAttribute('data-md-theme');
|
|
||||||
if (mdTheme) return mdTheme;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
let lastTheme = detect();
|
|
||||||
if (lastTheme) onThemeChange(lastTheme);
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
const current = detect();
|
|
||||||
if (current && current !== lastTheme) {
|
|
||||||
lastTheme = current;
|
|
||||||
onThemeChange(current);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(element, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: [attr, 'class', 'data-md-theme'],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从父容器继承主题(读取 CSS 变量 + data-md-theme)
|
|
||||||
* 适用场景:编辑器嵌入已有主题体系的应用时自动适配
|
|
||||||
*
|
|
||||||
* @param {HTMLElement} container - 编辑器容器元素
|
|
||||||
* @param {(theme: string) => void} onThemeDetected - 检测到主题的回调
|
|
||||||
* @returns {() => void} 取消跟随函数
|
|
||||||
*/
|
|
||||||
const adoptFromParent = (container, onThemeDetected) => {
|
|
||||||
if (typeof document === 'undefined' || !container) return () => {};
|
|
||||||
|
|
||||||
const detect = () => {
|
|
||||||
// 向上遍历 DOM 树寻找 data-md-theme 属性
|
|
||||||
let el = container.parentElement;
|
|
||||||
while (el) {
|
|
||||||
const t = el.getAttribute('data-md-theme');
|
|
||||||
if (t) return t;
|
|
||||||
// 也检测常见类名
|
|
||||||
if (el.classList.contains('theme-dark') || el.classList.contains('dark')) return 'dark';
|
|
||||||
if (el.classList.contains('theme-light') || el.classList.contains('light')) return 'light';
|
|
||||||
el = el.parentElement;
|
|
||||||
}
|
|
||||||
// 回退:读取父元素 computed style 中的 --md-bg 判断明暗
|
|
||||||
if (container.parentElement) {
|
|
||||||
const bg = getComputedStyle(container.parentElement).getPropertyValue('--md-bg').trim()
|
|
||||||
|| getComputedStyle(container.parentElement).backgroundColor;
|
|
||||||
if (bg) {
|
|
||||||
// 简单亮度判断
|
|
||||||
const rgb = bg.match(/\d+/g);
|
|
||||||
if (rgb && rgb.length >= 3) {
|
|
||||||
const brightness = (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000;
|
|
||||||
return brightness < 128 ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const detected = detect();
|
|
||||||
if (detected) onThemeDetected(detected);
|
|
||||||
|
|
||||||
// 监听父元素的属性变化
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
const d = detect();
|
|
||||||
if (d) onThemeDetected(d);
|
|
||||||
});
|
|
||||||
|
|
||||||
let target = container.parentElement;
|
|
||||||
while (target) {
|
|
||||||
observer.observe(target, { attributes: true, attributeFilter: ['class', 'data-md-theme', 'data-theme'] });
|
|
||||||
target = target.parentElement;
|
|
||||||
// 只观察向上3层,避免性能问题
|
|
||||||
if (target === document.documentElement) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建一个主题观察者,监听外部主题源变化
|
|
||||||
* @param {Function|string} source - 回调函数返回主题名,或 CSS 选择器
|
|
||||||
* @param {(theme: string) => void} onChange - 主题变化回调
|
|
||||||
* @returns {() => void} 取消观察
|
|
||||||
*/
|
|
||||||
const watch = (source, onChange) => {
|
|
||||||
if (typeof source === 'function') {
|
|
||||||
const tick = () => {
|
|
||||||
try {
|
|
||||||
const theme = source();
|
|
||||||
if (theme) onChange(theme);
|
|
||||||
} catch (_) {}
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
const id = setInterval(tick, 500);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof source === 'string' && typeof document !== 'undefined') {
|
|
||||||
const el = document.querySelector(source);
|
|
||||||
if (!el) return () => {};
|
|
||||||
return followExternalTheme({ element: el, attr: 'data-theme' }, onChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 自定义主题注册(v0.1.5: 支持继承) ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册自定义主题
|
|
||||||
* @param {string} name - 主题名
|
|
||||||
* @param {Object} config - 主题配置
|
|
||||||
* @param {string} [config.extends] - 继承的基础主题名(如 'light')
|
|
||||||
*/
|
|
||||||
const registerTheme = (name, config = {}) => {
|
|
||||||
const baseName = config.extends || 'light';
|
|
||||||
const base = THEMES[baseName] || THEMES.light;
|
|
||||||
|
|
||||||
THEMES[name] = {
|
|
||||||
bg: config.bg || base.bg,
|
|
||||||
text: config.text || base.text,
|
|
||||||
border: config.border || base.border,
|
|
||||||
shadow: config.shadow || base.shadow,
|
|
||||||
hoverShadow: config.hoverShadow || base.hoverShadow,
|
|
||||||
toolbarBg: config.toolbarBg || base.toolbarBg || base.bg,
|
|
||||||
textareaBg: config.textareaBg || base.textareaBg || base.bg,
|
|
||||||
previewBg: config.previewBg || base.previewBg || base.bg,
|
|
||||||
codeBg: config.codeBg || base.codeBg,
|
|
||||||
codeText: config.codeText || base.codeText,
|
|
||||||
accent: config.accent || base.accent,
|
|
||||||
muted: config.muted || base.muted,
|
|
||||||
progressBg: config.progressBg || base.progressBg,
|
|
||||||
closeHoverBg: config.closeHoverBg || base.closeHoverBg,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const unregisterTheme = (name) => {
|
|
||||||
if (name === 'light' || name === 'dark' || name === 'auto' || name === 'warm') {
|
|
||||||
console.warn('Cannot unregister built-in theme:', name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
delete THEMES[name];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAllThemes = () => ({ ...THEMES });
|
|
||||||
const getThemeNames = () => Object.keys(THEMES);
|
|
||||||
const hasTheme = (name) => name in THEMES;
|
|
||||||
|
|
||||||
// ============ 实例级主题管理(v0.1.5 新增) ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 为编辑器实例创建隔离的主题上下文
|
|
||||||
* 每个上下文独立跟踪主题,变更时自动同步 CSS 变量到实例 DOM
|
|
||||||
*
|
|
||||||
* @param {Object} editor - 编辑器实例(需有 el / config 属性)
|
|
||||||
* @returns {Object} 实例主题 API
|
|
||||||
*/
|
|
||||||
const createInstanceTheme = (editor) => {
|
|
||||||
let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto';
|
|
||||||
|
|
||||||
/** 应用主题到当前实例的 DOM */
|
|
||||||
const apply = (theme) => {
|
|
||||||
instanceTheme = theme;
|
|
||||||
const resolved = resolveTheme(theme);
|
|
||||||
const config = getThemeConfig(theme);
|
|
||||||
|
|
||||||
if (editor.el) {
|
|
||||||
// 更新 wrapper 类名
|
|
||||||
['me-theme-light', 'me-theme-dark', 'me-theme-warm'].forEach((c) => {
|
|
||||||
editor.el.classList.remove(c);
|
|
||||||
});
|
|
||||||
if (resolved !== 'auto') {
|
|
||||||
editor.el.classList.add(`me-theme-${resolved}`);
|
|
||||||
}
|
|
||||||
editor.el.setAttribute('data-md-theme', resolved);
|
|
||||||
|
|
||||||
// 设置实例级 CSS 变量(scope 到 wrapper)
|
|
||||||
setThemeVariables(config, editor.el);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 触发编辑器实例事件
|
|
||||||
if (typeof editor._emit === 'function') {
|
|
||||||
editor._emit('themeChange', { theme, resolved, config });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 强制刷新预览(主题可能影响渲染)
|
|
||||||
if (typeof editor.refresh === 'function') {
|
|
||||||
editor.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return instanceTheme;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 获取实例当前主题名 */
|
|
||||||
const get = () => instanceTheme;
|
|
||||||
|
|
||||||
/** 设置实例主题 */
|
|
||||||
const set = (theme) => apply(theme);
|
|
||||||
|
|
||||||
/** 切换 light/dark */
|
|
||||||
const toggle = () => {
|
|
||||||
const resolved = resolveTheme(instanceTheme);
|
|
||||||
return apply(resolved === 'dark' ? 'light' : 'dark');
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 获取解析后的主题名 */
|
|
||||||
const getResolved = () => resolveTheme(instanceTheme);
|
|
||||||
|
|
||||||
/** 获取实例主题配置 */
|
|
||||||
const getConfig = () => getThemeConfig(instanceTheme);
|
|
||||||
|
|
||||||
/** 获取实例 CSS 变量导出 */
|
|
||||||
const getVars = () => {
|
|
||||||
if (editor.el) return exportCSSVars(editor.el);
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 外部主题跟随:同步到外部元素 */
|
|
||||||
const syncWithElement = (element, opts = {}) => {
|
|
||||||
return followExternalTheme(
|
|
||||||
{ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback },
|
|
||||||
(detected) => apply(detected),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 从父容器继承主题 */
|
|
||||||
const adopt = () => {
|
|
||||||
if (editor.container) {
|
|
||||||
return adoptFromParent(editor.container, (detected) => apply(detected));
|
|
||||||
}
|
|
||||||
return () => {};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 监听外部主题变化 */
|
|
||||||
const watchExternal = (source) => {
|
|
||||||
return watch(source, (theme) => apply(theme));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始应用
|
|
||||||
apply(instanceTheme);
|
|
||||||
|
|
||||||
return {
|
|
||||||
apply,
|
|
||||||
get,
|
|
||||||
set,
|
|
||||||
toggle,
|
|
||||||
getResolved,
|
|
||||||
getConfig,
|
|
||||||
getVars,
|
|
||||||
syncWithElement,
|
|
||||||
adopt,
|
|
||||||
watch: watchExternal,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建独立主题管理器(工厂函数,向后兼容)
|
|
||||||
* @returns {Object} 主题管理器
|
|
||||||
*/
|
|
||||||
const createThemeManager = () => ({
|
|
||||||
getSystemTheme,
|
|
||||||
resolveTheme,
|
|
||||||
getThemeConfig,
|
|
||||||
applyTheme,
|
|
||||||
getCurrentTheme,
|
|
||||||
getResolvedTheme,
|
|
||||||
switchTheme,
|
|
||||||
toggleTheme,
|
|
||||||
resetToAuto,
|
|
||||||
initTheme,
|
|
||||||
watchSystemTheme,
|
|
||||||
unwatchSystemTheme,
|
|
||||||
addThemeListener,
|
|
||||||
removeThemeListener,
|
|
||||||
clearThemeListeners,
|
|
||||||
registerTheme,
|
|
||||||
unregisterTheme,
|
|
||||||
getAllThemes,
|
|
||||||
getThemeNames,
|
|
||||||
hasTheme,
|
|
||||||
saveTheme,
|
|
||||||
loadTheme,
|
|
||||||
setThemeVariables,
|
|
||||||
exportCSSVars,
|
|
||||||
getCSSVariable,
|
|
||||||
applyThemeToElement,
|
|
||||||
followExternalTheme,
|
|
||||||
adoptFromParent,
|
|
||||||
watch,
|
|
||||||
createInstanceTheme,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============ 预设主题 ============
|
|
||||||
|
|
||||||
const presetThemes = {
|
|
||||||
light: { name: '浅色', description: '明亮清晰的主题', config: THEMES.light },
|
|
||||||
dark: { name: '深色', description: '护眼舒适的暗色主题', config: THEMES.dark },
|
|
||||||
auto: { name: '自动', description: '跟随系统主题设置', config: 'auto' },
|
|
||||||
warm: { name: '暖色', description: '温馨舒适的暖色主题', config: THEMES.warm },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化:确保 THEMES 中有预设
|
|
||||||
Object.entries(presetThemes).forEach(([name, theme]) => {
|
|
||||||
if (name !== 'auto' && theme.config !== 'auto') {
|
|
||||||
THEMES[name] = theme.config;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============ 工具代理层 ============
|
|
||||||
|
|
||||||
const themeUtils = {
|
|
||||||
// 系统
|
|
||||||
getSystemTheme,
|
|
||||||
resolveTheme,
|
|
||||||
getThemeConfig,
|
|
||||||
watchSystemTheme,
|
|
||||||
unwatchSystemTheme,
|
|
||||||
// 全局主题
|
|
||||||
applyTheme,
|
|
||||||
getCurrentTheme,
|
|
||||||
getResolvedTheme,
|
|
||||||
switchTheme,
|
|
||||||
toggleTheme,
|
|
||||||
resetToAuto,
|
|
||||||
initTheme,
|
|
||||||
// 持久化
|
|
||||||
saveTheme,
|
|
||||||
loadTheme,
|
|
||||||
// 监听
|
|
||||||
addThemeListener,
|
|
||||||
removeThemeListener,
|
|
||||||
clearThemeListeners,
|
|
||||||
// 注册
|
|
||||||
registerTheme,
|
|
||||||
unregisterTheme,
|
|
||||||
getAllThemes,
|
|
||||||
getThemeNames,
|
|
||||||
hasTheme,
|
|
||||||
// CSS 变量
|
|
||||||
setThemeVariables,
|
|
||||||
exportCSSVars,
|
|
||||||
getCSSVariable,
|
|
||||||
applyThemeToElement,
|
|
||||||
// 外部跟随(v0.1.5)
|
|
||||||
followExternalTheme,
|
|
||||||
adoptFromParent,
|
|
||||||
watch,
|
|
||||||
// 实例主题(v0.1.5)
|
|
||||||
createInstanceTheme,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 具名导出 ============
|
|
||||||
|
|
||||||
export {
|
|
||||||
themeUtils,
|
|
||||||
themeUtils as default,
|
|
||||||
getSystemTheme,
|
|
||||||
resolveTheme,
|
|
||||||
getThemeConfig,
|
|
||||||
applyTheme,
|
|
||||||
getCurrentTheme,
|
|
||||||
getResolvedTheme,
|
|
||||||
switchTheme,
|
|
||||||
toggleTheme,
|
|
||||||
resetToAuto,
|
|
||||||
saveTheme,
|
|
||||||
loadTheme,
|
|
||||||
initTheme,
|
|
||||||
watchSystemTheme,
|
|
||||||
unwatchSystemTheme,
|
|
||||||
addThemeListener,
|
|
||||||
removeThemeListener,
|
|
||||||
clearThemeListeners,
|
|
||||||
registerTheme,
|
|
||||||
unregisterTheme,
|
|
||||||
getAllThemes,
|
|
||||||
getThemeNames,
|
|
||||||
hasTheme,
|
|
||||||
setThemeVariables,
|
|
||||||
exportCSSVars,
|
|
||||||
getCSSVariable,
|
|
||||||
applyThemeToElement,
|
|
||||||
followExternalTheme,
|
|
||||||
adoptFromParent,
|
|
||||||
watch,
|
|
||||||
createInstanceTheme,
|
|
||||||
createThemeManager,
|
|
||||||
presetThemes,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 向后兼容别名
|
|
||||||
const getTheme = resolveTheme;
|
|
||||||
export { getTheme };
|
|
||||||
+298
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Themes — theme system
|
||||||
|
* @module themes
|
||||||
|
* @version 0.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { THEMES } from './constants';
|
||||||
|
import { prefersDark } from './utils';
|
||||||
|
import type { ThemeConfig, ThemeName } from './constants';
|
||||||
|
|
||||||
|
// ============ State ============
|
||||||
|
|
||||||
|
let globalCurrentTheme = 'auto';
|
||||||
|
let globalThemeListeners = new Set<(theme: string, resolved: string) => void>();
|
||||||
|
let _sysWatchUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
|
// ============ System theme ============
|
||||||
|
|
||||||
|
export const getSystemTheme = (): 'light' | 'dark' => {
|
||||||
|
if (typeof window === 'undefined') return 'light';
|
||||||
|
return prefersDark() ? 'dark' : 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
const _watchSystem = (callback: (theme: 'light' | 'dark') => void): (() => void) => {
|
||||||
|
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
||||||
|
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = (e: MediaQueryListEvent) => callback(e.matches ? 'dark' : 'light');
|
||||||
|
mql.addEventListener('change', handler);
|
||||||
|
return () => mql.removeEventListener('change', handler);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const watchSystemTheme = (): void => {
|
||||||
|
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||||
|
if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; }
|
||||||
|
_sysWatchUnsub = _watchSystem((sysTheme) => {
|
||||||
|
if (globalCurrentTheme === 'auto') applyTheme('auto');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unwatchSystemTheme = (): void => {
|
||||||
|
if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Theme resolution ============
|
||||||
|
|
||||||
|
export const resolveTheme = (theme: string): string => {
|
||||||
|
if (!theme || theme === 'auto') return getSystemTheme();
|
||||||
|
return theme;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTheme = resolveTheme;
|
||||||
|
|
||||||
|
export const getThemeConfig = (theme: string): ThemeConfig => {
|
||||||
|
const resolved = resolveTheme(theme);
|
||||||
|
const config = THEMES[resolved];
|
||||||
|
return (config && typeof config === 'object' ? config : THEMES.light) as ThemeConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ CSS variables ============
|
||||||
|
|
||||||
|
export const setThemeVariables = (config: ThemeConfig, target?: HTMLElement): void => {
|
||||||
|
if (typeof document === 'undefined' || !config || typeof config !== 'object') return;
|
||||||
|
const root = target || document.documentElement;
|
||||||
|
const vars: Record<string, string | undefined> = {
|
||||||
|
'--md-bg': config.bg, '--md-text': config.text, '--md-border': config.border,
|
||||||
|
'--md-shadow': config.shadow, '--md-hover-shadow': config.hoverShadow,
|
||||||
|
'--md-toolbar-bg': config.toolbarBg || config.bg,
|
||||||
|
'--md-textarea-bg': config.textareaBg || config.bg,
|
||||||
|
'--md-preview-bg': config.previewBg || config.bg,
|
||||||
|
'--md-code-bg': config.codeBg || 'rgba(127,127,127,0.1)',
|
||||||
|
'--md-code-text': config.codeText || config.text,
|
||||||
|
'--md-accent': config.accent || '#3b82f6',
|
||||||
|
'--md-muted': config.muted || '#9ca3af',
|
||||||
|
};
|
||||||
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
|
if (v !== undefined && v !== null) root.style.setProperty(k, String(v));
|
||||||
|
}
|
||||||
|
if (config.progressBg) root.style.setProperty('--md-progress-bg', config.progressBg);
|
||||||
|
if (config.closeHoverBg) root.style.setProperty('--md-close-hover-bg', config.closeHoverBg);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportCSSVars = (el?: HTMLElement): Record<string, string> => {
|
||||||
|
const root = el || document.documentElement;
|
||||||
|
if (typeof document === 'undefined') return {};
|
||||||
|
const style = getComputedStyle(root);
|
||||||
|
const varNames = ['--md-bg','--md-text','--md-border','--md-shadow','--md-hover-shadow','--md-toolbar-bg','--md-textarea-bg','--md-preview-bg','--md-code-bg','--md-code-text','--md-accent','--md-muted'];
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
varNames.forEach((name) => { const val = style.getPropertyValue(name).trim(); if (val) result[name] = val; });
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCSSVariable = (name: string, el?: HTMLElement): string => {
|
||||||
|
const root = el || document.documentElement;
|
||||||
|
if (typeof document === 'undefined') return '';
|
||||||
|
const fullName = name.startsWith('--') ? name : `--md-${name}`;
|
||||||
|
return getComputedStyle(root).getPropertyValue(fullName).trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Apply theme ============
|
||||||
|
|
||||||
|
export const applyTheme = (theme: string): void => {
|
||||||
|
globalCurrentTheme = theme;
|
||||||
|
const resolved = resolveTheme(theme);
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.setAttribute('data-md-theme', resolved);
|
||||||
|
document.documentElement.classList.remove('md-theme-light','md-theme-dark','md-theme-auto','md-theme-warm');
|
||||||
|
document.documentElement.classList.add(`md-theme-${resolved}`);
|
||||||
|
setThemeVariables(getThemeConfig(theme));
|
||||||
|
}
|
||||||
|
notifyGlobalListeners(theme, resolved);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyThemeToElement = (theme: string, target: HTMLElement): void => {
|
||||||
|
if (!target || typeof document === 'undefined') return;
|
||||||
|
const resolved = resolveTheme(theme);
|
||||||
|
target.setAttribute('data-md-theme', resolved);
|
||||||
|
target.classList.remove('md-theme-light','md-theme-dark','md-theme-auto','md-theme-warm');
|
||||||
|
if (resolved !== 'auto') target.classList.add(`md-theme-${resolved}`);
|
||||||
|
setThemeVariables(getThemeConfig(theme), target);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const switchTheme = (theme: string): void => { applyTheme(theme); saveTheme(theme); };
|
||||||
|
export const toggleTheme = (): void => { const r = getResolvedTheme(); switchTheme(r === 'dark' ? 'light' : 'dark'); };
|
||||||
|
export const resetToAuto = (): void => { switchTheme('auto'); };
|
||||||
|
export const getCurrentTheme = (): string => globalCurrentTheme;
|
||||||
|
export const getResolvedTheme = (): string => resolveTheme(globalCurrentTheme);
|
||||||
|
|
||||||
|
// ============ Persistence ============
|
||||||
|
|
||||||
|
export const saveTheme = (theme: string): void => {
|
||||||
|
if (typeof localStorage !== 'undefined') { try { localStorage.setItem('metona-editor-theme', theme); } catch (_) {} }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadTheme = (): string => {
|
||||||
|
if (typeof localStorage !== 'undefined') { try { return localStorage.getItem('metona-editor-theme') || 'auto'; } catch (_) {} }
|
||||||
|
return 'auto';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Listeners ============
|
||||||
|
|
||||||
|
const notifyGlobalListeners = (theme: string, resolved: string): void => {
|
||||||
|
globalThemeListeners.forEach((fn) => { try { fn(theme, resolved); } catch (e) { console.error('Theme listener error:', e); } });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addThemeListener = (fn: (theme: string, resolved: string) => void): () => void => {
|
||||||
|
globalThemeListeners.add(fn); return () => { globalThemeListeners.delete(fn); };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeThemeListener = (fn: (theme: string, resolved: string) => void): void => { globalThemeListeners.delete(fn); };
|
||||||
|
export const clearThemeListeners = (): void => { globalThemeListeners.clear(); };
|
||||||
|
|
||||||
|
// ============ System watch ============
|
||||||
|
|
||||||
|
export const startSystemWatch = (): void => { watchSystemTheme(); };
|
||||||
|
export const stopSystemWatch = unwatchSystemTheme;
|
||||||
|
|
||||||
|
// ============ Init ============
|
||||||
|
|
||||||
|
export const initTheme = (): void => { applyTheme(loadTheme()); startSystemWatch(); };
|
||||||
|
|
||||||
|
// ============ External follow ============
|
||||||
|
|
||||||
|
export const followExternalTheme = (
|
||||||
|
options: { element?: HTMLElement; attr?: string; classMap?: Record<string, string>; callback?: (el: HTMLElement) => string | null },
|
||||||
|
onThemeChange: (theme: string) => void,
|
||||||
|
): () => void => {
|
||||||
|
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => {};
|
||||||
|
const { element = document.documentElement, attr = 'data-theme', classMap = null, callback = null } = options;
|
||||||
|
const detect = (): string | null => {
|
||||||
|
if (typeof callback === 'function') return callback(element);
|
||||||
|
const attrVal = element.getAttribute(attr); if (attrVal) return attrVal;
|
||||||
|
if (classMap) { for (const [cls, theme] of Object.entries(classMap)) { if (element.classList.contains(cls)) return theme; } }
|
||||||
|
const mdTheme = element.getAttribute('data-md-theme'); if (mdTheme) return mdTheme;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
let lastTheme = detect(); if (lastTheme) onThemeChange(lastTheme);
|
||||||
|
const observer = new MutationObserver(() => { const current = detect(); if (current && current !== lastTheme) { lastTheme = current; onThemeChange(current); } });
|
||||||
|
observer.observe(element, { attributes: true, attributeFilter: [attr, 'class', 'data-md-theme'] });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adoptFromParent = (container: HTMLElement, onThemeDetected: (theme: string) => void): () => void => {
|
||||||
|
if (typeof document === 'undefined' || !container) return () => {};
|
||||||
|
const detect = (): string | null => {
|
||||||
|
let el: HTMLElement | null = container.parentElement;
|
||||||
|
while (el) {
|
||||||
|
const t = el.getAttribute('data-md-theme'); if (t) return t;
|
||||||
|
if (el.classList.contains('theme-dark') || el.classList.contains('dark')) return 'dark';
|
||||||
|
if (el.classList.contains('theme-light') || el.classList.contains('light')) return 'light';
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
if (container.parentElement) {
|
||||||
|
const bg = getComputedStyle(container.parentElement).getPropertyValue('--md-bg').trim() || getComputedStyle(container.parentElement).backgroundColor;
|
||||||
|
if (bg) { const rgb = bg.match(/\d+/g); if (rgb && rgb.length >= 3) { const brightness = (parseInt(rgb[0])*299 + parseInt(rgb[1])*587 + parseInt(rgb[2])*114)/1000; return brightness < 128 ? 'dark' : 'light'; } }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const detected = detect(); if (detected) onThemeDetected(detected);
|
||||||
|
const observer = new MutationObserver(() => { const d = detect(); if (d) onThemeDetected(d); });
|
||||||
|
let target: HTMLElement | null = container.parentElement;
|
||||||
|
while (target) { observer.observe(target, { attributes: true, attributeFilter: ['class','data-md-theme','data-theme'] }); target = target.parentElement; if (target === document.documentElement) break; }
|
||||||
|
return () => observer.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const watch = (source: Function | string, onChange: (theme: string) => void): () => void => {
|
||||||
|
if (typeof source === 'function') { const tick = () => { try { const t = source(); if (t) onChange(t); } catch (_) {} }; tick(); const id = setInterval(tick, 500); return () => clearInterval(id); }
|
||||||
|
if (typeof source === 'string' && typeof document !== 'undefined') { const el = document.querySelector(source); if (!el) return () => {}; return followExternalTheme({ element: el as HTMLElement, attr: 'data-theme' }, onChange); }
|
||||||
|
return () => {};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Custom theme registration ============
|
||||||
|
|
||||||
|
export const registerTheme = (name: string, config: Partial<ThemeConfig> & { extends?: string } = {}): void => {
|
||||||
|
const baseName = config.extends || 'light';
|
||||||
|
const base = (THEMES[baseName] || THEMES.light) as ThemeConfig;
|
||||||
|
THEMES[name] = {
|
||||||
|
bg: config.bg || base.bg, text: config.text || base.text, border: config.border || base.border,
|
||||||
|
shadow: config.shadow || base.shadow, hoverShadow: config.hoverShadow || base.hoverShadow,
|
||||||
|
toolbarBg: config.toolbarBg || base.toolbarBg || base.bg,
|
||||||
|
textareaBg: config.textareaBg || base.textareaBg || base.bg,
|
||||||
|
previewBg: config.previewBg || base.previewBg || base.bg,
|
||||||
|
codeBg: config.codeBg || base.codeBg, codeText: config.codeText || base.codeText,
|
||||||
|
accent: config.accent || base.accent, muted: config.muted || base.muted,
|
||||||
|
progressBg: config.progressBg || base.progressBg,
|
||||||
|
closeHoverBg: config.closeHoverBg || base.closeHoverBg,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unregisterTheme = (name: string): void => {
|
||||||
|
if (name === 'light' || name === 'dark' || name === 'auto' || name === 'warm') { console.warn('Cannot unregister built-in theme:', name); return; }
|
||||||
|
delete THEMES[name];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllThemes = (): Record<string, ThemeConfig | string> => ({ ...THEMES });
|
||||||
|
export const getThemeNames = (): string[] => Object.keys(THEMES);
|
||||||
|
export const hasTheme = (name: string): boolean => name in THEMES;
|
||||||
|
|
||||||
|
// ============ Instance theme ============
|
||||||
|
|
||||||
|
export const createInstanceTheme = (editor: any): any => {
|
||||||
|
let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto';
|
||||||
|
const apply = (theme: string): string => {
|
||||||
|
instanceTheme = theme;
|
||||||
|
const resolved = resolveTheme(theme);
|
||||||
|
const config = getThemeConfig(theme);
|
||||||
|
if (editor.el) {
|
||||||
|
['me-theme-light','me-theme-dark','me-theme-warm'].forEach((c) => editor.el.classList.remove(c));
|
||||||
|
if (resolved !== 'auto') editor.el.classList.add(`me-theme-${resolved}`);
|
||||||
|
editor.el.setAttribute('data-md-theme', resolved);
|
||||||
|
setThemeVariables(config, editor.el);
|
||||||
|
}
|
||||||
|
if (typeof editor._emit === 'function') editor._emit('themeChange', { theme, resolved, config });
|
||||||
|
if (typeof editor.refresh === 'function') editor.refresh();
|
||||||
|
return instanceTheme;
|
||||||
|
};
|
||||||
|
const get = () => instanceTheme;
|
||||||
|
const set = (theme: string) => apply(theme);
|
||||||
|
const toggle = () => { const r = resolveTheme(instanceTheme); return apply(r === 'dark' ? 'light' : 'dark'); };
|
||||||
|
const getResolved = () => resolveTheme(instanceTheme);
|
||||||
|
const getConfig = () => getThemeConfig(instanceTheme);
|
||||||
|
const getVars = () => editor.el ? exportCSSVars(editor.el) : {};
|
||||||
|
const syncWithElement = (element: HTMLElement, opts: any = {}) => followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected));
|
||||||
|
const adopt = () => editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => {};
|
||||||
|
const watchExternal = (source: any) => watch(source, (theme) => apply(theme));
|
||||||
|
apply(instanceTheme);
|
||||||
|
return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createThemeManager = () => ({
|
||||||
|
getSystemTheme, resolveTheme, getThemeConfig, applyTheme, getCurrentTheme, getResolvedTheme,
|
||||||
|
switchTheme, toggleTheme, resetToAuto, initTheme, watchSystemTheme, unwatchSystemTheme,
|
||||||
|
addThemeListener, removeThemeListener, clearThemeListeners, registerTheme, unregisterTheme,
|
||||||
|
getAllThemes, getThemeNames, hasTheme, saveTheme, loadTheme, setThemeVariables, exportCSSVars,
|
||||||
|
getCSSVariable, applyThemeToElement, followExternalTheme, adoptFromParent, watch, createInstanceTheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const presetThemes = {
|
||||||
|
light: { name: '浅色', description: '明亮清晰', config: THEMES.light },
|
||||||
|
dark: { name: '深色', description: '护眼暗色', config: THEMES.dark },
|
||||||
|
auto: { name: '自动', description: '跟随系统', config: 'auto' },
|
||||||
|
warm: { name: '暖色', description: '温馨暖色', config: THEMES.warm },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure built-in themes in THEMES
|
||||||
|
for (const [name, theme] of Object.entries(presetThemes)) {
|
||||||
|
if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config as ThemeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeUtils = {
|
||||||
|
getSystemTheme, resolveTheme, getThemeConfig, watchSystemTheme, unwatchSystemTheme,
|
||||||
|
applyTheme, getCurrentTheme, getResolvedTheme, switchTheme, toggleTheme, resetToAuto, initTheme,
|
||||||
|
saveTheme, loadTheme, addThemeListener, removeThemeListener, clearThemeListeners,
|
||||||
|
registerTheme, unregisterTheme, getAllThemes, getThemeNames, hasTheme,
|
||||||
|
setThemeVariables, exportCSSVars, getCSSVariable, applyThemeToElement,
|
||||||
|
followExternalTheme, adoptFromParent, watch, createInstanceTheme,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default themeUtils;
|
||||||
-138
@@ -1,138 +0,0 @@
|
|||||||
/**
|
|
||||||
* MetonaEditor Utils - 工具函数
|
|
||||||
* @module utils
|
|
||||||
* @version 0.1.14
|
|
||||||
* @description 通用工具函数集合
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成唯一ID
|
|
||||||
* @returns {string} 唯一ID
|
|
||||||
*/
|
|
||||||
export const generateId = () => {
|
|
||||||
return 'me-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTML转义
|
|
||||||
* @param {string} s - 输入字符串
|
|
||||||
* @returns {string} 转义后的字符串
|
|
||||||
*/
|
|
||||||
export const escapeHTML = (s) => {
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return String(s == null ? '' : s)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(s == null ? '' : s);
|
|
||||||
return div.innerHTML;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检测暗色模式偏好
|
|
||||||
* @returns {boolean} 是否偏好暗色模式
|
|
||||||
*/
|
|
||||||
export const prefersDark = () => {
|
|
||||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 防抖函数
|
|
||||||
* @param {Function} func - 要防抖的函数
|
|
||||||
* @param {number} wait - 等待时间(毫秒)
|
|
||||||
* @param {boolean} immediate - 是否立即执行
|
|
||||||
* @returns {Function} 防抖后的函数
|
|
||||||
*/
|
|
||||||
export const debounce = (func, wait, immediate = false) => {
|
|
||||||
let timeout;
|
|
||||||
return function executedFunction(...args) {
|
|
||||||
const context = this;
|
|
||||||
const later = () => {
|
|
||||||
timeout = null;
|
|
||||||
if (!immediate) func.apply(context, args);
|
|
||||||
};
|
|
||||||
const callNow = immediate && !timeout;
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(later, wait);
|
|
||||||
if (callNow) func.apply(context, args);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 节流函数
|
|
||||||
* @param {Function} func - 要节流的函数
|
|
||||||
* @param {number} limit - 限制时间(毫秒)
|
|
||||||
* @returns {Function} 节流后的函数
|
|
||||||
*/
|
|
||||||
export const throttle = (func, limit) => {
|
|
||||||
let inThrottle;
|
|
||||||
return function executedFunction(...args) {
|
|
||||||
const context = this;
|
|
||||||
if (!inThrottle) {
|
|
||||||
func.apply(context, args);
|
|
||||||
inThrottle = true;
|
|
||||||
setTimeout(() => inThrottle = false, limit);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 深度合并对象
|
|
||||||
* @param {Object} target - 目标对象
|
|
||||||
* @param {Object} source - 源对象
|
|
||||||
* @returns {Object} 合并后的对象
|
|
||||||
*/
|
|
||||||
export const deepMerge = (target, source) => {
|
|
||||||
const output = { ...target };
|
|
||||||
|
|
||||||
Object.keys(source).forEach(key => {
|
|
||||||
if (source[key] instanceof Object && key in target && target[key] instanceof Object) {
|
|
||||||
output[key] = deepMerge(target[key], source[key]);
|
|
||||||
} else {
|
|
||||||
output[key] = source[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return output;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否为浏览器环境
|
|
||||||
* @returns {boolean} 是否为浏览器环境
|
|
||||||
*/
|
|
||||||
export const isBrowser = () => {
|
|
||||||
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化文件大小
|
|
||||||
* @param {number} bytes - 字节数
|
|
||||||
* @param {number} decimals - 小数位数
|
|
||||||
* @returns {string} 格式化后的字符串
|
|
||||||
*/
|
|
||||||
export const formatFileSize = (bytes, decimals = 2) => {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
|
|
||||||
const k = 1024;
|
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 延迟执行
|
|
||||||
* @param {number} ms - 毫秒数
|
|
||||||
* @returns {Promise} Promise对象
|
|
||||||
*/
|
|
||||||
export const sleep = (ms) => {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
};
|
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* MetonaEditor Utils — utility functions
|
||||||
|
* @module utils
|
||||||
|
* @version 0.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Generate a unique ID */
|
||||||
|
export const generateId = (): string => {
|
||||||
|
return 'me-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** HTML-escape a string */
|
||||||
|
export const escapeHTML = (s: unknown): string => {
|
||||||
|
const str = String(s == null ? '' : s);
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Detect dark mode preference */
|
||||||
|
export const prefersDark = (): boolean => {
|
||||||
|
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Debounce a function */
|
||||||
|
export function debounce<T extends (...args: any[]) => void>(
|
||||||
|
func: T,
|
||||||
|
wait: number,
|
||||||
|
immediate: boolean = false,
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null;
|
||||||
|
return function (this: any, ...args: Parameters<T>) {
|
||||||
|
const context = this;
|
||||||
|
const later = () => {
|
||||||
|
timeout = null;
|
||||||
|
if (!immediate) func.apply(context, args);
|
||||||
|
};
|
||||||
|
const callNow = immediate && !timeout;
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
if (callNow) func.apply(context, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throttle a function */
|
||||||
|
export function throttle<T extends (...args: any[]) => void>(
|
||||||
|
func: T,
|
||||||
|
limit: number,
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let inThrottle = false;
|
||||||
|
return function (this: any, ...args: Parameters<T>) {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func.apply(this, args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => { inThrottle = false; }, limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep-merge two objects */
|
||||||
|
export const deepMerge = <T extends Record<string, unknown>>(target: T, source: Partial<T>): T => {
|
||||||
|
const output: Record<string, unknown> = { ...target };
|
||||||
|
for (const key of Object.keys(source as Record<string, unknown>)) {
|
||||||
|
const sv = (source as Record<string, unknown>)[key];
|
||||||
|
const tv = (target as Record<string, unknown>)[key];
|
||||||
|
if (sv instanceof Object && key in target && tv instanceof Object) {
|
||||||
|
output[key] = deepMerge(tv as Record<string, unknown>, sv as Record<string, unknown>);
|
||||||
|
} else {
|
||||||
|
output[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Check if running in browser */
|
||||||
|
export const isBrowser = (): boolean => {
|
||||||
|
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Format file size to human-readable string */
|
||||||
|
export const formatFileSize = (bytes: number, decimals: number = 2): string => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sleep for ms milliseconds */
|
||||||
|
export const sleep = (ms: number): Promise<void> => {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
};
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
* 覆盖 animationUtils 全部 API + createAnimation 工厂
|
* 覆盖 animationUtils 全部 API + createAnimation 工厂
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { animationUtils, createAnimation, animationPresets } from '../src/animations.js';
|
import { animationUtils, createAnimation, animationPresets } from '../src/animations';
|
||||||
import { ANIMATIONS } from '../src/constants.js';
|
import { ANIMATIONS } from '../src/constants';
|
||||||
|
|
||||||
describe('animationUtils - 默认动画注册', () => {
|
describe('animationUtils - 默认动画注册', () => {
|
||||||
test('默认动画已注册到 animationMap', () => {
|
test('默认动画已注册到 animationMap', () => {
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* 覆盖 MarkdownEditor 类的构造、API、命令、历史栈、模式、事件、销毁
|
* 覆盖 MarkdownEditor 类的构造、API、命令、历史栈、模式、事件、销毁
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MarkdownEditor } from '../src/core.js';
|
import { MarkdownEditor } from '../src/core';
|
||||||
|
|
||||||
// jsdom 不提供 PointerEvent,用 MouseEvent 作为基类 polyfill
|
// jsdom 不提供 PointerEvent,用 MouseEvent 作为基类 polyfill
|
||||||
if (typeof window !== 'undefined' && typeof window.PointerEvent === 'undefined') {
|
if (typeof window !== 'undefined' && typeof window.PointerEvent === 'undefined') {
|
||||||
@@ -1532,3 +1532,202 @@ describe('MarkdownEditor - v0.1.12 shortcutHelp 插件', () => {
|
|||||||
ed.destroy();
|
ed.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ v0.2.0 覆盖率补齐测试 ============
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.0 Smart Enter 引用空行', () => {
|
||||||
|
test('空引用行 Enter 结束引用', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '> ' });
|
||||||
|
ed.textarea.setSelectionRange(2, 2);
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
expect(ed.getValue()).toBe('\n');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.0 括号闭合边界', () => {
|
||||||
|
test('光标在同类引号后跳过而不插入', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '"hello" world', autoBrackets: true });
|
||||||
|
ed.textarea.setSelectionRange(6, 6); // 光标在闭合引号前
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '"', bubbles: true }));
|
||||||
|
// 跳过闭合引号,selectionStart 前进一位
|
||||||
|
expect(ed.textarea.selectionStart).toBe(7);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('光标在单词中间不自动闭合引号', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'hello world', autoBrackets: true });
|
||||||
|
ed.textarea.setSelectionRange(3, 3); // 光标在 'l' 和 'l' 之间
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '"', bubbles: true }));
|
||||||
|
// 不闭合,因为 \w 检查
|
||||||
|
expect(ed.getValue()).toBe('hello world');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.0 exec 补充', () => {
|
||||||
|
test('exec zen 切换 Zen 模式', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
expect(ed.isZen()).toBe(false);
|
||||||
|
ed.exec('zen');
|
||||||
|
expect(ed.isZen()).toBe(true);
|
||||||
|
ed.exec('zen');
|
||||||
|
expect(ed.isZen()).toBe(false);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exec wordwrap 切换换行', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
expect(ed.isWordWrap()).toBe(true);
|
||||||
|
ed.exec('wordwrap');
|
||||||
|
expect(ed.isWordWrap()).toBe(false);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.0 setWordWrap API', () => {
|
||||||
|
test('setWordWrap 设置换行模式', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
ed.setWordWrap(false);
|
||||||
|
expect(ed.isWordWrap()).toBe(false);
|
||||||
|
expect(ed.textarea.style.whiteSpace).toBe('pre');
|
||||||
|
ed.setWordWrap(true);
|
||||||
|
expect(ed.isWordWrap()).toBe(true);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.0 右键菜单和快捷键管理', () => {
|
||||||
|
test('registerContextMenu 设置菜单项', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
ed.registerContextMenu([{ label: 'Copy', action: 'copy' }]);
|
||||||
|
expect(ed._contextMenuItems.length).toBe(1);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getShortcuts 空列表', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
expect(Array.isArray(ed.getShortcuts())).toBe(true);
|
||||||
|
expect(ed.getShortcuts().length).toBe(0);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unregisterShortcut 移除注册', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
ed.registerShortcut('Ctrl+K', 'link');
|
||||||
|
expect(ed.getShortcuts().length).toBe(1);
|
||||||
|
ed.unregisterShortcut('Ctrl+K');
|
||||||
|
expect(ed.getShortcuts().length).toBe(0);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.0 工具栏管理', () => {
|
||||||
|
test('removeToolbarButton 移除按钮', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
|
||||||
|
const before = ed.toolbarEl.querySelectorAll('.me-btn').length;
|
||||||
|
ed.removeToolbarButton('bold');
|
||||||
|
expect(ed.toolbarEl.querySelectorAll('.me-btn').length).toBe(before - 1);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configureToolbar 重建工具栏', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
ed.configureToolbar(['undo', 'redo']);
|
||||||
|
expect(ed.toolbarEl.querySelectorAll('.me-btn').length).toBe(2);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.0 render 错误处理', () => {
|
||||||
|
test('render 抛错时显示错误信息', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {
|
||||||
|
value: '# test', mode: 'split',
|
||||||
|
render: () => { throw new Error('boom'); },
|
||||||
|
});
|
||||||
|
expect(ed.previewEl.innerHTML).toContain('渲染失败');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.0 outline 构建', () => {
|
||||||
|
test('outline 启用时构建面板', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '# H1\n## H2\n### H3', outline: true, mode: 'split' });
|
||||||
|
ed._buildOutline();
|
||||||
|
const panel = ed.el.querySelector('.me-outline');
|
||||||
|
expect(panel).not.toBeNull();
|
||||||
|
if (panel) {
|
||||||
|
const links = panel.querySelectorAll('a');
|
||||||
|
expect(links.length).toBeGreaterThanOrEqual(2);
|
||||||
|
}
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.0 gutter 更新', () => {
|
||||||
|
test('gutter 行数不变时跳过 DOM 更新', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'a\nb', lineNumbers: true, mode: 'split' });
|
||||||
|
// 初始渲染已经填充了 gutter
|
||||||
|
const before = ed.gutter.children.length;
|
||||||
|
expect(before).toBeGreaterThan(0);
|
||||||
|
// 再次调用 _renderGutter 应该跳过
|
||||||
|
ed._renderGutter();
|
||||||
|
expect(ed.gutter.children.length).toBe(before);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.2.0 getStatus plugins', () => {
|
||||||
|
test('getStatus 包含插件名列表', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
ed.use({ name: 'test-p', install() {} });
|
||||||
|
const s = ed.getStatus();
|
||||||
|
expect(s.plugins).toContain('test-p');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,10 +29,11 @@ import {
|
|||||||
loadLocale,
|
loadLocale,
|
||||||
getDefaultLocale,
|
getDefaultLocale,
|
||||||
initI18n,
|
initI18n,
|
||||||
|
loadRemote,
|
||||||
presetLocales,
|
presetLocales,
|
||||||
createI18nManager,
|
createI18nManager,
|
||||||
} from '../src/i18n.js';
|
} from '../src/i18n';
|
||||||
import { LOCALES } from '../src/constants.js';
|
import { LOCALES } from '../src/constants';
|
||||||
|
|
||||||
// 确保 localStorage 存在(jsdom 兼容)
|
// 确保 localStorage 存在(jsdom 兼容)
|
||||||
if (typeof global.localStorage === 'undefined') {
|
if (typeof global.localStorage === 'undefined') {
|
||||||
@@ -414,7 +415,7 @@ describe('createI18nManager', () => {
|
|||||||
// ============ v0.1.6 新增测试 ============
|
// ============ v0.1.6 新增测试 ============
|
||||||
|
|
||||||
describe('v0.1.6 实例级 i18n', () => {
|
describe('v0.1.6 实例级 i18n', () => {
|
||||||
const { createInstanceI18n } = require('../src/i18n.js');
|
const { createInstanceI18n } = require('../src/i18n');
|
||||||
|
|
||||||
test('createInstanceI18n 创建独立上下文', () => {
|
test('createInstanceI18n 创建独立上下文', () => {
|
||||||
const mockEditor = {
|
const mockEditor = {
|
||||||
@@ -463,7 +464,7 @@ describe('v0.1.6 实例级 i18n', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('v0.1.6 复数规则', () => {
|
describe('v0.1.6 复数规则', () => {
|
||||||
const { addTranslations, t, setCurrentLocale } = require('../src/i18n.js');
|
const { addTranslations, t, setCurrentLocale } = require('../src/i18n');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setCurrentLocale('en-US');
|
setCurrentLocale('en-US');
|
||||||
@@ -485,3 +486,72 @@ describe('v0.1.6 复数规则', () => {
|
|||||||
expect(t('items', { count: 3 })).toBe('3 个项目');
|
expect(t('items', { count: 3 })).toBe('3 个项目');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ v0.2.0 覆盖率补齐 ============
|
||||||
|
|
||||||
|
describe('v0.2.0 复数规则', () => {
|
||||||
|
test('俄语 few 形式', () => {
|
||||||
|
addTranslations('ru', { apples: { one: '{count} яблоко', few: '{count} яблока', many: '{count} яблок', other: '{count} яблок' } });
|
||||||
|
setCurrentLocale('ru');
|
||||||
|
expect(t('apples', { count: 2 })).toContain('яблока');
|
||||||
|
expect(t('apples', { count: 5 })).toContain('яблок');
|
||||||
|
expect(t('apples', { count: 1 })).toContain('яблоко');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('v0.2.0 翻译回退链', () => {
|
||||||
|
test('缺翻译回退到 fallback 语言', () => {
|
||||||
|
setCurrentLocale('en-US');
|
||||||
|
// 'outline' key exists in en-US
|
||||||
|
expect(t('outline')).toBe('Outline');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('v0.2.0 loadRemote 错误处理', () => {
|
||||||
|
test('loadRemote 网络错误返回 false', async () => {
|
||||||
|
// jsdom doesn't expose fetch on globalThis by default
|
||||||
|
const origFetch = (globalThis as any).fetch;
|
||||||
|
(globalThis as any).fetch = jest.fn().mockRejectedValue(new Error('network'));
|
||||||
|
const result = await loadRemote('https://example.com/locale.json', 'en-US');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
(globalThis as any).fetch = origFetch;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('v0.2.0 格式化异常处理', () => {
|
||||||
|
test('formatNumber 异常回退为字符串', () => {
|
||||||
|
// 先用一个已知 locale
|
||||||
|
setCurrentLocale('en-US');
|
||||||
|
const r = formatNumber(123);
|
||||||
|
expect(typeof r).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatCurrency 正常输出', () => {
|
||||||
|
setCurrentLocale('en-US');
|
||||||
|
const r = formatCurrency(10, 'USD');
|
||||||
|
expect(r).toContain('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatDate 异常回退为字符串', () => {
|
||||||
|
setCurrentLocale('en-US');
|
||||||
|
const r = formatDate('2024-01-15');
|
||||||
|
expect(typeof r).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('v0.2.0 实例级 i18n 工具栏', () => {
|
||||||
|
test('createInstanceI18n set 更新 toolbar tooltips', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new (require('../src/core').MarkdownEditor)(c, {});
|
||||||
|
// 设置语言为英文
|
||||||
|
ed.setLocale('en-US');
|
||||||
|
// 验证工具栏按钮 title 更新
|
||||||
|
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]') as HTMLElement;
|
||||||
|
if (boldBtn) {
|
||||||
|
expect(boldBtn.title).toBe('Bold');
|
||||||
|
}
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* 覆盖 Markdown 解析器的所有语法分支与安全特性
|
* 覆盖 Markdown 解析器的所有语法分支与安全特性
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from '../src/parser.js';
|
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from '../src/parser';
|
||||||
|
|
||||||
describe('parseMarkdown - 基础', () => {
|
describe('parseMarkdown - 基础', () => {
|
||||||
test('空输入返回空字符串', () => {
|
test('空输入返回空字符串', () => {
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
* 覆盖预设插件 autoSave / exportTool / searchReplace 及 pluginUtils
|
* 覆盖预设插件 autoSave / exportTool / searchReplace 及 pluginUtils
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MarkdownEditor } from '../src/core.js';
|
import { MarkdownEditor } from '../src/core';
|
||||||
import { presetPlugins, pluginUtils, PluginManager } from '../src/plugins.js';
|
import { presetPlugins, pluginUtils, PluginManager } from '../src/plugins';
|
||||||
|
|
||||||
// 确保 localStorage 存在(jsdom 兼容)
|
// 确保 localStorage 存在(jsdom 兼容)
|
||||||
if (typeof global.localStorage === 'undefined') {
|
if (typeof global.localStorage === 'undefined') {
|
||||||
@@ -871,7 +871,7 @@ describe('零散分支补全', () => {
|
|||||||
// ============ v0.1.6 新增测试 ============
|
// ============ v0.1.6 新增测试 ============
|
||||||
|
|
||||||
describe('v0.1.6 topologicalSort', () => {
|
describe('v0.1.6 topologicalSort', () => {
|
||||||
const { topologicalSort } = require('../src/plugins.js');
|
const { topologicalSort } = require('../src/plugins');
|
||||||
|
|
||||||
test('空数组返回空数组', () => {
|
test('空数组返回空数组', () => {
|
||||||
expect(topologicalSort([])).toEqual([]);
|
expect(topologicalSort([])).toEqual([]);
|
||||||
@@ -908,7 +908,7 @@ describe('v0.1.6 topologicalSort', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('v0.1.6 validateConfig', () => {
|
describe('v0.1.6 validateConfig', () => {
|
||||||
const { validateConfig } = require('../src/plugins.js');
|
const { validateConfig } = require('../src/plugins');
|
||||||
|
|
||||||
test('required 字段缺失报错', () => {
|
test('required 字段缺失报错', () => {
|
||||||
const result = validateConfig({ name: { required: true } }, {});
|
const result = validateConfig({ name: { required: true } }, {});
|
||||||
@@ -938,7 +938,7 @@ describe('v0.1.6 editor.unuse()', () => {
|
|||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
const c = document.createElement('div');
|
const c = document.createElement('div');
|
||||||
document.body.appendChild(c);
|
document.body.appendChild(c);
|
||||||
const { MarkdownEditor } = require('../src/core.js');
|
const { MarkdownEditor } = require('../src/core');
|
||||||
const ed = new MarkdownEditor(c, {});
|
const ed = new MarkdownEditor(c, {});
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
ed.use({ name: 'test', install() {}, destroy() { destroyed = true; } });
|
ed.use({ name: 'test', install() {}, destroy() { destroyed = true; } });
|
||||||
@@ -955,7 +955,7 @@ describe('v0.1.6 editor shortcuts', () => {
|
|||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
const c = document.createElement('div');
|
const c = document.createElement('div');
|
||||||
document.body.appendChild(c);
|
document.body.appendChild(c);
|
||||||
const { MarkdownEditor } = require('../src/core.js');
|
const { MarkdownEditor } = require('../src/core');
|
||||||
const ed = new MarkdownEditor(c, { value: 'hello' });
|
const ed = new MarkdownEditor(c, { value: 'hello' });
|
||||||
let called = false;
|
let called = false;
|
||||||
ed.registerShortcut('Ctrl+J', () => { called = true; });
|
ed.registerShortcut('Ctrl+J', () => { called = true; });
|
||||||
@@ -968,7 +968,7 @@ describe('v0.1.6 editor shortcuts', () => {
|
|||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
const c = document.createElement('div');
|
const c = document.createElement('div');
|
||||||
document.body.appendChild(c);
|
document.body.appendChild(c);
|
||||||
const { MarkdownEditor } = require('../src/core.js');
|
const { MarkdownEditor } = require('../src/core');
|
||||||
const ed = new MarkdownEditor(c, { value: 'hello' });
|
const ed = new MarkdownEditor(c, { value: 'hello' });
|
||||||
let called = false;
|
let called = false;
|
||||||
ed.registerShortcut('Ctrl+K', () => { called = true; });
|
ed.registerShortcut('Ctrl+K', () => { called = true; });
|
||||||
@@ -984,7 +984,7 @@ describe('v0.1.6 editor shortcuts', () => {
|
|||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
const c = document.createElement('div');
|
const c = document.createElement('div');
|
||||||
document.body.appendChild(c);
|
document.body.appendChild(c);
|
||||||
const { MarkdownEditor } = require('../src/core.js');
|
const { MarkdownEditor } = require('../src/core');
|
||||||
const ed = new MarkdownEditor(c, {});
|
const ed = new MarkdownEditor(c, {});
|
||||||
ed.registerShortcut('Ctrl+B', 'bold', 'Bold');
|
ed.registerShortcut('Ctrl+B', 'bold', 'Bold');
|
||||||
expect(ed.getShortcuts().length).toBe(1);
|
expect(ed.getShortcuts().length).toBe(1);
|
||||||
@@ -997,7 +997,7 @@ describe('v0.1.6 editor toast', () => {
|
|||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
const c = document.createElement('div');
|
const c = document.createElement('div');
|
||||||
document.body.appendChild(c);
|
document.body.appendChild(c);
|
||||||
const { MarkdownEditor } = require('../src/core.js');
|
const { MarkdownEditor } = require('../src/core');
|
||||||
const ed = new MarkdownEditor(c, {});
|
const ed = new MarkdownEditor(c, {});
|
||||||
ed.toast('Test message', { duration: 0 });
|
ed.toast('Test message', { duration: 0 });
|
||||||
const toast = ed.el.querySelector('.me-toast');
|
const toast = ed.el.querySelector('.me-toast');
|
||||||
@@ -1010,7 +1010,7 @@ describe('v0.1.6 editor toast', () => {
|
|||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
const c = document.createElement('div');
|
const c = document.createElement('div');
|
||||||
document.body.appendChild(c);
|
document.body.appendChild(c);
|
||||||
const { MarkdownEditor } = require('../src/core.js');
|
const { MarkdownEditor } = require('../src/core');
|
||||||
const ed = new MarkdownEditor(c, {});
|
const ed = new MarkdownEditor(c, {});
|
||||||
expect(ed.toast('msg')).toBe(ed);
|
expect(ed.toast('msg')).toBe(ed);
|
||||||
ed.destroy();
|
ed.destroy();
|
||||||
@@ -1022,7 +1022,7 @@ describe('v0.1.6 editor toolbar config', () => {
|
|||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
const c = document.createElement('div');
|
const c = document.createElement('div');
|
||||||
document.body.appendChild(c);
|
document.body.appendChild(c);
|
||||||
const { MarkdownEditor } = require('../src/core.js');
|
const { MarkdownEditor } = require('../src/core');
|
||||||
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
|
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
|
||||||
const before = ed.toolbarEl.querySelectorAll('.me-btn').length;
|
const before = ed.toolbarEl.querySelectorAll('.me-btn').length;
|
||||||
ed.configureToolbar(['bold', 'italic', 'h1', 'h2', '|', 'undo']);
|
ed.configureToolbar(['bold', 'italic', 'h1', 'h2', '|', 'undo']);
|
||||||
@@ -1035,7 +1035,7 @@ describe('v0.1.6 editor toolbar config', () => {
|
|||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
const c = document.createElement('div');
|
const c = document.createElement('div');
|
||||||
document.body.appendChild(c);
|
document.body.appendChild(c);
|
||||||
const { MarkdownEditor } = require('../src/core.js');
|
const { MarkdownEditor } = require('../src/core');
|
||||||
const ed = new MarkdownEditor(c, {});
|
const ed = new MarkdownEditor(c, {});
|
||||||
const before = ed.toolbarEl.querySelectorAll('.me-btn-bold').length;
|
const before = ed.toolbarEl.querySelectorAll('.me-btn-bold').length;
|
||||||
expect(before).toBe(1);
|
expect(before).toBe(1);
|
||||||
@@ -1045,3 +1045,76 @@ describe('v0.1.6 editor toolbar config', () => {
|
|||||||
ed.destroy();
|
ed.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ v0.2.0 覆盖率补齐 ============
|
||||||
|
|
||||||
|
describe('v0.2.0 topologicalSort 环检测', () => {
|
||||||
|
test('循环依赖回退到原始顺序', () => {
|
||||||
|
const { topologicalSort: ts } = require('../src/plugins');
|
||||||
|
const pA = { name: 'A', depends: ['B'], priority: 0, install() {}, destroy() {} };
|
||||||
|
const pB = { name: 'B', depends: ['A'], priority: 1, install() {}, destroy() {} };
|
||||||
|
const sorted = ts([pA, pB]);
|
||||||
|
expect(sorted.length).toBe(2);
|
||||||
|
expect(sorted[0].name).toBe('A');
|
||||||
|
expect(sorted[1].name).toBe('B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('v0.2.0 searchReplace 全文交互', () => {
|
||||||
|
test('打开面板填入选中文本', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: 'hello foo bar' });
|
||||||
|
ed.use('searchReplace');
|
||||||
|
ed.textarea.setSelectionRange(6, 9);
|
||||||
|
// 触发 Ctrl+F
|
||||||
|
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
|
||||||
|
const fi = ed.el.querySelector('.me-search-find') as HTMLInputElement;
|
||||||
|
expect(fi).not.toBeNull();
|
||||||
|
expect(fi.value).toBe('foo');
|
||||||
|
// 清理
|
||||||
|
const panel = ed.el.querySelector('.me-search');
|
||||||
|
if (panel) panel.remove();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('v0.2.0 fileSystem 插件', () => {
|
||||||
|
test('fileSystem 安装后暴露 API', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
ed.use('fileSystem');
|
||||||
|
expect(typeof (ed as any).openFile).toBe('function');
|
||||||
|
expect(typeof (ed as any).saveFile).toBe('function');
|
||||||
|
expect(typeof (ed as any).saveFileAs).toBe('function');
|
||||||
|
expect(typeof (ed as any).getFileHandle).toBe('function');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unuse 卸载 fileSystem', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
ed.use('fileSystem');
|
||||||
|
ed.unuse('fileSystem');
|
||||||
|
expect((ed as any).openFile).toBeUndefined();
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('v0.2.0 exportTool 插件', () => {
|
||||||
|
test('exportTool 安装暴露 API', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { value: '# test' });
|
||||||
|
ed.use('exportTool');
|
||||||
|
expect(typeof (ed as any).exportMarkdown).toBe('function');
|
||||||
|
expect(typeof (ed as any).exportHTML).toBe('function');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,9 +41,9 @@ import {
|
|||||||
adoptFromParent,
|
adoptFromParent,
|
||||||
watch,
|
watch,
|
||||||
createInstanceTheme,
|
createInstanceTheme,
|
||||||
} from '../src/themes.js';
|
} from '../src/themes';
|
||||||
import { THEMES } from '../src/constants.js';
|
import { THEMES } from '../src/constants';
|
||||||
import { MarkdownEditor } from '../src/core.js';
|
import { MarkdownEditor } from '../src/core';
|
||||||
|
|
||||||
// 确保 localStorage 存在(jsdom 兼容)
|
// 确保 localStorage 存在(jsdom 兼容)
|
||||||
if (typeof global.localStorage === 'undefined') {
|
if (typeof global.localStorage === 'undefined') {
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
isBrowser,
|
isBrowser,
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
sleep,
|
sleep,
|
||||||
} from '../src/utils.js';
|
} from '../src/utils';
|
||||||
|
|
||||||
describe('generateId', () => {
|
describe('generateId', () => {
|
||||||
test('返回字符串', () => {
|
test('返回字符串', () => {
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests", "site"]
|
||||||
|
}
|
||||||
Vendored
-487
@@ -1,487 +0,0 @@
|
|||||||
// Type definitions for MetonaEditor v0.1.14
|
|
||||||
// Project: https://git.metona.cn/MetonaTeam/MetonaEditor
|
|
||||||
// Author: thzxx
|
|
||||||
|
|
||||||
declare const VERSION: string;
|
|
||||||
|
|
||||||
/** 视图模式 */
|
|
||||||
export type EditMode = 'edit' | 'split' | 'preview';
|
|
||||||
|
|
||||||
/** 主题名 */
|
|
||||||
export type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string;
|
|
||||||
|
|
||||||
/** 渲染环境,传给自定义 render 函数 */
|
|
||||||
export interface RenderEnv {
|
|
||||||
highlight?: (code: string, lang: string) => string;
|
|
||||||
locale?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 工具栏项:动作名或 '|'(分隔符) */
|
|
||||||
export type ToolbarItem = string | '|';
|
|
||||||
|
|
||||||
/** 编辑器配置 */
|
|
||||||
export interface MarkdownEditorOptions {
|
|
||||||
/** 初始内容 */
|
|
||||||
value?: string;
|
|
||||||
/** 占位符 */
|
|
||||||
placeholder?: string;
|
|
||||||
/** 视图模式,默认 'split' */
|
|
||||||
mode?: EditMode;
|
|
||||||
/** 高度:数字为 px,字符串原样使用,默认 400 */
|
|
||||||
height?: number | string;
|
|
||||||
/** 工具栏按钮序列,传 false 隐藏工具栏 */
|
|
||||||
toolbar?: ToolbarItem[] | false;
|
|
||||||
/** 是否显示字数统计状态栏,默认 true */
|
|
||||||
wordCount?: boolean;
|
|
||||||
/** 自动聚焦,默认 false */
|
|
||||||
autofocus?: boolean;
|
|
||||||
/** 拼写检查,默认 false */
|
|
||||||
spellcheck?: boolean;
|
|
||||||
/** 历史栈上限,默认 100 */
|
|
||||||
historyLimit?: number;
|
|
||||||
/** 分屏模式下同步滚动,默认 true */
|
|
||||||
syncScroll?: boolean;
|
|
||||||
/** Tab 插入的空格数,0 表示插入 \t,默认 2 */
|
|
||||||
tabSize?: number;
|
|
||||||
/** 只读模式,默认 false */
|
|
||||||
readOnly?: boolean;
|
|
||||||
/** 历史栈防抖延迟(ms),默认 400 */
|
|
||||||
historyDebounce?: number;
|
|
||||||
/** 显示行号,默认 true(v0.1.7) */
|
|
||||||
lineNumbers?: boolean;
|
|
||||||
/** 显示大纲面板,默认 false(v0.1.7) */
|
|
||||||
outline?: boolean;
|
|
||||||
/** 自动括号闭合,默认 true(v0.1.7) */
|
|
||||||
autoBrackets?: boolean;
|
|
||||||
/** 主题,默认 'auto' */
|
|
||||||
theme?: ThemeName;
|
|
||||||
/** 国际化语言,默认 'zh-CN' */
|
|
||||||
locale?: string;
|
|
||||||
/** 自定义 Markdown 渲染函数,覆盖内置解析器 */
|
|
||||||
render?: (markdown: string, env: RenderEnv) => string;
|
|
||||||
/** 自定义代码高亮函数 */
|
|
||||||
highlight?: (code: string, lang: string) => string;
|
|
||||||
/** 自定义 HTML 净化函数(接收渲染后 HTML,返回安全 HTML) */
|
|
||||||
sanitize?: (html: string) => string;
|
|
||||||
/** 自定义容器 className */
|
|
||||||
className?: string;
|
|
||||||
/** 容器内联样式对象 */
|
|
||||||
style?: { [key: string]: string };
|
|
||||||
/** 实例级插件列表 */
|
|
||||||
plugins?: Array<string | PluginObject>;
|
|
||||||
/** 唯一 id */
|
|
||||||
id?: string;
|
|
||||||
|
|
||||||
// 生命周期回调
|
|
||||||
onCreate?: (editor: MarkdownEditor) => void;
|
|
||||||
onDestroy?: (editor: MarkdownEditor) => void;
|
|
||||||
onInput?: (value: string, editor: MarkdownEditor) => void;
|
|
||||||
onChange?: (value: string, editor: MarkdownEditor) => void;
|
|
||||||
onFocus?: (editor: MarkdownEditor) => void;
|
|
||||||
onBlur?: (editor: MarkdownEditor) => void;
|
|
||||||
onSave?: (value: string, editor: MarkdownEditor) => void;
|
|
||||||
onModeChange?: (mode: EditMode, editor: MarkdownEditor) => void;
|
|
||||||
onFullscreen?: (fullscreen: boolean, editor: MarkdownEditor) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 插件对象 */
|
|
||||||
export interface PluginObject {
|
|
||||||
name: string;
|
|
||||||
version?: string;
|
|
||||||
description?: string;
|
|
||||||
depends?: string[];
|
|
||||||
priority?: number;
|
|
||||||
[key: string]: any;
|
|
||||||
install?: (editor: MarkdownEditor) => void | Promise<void>;
|
|
||||||
destroy?: (editor: MarkdownEditor) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 工具栏按钮配置(addToolbarButton 用) */
|
|
||||||
export interface ToolbarButtonConfig {
|
|
||||||
action: string;
|
|
||||||
title?: string;
|
|
||||||
text?: string;
|
|
||||||
icon?: string;
|
|
||||||
onClick?: (editor: MarkdownEditor) => void;
|
|
||||||
handler?: (...args: any[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 统计信息 */
|
|
||||||
export interface EditorStats {
|
|
||||||
characters: number;
|
|
||||||
words: number;
|
|
||||||
chineseChars: number;
|
|
||||||
englishWords: number;
|
|
||||||
lines: number;
|
|
||||||
readingTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 编辑器状态 */
|
|
||||||
export interface EditorStatus {
|
|
||||||
id: string;
|
|
||||||
mode: EditMode;
|
|
||||||
theme: ThemeName;
|
|
||||||
locale: string;
|
|
||||||
fullscreen: boolean;
|
|
||||||
readOnly: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
destroyed: boolean;
|
|
||||||
plugins: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 事件名 */
|
|
||||||
export type EditorEventName =
|
|
||||||
| 'input' | 'change' | 'focus' | 'blur' | 'save'
|
|
||||||
| 'modeChange' | 'fullscreen' | 'autosave' | 'destroy'
|
|
||||||
| 'beforeRender' | 'afterRender'
|
|
||||||
| 'themeChange' | 'localeChange';
|
|
||||||
|
|
||||||
/** 全局钩子名 */
|
|
||||||
export type HookName =
|
|
||||||
| 'beforeCreate' | 'afterCreate'
|
|
||||||
| 'beforeRender' | 'afterRender'
|
|
||||||
| 'beforeDestroy' | 'afterDestroy';
|
|
||||||
|
|
||||||
/** MarkdownEditor 编辑器类 */
|
|
||||||
export class MarkdownEditor {
|
|
||||||
constructor(container: string | HTMLElement, options?: MarkdownEditorOptions);
|
|
||||||
|
|
||||||
/** 实例 id */
|
|
||||||
readonly id: string;
|
|
||||||
/** 容器元素 */
|
|
||||||
readonly container: HTMLElement;
|
|
||||||
/** 配置(只读视图) */
|
|
||||||
readonly config: MarkdownEditorOptions;
|
|
||||||
/** 根元素 */
|
|
||||||
readonly el: HTMLElement;
|
|
||||||
readonly toolbarEl: HTMLElement;
|
|
||||||
readonly bodyEl: HTMLElement;
|
|
||||||
readonly editorPane: HTMLElement;
|
|
||||||
readonly previewPane: HTMLElement;
|
|
||||||
readonly dividerEl: HTMLElement;
|
|
||||||
readonly textarea: HTMLTextAreaElement;
|
|
||||||
readonly previewEl: HTMLElement;
|
|
||||||
readonly statusEl: HTMLElement | null;
|
|
||||||
|
|
||||||
// 静态钩子
|
|
||||||
static on(name: HookName, fn: (editor: MarkdownEditor) => void): () => void;
|
|
||||||
static off(name: HookName, fn: (editor: MarkdownEditor) => void): void;
|
|
||||||
static trigger(name: HookName, editor: MarkdownEditor): void;
|
|
||||||
|
|
||||||
// 内容
|
|
||||||
getValue(): string;
|
|
||||||
setValue(markdown: string, opts?: { silent?: boolean }): this;
|
|
||||||
getHTML(): string;
|
|
||||||
/** 强制刷新预览(内容未变时手动触发重渲染) */
|
|
||||||
refresh(): this;
|
|
||||||
insert(text: string, opts?: { replace?: boolean }): this;
|
|
||||||
wrap(before: string, after?: string): this;
|
|
||||||
|
|
||||||
// 选区/焦点
|
|
||||||
focus(): this;
|
|
||||||
blur(): this;
|
|
||||||
enable(): this;
|
|
||||||
disable(): this;
|
|
||||||
isDisabled(): boolean;
|
|
||||||
setReadOnly(readOnly: boolean): this;
|
|
||||||
isReadOnly(): boolean;
|
|
||||||
|
|
||||||
// 事件
|
|
||||||
on(name: EditorEventName, fn: (...args: any[]) => void): () => void;
|
|
||||||
off(name: EditorEventName, fn: (...args: any[]) => void): this;
|
|
||||||
|
|
||||||
// 命令
|
|
||||||
exec(action: string, ...args: any[]): this;
|
|
||||||
|
|
||||||
// 历史栈
|
|
||||||
undo(): this;
|
|
||||||
redo(): this;
|
|
||||||
canUndo(): boolean;
|
|
||||||
canRedo(): boolean;
|
|
||||||
|
|
||||||
// 模式与全屏
|
|
||||||
setMode(mode: EditMode): this;
|
|
||||||
getMode(): EditMode;
|
|
||||||
toggleFullscreen(): this;
|
|
||||||
isFullscreen(): boolean;
|
|
||||||
exitFullscreen(): this;
|
|
||||||
|
|
||||||
// 统计与状态
|
|
||||||
getStats(): EditorStats;
|
|
||||||
getStatus(): EditorStatus;
|
|
||||||
|
|
||||||
// 主题(v0.1.5: 实例级主题管理)
|
|
||||||
setTheme(theme: ThemeName): this;
|
|
||||||
getTheme(): string;
|
|
||||||
getThemeContext(): {
|
|
||||||
get(): string;
|
|
||||||
set(theme: string): string;
|
|
||||||
toggle(): string;
|
|
||||||
getResolved(): string;
|
|
||||||
getConfig(): object;
|
|
||||||
getVars(): object;
|
|
||||||
syncWithElement(element: HTMLElement, opts?: object): () => void;
|
|
||||||
adopt(): () => void;
|
|
||||||
watch(source: Function | string): () => void;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
// 插件
|
|
||||||
use(plugin: string | PluginObject, options?: object): this;
|
|
||||||
/** 卸载插件(v0.1.6) */
|
|
||||||
unuse(name: string): this;
|
|
||||||
getPlugins(): PluginObject[];
|
|
||||||
addToolbarButton(config: ToolbarButtonConfig): this;
|
|
||||||
|
|
||||||
// 快捷键(v0.1.6)
|
|
||||||
registerShortcut(combo: string, handler: Function | string, description?: string): this;
|
|
||||||
unregisterShortcut(combo: string): this;
|
|
||||||
getShortcuts(): Array<{ combo: string; handler: Function | string; description: string }>;
|
|
||||||
|
|
||||||
// 工具栏(v0.1.6)
|
|
||||||
configureToolbar(tools: ToolbarItem[]): this;
|
|
||||||
removeToolbarButton(action: string): this;
|
|
||||||
|
|
||||||
// 右键菜单(v0.1.6)
|
|
||||||
registerContextMenu(items: Array<{ label: string; action?: string; shortcut?: string; type?: string }>): this;
|
|
||||||
|
|
||||||
// Toast 通知(v0.1.6)
|
|
||||||
toast(message: string, opts?: { type?: 'success' | 'error' | 'warning' | 'info'; duration?: number; animation?: string }): this;
|
|
||||||
|
|
||||||
// 实例级 i18n(v0.1.6)
|
|
||||||
setLocale(locale: string): this;
|
|
||||||
getLocale(): string;
|
|
||||||
t(key: string, params?: object): string;
|
|
||||||
|
|
||||||
// 生命周期
|
|
||||||
destroy(): void;
|
|
||||||
isDestroyed(): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** MarkdownEditor 别名 */
|
|
||||||
export const Editor: typeof MarkdownEditor;
|
|
||||||
|
|
||||||
/** 工厂函数:创建编辑器实例 */
|
|
||||||
export function create(container: string | HTMLElement, options?: MarkdownEditorOptions): MarkdownEditor;
|
|
||||||
|
|
||||||
/** 注册全局默认插件(作用于所有后续创建的实例) */
|
|
||||||
export function use(plugin: string | PluginObject, options?: object): typeof api;
|
|
||||||
|
|
||||||
/** 注册全局事件钩子 */
|
|
||||||
export function on(name: HookName, fn: (editor: MarkdownEditor) => void): () => void;
|
|
||||||
/** 注销全局事件钩子 */
|
|
||||||
export function off(name: HookName, fn: (editor: MarkdownEditor) => void): typeof api;
|
|
||||||
|
|
||||||
/** 切换全局主题 */
|
|
||||||
export function setTheme(theme: ThemeName, options?: object): typeof api;
|
|
||||||
/** 切换全局语言 */
|
|
||||||
export function setLocale(locale: string): typeof api;
|
|
||||||
/** 销毁所有全局资源 */
|
|
||||||
export function destroy(): void;
|
|
||||||
/** 获取库运行状态 */
|
|
||||||
export function getStatus(): {
|
|
||||||
version: string;
|
|
||||||
theme: string;
|
|
||||||
locale: string;
|
|
||||||
globalPlugins: string[];
|
|
||||||
presetPlugins: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 解析器 ============
|
|
||||||
/** Markdown 解析函数 */
|
|
||||||
export function parseMarkdown(markdown: string, env?: RenderEnv): string;
|
|
||||||
/** URL 安全过滤(增强版) */
|
|
||||||
export function safeUrl(url: string): string;
|
|
||||||
/** 生成标题锚点 id(Unicode NFKC 规范化) */
|
|
||||||
export function slugify(text: string): string;
|
|
||||||
/** 清除渲染缓存(主题切换等场景) */
|
|
||||||
export function clearRenderCache(): void;
|
|
||||||
|
|
||||||
// ============ 插件工具(v0.1.6)============
|
|
||||||
/** 插件拓扑排序 */
|
|
||||||
export function topologicalSort(plugins: PluginObject[]): PluginObject[];
|
|
||||||
/** 插件配置校验 */
|
|
||||||
export function validateConfig(schema: object, config: object): { valid: boolean; errors: string[]; patched: object };
|
|
||||||
|
|
||||||
// ============ i18n 工具(v0.1.6)============
|
|
||||||
/** 创建实例级 i18n 上下文 */
|
|
||||||
export function createInstanceI18n(editor: object): object;
|
|
||||||
/** 远程加载翻译包 */
|
|
||||||
export function loadRemote(url: string, locale: string): Promise<boolean>;
|
|
||||||
|
|
||||||
// ============ 主题工具(v0.1.5)============
|
|
||||||
/** 导出 CSS 变量 */
|
|
||||||
export function exportCSSVars(el?: HTMLElement): Record<string, string>;
|
|
||||||
/** 获取单个 CSS 变量 */
|
|
||||||
export function getCSSVariable(name: string, el?: HTMLElement): string;
|
|
||||||
/** 外部主题跟随 */
|
|
||||||
export function followExternalTheme(opts: object, onChange: (theme: string) => void): () => void;
|
|
||||||
/** 从父容器继承主题 */
|
|
||||||
export function adoptFromParent(container: HTMLElement, onDetected: (theme: string) => void): () => void;
|
|
||||||
/** 创建实例级主题上下文 */
|
|
||||||
export function createInstanceTheme(editor: object): object;
|
|
||||||
|
|
||||||
// ============ 插件 ============
|
|
||||||
/** 预设插件表 */
|
|
||||||
export const presetPlugins: {
|
|
||||||
autoSave: PluginObject;
|
|
||||||
exportTool: PluginObject;
|
|
||||||
searchReplace: PluginObject;
|
|
||||||
[key: string]: PluginObject;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 插件工具 */
|
|
||||||
export const pluginUtils: {
|
|
||||||
createManager(): PluginManager;
|
|
||||||
register(name: string, plugin: PluginObject): PluginManager;
|
|
||||||
unregister(name: string): PluginManager;
|
|
||||||
get(name: string): PluginObject | null;
|
|
||||||
has(name: string): boolean;
|
|
||||||
getAll(): PluginObject[];
|
|
||||||
getNames(): string[];
|
|
||||||
enable(name: string): PluginManager;
|
|
||||||
disable(name: string): PluginManager;
|
|
||||||
isEnabled(name: string): boolean;
|
|
||||||
getPreset(name: string): PluginObject | null;
|
|
||||||
getAllPresets(): { [key: string]: PluginObject };
|
|
||||||
createPlugin(config: object): PluginObject;
|
|
||||||
validatePlugin(plugin: PluginObject): { valid: boolean; errors: string[] };
|
|
||||||
topologicalSort(plugins: PluginObject[]): PluginObject[];
|
|
||||||
validateConfig(schema: object, config: object): { valid: boolean; errors: string[]; patched: object };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 插件管理器 */
|
|
||||||
export class PluginManager {
|
|
||||||
register(name: string, plugin: PluginObject): this;
|
|
||||||
unregister(name: string): this;
|
|
||||||
get(name: string): PluginObject | null;
|
|
||||||
has(name: string): boolean;
|
|
||||||
getAll(): PluginObject[];
|
|
||||||
getNames(): string[];
|
|
||||||
enable(name: string): this;
|
|
||||||
disable(name: string): this;
|
|
||||||
isEnabled(name: string): boolean;
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 常量 ============
|
|
||||||
export const DEFAULTS: Readonly<MarkdownEditorOptions>;
|
|
||||||
export const ICONS: { [key: string]: string };
|
|
||||||
export const THEMES: { [key: string]: any };
|
|
||||||
export const EDIT_MODES: EditMode[];
|
|
||||||
export const DEFAULT_TOOLBAR: ToolbarItem[];
|
|
||||||
export const TOOLBAR_ACTIONS: string[];
|
|
||||||
|
|
||||||
// ============ 工具集 ============
|
|
||||||
export const themeUtils: {
|
|
||||||
initTheme(): void;
|
|
||||||
switchTheme(theme: ThemeName): void;
|
|
||||||
toggleTheme(): void;
|
|
||||||
getCurrentTheme(): string;
|
|
||||||
getResolvedTheme(): string;
|
|
||||||
applyTheme(theme: ThemeName): void;
|
|
||||||
registerTheme(name: string, config: { extends?: string; [key: string]: any }): void;
|
|
||||||
unregisterTheme(name: string): void;
|
|
||||||
getAllThemes(): object;
|
|
||||||
getThemeNames(): string[];
|
|
||||||
hasTheme(name: string): boolean;
|
|
||||||
watchSystemTheme(): void;
|
|
||||||
unwatchSystemTheme(): void;
|
|
||||||
addThemeListener(fn: (theme: string, resolved: string) => void): () => void;
|
|
||||||
removeThemeListener(fn: (theme: string, resolved: string) => void): void;
|
|
||||||
clearThemeListeners(): void;
|
|
||||||
// v0.1.5 新增
|
|
||||||
exportCSSVars(el?: HTMLElement): Record<string, string>;
|
|
||||||
getCSSVariable(name: string, el?: HTMLElement): string;
|
|
||||||
applyThemeToElement(theme: string, target: HTMLElement): void;
|
|
||||||
followExternalTheme(opts: object, onChange: (theme: string) => void): () => void;
|
|
||||||
adoptFromParent(container: HTMLElement, onDetected: (theme: string) => void): () => void;
|
|
||||||
watch(source: Function | string, onChange: (theme: string) => void): () => void;
|
|
||||||
createInstanceTheme(editor: object): object;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const i18nUtils: {
|
|
||||||
initI18n(): void;
|
|
||||||
t(key: string, params?: object): string;
|
|
||||||
getCurrentLocale(): string;
|
|
||||||
setCurrentLocale(locale: string): void;
|
|
||||||
switchLocale(locale: string): void;
|
|
||||||
getFallbackLocale(): string;
|
|
||||||
setFallbackLocale(locale: string): void;
|
|
||||||
hasTranslation(key: string): boolean;
|
|
||||||
getTranslations(locale: string): object;
|
|
||||||
addTranslations(locale: string, translations: object): void;
|
|
||||||
loadRemote(url: string, locale: string): Promise<boolean>;
|
|
||||||
getSupportedLocales(): string[];
|
|
||||||
isLocaleSupported(locale: string): boolean;
|
|
||||||
getLocaleName(locale: string): string;
|
|
||||||
getLocaleDirection(locale: string): 'ltr' | 'rtl';
|
|
||||||
formatNumber(n: number, opts?: object): string;
|
|
||||||
formatCurrency(amount: number, currency?: string, opts?: object): string;
|
|
||||||
formatDate(date: Date | string, opts?: object): string;
|
|
||||||
addLocaleListener(fn: (locale: string) => void): () => void;
|
|
||||||
removeLocaleListener(fn: (locale: string) => void): void;
|
|
||||||
clearLocaleListeners(): void;
|
|
||||||
initI18n(): void;
|
|
||||||
saveLocale(locale: string): void;
|
|
||||||
loadLocale(): string;
|
|
||||||
getDefaultLocale(): string;
|
|
||||||
createInstanceI18n(editor: object): object;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const animationUtils: {
|
|
||||||
cancelAll(): void;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 默认导出 ============
|
|
||||||
export interface MeEditorAPI {
|
|
||||||
VERSION: string;
|
|
||||||
version: string;
|
|
||||||
MarkdownEditor: typeof MarkdownEditor;
|
|
||||||
Editor: typeof MarkdownEditor;
|
|
||||||
create: typeof create;
|
|
||||||
use: typeof use;
|
|
||||||
on: typeof on;
|
|
||||||
off: typeof off;
|
|
||||||
setTheme: typeof setTheme;
|
|
||||||
setLocale: typeof setLocale;
|
|
||||||
destroy: typeof destroy;
|
|
||||||
getStatus: typeof getStatus;
|
|
||||||
parseMarkdown: typeof parseMarkdown;
|
|
||||||
safeUrl: typeof safeUrl;
|
|
||||||
slugify: typeof slugify;
|
|
||||||
clearRenderCache: typeof clearRenderCache;
|
|
||||||
topologicalSort: typeof topologicalSort;
|
|
||||||
validateConfig: typeof validateConfig;
|
|
||||||
createInstanceI18n: typeof createInstanceI18n;
|
|
||||||
loadRemote: typeof loadRemote;
|
|
||||||
exportCSSVars: typeof exportCSSVars;
|
|
||||||
getCSSVariable: typeof getCSSVariable;
|
|
||||||
followExternalTheme: typeof followExternalTheme;
|
|
||||||
adoptFromParent: typeof adoptFromParent;
|
|
||||||
createInstanceTheme: typeof createInstanceTheme;
|
|
||||||
themes: typeof themeUtils;
|
|
||||||
i18n: typeof i18nUtils;
|
|
||||||
animations: typeof animationUtils;
|
|
||||||
plugins: typeof pluginUtils;
|
|
||||||
presetPlugins: typeof presetPlugins;
|
|
||||||
DEFAULTS: typeof DEFAULTS;
|
|
||||||
ICONS: typeof ICONS;
|
|
||||||
THEMES: typeof THEMES;
|
|
||||||
EDIT_MODES: typeof EDIT_MODES;
|
|
||||||
DEFAULT_TOOLBAR: typeof DEFAULT_TOOLBAR;
|
|
||||||
TOOLBAR_ACTIONS: typeof TOOLBAR_ACTIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认导出 API 对象 */
|
|
||||||
declare const api: MeEditorAPI;
|
|
||||||
export default api;
|
|
||||||
|
|
||||||
export {
|
|
||||||
api,
|
|
||||||
api as meEditor,
|
|
||||||
api as MeEditor,
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user