diff --git a/README.md b/README.md index a2d2d06..0923a7b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。 -[![npm version](https://img.shields.io/badge/version-0.1.1-blue.svg)](https://www.npmjs.com/package/@metona-team/metona-editor) +[![npm version](https://img.shields.io/badge/version-0.1.2-blue.svg)](https://www.npmjs.com/package/@metona-team/metona-editor) [![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) [![tests](https://img.shields.io/badge/tests-407%20passed-brightgreen.svg)](./tests) [![coverage](https://img.shields.io/badge/coverage-97%25-brightgreen.svg)](./tests) diff --git a/package.json b/package.json index 2d00cb7..3c9ec19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metona-team/metona-editor", - "version": "0.1.1", + "version": "0.1.2", "description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。", "type": "module", "main": "dist/metona-editor.js", diff --git a/site/demo.html b/site/demo.html index cff6b25..ee1665b 100644 --- a/site/demo.html +++ b/site/demo.html @@ -305,7 +305,7 @@ '', '| 名称 | 值 |', '| --- | --- |', - '| 版本 | 0.1.1 |', + '| 版本 | 0.1.2 |', '| 依赖 | 零 |', '| 测试 | 390+ |', '', diff --git a/src/animations.js b/src/animations.js index 57189c8..7dd9c66 100644 --- a/src/animations.js +++ b/src/animations.js @@ -1,7 +1,7 @@ /** * MetonaEditor Animations - 动画管理 * @module animations - * @version 0.1.1 + * @version 0.1.2 * @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用; * 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块) */ diff --git a/src/constants.js b/src/constants.js index f1baf3b..091040c 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,7 +1,7 @@ /** * MetonaEditor Constants - 常量定义 * @module constants - * @version 0.1.1 + * @version 0.1.2 * @description 默认配置、主题、动画、工具栏等常量 */ @@ -42,10 +42,14 @@ export const DEFAULTS = Object.freeze({ spellcheck: false, // 历史栈上限 historyLimit: 100, + // 历史栈防抖延迟(ms) + historyDebounce: 400, // 同步滚动(分屏模式) syncScroll: true, // 制表符插入的空格数(0 表示插入 \t) tabSize: 2, + // 只读模式 + readOnly: false, // 主题:light / dark / auto / warm / 自定义 theme: 'auto', // 国际化 diff --git a/src/core.js b/src/core.js index cafeed1..656f504 100644 --- a/src/core.js +++ b/src/core.js @@ -1,7 +1,7 @@ /** * MetonaEditor Core - 编辑器核心 * @module core - * @version 0.1.1 + * @version 0.1.2 * @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换 */ @@ -74,7 +74,7 @@ class MarkdownEditor { this.config = { ...DEFAULTS, ...options }; if (options.style && typeof options.style === 'object') { - this.config.style = { ...options.style }; + this.config.style = { ...DEFAULTS.style, ...options.style }; } this._value = this.config.value || ''; @@ -136,6 +136,7 @@ class MarkdownEditor { _buildDOM() { 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')); @@ -166,6 +167,7 @@ class MarkdownEditor { const textarea = document.createElement('textarea'); textarea.className = 'me-textarea'; textarea.spellcheck = !!this.config.spellcheck; + textarea.readOnly = !!this.config.readOnly; textarea.placeholder = this.config.placeholder || i18nT('placeholder'); textarea.setAttribute('aria-label', i18nT('edit')); editorPane.appendChild(textarea); @@ -410,6 +412,7 @@ class MarkdownEditor { 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) => { @@ -443,6 +446,7 @@ class MarkdownEditor { _render() { if (this._mode === 'edit') return; MarkdownEditor.trigger('beforeRender', this); + this._emit('beforeRender', this); const env = { highlight: this._highlightFn, @@ -463,13 +467,14 @@ class MarkdownEditor { this.previewEl.innerHTML = html; MarkdownEditor.trigger('afterRender', this); + this._emit('afterRender', this); } // ============ 历史栈 ============ _scheduleHistory() { if (this._historyTimer) clearTimeout(this._historyTimer); - this._historyTimer = setTimeout(() => this._pushHistory(), 400); + this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } _pushHistory() { @@ -668,10 +673,16 @@ class MarkdownEditor { setMode(mode) { 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.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit') this._render(); + // 恢复滚动位置 + if (this.textarea) this.textarea.scrollTop = taScroll; + if (this.previewPane) this.previewPane.scrollTop = pvScroll; this._emit('modeChange', mode); if (typeof this.config.onModeChange === 'function') { try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); } @@ -816,6 +827,18 @@ class MarkdownEditor { 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 diff --git a/src/i18n.js b/src/i18n.js index 36e545c..feb307d 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1,7 +1,7 @@ /** * MetonaEditor i18n - 国际化管理 * @module i18n - * @version 0.1.1 + * @version 0.1.2 * @description 多语言支持、语言切换和翻译管理 */ diff --git a/src/icons.js b/src/icons.js index 838a244..4f74813 100644 --- a/src/icons.js +++ b/src/icons.js @@ -1,7 +1,7 @@ /** * MetonaEditor Icons — 工具栏图标SVG定义 * @module icons - * @version 0.1.1 + * @version 0.1.2 * @description 工具栏格式化按钮 SVG 图标 */ diff --git a/src/index.js b/src/index.js index 6ed6837..4bc79e5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ /** * MetonaEditor - 轻量级 Markdown Editor 库 * @module metona-editor - * @version 0.1.1 + * @version 0.1.2 * @author thzxx * @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。 * @license MIT @@ -29,7 +29,7 @@ import { animationUtils } from './animations.js'; import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants.js'; // 版本信息 -const VERSION = '0.1.1'; +const VERSION = '0.1.2'; /** * 全局默认插件队列 diff --git a/src/locales.js b/src/locales.js index fa90840..95aed98 100644 --- a/src/locales.js +++ b/src/locales.js @@ -1,7 +1,7 @@ /** * MetonaEditor Locales — 国际化翻译数据 * @module locales - * @version 0.1.1 + * @version 0.1.2 * @description 内置 zh-CN / en-US 完整翻译 */ diff --git a/src/parser.js b/src/parser.js index fcdcd49..9641084 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,7 +1,7 @@ /** * MetonaEditor Parser - 轻量 Markdown 解析器 * @module parser - * @version 0.1.1 + * @version 0.1.2 * @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展 * * 支持语法: @@ -28,6 +28,30 @@ 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])) { const header = line; + const align = parseAlign(lines[i + 1]); i += 2; const rows = []; while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) { rows.push(lines[i]); i++; } - tokens.push({ type: 'table', header, rows }); + tokens.push({ type: 'table', header, rows, align }); continue; } @@ -170,6 +195,21 @@ const isTableSeparator = (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 */ @@ -233,12 +273,14 @@ const renderCode = (code, lang, env) => { const renderTable = (tok, env) => { const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/); const headers = splitRow(tok.header); + const align = tok.align || []; + const alignStyle = (i) => align[i] ? ` style="text-align:${align[i]}"` : ''; let html = '
'; - html += headers.map((h) => ``).join(''); + html += headers.map((h, i) => `${renderInline(h, env)}`).join(''); html += ''; html += tok.rows.map((r) => { const cells = splitRow(r); - return `${cells.map((c) => ``).join('')}`; + return `${cells.map((c, i) => `${renderInline(c, env)}`).join('')}`; }).join(''); html += '
${renderInline(h, env)}
${renderInline(c, env)}
'; return html; @@ -297,9 +339,21 @@ const renderInline = (text, env) => { // 8. 删除线 ~~text~~(非贪婪匹配,支持内含单个 ~ 字符) s = s.replace(/~~(.+?)~~/g, '$1'); + // 8b. 高亮标记 ==text== + s = s.replace(/==(.+?)==/g, '$1'); + + // 8c. 上标 ^text^(排除空白和首尾脱字符) + s = s.replace(/\^([^\s^].*?[^\s^]|[^\s^])\^/g, '$1'); + + // 8d. 下标 ~text~(排除空白和首尾波浪线) + s = s.replace(/~([^\s~].*?[^\s~]|[^\s~])~/g, '$1'); + // 9. 还原行内代码 s = s.replace(/\u0000(\d+)\u0000/g, (m, idx) => `${escapeHTML(codes[+idx])}`); + // 9b. Emoji 短码 :name: + s = s.replace(/:(\w+):/g, (m, name) => EMOJI_MAP[name] || m); + // 10. 软换行 s = s.replace(/\n/g, '
'); diff --git a/src/plugins.js b/src/plugins.js index 47efb81..9fea20c 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -1,7 +1,7 @@ /** * MetonaEditor Plugins - 插件系统 * @module plugins - * @version 0.1.1 + * @version 0.1.2 * @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(`![${name}](${dataUri})\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, exportTool: exportToolPlugin, searchReplace: searchReplacePlugin, + imagePaste: imagePastePlugin, }; /** diff --git a/src/styles.js b/src/styles.js index 5940d7e..da13cf0 100644 --- a/src/styles.js +++ b/src/styles.js @@ -1,7 +1,7 @@ /** * MetonaEditor Styles - 编辑器样式 * @module styles - * @version 0.1.1 + * @version 0.1.2 * @description 编辑器 UI 样式注入与管理 */ @@ -323,6 +323,34 @@ const generateCSS = () => { /* ============ 全屏模式优化 ============ */ .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.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; } + } `; }; diff --git a/src/themes.js b/src/themes.js index 1e3a5ba..9e3f5dc 100644 --- a/src/themes.js +++ b/src/themes.js @@ -1,7 +1,7 @@ /** * MetonaEditor Themes - 主题管理 * @module themes - * @version 0.1.1 + * @version 0.1.2 * @description 主题系统、自定义主题和主题切换 */ diff --git a/src/utils.js b/src/utils.js index 28fb656..7e1ca3c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,7 @@ /** * MetonaEditor Utils - 工具函数 * @module utils - * @version 0.1.1 + * @version 0.1.2 * @description 通用工具函数集合 */ diff --git a/tests/core.test.js b/tests/core.test.js index d0c77d2..eaf3ef2 100644 --- a/tests/core.test.js +++ b/tests/core.test.js @@ -1241,3 +1241,41 @@ describe('MarkdownEditor - 零散分支补全', () => { 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(); + }); +}); diff --git a/tests/parser.test.js b/tests/parser.test.js index 5a2f83b..0ee2c95 100644 --- a/tests/parser.test.js +++ b/tests/parser.test.js @@ -228,10 +228,10 @@ describe('parseMarkdown - 表格', () => { const html = parseMarkdown(md); expect(html).toContain(''); expect(html).toContain(''); - expect(html).toContain(''); - expect(html).toContain(''); - expect(html).toContain(''); - expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); }); test('表格被 me-table-wrap 包裹', () => { @@ -351,3 +351,38 @@ describe('slugify', () => { expect(slugify('-hello-')).toBe('hello'); }); }); + +describe('parseMarkdown - v0.1.2 新语法', () => { + test('高亮标记 ==text==', () => { + const html = parseMarkdown('==highlight=='); + expect(html).toContain('highlight'); + }); + + test('上标 ^text^', () => { + const html = parseMarkdown('x^2^'); + expect(html).toContain('2'); + }); + + test('下标 ~text~', () => { + const html = parseMarkdown('H~2~O'); + expect(html).toContain('2'); + }); + + 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'); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 01fb37c..00966df 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -44,6 +44,10 @@ export interface MarkdownEditorOptions { syncScroll?: boolean; /** Tab 插入的空格数,0 表示插入 \t,默认 2 */ tabSize?: number; + /** 只读模式,默认 false */ + readOnly?: boolean; + /** 历史栈防抖延迟(ms),默认 400 */ + historyDebounce?: number; /** 主题,默认 'auto' */ theme?: ThemeName; /** 国际化语言,默认 'zh-CN' */ @@ -119,7 +123,8 @@ export interface EditorStatus { /** 事件名 */ export type EditorEventName = | 'input' | 'change' | 'focus' | 'blur' | 'save' - | 'modeChange' | 'fullscreen' | 'autosave' | 'destroy'; + | 'modeChange' | 'fullscreen' | 'autosave' | 'destroy' + | 'beforeRender' | 'afterRender'; /** 全局钩子名 */ export type HookName = @@ -166,6 +171,8 @@ export class MarkdownEditor { enable(): this; disable(): this; isDisabled(): boolean; + setReadOnly(readOnly: boolean): this; + isReadOnly(): boolean; // 事件 on(name: EditorEventName, fn: (...args: any[]) => void): () => void;
ab12ab12