功能修复
This commit is contained in:
79
src/App.vue
79
src/App.vue
@@ -9,6 +9,8 @@ const content = ref("");
|
|||||||
const wordCount = ref(0);
|
const wordCount = ref(0);
|
||||||
const charCount = ref(0);
|
const charCount = ref(0);
|
||||||
|
|
||||||
|
type DocumentMode = "preview" | "edit" | "mixed";
|
||||||
|
|
||||||
// File state
|
// File state
|
||||||
const currentFilePath = ref<string | null>(null);
|
const currentFilePath = ref<string | null>(null);
|
||||||
const saveStatus = ref("");
|
const saveStatus = ref("");
|
||||||
@@ -75,7 +77,7 @@ function onEditorWheel(e: WheelEvent) {
|
|||||||
|
|
||||||
// ---- toolbar state ----
|
// ---- toolbar state ----
|
||||||
const showThumbnail = ref(true);
|
const showThumbnail = ref(true);
|
||||||
const previewOnly = ref(false);
|
const documentMode = ref<DocumentMode>("edit");
|
||||||
const showMoreMenu = ref(false);
|
const showMoreMenu = ref(false);
|
||||||
|
|
||||||
function handleExportPdf() {
|
function handleExportPdf() {
|
||||||
@@ -319,17 +321,33 @@ listen("menu-event", (event) => {
|
|||||||
|
|
||||||
<!-- Right: Actions -->
|
<!-- Right: Actions -->
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex items-center gap-0.5">
|
||||||
<!-- Preview/Edit toggle -->
|
<!-- Document mode -->
|
||||||
|
<div class="mode-toggle" role="group" aria-label="文档模式">
|
||||||
<button
|
<button
|
||||||
class="toolbar-btn"
|
class="mode-toggle-btn"
|
||||||
:class="{ 'toolbar-btn-active': !previewOnly }"
|
:class="{ 'mode-toggle-btn-active': documentMode === 'preview' }"
|
||||||
:title="previewOnly ? '切换到编辑模式' : '切换到预览模式'"
|
title="预览模式"
|
||||||
@click="previewOnly = !previewOnly"
|
@click="documentMode = 'preview'"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
预览
|
||||||
<path d="M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="mode-toggle-btn"
|
||||||
|
:class="{ 'mode-toggle-btn-active': documentMode === 'edit' }"
|
||||||
|
title="编辑模式"
|
||||||
|
@click="documentMode = 'edit'"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mode-toggle-btn"
|
||||||
|
:class="{ 'mode-toggle-btn-active': documentMode === 'mixed' }"
|
||||||
|
title="混合模式"
|
||||||
|
@click="documentMode = 'mixed'"
|
||||||
|
>
|
||||||
|
混合
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- More menu -->
|
<!-- More menu -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -363,7 +381,7 @@ listen("menu-event", (event) => {
|
|||||||
</div>
|
</div>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
:model-value="content"
|
:model-value="content"
|
||||||
:preview-only="previewOnly"
|
:document-mode="documentMode"
|
||||||
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
||||||
@update:model-value="onUpdate"
|
@update:model-value="onUpdate"
|
||||||
@save-shortcut="doSave"
|
@save-shortcut="doSave"
|
||||||
@@ -443,6 +461,47 @@ listen("menu-event", (event) => {
|
|||||||
background-color: #f4f1ea;
|
background-color: #f4f1ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 30px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid #e0dbcf;
|
||||||
|
border-radius: 7px;
|
||||||
|
background-color: #f4f1ea;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 42px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: #8c877d;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s,
|
||||||
|
color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle-btn:hover {
|
||||||
|
color: #38342e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle-btn-active {
|
||||||
|
background-color: #fffdfa;
|
||||||
|
color: #bf6a3b;
|
||||||
|
box-shadow: 0 1px 3px rgba(56, 52, 46, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from "vue";
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
type ComponentPublicInstance,
|
||||||
|
} from "vue";
|
||||||
import { unified } from "unified";
|
import { unified } from "unified";
|
||||||
import remarkParse from "remark-parse";
|
import remarkParse from "remark-parse";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@@ -7,15 +13,34 @@ import remarkRehype from "remark-rehype";
|
|||||||
import rehypeStringify from "rehype-stringify";
|
import rehypeStringify from "rehype-stringify";
|
||||||
import rehypeSanitize from "rehype-sanitize";
|
import rehypeSanitize from "rehype-sanitize";
|
||||||
|
|
||||||
|
type DocumentMode = "preview" | "edit" | "mixed";
|
||||||
|
|
||||||
|
interface MarkdownAstNode {
|
||||||
|
position?: {
|
||||||
|
start?: { offset?: number };
|
||||||
|
end?: { offset?: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownBlock {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
html: string;
|
||||||
|
startOffset: number;
|
||||||
|
endOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue?: string;
|
modelValue?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
documentMode?: DocumentMode;
|
||||||
previewOnly?: boolean;
|
previewOnly?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: "",
|
modelValue: "",
|
||||||
placeholder: "开始写作...",
|
placeholder: "开始写作...",
|
||||||
|
documentMode: undefined,
|
||||||
previewOnly: false,
|
previewOnly: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -35,10 +60,33 @@ const md2html = unified()
|
|||||||
.use(rehypeSanitize)
|
.use(rehypeSanitize)
|
||||||
.use(rehypeStringify);
|
.use(rehypeStringify);
|
||||||
|
|
||||||
|
const mdParser = unified().use(remarkParse).use(remarkGfm);
|
||||||
|
|
||||||
|
const activeDocumentMode = computed<DocumentMode>(() => {
|
||||||
|
return props.documentMode ?? (props.previewOnly ? "preview" : "edit");
|
||||||
|
});
|
||||||
|
|
||||||
// ── Rendering ──────────────────────────────────────────────────────
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const renderedHtml = ref("");
|
const renderedHtml = ref("");
|
||||||
const renderError = ref(false);
|
const renderError = ref(false);
|
||||||
|
const markdownBlocks = ref<MarkdownBlock[]>([]);
|
||||||
|
|
||||||
|
const editorTextareaRef = ref<HTMLTextAreaElement>();
|
||||||
|
const scrollRef = ref<HTMLDivElement>();
|
||||||
|
const blockTextareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
const EMPTY_BLOCK_ID = "__empty__";
|
||||||
|
const editingBlockId = ref<string | null>(null);
|
||||||
|
const editingSource = ref("");
|
||||||
|
const editingBaseValue = ref("");
|
||||||
|
const editingRange = ref<{ startOffset: number; endOffset: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const pendingLocalModelUpdate = ref(false);
|
||||||
|
const isEditingEmptyDocument = computed(
|
||||||
|
() => editingBlockId.value === EMPTY_BLOCK_ID,
|
||||||
|
);
|
||||||
|
|
||||||
function renderFullHtml(md: string) {
|
function renderFullHtml(md: string) {
|
||||||
try {
|
try {
|
||||||
@@ -51,13 +99,86 @@ function renderFullHtml(md: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBlockHtml(source: string) {
|
||||||
|
try {
|
||||||
|
const file = md2html.processSync(source);
|
||||||
|
return String(file.value);
|
||||||
|
} catch {
|
||||||
|
return `<pre><code>${escapeHtml(source)}</code></pre>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlocks(md: string) {
|
||||||
|
if (!md.trim()) {
|
||||||
|
markdownBlocks.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tree = mdParser.parse(md) as { children?: MarkdownAstNode[] };
|
||||||
|
const blocks = (tree.children ?? [])
|
||||||
|
.map((node, index): MarkdownBlock | null => {
|
||||||
|
const startOffset = node.position?.start?.offset;
|
||||||
|
const endOffset = node.position?.end?.offset;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof startOffset !== "number" ||
|
||||||
|
typeof endOffset !== "number" ||
|
||||||
|
endOffset < startOffset
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = md.slice(startOffset, endOffset);
|
||||||
|
return {
|
||||||
|
id: `${index}-${startOffset}-${endOffset}`,
|
||||||
|
source,
|
||||||
|
html: renderBlockHtml(source),
|
||||||
|
startOffset,
|
||||||
|
endOffset,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((block): block is MarkdownBlock => block !== null);
|
||||||
|
|
||||||
|
markdownBlocks.value = blocks;
|
||||||
|
} catch {
|
||||||
|
markdownBlocks.value = [
|
||||||
|
{
|
||||||
|
id: "fallback-0",
|
||||||
|
source: md,
|
||||||
|
html: renderBlockHtml(md),
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: md.length,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(val) => {
|
(val) => {
|
||||||
|
const shouldResetScroll = !pendingLocalModelUpdate.value;
|
||||||
|
pendingLocalModelUpdate.value = false;
|
||||||
|
|
||||||
renderFullHtml(val);
|
renderFullHtml(val);
|
||||||
// 切换文件时重置滚动位置到顶部
|
parseBlocks(val);
|
||||||
|
if (editingBlockId.value && val !== editingBaseValue.value) {
|
||||||
|
cancelBlockEdit();
|
||||||
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (scrollRef.value) scrollRef.value.scrollTop = 0;
|
resizeEditorTextarea();
|
||||||
|
// 外部切换文件时重置滚动位置;本组件内编辑时保留当前位置。
|
||||||
|
if (shouldResetScroll && scrollRef.value) scrollRef.value.scrollTop = 0;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -65,18 +186,54 @@ watch(
|
|||||||
|
|
||||||
// ── Editor textarea handlers ───────────────────────────────────────
|
// ── Editor textarea handlers ───────────────────────────────────────
|
||||||
|
|
||||||
const editorTextareaRef = ref<HTMLTextAreaElement>();
|
function setBlockTextareaRef(el: Element | ComponentPublicInstance | null) {
|
||||||
const scrollRef = ref<HTMLDivElement>();
|
blockTextareaRef.value = el instanceof HTMLTextAreaElement ? el : null;
|
||||||
|
|
||||||
function onEditorInput(e: Event) {
|
|
||||||
const val = (e.target as HTMLTextAreaElement).value;
|
|
||||||
emit("update:modelValue", val);
|
|
||||||
renderFullHtml(val);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryAutoPair(
|
function resizeTextarea(ta: HTMLTextAreaElement) {
|
||||||
|
ta.style.height = "auto";
|
||||||
|
ta.style.height = `${ta.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeEditorTextarea() {
|
||||||
|
const ta = editorTextareaRef.value;
|
||||||
|
if (!ta) return;
|
||||||
|
resizeTextarea(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusBlockEditor() {
|
||||||
|
void nextTick(() => {
|
||||||
|
const ta = blockTextareaRef.value;
|
||||||
|
if (!ta) return;
|
||||||
|
resizeTextarea(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.selectionStart = ta.selectionEnd = ta.value.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitModelValue(value: string) {
|
||||||
|
pendingLocalModelUpdate.value = true;
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFullEditorValue(value: string) {
|
||||||
|
emitModelValue(value);
|
||||||
|
renderFullHtml(value);
|
||||||
|
parseBlocks(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditorInput(e: Event) {
|
||||||
|
const ta = e.target as HTMLTextAreaElement;
|
||||||
|
const val = ta.value;
|
||||||
|
applyFullEditorValue(val);
|
||||||
|
resizeTextarea(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryAutoPairForValue(
|
||||||
ta: HTMLTextAreaElement,
|
ta: HTMLTextAreaElement,
|
||||||
key: string,
|
key: string,
|
||||||
|
value: string,
|
||||||
|
onChange: (nextValue: string) => void,
|
||||||
): boolean {
|
): boolean {
|
||||||
const pairs: Record<string, string> = {
|
const pairs: Record<string, string> = {
|
||||||
"(": ")",
|
"(": ")",
|
||||||
@@ -91,16 +248,15 @@ function tryAutoPair(
|
|||||||
|
|
||||||
const start = ta.selectionStart;
|
const start = ta.selectionStart;
|
||||||
const end = ta.selectionEnd;
|
const end = ta.selectionEnd;
|
||||||
const selected = props.modelValue.substring(start, end);
|
const selected = value.substring(start, end);
|
||||||
const newVal =
|
const newVal =
|
||||||
props.modelValue.substring(0, start) +
|
value.substring(0, start) +
|
||||||
key +
|
key +
|
||||||
selected +
|
selected +
|
||||||
close +
|
close +
|
||||||
props.modelValue.substring(end);
|
value.substring(end);
|
||||||
|
|
||||||
emit("update:modelValue", newVal);
|
onChange(newVal);
|
||||||
renderFullHtml(newVal);
|
|
||||||
|
|
||||||
void nextTick(() => {
|
void nextTick(() => {
|
||||||
ta.selectionStart = start + 1;
|
ta.selectionStart = start + 1;
|
||||||
@@ -110,6 +266,10 @@ function tryAutoPair(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tryAutoPair(ta: HTMLTextAreaElement, key: string): boolean {
|
||||||
|
return tryAutoPairForValue(ta, key, props.modelValue, applyFullEditorValue);
|
||||||
|
}
|
||||||
|
|
||||||
function onEditorKeydown(e: KeyboardEvent) {
|
function onEditorKeydown(e: KeyboardEvent) {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -127,8 +287,7 @@ function onEditorKeydown(e: KeyboardEvent) {
|
|||||||
props.modelValue.substring(0, start) +
|
props.modelValue.substring(0, start) +
|
||||||
" " +
|
" " +
|
||||||
props.modelValue.substring(end);
|
props.modelValue.substring(end);
|
||||||
emit("update:modelValue", newVal);
|
applyFullEditorValue(newVal);
|
||||||
renderFullHtml(newVal);
|
|
||||||
void nextTick(() => {
|
void nextTick(() => {
|
||||||
ta.selectionStart = ta.selectionEnd = start + 2;
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||||
ta.focus();
|
ta.focus();
|
||||||
@@ -141,6 +300,121 @@ function onEditorKeydown(e: KeyboardEvent) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mixed mode block editing ───────────────────────────────────────
|
||||||
|
|
||||||
|
function startBlockEdit(block: MarkdownBlock) {
|
||||||
|
if (activeDocumentMode.value !== "mixed") return;
|
||||||
|
if (editingBlockId.value && editingBlockId.value !== block.id) {
|
||||||
|
finalizeBlockEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
editingBlockId.value = block.id;
|
||||||
|
editingSource.value = block.source;
|
||||||
|
editingBaseValue.value = props.modelValue;
|
||||||
|
editingRange.value = {
|
||||||
|
startOffset: block.startOffset,
|
||||||
|
endOffset: block.endOffset,
|
||||||
|
};
|
||||||
|
focusBlockEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEmptyBlockEdit() {
|
||||||
|
if (activeDocumentMode.value !== "mixed") return;
|
||||||
|
editingBlockId.value = EMPTY_BLOCK_ID;
|
||||||
|
editingSource.value = props.modelValue;
|
||||||
|
editingBaseValue.value = props.modelValue;
|
||||||
|
editingRange.value = {
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: props.modelValue.length,
|
||||||
|
};
|
||||||
|
focusBlockEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelBlockEdit() {
|
||||||
|
editingBlockId.value = null;
|
||||||
|
editingSource.value = "";
|
||||||
|
editingBaseValue.value = "";
|
||||||
|
editingRange.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeBlockEdit() {
|
||||||
|
const range = editingRange.value;
|
||||||
|
if (!editingBlockId.value || !range) {
|
||||||
|
cancelBlockEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue =
|
||||||
|
props.modelValue.slice(0, range.startOffset) +
|
||||||
|
editingSource.value +
|
||||||
|
props.modelValue.slice(range.endOffset);
|
||||||
|
|
||||||
|
cancelBlockEdit();
|
||||||
|
|
||||||
|
if (nextValue !== props.modelValue) {
|
||||||
|
emitModelValue(nextValue);
|
||||||
|
renderFullHtml(nextValue);
|
||||||
|
parseBlocks(nextValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlockInput(e: Event) {
|
||||||
|
const ta = e.target as HTMLTextAreaElement;
|
||||||
|
editingSource.value = ta.value;
|
||||||
|
resizeTextarea(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlockKeydown(e: KeyboardEvent) {
|
||||||
|
const ta = e.target as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
finalizeBlockEdit();
|
||||||
|
emit("save-shortcut");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelBlockEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
const start = ta.selectionStart;
|
||||||
|
const end = ta.selectionEnd;
|
||||||
|
editingSource.value =
|
||||||
|
editingSource.value.substring(0, start) +
|
||||||
|
" " +
|
||||||
|
editingSource.value.substring(end);
|
||||||
|
void nextTick(() => {
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||||
|
resizeTextarea(ta);
|
||||||
|
ta.focus();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
tryAutoPairForValue(ta, e.key, editingSource.value, (nextValue) => {
|
||||||
|
editingSource.value = nextValue;
|
||||||
|
void nextTick(() => resizeTextarea(ta));
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeDocumentMode, (mode, oldMode) => {
|
||||||
|
if (oldMode === "mixed" && mode !== "mixed" && editingBlockId.value) {
|
||||||
|
finalizeBlockEdit();
|
||||||
|
}
|
||||||
|
if (mode === "edit") {
|
||||||
|
void nextTick(resizeEditorTextarea);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -158,12 +432,13 @@ function onEditorKeydown(e: KeyboardEvent) {
|
|||||||
>
|
>
|
||||||
<!-- Editor mode -->
|
<!-- Editor mode -->
|
||||||
<textarea
|
<textarea
|
||||||
v-if="!previewOnly"
|
v-if="activeDocumentMode === 'edit'"
|
||||||
ref="editorTextareaRef"
|
ref="editorTextareaRef"
|
||||||
:value="props.modelValue"
|
:value="props.modelValue"
|
||||||
:placeholder="props.placeholder"
|
:placeholder="props.placeholder"
|
||||||
class="
|
class="
|
||||||
w-full h-full min-h-[60vh] resize-none bg-transparent outline-none
|
markdown-source-editor
|
||||||
|
w-full min-h-[60vh] resize-none overflow-hidden bg-transparent outline-none
|
||||||
font-serif text-[#38342e] leading-relaxed
|
font-serif text-[#38342e] leading-relaxed
|
||||||
placeholder:text-[#b8b3a8]
|
placeholder:text-[#b8b3a8]
|
||||||
"
|
"
|
||||||
@@ -172,12 +447,68 @@ function onEditorKeydown(e: KeyboardEvent) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Preview mode -->
|
<!-- Preview mode -->
|
||||||
<div v-else class="min-h-[60vh]">
|
<div v-else-if="activeDocumentMode === 'preview'" class="min-h-[60vh]">
|
||||||
<div v-if="renderError" class="text-red-500 text-sm">
|
<div v-if="renderError" class="text-red-500 text-sm">
|
||||||
⚠ Markdown 解析错误
|
⚠ Markdown 解析错误
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="markdown-preview" v-html="renderedHtml" />
|
<div v-else class="markdown-preview" v-html="renderedHtml" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mixed mode -->
|
||||||
|
<div v-else class="min-h-[60vh] markdown-preview markdown-mixed">
|
||||||
|
<div v-if="renderError" class="text-red-500 text-sm">
|
||||||
|
⚠ Markdown 解析错误
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-else-if="isEditingEmptyDocument"
|
||||||
|
:ref="setBlockTextareaRef"
|
||||||
|
:value="editingSource"
|
||||||
|
:placeholder="props.placeholder"
|
||||||
|
class="markdown-block-editor"
|
||||||
|
@input="onBlockInput"
|
||||||
|
@keydown="onBlockKeydown"
|
||||||
|
@blur="finalizeBlockEdit"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else-if="!props.modelValue.trim()"
|
||||||
|
class="markdown-empty-placeholder"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="startEmptyBlockEdit"
|
||||||
|
@keydown.enter.prevent="startEmptyBlockEdit"
|
||||||
|
@keydown.space.prevent="startEmptyBlockEdit"
|
||||||
|
>
|
||||||
|
{{ props.placeholder }}
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="block in markdownBlocks"
|
||||||
|
:key="block.id"
|
||||||
|
class="markdown-block"
|
||||||
|
:class="{ 'markdown-block-editing': editingBlockId === block.id }"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-if="editingBlockId === block.id"
|
||||||
|
:ref="setBlockTextareaRef"
|
||||||
|
:value="editingSource"
|
||||||
|
class="markdown-block-editor"
|
||||||
|
@input="onBlockInput"
|
||||||
|
@keydown="onBlockKeydown"
|
||||||
|
@blur="finalizeBlockEdit"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="markdown-block-preview"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
v-html="block.html"
|
||||||
|
@click="startBlockEdit(block)"
|
||||||
|
@keydown.enter.prevent="startBlockEdit(block)"
|
||||||
|
@keydown.space.prevent="startBlockEdit(block)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -194,6 +525,15 @@ function onEditorKeydown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
.markdown-preview > *:first-child { margin-top: 0; }
|
.markdown-preview > *:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.markdown-source-editor {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-mixed > .markdown-block:first-child .markdown-block-preview > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-preview h1,
|
.markdown-preview h1,
|
||||||
.markdown-preview h2,
|
.markdown-preview h2,
|
||||||
.markdown-preview h3,
|
.markdown-preview h3,
|
||||||
@@ -293,4 +633,68 @@ function onEditorKeydown(e: KeyboardEvent) {
|
|||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mixed mode block editing ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.markdown-block {
|
||||||
|
margin: -1px -0.375rem;
|
||||||
|
padding: 1px 0.375rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-block:hover {
|
||||||
|
background-color: #f4f1ea;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-block-editing,
|
||||||
|
.markdown-block-editing:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-block-preview {
|
||||||
|
min-height: 1.625em;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-block-preview:focus-visible,
|
||||||
|
.markdown-empty-placeholder:focus-visible {
|
||||||
|
outline: 2px solid #d6aa8e;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-block-editor {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 4rem;
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #d6aa8e;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: #fffdfa;
|
||||||
|
color: #38342e;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0.375rem 0;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(191, 106, 59, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-block-editor::placeholder,
|
||||||
|
.markdown-empty-placeholder {
|
||||||
|
color: #b8b3a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-empty-placeholder {
|
||||||
|
min-height: 12rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
cursor: text;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user