切换markdown渲染为cm6

This commit is contained in:
2026-07-03 15:06:53 +08:00
parent a37bb0a894
commit 8f95ea3d39
8 changed files with 917 additions and 688 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from "vue";
import { ref, onMounted, onUnmounted } from "vue";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import MarkdownEditor from "./components/MarkdownEditor.vue";
@@ -23,6 +23,72 @@ interface DirEntry {
const folderPath = ref("");
const dirEntries = ref<DirEntry[]>([]);
// ---- editor zoom (Ctrl+scroll) ----
const ZOOM_MIN = 0.7;
const ZOOM_MAX = 1.5;
const ZOOM_STEP = 0.1;
function loadZoomPref(): number {
try {
const stored = localStorage.getItem("editor-zoom");
if (stored) {
const val = parseFloat(stored);
if (!isNaN(val) && val >= ZOOM_MIN && val <= ZOOM_MAX) return val;
}
} catch { /* localStorage unavailable */ }
return 1.0;
}
const editorZoom = ref(loadZoomPref());
const showZoomIndicator = ref(false);
let zoomIndicatorTimer: ReturnType<typeof setTimeout> | null = null;
function applyZoomCssVars() {
document.documentElement.style.setProperty("--editor-zoom", String(editorZoom.value));
}
function saveZoomPref() {
try { localStorage.setItem("editor-zoom", String(editorZoom.value)); } catch { /* noop */ }
}
function onEditorWheel(e: WheelEvent) {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const delta = e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP;
const next = Math.round((editorZoom.value + delta) * 10) / 10;
editorZoom.value = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, next));
applyZoomCssVars();
saveZoomPref();
// Show zoom indicator and reset timer
showZoomIndicator.value = true;
if (zoomIndicatorTimer) clearTimeout(zoomIndicatorTimer);
zoomIndicatorTimer = setTimeout(() => { showZoomIndicator.value = false; }, 1500);
}
// ---- full-width mode ----
function loadFullWidthPref(): boolean {
try {
return localStorage.getItem("editor-full-width") === "true";
} catch { return false; }
}
const isFullWidth = ref(loadFullWidthPref());
function onToggleFullWidth(val: boolean) {
isFullWidth.value = val;
try { localStorage.setItem("editor-full-width", String(val)); } catch { /* noop */ }
}
onMounted(() => {
applyZoomCssVars();
window.addEventListener("wheel", onEditorWheel, { passive: false });
});
onUnmounted(() => {
window.removeEventListener("wheel", onEditorWheel);
if (zoomIndicatorTimer) clearTimeout(zoomIndicatorTimer);
});
function stripMarkdown(md: string): string {
return md
// Remove images ![alt](url)
@@ -55,13 +121,11 @@ function onUpdate(md: string) {
}
function onHeadingClick(heading: { text: string; level: number }) {
// Navigate to heading in the editor — find the heading element and scroll it into view
const editorEl = document.querySelector(".tiptap-editor .ProseMirror");
if (!editorEl) return;
const headingTags = editorEl.querySelectorAll("h1, h2, h3");
for (const el of headingTags) {
if (el.textContent?.trim() === heading.text && el.tagName === `H${heading.level}`) {
// Navigate to heading in the CodeMirror editor
const className = `.cm-lp-h${heading.level}`;
const headingEls = document.querySelectorAll(className);
for (const el of headingEls) {
if (el.textContent?.trim() === heading.text) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
break;
}
@@ -202,21 +266,34 @@ listen("menu-event", (event) => {
:entries="dirEntries"
:content="content"
:current-file-path="currentFilePath"
:is-full-width="isFullWidth"
@heading-click="onHeadingClick"
@open-folder="onOpenFolder"
@toggle-folder="onToggleFolder"
@open-file="onOpenFile"
@toggle-full-width="onToggleFullWidth"
/>
<!-- Right: Editor area -->
<main class="flex-1 flex flex-col overflow-y-auto">
<main class="flex-1 flex flex-col overflow-hidden relative">
<MarkdownEditor
v-model="content"
:model-value="content"
:is-full-width="isFullWidth"
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
@update:model-value="onUpdate"
@save-shortcut="doSave"
class="flex-1 w-full"
/>
<!-- Zoom indicator -->
<Transition name="zoom-fade">
<div
v-if="showZoomIndicator"
class="fixed top-4 right-4 z-50 px-2.5 py-1 rounded-md bg-[#38342e]/80 text-white text-xs font-medium pointer-events-none select-none backdrop-blur-sm"
>
{{ Math.round(editorZoom * 100) }}%
</div>
</Transition>
</main>
</div>
<footer class="border-t border-[#e8e4da] bg-[#f4f1eb]/60 backdrop-blur-sm">
@@ -243,4 +320,15 @@ listen("menu-event", (event) => {
</div>
</template>
<style>
.zoom-fade-enter-active,
.zoom-fade-leave-active {
transition: opacity 0.2s ease;
}
.zoom-fade-enter-from,
.zoom-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -15,10 +15,12 @@ const props = withDefaults(
entries: DirEntry[];
content?: string;
currentFilePath?: string | null;
isFullWidth?: boolean;
}>(),
{
content: "",
currentFilePath: null,
isFullWidth: false,
},
);
@@ -29,6 +31,7 @@ const emit = defineEmits<{
openFile: [path: string];
createFile: [];
createFolder: [];
toggleFullWidth: [value: boolean];
}>();
// Wrap the workspace root as a top-level tree node
@@ -244,10 +247,31 @@ const showSettings = ref(false);
<i class="ri-close-line text-base"></i>
</button>
</div>
<div class="px-4 py-6">
<p class="text-xs text-[#b8b3a8] text-center leading-relaxed">
设置功能即将上线
</p>
<div class="px-4 py-6 space-y-5">
<!-- Full-width 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">关闭后编辑器居中显示,适合专注书写</p>
</div>
<button
type="button"
role="switch"
:aria-checked="props.isFullWidth"
: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.isFullWidth ? 'bg-[#bf6a3b]' : 'bg-[#d4cfc4]',
]"
@click="emit('toggleFullWidth', !props.isFullWidth)"
>
<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.isFullWidth ? 'translate-x-4' : 'translate-x-0',
]"
/>
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,18 +1,25 @@
<script setup lang="ts">
import { watch } from "vue";
import { useEditor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "@tiptap/markdown";
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";
const props = withDefaults(
defineProps<{
modelValue?: string;
placeholder?: string;
isFullWidth?: boolean;
}>(),
{
modelValue: "",
placeholder: "开始写作...",
isFullWidth: false,
},
);
@@ -21,42 +28,173 @@ const emit = defineEmits<{
"save-shortcut": [];
}>();
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Placeholder.configure({
placeholder: props.placeholder,
}),
Markdown,
],
editorProps: {
attributes: {
class:
"min-h-[320px] px-6 py-4 outline-none focus:outline-none",
const editorContainer = ref<HTMLDivElement>();
const editorView = ref<EditorView>();
// Annotation to mark sync transactions, so we don't re-emit to parent
const syncAnnotation = Annotation.define<boolean>();
// ── Editor theme (warm color palette, matching original design) ────
const editorTheme = EditorView.theme(
{
"&": {
maxHeight: "100%",
fontSize: "calc(1rem * var(--editor-zoom, 1))",
backgroundColor: "white",
},
handleKeyDown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
emit("save-shortcut");
return true;
".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 ──
".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
".cm-placeholder": {
color: "#b8b3a8",
},
// ── 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());
}
return false;
},
},
onUpdate: ({ editor }) => {
emit("update:modelValue", editor.getMarkdown());
},
}),
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(
() => props.modelValue,
(val) => {
if (editor.value && val !== editor.value.getMarkdown()) {
editor.value.commands.setContent(val, { contentType: "markdown", emitUpdate: false });
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,
});
}
},
);
@@ -64,137 +202,16 @@ watch(
<template>
<div
class="flex flex-col flex-1 w-full border-[#e0dbcf] bg-white shadow-2xl overflow-y-auto"
:class="[
'flex flex-col 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))` }
: undefined
"
>
<!-- Editor content -->
<EditorContent :editor="editor" class="tiptap-editor flex-1 flex flex-col" />
<div ref="editorContainer" class="flex-1 min-h-0" />
</div>
</template>
<style>
/* TipTap / ProseMirror content styling */
.tiptap-editor .ProseMirror {
color: #38342e;
min-height: 320px;
flex: 1;
outline: none;
overflow-y: auto;
}
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: #b8b3a8;
float: left;
height: 0;
pointer-events: none;
content: attr(data-placeholder);
}
/* Headings */
.tiptap-editor .ProseMirror h1 {
font-size: 1.5rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: #2d2a26;
}
.tiptap-editor .ProseMirror h2 {
font-size: 1.25rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: #2d2a26;
}
.tiptap-editor .ProseMirror h3 {
font-size: 1.125rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: #2d2a26;
}
/* Blockquote */
.tiptap-editor .ProseMirror blockquote {
border-left: 4px solid #bf6a3b;
padding-left: 1rem;
margin-top: 0.75rem;
margin-bottom: 0.75rem;
color: #8c877d;
font-style: italic;
}
/* Inline code */
.tiptap-editor .ProseMirror code {
background-color: #f4f1ea;
color: #bf6a3b;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Code block */
.tiptap-editor .ProseMirror pre {
background-color: #f4f1ea;
border-radius: 0.5rem;
padding: 1rem;
margin-top: 0.75rem;
margin-bottom: 0.75rem;
overflow-x: auto;
}
.tiptap-editor .ProseMirror pre code {
background-color: transparent;
color: #5c574e;
padding: 0;
}
/* Lists */
.tiptap-editor .ProseMirror ul {
list-style-type: disc;
padding-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.tiptap-editor .ProseMirror ol {
list-style-type: decimal;
padding-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.tiptap-editor .ProseMirror li {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
/* Horizontal rule */
.tiptap-editor .ProseMirror hr {
border-color: #e8e4da;
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
/* Links */
.tiptap-editor .ProseMirror a {
color: #bf6a3b;
text-decoration: underline;
}
/* Bold / Italic / Strike */
.tiptap-editor .ProseMirror strong {
font-weight: 700;
color: #2d2a26;
}
.tiptap-editor .ProseMirror em {
font-style: italic;
}
.tiptap-editor .ProseMirror s {
text-decoration: line-through;
color: #8c877d;
}
/* Paragraph spacing */
.tiptap-editor .ProseMirror p {
margin-top: 0.375rem;
margin-bottom: 0.375rem;
line-height: 1.625;
}
</style>

377
src/editor/livePreview.ts Normal file
View File

@@ -0,0 +1,377 @@
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,
});
}