release: v0.1.12 — accessibility, Zen mode, scroll sync v2, shortcutHelp plugin
feat(a11y): toolbar keyboard navigation (Arrow keys, Home, End)
feat(a11y): aria-live region for screen reader announcements on mode change
feat(a11y): enhance focus-visible style (outline-offset: 2px, hide on mouse focus)
feat(core): Zen mode (editor.toggleZen() / exec('zen'))
- Toggle with Ctrl+Shift+Z shortcut concept
- Auto-hide toolbar and statusbar, mouse to top edge reveals toolbar
- Centered editor pane with max-width constraint
feat(core): heading-aware scroll sync v2
- Sync preview scroll to nearest heading before cursor position
- Falls back to proportional sync when no headings found
feat(core): Word wrap toggle (editor.toggleWordWrap() / setWordWrap(bool))
feat(plugins): shortcutHelp preset plugin
- Press '?' to open keyboard shortcuts overlay panel
- Lists built-in + user-registered shortcuts
- Click overlay or press Escape to close
style: Zen mode CSS, sr-only class, focus-visible refinement
test: 3 new tests for Zen mode, WordWrap, shortcutHelp (535 total)
chore: bump version 0.1.11 → 0.1.12 across all files
This commit is contained in:
+160
-12
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* MetonaEditor Core - 编辑器核心
|
||||
* @module core
|
||||
* @version 0.1.11
|
||||
* @version 0.1.12
|
||||
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
|
||||
* v0.1.6: 插件依赖拓扑排序、实例 i18n、快捷键/工具栏定制、右键菜单、Toast
|
||||
* v0.1.7: 行号装订线、自动格式化、括号自动闭合、拖放、大纲面板
|
||||
* v0.1.12: 工具栏键盘导航、aria-live、Zen模式、滚动同步v2、WordWrap
|
||||
*/
|
||||
|
||||
import { generateId, escapeHTML, isBrowser } from './utils.js';
|
||||
@@ -110,6 +111,8 @@ class MarkdownEditor {
|
||||
this._contextMenuItems = []; // v0.1.6: 右键菜单项
|
||||
this._customActions = {}; // 自定义工具栏动作
|
||||
this._outlineTimer = null; // 大纲防抖计时器
|
||||
this._zenMode = false; // v0.1.12: Zen 模式
|
||||
this._wordWrap = true; // v0.1.12: 自动换行
|
||||
|
||||
// 渲染函数:自定义覆盖内置解析器
|
||||
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
|
||||
@@ -139,6 +142,8 @@ class MarkdownEditor {
|
||||
|
||||
this._buildToolbar();
|
||||
this._updateModeButtons(); // 初始化按钮状态
|
||||
this._bindToolbarKeyboard(); // v0.1.12: 键盘导航
|
||||
this._initAriaLive(); // v0.1.12: 屏幕阅读器
|
||||
this._bindEvents();
|
||||
|
||||
// 初始内容
|
||||
@@ -352,17 +357,11 @@ class MarkdownEditor {
|
||||
ta.addEventListener('keyup', onCursorActivity);
|
||||
|
||||
const onScroll = () => {
|
||||
// 同步行号滚动(始终同步,不限于分屏模式)
|
||||
if (this.gutter) {
|
||||
this.gutter.scrollTop = ta.scrollTop;
|
||||
if (this.gutter) this.gutter.scrollTop = ta.scrollTop;
|
||||
// v0.1.12: 标题感知同步
|
||||
if (this.config.syncScroll && this._mode === 'split') {
|
||||
this._syncScrollByHeading();
|
||||
}
|
||||
// 同步预览滚动(仅分屏模式)
|
||||
if (!this.config.syncScroll || this._mode !== 'split') return;
|
||||
const max = ta.scrollHeight - ta.clientHeight;
|
||||
if (max <= 0) return;
|
||||
const ratio = ta.scrollTop / max;
|
||||
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
|
||||
this.previewPane.scrollTop = ratio * pmax;
|
||||
};
|
||||
ta.addEventListener('scroll', onScroll);
|
||||
|
||||
@@ -913,6 +912,8 @@ class MarkdownEditor {
|
||||
split: () => this.setMode('split'),
|
||||
preview: () => this.setMode('preview'),
|
||||
fullscreen: () => this.toggleFullscreen(),
|
||||
zen: () => this.toggleZen(), // v0.1.12
|
||||
wordwrap: () => this.toggleWordWrap(), // v0.1.12
|
||||
};
|
||||
const fn = actions[action];
|
||||
if (fn) { fn.apply(this, args); return this; }
|
||||
@@ -1059,6 +1060,7 @@ class MarkdownEditor {
|
||||
if (this.textarea) this.textarea.scrollTop = taScroll;
|
||||
if (this.previewPane) this.previewPane.scrollTop = pvScroll;
|
||||
this._emit('modeChange', mode);
|
||||
this._announce(`${i18nT(mode) || mode} ${i18nT('mode') || 'mode'}`); // v0.1.12
|
||||
if (typeof this.config.onModeChange === 'function') {
|
||||
try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
@@ -1126,7 +1128,153 @@ class MarkdownEditor {
|
||||
return this;
|
||||
}
|
||||
|
||||
// ============ 字数统计 ============
|
||||
// ============ Zen 模式(v0.1.12) ============
|
||||
|
||||
toggleZen() {
|
||||
this._zenMode = !this._zenMode;
|
||||
if (this.el) this.el.classList.toggle('me-zen', this._zenMode);
|
||||
|
||||
// 鼠标移到顶部时显示工具栏
|
||||
if (this._zenMode && this.toolbarEl) {
|
||||
this._zenMouseHandler = (e) => {
|
||||
this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0';
|
||||
this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none';
|
||||
};
|
||||
this.toolbarEl.style.transition = 'opacity 0.2s';
|
||||
this.toolbarEl.style.opacity = '0';
|
||||
this.toolbarEl.style.pointerEvents = 'none';
|
||||
document.addEventListener('mousemove', this._zenMouseHandler);
|
||||
// 隐藏状态栏
|
||||
if (this.statusEl) this.statusEl.style.display = 'none';
|
||||
} else {
|
||||
if (this._zenMouseHandler) {
|
||||
document.removeEventListener('mousemove', this._zenMouseHandler);
|
||||
this._zenMouseHandler = null;
|
||||
}
|
||||
if (this.toolbarEl) {
|
||||
this.toolbarEl.style.opacity = '';
|
||||
this.toolbarEl.style.pointerEvents = '';
|
||||
this.toolbarEl.style.transition = '';
|
||||
}
|
||||
if (this.statusEl) this.statusEl.style.display = '';
|
||||
}
|
||||
|
||||
this._emit('zenChange', this._zenMode);
|
||||
return this;
|
||||
}
|
||||
|
||||
isZen() { return this._zenMode; }
|
||||
|
||||
// ============ Word Wrap(v0.1.12) ============
|
||||
|
||||
toggleWordWrap() {
|
||||
this._wordWrap = !this._wordWrap;
|
||||
if (this.textarea) {
|
||||
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre';
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
setWordWrap(on) {
|
||||
this._wordWrap = !!on;
|
||||
if (this.textarea) {
|
||||
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre';
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
isWordWrap() { return this._wordWrap; }
|
||||
|
||||
// ============ 工具栏键盘导航(v0.1.12 无障碍) ============
|
||||
|
||||
_bindToolbarKeyboard() {
|
||||
if (!this.toolbarEl) return;
|
||||
|
||||
const onKeydown = (e) => {
|
||||
const btns = [...this.toolbarEl.querySelectorAll('.me-btn:not(:disabled)')];
|
||||
if (!btns.length) return;
|
||||
const idx = btns.indexOf(document.activeElement);
|
||||
if (idx === -1) return;
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const next = (idx + 1) % btns.length;
|
||||
btns[next].focus();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const prev = (idx - 1 + btns.length) % btns.length;
|
||||
btns[prev].focus();
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
btns[0].focus();
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
btns[btns.length - 1].focus();
|
||||
}
|
||||
};
|
||||
|
||||
this.toolbarEl.addEventListener('keydown', onKeydown);
|
||||
this._cleanups.push(() => this.toolbarEl.removeEventListener('keydown', onKeydown));
|
||||
}
|
||||
|
||||
// ============ aria-live 区域(v0.1.12 无障碍) ============
|
||||
|
||||
_initAriaLive() {
|
||||
if (!this.el) return;
|
||||
const live = document.createElement('div');
|
||||
live.className = 'me-sr-only';
|
||||
live.setAttribute('aria-live', 'polite');
|
||||
live.setAttribute('aria-atomic', 'true');
|
||||
this.el.appendChild(live);
|
||||
this._ariaLive = live;
|
||||
}
|
||||
|
||||
_announce(msg) {
|
||||
if (this._ariaLive) {
|
||||
this._ariaLive.textContent = '';
|
||||
requestAnimationFrame(() => { this._ariaLive.textContent = msg; });
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 滚动同步 v2 — 标题感知(v0.1.12) ============
|
||||
|
||||
_syncScrollByHeading() {
|
||||
if (!this.config.syncScroll || this._mode !== 'split') return;
|
||||
const ta = this.textarea;
|
||||
if (!ta) return;
|
||||
|
||||
// 找到光标所在位置最近的标题
|
||||
const pos = ta.selectionStart;
|
||||
const before = this._value.substring(0, pos);
|
||||
const headings = [...before.matchAll(/^(#{1,6})\s+(.+)$/gm)];
|
||||
if (!headings.length) {
|
||||
// 无标题,回退到比例同步
|
||||
const max = ta.scrollHeight - ta.clientHeight;
|
||||
if (max <= 0) return;
|
||||
const ratio = ta.scrollTop / max;
|
||||
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
|
||||
this.previewPane.scrollTop = ratio * pmax;
|
||||
return;
|
||||
}
|
||||
|
||||
// 取最后一个标题(光标之前最近的标题)
|
||||
const lastH = headings[headings.length - 1];
|
||||
const headingText = lastH[2].trim();
|
||||
|
||||
// 在预览区查找对应标题元素
|
||||
const previewEls = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
for (const el of previewEls) {
|
||||
if (el.textContent.trim() === headingText) {
|
||||
el.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback
|
||||
const max = ta.scrollHeight - ta.clientHeight;
|
||||
if (max <= 0) return;
|
||||
this.previewPane.scrollTop = (ta.scrollTop / max) * (this.previewPane.scrollHeight - this.previewPane.clientHeight);
|
||||
}
|
||||
|
||||
_updateWordCount() {
|
||||
if (!this.config.wordCount || !this.statusEl) return;
|
||||
|
||||
Reference in New Issue
Block a user