完善预览功能

This commit is contained in:
2026-07-19 23:50:02 +08:00
parent 3c6b142098
commit f725261370
6 changed files with 132 additions and 13 deletions

View File

@@ -6,6 +6,6 @@ This template should help get you started developing with Vue 3 and TypeScript i
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) - [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
$env:HTTPS_PROXY="http://127.0.0.1:10808" $env:HTTPS_PROXY="http://127.0.0.1:10808"
hello

View File

@@ -18,6 +18,7 @@ type SyncProvider = "none" | "webdav" | "s3";
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode"; const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
const APP_THEME_KEY = "yurou:app-theme"; const APP_THEME_KEY = "yurou:app-theme";
const RENDER_SINGLE_LINE_BREAKS_KEY = "yurou:render-single-line-breaks"; const RENDER_SINGLE_LINE_BREAKS_KEY = "yurou:render-single-line-breaks";
const PARAGRAPH_INDENT_KEY = "yurou:paragraph-indent";
const MARKDOWN_EXTENSIONS = new Set(["md"]); const MARKDOWN_EXTENSIONS = new Set(["md"]);
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]); const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]); const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
@@ -124,6 +125,21 @@ function saveRenderSingleLineBreaks(value: boolean) {
} }
} }
function loadParagraphIndent() {
try {
return localStorage.getItem(PARAGRAPH_INDENT_KEY) === "true";
} catch {
return false;
}
}
function saveParagraphIndent(value: boolean) {
try {
localStorage.setItem(PARAGRAPH_INDENT_KEY, String(value));
} catch { /* noop */
}
}
interface SyncConfig { interface SyncConfig {
provider: SyncProvider; provider: SyncProvider;
webdav: { webdav: {
@@ -230,6 +246,7 @@ const defaultDocumentMode = ref<DocumentMode>(loadDefaultDocumentMode());
const documentMode = ref<DocumentMode>(defaultDocumentMode.value); const documentMode = ref<DocumentMode>(defaultDocumentMode.value);
const appTheme = ref<AppTheme>(loadAppTheme()); const appTheme = ref<AppTheme>(loadAppTheme());
const renderSingleLineBreaks = ref(loadRenderSingleLineBreaks()); const renderSingleLineBreaks = ref(loadRenderSingleLineBreaks());
const paragraphIndent = ref(loadParagraphIndent());
const showMoreMenu = ref(false); const showMoreMenu = ref(false);
const settingsRequest = ref(0); const settingsRequest = ref(0);
const settingsInitialPage = ref<"about" | "editor" | "sync">("about"); const settingsInitialPage = ref<"about" | "editor" | "sync">("about");
@@ -667,6 +684,11 @@ function updateRenderSingleLineBreaks(value: boolean) {
saveRenderSingleLineBreaks(value); saveRenderSingleLineBreaks(value);
} }
function updateParagraphIndent(value: boolean) {
paragraphIndent.value = value;
saveParagraphIndent(value);
}
function resetWorkspaceAndEditorState(options: { stopWatcher?: boolean } = {}) { function resetWorkspaceAndEditorState(options: { stopWatcher?: boolean } = {}) {
if (options.stopWatcher !== false) { if (options.stopWatcher !== false) {
void stopWorkspaceWatcher(); void stopWorkspaceWatcher();
@@ -1269,6 +1291,7 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
:default-document-mode="defaultDocumentMode" :default-document-mode="defaultDocumentMode"
:app-theme="appTheme" :app-theme="appTheme"
:render-single-line-breaks="renderSingleLineBreaks" :render-single-line-breaks="renderSingleLineBreaks"
:paragraph-indent="paragraphIndent"
:recent-workspaces="recentWorkspaces" :recent-workspaces="recentWorkspaces"
@open-folder="onOpenFolder" @open-folder="onOpenFolder"
@switch-workspace="(path: string) => switchWorkspace(path)" @switch-workspace="(path: string) => switchWorkspace(path)"
@@ -1283,6 +1306,7 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
@update-default-document-mode="updateDefaultDocumentMode" @update-default-document-mode="updateDefaultDocumentMode"
@update-app-theme="updateAppTheme" @update-app-theme="updateAppTheme"
@update-render-single-line-breaks="updateRenderSingleLineBreaks" @update-render-single-line-breaks="updateRenderSingleLineBreaks"
@update-paragraph-indent="updateParagraphIndent"
@prepare-workspace-sync="prepareWorkspaceSync" @prepare-workspace-sync="prepareWorkspaceSync"
@workspace-synced="handleWorkspaceSynced" @workspace-synced="handleWorkspaceSynced"
/> />
@@ -1431,6 +1455,7 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
:document-mode="documentMode" :document-mode="documentMode"
:file-path="currentFilePath" :file-path="currentFilePath"
:render-single-line-breaks="renderSingleLineBreaks" :render-single-line-breaks="renderSingleLineBreaks"
:paragraph-indent="paragraphIndent"
placeholder="输入 Markdown 内容... 支持标题粗体斜体代码块等" placeholder="输入 Markdown 内容... 支持标题粗体斜体代码块等"
@update:model-value="onUpdate" @update:model-value="onUpdate"
@save-shortcut="doSave" @save-shortcut="doSave"

View File

@@ -37,6 +37,7 @@ const props = withDefaults(
defaultDocumentMode?: DocumentMode; defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme; appTheme?: AppTheme;
renderSingleLineBreaks?: boolean; renderSingleLineBreaks?: boolean;
paragraphIndent?: boolean;
recentWorkspaces?: WorkspaceEntry[]; recentWorkspaces?: WorkspaceEntry[];
}>(), }>(),
{ {
@@ -46,6 +47,7 @@ const props = withDefaults(
defaultDocumentMode: "edit", defaultDocumentMode: "edit",
appTheme: "warm", appTheme: "warm",
renderSingleLineBreaks: false, renderSingleLineBreaks: false,
paragraphIndent: false,
recentWorkspaces: () => [], recentWorkspaces: () => [],
}, },
); );
@@ -63,6 +65,7 @@ const emit = defineEmits<{
updateDefaultDocumentMode: [mode: DocumentMode]; updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme]; updateAppTheme: [theme: AppTheme];
updateRenderSingleLineBreaks: [value: boolean]; updateRenderSingleLineBreaks: [value: boolean];
updateParagraphIndent: [value: boolean];
prepareWorkspaceSync: [done: (saved: boolean) => void]; prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: []; workspaceSynced: [];
"search-result-click": [filePath: string, lineNumber: number]; "search-result-click": [filePath: string, lineNumber: number];
@@ -882,9 +885,11 @@ function setAllFoldersExpanded(expanded: boolean) {
:default-document-mode="defaultDocumentMode" :default-document-mode="defaultDocumentMode"
:app-theme="appTheme" :app-theme="appTheme"
:render-single-line-breaks="renderSingleLineBreaks" :render-single-line-breaks="renderSingleLineBreaks"
:paragraph-indent="paragraphIndent"
@update-default-document-mode="(mode: DocumentMode) => emit('updateDefaultDocumentMode', mode)" @update-default-document-mode="(mode: DocumentMode) => emit('updateDefaultDocumentMode', mode)"
@update-app-theme="(theme: AppTheme) => emit('updateAppTheme', theme)" @update-app-theme="(theme: AppTheme) => emit('updateAppTheme', theme)"
@update-render-single-line-breaks="(value: boolean) => emit('updateRenderSingleLineBreaks', value)" @update-render-single-line-breaks="(value: boolean) => emit('updateRenderSingleLineBreaks', value)"
@update-paragraph-indent="(value: boolean) => emit('updateParagraphIndent', value)"
@prepare-workspace-sync="forwardPrepareWorkspaceSync" @prepare-workspace-sync="forwardPrepareWorkspaceSync"
@workspace-synced="emit('workspaceSynced')" @workspace-synced="emit('workspaceSynced')"
/> />

View File

@@ -80,6 +80,7 @@ const emit = defineEmits<{
blur: []; blur: [];
escape: []; escape: [];
paste: [payload: PastePayload]; paste: [payload: PastePayload];
"backspace-at-start": [];
}>(); }>();
const hostRef = ref<HTMLDivElement>(); const hostRef = ref<HTMLDivElement>();
@@ -346,6 +347,17 @@ function createEditor() {
}, },
indentWithTab, indentWithTab,
...closeBracketsKeymap, ...closeBracketsKeymap,
{
key: "Backspace",
run(view) {
const selection = view.state.selection.main;
if (selection.empty && selection.head === 0) {
emit("backspace-at-start");
return true;
}
return false;
},
},
...defaultKeymap, ...defaultKeymap,
...searchKeymap, ...searchKeymap,
...historyKeymap, ...historyKeymap,
@@ -449,7 +461,15 @@ function scrollToLine(lineNumber: number) {
view.focus(); view.focus();
} }
defineExpose({ focus, focusAtEnd, replaceRange, scrollToLine }); function setCursorPosition(pos: number) {
const view = editorView;
if (!view) return;
const safePos = Math.max(0, Math.min(pos, view.state.doc.length));
view.dispatch({ selection: EditorSelection.cursor(safePos), scrollIntoView: true });
view.focus();
}
defineExpose({ focus, focusAtEnd, replaceRange, scrollToLine, setCursorPosition });
onMounted(createEditor); onMounted(createEditor);

View File

@@ -70,6 +70,7 @@ interface MarkdownCodeEditorApi {
focusAtEnd: () => void; focusAtEnd: () => void;
replaceRange: (text: string, from?: number, to?: number) => void; replaceRange: (text: string, from?: number, to?: number) => void;
scrollToLine: (lineNumber: number) => void; scrollToLine: (lineNumber: number) => void;
setCursorPosition: (pos: number) => void;
} }
const props = withDefaults( const props = withDefaults(
@@ -80,6 +81,7 @@ const props = withDefaults(
previewOnly?: boolean; previewOnly?: boolean;
filePath?: string | null; filePath?: string | null;
renderSingleLineBreaks?: boolean; renderSingleLineBreaks?: boolean;
paragraphIndent?: boolean;
}>(), }>(),
{ {
modelValue: "", modelValue: "",
@@ -88,6 +90,7 @@ const props = withDefaults(
previewOnly: false, previewOnly: false,
filePath: null, filePath: null,
renderSingleLineBreaks: false, renderSingleLineBreaks: false,
paragraphIndent: false,
}, },
); );
@@ -271,6 +274,9 @@ const pendingLocalModelUpdate = ref(false);
const isEditingEmptyDocument = computed( const isEditingEmptyDocument = computed(
() => editingBlockId.value === EMPTY_BLOCK_ID, () => editingBlockId.value === EMPTY_BLOCK_ID,
); );
const previewParagraphStyle = computed(() => ({
"--paragraph-indent": props.paragraphIndent ? "2em" : "0",
}));
let modeScrollRestoreFrame: number | null = null; let modeScrollRestoreFrame: number | null = null;
let renderRequestId = 0; let renderRequestId = 0;
const previewImageSelector = ".markdown-preview:not(.markdown-mixed) img"; const previewImageSelector = ".markdown-preview:not(.markdown-mixed) img";
@@ -591,6 +597,56 @@ function flushPendingChanges(): string | undefined {
return finalizeBlockEdit(); return finalizeBlockEdit();
} }
async function mergeCurrentBlockWithPrevious() {
if (activeDocumentMode.value !== "mixed") return;
const blockId = editingBlockId.value;
if (!blockId) return;
const blockIndex = markdownBlocks.value.findIndex((b) => b.id === blockId);
if (blockIndex <= 0) return;
const previousBlock = markdownBlocks.value[blockIndex - 1];
const currentBlock = markdownBlocks.value[blockIndex];
// 将当前编辑中的源码与前一个块的源码合并
const mergedSource = previousBlock.source + editingSource.value;
// 构建新文档:用合并后的源码替换两个块的范围
const nextValue =
props.modelValue.slice(0, previousBlock.startOffset) +
mergedSource +
props.modelValue.slice(currentBlock.endOffset);
// 记录合并点位置(上一个块的原末尾),用于后续光标定位
const cursorOffset = previousBlock.source.length;
// 取消当前编辑状态
cancelBlockEdit();
// 更新文档
emitModelValue(nextValue);
// 等待重新解析块结构
await parseBlocks(nextValue);
// 找到合并后的新块startOffset 仍为 previousBlock.startOffset
const newBlock = markdownBlocks.value.find(
(b) => b.startOffset === previousBlock.startOffset,
);
if (newBlock) {
// 进入编辑模式,光标定位在合并点
editingBlockId.value = newBlock.id;
editingSource.value = newBlock.source;
editingBaseValue.value = nextValue;
editingRange.value = {
startOffset: newBlock.startOffset,
endOffset: newBlock.endOffset,
};
await nextTick();
blockCodeRef.value?.setCursorPosition(cursorOffset);
}
}
watch(activeDocumentMode, (mode, oldMode) => { watch(activeDocumentMode, (mode, oldMode) => {
const scrollSnapshot = captureScrollSnapshot(); const scrollSnapshot = captureScrollSnapshot();
let committedValue: string | undefined; let committedValue: string | undefined;
@@ -975,11 +1031,11 @@ defineExpose({
<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" :style="previewParagraphStyle" v-html="renderedHtml" />
</div> </div>
<!-- Mixed mode --> <!-- Mixed mode -->
<div v-else class="min-h-[60vh] markdown-preview markdown-mixed"> <div v-else class="min-h-[60vh] markdown-preview markdown-mixed" :style="previewParagraphStyle">
<div v-if="renderError" class="text-red-500 text-sm"> <div v-if="renderError" class="text-red-500 text-sm">
Markdown 解析错误 Markdown 解析错误
</div> </div>
@@ -1030,6 +1086,7 @@ defineExpose({
@escape="cancelBlockEdit" @escape="cancelBlockEdit"
@blur="finalizeBlockEdit" @blur="finalizeBlockEdit"
@paste="onBlockPaste" @paste="onBlockPaste"
@backspace-at-start="mergeCurrentBlockWithPrevious"
/> />
<div <div
v-else v-else
@@ -1091,7 +1148,7 @@ defineExpose({
.markdown-preview h3 { font-size: 1.125em; } .markdown-preview h3 { font-size: 1.125em; }
.markdown-preview h4 { font-size: 1em; } .markdown-preview h4 { font-size: 1em; }
.markdown-preview p { margin: 0; } .markdown-preview p { margin: 0; text-indent: var(--paragraph-indent, 0); }
.markdown-preview:not(.markdown-mixed) > p + p, .markdown-preview:not(.markdown-mixed) > p + p,
.markdown-mixed .markdown-mixed
@@ -1099,7 +1156,7 @@ defineExpose({
+ .markdown-block-paragraph + .markdown-block-paragraph
> .markdown-block-preview > .markdown-block-preview
> p { > p {
margin-top: 1.625em; margin-top: 0;
} }
.markdown-preview strong { font-weight: 700; color: var(--app-text-strong); } .markdown-preview strong { font-weight: 700; color: var(--app-text-strong); }
@@ -1224,11 +1281,6 @@ defineExpose({
box-shadow 0.15s ease; box-shadow 0.15s ease;
} }
.markdown-block:not(.markdown-block-editing):hover > .markdown-block-preview > * {
background-color: var(--app-surface-muted);
cursor: text;
}
.markdown-block-editing { .markdown-block-editing {
display: block; display: block;
} }
@@ -1236,7 +1288,7 @@ defineExpose({
.markdown-mixed .markdown-mixed
> .markdown-block-paragraph > .markdown-block-paragraph
+ .markdown-block-paragraph.markdown-block-editing { + .markdown-block-paragraph.markdown-block-editing {
margin-top: 1.625em; margin-top: 0;
} }
.markdown-block-editing, .markdown-block-editing,

View File

@@ -78,6 +78,7 @@ const props = withDefaults(
defaultDocumentMode?: DocumentMode; defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme; appTheme?: AppTheme;
renderSingleLineBreaks?: boolean; renderSingleLineBreaks?: boolean;
paragraphIndent?: boolean;
}>(), }>(),
{ {
initialPage: "about", initialPage: "about",
@@ -85,6 +86,7 @@ const props = withDefaults(
defaultDocumentMode: "edit", defaultDocumentMode: "edit",
appTheme: "warm", appTheme: "warm",
renderSingleLineBreaks: false, renderSingleLineBreaks: false,
paragraphIndent: false,
}, },
); );
@@ -93,6 +95,7 @@ const emit = defineEmits<{
updateDefaultDocumentMode: [mode: DocumentMode]; updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme]; updateAppTheme: [theme: AppTheme];
updateRenderSingleLineBreaks: [value: boolean]; updateRenderSingleLineBreaks: [value: boolean];
updateParagraphIndent: [value: boolean];
prepareWorkspaceSync: [done: (saved: boolean) => void]; prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: []; workspaceSynced: [];
}>(); }>();
@@ -604,6 +607,20 @@ onBeforeUnmount(() => {
<span></span> <span></span>
</label> </label>
</div> </div>
<div class="settings-row">
<div class="settings-copy">
<h3>段落首行缩进</h3>
<p>开启后预览和混合模式下段落首行会缩进两个字符更符合中文排版习惯</p>
</div>
<label class="settings-switch" title="段落首行缩进">
<input
:checked="paragraphIndent"
type="checkbox"
@change="emit('updateParagraphIndent', ($event.target as HTMLInputElement).checked)"
/>
<span></span>
</label>
</div>
</section> </section>
</div> </div>