feat: v0.2.3 — 浮动格式工具栏 + 10个新API + 德语翻译 + 类型修复
CI / test (18.x) (push) Canceled after 0s
CI / test (20.x) (push) Canceled after 0s
CI / test (22.x) (push) Canceled after 0s
CI / test (24.x) (push) Canceled after 0s

### Added
- 浮动格式工具栏:选中文本自动弹出 bold/italic/code/link/strikethrough
- 10 个新 API: getSelectedText/getCursorPosition/setCursorPosition/scrollToLine/selectLine/selectAll/replaceAll/replaceAllRegex/lineCount/getLine
- 2 个新事件: selectionChange / cursorMove
- 德语 (de) locale 完整翻译
- floatingToolbar 配置项(默认 true)

### Fixed
- onLinkClick 移除 as any,正式加入 EditorOptions/DEFAULTS
- 所有版本号统一为 0.2.3

### Tests
- core.test.ts: 183 → 214 (+31 tests)
- 总计 ~725 tests
This commit is contained in:
2026-07-25 14:53:43 +08:00
parent 5184e9e8c1
commit 708f0937d8
13 changed files with 569 additions and 31 deletions
+188 -1
View File
@@ -82,6 +82,8 @@ export class MarkdownEditor {
_highlightFn!: ((code: string, lang: string) => string) | null;
_themeCtx: any;
_i18nCtx!: InstanceI18n;
_floatingToolbar!: HTMLElement | null;
_selectionTimer!: ReturnType<typeof setInterval> | null;
constructor(container: string | HTMLElement, options: EditorOptions = {}) {
if (!isBrowser()) {
@@ -105,6 +107,7 @@ export class MarkdownEditor {
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
this._outlineTimer = null; this._zenMode = false; this._wordWrap = true; this._syncing = false;
this._zenMouseHandler = null;
this._floatingToolbar = null; this._selectionTimer = null;
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
@@ -128,6 +131,7 @@ export class MarkdownEditor {
this._bindEvents();
this._bindContextMenu();
this._trackOutlineScroll();
if (this.config.floatingToolbar !== false) this._initFloatingToolbar();
this.textarea.value = this._value;
this._pushHistory();
@@ -273,7 +277,7 @@ export class MarkdownEditor {
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; }
if (typeof this.config.onLinkClick === 'function') { try { this.config.onLinkClick(href, a.textContent, this); } catch (_) {} return; }
window.open(href, '_blank', 'noopener');
};
this.previewEl.addEventListener('click', onPreviewClick);
@@ -519,6 +523,7 @@ export class MarkdownEditor {
ticking = true;
requestAnimationFrame(() => {
ticking = false;
if (!this.el) return;
const panel = this.el.querySelector('.me-outline');
if (!panel) return;
const headings = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
@@ -701,6 +706,100 @@ export class MarkdownEditor {
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; }
_initFloatingToolbar(): void {
if (typeof document === 'undefined') return;
// Inject floating toolbar CSS once globally
if (!document.getElementById('me-float-style')) {
const style = document.createElement('style');
style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;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);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`;
document.head.appendChild(style);
}
const ta = this.textarea;
const show = () => {
if (this._destroyed || this.config.readOnly) return;
const start = ta.selectionStart; const end = ta.selectionEnd;
if (start === end) { this._hideFloatingToolbar(); return; }
if (!this._floatingToolbar) this._buildFloatingToolbar();
// Position the toolbar near the selection
const rect = ta.getBoundingClientRect();
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
const lineHeight = parseInt(getComputedStyle(ta).lineHeight, 10) || 22;
let top = rect.top + currentLine * lineHeight - 38; // above the selection
let left = rect.left + (ta.selectionDirection !== 'backward' ? (ta.selectionEnd - ta.selectionStart) / 2 : 0) * 8 + 10;
// Clamp to stay within the editor pane
if (top < rect.top) top = rect.top + currentLine * lineHeight + lineHeight + 4;
if (left < rect.left) left = rect.left + 4;
const maxLeft = rect.right - 200;
if (left > maxLeft) left = maxLeft;
this._floatingToolbar!.style.top = top + 'px';
this._floatingToolbar!.style.left = left + 'px';
this._floatingToolbar!.classList.add('me-visible');
};
const hide = () => { this._hideFloatingToolbar(); };
// Detect selection changes
ta.addEventListener('mouseup', () => setTimeout(show, 0));
ta.addEventListener('keyup', () => {
if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0);
else setTimeout(hide, 0);
// Emit cursorMove
const pos = this.getCursorPosition();
this._emit('cursorMove', pos);
});
ta.addEventListener('blur', () => setTimeout(hide, 300));
ta.addEventListener('click', () => setTimeout(() => {
if (ta.selectionStart === ta.selectionEnd) hide();
}, 0));
// Emit selectionChange on selection changes
let lastSelStart = ta.selectionStart;
let lastSelEnd = ta.selectionEnd;
this._selectionTimer = setInterval(() => {
if (this._destroyed) return;
const s = ta.selectionStart; const e = ta.selectionEnd;
if (s !== lastSelStart || e !== lastSelEnd) {
lastSelStart = s; lastSelEnd = e;
const text = this._value.slice(s, e);
this._emit('selectionChange', { start: s, end: e, text });
}
}, 200) as any;
this._cleanups.push(() => { if (this._selectionTimer) clearInterval(this._selectionTimer); });
}
_buildFloatingToolbar(): void {
const bar = document.createElement('div');
bar.className = 'me-float-toolbar';
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => {
const btn = this._createBtn(action);
btn.addEventListener('mousedown', (e) => {
e.preventDefault(); e.stopPropagation();
this.exec(action);
// Keep selection after exec
setTimeout(() => this.textarea.focus(), 0);
});
bar.appendChild(btn);
});
this.editorPane.appendChild(bar);
this._floatingToolbar = bar;
}
_hideFloatingToolbar(): void {
if (this._floatingToolbar) {
this._floatingToolbar.classList.remove('me-visible');
}
}
_bindToolbarKeyboard(): void {
if (!this.toolbarEl) return;
const onKeydown = (e: KeyboardEvent) => {
@@ -743,6 +842,91 @@ export class MarkdownEditor {
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)), }; }
getSelectedText(): string {
if (!this.textarea) return '';
return this._value.slice(this.textarea.selectionStart, this.textarea.selectionEnd);
}
getCursorPosition(): { line: number; column: number } {
if (!this.textarea) return { line: 1, column: 0 };
const pos = this.textarea.selectionStart;
const before = this._value.substring(0, pos);
const line = before.split('\n').length;
const column = pos - before.lastIndexOf('\n') - 1;
return { line, column: column < 0 ? 0 : column };
}
setCursorPosition(line: number, column: number = 0): this {
if (!this.textarea) return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
let pos = 0;
for (let i = 0; i < clampedLine - 1; i++) pos += lines[i].length + 1;
pos += Math.min(column, lines[clampedLine - 1]?.length || 0);
this.textarea.focus();
this.textarea.setSelectionRange(pos, pos);
return this;
}
scrollToLine(line: number): this {
if (!this.textarea) return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
const lineHeight = 22; // approximate
this.textarea.scrollTop = (clampedLine - 1) * lineHeight;
if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop;
return this;
}
selectLine(line: number): this {
if (!this.textarea) return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
let startPos = 0;
for (let i = 0; i < clampedLine - 1; i++) startPos += lines[i].length + 1;
const endPos = startPos + lines[clampedLine - 1].length;
this.textarea.focus();
this.textarea.setSelectionRange(startPos, endPos);
return this;
}
selectAll(): this { if (this.textarea) { this.textarea.focus(); this.textarea.select(); } return this; }
replaceAll(search: string, replace: string, caseSensitive: boolean = true): number {
if (!search) return 0;
const flags = caseSensitive ? 'g' : 'gi';
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, flags);
const matches = this._value.match(re);
if (!matches) return 0;
this._value = this._value.replace(re, replace);
this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
return matches.length;
}
replaceAllRegex(pattern: RegExp, replace: string): number {
if (!pattern) return 0;
const re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
const matches = this._value.match(re);
if (!matches) return 0;
this._value = this._value.replace(re, replace);
this.textarea.value = this._value;
this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value);
return matches.length;
}
lineCount(): number {
return this._value ? this._value.split('\n').length : 0;
}
getLine(line: number): string {
const lines = this._value.split('\n');
const idx = line - 1;
if (idx < 0 || idx >= lines.length) return '';
return lines[idx];
}
getValue(): string { return this._destroyed ? '' : this._value; }
setValue(md: string, opts: { silent?: boolean } = {}): this {
if (this._destroyed) return this;
@@ -936,8 +1120,11 @@ export class MarkdownEditor {
if (this._renderRaf) cancelAnimationFrame(this._renderRaf);
if (this._historyTimer) clearTimeout(this._historyTimer);
if (this._outlineTimer) clearTimeout(this._outlineTimer);
if (this._selectionTimer) { clearInterval(this._selectionTimer); this._selectionTimer = null; }
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} }); this._cleanups = [];
this._shortcuts = []; this._contextMenuItems = []; this._customActions = {};
if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
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);