diff --git a/src/views/editor/components/header/header.vue b/src/views/editor/components/header/header.vue index 54f70558..5d8bb7e9 100644 --- a/src/views/editor/components/header/header.vue +++ b/src/views/editor/components/header/header.vue @@ -8,12 +8,18 @@ import { useFile } from './hook' import Contact from '@/components/contact.vue' import ExportTotal from '@/components/exportTotal.vue' +defineProps<{ + localMarkdownFileName: string + localMarkdownSyncStatus: 'idle' | 'syncing' | 'synced' | 'error' +}>() + const emit = defineEmits([ 'download-dynamic', 'download-native', 'download-md', 'import-md', - 'download-picture' + 'download-picture', + 'sync-local-markdown' ]) const { exportFile, importFile, fileName } = useFile(emit) @@ -35,6 +41,21 @@ const { open, toggle } = useSwitch() + + +
diff --git a/src/views/editor/hook.ts b/src/views/editor/hook.ts index bf0b3b83..1a128f7d 100644 --- a/src/views/editor/hook.ts +++ b/src/views/editor/hook.ts @@ -1,6 +1,8 @@ import { onActivated, onDeactivated, Ref, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useDebounceFn, useThrottleFn } from '@vueuse/core' +import { ElMessageBox } from 'element-plus' +import 'element-plus/es/components/message-box/style/css' import { getLocalStorage } from '@/common/localstorage' import { errorMessage, successMessage, warningMessage } from '@/common/message' @@ -172,6 +174,121 @@ export function useImportMD(resumeType: string) { } } +type LocalMarkdownFileHandle = { + name: string + getFile: () => Promise + createWritable: () => Promise<{ + write: (content: string) => Promise + close: () => Promise + abort?: () => Promise + }> +} + +type FilePickerWindow = Window & { + showOpenFilePicker?: (options: { + multiple: boolean + types: Array<{ description: string; accept: Record }> + }) => Promise +} + +export function useLocalMarkdownSync(resumeType: Ref) { + const editorStore = useEditorStore() + const localMarkdownFileName = ref('') + const localMarkdownSyncStatus = ref<'idle' | 'syncing' | 'synced' | 'error'>('idle') + let fileHandle: LocalMarkdownFileHandle | null = null + let writeQueue = Promise.resolve() + + async function writeFile(handle: LocalMarkdownFileHandle, content: string) { + const writable = await handle.createWritable() + try { + await writable.write(content) + await writable.close() + } catch (error) { + await writable.abort?.() + throw error + } + } + + const writeChanges = useDebounceFn((content: string, currentHandle: LocalMarkdownFileHandle) => { + if (fileHandle !== currentHandle) return + localMarkdownSyncStatus.value = 'syncing' + writeQueue = writeQueue + .then(() => writeFile(currentHandle, content)) + .then(() => { + if (fileHandle === currentHandle) localMarkdownSyncStatus.value = 'synced' + }) + .catch(() => { + if (fileHandle !== currentHandle) return + fileHandle = null + localMarkdownFileName.value = '' + localMarkdownSyncStatus.value = 'error' + errorMessage('本地Markdown同步失败,请重新授权文件') + }) + }, 500) + + watch( + () => editorStore.MDContent, + content => fileHandle && writeChanges(content, fileHandle) + ) + + async function syncLocalMarkdown() { + if (editorStore.writable) return warningMessage('请先切换到Markdown模式') + const picker = (window as FilePickerWindow).showOpenFilePicker + if (!picker) return warningMessage('当前浏览器不支持本地文件同步,请继续使用导入/导出MD') + + try { + const [handle] = await picker.call(window, { + multiple: false, + types: [{ description: 'Markdown', accept: { 'text/markdown': ['.md'] } }] + }) + if (!handle) return + const fileContent = await (await handle.getFile()).text() + let loadFileContent = false + + if (fileContent !== editorStore.MDContent) { + try { + await ElMessageBox.confirm( + `本地文件 ${handle.name} 与编辑器内容不同,请选择要保留的版本。`, + '内容不一致', + { + confirmButtonText: '用编辑器内容覆盖文件', + cancelButtonText: '载入本地文件', + distinguishCancelAndClose: true, + closeOnClickModal: false, + type: 'warning' + } + ) + } catch (action) { + if (action !== 'cancel') return + loadFileContent = true + } + } + + fileHandle = handle + localMarkdownFileName.value = handle.name + if (loadFileContent) { + editorStore.setMDContent(fileContent, resumeType.value) + } else if (fileContent !== editorStore.MDContent) { + await writeFile(handle, editorStore.MDContent) + } + localMarkdownSyncStatus.value = 'synced' + successMessage(`已同步 ${handle.name},后续修改将自动保存`) + } catch (error) { + if ((error as DOMException).name === 'AbortError') return + fileHandle = null + localMarkdownFileName.value = '' + localMarkdownSyncStatus.value = 'error' + errorMessage('无法同步本地Markdown文件') + } + } + + return { + localMarkdownFileName, + localMarkdownSyncStatus, + syncLocalMarkdown + } +} + export function useAvatar(resumeType: string) { const matchAvatarSlot = /!\[个人头像\]\(.*\)/ function setAvatar(path: string) {