切换markdown渲染为cm6
This commit is contained in:
108
src/App.vue
108
src/App.vue
@@ -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 
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
377
src/editor/livePreview.ts
Normal 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: 
|
||||
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