From 289b8e55a61e3821840c08178563ddc6824ab540 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Fri, 24 Jul 2026 10:33:12 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v0.1.7=20=E2=80=94=20editing=20exper?= =?UTF-8?q?ience:=20gutter,=20auto-format,=20bracket=20close,=20drag-drop,?= =?UTF-8?q?=20outline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(core): line number gutter with current line highlight - .me-gutter rendered alongside textarea, sync-scrolls with content - .me-gutter-active highlights the line containing the cursor - config.lineNumbers (default true) to toggle feat(core): smart Enter auto-formatting - List continuation: '- ' / '1. ' auto-insert on Enter at end of list item - Ordered list auto-increment: '1.' → '2.' - Quote continuation: '> ' auto-insert on Enter at end of quote line - Empty list/quote item: Enter removes the marker (end list) feat(core): bracket/quote auto-close - (), [], {}, <>, "", '', ``, **, __ auto-close pairs - Selection wrapping: select text then press ( → wraps as (text) - config.autoBrackets (default true) to toggle feat(core): drag & drop file support - Drop image files → auto base64 inline insert ![](data:...) - Drop text/code files → insert file contents - Drop external text from browser → insert at cursor feat(core): outline/TOC panel - Extracts headings from rendered HTML preview - Nested tree with indentation by heading level - Click to scroll-jump in both preview and textarea - config.outline (default false) to toggle style: gutter, outline panel, current line highlight CSS test: 11 new tests for gutter, auto-format, bracket close, outline (532 total) chore: bump version 0.1.6 → 0.1.7 across all files, site, types --- README.md | 5 +- package.json | 2 +- site/demo.html | 4 +- site/docs.html | 4 +- src/animations.js | 2 +- src/constants.js | 8 +- src/core.js | 324 ++++++++++++++++++++++++++++++++++++++++++++- src/i18n.js | 2 +- src/icons.js | 2 +- src/index.js | 4 +- src/locales.js | 2 +- src/parser.js | 2 +- src/plugins.js | 2 +- src/styles.js | 64 ++++++++- src/themes.js | 2 +- src/utils.js | 2 +- tests/core.test.js | 130 ++++++++++++++++++ types/index.d.ts | 2 +- 18 files changed, 539 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 39db01e..123bc20 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ > 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。 -[![npm version](https://img.shields.io/badge/version-0.1.6-blue.svg)](https://www.npmjs.com/package/@metona-team/metona-editor) +[![npm version](https://img.shields.io/badge/version-0.1.7-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-521%20passed-brightgreen.svg)](./tests) +[![tests](https://img.shields.io/badge/tests-532%20passed-brightgreen.svg)](./tests) [![coverage](https://img.shields.io/badge/coverage-97%25-brightgreen.svg)](./tests) --- @@ -19,6 +19,7 @@ - **安全** — 内置 XSS 防护(`javascript:`/`vbscript:`/`file:` 协议过滤),HTML 转义,`safeUrl` 净化 - **内置解析器** — 自研 CommonMark 子集 + GFM 扩展(表格含列对齐、任务列表、上下标、数学公式、脚注、嵌套列表、emoji 短码等),可整体替换 - **三模式视图** — edit / split / preview 自由切换,分屏拖拽调整比例,滚动同步,模式切换保持滚动位置 +- **编辑体验** — 行号装订线 + 当前行高亮、智能 Enter(列表/引用自动延续)、括号/引号自动闭合、拖放文件/图片/文本、大纲/目录面板 - **主题系统** — light / dark / auto / warm 四套预设,CSS 变量定制,实例级主题隔离,外部主题跟随(syncWithElement / adoptFromParent),主题继承(extends),跟随系统主题,@media print 打印样式 - **国际化** — zh-CN / en-US 完整翻译,实例级语言隔离,复数规则,命名空间,动态远程加载翻译包,RTL 支持 - **历史栈** — 撤销/重做,防抖合并(可配置延迟),可配置上限 diff --git a/package.json b/package.json index 850fc7e..372000a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metona-team/metona-editor", - "version": "0.1.6", + "version": "0.1.7", "description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。", "type": "module", "main": "dist/metona-editor.js", diff --git a/site/demo.html b/site/demo.html index 3efa1ae..e5f3983 100644 --- a/site/demo.html +++ b/site/demo.html @@ -313,9 +313,9 @@ '', '| 名称 | 值 |', '| --- | --- |', - '| 版本 | 0.1.6 |', + '| 版本 | 0.1.7 |', '| 依赖 | 零 |', - '| 测试 | 521+ |', + '| 测试 | 532+ |', '', '## 新增语法', '', diff --git a/site/docs.html b/site/docs.html index 8d82cb2..d3342a9 100644 --- a/site/docs.html +++ b/site/docs.html @@ -175,7 +175,7 @@

文档

-

MetonaEditor v0.1.6 完整 API、配置与使用指南

+

MetonaEditor v0.1.7 完整 API、配置与使用指南

@@ -618,7 +618,7 @@ i18nUtils.formatDate
- MetonaEditor v0.1.6 · 源码仓库 · MIT License + MetonaEditor v0.1.7 · 源码仓库 · MIT License
diff --git a/src/animations.js b/src/animations.js index 11cfdbd..c2c6e87 100644 --- a/src/animations.js +++ b/src/animations.js @@ -1,7 +1,7 @@ /** * MetonaEditor Animations - 动画管理 * @module animations - * @version 0.1.6 + * @version 0.1.7 * @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用; * 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块) */ diff --git a/src/constants.js b/src/constants.js index 6484a61..405df20 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,7 +1,7 @@ /** * MetonaEditor Constants - 常量定义 * @module constants - * @version 0.1.6 + * @version 0.1.7 * @description 默认配置、主题、动画、工具栏等常量 */ @@ -50,6 +50,12 @@ export const DEFAULTS = Object.freeze({ tabSize: 2, // 只读模式 readOnly: false, + // 显示行号(v0.1.7) + lineNumbers: true, + // 显示大纲面板(v0.1.7) + outline: false, + // 自动括号闭合(v0.1.7) + autoBrackets: true, // 主题:light / dark / auto / warm / 自定义 theme: 'auto', // 国际化 diff --git a/src/core.js b/src/core.js index dffffe8..5915240 100644 --- a/src/core.js +++ b/src/core.js @@ -1,9 +1,10 @@ /** * MetonaEditor Core - 编辑器核心 * @module core - * @version 0.1.6 + * @version 0.1.7 * @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换 * v0.1.6: 插件依赖拓扑排序、实例 i18n、快捷键/工具栏定制、右键菜单、Toast + * v0.1.7: 行号装订线、自动格式化、括号自动闭合、拖放、大纲面板 */ import { generateId, escapeHTML, isBrowser } from './utils.js'; @@ -24,6 +25,13 @@ const TOAST_ICONS = { info: 'ℹ', }; +/** 括号/引号自动闭合映射 */ +const BRACKET_PAIRS = { + '(': ')', '[': ']', '{': '}', '<': '>', + '"': '"', "'": "'", '`': '`', + '*': '*', '_': '_', +}; + /** * 解析主题名称为实际主题(auto -> light/dark) */ @@ -189,6 +197,16 @@ class MarkdownEditor { const editorPane = document.createElement('div'); editorPane.className = 'me-editor-pane'; + // 编辑区内层容器(行号 + textarea 水平排列) + const editorInner = document.createElement('div'); + editorInner.className = 'me-editor-inner'; + + // 行号装订线(v0.1.7) + 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; @@ -196,7 +214,9 @@ class MarkdownEditor { textarea.style.tabSize = String(this.config.tabSize || 2); textarea.placeholder = this.config.placeholder || i18nT('placeholder'); textarea.setAttribute('aria-label', i18nT('edit')); - editorPane.appendChild(textarea); + editorInner.appendChild(textarea); + + editorPane.appendChild(editorInner); const divider = document.createElement('div'); divider.className = 'me-divider'; @@ -232,6 +252,8 @@ class MarkdownEditor { this.toolbarEl = toolbar; this.bodyEl = body; this.editorPane = editorPane; + this.editorInner = editorInner; + this.gutter = gutter; this.previewPane = previewPane; this.dividerEl = divider; this.textarea = textarea; @@ -296,6 +318,8 @@ class MarkdownEditor { 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') { @@ -307,10 +331,27 @@ class MarkdownEditor { }; ta.addEventListener('input', onInput); - const onKeydown = (e) => this._handleKeydown(e); + const onKeydown = (e) => { + // v0.1.7: 智能 Enter + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + this._handleSmartEnter(e); + } + this._handleKeydown(e); + }; ta.addEventListener('keydown', onKeydown); + // v0.1.7: 括号/引号自动闭合 + const onKeypress = (e) => this._handleBracketAutoClose(e); + ta.addEventListener('keypress', onKeypress); + + // v0.1.7: 当前行高亮 + const onCursorActivity = () => { this._updateCurrentLine(); }; + ta.addEventListener('click', onCursorActivity); + ta.addEventListener('keyup', onCursorActivity); + const onScroll = () => { + // 同步行号滚动 + if (this.gutter) this.gutter.scrollTop = ta.scrollTop; if (!this.config.syncScroll || this._mode !== 'split') return; const max = ta.scrollHeight - ta.clientHeight; const ratio = max > 0 ? ta.scrollTop / max : 0; @@ -352,10 +393,16 @@ class MarkdownEditor { const onDividerDown = (e) => this._bindDividerDrag(e); this.dividerEl.addEventListener('pointerdown', onDividerDown); + // v0.1.7: 拖放支持 + this._bindDragDrop(); + this._cleanups.push(() => { ta.removeEventListener('input', onInput); ta.removeEventListener('keydown', onKeydown); + ta.removeEventListener('keypress', onKeypress); ta.removeEventListener('scroll', onScroll); + ta.removeEventListener('click', onCursorActivity); + ta.removeEventListener('keyup', onCursorActivity); ta.removeEventListener('focus', onFocus); ta.removeEventListener('blur', onBlur); this.toolbarEl.removeEventListener('click', onToolbarClick); @@ -513,11 +560,279 @@ class MarkdownEditor { } this.previewEl.innerHTML = html; + this._renderGutter(); MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this); } - // ============ 历史栈 ============ + // ============ 行号渲染(v0.1.7) ============ + + _renderGutter() { + if (!this.config.lineNumbers || !this.gutter) return; + const lines = this._value ? this._value.split('\n').length : 1; + const cur = this.gutter.children.length; + if (cur === lines) return; // 行数未变,跳过 + let html = ''; + for (let i = 1; i <= lines; i++) { + html += `
${i}
`; + } + this.gutter.innerHTML = html; + } + + _updateCurrentLine() { + 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]; + if (cur) cur.classList.add('me-gutter-active'); + } + + // ============ 自动格式化(v0.1.7) ============ + + /** + * 智能 Enter:在列表/引用行尾自动延续标记 + */ + _handleSmartEnter(e) { + 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); + + // 列表项延续:匹配 "- " / "* " / "+ " / "1. " / "2. " 等 + 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); + return; + } + } + + // ============ 括号/引号自动闭合(v0.1.7) ============ + + _handleBracketAutoClose(e) { + 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; + const hasSelection = start !== end; + + // 有选区:包裹选中文本 + if (hasSelection) { + e.preventDefault(); + const selected = ta.value.slice(start, end); + const wrap = key === '*' || key === '_' ? key + selected + close : 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] || ''; + const isQuotePair = key === close; // 引号自己闭合自己 + + if (isQuotePair) { + // 引号:下一个字符是同种引号 → 跳过而非插入 + 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); + } + + // ============ 拖放支持(v0.1.7) ============ + + _bindDragDrop() { + const ta = this.textarea; + if (!ta) return; + + const onDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }; + const onDrop = (e) => { + 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 = () => { + const name = file.name || `image-${Date.now().toString(36)}.png`; + this.insert(`![${name}](${reader.result})\n`); + }; + reader.readAsDataURL(file); + } else if (file.type.startsWith('text/') || file.name.match(/\.(md|txt|js|ts|json|css|html|xml|yml|yaml)$/i)) { + const reader = new FileReader(); + reader.onload = () => this.insert(reader.result); + 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); + }); + } + + // ============ 大纲面板(v0.1.7) ============ + + _buildOutline() { + if (!this.config.outline || !this.previewEl) return; + // 移除旧面板 + const old = this.el.querySelector('.me-outline'); + if (old) old.remove(); + + const headings = []; + const headingRe = /]*>(.+?)<\/h\1>/gi; + const html = this.previewEl.innerHTML; + let m; + while ((m = headingRe.exec(html)) !== 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 = '
' + (i18nT('outline') || '大纲') + '
'; + + const buildTree = (items, minLevel) => { + let h = '
    '; + let i = 0; + while (i < items.length) { + const item = items[i]; + if (item.level < minLevel) break; + h += `
  • ${escapeHTML(item.text)}`; + // 收集子项 + const subItems = []; + let j = i + 1; + while (j < items.length && items[j].level > item.level) { + subItems.push(items[j]); + j++; + } + if (subItems.length) { + h += buildTree(subItems, item.level + 1); + } + h += '
  • '; + i = j > i + 1 ? j : i + 1; + } + h += '
'; + return h; + }; + + panel.innerHTML += buildTree(headings, 1); + this.el.appendChild(panel); + + // 点击跳转 + panel.addEventListener('click', (e) => { + const a = e.target.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' }); + // 同步滚动 textarea + const headingText = target.textContent; + const idx = this._value.indexOf(headingText); + if (idx !== -1) { + this.textarea.focus(); + this.textarea.setSelectionRange(idx, idx); + const lineNum = this._value.substring(0, idx).split('\n').length - 1; + this.textarea.scrollTop = lineNum * 20; + } + } + }); + } + + _updateOutline() { + if (!this.config.outline) return; + // 防抖重建 + clearTimeout(this._outlineTimer); + this._outlineTimer = setTimeout(() => this._buildOutline(), 300); + } _scheduleHistory() { if (this._historyTimer) clearTimeout(this._historyTimer); @@ -552,6 +867,7 @@ class MarkdownEditor { this._value = this._history[this._historyIndex]; this.textarea.value = this._value; this._render(); + this._renderGutter(); this._updateWordCount(); this._emit('change', this._value); if (typeof this.config.onChange === 'function') { diff --git a/src/i18n.js b/src/i18n.js index 8681296..2f27b92 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1,7 +1,7 @@ /** * MetonaEditor i18n — 国际化管理(增强版) * @module i18n - * @version 0.1.6 + * @version 0.1.7 * @description 多语言支持、实例级语言隔离、复数规则、动态加载、命名空间 * * v0.1.6 增强: diff --git a/src/icons.js b/src/icons.js index ae12667..fa7cf45 100644 --- a/src/icons.js +++ b/src/icons.js @@ -1,7 +1,7 @@ /** * MetonaEditor Icons — 工具栏图标SVG定义 * @module icons - * @version 0.1.6 + * @version 0.1.7 * @description 工具栏格式化按钮 SVG 图标 */ diff --git a/src/index.js b/src/index.js index 2d0a551..eaf8357 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ /** * MetonaEditor - 轻量级 Markdown Editor 库 * @module metona-editor - * @version 0.1.6 + * @version 0.1.7 * @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.6'; +const VERSION = '0.1.7'; /** * 全局默认插件队列 diff --git a/src/locales.js b/src/locales.js index 995e8df..93df0b8 100644 --- a/src/locales.js +++ b/src/locales.js @@ -1,7 +1,7 @@ /** * MetonaEditor Locales — 国际化翻译数据 * @module locales - * @version 0.1.6 + * @version 0.1.7 * @description 内置 zh-CN / en-US 完整翻译 */ diff --git a/src/parser.js b/src/parser.js index b3a8d44..365324b 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,7 +1,7 @@ /** * MetonaEditor Parser - 轻量 Markdown 解析器(增强版) * @module parser - * @version 0.1.6 + * @version 0.1.7 * @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展 * * v0.1.4 增强: diff --git a/src/plugins.js b/src/plugins.js index 44687f1..cc96251 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -1,7 +1,7 @@ /** * MetonaEditor Plugins — 插件系统 v2 * @module plugins - * @version 0.1.6 + * @version 0.1.7 * @description 插件管理器 + 预设插件 + 依赖/生命周期/异步/卸载 * * v0.1.6 增强: diff --git a/src/styles.js b/src/styles.js index adfe472..c6e2380 100644 --- a/src/styles.js +++ b/src/styles.js @@ -1,7 +1,7 @@ /** * MetonaEditor Styles - 编辑器样式 * @module styles - * @version 0.1.6 + * @version 0.1.7 * @description 编辑器 UI 样式注入与管理 */ @@ -127,6 +127,32 @@ const generateCSS = () => { 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: 12px; + 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); @@ -454,6 +480,42 @@ const generateCSS = () => { 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; } + /* ============ 打印样式 ============ */ @media print { .me-wrapper { border: 0 !important; box-shadow: none !important; } diff --git a/src/themes.js b/src/themes.js index 56740ec..614e142 100644 --- a/src/themes.js +++ b/src/themes.js @@ -1,7 +1,7 @@ /** * MetonaEditor Themes — 主题系统(增强版) * @module themes - * @version 0.1.6 + * @version 0.1.7 * @description 全局+实例级主题管理、CSS 变量隔离、外部主题跟随、主题继承 * * v0.1.5 增强: diff --git a/src/utils.js b/src/utils.js index 0b3df7f..fa5ac11 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,7 @@ /** * MetonaEditor Utils - 工具函数 * @module utils - * @version 0.1.6 + * @version 0.1.7 * @description 通用工具函数集合 */ diff --git a/tests/core.test.js b/tests/core.test.js index 2e7864f..b09a12f 100644 --- a/tests/core.test.js +++ b/tests/core.test.js @@ -1352,3 +1352,133 @@ describe('MarkdownEditor - v0.1.3 修复验证', () => { expect(ed.isDisabled()).toBe(false); }); }); + +// ============ v0.1.7 新增测试 ============ + +describe('MarkdownEditor - v0.1.7 行号装订线', () => { + test('lineNumbers 开启时创建 gutter', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: 'line1\nline2\nline3', lineNumbers: true }); + expect(ed.gutter).not.toBeNull(); + expect(ed.gutter.children.length).toBeGreaterThan(0); + ed.destroy(); + }); + + test('lineNumbers 关闭时不显示', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: 'test', lineNumbers: false }); + expect(ed.gutter).not.toBeNull(); + expect(ed.gutter.style.display).toBe('none'); + ed.destroy(); + }); + + test('输入后行号更新', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: 'a\nb', lineNumbers: true }); + ed.textarea.value = 'a\nb\nc'; + ed.textarea.dispatchEvent(new Event('input', { bubbles: true })); + expect(ed.gutter.children.length).toBe(3); + ed.destroy(); + }); +}); + +describe('MarkdownEditor - v0.1.7 自动格式化', () => { + test('列表项行尾 Enter 自动延续 -', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: '- item' }); + ed.textarea.setSelectionRange(6, 6); // 光标在行尾 + ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(ed.getValue()).toContain('- '); + ed.destroy(); + }); + + 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(); + }); + + test('有序列表自动递增', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: '1. first' }); + ed.textarea.setSelectionRange(8, 8); + ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(ed.getValue()).toContain('2. '); + ed.destroy(); + }); + + test('引用块延续 >', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: '> quote' }); + ed.textarea.setSelectionRange(7, 7); + ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(ed.getValue()).toContain('> '); + ed.destroy(); + }); +}); + +describe('MarkdownEditor - v0.1.7 括号自动闭合', () => { + test('无选区输入 ( 自动闭合', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: 'hello', autoBrackets: true }); + ed.textarea.setSelectionRange(5, 5); + ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '(', bubbles: true })); + expect(ed.getValue()).toBe('hello()'); + expect(ed.textarea.selectionStart).toBe(6); // 光标在括号内 + 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(0, 5); + ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '(', bubbles: true })); + expect(ed.getValue()).toBe('(hello) world'); + ed.destroy(); + }); + + test('autoBrackets 关闭时不闭合', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: 'hello', autoBrackets: false }); + ed.textarea.setSelectionRange(5, 5); + // jsdom 不模拟原生键盘插入,验证不抛错即可 + expect(() => { + ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '(', bubbles: true })); + }).not.toThrow(); + ed.destroy(); + }); +}); + +describe('MarkdownEditor - v0.1.7 outline', () => { + test('outline 关闭时不创建面板', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: '# hi\n## there', outline: false, mode: 'split' }); + expect(ed.el.querySelector('.me-outline')).toBeNull(); + ed.destroy(); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index de7f954..051f2d8 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,4 @@ -// Type definitions for MetonaEditor v0.1.6 +// Type definitions for MetonaEditor v0.1.7 // Project: https://git.metona.cn/MetonaTeam/MetonaEditor // Author: thzxx