切换markdown渲染为unified

This commit is contained in:
2026-07-06 18:08:35 +08:00
parent 8f95ea3d39
commit 8ad8af7e6b
10 changed files with 1501 additions and 775 deletions

View File

@@ -79,6 +79,23 @@ function onToggleFullWidth(val: boolean) {
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(() => {
applyZoomCssVars();
window.addEventListener("wheel", onEditorWheel, { passive: false });
@@ -121,9 +138,8 @@ function onUpdate(md: string) {
}
function onHeadingClick(heading: { text: string; level: number }) {
// Navigate to heading in the CodeMirror editor
const className = `.cm-lp-h${heading.level}`;
const headingEls = document.querySelectorAll(className);
const selector = `.markdown-preview h${heading.level}`;
const headingEls = document.querySelectorAll(selector);
for (const el of headingEls) {
if (el.textContent?.trim() === heading.text) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
@@ -267,11 +283,13 @@ listen("menu-event", (event) => {
:content="content"
:current-file-path="currentFilePath"
:is-full-width="isFullWidth"
:display-mode="displayMode"
@heading-click="onHeadingClick"
@open-folder="onOpenFolder"
@toggle-folder="onToggleFolder"
@open-file="onOpenFile"
@toggle-full-width="onToggleFullWidth"
@toggle-display-mode="onToggleDisplayMode"
/>
<!-- Right: Editor area -->
@@ -279,6 +297,7 @@ listen("menu-event", (event) => {
<MarkdownEditor
:model-value="content"
:is-full-width="isFullWidth"
:display-mode="displayMode"
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
@update:model-value="onUpdate"
@save-shortcut="doSave"

View File

@@ -16,11 +16,13 @@ const props = withDefaults(
content?: string;
currentFilePath?: string | null;
isFullWidth?: boolean;
displayMode?: "split" | "wysiwyg";
}>(),
{
content: "",
currentFilePath: null,
isFullWidth: false,
displayMode: "split",
},
);
@@ -32,6 +34,7 @@ const emit = defineEmits<{
createFile: [];
createFolder: [];
toggleFullWidth: [value: boolean];
toggleDisplayMode: [value: "split" | "wysiwyg"];
}>();
// Wrap the workspace root as a top-level tree node
@@ -248,6 +251,33 @@ const showSettings = ref(false);
</button>
</div>
<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 -->
<div class="flex items-center justify-between">
<div class="flex-1 mr-3">

View File

@@ -1,25 +1,24 @@
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
import {
EditorView,
placeholder,
keymap,
} from "@codemirror/view";
import { EditorState, Annotation, Transaction } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { livePreview } from "../editor/livePreview";
import { ref, watch, nextTick, computed } from "vue";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeSanitize from "rehype-sanitize";
const props = withDefaults(
defineProps<{
modelValue?: string;
placeholder?: string;
isFullWidth?: boolean;
displayMode?: "split" | "wysiwyg";
}>(),
{
modelValue: "",
placeholder: "开始写作...",
isFullWidth: false,
displayMode: "split",
},
);
@@ -28,190 +27,495 @@ const emit = defineEmits<{
"save-shortcut": [];
}>();
const editorContainer = ref<HTMLDivElement>();
const editorView = ref<EditorView>();
const isSingle = computed(() => props.displayMode === "wysiwyg");
// Annotation to mark sync transactions, so we don't re-emit to parent
const syncAnnotation = Annotation.define<boolean>();
// ── Unified processors ─────────────────────────────────────────────
// ── 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(
{
"&": {
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",
},
/** Parser only: markdown → mdast (for block extraction) */
const mdParser = unified().use(remarkParse).use(remarkGfm);
// ── Live Preview: styled content ──
".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",
},
// ── Split-pane rendering ───────────────────────────────────────────
// Placeholder
".cm-placeholder": {
color: "#b8b3a8",
},
const renderedHtml = ref("");
const renderError = ref(false);
// ── Remove focus outline ──
"&.cm-editor.cm-focused": {
outline: "none",
},
"&.cm-editor .cm-content": {
outline: "none",
},
},
{ 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,
];
function renderFullHtml(md: string) {
try {
const file = md2html.processSync(md);
renderedHtml.value = String(file.value);
renderError.value = false;
} catch {
renderedHtml.value = "";
renderError.value = true;
}
}
// ── 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(
() => props.modelValue,
(val) => {
const view = editorView.value;
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,
});
}
if (!isSingle.value) renderFullHtml(val);
},
{ 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// 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>
<template>
<div
: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',
]"
:style="
!props.isFullWidth
? { maxWidth: `calc(800px * var(--editor-zoom, 1))` }
? { maxWidth: `calc(1600px * var(--editor-zoom, 1))` }
: 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>
</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>

View File

@@ -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: ![alt](url)
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,
});
}