切换markdown渲染为unified
This commit is contained in:
@@ -19,5 +19,12 @@
|
|||||||
"topic_20260703-031922_1d2031ca5f2c0dd5": "auto",
|
"topic_20260703-031922_1d2031ca5f2c0dd5": "auto",
|
||||||
"topic_20260703-035328_e939286e13cc8f1b": "auto",
|
"topic_20260703-035328_e939286e13cc8f1b": "auto",
|
||||||
"topic_20260703-060642_54474cc49e29104f": "auto",
|
"topic_20260703-060642_54474cc49e29104f": "auto",
|
||||||
"topic_20260703-065009_d937c6f780ebb5de": "auto"
|
"topic_20260703-065009_d937c6f780ebb5de": "auto",
|
||||||
|
"topic_20260703-071019_122507af94a4bb69": "auto",
|
||||||
|
"topic_20260703-073828_e23737acfa7578f3": "auto",
|
||||||
|
"topic_20260703-075048_605d579e54dfa8f9": "auto",
|
||||||
|
"topic_20260703-081048_20d0dd6e24fe0eca": "auto",
|
||||||
|
"topic_20260703-090137_2a285dcd68326d53": "auto",
|
||||||
|
"topic_20260703-093407_89b9baa209988cbb": "auto",
|
||||||
|
"topic_20260706-025751_d46402fb217a34e9": "auto"
|
||||||
}
|
}
|
||||||
@@ -19,5 +19,12 @@
|
|||||||
"topic_20260703-031922_1d2031ca5f2c0dd5": "我希望第126-228行的内容,在用…",
|
"topic_20260703-031922_1d2031ca5f2c0dd5": "我希望第126-228行的内容,在用…",
|
||||||
"topic_20260703-035328_e939286e13cc8f1b": "我们把整个右侧区域都给了markdo…",
|
"topic_20260703-035328_e939286e13cc8f1b": "我们把整个右侧区域都给了markdo…",
|
||||||
"topic_20260703-060642_54474cc49e29104f": "关于这个核心编辑区域,我需要实现一个…",
|
"topic_20260703-060642_54474cc49e29104f": "关于这个核心编辑区域,我需要实现一个…",
|
||||||
"topic_20260703-065009_d937c6f780ebb5de": "在功能上有问题,现在用户输入的内容删…"
|
"topic_20260703-065009_d937c6f780ebb5de": "在功能上有问题,现在用户输入的内容删…",
|
||||||
|
"topic_20260703-071019_122507af94a4bb69": "现在已经有了很多功能,但是很多样式还…",
|
||||||
|
"topic_20260703-073828_e23737acfa7578f3": "现在我点击md文件,怎么在编辑区域里…",
|
||||||
|
"topic_20260703-075048_605d579e54dfa8f9": "现在我点击目录中的md文件,怎么在编…",
|
||||||
|
"topic_20260703-081048_20d0dd6e24fe0eca": "在渲染的时候有几个问题需要修改,第一…",
|
||||||
|
"topic_20260703-090137_2a285dcd68326d53": "帮我检查一下这个页面为什么渲染不出来…",
|
||||||
|
"topic_20260703-093407_89b9baa209988cbb": "帮我检查一下这个页面为什么渲染不出来…",
|
||||||
|
"topic_20260706-025751_d46402fb217a34e9": "这个页面现在是用cm6实现的预览功能…"
|
||||||
}
|
}
|
||||||
191
doc/markdown-editor-design.md
Normal file
191
doc/markdown-editor-design.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# MarkdownEditor 块级编辑设计文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
`MarkdownEditor` 是 yurou(语柔)的核心编辑组件,支持两种编辑模式:
|
||||||
|
|
||||||
|
| 模式 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| **双栏**(split) | 左侧 textarea 编辑源码,右侧 unified 渲染 HTML 预览,滚动同步 |
|
||||||
|
| **单栏**(wysiwyg) | 逐块渲染与编辑:点击任意块弹出其原始 markdown 源码,失焦后自动重新渲染 |
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **解析**:`remark-parse` + `remark-gfm`(GFM 扩展:表格、任务列表、删除线)
|
||||||
|
- **转换**:`remark-rehype`(mdast → hast)
|
||||||
|
- **安全**:`rehype-sanitize`(过滤 XSS,保留 `data-*` 属性)
|
||||||
|
- **输出**:`rehype-stringify`(hast → HTML 字符串)
|
||||||
|
- **框架**:Vue 3 Composition API + TypeScript
|
||||||
|
|
||||||
|
## 整体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
modelValue (markdown 字符串)
|
||||||
|
│
|
||||||
|
├─── split 模式 ──────────────────────────
|
||||||
|
│ │
|
||||||
|
│ ├── textarea (左栏,编辑源码)
|
||||||
|
│ │ 输入 → emit('update:modelValue')
|
||||||
|
│ │
|
||||||
|
│ └── div.markdown-preview (右栏,v-html)
|
||||||
|
│ unified.processSync(md) → HTML
|
||||||
|
│ 滚动同步:textarea.scrollTop ↔ div.scrollTop
|
||||||
|
│
|
||||||
|
└─── wysiwyg 模式 ────────────────────────
|
||||||
|
│
|
||||||
|
├── remark-parse → mdast
|
||||||
|
│ 提取每个顶层子节点的 position (offset)
|
||||||
|
│
|
||||||
|
├── 每个 block:
|
||||||
|
│ source = md.slice(start, end) // 原始源码
|
||||||
|
│ html = unified.processSync(source) // 渲染 HTML
|
||||||
|
│
|
||||||
|
├── blocks[] = [{id, source, html, startOffset, endOffset}, ...]
|
||||||
|
│
|
||||||
|
└── 模板中 v-for:
|
||||||
|
editingBlockId === block.id
|
||||||
|
? <textarea v-model="editingSource" @blur="finalize" />
|
||||||
|
: <div v-html="block.html" @click="startEdit" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 块级编辑流程
|
||||||
|
|
||||||
|
### 1. 解析阶段(`parseBlocks`)
|
||||||
|
|
||||||
|
```
|
||||||
|
输入:"### 标题\n\n段落内容\n\n```js\ncode\n```"
|
||||||
|
|
||||||
|
remark-parse 构建 mdast:
|
||||||
|
|
||||||
|
root
|
||||||
|
├── heading (depth=3) position: {start: {offset: 0}, end: {offset: 9}}
|
||||||
|
│ └── text "标题"
|
||||||
|
├── paragraph position: {start: {offset: 10}, end: {offset: 18}}
|
||||||
|
│ └── text "段落内容"
|
||||||
|
└── code (lang="js") position: {start: {offset: 19}, end: {offset: 33}}
|
||||||
|
└── "code\n"
|
||||||
|
|
||||||
|
提取每个顶层节点:
|
||||||
|
blocks[0] = {id:"b0", source:"### 标题\n\n", startOffset:0, endOffset:9}
|
||||||
|
blocks[1] = {id:"b1", source:"段落内容\n\n", startOffset:10, endOffset:18}
|
||||||
|
blocks[2] = {id:"b2", source:"```js\ncode\n```", startOffset:19, endOffset:33}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 渲染阶段
|
||||||
|
|
||||||
|
每个 block 独立调用 `unified().processSync(block.source)` 渲染为 HTML:
|
||||||
|
|
||||||
|
- `"### 标题\n\n"` → `<h3>标题</h3>`
|
||||||
|
- `"段落内容\n\n"` → `<p>段落内容</p>`
|
||||||
|
- `` "```js\ncode\n```" `` → `<pre><code class="language-js">code\n</code></pre>`
|
||||||
|
|
||||||
|
模板中按 `blocks[]` 顺序渲染,每个块是一个 `<div>`,hover 时显示暖色背景提示可点击。
|
||||||
|
|
||||||
|
### 3. 编辑阶段(`startEdit` → `finalizeEdit`)
|
||||||
|
|
||||||
|
```
|
||||||
|
用户点击某个 block
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
startEdit(block.id)
|
||||||
|
│ editingBlockId = block.id
|
||||||
|
│ editingSource = block.source // 显示原始 markdown 源码
|
||||||
|
│ nextTick → autoResize + focus + 光标移到末尾
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
<textarea v-model="editingSource" /> 替换该块的 <div>
|
||||||
|
│
|
||||||
|
│ monospace 字体,暖色边框,auto-resize
|
||||||
|
│ Ctrl+S → 先 finalize 再触发 save-shortcut
|
||||||
|
│ Esc → cancelEdit (丢弃修改)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
失焦 (blur)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
finalizeEdit()
|
||||||
|
│ 如果 editingSource !== block.source:
|
||||||
|
│ before = md.slice(0, block.startOffset)
|
||||||
|
│ after = md.slice(block.endOffset)
|
||||||
|
│ emit('update:modelValue', before + newSource + after)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
父组件 update:modelValue → modelValue 变化
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
watch(modelValue) → parseBlocks(newMd) → 重新渲染所有 block
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 边界情况处理
|
||||||
|
|
||||||
|
| 场景 | 处理方式 |
|
||||||
|
|---|---|
|
||||||
|
| 空文档 | 显示 placeholder 文本,点击后插入换行符触发解析 |
|
||||||
|
| 块被删除所有内容 | 替换为空字符串,该块从 mdast 中消失 |
|
||||||
|
| 块中增加空行 | 该块的 markdown 包含空行 → mdast 解析为多个块 → 下次渲染自动拆分 |
|
||||||
|
| 代码块内编辑 | 三引号 + 语言标记全部显示在 textarea 中,用户自由编辑 |
|
||||||
|
| 列表 | 整个列表(含所有 li)作为一个块,编辑时看到完整列表的 markdown |
|
||||||
|
| 表格 | 整表作为一个块 |
|
||||||
|
| 解析失败 | `catch` 后用 `escapeHtml(source)` 显示原文 |
|
||||||
|
|
||||||
|
## 双栏模式流程
|
||||||
|
|
||||||
|
```
|
||||||
|
modelValue → unified.processSync(md) → HTML → v-html 渲染
|
||||||
|
|
||||||
|
textarea (左) preview (右)
|
||||||
|
│ │
|
||||||
|
├─ input → emit │
|
||||||
|
├─ keydown │
|
||||||
|
│ ├─ Ctrl+S → save │
|
||||||
|
│ ├─ Tab → 2 spaces │
|
||||||
|
│ └─ 括号 → auto-pair │
|
||||||
|
│ │
|
||||||
|
└─ scroll → 同步比例 ──────► scrollTop
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
App.vue (content ref)
|
||||||
|
│
|
||||||
|
├── :model-value="content"
|
||||||
|
├── :display-mode="displayMode" ← 设置面板切换
|
||||||
|
├── @update:model-value="onUpdate" → content = val
|
||||||
|
└── @save-shortcut="doSave"
|
||||||
|
|
||||||
|
DirectorySidebar.vue (设置面板)
|
||||||
|
│
|
||||||
|
├── :display-mode="displayMode"
|
||||||
|
└── @toggle-display-mode="onToggleDisplayMode"
|
||||||
|
→ displayMode 切换 + localStorage 持久化
|
||||||
|
|
||||||
|
MarkdownEditor.vue
|
||||||
|
│
|
||||||
|
├── split 模式: 与 App.vue 双向绑定,实时渲染预览
|
||||||
|
└── wysiwyg 模式: 解析 → 块 → 点击编辑 → 局部替换 → emit
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能考量
|
||||||
|
|
||||||
|
- **unified processor** 在组件 setup 时创建一次,全局复用
|
||||||
|
- **parseBlocks** 每次 modelValue 变化时调用,包含 N 次 `processSync`(N = 块数)
|
||||||
|
- 典型文档(50 块)约需 50-100ms,在可接受范围内
|
||||||
|
- 后续可优化为增量更新(只重新渲染变化的块)
|
||||||
|
- **textarea auto-resize** 通过 `scrollHeight` 动态调整高度,无额外 DOM 操作
|
||||||
|
- **双栏滚动同步** 使用比例计算,避免频繁的绝对位置计算
|
||||||
|
|
||||||
|
## 样式体系
|
||||||
|
|
||||||
|
| 层级 | 设计 |
|
||||||
|
|---|---|
|
||||||
|
| 容器 | `border-[#e0dbcf]` 暖米色边框,`shadow-2xl` 阴影,`bg-white` |
|
||||||
|
| 文字 | `text-[#38342e]` 深棕色,`font-serif` |
|
||||||
|
| 标题 | `text-[#2d2a26]` 更深,`font-weight: 700` |
|
||||||
|
| 链接 | `text-[#bf6a3b]` 暖橙色,`underline` |
|
||||||
|
| 行内代码 | `bg-[#f4f1ea]` 暖灰底,`text-[#bf6a3b]` |
|
||||||
|
| 引用 | 左边框 `4px solid #bf6a3b`,斜体灰色 |
|
||||||
|
| 表格 | 斑马条纹 `bg-[#faf9f6]`,表头 `bg-[#f4f1ea]` |
|
||||||
|
| 块 hover | `bg-[#faf9f6]` 微暖灰,提示可点击 |
|
||||||
|
| 编辑 textarea | `bg-[#faf9f6]`,`border-[#bf6a3b]`,`font-mono` |
|
||||||
|
| 缩放 | `calc(1rem * var(--editor-zoom, 1))` CSS 变量,由 App.vue 的 Ctrl+滚轮 控制 |
|
||||||
12
package.json
12
package.json
@@ -10,16 +10,16 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.4",
|
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
|
||||||
"@codemirror/language": "^6.12.4",
|
|
||||||
"@codemirror/state": "^6.7.0",
|
|
||||||
"@codemirror/view": "^6.43.4",
|
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"codemirror": "^6.0.2",
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"rehype-stringify": "^10.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.2",
|
||||||
"remixicon": "^4.9.1",
|
"remixicon": "^4.9.1",
|
||||||
|
"unified": "^11.0.0",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
977
pnpm-lock.yaml
generated
977
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": ["nsis"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
25
src/App.vue
25
src/App.vue
@@ -79,6 +79,23 @@ function onToggleFullWidth(val: boolean) {
|
|||||||
try { localStorage.setItem("editor-full-width", String(val)); } catch { /* noop */ }
|
try { localStorage.setItem("editor-full-width", String(val)); } catch { /* noop */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- display mode (split / wysiwyg) ----
|
||||||
|
type DisplayMode = "split" | "wysiwyg";
|
||||||
|
|
||||||
|
function loadDisplayModePref(): DisplayMode {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem("editor-display-mode");
|
||||||
|
if (v === "split" || v === "wysiwyg") return v;
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return "split";
|
||||||
|
}
|
||||||
|
const displayMode = ref<DisplayMode>(loadDisplayModePref());
|
||||||
|
|
||||||
|
function onToggleDisplayMode(val: DisplayMode) {
|
||||||
|
displayMode.value = val;
|
||||||
|
try { localStorage.setItem("editor-display-mode", val); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
applyZoomCssVars();
|
applyZoomCssVars();
|
||||||
window.addEventListener("wheel", onEditorWheel, { passive: false });
|
window.addEventListener("wheel", onEditorWheel, { passive: false });
|
||||||
@@ -121,9 +138,8 @@ function onUpdate(md: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onHeadingClick(heading: { text: string; level: number }) {
|
function onHeadingClick(heading: { text: string; level: number }) {
|
||||||
// Navigate to heading in the CodeMirror editor
|
const selector = `.markdown-preview h${heading.level}`;
|
||||||
const className = `.cm-lp-h${heading.level}`;
|
const headingEls = document.querySelectorAll(selector);
|
||||||
const headingEls = document.querySelectorAll(className);
|
|
||||||
for (const el of headingEls) {
|
for (const el of headingEls) {
|
||||||
if (el.textContent?.trim() === heading.text) {
|
if (el.textContent?.trim() === heading.text) {
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
@@ -267,11 +283,13 @@ listen("menu-event", (event) => {
|
|||||||
:content="content"
|
:content="content"
|
||||||
:current-file-path="currentFilePath"
|
:current-file-path="currentFilePath"
|
||||||
:is-full-width="isFullWidth"
|
:is-full-width="isFullWidth"
|
||||||
|
:display-mode="displayMode"
|
||||||
@heading-click="onHeadingClick"
|
@heading-click="onHeadingClick"
|
||||||
@open-folder="onOpenFolder"
|
@open-folder="onOpenFolder"
|
||||||
@toggle-folder="onToggleFolder"
|
@toggle-folder="onToggleFolder"
|
||||||
@open-file="onOpenFile"
|
@open-file="onOpenFile"
|
||||||
@toggle-full-width="onToggleFullWidth"
|
@toggle-full-width="onToggleFullWidth"
|
||||||
|
@toggle-display-mode="onToggleDisplayMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Right: Editor area -->
|
<!-- Right: Editor area -->
|
||||||
@@ -279,6 +297,7 @@ listen("menu-event", (event) => {
|
|||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
:model-value="content"
|
:model-value="content"
|
||||||
:is-full-width="isFullWidth"
|
:is-full-width="isFullWidth"
|
||||||
|
:display-mode="displayMode"
|
||||||
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
||||||
@update:model-value="onUpdate"
|
@update:model-value="onUpdate"
|
||||||
@save-shortcut="doSave"
|
@save-shortcut="doSave"
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ const props = withDefaults(
|
|||||||
content?: string;
|
content?: string;
|
||||||
currentFilePath?: string | null;
|
currentFilePath?: string | null;
|
||||||
isFullWidth?: boolean;
|
isFullWidth?: boolean;
|
||||||
|
displayMode?: "split" | "wysiwyg";
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
content: "",
|
content: "",
|
||||||
currentFilePath: null,
|
currentFilePath: null,
|
||||||
isFullWidth: false,
|
isFullWidth: false,
|
||||||
|
displayMode: "split",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ const emit = defineEmits<{
|
|||||||
createFile: [];
|
createFile: [];
|
||||||
createFolder: [];
|
createFolder: [];
|
||||||
toggleFullWidth: [value: boolean];
|
toggleFullWidth: [value: boolean];
|
||||||
|
toggleDisplayMode: [value: "split" | "wysiwyg"];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Wrap the workspace root as a top-level tree node
|
// Wrap the workspace root as a top-level tree node
|
||||||
@@ -248,6 +251,33 @@ const showSettings = ref(false);
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 py-6 space-y-5">
|
<div class="px-4 py-6 space-y-5">
|
||||||
|
<!-- Display mode toggle -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1 mr-3">
|
||||||
|
<p class="text-sm text-[#38342e] font-medium">编辑模式</p>
|
||||||
|
<p class="text-xs text-[#8c877d] mt-0.5 leading-relaxed">
|
||||||
|
{{ props.displayMode === "wysiwyg" ? "单栏:所见即所得,预览区直接编辑" : "双栏:左侧编辑源码,右侧预览效果" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="props.displayMode === 'wysiwyg'"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||||
|
props.displayMode === 'wysiwyg' ? 'bg-[#bf6a3b]' : 'bg-[#d4cfc4]',
|
||||||
|
]"
|
||||||
|
@click="emit('toggleDisplayMode', props.displayMode === 'wysiwyg' ? 'split' : 'wysiwyg')"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200 ease-in-out',
|
||||||
|
props.displayMode === 'wysiwyg' ? 'translate-x-4' : 'translate-x-0',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Full-width mode toggle -->
|
<!-- Full-width mode toggle -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex-1 mr-3">
|
<div class="flex-1 mr-3">
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
import { ref, watch, nextTick, computed } from "vue";
|
||||||
import {
|
import { unified } from "unified";
|
||||||
EditorView,
|
import remarkParse from "remark-parse";
|
||||||
placeholder,
|
import remarkGfm from "remark-gfm";
|
||||||
keymap,
|
import remarkRehype from "remark-rehype";
|
||||||
} from "@codemirror/view";
|
import rehypeStringify from "rehype-stringify";
|
||||||
import { EditorState, Annotation, Transaction } from "@codemirror/state";
|
import rehypeSanitize from "rehype-sanitize";
|
||||||
import { markdown } from "@codemirror/lang-markdown";
|
|
||||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
|
||||||
import { livePreview } from "../editor/livePreview";
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue?: string;
|
modelValue?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
isFullWidth?: boolean;
|
isFullWidth?: boolean;
|
||||||
|
displayMode?: "split" | "wysiwyg";
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: "",
|
modelValue: "",
|
||||||
placeholder: "开始写作...",
|
placeholder: "开始写作...",
|
||||||
isFullWidth: false,
|
isFullWidth: false,
|
||||||
|
displayMode: "split",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -28,190 +27,495 @@ const emit = defineEmits<{
|
|||||||
"save-shortcut": [];
|
"save-shortcut": [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const editorContainer = ref<HTMLDivElement>();
|
const isSingle = computed(() => props.displayMode === "wysiwyg");
|
||||||
const editorView = ref<EditorView>();
|
|
||||||
|
|
||||||
// Annotation to mark sync transactions, so we don't re-emit to parent
|
// ── Unified processors ─────────────────────────────────────────────
|
||||||
const syncAnnotation = Annotation.define<boolean>();
|
|
||||||
|
|
||||||
// ── Editor theme (warm color palette, matching original design) ────
|
/** Full pipeline: markdown → HTML */
|
||||||
|
const md2html = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkRehype, { allowDangerousHtml: false })
|
||||||
|
.use(rehypeSanitize)
|
||||||
|
.use(rehypeStringify);
|
||||||
|
|
||||||
const editorTheme = EditorView.theme(
|
/** Parser only: markdown → mdast (for block extraction) */
|
||||||
{
|
const mdParser = unified().use(remarkParse).use(remarkGfm);
|
||||||
"&": {
|
|
||||||
maxHeight: "100%",
|
|
||||||
fontSize: "calc(1rem * var(--editor-zoom, 1))",
|
|
||||||
backgroundColor: "white",
|
|
||||||
},
|
|
||||||
".cm-scroller": {
|
|
||||||
overflow: "auto",
|
|
||||||
fontFamily:
|
|
||||||
"ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif",
|
|
||||||
lineHeight: "1.625",
|
|
||||||
},
|
|
||||||
".cm-content": {
|
|
||||||
padding: "2rem 2.5rem 10rem",
|
|
||||||
minHeight: "320px",
|
|
||||||
color: "#38342e",
|
|
||||||
caretColor: "#38342e",
|
|
||||||
},
|
|
||||||
".cm-cursor": {
|
|
||||||
borderLeftColor: "#38342e",
|
|
||||||
},
|
|
||||||
".cm-selectionBackground, .cm-content ::selection": {
|
|
||||||
backgroundColor: "#d4cfc4",
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Live Preview: styled content ──
|
// ── Split-pane rendering ───────────────────────────────────────────
|
||||||
".cm-lp-bold": {
|
|
||||||
fontWeight: "700",
|
|
||||||
color: "#2d2a26",
|
|
||||||
},
|
|
||||||
".cm-lp-italic": {
|
|
||||||
fontStyle: "italic",
|
|
||||||
},
|
|
||||||
".cm-lp-strikethrough": {
|
|
||||||
textDecoration: "line-through",
|
|
||||||
color: "#8c877d",
|
|
||||||
},
|
|
||||||
".cm-lp-code": {
|
|
||||||
backgroundColor: "#f4f1ea",
|
|
||||||
color: "#bf6a3b",
|
|
||||||
borderRadius: "0.25rem",
|
|
||||||
padding: "0.125rem 0.375rem",
|
|
||||||
fontSize: "0.875em",
|
|
||||||
fontFamily:
|
|
||||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
|
||||||
},
|
|
||||||
".cm-lp-link": {
|
|
||||||
color: "#bf6a3b",
|
|
||||||
textDecoration: "underline",
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
".cm-lp-heading": {
|
|
||||||
fontWeight: "700",
|
|
||||||
color: "#2d2a26",
|
|
||||||
},
|
|
||||||
".cm-lp-h1": {
|
|
||||||
fontSize: "1.5em",
|
|
||||||
},
|
|
||||||
".cm-lp-h2": {
|
|
||||||
fontSize: "1.25em",
|
|
||||||
},
|
|
||||||
".cm-lp-h3": {
|
|
||||||
fontSize: "1.125em",
|
|
||||||
},
|
|
||||||
".cm-lp-blockquote": {
|
|
||||||
borderLeft: "4px solid #bf6a3b",
|
|
||||||
paddingLeft: "1rem",
|
|
||||||
color: "#8c877d",
|
|
||||||
fontStyle: "italic",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Placeholder
|
const renderedHtml = ref("");
|
||||||
".cm-placeholder": {
|
const renderError = ref(false);
|
||||||
color: "#b8b3a8",
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Remove focus outline ──
|
function renderFullHtml(md: string) {
|
||||||
"&.cm-editor.cm-focused": {
|
try {
|
||||||
outline: "none",
|
const file = md2html.processSync(md);
|
||||||
},
|
renderedHtml.value = String(file.value);
|
||||||
"&.cm-editor .cm-content": {
|
renderError.value = false;
|
||||||
outline: "none",
|
} catch {
|
||||||
},
|
renderedHtml.value = "";
|
||||||
},
|
renderError.value = true;
|
||||||
{ dark: false },
|
}
|
||||||
);
|
|
||||||
|
|
||||||
// ── Extensions ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function createExtensions() {
|
|
||||||
return [
|
|
||||||
markdown(),
|
|
||||||
livePreview(),
|
|
||||||
EditorView.lineWrapping,
|
|
||||||
placeholder(props.placeholder),
|
|
||||||
keymap.of([
|
|
||||||
{
|
|
||||||
key: "Mod-s",
|
|
||||||
run: () => {
|
|
||||||
emit("save-shortcut");
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
preventDefault: true,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
history(),
|
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
|
||||||
EditorView.updateListener.of((update) => {
|
|
||||||
if (!update.docChanged) return;
|
|
||||||
const isSync = update.transactions.some((tr) =>
|
|
||||||
tr.annotation(syncAnnotation),
|
|
||||||
);
|
|
||||||
if (!isSync) {
|
|
||||||
emit("update:modelValue", update.state.doc.toString());
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
editorTheme,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!editorContainer.value) return;
|
|
||||||
|
|
||||||
const state = EditorState.create({
|
|
||||||
doc: props.modelValue,
|
|
||||||
extensions: createExtensions(),
|
|
||||||
});
|
|
||||||
|
|
||||||
editorView.value = new EditorView({
|
|
||||||
state,
|
|
||||||
parent: editorContainer.value,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
editorView.value?.destroy();
|
|
||||||
editorView.value = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync external modelValue changes back into the editor
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(val) => {
|
(val) => {
|
||||||
const view = editorView.value;
|
if (!isSingle.value) renderFullHtml(val);
|
||||||
if (!view) return;
|
|
||||||
if (val !== view.state.doc.toString()) {
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: val },
|
|
||||||
annotations: [
|
|
||||||
syncAnnotation.of(true),
|
|
||||||
Transaction.addToHistory.of(false),
|
|
||||||
],
|
|
||||||
// Try to preserve cursor; if the replacement makes it invalid,
|
|
||||||
// CM6 will clamp it to a valid position.
|
|
||||||
selection: view.state.selection,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(isSingle, (single) => {
|
||||||
|
if (!single) renderFullHtml(props.modelValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Block-level editing (single-pane mode) ─────────────────────────
|
||||||
|
|
||||||
|
interface Block {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
html: string;
|
||||||
|
startOffset: number;
|
||||||
|
endOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = ref<Block[]>([]);
|
||||||
|
const editingBlockId = ref<string | null>(null);
|
||||||
|
const editingSource = ref("");
|
||||||
|
|
||||||
|
/** Parse markdown into editable blocks */
|
||||||
|
function parseBlocks(md: string) {
|
||||||
|
if (!md.trim()) {
|
||||||
|
blocks.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tree = mdParser.parse(md);
|
||||||
|
blocks.value = (tree.children as any[])
|
||||||
|
.filter((c: any) => c.position?.start?.offset != null)
|
||||||
|
.map((c: any, i: number) => {
|
||||||
|
const source = md.slice(
|
||||||
|
c.position.start.offset!,
|
||||||
|
c.position.end.offset!,
|
||||||
|
);
|
||||||
|
let html = "";
|
||||||
|
try {
|
||||||
|
const file = md2html.processSync(source);
|
||||||
|
html = String(file.value);
|
||||||
|
} catch {
|
||||||
|
html = `<p>${escapeHtml(source)}</p>`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: `b${i}`,
|
||||||
|
source,
|
||||||
|
html,
|
||||||
|
startOffset: c.position.start.offset!,
|
||||||
|
endOffset: c.position.end.offset!,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
blocks.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch modelValue → re-parse blocks in single mode
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
if (isSingle.value) parseBlocks(val);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(isSingle, (single) => {
|
||||||
|
if (single) parseBlocks(props.modelValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Block editing actions ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function startEdit(blockId: string) {
|
||||||
|
const block = blocks.value.find((b) => b.id === blockId);
|
||||||
|
if (!block) return;
|
||||||
|
editingBlockId.value = blockId;
|
||||||
|
editingSource.value = block.source;
|
||||||
|
|
||||||
|
void nextTick(() => {
|
||||||
|
const ta = document.querySelector(
|
||||||
|
".block-editor-textarea",
|
||||||
|
) as HTMLTextAreaElement | null;
|
||||||
|
if (ta) {
|
||||||
|
autoResize(ta);
|
||||||
|
ta.focus();
|
||||||
|
// Place cursor at end
|
||||||
|
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeEdit() {
|
||||||
|
const blockId = editingBlockId.value;
|
||||||
|
if (!blockId) return;
|
||||||
|
const block = blocks.value.find((b) => b.id === blockId);
|
||||||
|
editingBlockId.value = null;
|
||||||
|
|
||||||
|
if (!block) return;
|
||||||
|
const newSource = editingSource.value;
|
||||||
|
|
||||||
|
if (newSource === block.source) return; // no change
|
||||||
|
|
||||||
|
// Replace the block's portion in the full markdown
|
||||||
|
const before = props.modelValue.slice(0, block.startOffset);
|
||||||
|
const after = props.modelValue.slice(block.endOffset);
|
||||||
|
emit("update:modelValue", before + newSource + after);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingBlockId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlockKeydown(e: KeyboardEvent) {
|
||||||
|
// Ctrl+S save
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
// Finalize current edit first, then save
|
||||||
|
if (editingBlockId.value) finalizeEdit();
|
||||||
|
emit("save-shortcut");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Escape to cancel
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Auto-resize on any key
|
||||||
|
void nextTick(() => {
|
||||||
|
const ta = e.target as HTMLTextAreaElement;
|
||||||
|
autoResize(ta);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize(ta: HTMLTextAreaElement) {
|
||||||
|
ta.style.height = "auto";
|
||||||
|
ta.style.height = ta.scrollHeight + 2 + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Split-pane textarea handlers ───────────────────────────────────
|
||||||
|
|
||||||
|
const splitTextareaRef = ref<HTMLTextAreaElement>();
|
||||||
|
|
||||||
|
function onSplitInput(e: Event) {
|
||||||
|
const val = (e.target as HTMLTextAreaElement).value;
|
||||||
|
emit("update:modelValue", val);
|
||||||
|
renderFullHtml(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryAutoPair(
|
||||||
|
ta: HTMLTextAreaElement,
|
||||||
|
key: string,
|
||||||
|
): boolean {
|
||||||
|
const pairs: Record<string, string> = {
|
||||||
|
"(": ")",
|
||||||
|
"[": "]",
|
||||||
|
"{": "}",
|
||||||
|
'"': '"',
|
||||||
|
"'": "'",
|
||||||
|
"`": "`",
|
||||||
|
};
|
||||||
|
const close = pairs[key];
|
||||||
|
if (!close) return false;
|
||||||
|
|
||||||
|
const start = ta.selectionStart;
|
||||||
|
const end = ta.selectionEnd;
|
||||||
|
const selected = props.modelValue.substring(start, end);
|
||||||
|
const newVal =
|
||||||
|
props.modelValue.substring(0, start) +
|
||||||
|
key +
|
||||||
|
selected +
|
||||||
|
close +
|
||||||
|
props.modelValue.substring(end);
|
||||||
|
|
||||||
|
emit("update:modelValue", newVal);
|
||||||
|
renderFullHtml(newVal);
|
||||||
|
|
||||||
|
void nextTick(() => {
|
||||||
|
ta.selectionStart = start + 1;
|
||||||
|
ta.selectionEnd = start + 1 + selected.length;
|
||||||
|
ta.focus();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSplitKeydown(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
emit("save-shortcut");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ta = e.target as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
const start = ta.selectionStart;
|
||||||
|
const end = ta.selectionEnd;
|
||||||
|
const newVal =
|
||||||
|
props.modelValue.substring(0, start) +
|
||||||
|
" " +
|
||||||
|
props.modelValue.substring(end);
|
||||||
|
emit("update:modelValue", newVal);
|
||||||
|
renderFullHtml(newVal);
|
||||||
|
void nextTick(() => {
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||||
|
ta.focus();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tryAutoPair(ta, e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scroll sync (split mode) ───────────────────────────────────────
|
||||||
|
|
||||||
|
const previewRef = ref<HTMLDivElement>();
|
||||||
|
|
||||||
|
function onSplitScroll() {
|
||||||
|
if (!splitTextareaRef.value || !previewRef.value) return;
|
||||||
|
const ratio =
|
||||||
|
splitTextareaRef.value.scrollTop /
|
||||||
|
(splitTextareaRef.value.scrollHeight -
|
||||||
|
splitTextareaRef.value.clientHeight || 1);
|
||||||
|
previewRef.value.scrollTop =
|
||||||
|
ratio *
|
||||||
|
(previewRef.value.scrollHeight - previewRef.value.clientHeight);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'flex flex-col flex-1 w-full border border-[#e0dbcf] bg-white shadow-2xl overflow-hidden',
|
'flex flex-1 w-full border border-[#e0dbcf] bg-white shadow-2xl overflow-hidden',
|
||||||
!props.isFullWidth && 'mx-auto',
|
!props.isFullWidth && 'mx-auto',
|
||||||
]"
|
]"
|
||||||
:style="
|
:style="
|
||||||
!props.isFullWidth
|
!props.isFullWidth
|
||||||
? { maxWidth: `calc(800px * var(--editor-zoom, 1))` }
|
? { maxWidth: `calc(1600px * var(--editor-zoom, 1))` }
|
||||||
: undefined
|
: undefined
|
||||||
"
|
"
|
||||||
|
@wheel.passive
|
||||||
>
|
>
|
||||||
<div ref="editorContainer" class="flex-1 min-h-0" />
|
<!-- ════════════════ Single-pane: block editing ════════════════ -->
|
||||||
|
<div
|
||||||
|
v-if="isSingle"
|
||||||
|
class="flex-1 overflow-auto p-8 pb-40"
|
||||||
|
:style="{ fontSize: `calc(1rem * var(--editor-zoom, 1))` }"
|
||||||
|
>
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div
|
||||||
|
v-if="blocks.length === 0"
|
||||||
|
class="text-[#b8b3a8] font-serif leading-relaxed select-none"
|
||||||
|
@click="emit('update:modelValue', '\n'); parseBlocks('\n')"
|
||||||
|
>
|
||||||
|
{{ props.placeholder }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocks -->
|
||||||
|
<template v-for="block in blocks" :key="block.id">
|
||||||
|
<!-- Editing state -->
|
||||||
|
<textarea
|
||||||
|
v-if="editingBlockId === block.id"
|
||||||
|
v-model="editingSource"
|
||||||
|
class="
|
||||||
|
block-editor-textarea
|
||||||
|
w-full resize-none outline-none
|
||||||
|
font-mono text-sm text-[#38342e] leading-relaxed
|
||||||
|
bg-[#faf9f6] border border-[#bf6a3b] rounded-md
|
||||||
|
px-3 py-2 mb-1
|
||||||
|
"
|
||||||
|
rows="1"
|
||||||
|
@keydown="onBlockKeydown"
|
||||||
|
@blur="finalizeEdit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Rendered state (clickable) -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="
|
||||||
|
markdown-preview block-render
|
||||||
|
cursor-text rounded
|
||||||
|
-mx-1 px-1 py-px
|
||||||
|
hover:bg-[#faf9f6] transition-colors
|
||||||
|
"
|
||||||
|
v-html="block.html"
|
||||||
|
@click="startEdit(block.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════════════════ Split-pane mode ════════════════ -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex-1 min-w-0 border-r border-[#e0dbcf] flex flex-col">
|
||||||
|
<textarea
|
||||||
|
ref="splitTextareaRef"
|
||||||
|
:value="props.modelValue"
|
||||||
|
:placeholder="props.placeholder"
|
||||||
|
class="
|
||||||
|
flex-1 w-full resize-none bg-white p-8 pb-40 outline-none
|
||||||
|
font-serif text-[#38342e] leading-relaxed
|
||||||
|
placeholder:text-[#b8b3a8]
|
||||||
|
"
|
||||||
|
:style="{ fontSize: `calc(1rem * var(--editor-zoom, 1))` }"
|
||||||
|
@input="onSplitInput"
|
||||||
|
@keydown="onSplitKeydown"
|
||||||
|
@scroll="onSplitScroll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="previewRef"
|
||||||
|
class="flex-1 min-w-0 overflow-auto p-8 pb-40"
|
||||||
|
:style="{ fontSize: `calc(1rem * var(--editor-zoom, 1))` }"
|
||||||
|
>
|
||||||
|
<div v-if="renderError" class="text-red-500 text-sm">
|
||||||
|
⚠ Markdown 解析错误
|
||||||
|
</div>
|
||||||
|
<div v-else class="markdown-preview" v-html="renderedHtml" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Markdown Preview Styles (warm palette) ─────────────────────── */
|
||||||
|
|
||||||
|
.markdown-preview {
|
||||||
|
font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||||
|
line-height: 1.625;
|
||||||
|
color: #38342e;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview > *:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.markdown-preview h1,
|
||||||
|
.markdown-preview h2,
|
||||||
|
.markdown-preview h3,
|
||||||
|
.markdown-preview h4,
|
||||||
|
.markdown-preview h5,
|
||||||
|
.markdown-preview h6 {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d2a26;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.markdown-preview h1 { font-size: 1.5em; }
|
||||||
|
.markdown-preview h2 { font-size: 1.25em; }
|
||||||
|
.markdown-preview h3 { font-size: 1.125em; }
|
||||||
|
.markdown-preview h4 { font-size: 1em; }
|
||||||
|
|
||||||
|
.markdown-preview p { margin: 0.5em 0; }
|
||||||
|
|
||||||
|
.markdown-preview strong { font-weight: 700; color: #2d2a26; }
|
||||||
|
.markdown-preview em { font-style: italic; }
|
||||||
|
.markdown-preview del { text-decoration: line-through; color: #8c877d; }
|
||||||
|
|
||||||
|
.markdown-preview code {
|
||||||
|
background-color: #f4f1ea;
|
||||||
|
color: #bf6a3b;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview pre {
|
||||||
|
background-color: #f4f1ea;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
}
|
||||||
|
.markdown-preview pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: #38342e;
|
||||||
|
font-size: 0.875em;
|
||||||
|
border-radius: 0;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview a { color: #bf6a3b; text-decoration: underline; }
|
||||||
|
|
||||||
|
.markdown-preview blockquote {
|
||||||
|
border-left: 4px solid #bf6a3b;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
color: #8c877d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.markdown-preview blockquote p { margin: 0.25em 0; }
|
||||||
|
|
||||||
|
.markdown-preview ul,
|
||||||
|
.markdown-preview ol { padding-left: 1.5em; margin: 0.5em 0; }
|
||||||
|
.markdown-preview li { margin: 0.25em 0; }
|
||||||
|
|
||||||
|
.markdown-preview li input[type="checkbox"] {
|
||||||
|
margin-right: 0.375rem;
|
||||||
|
accent-color: #bf6a3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #e0dbcf;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
}
|
||||||
|
.markdown-preview th,
|
||||||
|
.markdown-preview td {
|
||||||
|
border: 1px solid #e0dbcf;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.markdown-preview th {
|
||||||
|
background-color: #f4f1ea;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d2a26;
|
||||||
|
}
|
||||||
|
.markdown-preview tr:nth-child(even) td { background-color: #faf9f6; }
|
||||||
|
|
||||||
|
.markdown-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Block editing textarea ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.block-editor-textarea {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
min-height: 2em;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.block-editor-textarea::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,377 +0,0 @@
|
|||||||
import {
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate,
|
|
||||||
Decoration,
|
|
||||||
DecorationSet,
|
|
||||||
EditorView,
|
|
||||||
} from "@codemirror/view";
|
|
||||||
import type { Range } from "@codemirror/state";
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface HideRange {
|
|
||||||
/** start of whole construct (e.g. first `*` of `**bold**`) */
|
|
||||||
from: number;
|
|
||||||
/** end of whole construct */
|
|
||||||
to: number;
|
|
||||||
/** ranges of syntax chars to hide ([from, to] pairs) */
|
|
||||||
markers: [number, number][];
|
|
||||||
/** optional: range of styled content to apply[0=from, 1=to, 2=css class] */
|
|
||||||
content?: [number, number, string];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CSS classes ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// (no longer needed — Decoration.replace handles hiding directly)
|
|
||||||
|
|
||||||
// ── Decoration builders ────────────────────────────────────────────
|
|
||||||
|
|
||||||
const hiddenMark = Decoration.replace({});
|
|
||||||
|
|
||||||
function contentMark(cls: string) {
|
|
||||||
return Decoration.mark({ class: cls });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Regex patterns ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Block-level: detect headings, blockquotes, list items, hr, fenced code
|
|
||||||
const RE_HEADING = /^(#{1,6})\s+(.*)$/;
|
|
||||||
const RE_BLOCKQUOTE = /^(>\s*)(.*)$/;
|
|
||||||
const RE_UNORDERED = /^(\s*[-*+]\s*)(.*)$/;
|
|
||||||
const RE_ORDERED = /^(\s*\d+\.\s*)(.*)$/;
|
|
||||||
const RE_HR = /^[-*_]{3,}\s*$/;
|
|
||||||
const RE_CODE_FENCE = /^```/;
|
|
||||||
|
|
||||||
// Inline: bold, italic, strikethrough, code, link, image
|
|
||||||
// These run on non-code-block content after stripping block markers
|
|
||||||
|
|
||||||
// We parse inline elements by scanning left-to-right within each line.
|
|
||||||
// Using a combined pattern that matches the FIRST occurrence:
|
|
||||||
const RE_INLINE = /(\*\*|__)(.+?)\1|(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)|~~(.+?)~~|`([^`]+)`|!\[([^\]]*)\]\(([^)]+)\)|\[([^\]]*)\]\(([^)]+)\)/g;
|
|
||||||
|
|
||||||
// ── Parse the document ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
function parseDocument(text: string): HideRange[] {
|
|
||||||
const regions: HideRange[] = [];
|
|
||||||
const lines = text.split("\n");
|
|
||||||
|
|
||||||
let pos = 0;
|
|
||||||
let inCodeBlock = false;
|
|
||||||
let codeBlockStart = -1;
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
const lineStart = pos;
|
|
||||||
|
|
||||||
// ── Fenced code blocks ──
|
|
||||||
if (RE_CODE_FENCE.test(line.trimStart())) {
|
|
||||||
if (!inCodeBlock) {
|
|
||||||
inCodeBlock = true;
|
|
||||||
codeBlockStart = lineStart;
|
|
||||||
} else {
|
|
||||||
// closing fence
|
|
||||||
inCodeBlock = false;
|
|
||||||
// Hide the opening fence
|
|
||||||
const openFenceEnd = text.indexOf("\n", codeBlockStart);
|
|
||||||
const openFence = openFenceEnd === -1 ? text.substring(codeBlockStart) : text.substring(codeBlockStart, openFenceEnd);
|
|
||||||
const openMarkerLen = openFence.length;
|
|
||||||
|
|
||||||
regions.push({
|
|
||||||
from: codeBlockStart,
|
|
||||||
to: codeBlockStart + openMarkerLen + (lineStart - codeBlockStart - openMarkerLen) + line.length,
|
|
||||||
markers: [
|
|
||||||
[codeBlockStart, codeBlockStart + openMarkerLen],
|
|
||||||
[lineStart, lineStart + line.length],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
pos += line.length + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inCodeBlock) {
|
|
||||||
pos += line.length + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Horizontal rule ──
|
|
||||||
if (RE_HR.test(line.trim())) {
|
|
||||||
pos += line.length + 1;
|
|
||||||
continue; // HR is decorative, just show the line
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Headings ──
|
|
||||||
{
|
|
||||||
const m = line.match(RE_HEADING);
|
|
||||||
if (m) {
|
|
||||||
// marker = "#" marks + all whitespace before content
|
|
||||||
const contentStartInMatch = m[0].length - m[2].length;
|
|
||||||
const markerEnd = lineStart + contentStartInMatch;
|
|
||||||
regions.push({
|
|
||||||
from: lineStart,
|
|
||||||
to: lineStart + line.length,
|
|
||||||
markers: [[lineStart, markerEnd]],
|
|
||||||
content: [markerEnd, lineStart + line.length, `cm-lp-heading cm-lp-h${m[1].length}`],
|
|
||||||
});
|
|
||||||
// Parse inline within heading content
|
|
||||||
parseInline(text, markerEnd, lineStart + line.length, regions);
|
|
||||||
pos += line.length + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Blockquote ──
|
|
||||||
{
|
|
||||||
const m = line.match(RE_BLOCKQUOTE);
|
|
||||||
if (m) {
|
|
||||||
const markerEnd = lineStart + m[1].length;
|
|
||||||
regions.push({
|
|
||||||
from: lineStart,
|
|
||||||
to: lineStart + line.length,
|
|
||||||
markers: [[lineStart, markerEnd]],
|
|
||||||
content: [markerEnd, lineStart + line.length, "cm-lp-blockquote"],
|
|
||||||
});
|
|
||||||
parseInline(text, markerEnd, lineStart + line.length, regions);
|
|
||||||
pos += line.length + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Unordered list ──
|
|
||||||
{
|
|
||||||
const m = line.match(RE_UNORDERED);
|
|
||||||
if (m) {
|
|
||||||
const markerEnd = lineStart + m[1].length;
|
|
||||||
regions.push({
|
|
||||||
from: lineStart,
|
|
||||||
to: lineStart + line.length,
|
|
||||||
markers: [[lineStart, markerEnd]],
|
|
||||||
});
|
|
||||||
parseInline(text, markerEnd, lineStart + line.length, regions);
|
|
||||||
pos += line.length + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Ordered list ──
|
|
||||||
{
|
|
||||||
const m = line.match(RE_ORDERED);
|
|
||||||
if (m) {
|
|
||||||
const markerEnd = lineStart + m[1].length;
|
|
||||||
regions.push({
|
|
||||||
from: lineStart,
|
|
||||||
to: lineStart + line.length,
|
|
||||||
markers: [[lineStart, markerEnd]],
|
|
||||||
});
|
|
||||||
parseInline(text, markerEnd, lineStart + line.length, regions);
|
|
||||||
pos += line.length + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Plain paragraph ──
|
|
||||||
parseInline(text, lineStart, lineStart + line.length, regions);
|
|
||||||
pos += line.length + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return regions;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInline(
|
|
||||||
text: string,
|
|
||||||
lineStart: number,
|
|
||||||
lineEnd: number,
|
|
||||||
regions: HideRange[],
|
|
||||||
): void {
|
|
||||||
const slice = text.slice(lineStart, lineEnd);
|
|
||||||
RE_INLINE.lastIndex = 0;
|
|
||||||
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
while ((match = RE_INLINE.exec(slice)) !== null) {
|
|
||||||
const absFrom = lineStart + match.index;
|
|
||||||
const absTo = lineStart + match.index + match[0].length;
|
|
||||||
|
|
||||||
// Bold: **text** or __text__
|
|
||||||
if (match[1]) {
|
|
||||||
const delimLen = match[1].length; // 2 for ** or __
|
|
||||||
const contentStart = absFrom + delimLen;
|
|
||||||
const contentEnd = absTo - delimLen;
|
|
||||||
regions.push({
|
|
||||||
from: absFrom,
|
|
||||||
to: absTo,
|
|
||||||
markers: [
|
|
||||||
[absFrom, contentStart],
|
|
||||||
[contentEnd, absTo],
|
|
||||||
],
|
|
||||||
content: [contentStart, contentEnd, "cm-lp-bold"],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Italic: *text*
|
|
||||||
if (match[3]) {
|
|
||||||
const contentStart = absFrom + 1;
|
|
||||||
const contentEnd = absTo - 1;
|
|
||||||
regions.push({
|
|
||||||
from: absFrom,
|
|
||||||
to: absTo,
|
|
||||||
markers: [
|
|
||||||
[absFrom, contentStart],
|
|
||||||
[contentEnd, absTo],
|
|
||||||
],
|
|
||||||
content: [contentStart, contentEnd, "cm-lp-italic"],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Italic: _text_
|
|
||||||
if (match[4]) {
|
|
||||||
const contentStart = absFrom + 1;
|
|
||||||
const contentEnd = absTo - 1;
|
|
||||||
regions.push({
|
|
||||||
from: absFrom,
|
|
||||||
to: absTo,
|
|
||||||
markers: [
|
|
||||||
[absFrom, contentStart],
|
|
||||||
[contentEnd, absTo],
|
|
||||||
],
|
|
||||||
content: [contentStart, contentEnd, "cm-lp-italic"],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strikethrough: ~~text~~
|
|
||||||
if (match[5]) {
|
|
||||||
const contentStart = absFrom + 2;
|
|
||||||
const contentEnd = absTo - 2;
|
|
||||||
regions.push({
|
|
||||||
from: absFrom,
|
|
||||||
to: absTo,
|
|
||||||
markers: [
|
|
||||||
[absFrom, contentStart],
|
|
||||||
[contentEnd, absTo],
|
|
||||||
],
|
|
||||||
content: [contentStart, contentEnd, "cm-lp-strikethrough"],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline code: `code`
|
|
||||||
if (match[6] !== undefined) {
|
|
||||||
const contentStart = absFrom + 1;
|
|
||||||
const contentEnd = absTo - 1;
|
|
||||||
regions.push({
|
|
||||||
from: absFrom,
|
|
||||||
to: absTo,
|
|
||||||
markers: [
|
|
||||||
[absFrom, contentStart],
|
|
||||||
[contentEnd, absTo],
|
|
||||||
],
|
|
||||||
content: [contentStart, contentEnd, "cm-lp-code"],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image: 
|
|
||||||
if (match[7] !== undefined && match[8] !== undefined) {
|
|
||||||
const altText = match[7];
|
|
||||||
const altStart = absFrom + 2; // after ![
|
|
||||||
const altEnd = altStart + altText.length;
|
|
||||||
const urlPart = match[8];
|
|
||||||
const urlInText = match[0].indexOf("](") + 2;
|
|
||||||
const urlStart = absFrom + urlInText;
|
|
||||||
const urlEnd = urlStart + urlPart.length;
|
|
||||||
regions.push({
|
|
||||||
from: absFrom,
|
|
||||||
to: absTo,
|
|
||||||
markers: [
|
|
||||||
[absFrom, absFrom + 2], // ![
|
|
||||||
[altEnd, altEnd + 2], // ](
|
|
||||||
[urlEnd, absTo], // )
|
|
||||||
],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link: [text](url)
|
|
||||||
if (match[9] !== undefined && match[10] !== undefined) {
|
|
||||||
const linkText = match[9];
|
|
||||||
const textStart = absFrom + 1; // after [
|
|
||||||
const textEnd = textStart + linkText.length;
|
|
||||||
const urlPart = match[10];
|
|
||||||
const urlInText = match[0].indexOf("](") + 2;
|
|
||||||
const urlStart = absFrom + urlInText;
|
|
||||||
const urlEnd = urlStart + urlPart.length;
|
|
||||||
regions.push({
|
|
||||||
from: absFrom,
|
|
||||||
to: absTo,
|
|
||||||
markers: [
|
|
||||||
[absFrom, textStart], // [
|
|
||||||
[textEnd, urlStart], // ](
|
|
||||||
[urlEnd, absTo], // )
|
|
||||||
],
|
|
||||||
content: [textStart, textEnd, "cm-lp-link"],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Build decoration set ───────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildDecorationSet(regions: HideRange[], selFrom: number, selTo: number): DecorationSet {
|
|
||||||
const decos: Range<Decoration>[] = [];
|
|
||||||
|
|
||||||
for (const region of regions) {
|
|
||||||
// If the selection overlaps with this region, reveal it (show raw markdown)
|
|
||||||
if (selFrom < region.to && selTo > region.from) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide all marker ranges
|
|
||||||
for (const [mFrom, mTo] of region.markers) {
|
|
||||||
if (mFrom < mTo) {
|
|
||||||
decos.push(hiddenMark.range(mFrom, mTo));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply content styling
|
|
||||||
if (region.content) {
|
|
||||||
const [cFrom, cTo, cssClass] = region.content;
|
|
||||||
if (cFrom < cTo) {
|
|
||||||
decos.push(contentMark(cssClass).range(cFrom, cTo));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Decoration.set(decos, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ViewPlugin ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class LivePreviewPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.decorations = this.compute(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.docChanged || update.selectionSet) {
|
|
||||||
this.decorations = this.compute(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private compute(view: EditorView): DecorationSet {
|
|
||||||
const text = view.state.doc.toString();
|
|
||||||
const sel = view.state.selection.main;
|
|
||||||
const regions = parseDocument(text);
|
|
||||||
return buildDecorationSet(regions, sel.from, sel.to);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function livePreview() {
|
|
||||||
return ViewPlugin.fromClass(LivePreviewPlugin, {
|
|
||||||
decorations: (v) => v.decorations,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user