2 Commits
Author SHA1 Message Date
thzxx 20b6d03617 release: v0.4.2 — MetonaToast 0.1.2 降级与版本号更新
- chore: @metona-team/metona-toast 2.0.1 -> 0.1.2
- chore: 应用版本号 0.4.1 -> 0.4.2
2026-07-24 15:06:21 +08:00
thzxx 7bd625b9ce release: v0.4.1 — MetonaEditor 0.1.10 升级与三主题系统
- chore: 升级 @metona-team/metona-editor 0.1.3 → 0.1.10
- feat: 三主题系统(亮色/暗色/暖色),循环切换
- feat: 应用颜色自动同步编辑器主题(MeEditor.themes.exportCSSVars)
- feat: 启用编辑器新功能(lineNumbers/autoBrackets/readOnly/autofocus/exportTool)
- refactor: 主题从 boolean darkMode 重构为 ThemeMode 枚举
- refactor: 插件注册改用字符串数组(与 demo 一致)
- refactor: 主题同步从手动 CSS 覆写改为 editor.setTheme() 实例 API
- refactor: Ctrl+S 双通道(编辑器 onSave + 全局兜底)+ 防重入锁
- style: 移除 variables.css 中硬编码 .dark 色值,改为 JS 动态驱动
- test: 更新 editorStore 测试覆盖新的 themeMode/cycleTheme
2026-07-24 13:58:37 +08:00
22 changed files with 761 additions and 523 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# MarkLite v0.4.0 — 架构设计文档
# MarkLite v0.4.2 — 架构设计文档
## 1. 项目概述
+1 -1
View File
@@ -1,4 +1,4 @@
# MarkLite v0.4.0 — 开发环境配置指南
# MarkLite v0.4.2 — 开发环境配置指南
以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。
+1 -1
View File
@@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-v0.4.0-orange?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/Version-v0.4.2-orange?style=flat-square" alt="Version">
</p>
<p align="center">
+468 -365
View File
@@ -1,419 +1,522 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" data-md-theme="auto">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>MetonaEditor · 功能演示</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
<title>MetonaEditor v0.1.10 — 在线演示</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root {
--bg: #f5f6f8;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--border: #e5e7eb;
--accent: #3b82f6;
--accent-soft: rgba(59, 130, 246, 0.1);
--danger: #ef4444;
--success: #10b981;
--code-bg: #1c2029;
--code-text: #e6e8eb;
--app-bg: #f5f6f8;
--app-text: #1a1a2e;
--app-card: #ffffff;
--app-border: #e5e7eb;
--app-accent: #3b82f6;
--app-accent2: #8b5cf6;
/* dark overrides */
--header-bg: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
--header-text: #f1f5f9;
--badge-bg: rgba(255,255,255,0.12);
--badge-text: #e2e8f0;
}
[data-md-theme="dark"] {
--app-bg: #0f1117;
--app-text: #e2e8f0;
--app-card: #1a1d27;
--app-border: #2d3140;
--header-bg: linear-gradient(135deg, #0a0d15 0%, #161923 100%);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
background: var(--app-bg);
color: var(--app-text);
min-height: 100vh;
line-height: 1.6;
padding: 24px;
transition: background 0.3s, color 0.3s;
}
.wrap { max-width: 1120px; margin: 0 auto; }
/* 顶栏 */
.topbar {
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 12px;
margin-bottom: 20px;
/* ====== Header ====== */
.header {
background: var(--header-bg);
color: var(--header-text);
padding: 32px 24px 24px;
text-align: center;
position: relative;
overflow: hidden;
}
.topbar h1 { font-size: 22px; font-weight: 700; display: flex; align-items: center; gap: 12px; }
.topbar h1 a {
color: var(--accent); text-decoration: none;
font-size: 13px; font-weight: 400;
padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px;
transition: border-color 0.15s;
.header::before {
content: '';
position: absolute;
top: -60px; right: -60px;
width: 300px; height: 300px;
background: radial-gradient(circle, rgba(139,92,246,0.3) 0%, transparent 70%);
border-radius: 50%;
}
.topbar h1 a:hover { border-color: var(--accent); }
.controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.ctrl-label { font-size: 12px; color: var(--muted); margin-right: 4px; }
.ctrl-group {
display: inline-flex; gap: 0;
background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
.header h1 {
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.5px;
position: relative;
}
.ctrl-btn {
padding: 7px 14px; border: 0; background: transparent; cursor: pointer;
font-size: 13px; color: var(--text); transition: background 0.15s, color 0.15s;
.header h1 span.grad {
background: linear-gradient(135deg, var(--app-accent), var(--app-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header .badges {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin-top: 14px;
position: relative;
}
.header .badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
background: var(--badge-bg);
color: var(--badge-text);
border: 1px solid rgba(255,255,255,0.1);
}
.ctrl-btn:hover { background: #f3f4f6; }
.ctrl-btn.active { background: var(--accent); color: #fff; }
.ctrl-btn + .ctrl-btn { border-left: 1px solid var(--border); }
/* 编辑器 */
#editor { margin-bottom: 24px; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 20px -8px rgba(0,0,0,0.1); }
/* ====== Toolbar Strip ====== */
.toolbar-strip {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 24px;
background: var(--app-card);
border-bottom: 1px solid var(--app-border);
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 50;
transition: background 0.3s;
}
.toolbar-strip label {
font-size: 13px;
font-weight: 500;
color: var(--app-text);
opacity: 0.7;
white-space: nowrap;
}
.toolbar-strip select {
padding: 5px 10px;
border: 1px solid var(--app-border);
border-radius: 6px;
font-size: 13px;
background: var(--app-bg);
color: var(--app-text);
cursor: pointer;
}
.toolbar-strip button {
padding: 6px 14px;
border: 1px solid var(--app-border);
border-radius: 6px;
font-size: 13px;
font-weight: 500;
background: var(--app-card);
color: var(--app-text);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.toolbar-strip button:hover { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
.toolbar-strip button.accent { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
.toolbar-strip .spacer { flex: 1; }
/* 网格布局 */
.grid {
.toggle-group { display: flex; gap: 2px; }
.toggle-group button {
border-radius: 0;
margin: 0;
}
.toggle-group button:first-child { border-radius: 6px 0 0 6px; }
.toggle-group button:last-child { border-radius: 0 6px 6px 0; }
.toggle-group button.on { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
/* ====== Editor Container ====== */
.editor-section {
max-width: 1200px;
margin: 24px auto;
padding: 0 24px;
}
.editor-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
background: var(--app-card);
border: 1px solid var(--app-border);
transition: box-shadow 0.3s, background 0.3s;
}
.editor-card:hover { box-shadow: 0 8px 36px rgba(0,0,0,0.1); }
/* ====== Info Cards ====== */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 16px;
max-width: 1200px;
margin: 24px auto;
padding: 0 24px;
}
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
}
.panel {
background: var(--card);
border: 1px solid var(--border);
.info-card {
background: var(--app-card);
border: 1px solid var(--app-border);
border-radius: 10px;
padding: 16px 18px;
}
.panel-title {
font-size: 13px; color: var(--muted);
margin-bottom: 10px; font-weight: 600;
display: flex; align-items: center; justify-content: space-between;
}
.panel-title .count {
background: var(--accent-soft); color: var(--accent);
padding: 2px 8px; border-radius: 999px; font-size: 11px;
padding: 18px 20px;
transition: box-shadow 0.3s;
}
.info-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
.info-card .icon { font-size: 24px; margin-bottom: 8px; }
.info-card h4 { font-size: 14px; font-weight: 650; margin-bottom: 4px; }
.info-card p { font-size: 12px; color: var(--app-text); opacity: 0.65; margin: 0; }
/* 控制台 */
.console {
background: var(--code-bg); color: var(--code-text);
border-radius: 8px; padding: 14px 16px;
font-family: "SF Mono", "Cascadia Code", Consolas, monospace; font-size: 12.5px;
min-height: 140px; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-all;
line-height: 1.7;
/* ====== Footer ====== */
.footer {
text-align: center;
padding: 32px 24px;
font-size: 13px;
color: var(--app-text);
opacity: 0.5;
}
.console:empty::before { content: "点击下方按钮查看输出..."; color: #6b7280; }
.console .log-time { color: #5c6370; }
.console .log-label { color: #82aaff; font-weight: 600; }
.footer a { color: var(--app-accent); text-decoration: none; }
/* 事件流 */
.events {
background: var(--code-bg); color: var(--code-text);
border-radius: 8px; padding: 14px 16px;
font-family: "SF Mono", Consolas, monospace; font-size: 12px;
min-height: 140px; max-height: 240px; overflow: auto;
line-height: 1.7;
/* ====== Responsive ====== */
@media (max-width: 768px) {
.header h1 { font-size: 1.5rem; }
.toolbar-strip { padding: 8px 12px; gap: 6px; }
.toolbar-strip button { padding: 5px 10px; font-size: 12px; }
.info-grid { grid-template-columns: 1fr 1fr; }
}
.events:empty::before { content: "事件流将在这里实时显示..."; color: #6b7280; }
.event-line { padding: 2px 0; }
.event-line .ev-name { color: #c3e88d; }
.event-line .ev-time { color: #5c6370; }
/* API 按钮 */
.api-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
margin-top: 12px;
}
.api-btn {
padding: 9px 12px; border: 1px solid var(--border); background: var(--card);
border-radius: 7px; cursor: pointer; font-size: 12.5px; color: var(--text);
transition: all 0.15s; text-align: center; font-family: "SF Mono", Consolas, monospace;
}
.api-btn:hover { border-color: var(--accent); color: var(--accent); transform: translateY(-1px); }
.api-btn:active { transform: translateY(0); }
.api-btn.danger:hover { border-color: var(--danger); color: var(--danger); }
.api-btn.success:hover { border-color: var(--success); color: var(--success); }
.hint {
font-size: 12px; color: var(--muted);
margin-top: 14px; padding: 10px 14px;
background: var(--accent-soft); border-radius: 8px;
border-left: 3px solid var(--accent);
}
code { background: #f3f4f6; padding: 1px 6px; border-radius: 4px; font-size: 12px; font-family: "SF Mono", Consolas, monospace; }
/* 状态指示 */
.status-bar {
display: flex; gap: 16px; flex-wrap: wrap;
padding: 10px 14px;
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; margin-bottom: 16px;
font-size: 12px; color: var(--muted);
}
.status-item { display: inline-flex; align-items: center; gap: 6px; }
.status-item strong { color: var(--text); font-weight: 600; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--success);
}
.status-dot.destroyed { background: var(--danger); }
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<h1>
MetonaEditor 功能演示
<a href="index.html">← 返回首页</a>
<a href="docs.html">📖 文档</a>
</h1>
<div class="controls">
<span class="ctrl-label">主题</span>
<div class="ctrl-group" id="themeCtrl">
<button class="ctrl-btn" data-theme="light">亮色</button>
<button class="ctrl-btn" data-theme="dark">暗色</button>
<button class="ctrl-btn" data-theme="warm">暖色</button>
</div>
<span class="ctrl-label">语言</span>
<div class="ctrl-group" id="localeCtrl">
<button class="ctrl-btn" data-locale="zh-CN">中文</button>
<button class="ctrl-btn" data-locale="en-US">English</button>
</div>
<span class="ctrl-label">模式</span>
<div class="ctrl-group" id="readonlyCtrl">
<button class="ctrl-btn" id="btnReadOnly">只读</button>
</div>
</div>
<header class="header">
<h1><span class="grad">Metona</span>Editor</h1>
<div class="badges">
<span class="badge">v0.1.10</span>
<span class="badge">零依赖</span>
<span class="badge">532 tests</span>
<span class="badge">MIT</span>
<span class="badge">中文优先</span>
</div>
</header>
<div class="toolbar-strip" id="controlBar">
<a href="index.html" style="text-decoration:none;font-size:18px;margin-right:8px;" title="返回首页">🏠</a>
<label>主题</label>
<select id="themeSelect">
<option value="auto">🌓 跟随系统</option>
<option value="light">☀️ 亮色</option>
<option value="dark">🌙 暗色</option>
<option value="warm">🔥 暖色</option>
</select>
<label>语言</label>
<select id="localeSelect">
<option value="zh-CN">中文</option>
<option value="en-US">English</option>
</select>
<label>模式</label>
<div class="toggle-group" id="modeBtns">
<button data-mode="edit">编辑</button>
<button data-mode="split" class="on">分屏</button>
<button data-mode="preview">预览</button>
</div>
<div class="status-bar" id="statusBar">
<div class="status-item">
<span class="status-dot" id="statusDot"></span>
<span>状态:</span><strong id="stState">运行中</strong>
</div>
<div class="status-item"><span>模式:</span><strong id="stMode">split</strong></div>
<div class="status-item"><span>主题:</span><strong id="stTheme">light</strong></div>
<div class="status-item"><span>语言:</span><strong id="stLocale">zh-CN</strong></div>
<div class="status-item"><span>字数:</span><strong id="stWords">0</strong></div>
<div class="status-item"><span>全屏:</span><strong id="stFs"></strong></div>
<div class="spacer"></div>
<label>行号</label>
<div class="toggle-group" id="lineNumBtns">
<button class="on"></button>
<button></button>
</div>
<div id="editor"></div>
<div class="grid">
<div class="panel">
<div class="panel-title">
<span>API 控制台</span>
<span class="count" id="apiCount">0 次调用</span>
</div>
<div class="console" id="console"></div>
<div class="api-grid">
<button class="api-btn" data-api="getValue">getValue()</button>
<button class="api-btn" data-api="getHTML">getHTML()</button>
<button class="api-btn" data-api="getStats">getStats()</button>
<button class="api-btn" data-api="getStatus">getStatus()</button>
<button class="api-btn" data-api="insert">insert()</button>
<button class="api-btn" data-api="exec-bold">exec('bold')</button>
<button class="api-btn" data-api="exec-h1">exec('h1')</button>
<button class="api-btn" data-api="setMode-edit">编辑模式</button>
<button class="api-btn" data-api="setMode-split">分屏模式</button>
<button class="api-btn" data-api="setMode-preview">预览模式</button>
<button class="api-btn" data-api="fullscreen">切换全屏</button>
<button class="api-btn" data-api="undo">undo()</button>
<button class="api-btn" data-api="redo">redo()</button>
<button class="api-btn success" data-api="exportMD">导出 .md</button>
<button class="api-btn success" data-api="exportHTML">导出 .html</button>
<button class="api-btn" data-api="restoreDraft">恢复草稿</button>
<button class="api-btn" data-api="clearDraft">清空草稿</button>
<button class="api-btn" data-api="refresh">refresh()</button>
<button class="api-btn danger" data-api="destroy">destroy()</button>
</div>
</div>
<div class="panel">
<div class="panel-title">
<span>事件流(实时)</span>
<span class="count" id="evCount">0 个事件</span>
</div>
<div class="events" id="events"></div>
</div>
<label>大纲</label>
<div class="toggle-group" id="outlineBtns">
<button></button>
<button class="on"></button>
</div>
<div class="hint">
<strong>快捷键提示:</strong>
编辑区内按 <code>Ctrl+B</code> 加粗、<code>Ctrl+I</code> 斜体、<code>Ctrl+K</code> 插入链接、
<code>Ctrl+Z</code> 撤销、<code>Ctrl+S</code> 触发保存、<code>Ctrl+F</code> 查找、<code>Ctrl+H</code> 替换、
<code>Tab</code> 缩进、<code>Esc</code> 关闭面板。
<button onclick="demoToast()" title="演示 Toast 通知">💬 Toast</button>
<button onclick="demoOutline()" title="生成大纲">📋 大纲</button>
</div>
<div class="editor-section">
<div class="editor-card">
<div id="editor"></div>
</div>
</div>
<div class="info-grid">
<div class="info-card">
<div class="icon">📝</div>
<h4>丰富语法</h4>
<p>数学公式、脚注、定义列表、嵌套列表、表格对齐、任务列表、Emoji</p>
</div>
<div class="info-card">
<div class="icon">⌨️</div>
<h4>快捷键</h4>
<p>Ctrl+B 粗体 · Ctrl+I 斜体 · Ctrl+S 保存 · Ctrl+F 搜索 · Ctrl+H 替换</p>
</div>
<div class="info-card">
<div class="icon">🎨</div>
<h4>4 套主题</h4>
<p>亮色 · 暗色 · 暖色 · 跟随系统,实例级隔离,外部主题跟随</p>
</div>
<div class="info-card">
<div class="icon">🔌</div>
<h4>4 个插件</h4>
<p>自动保存草稿 · 导出 MD/HTML · 搜索替换 · 图片粘贴</p>
</div>
<div class="info-card">
<div class="icon">🧩</div>
<h4>编辑增强</h4>
<p>行号装订线 · 智能 Enter · 括号自动闭合 · 拖放文件 · 大纲面板</p>
</div>
<div class="info-card">
<div class="icon">🛡️</div>
<h4>安全</h4>
<p>XSS 防护 · URL 协议过滤 · HTML 实体保护 · sanitize 钩子</p>
</div>
</div>
<footer class="footer">
MetonaEditor v0.1.10 ·
<a href="index.html">首页</a> ·
<a href="docs.html">API 文档</a> ·
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> ·
MIT License
</footer>
<script src="../dist/metona-editor.js"></script>
<script>
let apiCalls = 0;
let eventCount = 0;
(function () {
const demoMd = [
'# 🚀 MetonaEditor 演示',
'',
'> 轻量、零依赖、精致美观的 **Markdown Editor** 库。',
'> 单文件,开箱即用,中文优先。',
'',
'## ✨ 核心特性',
'',
'- 零依赖 · 单文件打包 · UMD / ESM / CJS',
'- 三模式视图 · 分屏拖拽 · 滚动同步',
'- 行号装订线 · 当前行高亮',
'- 智能 Enter · 括号自动闭合',
'- 4 套主题 · 实例级隔离 · 外部跟随',
'- 插件系统 v2 · 依赖排序 · 异步加载',
'',
'## 📐 数学公式',
'',
'行内公式:爱因斯坦质能方程 $E = mc^2$',
'',
'块级公式:',
'$$',
'\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}',
'$$',
'',
'## 📋 表格与对齐',
'',
'| 版本 | 日期 | 测试数 |',
'| :--- | :---: | ---: |',
'| v0.1.10 | 2026-07 | 532+ |',
'| v0.1.6 | 2026-07 | 521 |',
'| v0.1.5 | 2026-07 | 499 |',
'',
'## ✅ 任务列表',
'',
'- [x] 解析器增强(11 项新语法)',
'- [x] 主题系统(实例隔离 + 外部跟随)',
'- [x] 插件 v2 + i18n 增强',
'- [x] 编辑体验(行号 / 自动格式化 / 括号闭合)',
'- [ ] 更多语言翻译包',
'- [ ] VS Code 插件',
'',
'## 🎯 嵌套列表',
'',
'1. 一级有序',
' 1. 二级有序',
' 2. 二级第二项',
'2. 回到一级',
' - 混杂无序',
' - 第二项',
' - 三级嵌套',
'',
'## 📖 定义列表',
'',
'MetonaEditor',
': 一款轻量、零依赖的中文优先 Markdown 编辑器库。',
': 支持 CommonMark + GFM 扩展语法。',
'',
'Markdown',
': 由 John Gruber 创建的轻量级标记语言。',
'',
'## 🔗 链接与图片',
'',
'访问 [MetonaEditor 仓库](https://git.metona.cn/MetonaTeam/MetonaEditor \'点击访问源码\')',
'',
'图片示例:![示例](https://placehold.co/400x120/3b82f6/white?text=MetonaEditor)',
'',
'## 💻 代码块',
'',
'```js',
'import MeEditor from \'@metona-team/metona-editor\';',
'',
'const editor = MeEditor.create(\'#editor\', {',
' value: \'# Hello World\',',
' mode: \'split\',',
' lineNumbers: true,',
' autoBrackets: true,',
' plugins: [\'autoSave\', \'searchReplace\'],',
' onChange: (value) => console.log(value),',
'});',
'```',
'',
'## 🎨 文本样式',
'',
'- **粗体文本** 与 __另一种粗体__',
'- *斜体文本* 与 _另一种斜体_',
'- ~~删除线~~ 与 ==高亮标记==',
'- 化学式:H~2~O 与 数学:x^2^ + y^2^ = z^2^',
'- 行内代码:`const x = 42;`',
'- Emoji:smile: :rocket: :fire: :tada: :rainbow: :coffee:',
'',
'## 📌 脚注',
'',
'这是一个带脚注的句子[^1],这是另一个脚注[^note]。',
'',
'[^1]: 第一个脚注的详细说明内容。',
'[^note]: 脚注可以包含 **格式化** 和 `代码`。',
'',
'---',
'',
'> **提示:** 试试拖放图片到编辑器、按 `Ctrl+F` 搜索、`Ctrl+H` 替换、',
'> 在列表行尾按 `Enter` 体验智能延续、选中文字后输入 `(` 体验自动包裹。',
].join('\n');
const log = (label, val) => {
apiCalls++;
document.getElementById('apiCount').textContent = apiCalls + ' 次调用';
const c = document.getElementById('console');
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false });
const text = typeof val === 'string' ? val : JSON.stringify(val, null, 2);
const truncated = text.length > 500 ? text.slice(0, 500) + '\n... (已截断)' : text;
c.innerHTML = `<span class="log-time">[${now}]</span> <span class="log-label">${label}</span>\n${escapeHtml(truncated)}`;
};
const logEvent = (name, data) => {
eventCount++;
document.getElementById('evCount').textContent = eventCount + ' 个事件';
const el = document.getElementById('events');
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false });
const dataStr = data !== undefined ? ' ' + (typeof data === 'string' ? data.slice(0, 40) : JSON.stringify(data).slice(0, 40)) : '';
const line = document.createElement('div');
line.className = 'event-line';
line.innerHTML = `<span class="ev-time">${now}</span> <span class="ev-name">${name}</span>${escapeHtml(dataStr)}`;
el.appendChild(line);
el.scrollTop = el.scrollHeight;
// 限制最多 50 条
while (el.children.length > 50) el.removeChild(el.firstChild);
};
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function updateStatus() {
if (editor.isDestroyed()) {
document.getElementById('stState').textContent = '已销毁';
document.getElementById('statusDot').classList.add('destroyed');
return;
}
const s = editor.getStatus();
const stats = editor.getStats();
document.getElementById('stState').textContent = '运行中';
document.getElementById('stMode').textContent = s.mode;
document.getElementById('stTheme').textContent = s.theme;
document.getElementById('stLocale').textContent = s.locale;
document.getElementById('stWords').textContent = stats.words;
document.getElementById('stFs').textContent = s.fullscreen ? '是' : '否';
}
// 创建编辑器,启用全部预设插件
const editor = MeEditor.create('#editor', {
const editor = window.MeEditor.create('#editor', {
value: demoMd,
mode: 'split',
height: 420,
theme: 'light',
height: 620,
lineNumbers: true,
autoBrackets: true,
wordCount: true,
outline: false,
theme: 'auto',
locale: 'zh-CN',
value: [
'# 功能演示',
'',
'这里是 MetonaEditor 的完整功能演示。',
'',
'## 行内格式',
'',
'- 支持 **加粗**、*斜体*、~~删除线~~、`行内代码`',
'- 高亮 ==重点标记== 、上标 x^2^、下标 H~2~O',
'- Emoji 短码 :smile: :rocket: :fire: :tada:',
'- 支持 [链接](https://example.com) 与 ![图片](https://placehold.co/100)',
'',
'## 表格',
'',
'| 名称 | 值 |',
'| --- | --- |',
'| 版本 | 0.1.3 |',
'| 依赖 | 零 |',
'| 测试 | 390+ |',
'',
'## 任务列表',
'',
'- [x] 分屏预览',
'- [x] 插件系统',
'- [x] 主题与国际化',
'- [ ] 你的创意',
'',
'## 代码块',
'',
'```js',
'function hello(name) {',
' return `Hello, ${name}!`;',
'}',
'```',
'',
'> 试试顶部的主题/语言切换与下方的 API 按钮。',
'',
'右侧事件流会实时显示编辑器触发的事件。'
].join('\n'),
plugins: ['autoSave', 'exportTool', 'searchReplace', 'imagePaste'],
onChange: (val) => { logEvent('change', val); updateStatus(); },
onInput: (val) => logEvent('input', val),
onFocus: () => logEvent('focus'),
onBlur: () => logEvent('blur'),
onSave: () => logEvent('save'),
onModeChange: (mode) => { logEvent('modeChange', mode); updateStatus(); },
onFullscreen: (fs) => { logEvent('fullscreen', fs); updateStatus(); },
});
// 监听 autosave 事件(来自 autoSave 插件)
editor.on('autosave', (data) => logEvent('autosave', data));
// ====== Control Bar Handlers ======
// 主题切换
document.getElementById('themeCtrl').addEventListener('click', (e) => {
const btn = e.target.closest('[data-theme]');
if (!btn) return;
document.querySelectorAll('#themeCtrl .ctrl-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
MeEditor.setTheme(btn.dataset.theme);
editor.el.className = editor.el.className.replace(/me-theme-\w+/g, '') + ' me-theme-' + btn.dataset.theme;
updateStatus();
});
document.querySelector('#themeCtrl [data-theme="light"]').classList.add('active');
// 语言切换
document.getElementById('localeCtrl').addEventListener('click', (e) => {
const btn = e.target.closest('[data-locale]');
if (!btn) return;
document.querySelectorAll('#localeCtrl .ctrl-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
MeEditor.setLocale(btn.dataset.locale);
updateStatus();
});
document.querySelector('#localeCtrl [data-locale="zh-CN"]').classList.add('active');
// 只读切换
document.getElementById('btnReadOnly').addEventListener('click', () => {
const isRO = editor.isReadOnly();
editor.setReadOnly(!isRO);
document.getElementById('btnReadOnly').classList.toggle('active', !isRO);
document.getElementById('btnReadOnly').textContent = !isRO ? '只读中' : '只读';
log('setReadOnly', String(!isRO));
updateStatus();
// Theme
document.getElementById('themeSelect').addEventListener('change', function () {
editor.setTheme(this.value);
});
// API 控制台
document.querySelector('.api-grid').addEventListener('click', (e) => {
const btn = e.target.closest('[data-api]');
if (!btn) return;
const api = btn.dataset.api;
if (editor.isDestroyed()) { log('error', '编辑器已销毁,请刷新页面'); return; }
switch (api) {
case 'getValue': log('getValue', editor.getValue().slice(0, 200) + '...'); break;
case 'getHTML': log('getHTML', editor.getHTML()); break;
case 'getStats': log('getStats', editor.getStats()); break;
case 'getStatus': log('getStatus', editor.getStatus()); break;
case 'insert': editor.insert('\n\n## 新插入的标题\n'); log('insert', '已插入内容'); break;
case 'exec-bold': editor.exec('bold'); log('exec', "执行 'bold'"); break;
case 'exec-h1': editor.exec('h1'); log('exec', "执行 'h1'"); break;
case 'setMode-edit': editor.setMode('edit'); log('setMode', '切换到 edit'); break;
case 'setMode-split': editor.setMode('split'); log('setMode', '切换到 split'); break;
case 'setMode-preview': editor.setMode('preview'); log('setMode', '切换到 preview'); break;
case 'fullscreen': editor.toggleFullscreen(); log('fullscreen', '已切换全屏'); break;
case 'undo': editor.undo(); log('undo', '撤销'); break;
case 'redo': editor.redo(); log('redo', '重做'); break;
case 'exportMD': editor.exportMarkdown(); log('export', '已触发 .md 下载'); break;
case 'exportHTML': editor.exportHTML('demo.html', { title: 'MetonaEditor 导出' }); log('export', '已触发 .html 下载'); break;
case 'restoreDraft': editor.restoreDraft(); log('restoreDraft', '已从 localStorage 恢复草稿'); break;
case 'clearDraft': editor.clearDraft(); log('clearDraft', '已清空草稿'); break;
case 'refresh': editor.refresh(); log('refresh', '强制刷新预览'); break;
case 'destroy': editor.destroy(); log('destroy', '编辑器已销毁'); updateStatus(); break;
// Locale
document.getElementById('localeSelect').addEventListener('change', function () {
editor.setLocale(this.value);
});
// Mode buttons
document.querySelectorAll('#modeBtns button').forEach(function (btn) {
btn.addEventListener('click', function () {
editor.setMode(this.dataset.mode);
document.querySelectorAll('#modeBtns button').forEach(function (b) { b.classList.remove('on'); });
this.classList.add('on');
});
});
// Line numbers toggle
var lineNumBtns = document.querySelectorAll('#lineNumBtns button');
lineNumBtns[0].addEventListener('click', function () {
editor.config.lineNumbers = true;
if (editor.gutter) editor.gutter.style.display = '';
editor._renderGutter();
lineNumBtns[0].classList.add('on');
lineNumBtns[1].classList.remove('on');
});
lineNumBtns[1].addEventListener('click', function () {
editor.config.lineNumbers = false;
if (editor.gutter) editor.gutter.style.display = 'none';
lineNumBtns[1].classList.add('on');
lineNumBtns[0].classList.remove('on');
});
// Outline toggle
var outlineBtns = document.querySelectorAll('#outlineBtns button');
outlineBtns[0].addEventListener('click', function () {
editor.config.outline = true;
editor._buildOutline();
outlineBtns[0].classList.add('on');
outlineBtns[1].classList.remove('on');
});
outlineBtns[1].addEventListener('click', function () {
editor.config.outline = false;
var panel = editor.el.querySelector('.me-outline');
if (panel) panel.remove();
outlineBtns[1].classList.add('on');
outlineBtns[0].classList.remove('on');
});
// Toast demo
window.demoToast = function () {
var types = ['success', 'error', 'warning', 'info'];
var type = types[Math.floor(Math.random() * types.length)];
var messages = {
success: '✅ 内容已自动保存',
error: '❌ 操作失败,请重试',
warning: '⚠️ 有未保存的更改',
info: 'ℹ️ 这是来自插件系统的通知',
};
editor.toast(messages[type], { type: type, duration: 2500 });
};
// Outline demo
window.demoOutline = function () {
if (editor.config.outline) {
editor.config.outline = false;
var panel = editor.el.querySelector('.me-outline');
if (panel) panel.remove();
outlineBtns[0].classList.remove('on');
outlineBtns[1].classList.add('on');
} else {
editor.config.outline = true;
editor._buildOutline();
outlineBtns[0].classList.add('on');
outlineBtns[1].classList.remove('on');
}
};
// Register custom shortcut
editor.registerShortcut('Ctrl+Shift+T', function () {
editor.toast('快捷键 Ctrl+Shift+T 已触发!', { type: 'info' });
}, 'Toast test');
// Listen for theme change to update select
editor.on('themeChange', function (data) {
document.getElementById('themeSelect').value = data.theme;
});
// 初始状态
updateStatus();
logEvent('ready', '编辑器已就绪');
// Sync editor mode button state
editor.on('modeChange', function (mode) {
document.querySelectorAll('#modeBtns button').forEach(function (b) {
b.classList.toggle('on', b.dataset.mode === mode);
});
});
})();
</script>
</body>
</html>
+28 -7
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
<title>MetonaEditor · 文档</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -175,7 +176,7 @@
<header class="docs-header">
<h1><span class="grad">文档</span></h1>
<p class="sub">MetonaEditor v0.1.3 完整 API、配置与使用指南</p>
<p class="sub">MetonaEditor v0.1.10 完整 API、配置与使用指南</p>
</header>
<div class="container">
@@ -554,20 +555,40 @@ editor<span class="c-punc">.</span><span class="c-fn">exportHTML</span><span cla
<section id="themes">
<h2>主题系统</h2>
<pre><span class="c-com">// 切换主题</span>
<pre><span class="c-com">// 全局切换主题</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'light'</span><span class="c-punc">);</span> <span class="c-com">// 亮色</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span> <span class="c-com">// 暗色</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'warm'</span><span class="c-punc">);</span> <span class="c-com">// 暖色</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'auto'</span><span class="c-punc">);</span> <span class="c-com">// 自动(默认)</span>
<span class="c-com">// 注册自定义主题</span>
<span class="c-com">// 实例级主题(v0.1.5,每个编辑器独立)</span>
editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">getTheme</span><span class="c-punc">();</span> <span class="c-com">// 'dark'</span>
editor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'themeChange'</span><span class="c-punc">,</span> <span class="c-punc">({</span> theme <span class="c-punc">})</span> <span class="c-punc">=></span> <span class="c-punc">{});</span>
<span class="c-com">// 注册自定义主题(v0.1.5: 支持继承)</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span>
bg<span class="c-punc">:</span> <span class="c-str">'#001122'</span><span class="c-punc">,</span> text<span class="c-punc">:</span> <span class="c-str">'#aabbcc'</span><span class="c-punc">,</span> accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span><span class="c-punc">,</span>
extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> <span class="c-com">// 继承 dark 主题</span>
accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span><span class="c-punc">,</span>
<span class="c-punc">});</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">applyTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">);</span></pre>
<p>CSS 变量:主题变量限定在 <code>.me-wrapper</code> 元素上,不污染全局样式。支持 <code>@media print</code> 打印样式。</p>
<span class="c-com">// 外部主题跟随(v0.1.5</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">followExternalTheme</span><span class="c-punc">(</span>
<span class="c-punc">{</span> element<span class="c-punc">:</span> document<span class="c-punc">.</span>body<span class="c-punc">,</span> attr<span class="c-punc">:</span> <span class="c-str">'data-theme'</span> <span class="c-punc">},</span>
<span class="c-punc">(</span>theme<span class="c-punc">)</span> <span class="c-punc">=></span> editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span>theme<span class="c-punc">)</span>
<span class="c-punc">);</span>
<span class="c-com">// 从父容器继承主题</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">adoptFromParent</span><span class="c-punc">(</span>editor<span class="c-punc">.</span>container<span class="c-punc">,</span>
<span class="c-punc">(</span>theme<span class="c-punc">)</span> <span class="c-punc">=></span> editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span>theme<span class="c-punc">)</span>
<span class="c-punc">);</span>
<span class="c-com">// CSS 变量导出</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">exportCSSVars</span><span class="c-punc">();</span> <span class="c-com">// { '--md-bg': '#fff', ... }</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">getCSSVariable</span><span class="c-punc">(</span><span class="c-str">'accent'</span><span class="c-punc">);</span> <span class="c-com">// '#3b82f6'</span></pre>
<p>CSS 变量限定在 <code>.me-wrapper</code> 元素上,不污染全局。支持 <code>@media print</code> 打印样式。v0.1.5 新增实例级主题隔离、外部跟随、主题继承。</p>
</section>
<!-- ======== 国际化 ======== -->
@@ -598,7 +619,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
<footer>
<div class="container">
MetonaEditor v0.1.3 · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
MetonaEditor v0.1.10 · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
</div>
</footer>
+37 -9
View File
@@ -136,7 +136,7 @@
<!-- 6. Action Toast -->
<div class="card">
<div class="label">⚡ Action Toast <span style="color:#34d399;font-size:10px;">v2.0</span></div>
<div class="label">⚡ Action Toast</div>
<div class="btn-row">
<button class="btn c-e" onclick="demoActionDelete()">删除+撤销</button>
<button class="btn c-i" onclick="demoActionMulti()">多按钮</button>
@@ -146,7 +146,7 @@
<!-- 7. 分组管理 -->
<div class="card">
<div class="label">📁 分组管理 <span style="color:#34d399;font-size:10px;">v2.0</span></div>
<div class="label">📁 分组管理</div>
<div class="btn-row">
<button class="btn c-p" onclick="demoGroup()">创建分组消息</button>
<button class="btn c-e" onclick="dismissGroup()">关闭分组</button>
@@ -209,9 +209,9 @@
</div>
</div>
<!-- 12. v2.0 新功能 -->
<!-- 12. 高级特性 -->
<div class="card">
<div class="label">🆕 v2.0 新功能 <span style="color:#34d399;font-size:10px;">resetTimer · onUpdate · render · notify</span></div>
<div class="label">🔥 高级特性 <span style="color:#34d399;font-size:10px;">resetTimer · onUpdate · render · notify</span></div>
<div class="btn-row">
<button class="btn c-i" onclick="demoResetTimer()">resetTimerOnUpdate</button>
<button class="btn c-p" onclick="demoOnUpdate()">onUpdate 回调</button>
@@ -242,7 +242,16 @@
</div>
</div>
<!-- 14. 交互特性 -->
<!-- 14. 错误回调 -->
<div class="card">
<div class="label">🛡️ onError 错误捕获 <span style="color:#34d399;font-size:10px;">v0.1.2</span></div>
<div class="btn-row">
<button class="btn c-e" onclick="demoOnError()">演示 onError</button>
<button class="btn c-w" onclick="demoOnErrorReset()">重置回调</button>
</div>
</div>
<!-- 15. 交互特性 -->
<div class="card">
<div class="label">✋ 交互特性</div>
<div class="btn-row">
@@ -254,7 +263,7 @@
</div>
</div>
<!-- 15. 国际化切换 -->
<!-- 16. 国际化切换 -->
<div class="card">
<div class="label">🌍 国际化 & 格式化</div>
<div class="btn-row">
@@ -264,7 +273,7 @@
</div>
</div>
<!-- 16. 107种预设图标 -->
<!-- 17. 107种预设图标 -->
<div class="card" style="grid-column:1/-1;">
<div class="label">🎨 107种预设图标 — 点击任意图标发送Toast</div>
<div class="btn-row" id="icon-grid" style="max-height:300px;overflow-y:auto;padding:4px 0;"></div>
@@ -273,7 +282,7 @@
</main>
<footer>
MetonaToast v2.0.1 · MIT · <a href="index.html">首页</a> · <a href="docs.html">文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaToast">Gitea</a>
MetonaToast v0.1.2 · MIT · <a href="index.html">首页</a> · <a href="docs.html">文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaToast">Gitea</a>
</footer>
<script src="../dist/metona-toast.js"></script>
@@ -351,7 +360,7 @@
MeToast.info('自定义插件已安装(消息后追加计数)');
}
// === v2.0 new features ===
// === 高级特性 ===
function demoResetTimer() {
const t=MeToast.info('这条消息不会自动消失(resetTimer',{duration:3000,resetTimerOnUpdate:true,closeButton:true});
let n=0; const iv=setInterval(()=>{n++;if(n>8){clearInterval(iv);t.close();return;}t.update({message:'重置计时器 #'+n+' — 每次更新重置3秒'});},1000);
@@ -387,6 +396,25 @@
});
}
// === onError ===
function demoOnError() {
MeToast.configure({
onError: function(info) {
MeToast.error('⚠️ 捕获错误: ' + (info.hook || info.source), { duration: 3000, position: 'bottom-center' });
}
});
MeToast.info('已启用 onError 监控(钩子/定时器异常将被捕获)', { duration: 3000 });
// 触发一个钩子错误来演示
var badFn = function() { throw new Error('demo-error'); };
Toast.on('afterShow', badFn);
MeToast.success('这条消息会触发错误', { duration: 2000 });
setTimeout(function() { Toast.off('afterShow', badFn); }, 3000);
}
function demoOnErrorReset() {
MeToast.configure({ onError: null });
MeToast.info('onError 已重置为 null');
}
// === i18n ===
function switchLocale(loc) {
try{MeToast.i18n.switchLocale(loc);MeToast.configure({locale:loc});MeToast.success(loc==='zh-CN'?'已切换到中文':'Switched to English',{theme:'dark'});}
+12 -10
View File
@@ -69,9 +69,9 @@
<a href="#countdown">countdown()</a>
<a href="#queue">queue()</a>
<a href="#stack">stack()</a>
<a href="#action">action() <span class="badge badge-new">NEW</span></a>
<a href="#action">action()</a>
<div class="section">管理方法</div>
<a href="#group">group() <span class="badge badge-new">NEW</span></a>
<a href="#group">group()</a>
<a href="#dismiss">dismiss()</a>
<a href="#clear">clear()</a>
<a href="#configure">configure()</a>
@@ -87,7 +87,7 @@
<main class="main">
<h1>API 文档</h1>
<p>MetonaToast v2.0.1 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 <a href="#config">配置项</a> 作为可选第二参数。</p>
<p>MetonaToast v0.1.2 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 <a href="#config">配置项</a> 作为可选第二参数。</p>
<!-- ===== 基础通知 ===== -->
<h2 id="show">show(message, opts?)</h2>
@@ -309,7 +309,7 @@ q.<span class="fn">cancel</span>(); <span class="cm">// 中途取消</span>
<span class="fn">type</span>: <span class="s">'info'</span>,
});</pre>
<h2 id="action">action(message, actions, opts?) <span class="badge badge-new">v2.0</span></h2>
<h2 id="action">action(message, actions, opts?)</h2>
<p>Action Toast:内嵌操作按钮。默认 duration=0 不自动关闭,closeButton=true。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
@@ -331,7 +331,7 @@ q.<span class="fn">cancel</span>(); <span class="cm">// 中途取消</span>
]);</pre>
<!-- ===== 管理方法 ===== -->
<h2 id="group">group(name) <span class="badge badge-new">v2.0</span></h2>
<h2 id="group">group(name)</h2>
<p>创建 Toast 分组,返回 GroupAPI 对象。该对象所有方法自动传入 <code>group: name</code>,按组管理。</p>
<table>
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
@@ -409,7 +409,7 @@ MeToast.<span class="fn">use</span>(<span class="s">'accessibility'</span>); <sp
<!-- ===== 配置参考 ===== -->
<h2 id="config">全部配置项</h2>
<p>以下配置可用于 <code>configure()</code><code>init()</code> 或单个 Toast 方法的 opts 参数。<span class="badge badge-new">v2.0</span> 标识的为 v2.0 新增。</p>
<p>以下配置可用于 <code>configure()</code><code>init()</code> 或单个 Toast 方法的 opts 参数。</p>
<table>
<tr><th>配置项</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>position</td><td>string</td><td>'top-right'</td><td>位置:top-left / top-center / top-right / bottom-left / bottom-center / bottom-right</td></tr>
@@ -431,9 +431,10 @@ MeToast.<span class="fn">use</span>(<span class="s">'accessibility'</span>); <sp
<tr><td>className</td><td>string</td><td>''</td><td>附加到 Toast 元素上的 CSS 类名</td></tr>
<tr><td>style</td><td>object</td><td>{}</td><td>附加到 Toast 元素上的内联样式对象</td></tr>
<tr><td>locale</td><td>string</td><td>'zh-CN'</td><td>语言代码(zh-CN / en-US 等)</td></tr>
<tr><td>resetTimerOnUpdate <span class="badge badge-new">v2.0</span></td><td>boolean</td><td>false</td><td>调用 update() 时重置 duration 倒计时</td></tr>
<tr><td>notifyWhenHidden <span class="badge badge-new">v2.0</span></td><td>boolean</td><td>false</td><td>页面不可见时自动通过 Notification API 发送系统通知</td></tr>
<tr><td>render <span class="badge badge-new">v2.0</span></td><td>function</td><td></td><td>自定义渲染函数 <code>(toast) => htmlString</code>,完全接管 DOM 构建</td></tr>
<tr><td>resetTimerOnUpdate</td><td>boolean</td><td>false</td><td>调用 update() 时重置 duration 倒计时</td></tr>
<tr><td>notifyWhenHidden</td><td>boolean</td><td>false</td><td>页面不可见时自动通过 Notification API 发送系统通知</td></tr>
<tr><td>render</td><td>function</td><td></td><td>自定义渲染函数 <code>(toast) => htmlString</code>,完全接管 DOM 构建</td></tr>
<tr><td>onError</td><td>function</td><td></td><td>全局错误回调 <code>({ hook, source, error, toast }) => void</code>,钩子异常或定时器错误时触发</td></tr>
</table>
<h2 id="callbacks">回调函数</h2>
@@ -442,7 +443,8 @@ MeToast.<span class="fn">use</span>(<span class="s">'accessibility'</span>); <sp
<tr><td>onShow</td><td>(toast: ToastInstance) => void</td><td>Toast DOM 创建并播放入场动画后</td></tr>
<tr><td>onClose</td><td>(toast: ToastInstance) => void</td><td>Toast DOM 被移除后(离场动画完成时)</td></tr>
<tr><td>onClick</td><td>(toast: ToastInstance) => void</td><td>Toast 被点击时(closeOnClick 为 true 时还会自动关闭)</td></tr>
<tr><td>onUpdate <span class="badge badge-new">v2.0</span></td><td>(toast: ToastInstance) => void</td><td>Toast 内容通过 update() 更新后</td></tr>
<tr><td>onUpdate</td><td>(toast: ToastInstance) => void</td><td>Toast 内容通过 update() 更新后</td></tr>
<tr><td>onError</td><td>({ hook, source, error, toast }) => void</td><td>钩子回调或定时器发生异常时(全局配置,适用于接入错误监控)</td></tr>
</table>
<h2 id="animations">动画列表</h2>
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "marklite",
"version": "0.4.0",
"version": "0.4.2",
"description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js",
"scripts": {
@@ -28,9 +28,9 @@
"author": "MarkLite",
"license": "MIT",
"dependencies": {
"@metona-team/metona-editor": "^0.1.3",
"@metona-team/metona-editor": "^0.1.10",
"@metona-team/metona-toast": "^0.1.2",
"dexie": "^4.0.11",
"@metona-team/metona-toast": "^2.0.1",
"nanoid": "^5.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+3 -3
View File
@@ -33,7 +33,7 @@ export function App() {
const viewMode = useEditorStore(s => s.viewMode)
const externallyModified = useEditorStore(s => s.externallyModified)
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const { darkMode, toggleDarkMode } = useTheme()
const { themeMode, cycleTheme } = useTheme()
const { confirm, confirmDialogProps } = useConfirm()
const [showAbout, setShowAbout] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
@@ -86,7 +86,7 @@ export function App() {
<div id="app" className={`mode-${viewMode}`}>
<Toolbar
onOpen={handleOpenFile} onSave={handleSave}
darkMode={darkMode} onToggleDark={toggleDarkMode}
themeMode={themeMode} onCycleTheme={cycleTheme}
onShowAbout={() => setShowAbout(true)}
isAutoSaving={isAutoSaving}
autoSaveEnabled={autoSaveEnabled}
@@ -101,7 +101,7 @@ export function App() {
)}
{tabs.length > 0 ? (
<div id="content-wrapper">
<div id="editor-panel"><Editor darkMode={darkMode} /></div>
<div id="editor-panel"><Editor themeMode={themeMode} onAppSave={handleSave} /></div>
</div>
) : (
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
+40 -48
View File
@@ -1,13 +1,21 @@
import React, { useEffect, useRef } from 'react'
import MeEditor from '@metona-team/metona-editor'
import type { MarkdownEditor, EditMode, PluginObject } from '@metona-team/metona-editor'
import type { MarkdownEditor, MarkdownEditorOptions, EditMode, ThemeName } from '@metona-team/metona-editor'
import { useTabStore } from '../../stores/tabStore'
import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
import { settingsRepository } from '../../db/settingsRepository'
import { renderMarkdownSync } from '../../lib/markdown'
import type { ThemeMode } from '../../types/settings'
// v0.1.9 demo 验证过但类型声明未收录的配置项
interface ExtendedEditorOptions extends MarkdownEditorOptions {
lineNumbers?: boolean
autoBrackets?: boolean
}
interface EditorProps {
darkMode: boolean
themeMode: ThemeMode
onAppSave?: () => void
}
/** 将应用 viewMode 映射到 MetonaEditor 的 mode */
@@ -24,26 +32,17 @@ function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' {
return 'editor'
}
/** 组装实例级插件列表 */
function buildPlugins(): PluginObject[] {
const plugins: PluginObject[] = []
// 搜索/替换(Ctrl+F / Ctrl+H
if (MeEditor.presetPlugins?.searchReplace) {
plugins.push(MeEditor.presetPlugins.searchReplace)
}
// 粘贴图片 → base64
if (MeEditor.presetPlugins?.imagePaste) {
plugins.push(MeEditor.presetPlugins.imagePaste)
}
return plugins
}
/** 预设插件(字符串形式,与 demo 一致) */
const EDITOR_PLUGINS: string[] = [
'searchReplace', // Ctrl+F/Ctrl+H
'imagePaste', // Ctrl+V 粘贴图片
'exportTool', // 导出 MD/HTML
]
/**
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
*/
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) {
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const updateTabContent = useTabStore(s => s.updateTabContent)
@@ -65,7 +64,7 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
const container = containerRef.current
if (!container) return
const editor = MeEditor.create(container, {
const config: ExtendedEditorOptions = {
value: activeTab?.content ?? '',
mode: mapViewMode(viewMode),
height: '100%',
@@ -78,12 +77,16 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
'edit', 'split', 'preview', 'fullscreen'
],
locale: 'zh-CN',
theme: darkMode ? 'dark' : 'light',
theme: themeMode as ThemeName,
placeholder: '在此输入 Markdown 内容...',
spellcheck: false,
tabSize: 2,
wordCount: true,
plugins: buildPlugins(),
autofocus: true,
lineNumbers: true,
autoBrackets: true,
readOnly: viewMode === 'preview',
plugins: EDITOR_PLUGINS,
// 使用 unified 管线渲染,保留图片路径修复能力
render: (md: string) => {
@@ -110,8 +113,15 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
const mapped = reverseMapMode(mode)
setViewMode(mapped)
settingsRepository.save({ viewMode: mapped })
},
// Ctrl+S → 触发应用层保存(Electron IPC 写文件系统)
onSave: () => {
onAppSave?.()
}
})
}
const editor = MeEditor.create(container, config)
editorRef.current = editor
setMetonaEditorGetter(() => editor)
@@ -165,37 +175,17 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// ── 暗色模式同步 ──────────────────────────────────────────
// ── 主题同步 ──────────────────────────────────────────
useEffect(() => {
try {
const themeName = darkMode ? 'dark' : 'light'
// 1. 全局主题(documentElement + localStorage
MeEditor.setTheme(themeName)
// 2. 覆写当前实例 wrapper 的 inline CSS 变量
// MeEditor.setTheme 只更新 documentElement,不更新 wrapper
// 而 create() 时写入的 inline 变量优先级最高,必须手动覆写
const wrapper = containerRef.current?.querySelector('.me-wrapper') as HTMLElement | null
if (wrapper) {
const config: Record<string, string> = MeEditor.themes.getThemeConfig(themeName) as Record<string, string>
wrapper.style.setProperty('--md-bg', config.bg)
wrapper.style.setProperty('--md-text', config.text)
wrapper.style.setProperty('--md-border', config.border)
wrapper.style.setProperty('--md-shadow', config.shadow)
if (config.hoverShadow) wrapper.style.setProperty('--md-hover-shadow', config.hoverShadow)
if (config.toolbarBg) wrapper.style.setProperty('--md-toolbar-bg', config.toolbarBg)
if (config.textareaBg) wrapper.style.setProperty('--md-textarea-bg', config.textareaBg)
if (config.previewBg) wrapper.style.setProperty('--md-preview-bg', config.previewBg)
if (config.codeBg) wrapper.style.setProperty('--md-code-bg', config.codeBg)
if (config.codeText) wrapper.style.setProperty('--md-code-text', config.codeText)
if (config.accent) wrapper.style.setProperty('--md-accent', config.accent)
if (config.muted) wrapper.style.setProperty('--md-muted', config.muted)
}
// 全局主题(documentElement + localStorage
MeEditor.setTheme(themeMode)
// v0.1.5+: 实例级主题,自动处理 wrapper CSS 变量
editorRef.current?.setTheme(themeMode)
} catch {
// 容错
}
}, [darkMode])
}, [themeMode])
// ── 视图模式同步 ──────────────────────────────────────────
useEffect(() => {
@@ -205,6 +195,8 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
if (editor.getMode() !== targetMode) {
editor.setMode(targetMode)
}
// 预览模式设为只读
editor.setReadOnly(viewMode === 'preview')
}, [viewMode])
return (
+21 -6
View File
@@ -1,25 +1,34 @@
import React from 'react'
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons'
import type { ThemeMode } from '../../types/settings'
interface ToolbarProps {
onOpen: () => void
onSave: () => void
darkMode: boolean
onToggleDark: () => void
themeMode: ThemeMode
onCycleTheme: () => void
onShowAbout: () => void
isAutoSaving: boolean
autoSaveEnabled: boolean
onToggleAutoSave: () => void
}
const THEME_LABELS: Record<ThemeMode, string> = {
light: '亮色',
dark: '暗色',
warm: '暖色',
}
/**
* 应用顶层工具栏 — 文件操作、自动保存、主题、关于。
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、关于。
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
*/
export const Toolbar = React.memo(function Toolbar({
onOpen, onSave, darkMode, onToggleDark, onShowAbout,
onOpen, onSave, themeMode, onCycleTheme, onShowAbout,
isAutoSaving, autoSaveEnabled, onToggleAutoSave
}: ToolbarProps) {
const nextLabel = THEME_LABELS[themeMode] ?? '主题'
return (
<div id="toolbar" role="toolbar" aria-label="工具栏">
<div className="toolbar-left" role="group" aria-label="文件操作">
@@ -42,8 +51,14 @@ export const Toolbar = React.memo(function Toolbar({
</button>
</div>
<div className="toolbar-right" role="group" aria-label="设置">
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题" aria-label={darkMode ? '切换到亮色主题' : '切换到暗色主题'}>
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
<button
className="toolbar-btn"
onClick={onCycleTheme}
title={`当前 ${nextLabel} — 点击切换`}
aria-label={`当前${nextLabel}主题,点击切换`}
>
{themeMode === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
<span style={{ fontSize: 12, marginLeft: 2 }}>{nextLabel}</span>
</button>
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
<Info size={18} />
+1 -1
View File
@@ -18,7 +18,7 @@ export interface ActiveTabRecord {
export interface SettingsRecord {
id: string // 固定为 'default'
darkMode: boolean
themeMode: 'light' | 'dark' | 'warm'
viewMode: 'editor' | 'preview' | 'source'
sidebarCollapsed: boolean
sidebarWidth: number
+1 -1
View File
@@ -8,7 +8,7 @@ export const settingsRepository = {
const record: SettingsRecord | undefined = await db.settings.get('default')
if (record) {
return {
darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode,
themeMode: record.themeMode ?? DEFAULT_SETTINGS.themeMode,
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
+11 -1
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useRef } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
@@ -8,12 +8,16 @@ import { showToast } from '../lib/toast'
/**
* AR-01: 从 App.tsx 提取的文件操作逻辑
* UX-02: 添加 loading 状态指示
* v0.1.9: handleSave 添加防重入锁,避免编辑器 onSave + 全局 Ctrl+S 双重触发
*/
export function useFileOperations() {
const createTab = useTabStore(s => s.createTab)
const getActiveTab = useTabStore(s => s.getActiveTab)
const setLoading = useEditorStore(s => s.setLoading)
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发
const savingGate = useRef(false)
const handleOpenFile = useCallback(async (): Promise<void> => {
try {
if (!window.electronAPI) return
@@ -33,6 +37,9 @@ export function useFileOperations() {
}, [createTab, setLoading])
const handleSave = useCallback(async (): Promise<void> => {
// 防重入:300ms 内忽略重复调用(编辑器 onSave + 全局 Ctrl+S 双重触发)
if (savingGate.current) return
savingGate.current = true
try {
const tab = getActiveTab()
if (!tab || !window.electronAPI) return
@@ -47,6 +54,9 @@ export function useFileOperations() {
} catch (error) {
logError('保存文件失败', error)
showToast('保存失败', 'error')
} finally {
// 300ms 后释放锁,允许下次保存
setTimeout(() => { savingGate.current = false }, 300)
}
}, [getActiveTab])
+3 -1
View File
@@ -4,13 +4,15 @@ import { useTabStore } from '../stores/tabStore'
/**
* 全局键盘快捷键 hook。
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
* 本 hook 仅处理应用级快捷键。
* v0.1.9 onSave 回调处理编辑器聚焦时的 Ctrl+S,
* 全局 handler 作为焦点外兜底(工具栏/侧边栏聚焦时仍可保存)。
*/
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
const handleKeydown = useCallback((e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
// 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return }
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
+5 -6
View File
@@ -10,7 +10,7 @@ import { logError } from '../lib/errorHandler'
* 替代各 hook 各自独立加载设置的模式。
*/
export function useSettingsInit() {
const setDarkMode = useEditorStore(s => s.setDarkMode)
const setThemeMode = useEditorStore(s => s.setThemeMode)
const setViewMode = useEditorStore(s => s.setViewMode)
const isInitialized = useRef(false)
@@ -20,11 +20,10 @@ export function useSettingsInit() {
settingsRepository.load()
.then((settings) => {
// 主题
// 主题:优先使用保存的设置,否则跟随系统偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const darkMode = settings.darkMode !== undefined ? settings.darkMode : prefersDark
setDarkMode(darkMode)
document.documentElement.classList.toggle('dark', darkMode)
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
setThemeMode(themeMode)
// 视图模式
setViewMode(settings.viewMode ?? 'editor')
@@ -42,7 +41,7 @@ export function useSettingsInit() {
.catch((error: unknown) => {
logError('加载设置失败', error)
})
}, [setDarkMode, setViewMode])
}, [setThemeMode, setViewMode])
return { isInitialized }
}
+80 -13
View File
@@ -1,23 +1,90 @@
import { useEffect } from 'react'
import { useEffect, useCallback } from 'react'
import MeEditor from '@metona-team/metona-editor'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
import { syncToastTheme } from '../lib/toast'
import type { ThemeMode } from '../types/settings'
/** 将 hex 颜色转为带 alpha 的版本,用于背景色 */
function hexWithAlpha(hex: string, alpha: number): string {
if (!hex || !hex.startsWith('#')) return hex
const a = Math.round(alpha * 255).toString(16).padStart(2, '0')
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a
}
/** 将编辑器 CSS 变量同步到应用根元素,保持颜色一致 */
function syncAppColorsToEditor(): void {
try {
const vars = MeEditor.themes.exportCSSVars()
if (!vars || typeof vars !== 'object') return
const root = document.documentElement
const set = (name: string, value: string | undefined) => {
if (value) root.style.setProperty(name, value)
}
const accent = vars['--md-accent'] ?? '#1a73e8'
// 直接映射 — 文本/边框用纯色
set('--bg', vars['--md-bg'])
set('--text', vars['--md-text'])
set('--border', vars['--md-border'])
set('--primary', accent)
set('--primary-dark', accent)
set('--text-secondary', vars['--md-muted'])
set('--text-tertiary', vars['--md-muted'])
set('--code-bg', vars['--md-code-bg'])
// 背景派生 — 用 accent 的低透明度版本
const accentBg = hexWithAlpha(accent, 0.12)
set('--primary-light', accentBg)
set('--sidebar-active', accentBg)
set('--sidebar-bg', vars['--md-bg'])
set('--sidebar-border', vars['--md-border'])
set('--sidebar-hover', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.4))
set('--bg-secondary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
set('--bg-tertiary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.08))
set('--border-light', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.5))
set('--search-bg', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
set('--search-border', vars['--md-border'])
// 阴影根据主题适配
const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff'
root.style.setProperty('--shadow', isDark
? '0 1px 3px rgba(0,0,0,0.3)'
: '0 1px 3px rgba(0,0,0,0.08)')
root.style.setProperty('--shadow-lg', isDark
? '0 4px 12px rgba(0,0,0,0.4)'
: '0 4px 12px rgba(0,0,0,0.1)')
} catch { /* 容错 */ }
}
const THEME_TO_TOAST: Record<ThemeMode, string> = {
light: 'light',
dark: 'dark',
warm: 'warm',
}
/**
* AR-04: 主题 hook
* 不再独立加载设置(由 useSettingsInit 统一加载)
* 仅负责主题切换时的同步和保存
* 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
* 应用颜色自动同步编辑器主题,保持一致。
*/
export function useTheme() {
const darkMode = useEditorStore(s => s.darkMode)
const toggleDarkMode = useEditorStore(s => s.toggleDarkMode)
const themeMode = useEditorStore(s => s.themeMode)
const cycleTheme = useEditorStore(s => s.cycleTheme)
// darkMode 变化时同步 class 并保存
const handleCycleTheme = useCallback(() => {
const next = cycleTheme()
// 同步编辑器全局主题
MeEditor.setTheme(next)
// 同步 Toast 主题
import('../lib/toast').then(({ MeToast }) => {
MeToast.themes.switchTheme(THEME_TO_TOAST[next] ?? 'auto')
})
// 持久化
settingsRepository.save({ themeMode: next })
}, [cycleTheme])
// themeMode 变化时同步应用颜色
useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode)
settingsRepository.save({ darkMode })
syncToastTheme(darkMode)
}, [darkMode])
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
syncAppColorsToEditor()
}, [themeMode])
return { darkMode, toggleDarkMode }
return { themeMode, cycleTheme: handleCycleTheme }
}
@@ -5,7 +5,7 @@ describe('editorStore', () => {
beforeEach(() => {
useEditorStore.setState({
viewMode: 'editor',
darkMode: false,
themeMode: 'light',
externallyModified: null,
loadingStates: {},
})
@@ -24,29 +24,36 @@ describe('editorStore', () => {
})
})
describe('setDarkMode', () => {
it('should enable dark mode', () => {
useEditorStore.getState().setDarkMode(true)
expect(useEditorStore.getState().darkMode).toBe(true)
describe('themeMode', () => {
it('should default to light', () => {
expect(useEditorStore.getState().themeMode).toBe('light')
})
it('should disable dark mode', () => {
useEditorStore.setState({ darkMode: true })
useEditorStore.getState().setDarkMode(false)
expect(useEditorStore.getState().darkMode).toBe(false)
it('should set theme mode', () => {
useEditorStore.getState().setThemeMode('dark')
expect(useEditorStore.getState().themeMode).toBe('dark')
})
it('should set warm theme', () => {
useEditorStore.getState().setThemeMode('warm')
expect(useEditorStore.getState().themeMode).toBe('warm')
})
})
describe('toggleDarkMode', () => {
it('should toggle from false to true', () => {
useEditorStore.getState().toggleDarkMode()
expect(useEditorStore.getState().darkMode).toBe(true)
describe('cycleTheme', () => {
it('should cycle light → dark', () => {
expect(useEditorStore.getState().cycleTheme()).toBe('dark')
expect(useEditorStore.getState().themeMode).toBe('dark')
})
it('should toggle from true to false', () => {
useEditorStore.setState({ darkMode: true })
useEditorStore.getState().toggleDarkMode()
expect(useEditorStore.getState().darkMode).toBe(false)
it('should cycle dark → warm', () => {
useEditorStore.setState({ themeMode: 'dark' })
expect(useEditorStore.getState().cycleTheme()).toBe('warm')
})
it('should cycle warm → light', () => {
useEditorStore.setState({ themeMode: 'warm' })
expect(useEditorStore.getState().cycleTheme()).toBe('light')
})
})
+15 -7
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'
import type { ViewMode } from '../types/settings'
import type { ViewMode, ThemeMode } from '../types/settings'
import type { MarkdownEditor } from '@metona-team/metona-editor'
// Module-level getter for the MetonaEditor instance
@@ -14,17 +14,19 @@ export function getMetonaEditor(): MarkdownEditor | null {
return _getEditor ? _getEditor() : null
}
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
interface EditorState {
viewMode: ViewMode
darkMode: boolean
themeMode: ThemeMode
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
externallyModified: { filePath: string } | null
// UX-02: 全局加载状态
loadingStates: Record<string, boolean>
setViewMode: (mode: ViewMode) => void
setDarkMode: (dark: boolean) => void
toggleDarkMode: () => void
setThemeMode: (mode: ThemeMode) => void
cycleTheme: () => ThemeMode
setExternallyModified: (info: { filePath: string } | null) => void
// UX-02: 加载状态管理
setLoading: (key: string, loading: boolean) => void
@@ -33,13 +35,19 @@ interface EditorState {
export const useEditorStore = create<EditorState>((set, get) => ({
viewMode: 'editor',
darkMode: false,
themeMode: 'light',
externallyModified: null,
loadingStates: {},
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
setDarkMode: (dark: boolean) => set({ darkMode: dark }),
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })),
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
cycleTheme: () => {
const current = get().themeMode
const idx = THEME_CYCLE.indexOf(current)
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]
set({ themeMode: next })
return next
},
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
setLoading: (key: string, loading: boolean) => set(state => ({
loadingStates: { ...state.loadingStates, [key]: loading }
+2 -19
View File
@@ -23,23 +23,6 @@
--sidebar-active: var(--primary-light);
--search-bg: var(--bg-secondary);
--search-border: var(--border);
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
}
:root.dark {
--primary: #8ab4f8;
--primary-light: #1a3a5c;
--primary-dark: #aecbfa;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-tertiary: #2d2d2d;
--text: #d4d4d4;
--text-secondary: #9e9e9e;
--text-tertiary: #6e6e6e;
--border: #3e3e3e;
--border-light: #333333;
--code-bg: #2d2d2d;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Consolas, 'Courier New', monospace;
}
+3 -2
View File
@@ -1,14 +1,15 @@
export type ThemeMode = 'light' | 'dark' | 'warm'
export type ViewMode = 'editor' | 'preview' | 'source'
export interface Settings {
darkMode: boolean
themeMode: ThemeMode
viewMode: ViewMode
sidebarCollapsed: boolean
sidebarWidth: number
}
export const DEFAULT_SETTINGS: Settings = {
darkMode: false,
themeMode: 'light',
viewMode: 'editor',
sidebarCollapsed: false,
sidebarWidth: 240
+1 -1
View File
@@ -1,5 +1,5 @@
// 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.4.0'
export const APP_VERSION = 'v0.4.2'
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([