功能完善
This commit is contained in:
@@ -35,5 +35,6 @@
|
|||||||
"topic_20260707-091519_dc062b2bfdf4249c": "auto",
|
"topic_20260707-091519_dc062b2bfdf4249c": "auto",
|
||||||
"topic_20260707-092315_b8c3939bfc696802": "auto",
|
"topic_20260707-092315_b8c3939bfc696802": "auto",
|
||||||
"topic_20260707-092711_af706c3fcc7e435e": "auto",
|
"topic_20260707-092711_af706c3fcc7e435e": "auto",
|
||||||
"topic_20260707-095503_bf879b65e4666516": "auto"
|
"topic_20260707-095503_bf879b65e4666516": "auto",
|
||||||
|
"topic_20260708-020616_9ab54d98daf65053": "auto"
|
||||||
}
|
}
|
||||||
@@ -35,5 +35,6 @@
|
|||||||
"topic_20260707-091519_dc062b2bfdf4249c": "关于App.vue里的toolabr…",
|
"topic_20260707-091519_dc062b2bfdf4249c": "关于App.vue里的toolabr…",
|
||||||
"topic_20260707-092315_b8c3939bfc696802": "当我点击目录树切换显示文件的时候,右…",
|
"topic_20260707-092315_b8c3939bfc696802": "当我点击目录树切换显示文件的时候,右…",
|
||||||
"topic_20260707-092711_af706c3fcc7e435e": "帮我检查一下app.vue里的too…",
|
"topic_20260707-092711_af706c3fcc7e435e": "帮我检查一下app.vue里的too…",
|
||||||
"topic_20260707-095503_bf879b65e4666516": "帮我移除markdownEditor…"
|
"topic_20260707-095503_bf879b65e4666516": "帮我移除markdownEditor…",
|
||||||
|
"topic_20260708-020616_9ab54d98daf65053": "新的会话"
|
||||||
}
|
}
|
||||||
17
src/App.vue
17
src/App.vue
@@ -79,6 +79,7 @@ function onEditorWheel(e: WheelEvent) {
|
|||||||
const showThumbnail = ref(true);
|
const showThumbnail = ref(true);
|
||||||
const documentMode = ref<DocumentMode>("edit");
|
const documentMode = ref<DocumentMode>("edit");
|
||||||
const showMoreMenu = ref(false);
|
const showMoreMenu = ref(false);
|
||||||
|
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null);
|
||||||
|
|
||||||
function handleExportPdf() {
|
function handleExportPdf() {
|
||||||
showMoreMenu.value = false;
|
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) {
|
if (!currentFilePath.value) {
|
||||||
// No file path yet — prompt user to pick a save location
|
// No file path yet — prompt user to pick a save location
|
||||||
try {
|
try {
|
||||||
@@ -222,7 +230,7 @@ async function doSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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)}`;
|
saveStatus.value = `已保存: ${getFileName(currentFilePath.value)}`;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
@@ -234,11 +242,13 @@ async function doSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doSaveAs() {
|
async function doSaveAs() {
|
||||||
|
const contentToSave = getLatestEditorContent();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chosen = await invoke<string | null>("pick_save_file");
|
const chosen = await invoke<string | null>("pick_save_file");
|
||||||
if (!chosen) return; // user cancelled
|
if (!chosen) return; // user cancelled
|
||||||
currentFilePath.value = chosen;
|
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)}`;
|
saveStatus.value = `已保存: ${getFileName(currentFilePath.value)}`;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
@@ -380,6 +390,7 @@ listen("menu-event", (event) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
|
ref="markdownEditorRef"
|
||||||
:model-value="content"
|
:model-value="content"
|
||||||
:document-mode="documentMode"
|
:document-mode="documentMode"
|
||||||
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
ref,
|
ref,
|
||||||
watch,
|
watch,
|
||||||
type ComponentPublicInstance,
|
type ComponentPublicInstance,
|
||||||
@@ -47,7 +48,7 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"update:modelValue": [value: string];
|
"update:modelValue": [value: string];
|
||||||
"save-shortcut": [];
|
"save-shortcut": [value?: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// ── Unified processors ─────────────────────────────────────────────
|
// ── Unified processors ─────────────────────────────────────────────
|
||||||
@@ -87,6 +88,18 @@ const pendingLocalModelUpdate = ref(false);
|
|||||||
const isEditingEmptyDocument = computed(
|
const isEditingEmptyDocument = computed(
|
||||||
() => editingBlockId.value === EMPTY_BLOCK_ID,
|
() => 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) {
|
function renderFullHtml(md: string) {
|
||||||
try {
|
try {
|
||||||
@@ -120,6 +133,7 @@ function renderBlockHtml(source: string) {
|
|||||||
function parseBlocks(md: string) {
|
function parseBlocks(md: string) {
|
||||||
if (!md.trim()) {
|
if (!md.trim()) {
|
||||||
markdownBlocks.value = [];
|
markdownBlocks.value = [];
|
||||||
|
renderError.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +164,7 @@ function parseBlocks(md: string) {
|
|||||||
.filter((block): block is MarkdownBlock => block !== null);
|
.filter((block): block is MarkdownBlock => block !== null);
|
||||||
|
|
||||||
markdownBlocks.value = blocks;
|
markdownBlocks.value = blocks;
|
||||||
|
renderError.value = false;
|
||||||
} catch {
|
} catch {
|
||||||
markdownBlocks.value = [
|
markdownBlocks.value = [
|
||||||
{
|
{
|
||||||
@@ -160,6 +175,7 @@ function parseBlocks(md: string) {
|
|||||||
endOffset: md.length,
|
endOffset: md.length,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
renderError.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,16 +183,19 @@ watch(
|
|||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(val) => {
|
(val) => {
|
||||||
const shouldResetScroll = !pendingLocalModelUpdate.value;
|
const shouldResetScroll = !pendingLocalModelUpdate.value;
|
||||||
|
const isLocalUpdate = pendingLocalModelUpdate.value;
|
||||||
pendingLocalModelUpdate.value = false;
|
pendingLocalModelUpdate.value = false;
|
||||||
|
|
||||||
renderFullHtml(val);
|
if (!isLocalUpdate) {
|
||||||
parseBlocks(val);
|
syncRenderedState(val);
|
||||||
|
}
|
||||||
|
|
||||||
if (editingBlockId.value && val !== editingBaseValue.value) {
|
if (editingBlockId.value && val !== editingBaseValue.value) {
|
||||||
cancelBlockEdit();
|
cancelBlockEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
resizeEditorTextarea();
|
scheduleEditorTextareaResize();
|
||||||
// 外部切换文件时重置滚动位置;本组件内编辑时保留当前位置。
|
// 外部切换文件时重置滚动位置;本组件内编辑时保留当前位置。
|
||||||
if (shouldResetScroll && scrollRef.value) scrollRef.value.scrollTop = 0;
|
if (shouldResetScroll && scrollRef.value) scrollRef.value.scrollTop = 0;
|
||||||
});
|
});
|
||||||
@@ -195,10 +214,20 @@ function resizeTextarea(ta: HTMLTextAreaElement) {
|
|||||||
ta.style.height = `${ta.scrollHeight}px`;
|
ta.style.height = `${ta.scrollHeight}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizeEditorTextarea() {
|
function scheduleTextareaResize(ta: HTMLTextAreaElement | undefined | null) {
|
||||||
const ta = editorTextareaRef.value;
|
|
||||||
if (!ta) return;
|
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() {
|
function focusBlockEditor() {
|
||||||
@@ -218,15 +247,13 @@ function emitModelValue(value: string) {
|
|||||||
|
|
||||||
function applyFullEditorValue(value: string) {
|
function applyFullEditorValue(value: string) {
|
||||||
emitModelValue(value);
|
emitModelValue(value);
|
||||||
renderFullHtml(value);
|
|
||||||
parseBlocks(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEditorInput(e: Event) {
|
function onEditorInput(e: Event) {
|
||||||
const ta = e.target as HTMLTextAreaElement;
|
const ta = e.target as HTMLTextAreaElement;
|
||||||
const val = ta.value;
|
const val = ta.value;
|
||||||
applyFullEditorValue(val);
|
applyFullEditorValue(val);
|
||||||
resizeTextarea(ta);
|
scheduleTextareaResize(ta);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryAutoPairForValue(
|
function tryAutoPairForValue(
|
||||||
@@ -270,10 +297,26 @@ function tryAutoPair(ta: HTMLTextAreaElement, key: string): boolean {
|
|||||||
return tryAutoPairForValue(ta, key, props.modelValue, applyFullEditorValue);
|
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) {
|
function onEditorKeydown(e: KeyboardEvent) {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
if (isSaveShortcut(e)) {
|
||||||
e.preventDefault();
|
requestSave(e);
|
||||||
emit("save-shortcut");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,11 +381,11 @@ function cancelBlockEdit() {
|
|||||||
editingRange.value = null;
|
editingRange.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeBlockEdit() {
|
function finalizeBlockEdit(): string | undefined {
|
||||||
const range = editingRange.value;
|
const range = editingRange.value;
|
||||||
if (!editingBlockId.value || !range) {
|
if (!editingBlockId.value || !range) {
|
||||||
cancelBlockEdit();
|
cancelBlockEdit();
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextValue =
|
const nextValue =
|
||||||
@@ -354,24 +397,23 @@ function finalizeBlockEdit() {
|
|||||||
|
|
||||||
if (nextValue !== props.modelValue) {
|
if (nextValue !== props.modelValue) {
|
||||||
emitModelValue(nextValue);
|
emitModelValue(nextValue);
|
||||||
renderFullHtml(nextValue);
|
syncRenderedState(nextValue);
|
||||||
parseBlocks(nextValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nextValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBlockInput(e: Event) {
|
function onBlockInput(e: Event) {
|
||||||
const ta = e.target as HTMLTextAreaElement;
|
const ta = e.target as HTMLTextAreaElement;
|
||||||
editingSource.value = ta.value;
|
editingSource.value = ta.value;
|
||||||
resizeTextarea(ta);
|
scheduleTextareaResize(ta);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBlockKeydown(e: KeyboardEvent) {
|
function onBlockKeydown(e: KeyboardEvent) {
|
||||||
const ta = e.target as HTMLTextAreaElement;
|
const ta = e.target as HTMLTextAreaElement;
|
||||||
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
if (isSaveShortcut(e)) {
|
||||||
e.preventDefault();
|
requestSave(e);
|
||||||
finalizeBlockEdit();
|
|
||||||
emit("save-shortcut");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +433,7 @@ function onBlockKeydown(e: KeyboardEvent) {
|
|||||||
editingSource.value.substring(end);
|
editingSource.value.substring(end);
|
||||||
void nextTick(() => {
|
void nextTick(() => {
|
||||||
ta.selectionStart = ta.selectionEnd = start + 2;
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||||
resizeTextarea(ta);
|
scheduleTextareaResize(ta);
|
||||||
ta.focus();
|
ta.focus();
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -400,21 +442,40 @@ function onBlockKeydown(e: KeyboardEvent) {
|
|||||||
if (
|
if (
|
||||||
tryAutoPairForValue(ta, e.key, editingSource.value, (nextValue) => {
|
tryAutoPairForValue(ta, e.key, editingSource.value, (nextValue) => {
|
||||||
editingSource.value = nextValue;
|
editingSource.value = nextValue;
|
||||||
void nextTick(() => resizeTextarea(ta));
|
void nextTick(() => scheduleTextareaResize(ta));
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flushPendingChanges(): string | undefined {
|
||||||
|
if (!editingBlockId.value) return undefined;
|
||||||
|
return finalizeBlockEdit();
|
||||||
|
}
|
||||||
|
|
||||||
watch(activeDocumentMode, (mode, oldMode) => {
|
watch(activeDocumentMode, (mode, oldMode) => {
|
||||||
|
let committedValue: string | undefined;
|
||||||
if (oldMode === "mixed" && mode !== "mixed" && editingBlockId.value) {
|
if (oldMode === "mixed" && mode !== "mixed" && editingBlockId.value) {
|
||||||
finalizeBlockEdit();
|
committedValue = finalizeBlockEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncRenderedState(committedValue ?? props.modelValue, mode);
|
||||||
|
|
||||||
if (mode === "edit") {
|
if (mode === "edit") {
|
||||||
void nextTick(resizeEditorTextarea);
|
void nextTick(scheduleEditorTextareaResize);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (textareaResizeFrame !== null) {
|
||||||
|
cancelAnimationFrame(textareaResizeFrame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
flushPendingChanges,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -422,6 +483,7 @@ watch(activeDocumentMode, (mode, oldMode) => {
|
|||||||
ref="scrollRef"
|
ref="scrollRef"
|
||||||
class="flex-1 w-full overflow-auto"
|
class="flex-1 w-full overflow-auto"
|
||||||
@wheel.passive
|
@wheel.passive
|
||||||
|
@keydown.capture="onRootKeydown"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx-auto px-8 pb-40 pt-8 h-full"
|
class="mx-auto px-8 pb-40 pt-8 h-full"
|
||||||
|
|||||||
Reference in New Issue
Block a user