diff --git a/.reasonix/desktop-topic-title-sources.json b/.reasonix/desktop-topic-title-sources.json index 03a2435..421631c 100644 --- a/.reasonix/desktop-topic-title-sources.json +++ b/.reasonix/desktop-topic-title-sources.json @@ -35,5 +35,6 @@ "topic_20260707-091519_dc062b2bfdf4249c": "auto", "topic_20260707-092315_b8c3939bfc696802": "auto", "topic_20260707-092711_af706c3fcc7e435e": "auto", - "topic_20260707-095503_bf879b65e4666516": "auto" + "topic_20260707-095503_bf879b65e4666516": "auto", + "topic_20260708-020616_9ab54d98daf65053": "auto" } \ No newline at end of file diff --git a/.reasonix/desktop-topic-titles.json b/.reasonix/desktop-topic-titles.json index e825128..6526aeb 100644 --- a/.reasonix/desktop-topic-titles.json +++ b/.reasonix/desktop-topic-titles.json @@ -35,5 +35,6 @@ "topic_20260707-091519_dc062b2bfdf4249c": "关于App.vue里的toolabr…", "topic_20260707-092315_b8c3939bfc696802": "当我点击目录树切换显示文件的时候,右…", "topic_20260707-092711_af706c3fcc7e435e": "帮我检查一下app.vue里的too…", - "topic_20260707-095503_bf879b65e4666516": "帮我移除markdownEditor…" + "topic_20260707-095503_bf879b65e4666516": "帮我移除markdownEditor…", + "topic_20260708-020616_9ab54d98daf65053": "新的会话" } \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 6842469..9fea10d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -79,6 +79,7 @@ function onEditorWheel(e: WheelEvent) { const showThumbnail = ref(true); const documentMode = ref("edit"); const showMoreMenu = ref(false); +const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null); function handleExportPdf() { showMoreMenu.value = false; @@ -208,7 +209,14 @@ async function onOpenFile(path: string) { } } -async function doSave() { +function getLatestEditorContent(contentOverride?: string) { + if (typeof contentOverride === "string") return contentOverride; + return markdownEditorRef.value?.flushPendingChanges() ?? content.value; +} + +async function doSave(contentOverride?: string) { + const contentToSave = getLatestEditorContent(contentOverride); + if (!currentFilePath.value) { // No file path yet — prompt user to pick a save location try { @@ -222,7 +230,7 @@ async function doSave() { } try { - await invoke("write_file", {path: currentFilePath.value, content: content.value}); + await invoke("write_file", {path: currentFilePath.value, content: contentToSave}); saveStatus.value = `已保存: ${getFileName(currentFilePath.value)}`; setTimeout(() => { saveStatus.value = ""; @@ -234,11 +242,13 @@ async function doSave() { } async function doSaveAs() { + const contentToSave = getLatestEditorContent(); + try { const chosen = await invoke("pick_save_file"); if (!chosen) return; // user cancelled currentFilePath.value = chosen; - await invoke("write_file", {path: currentFilePath.value, content: content.value}); + await invoke("write_file", {path: currentFilePath.value, content: contentToSave}); saveStatus.value = `已保存: ${getFileName(currentFilePath.value)}`; setTimeout(() => { saveStatus.value = ""; @@ -380,6 +390,7 @@ listen("menu-event", (event) => { (); // ── Unified processors ───────────────────────────────────────────── @@ -87,6 +88,18 @@ const pendingLocalModelUpdate = ref(false); const isEditingEmptyDocument = computed( () => editingBlockId.value === EMPTY_BLOCK_ID, ); +let textareaResizeFrame: number | null = null; + +function syncRenderedState(md: string, mode = activeDocumentMode.value) { + if (mode === "preview") { + renderFullHtml(md); + return; + } + + if (mode === "mixed") { + parseBlocks(md); + } +} function renderFullHtml(md: string) { try { @@ -120,6 +133,7 @@ function renderBlockHtml(source: string) { function parseBlocks(md: string) { if (!md.trim()) { markdownBlocks.value = []; + renderError.value = false; return; } @@ -150,6 +164,7 @@ function parseBlocks(md: string) { .filter((block): block is MarkdownBlock => block !== null); markdownBlocks.value = blocks; + renderError.value = false; } catch { markdownBlocks.value = [ { @@ -160,6 +175,7 @@ function parseBlocks(md: string) { endOffset: md.length, }, ]; + renderError.value = false; } } @@ -167,16 +183,19 @@ watch( () => props.modelValue, (val) => { const shouldResetScroll = !pendingLocalModelUpdate.value; + const isLocalUpdate = pendingLocalModelUpdate.value; pendingLocalModelUpdate.value = false; - renderFullHtml(val); - parseBlocks(val); + if (!isLocalUpdate) { + syncRenderedState(val); + } + if (editingBlockId.value && val !== editingBaseValue.value) { cancelBlockEdit(); } nextTick(() => { - resizeEditorTextarea(); + scheduleEditorTextareaResize(); // 外部切换文件时重置滚动位置;本组件内编辑时保留当前位置。 if (shouldResetScroll && scrollRef.value) scrollRef.value.scrollTop = 0; }); @@ -195,10 +214,20 @@ function resizeTextarea(ta: HTMLTextAreaElement) { ta.style.height = `${ta.scrollHeight}px`; } -function resizeEditorTextarea() { - const ta = editorTextareaRef.value; +function scheduleTextareaResize(ta: HTMLTextAreaElement | undefined | null) { if (!ta) return; - resizeTextarea(ta); + if (textareaResizeFrame !== null) { + cancelAnimationFrame(textareaResizeFrame); + } + + textareaResizeFrame = requestAnimationFrame(() => { + textareaResizeFrame = null; + if (ta.isConnected) resizeTextarea(ta); + }); +} + +function scheduleEditorTextareaResize() { + scheduleTextareaResize(editorTextareaRef.value); } function focusBlockEditor() { @@ -218,15 +247,13 @@ function emitModelValue(value: string) { 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); + scheduleTextareaResize(ta); } function tryAutoPairForValue( @@ -270,10 +297,26 @@ function tryAutoPair(ta: HTMLTextAreaElement, key: string): boolean { return tryAutoPairForValue(ta, key, props.modelValue, applyFullEditorValue); } +function isSaveShortcut(e: KeyboardEvent) { + return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s"; +} + +function requestSave(e?: KeyboardEvent) { + e?.preventDefault(); + e?.stopPropagation(); + const committedValue = flushPendingChanges(); + emit("save-shortcut", committedValue); +} + +function onRootKeydown(e: KeyboardEvent) { + if (isSaveShortcut(e)) { + requestSave(e); + } +} + function onEditorKeydown(e: KeyboardEvent) { - if ((e.ctrlKey || e.metaKey) && e.key === "s") { - e.preventDefault(); - emit("save-shortcut"); + if (isSaveShortcut(e)) { + requestSave(e); return; } @@ -338,11 +381,11 @@ function cancelBlockEdit() { editingRange.value = null; } -function finalizeBlockEdit() { +function finalizeBlockEdit(): string | undefined { const range = editingRange.value; if (!editingBlockId.value || !range) { cancelBlockEdit(); - return; + return undefined; } const nextValue = @@ -354,24 +397,23 @@ function finalizeBlockEdit() { if (nextValue !== props.modelValue) { emitModelValue(nextValue); - renderFullHtml(nextValue); - parseBlocks(nextValue); + syncRenderedState(nextValue); } + + return nextValue; } function onBlockInput(e: Event) { const ta = e.target as HTMLTextAreaElement; editingSource.value = ta.value; - resizeTextarea(ta); + scheduleTextareaResize(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"); + if (isSaveShortcut(e)) { + requestSave(e); return; } @@ -391,7 +433,7 @@ function onBlockKeydown(e: KeyboardEvent) { editingSource.value.substring(end); void nextTick(() => { ta.selectionStart = ta.selectionEnd = start + 2; - resizeTextarea(ta); + scheduleTextareaResize(ta); ta.focus(); }); return; @@ -400,21 +442,40 @@ function onBlockKeydown(e: KeyboardEvent) { if ( tryAutoPairForValue(ta, e.key, editingSource.value, (nextValue) => { editingSource.value = nextValue; - void nextTick(() => resizeTextarea(ta)); + void nextTick(() => scheduleTextareaResize(ta)); }) ) { e.preventDefault(); } } +function flushPendingChanges(): string | undefined { + if (!editingBlockId.value) return undefined; + return finalizeBlockEdit(); +} + watch(activeDocumentMode, (mode, oldMode) => { + let committedValue: string | undefined; if (oldMode === "mixed" && mode !== "mixed" && editingBlockId.value) { - finalizeBlockEdit(); + committedValue = finalizeBlockEdit(); } + + syncRenderedState(committedValue ?? props.modelValue, mode); + if (mode === "edit") { - void nextTick(resizeEditorTextarea); + void nextTick(scheduleEditorTextareaResize); } }); + +onBeforeUnmount(() => { + if (textareaResizeFrame !== null) { + cancelAnimationFrame(textareaResizeFrame); + } +}); + +defineExpose({ + flushPendingChanges, +});