release: v0.1.7 — editing experience: gutter, auto-format, bracket close, drag-drop, outline
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 
- 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
This commit is contained in:
+320
-4
@@ -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 += `<div class="me-gutter-line">${i}</div>`;
|
||||
}
|
||||
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(`\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-6])\s+id="([^"]+)"[^>]*>(.+?)<\/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 = '<div class="me-outline-title">' + (i18nT('outline') || '大纲') + '</div>';
|
||||
|
||||
const buildTree = (items, minLevel) => {
|
||||
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 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 += '</li>';
|
||||
i = j > i + 1 ? j : i + 1;
|
||||
}
|
||||
h += '</ul>';
|
||||
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') {
|
||||
|
||||
Reference in New Issue
Block a user