完善预览功能
This commit is contained in:
@@ -5,7 +5,7 @@ This template should help get you started developing with Vue 3 and TypeScript i
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [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"
|
||||
|
||||
hello
|
||||
|
||||
25
src/App.vue
25
src/App.vue
@@ -18,6 +18,7 @@ type SyncProvider = "none" | "webdav" | "s3";
|
||||
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
|
||||
const APP_THEME_KEY = "yurou:app-theme";
|
||||
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 IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
|
||||
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 {
|
||||
provider: SyncProvider;
|
||||
webdav: {
|
||||
@@ -230,6 +246,7 @@ const defaultDocumentMode = ref<DocumentMode>(loadDefaultDocumentMode());
|
||||
const documentMode = ref<DocumentMode>(defaultDocumentMode.value);
|
||||
const appTheme = ref<AppTheme>(loadAppTheme());
|
||||
const renderSingleLineBreaks = ref(loadRenderSingleLineBreaks());
|
||||
const paragraphIndent = ref(loadParagraphIndent());
|
||||
const showMoreMenu = ref(false);
|
||||
const settingsRequest = ref(0);
|
||||
const settingsInitialPage = ref<"about" | "editor" | "sync">("about");
|
||||
@@ -667,6 +684,11 @@ function updateRenderSingleLineBreaks(value: boolean) {
|
||||
saveRenderSingleLineBreaks(value);
|
||||
}
|
||||
|
||||
function updateParagraphIndent(value: boolean) {
|
||||
paragraphIndent.value = value;
|
||||
saveParagraphIndent(value);
|
||||
}
|
||||
|
||||
function resetWorkspaceAndEditorState(options: { stopWatcher?: boolean } = {}) {
|
||||
if (options.stopWatcher !== false) {
|
||||
void stopWorkspaceWatcher();
|
||||
@@ -1269,6 +1291,7 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
||||
:default-document-mode="defaultDocumentMode"
|
||||
:app-theme="appTheme"
|
||||
:render-single-line-breaks="renderSingleLineBreaks"
|
||||
:paragraph-indent="paragraphIndent"
|
||||
:recent-workspaces="recentWorkspaces"
|
||||
@open-folder="onOpenFolder"
|
||||
@switch-workspace="(path: string) => switchWorkspace(path)"
|
||||
@@ -1283,6 +1306,7 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
||||
@update-default-document-mode="updateDefaultDocumentMode"
|
||||
@update-app-theme="updateAppTheme"
|
||||
@update-render-single-line-breaks="updateRenderSingleLineBreaks"
|
||||
@update-paragraph-indent="updateParagraphIndent"
|
||||
@prepare-workspace-sync="prepareWorkspaceSync"
|
||||
@workspace-synced="handleWorkspaceSynced"
|
||||
/>
|
||||
@@ -1431,6 +1455,7 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
||||
:document-mode="documentMode"
|
||||
:file-path="currentFilePath"
|
||||
:render-single-line-breaks="renderSingleLineBreaks"
|
||||
:paragraph-indent="paragraphIndent"
|
||||
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等"
|
||||
@update:model-value="onUpdate"
|
||||
@save-shortcut="doSave"
|
||||
|
||||
@@ -37,6 +37,7 @@ const props = withDefaults(
|
||||
defaultDocumentMode?: DocumentMode;
|
||||
appTheme?: AppTheme;
|
||||
renderSingleLineBreaks?: boolean;
|
||||
paragraphIndent?: boolean;
|
||||
recentWorkspaces?: WorkspaceEntry[];
|
||||
}>(),
|
||||
{
|
||||
@@ -46,6 +47,7 @@ const props = withDefaults(
|
||||
defaultDocumentMode: "edit",
|
||||
appTheme: "warm",
|
||||
renderSingleLineBreaks: false,
|
||||
paragraphIndent: false,
|
||||
recentWorkspaces: () => [],
|
||||
},
|
||||
);
|
||||
@@ -63,6 +65,7 @@ const emit = defineEmits<{
|
||||
updateDefaultDocumentMode: [mode: DocumentMode];
|
||||
updateAppTheme: [theme: AppTheme];
|
||||
updateRenderSingleLineBreaks: [value: boolean];
|
||||
updateParagraphIndent: [value: boolean];
|
||||
prepareWorkspaceSync: [done: (saved: boolean) => void];
|
||||
workspaceSynced: [];
|
||||
"search-result-click": [filePath: string, lineNumber: number];
|
||||
@@ -882,9 +885,11 @@ function setAllFoldersExpanded(expanded: boolean) {
|
||||
:default-document-mode="defaultDocumentMode"
|
||||
:app-theme="appTheme"
|
||||
:render-single-line-breaks="renderSingleLineBreaks"
|
||||
:paragraph-indent="paragraphIndent"
|
||||
@update-default-document-mode="(mode: DocumentMode) => emit('updateDefaultDocumentMode', mode)"
|
||||
@update-app-theme="(theme: AppTheme) => emit('updateAppTheme', theme)"
|
||||
@update-render-single-line-breaks="(value: boolean) => emit('updateRenderSingleLineBreaks', value)"
|
||||
@update-paragraph-indent="(value: boolean) => emit('updateParagraphIndent', value)"
|
||||
@prepare-workspace-sync="forwardPrepareWorkspaceSync"
|
||||
@workspace-synced="emit('workspaceSynced')"
|
||||
/>
|
||||
|
||||
@@ -80,6 +80,7 @@ const emit = defineEmits<{
|
||||
blur: [];
|
||||
escape: [];
|
||||
paste: [payload: PastePayload];
|
||||
"backspace-at-start": [];
|
||||
}>();
|
||||
|
||||
const hostRef = ref<HTMLDivElement>();
|
||||
@@ -346,6 +347,17 @@ function createEditor() {
|
||||
},
|
||||
indentWithTab,
|
||||
...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,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
@@ -449,7 +461,15 @@ function scrollToLine(lineNumber: number) {
|
||||
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);
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ interface MarkdownCodeEditorApi {
|
||||
focusAtEnd: () => void;
|
||||
replaceRange: (text: string, from?: number, to?: number) => void;
|
||||
scrollToLine: (lineNumber: number) => void;
|
||||
setCursorPosition: (pos: number) => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -80,6 +81,7 @@ const props = withDefaults(
|
||||
previewOnly?: boolean;
|
||||
filePath?: string | null;
|
||||
renderSingleLineBreaks?: boolean;
|
||||
paragraphIndent?: boolean;
|
||||
}>(),
|
||||
{
|
||||
modelValue: "",
|
||||
@@ -88,6 +90,7 @@ const props = withDefaults(
|
||||
previewOnly: false,
|
||||
filePath: null,
|
||||
renderSingleLineBreaks: false,
|
||||
paragraphIndent: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -271,6 +274,9 @@ const pendingLocalModelUpdate = ref(false);
|
||||
const isEditingEmptyDocument = computed(
|
||||
() => editingBlockId.value === EMPTY_BLOCK_ID,
|
||||
);
|
||||
const previewParagraphStyle = computed(() => ({
|
||||
"--paragraph-indent": props.paragraphIndent ? "2em" : "0",
|
||||
}));
|
||||
let modeScrollRestoreFrame: number | null = null;
|
||||
let renderRequestId = 0;
|
||||
const previewImageSelector = ".markdown-preview:not(.markdown-mixed) img";
|
||||
@@ -591,6 +597,56 @@ function flushPendingChanges(): string | undefined {
|
||||
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) => {
|
||||
const scrollSnapshot = captureScrollSnapshot();
|
||||
let committedValue: string | undefined;
|
||||
@@ -975,11 +1031,11 @@ defineExpose({
|
||||
<div v-if="renderError" class="text-red-500 text-sm">
|
||||
⚠ Markdown 解析错误
|
||||
</div>
|
||||
<div v-else class="markdown-preview" v-html="renderedHtml" />
|
||||
<div v-else class="markdown-preview" :style="previewParagraphStyle" v-html="renderedHtml" />
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
⚠ Markdown 解析错误
|
||||
</div>
|
||||
@@ -1030,6 +1086,7 @@ defineExpose({
|
||||
@escape="cancelBlockEdit"
|
||||
@blur="finalizeBlockEdit"
|
||||
@paste="onBlockPaste"
|
||||
@backspace-at-start="mergeCurrentBlockWithPrevious"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
@@ -1091,7 +1148,7 @@ defineExpose({
|
||||
.markdown-preview h3 { font-size: 1.125em; }
|
||||
.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-mixed
|
||||
@@ -1099,7 +1156,7 @@ defineExpose({
|
||||
+ .markdown-block-paragraph
|
||||
> .markdown-block-preview
|
||||
> p {
|
||||
margin-top: 1.625em;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-preview strong { font-weight: 700; color: var(--app-text-strong); }
|
||||
@@ -1224,11 +1281,6 @@ defineExpose({
|
||||
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 {
|
||||
display: block;
|
||||
}
|
||||
@@ -1236,7 +1288,7 @@ defineExpose({
|
||||
.markdown-mixed
|
||||
> .markdown-block-paragraph
|
||||
+ .markdown-block-paragraph.markdown-block-editing {
|
||||
margin-top: 1.625em;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-block-editing,
|
||||
|
||||
@@ -78,6 +78,7 @@ const props = withDefaults(
|
||||
defaultDocumentMode?: DocumentMode;
|
||||
appTheme?: AppTheme;
|
||||
renderSingleLineBreaks?: boolean;
|
||||
paragraphIndent?: boolean;
|
||||
}>(),
|
||||
{
|
||||
initialPage: "about",
|
||||
@@ -85,6 +86,7 @@ const props = withDefaults(
|
||||
defaultDocumentMode: "edit",
|
||||
appTheme: "warm",
|
||||
renderSingleLineBreaks: false,
|
||||
paragraphIndent: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -93,6 +95,7 @@ const emit = defineEmits<{
|
||||
updateDefaultDocumentMode: [mode: DocumentMode];
|
||||
updateAppTheme: [theme: AppTheme];
|
||||
updateRenderSingleLineBreaks: [value: boolean];
|
||||
updateParagraphIndent: [value: boolean];
|
||||
prepareWorkspaceSync: [done: (saved: boolean) => void];
|
||||
workspaceSynced: [];
|
||||
}>();
|
||||
@@ -604,6 +607,20 @@ onBeforeUnmount(() => {
|
||||
<span></span>
|
||||
</label>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user