release: v0.1.2 — readOnly, new syntax, plugins, optimizations
Features: - feat(core): readOnly mode with config option and setReadOnly() API - feat(parser): superscript ^text^ and subscript ~text~ support - feat(parser): highlight/mark ==text== syntax - feat(parser): table column alignment with colons (:---, :---:, ---:) - feat(parser): emoji shortcodes 😄 → 😊 (80+ common emojis) - feat(plugins): imagePaste preset — paste clipboard images as base64 - feat(core): instance-level beforeRender/afterRender events - feat(styles): print stylesheet (@media print) - feat(styles): mark element CSS styling - feat(styles): readOnly mode CSS (.me-readonly) Optimizations: - perf(core): history debounce now configurable via historyDebounce option - perf(core): skip _render() in edit-only mode (already guarded) - fix(core): divider drag division-by-zero guard - fix(core): config.style deep merge with DEFAULTS - fix(core): scroll position preserved across mode switches Tests: - 9 new test cases: readOnly (3), parser syntax (6) - Total: 418 tests passing (+9 from v0.1.1) Chores: - version bump 0.1.1 → 0.1.2 - TypeScript definitions updated for new APIs
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。
|
> 轻量、零依赖、精致美观的 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)
|
[](./tests)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@metona-team/metona-editor",
|
"name": "@metona-team/metona-editor",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。",
|
"description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/metona-editor.js",
|
"main": "dist/metona-editor.js",
|
||||||
|
|||||||
+1
-1
@@ -305,7 +305,7 @@
|
|||||||
'',
|
'',
|
||||||
'| 名称 | 值 |',
|
'| 名称 | 值 |',
|
||||||
'| --- | --- |',
|
'| --- | --- |',
|
||||||
'| 版本 | 0.1.1 |',
|
'| 版本 | 0.1.2 |',
|
||||||
'| 依赖 | 零 |',
|
'| 依赖 | 零 |',
|
||||||
'| 测试 | 390+ |',
|
'| 测试 | 390+ |',
|
||||||
'',
|
'',
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Animations - 动画管理
|
* MetonaEditor Animations - 动画管理
|
||||||
* @module animations
|
* @module animations
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用;
|
* @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用;
|
||||||
* 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块)
|
* 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块)
|
||||||
*/
|
*/
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Constants - 常量定义
|
* MetonaEditor Constants - 常量定义
|
||||||
* @module constants
|
* @module constants
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @description 默认配置、主题、动画、工具栏等常量
|
* @description 默认配置、主题、动画、工具栏等常量
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -42,10 +42,14 @@ export const DEFAULTS = Object.freeze({
|
|||||||
spellcheck: false,
|
spellcheck: false,
|
||||||
// 历史栈上限
|
// 历史栈上限
|
||||||
historyLimit: 100,
|
historyLimit: 100,
|
||||||
|
// 历史栈防抖延迟(ms)
|
||||||
|
historyDebounce: 400,
|
||||||
// 同步滚动(分屏模式)
|
// 同步滚动(分屏模式)
|
||||||
syncScroll: true,
|
syncScroll: true,
|
||||||
// 制表符插入的空格数(0 表示插入 \t)
|
// 制表符插入的空格数(0 表示插入 \t)
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
|
// 只读模式
|
||||||
|
readOnly: false,
|
||||||
// 主题:light / dark / auto / warm / 自定义
|
// 主题:light / dark / auto / warm / 自定义
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
// 国际化
|
// 国际化
|
||||||
|
|||||||
+26
-3
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Core - 编辑器核心
|
* MetonaEditor Core - 编辑器核心
|
||||||
* @module core
|
* @module core
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
|
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ class MarkdownEditor {
|
|||||||
|
|
||||||
this.config = { ...DEFAULTS, ...options };
|
this.config = { ...DEFAULTS, ...options };
|
||||||
if (options.style && typeof options.style === 'object') {
|
if (options.style && typeof options.style === 'object') {
|
||||||
this.config.style = { ...options.style };
|
this.config.style = { ...DEFAULTS.style, ...options.style };
|
||||||
}
|
}
|
||||||
|
|
||||||
this._value = this.config.value || '';
|
this._value = this.config.value || '';
|
||||||
@@ -136,6 +136,7 @@ class MarkdownEditor {
|
|||||||
_buildDOM() {
|
_buildDOM() {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = `me-wrapper me-theme-${resolveThemeName(this.config.theme)}`;
|
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.dataset.id = this.id;
|
||||||
wrapper.setAttribute('role', 'application');
|
wrapper.setAttribute('role', 'application');
|
||||||
wrapper.setAttribute('aria-label', i18nT('edit'));
|
wrapper.setAttribute('aria-label', i18nT('edit'));
|
||||||
@@ -166,6 +167,7 @@ class MarkdownEditor {
|
|||||||
const textarea = document.createElement('textarea');
|
const textarea = document.createElement('textarea');
|
||||||
textarea.className = 'me-textarea';
|
textarea.className = 'me-textarea';
|
||||||
textarea.spellcheck = !!this.config.spellcheck;
|
textarea.spellcheck = !!this.config.spellcheck;
|
||||||
|
textarea.readOnly = !!this.config.readOnly;
|
||||||
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
|
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
|
||||||
textarea.setAttribute('aria-label', i18nT('edit'));
|
textarea.setAttribute('aria-label', i18nT('edit'));
|
||||||
editorPane.appendChild(textarea);
|
editorPane.appendChild(textarea);
|
||||||
@@ -410,6 +412,7 @@ class MarkdownEditor {
|
|||||||
const startX = e.clientX;
|
const startX = e.clientX;
|
||||||
const editorWidth = this.editorPane.getBoundingClientRect().width;
|
const editorWidth = this.editorPane.getBoundingClientRect().width;
|
||||||
const totalWidth = this.bodyEl.getBoundingClientRect().width;
|
const totalWidth = this.bodyEl.getBoundingClientRect().width;
|
||||||
|
if (totalWidth <= 0) return;
|
||||||
const divider = this.dividerEl;
|
const divider = this.dividerEl;
|
||||||
|
|
||||||
const onMove = (ev) => {
|
const onMove = (ev) => {
|
||||||
@@ -443,6 +446,7 @@ class MarkdownEditor {
|
|||||||
_render() {
|
_render() {
|
||||||
if (this._mode === 'edit') return;
|
if (this._mode === 'edit') return;
|
||||||
MarkdownEditor.trigger('beforeRender', this);
|
MarkdownEditor.trigger('beforeRender', this);
|
||||||
|
this._emit('beforeRender', this);
|
||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
highlight: this._highlightFn,
|
highlight: this._highlightFn,
|
||||||
@@ -463,13 +467,14 @@ class MarkdownEditor {
|
|||||||
|
|
||||||
this.previewEl.innerHTML = html;
|
this.previewEl.innerHTML = html;
|
||||||
MarkdownEditor.trigger('afterRender', this);
|
MarkdownEditor.trigger('afterRender', this);
|
||||||
|
this._emit('afterRender', this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 历史栈 ============
|
// ============ 历史栈 ============
|
||||||
|
|
||||||
_scheduleHistory() {
|
_scheduleHistory() {
|
||||||
if (this._historyTimer) clearTimeout(this._historyTimer);
|
if (this._historyTimer) clearTimeout(this._historyTimer);
|
||||||
this._historyTimer = setTimeout(() => this._pushHistory(), 400);
|
this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
_pushHistory() {
|
_pushHistory() {
|
||||||
@@ -668,10 +673,16 @@ class MarkdownEditor {
|
|||||||
|
|
||||||
setMode(mode) {
|
setMode(mode) {
|
||||||
if (!MODES.includes(mode) || mode === this._mode) return this;
|
if (!MODES.includes(mode) || mode === this._mode) return this;
|
||||||
|
// 保存当前滚动位置
|
||||||
|
const taScroll = this.textarea ? this.textarea.scrollTop : 0;
|
||||||
|
const pvScroll = this.previewPane ? this.previewPane.scrollTop : 0;
|
||||||
this._mode = mode;
|
this._mode = mode;
|
||||||
this.bodyEl.className = `me-body me-mode-${mode}`;
|
this.bodyEl.className = `me-body me-mode-${mode}`;
|
||||||
this._updateModeButtons();
|
this._updateModeButtons();
|
||||||
if (mode !== 'edit') this._render();
|
if (mode !== 'edit') this._render();
|
||||||
|
// 恢复滚动位置
|
||||||
|
if (this.textarea) this.textarea.scrollTop = taScroll;
|
||||||
|
if (this.previewPane) this.previewPane.scrollTop = pvScroll;
|
||||||
this._emit('modeChange', mode);
|
this._emit('modeChange', mode);
|
||||||
if (typeof this.config.onModeChange === 'function') {
|
if (typeof this.config.onModeChange === 'function') {
|
||||||
try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); }
|
try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); }
|
||||||
@@ -816,6 +827,18 @@ class MarkdownEditor {
|
|||||||
|
|
||||||
isDisabled() { return this.textarea.disabled; }
|
isDisabled() { return this.textarea.disabled; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置只读模式
|
||||||
|
*/
|
||||||
|
setReadOnly(readOnly) {
|
||||||
|
this.config.readOnly = !!readOnly;
|
||||||
|
if (this.textarea) this.textarea.readOnly = !!readOnly;
|
||||||
|
if (this.el) this.el.classList.toggle('me-readonly', !!readOnly);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
isReadOnly() { return this.config.readOnly; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册实例事件监听
|
* 注册实例事件监听
|
||||||
* 支持事件:change/input/focus/blur/save/modeChange/fullscreen/beforeCreate/afterCreate/beforeRender/afterRender/destroy
|
* 支持事件:change/input/focus/blur/save/modeChange/fullscreen/beforeCreate/afterCreate/beforeRender/afterRender/destroy
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor i18n - 国际化管理
|
* MetonaEditor i18n - 国际化管理
|
||||||
* @module i18n
|
* @module i18n
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @description 多语言支持、语言切换和翻译管理
|
* @description 多语言支持、语言切换和翻译管理
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Icons — 工具栏图标SVG定义
|
* MetonaEditor Icons — 工具栏图标SVG定义
|
||||||
* @module icons
|
* @module icons
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @description 工具栏格式化按钮 SVG 图标
|
* @description 工具栏格式化按钮 SVG 图标
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor - 轻量级 Markdown Editor 库
|
* MetonaEditor - 轻量级 Markdown Editor 库
|
||||||
* @module metona-editor
|
* @module metona-editor
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @author thzxx
|
* @author thzxx
|
||||||
* @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。
|
* @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。
|
||||||
* @license MIT
|
* @license MIT
|
||||||
@@ -29,7 +29,7 @@ import { animationUtils } from './animations.js';
|
|||||||
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants.js';
|
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants.js';
|
||||||
|
|
||||||
// 版本信息
|
// 版本信息
|
||||||
const VERSION = '0.1.1';
|
const VERSION = '0.1.2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局默认插件队列
|
* 全局默认插件队列
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Locales — 国际化翻译数据
|
* MetonaEditor Locales — 国际化翻译数据
|
||||||
* @module locales
|
* @module locales
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @description 内置 zh-CN / en-US 完整翻译
|
* @description 内置 zh-CN / en-US 完整翻译
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+58
-4
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Parser - 轻量 Markdown 解析器
|
* MetonaEditor Parser - 轻量 Markdown 解析器
|
||||||
* @module parser
|
* @module parser
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展
|
* @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展
|
||||||
*
|
*
|
||||||
* 支持语法:
|
* 支持语法:
|
||||||
@@ -28,6 +28,30 @@
|
|||||||
|
|
||||||
import { escapeHTML } from './utils.js';
|
import { escapeHTML } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 常用 Emoji 短码映射(子集)
|
||||||
|
*/
|
||||||
|
const EMOJI_MAP = {
|
||||||
|
smile: '😊', grinning: '😀', joy: '😂', rofl: '🤣', wink: '😉',
|
||||||
|
heart: '❤️', heart_eyes: '😍', star: '⭐', fire: '🔥', rocket: '🚀',
|
||||||
|
thumbsup: '👍', thumbsdown: '👎', clap: '👏', pray: '🙏', muscle: '💪',
|
||||||
|
ok: '👌', point_up: '👆', point_down: '👇', point_left: '👈', point_right: '👉',
|
||||||
|
check: '✅', cross: '❌', warning: '⚠️', info: 'ℹ️', question: '❓',
|
||||||
|
bulb: '💡', book: '📖', memo: '📝', pin: '📌', link: '🔗',
|
||||||
|
lock: '🔒', unlock: '🔓', key: '🔑', hammer: '🔨', wrench: '🔧',
|
||||||
|
gear: '⚙️', tools: '🛠️', magnet: '🧲', zap: '⚡', cloud: '☁️',
|
||||||
|
sun: '☀️', moon: '🌙', rain: '🌧️', snow: '❄️', umbrella: '☂️',
|
||||||
|
coffee: '☕', pizza: '🍕', cake: '🎂', beer: '🍺', wine: '🍷',
|
||||||
|
tada: '🎉', gift: '🎁', crown: '👑', gem: '💎', ring: '💍',
|
||||||
|
eye: '👁️', ear: '👂', nose: '👃', tongue: '👅', lips: '👄',
|
||||||
|
brain: '🧠', speech: '💬', thought: '💭', anger: '💢', sweat: '💦',
|
||||||
|
one: '1️⃣', two: '2️⃣', three: '3️⃣', four: '4️⃣', five: '5️⃣',
|
||||||
|
arrow_up: '⬆️', arrow_down: '⬇️', arrow_left: '⬅️', arrow_right: '➡️',
|
||||||
|
copyright: '©️', registered: '®️', tm: '™️', x: '❌', o: '⭕',
|
||||||
|
hundred: '💯', boom: '💥', dash: '💨', hole: '🕳️', bomb: '💣',
|
||||||
|
email: '📧', phone: '📞', clock: '🕐', hourglass: '⏳', calendar: '📅',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 危险协议过滤
|
* 危险协议过滤
|
||||||
*/
|
*/
|
||||||
@@ -105,13 +129,14 @@ export const parseMarkdown = (md, env = {}) => {
|
|||||||
// 表格:当前行含 |,下一行是分隔行
|
// 表格:当前行含 |,下一行是分隔行
|
||||||
if (/\|/.test(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
if (/\|/.test(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
||||||
const header = line;
|
const header = line;
|
||||||
|
const align = parseAlign(lines[i + 1]);
|
||||||
i += 2;
|
i += 2;
|
||||||
const rows = [];
|
const rows = [];
|
||||||
while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) {
|
while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) {
|
||||||
rows.push(lines[i]);
|
rows.push(lines[i]);
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
tokens.push({ type: 'table', header, rows });
|
tokens.push({ type: 'table', header, rows, align });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +195,21 @@ const isTableSeparator = (line) => {
|
|||||||
return /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line) && /-/.test(line);
|
return /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line) && /-/.test(line);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析表格分隔行中的对齐信息
|
||||||
|
*/
|
||||||
|
const parseAlign = (line) => {
|
||||||
|
const cells = line.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
|
||||||
|
return cells.map((c) => {
|
||||||
|
const l = /^:/.test(c);
|
||||||
|
const r = /:$/.test(c);
|
||||||
|
if (l && r) return 'center';
|
||||||
|
if (r) return 'right';
|
||||||
|
if (l) return 'left';
|
||||||
|
return 'left';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染单个块级 token
|
* 渲染单个块级 token
|
||||||
*/
|
*/
|
||||||
@@ -233,12 +273,14 @@ const renderCode = (code, lang, env) => {
|
|||||||
const renderTable = (tok, env) => {
|
const renderTable = (tok, env) => {
|
||||||
const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
|
const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
|
||||||
const headers = splitRow(tok.header);
|
const headers = splitRow(tok.header);
|
||||||
|
const align = tok.align || [];
|
||||||
|
const alignStyle = (i) => align[i] ? ` style="text-align:${align[i]}"` : '';
|
||||||
let html = '<div class="me-table-wrap"><table><thead><tr>';
|
let html = '<div class="me-table-wrap"><table><thead><tr>';
|
||||||
html += headers.map((h) => `<th>${renderInline(h, env)}</th>`).join('');
|
html += headers.map((h, i) => `<th${alignStyle(i)}>${renderInline(h, env)}</th>`).join('');
|
||||||
html += '</tr></thead><tbody>';
|
html += '</tr></thead><tbody>';
|
||||||
html += tok.rows.map((r) => {
|
html += tok.rows.map((r) => {
|
||||||
const cells = splitRow(r);
|
const cells = splitRow(r);
|
||||||
return `<tr>${cells.map((c) => `<td>${renderInline(c, env)}</td>`).join('')}</tr>`;
|
return `<tr>${cells.map((c, i) => `<td${alignStyle(i)}>${renderInline(c, env)}</td>`).join('')}</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
html += '</tbody></table></div>';
|
html += '</tbody></table></div>';
|
||||||
return html;
|
return html;
|
||||||
@@ -297,9 +339,21 @@ const renderInline = (text, env) => {
|
|||||||
// 8. 删除线 ~~text~~(非贪婪匹配,支持内含单个 ~ 字符)
|
// 8. 删除线 ~~text~~(非贪婪匹配,支持内含单个 ~ 字符)
|
||||||
s = s.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
s = s.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
||||||
|
|
||||||
|
// 8b. 高亮标记 ==text==
|
||||||
|
s = s.replace(/==(.+?)==/g, '<mark>$1</mark>');
|
||||||
|
|
||||||
|
// 8c. 上标 ^text^(排除空白和首尾脱字符)
|
||||||
|
s = s.replace(/\^([^\s^].*?[^\s^]|[^\s^])\^/g, '<sup>$1</sup>');
|
||||||
|
|
||||||
|
// 8d. 下标 ~text~(排除空白和首尾波浪线)
|
||||||
|
s = s.replace(/~([^\s~].*?[^\s~]|[^\s~])~/g, '<sub>$1</sub>');
|
||||||
|
|
||||||
// 9. 还原行内代码
|
// 9. 还原行内代码
|
||||||
s = s.replace(/\u0000(\d+)\u0000/g, (m, idx) => `<code>${escapeHTML(codes[+idx])}</code>`);
|
s = s.replace(/\u0000(\d+)\u0000/g, (m, idx) => `<code>${escapeHTML(codes[+idx])}</code>`);
|
||||||
|
|
||||||
|
// 9b. Emoji 短码 :name:
|
||||||
|
s = s.replace(/:(\w+):/g, (m, name) => EMOJI_MAP[name] || m);
|
||||||
|
|
||||||
// 10. 软换行
|
// 10. 软换行
|
||||||
s = s.replace(/\n/g, '<br/>');
|
s = s.replace(/\n/g, '<br/>');
|
||||||
|
|
||||||
|
|||||||
+43
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Plugins - 插件系统
|
* MetonaEditor Plugins - 插件系统
|
||||||
* @module plugins
|
* @module plugins
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace)
|
* @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace)
|
||||||
*
|
*
|
||||||
* 插件约定:
|
* 插件约定:
|
||||||
@@ -419,6 +419,47 @@ const searchReplacePlugin = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片粘贴插件
|
||||||
|
* 监听 textarea 粘贴事件,自动将剪贴板中的图片转换为 base64 data URI 并插入。
|
||||||
|
*/
|
||||||
|
const imagePastePlugin = {
|
||||||
|
name: 'imagePaste',
|
||||||
|
description: '粘贴图片自动转为 base64 内联',
|
||||||
|
|
||||||
|
_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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预设插件注册表
|
* 预设插件注册表
|
||||||
*/
|
*/
|
||||||
@@ -426,6 +467,7 @@ const presetPlugins = {
|
|||||||
autoSave: autoSavePlugin,
|
autoSave: autoSavePlugin,
|
||||||
exportTool: exportToolPlugin,
|
exportTool: exportToolPlugin,
|
||||||
searchReplace: searchReplacePlugin,
|
searchReplace: searchReplacePlugin,
|
||||||
|
imagePaste: imagePastePlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+29
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Styles - 编辑器样式
|
* MetonaEditor Styles - 编辑器样式
|
||||||
* @module styles
|
* @module styles
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @description 编辑器 UI 样式注入与管理
|
* @description 编辑器 UI 样式注入与管理
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -323,6 +323,34 @@ const generateCSS = () => {
|
|||||||
/* ============ 全屏模式优化 ============ */
|
/* ============ 全屏模式优化 ============ */
|
||||||
.me-wrapper.me-fullscreen .me-preview,
|
.me-wrapper.me-fullscreen .me-preview,
|
||||||
.me-wrapper.me-fullscreen .me-textarea { font-size: 15px; }
|
.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.45; pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 高亮标记 ============ */
|
||||||
|
.me-preview mark {
|
||||||
|
background: rgba(250, 204, 21, 0.3);
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.1em 0.2em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 打印样式 ============ */
|
||||||
|
@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; }
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Themes - 主题管理
|
* MetonaEditor Themes - 主题管理
|
||||||
* @module themes
|
* @module themes
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @description 主题系统、自定义主题和主题切换
|
* @description 主题系统、自定义主题和主题切换
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Utils - 工具函数
|
* MetonaEditor Utils - 工具函数
|
||||||
* @module utils
|
* @module utils
|
||||||
* @version 0.1.1
|
* @version 0.1.2
|
||||||
* @description 通用工具函数集合
|
* @description 通用工具函数集合
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -1241,3 +1241,41 @@ describe('MarkdownEditor - 零散分支补全', () => {
|
|||||||
ed.destroy();
|
ed.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MarkdownEditor - v0.1.2 readOnly 模式', () => {
|
||||||
|
test('config.readOnly 设置 textarea readonly', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { readOnly: true, value: 'locked' });
|
||||||
|
expect(ed.textarea.readOnly).toBe(true);
|
||||||
|
expect(ed.el.classList.contains('me-readonly')).toBe(true);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setReadOnly 动态切换', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, {});
|
||||||
|
expect(ed.isReadOnly()).toBe(false);
|
||||||
|
ed.setReadOnly(true);
|
||||||
|
expect(ed.isReadOnly()).toBe(true);
|
||||||
|
expect(ed.textarea.readOnly).toBe(true);
|
||||||
|
expect(ed.el.classList.contains('me-readonly')).toBe(true);
|
||||||
|
ed.setReadOnly(false);
|
||||||
|
expect(ed.isReadOnly()).toBe(false);
|
||||||
|
expect(ed.textarea.readOnly).toBe(false);
|
||||||
|
expect(ed.el.classList.contains('me-readonly')).toBe(false);
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readOnly 模式下内容仍可读取', () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const c = document.createElement('div');
|
||||||
|
document.body.appendChild(c);
|
||||||
|
const ed = new MarkdownEditor(c, { readOnly: true, value: '# hello' });
|
||||||
|
expect(ed.getValue()).toBe('# hello');
|
||||||
|
ed.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+39
-4
@@ -228,10 +228,10 @@ describe('parseMarkdown - 表格', () => {
|
|||||||
const html = parseMarkdown(md);
|
const html = parseMarkdown(md);
|
||||||
expect(html).toContain('<table>');
|
expect(html).toContain('<table>');
|
||||||
expect(html).toContain('<thead>');
|
expect(html).toContain('<thead>');
|
||||||
expect(html).toContain('<th>a</th>');
|
expect(html).toContain('<th style="text-align:left">a</th>');
|
||||||
expect(html).toContain('<th>b</th>');
|
expect(html).toContain('<th style="text-align:left">b</th>');
|
||||||
expect(html).toContain('<td>1</td>');
|
expect(html).toContain('<td style="text-align:left">1</td>');
|
||||||
expect(html).toContain('<td>2</td>');
|
expect(html).toContain('<td style="text-align:left">2</td>');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('表格被 me-table-wrap 包裹', () => {
|
test('表格被 me-table-wrap 包裹', () => {
|
||||||
@@ -351,3 +351,38 @@ describe('slugify', () => {
|
|||||||
expect(slugify('-hello-')).toBe('hello');
|
expect(slugify('-hello-')).toBe('hello');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseMarkdown - v0.1.2 新语法', () => {
|
||||||
|
test('高亮标记 ==text==', () => {
|
||||||
|
const html = parseMarkdown('==highlight==');
|
||||||
|
expect(html).toContain('<mark>highlight</mark>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('上标 ^text^', () => {
|
||||||
|
const html = parseMarkdown('x^2^');
|
||||||
|
expect(html).toContain('<sup>2</sup>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('下标 ~text~', () => {
|
||||||
|
const html = parseMarkdown('H~2~O');
|
||||||
|
expect(html).toContain('<sub>2</sub>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Emoji 短码 :smile:', () => {
|
||||||
|
const html = parseMarkdown(':smile: :rocket:');
|
||||||
|
expect(html).toContain('😊');
|
||||||
|
expect(html).toContain('🚀');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未知 Emoji 短码保留原文', () => {
|
||||||
|
const html = parseMarkdown(':unknown_emoji:');
|
||||||
|
expect(html).toContain(':unknown_emoji:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('表格对齐 :---: 居中', () => {
|
||||||
|
const md = '| a | b |\n| :---: | ---: |\n| 1 | 2 |';
|
||||||
|
const html = parseMarkdown(md);
|
||||||
|
expect(html).toContain('text-align:center');
|
||||||
|
expect(html).toContain('text-align:right');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Vendored
+8
-1
@@ -44,6 +44,10 @@ export interface MarkdownEditorOptions {
|
|||||||
syncScroll?: boolean;
|
syncScroll?: boolean;
|
||||||
/** Tab 插入的空格数,0 表示插入 \t,默认 2 */
|
/** Tab 插入的空格数,0 表示插入 \t,默认 2 */
|
||||||
tabSize?: number;
|
tabSize?: number;
|
||||||
|
/** 只读模式,默认 false */
|
||||||
|
readOnly?: boolean;
|
||||||
|
/** 历史栈防抖延迟(ms),默认 400 */
|
||||||
|
historyDebounce?: number;
|
||||||
/** 主题,默认 'auto' */
|
/** 主题,默认 'auto' */
|
||||||
theme?: ThemeName;
|
theme?: ThemeName;
|
||||||
/** 国际化语言,默认 'zh-CN' */
|
/** 国际化语言,默认 'zh-CN' */
|
||||||
@@ -119,7 +123,8 @@ export interface EditorStatus {
|
|||||||
/** 事件名 */
|
/** 事件名 */
|
||||||
export type EditorEventName =
|
export type EditorEventName =
|
||||||
| 'input' | 'change' | 'focus' | 'blur' | 'save'
|
| 'input' | 'change' | 'focus' | 'blur' | 'save'
|
||||||
| 'modeChange' | 'fullscreen' | 'autosave' | 'destroy';
|
| 'modeChange' | 'fullscreen' | 'autosave' | 'destroy'
|
||||||
|
| 'beforeRender' | 'afterRender';
|
||||||
|
|
||||||
/** 全局钩子名 */
|
/** 全局钩子名 */
|
||||||
export type HookName =
|
export type HookName =
|
||||||
@@ -166,6 +171,8 @@ export class MarkdownEditor {
|
|||||||
enable(): this;
|
enable(): this;
|
||||||
disable(): this;
|
disable(): this;
|
||||||
isDisabled(): boolean;
|
isDisabled(): boolean;
|
||||||
|
setReadOnly(readOnly: boolean): this;
|
||||||
|
isReadOnly(): boolean;
|
||||||
|
|
||||||
// 事件
|
// 事件
|
||||||
on(name: EditorEventName, fn: (...args: any[]) => void): () => void;
|
on(name: EditorEventName, fn: (...args: any[]) => void): () => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user