feat: v0.2.3 — 浮动格式工具栏 + 10个新API + 德语翻译 + 类型修复
### 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:
+188
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user