import type { VaultEvent } from '@@/apis/events/type' import type { Entry, TreeIcon } from '@@/apis/tree/type' import type { PluginAPI } from '@@/plugin-api' import type { SettingsSection } from '@/pinia/stores/settings' import { cleanupImagesApi, importImageApi, replaceImageApi, uploadFolderImageApi, uploadImageApi } from '@@/apis/assets' import { connectEventsApi } from '@@/apis/events' import { createFolderApi, deleteFileApi, getRawFileApi, importNotesApi, moveFileApi, writeFileApi } from '@@/apis/files' import { restoreTrashApi } from '@@/apis/trash' import { getTreeApi, setTreeIconApi } from '@@/apis/tree' import { appearancePaused } from '@@/appearance/appearance' import { locale, t as translate } from '@@/i18n/runtime' // Workspace state and operations: tree, tabs, save queue, conflict handling, and live synchronization. // File operations update the interface optimistically before requesting the server, rolling back on failure for immediate feedback. import { decodeLinkDestination } from '@@/markdown/links' import { pluginEditorFailure, refreshPlugins, startPlugins, stopPlugins } from '@@/plugins/host' import { notePath, pluginEvents, pluginRefresh, pluginsPaused, setPluginEditorHandlers } from '@@/plugins/registry' import { handleShortcut, registerCommand } from '@@/utils/commands' import { readNoteFile } from '@@/utils/note-files' import { toast } from '@@/utils/notifications' import { basename, dirname, encodePath, isImage, joinPath, NOTE_EXT, resolveAssetUrl } from '@@/utils/paths' import { imageResourceRoot, prepareResources } from '@@/utils/resource-tickets' import { typoraKeys } from '@@/utils/typora-keys' import { trackVisualViewport } from '@@/utils/viewport' import { Annotation, EditorState, Transaction } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { CanceledError, isCancel } from 'axios' import { nextTick, reactive, watch } from 'vue' import { ApiError } from '@/http/axios' import { pinia } from '@/pinia' import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings' import { useUserStore } from '@/pinia/stores/user' import { drafts } from './drafts' import { createEditorState, insertImageFiles, languageCompartment, pasteImages, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from './editor' import { refreshImages } from './editor/livepreview' import { editorLocale, localeCompartment } from './editor/locale' import { editorCommands } from './editor/shortcuts' import { refreshMetadata } from './metadata' const { loadSettings, settings, updateSettings } = useSettingsStore(pinia) export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error' export interface Tab { path: string pinned?: boolean // Last revision synchronized with the server. rev: string status: TabStatus // Changes not yet saved successfully. dirty: boolean saveError?: string // Server revision during conflict; an empty string means the file no longer exists there. conflictRev?: string } const RECENT_KEY = 'rawmark.recent' // Local interface state outside settings: sidebar collapse and outline panel. const LAYOUT_KEY = 'rawmark.layout' const layout = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}') as { sidebarCollapsed?: boolean, outlineOpen?: boolean } const RECENT_MAX = 30 export const ws = reactive({ entries: [] as Entry[], tabs: [] as Tab[], active: null as string | null, // Path currently being renamed inline in the tree. renaming: null as string | null, // Mobile sidebar drawer. sidebarOpen: false, // Show search instead of the tree in the sidebar. searchOpen: false, // Initial quick-switcher/command-palette query; null closes it, and leading > lists commands. switcher: null as string | null, // Open settings category; null closes it. index is the general entry, showing categories on phones and the first category on desktop. settings: (pluginsPaused ? 'plugins' : appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null, // "Move to..." dialog paths for notes or folders. moving: null as string | string[] | null, // Import destination folder; an empty string denotes the root. importing: null as string | null, // Image preview and management, independent of note tabs and drafts. image: null as string | null, images: null as string | null, imagesAll: false, // Desktop sidebar collapse state. sidebarCollapsed: layout.sidebarCollapsed ?? false, // Outline panel: persistent on the right on desktop, an overlay on phones. outlineOpen: layout.outlineOpen ?? false, sourceMode: false, focusMode: false, view: 'notes' as 'notes' | 'graph' | 'tags', // Recently opened notes, newest first. recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[], }) const SAVE_DELAY = 500 const RETRY_DELAY = 5000 const MAX_RETRIES = 12 // Keep the following state outside reactive: large immutable EditorState objects gain nothing from Vue proxies. const editorStates = new Map() // Each open note has a mutable reference through which editor extensions read its current path, updated on rename. const noteRefs = new Map() interface SaveSlot { timer?: ReturnType inflight?: Promise // Change and saved sequence numbers; equality means no pending changes. seq: number savedSeq: number uploads: number paused: number retries: number } const slots = new Map() const user = useUserStore(pinia) let workspaceRunning = false let workspaceEpoch = 0 let draftPreserved: Promise = Promise.resolve() let view: EditorView | null = null // In-flight local file operations; tree events during these operations must not determine whether tabs were deleted. let opsInFlight = 0 const externalChange = Annotation.define() // Editor content/cursor subscribers, such as the outline, bypass Vue reactivity to avoid component updates on every keystroke. const editorListeners = new Set<() => void>() export function onEditorUpdate(fn: () => void): () => void { editorListeners.add(fn) return () => editorListeners.delete(fn) } export function tabOf(path: string | null): Tab | undefined { return path === null ? undefined : ws.tabs.find(t => t.path === path) } export function activeTab(): Tab | undefined { return tabOf(ws.active) } function errorText(err: unknown): string { return err instanceof ApiError ? err.message : translate('common.network_error_check_your_connection_566064') } // ---- Editor state ---- export function attachView(v: EditorView | null) { view = v } /** Current editor view, accessed directly by controls such as the mobile toolbar. */ export function editorView(): EditorView | null { return view } export function toggleSourceMode() { ws.sourceMode = !ws.sourceMode const effects = previewCompartment.reconfigure(previewExtensions(ws.sourceMode)) for (const [path, state] of editorStates) { if (path === ws.active && view) { view.dispatch({ effects }) editorStates.set(path, view.state) } else { editorStates.set(path, state.update({ effects }).state) } } } /** Choose an image from photos/files and insert it at the cursor. */ export function insertImagesFromPicker() { const v = view const tab = activeTab() if (!v || tab?.status !== 'ready') return const input = document.createElement('input') input.type = 'file' input.accept = 'image/*' input.multiple = true input.addEventListener('change', () => { if (input.files?.length && view === v) insertImageFiles(v, [...input.files]) }) input.click() } export function editorStateOf(path: string): EditorState | undefined { return editorStates.get(path) } // Note identity survives rename/move and determines whether the editor still displays the same note. export function noteRefOf(path: string): { path: string } | undefined { return noteRefs.get(path) } // Called by the editor pane before tab switches to save view state, including cursor, non-scroll selection state, and undo history. export function storeViewState(path: string, state: EditorState) { if (editorStates.has(path)) editorStates.set(path, state) } function buildState(path: string, doc: string): EditorState { const version = user.getSessionVersion() const ref = { path } noteRefs.set(path, ref) const isCurrentNote = () => version === user.getSessionVersion() && noteRefs.get(ref.path) === ref return createEditorState({ doc, shortcuts: [], spellcheck: settings.spellcheck, tabSize: settings.tabSize, sourceMode: ws.sourceMode, openLink: (href) => { void openMarkdownLink(ref.path, href) }, resolveImage: (src) => { const url = resolveAssetUrl(ref.path, src) const entry = ws.entries.find(e => url === `/api/v1/raw/${encodePath(e.path)}`) return entry ? imageUrl(entry.path) : url.startsWith('/api/v1/raw/') ? imageResourceRoot() + url.slice('/api/v1/raw/'.length) : url }, onChange: (u) => { editorStates.set(ref.path, u.state) if (!u.transactions.some(tr => tr.annotation(externalChange))) scheduleSave(ref.path) }, extensions: [ notePath.of(ref), EditorView.updateListener.of((u) => { if (u.docChanged || u.selectionSet) editorListeners.forEach(fn => fn()) }), pasteImages({ canPaste: () => { if (!isCurrentNote()) return false if (!slot(ref.path).paused) return true toast(translate('workspace.this_note_is_moving_finish_d105e5')) return false }, upload: (file) => { if (!isCurrentNote()) throw new CanceledError(translate('workspace.the_note_receiving_this_paste_a59580')) return uploadImageApi({ note: ref.path, file }).then(({ data }) => data.link) }, importImage: (url) => { if (!isCurrentNote()) throw new CanceledError(translate('workspace.the_note_receiving_this_paste_a59580')) return importImageApi({ note: ref.path, url }).then(({ data }) => data.link) }, update: (change) => { if (!isCurrentNote()) return const shown = view?.state.facet(notePath) === ref const state = shown ? view!.state : editorStates.get(ref.path) if (!state) throw new Error(translate('workspace.the_editor_state_for_the_028961')) const spec = change(state) if (!spec) return if (shown) { view!.dispatch(spec) } else { const transaction = state.update(spec) editorStates.set(ref.path, transaction.state) if (transaction.docChanged) scheduleSave(ref.path) } }, onError: (err) => { if (isCurrentNote() && !isCancel(err)) toast(translate('workspace.paste_or_image_import_failed_09d4e6', { p0: err instanceof Error ? err.message : errorText(err) })) }, onPending: (d) => { if (!isCurrentNote()) return if (d === 1 && slot(ref.path).uploads === 0) toast(translate('workspace.processing_paste_or_image_import_023dad')) slot(ref.path).uploads += d }, }), ], }) } function reconfigurePluginStates() { const effects = [languageCompartment.reconfigure(pluginLanguage()), pluginCompartment.reconfigure(pluginEditorExtensions()), pluginRefresh.of(null)] // Compute every new state first so the registering party can roll back on failure; background tabs must not retain disabled extensions. const updates = [...editorStates].map(([path, state]) => { const current = path === ws.active && view ? view.state : state return { path, transaction: current.update({ effects }) } }) for (const { path, transaction } of updates) { if (path === ws.active && view) view.dispatch(transaction) editorStates.set(path, transaction.state) } } const pluginWrite: PluginAPI['vault']['write'] = async (path, content, options) => { const tab = tabOf(path) const saved = slots.get(path) if ((tab && (tab.dirty || tab.status !== 'ready')) || saved?.inflight || saved?.uploads || saved?.paused || opsInFlight) throw new ApiError(409, translate('workspace.this_note_has_unsaved_changes_f14427')) if (!options || (!('create' in options && options.create === true) && !('ifMatch' in options && typeof options.ifMatch === 'string' && options.ifMatch.length > 0))) throw new ApiError(400, translate('workspace.a_write_requires_a_non_b12fd4')) const initialRevision = tab?.rev const initialSequence = saved?.seq ?? 0 const rev = (await writeFileApi({ path, content, ifMatch: 'create' in options ? '' : options.ifMatch })).data.rev // A later save or newer revision may already exist; late responses must not revert contents or rev. if (tab && tabOf(path) === tab && tab.rev === initialRevision && (slots.get(path)?.seq ?? 0) === initialSequence && !tab.dirty && tab.status === 'ready' && !slots.get(path)?.inflight) { replaceDoc(path, content); tab.rev = rev } await refreshTree() return { rev } } // Replace document contents with server text by changing only the difference between shared prefix/suffix, preserving the cursor where possible and bypassing undo history. function replaceDoc(path: string, content: string) { const state = path === ws.active && view ? view.state : editorStates.get(path) if (!state) return const old = state.doc.toString() if (old === content) return let start = 0 while (start < old.length && start < content.length && old[start] === content[start]) start++ let endOld = old.length let endNew = content.length while (endOld > start && endNew > start && old[endOld - 1] === content[endNew - 1]) { endOld-- endNew-- } const spec = { changes: { from: start, to: endOld, insert: content.slice(start, endNew) }, annotations: [externalChange.of(true), Transaction.addToHistory.of(false)], } if (path === ws.active && view) view.dispatch(spec) else editorStates.set(path, state.update(spec).state) } function docOf(path: string): string | undefined { if (path === ws.active && view && editorStates.has(path)) return view.state.doc.toString() return editorStates.get(path)?.doc.toString() } // ---- Save queue ---- function slot(path: string): SaveSlot { let s = slots.get(path) if (!s) slots.set(path, (s = { seq: 0, savedSeq: 0, uploads: 0, paused: 0, retries: 0 })) return s } function scheduleSave(path: string, delay = SAVE_DELAY) { const s = slot(path) s.seq++ const tab = tabOf(path) if (tab) tab.dirty = true clearTimeout(s.timer) s.timer = setTimeout(() => void flush(path), delay) } // Immediately save pending changes for one note; keepalive attempts delivery when the page hides or closes. export async function flush(path: string, keepalive = false): Promise { const version = user.getSessionVersion() const s = slots.get(path) if (!s) return clearTimeout(s.timer) while (s.inflight) await s.inflight if (version !== user.getSessionVersion() || slots.get(path) !== s) return const tab = tabOf(path) if (!tab || tab.status !== 'ready' || s.seq === s.savedSeq) return if (s.paused || s.uploads) { // Defer saves during image uploads or file moves to avoid temporary blob: URLs or writes to old paths. s.timer = setTimeout(() => void flush(path), SAVE_DELAY) return } const content = docOf(path) if (content === undefined) return const seq = s.seq const baseRev = tab.rev s.inflight = (async () => { // Run draft persistence and the request in parallel; waiting for IndexedDB first often prevents the request from starting during page close. // Drafts are a recovery fallback; draft-write failures do not block saving itself. const draftSaved = drafts.put({ path, content, baseRev, updatedAt: Date.now() }).catch(err => console.warn('写入本地草稿失败', err)) try { const rev = (await writeFileApi({ path, content, ifMatch: baseRev, keepalive })).data.rev await draftSaved user.assertSession(version) tab.rev = rev tab.saveError = undefined s.savedSeq = seq s.retries = 0 if (s.seq === seq) { tab.dirty = false await drafts.delete(path) } } catch (err) { if (isCancel(err)) return if (err instanceof ApiError && err.status === 412) { const serverRev = typeof err.data.rev === 'string' ? err.data.rev : '' tab.status = serverRev ? 'conflict' : 'deleted' tab.conflictRev = serverRev } else if (!(err instanceof ApiError) || err.status >= 500) { // Network/server failures can be retried; the local draft preserves contents. tab.saveError = s.retries < MAX_RETRIES ? translate('workspace.save_failed_retrying_d62793') : translate('workspace.save_failed_check_your_connection_c0913f') if (s.retries++ < MAX_RETRIES) s.timer = setTimeout(() => void flush(path), RETRY_DELAY) } else { tab.saveError = translate('workspace.save_failed_6ba723', { p0: err.message }) } } })() await s.inflight s.inflight = undefined if (version !== user.getSessionVersion() || slots.get(path) !== s) return if (s.seq !== s.savedSeq && tab.status === 'ready' && !s.timer) scheduleSave(path) } export function retrySave(path: string) { slot(path).retries = 0 void flush(path) } export async function flushAll(keepalive = false) { await Promise.all([...slots.keys()].map(p => flush(p, keepalive))) } // Save failures remain in tab state; completion of flushAll does not guarantee a safe refresh. export function readyToReload(): boolean { return opsInFlight === 0 && !ws.tabs.some(t => t.dirty || t.status === 'loading') && [...slots.values()].every(s => !s.uploads && !s.paused && !s.inflight) } // ---- Conflict handling ---- // Discard local changes and load the server version. export async function useServerVersion(path: string) { const tab = tabOf(path) if (!tab) return try { const { content, rev } = await readNoteFile(path) replaceDoc(path, content) const s = slot(path) s.savedSeq = s.seq Object.assign(tab, { rev, status: 'ready', dirty: false, conflictRev: undefined, saveError: undefined }) await drafts.delete(path) } catch (err) { if (isCancel(err)) return toast(translate('workspace.load_failed_396d55', { p0: errorText(err) })) } } // Keep local changes and overwrite the server version, recreating the file if deleted there. export async function keepLocalVersion(path: string) { const version = user.getSessionVersion() const tab = tabOf(path) const content = docOf(path) if (!tab || content === undefined) return const s = slot(path) if (s.inflight || s.uploads || s.paused) { toast(translate('workspace.this_note_is_saving_uploading_c23675')) return } const seq = s.seq s.inflight = (async () => { try { const rev = (await writeFileApi({ path, content, ifMatch: tab.conflictRev ?? '' })).data.rev s.savedSeq = seq Object.assign(tab, { rev, status: 'ready', dirty: s.seq !== seq, conflictRev: undefined, saveError: undefined }) if (s.seq === seq) await drafts.delete(path) if (!ws.entries.some(e => e.path === path)) { void refreshTree().catch((err) => { if (!isCancel(err)) toast(translate('workspace.could_not_refresh_the_file_b5f68b', { p0: errorText(err) })) }) } } catch (err) { if (isCancel(err)) return if (err instanceof ApiError && err.status === 412) { tab.conflictRev = typeof err.data.rev === 'string' ? err.data.rev : '' toast(translate('workspace.the_server_version_changed_again_594f7d')) } else { toast(translate('workspace.save_failed_6ba723', { p0: errorText(err) })) } } })() await s.inflight s.inflight = undefined if (version !== user.getSessionVersion() || slots.get(path) !== s) return if (tab.dirty && tab.status === 'ready') void flush(path) } // ---- Open and focus (quick switcher and search results) ---- interface RevealTarget { path: string line: number terms: string[] } // The note may not be loaded or its editor visible yet; the editor pane performs this after displaying the note. let pendingReveal: RevealTarget | null = null /** Open a note and focus the editor; line > 0 targets that 1-based line and selects its first search-term match. */ export async function openAt(path: string, line = 0, terms: string[] = []) { pendingReveal = { path, line, terms } if (path === ws.active && view && editorStates.has(path)) { ws.sidebarOpen = false revealPending(view, path) } else { await openNote(path) } } /** Called after the editor pane displays the note; true means the requested line was reached, so do not restore the old scroll position. */ export function revealPending(v: EditorView, path: string): boolean { const r = pendingReveal if (!r || r.path !== path) return false pendingReveal = null // Focus opens software keyboards on touch devices and covers content; focus only devices with a mouse. if (!matchMedia('(pointer: coarse)').matches) v.focus() if (r.line === 0) return false const line = v.state.doc.line(Math.min(r.line, v.state.doc.lines)) const text = line.text.toLowerCase() let at = -1 let len = 0 for (const t of r.terms) { const i = text.indexOf(t.toLowerCase()) if (i >= 0 && (at < 0 || i < at)) [at, len] = [i, t.length] } const from = line.from + Math.max(at, 0) v.dispatch({ selection: { anchor: from, head: from + len }, effects: EditorView.scrollIntoView(from, { y: 'center' }) }) return true } // ---- Tabs ---- export async function openNote(path: string, options?: { background?: boolean }) { const version = user.getSessionVersion() const epoch = workspaceEpoch if (!options?.background) { ws.view = 'notes' ws.images = null ws.sidebarOpen = false } // New tree notes appear optimistically; wait for actual creation before reading them. const pending = creating.get(path) if (pending && !(await pending)) return user.assertSession(version) if (tabOf(path)) { if (!options?.background) ws.active = path return } const tab = reactive({ path, pinned: settings.pinnedTabs?.includes(path) ?? false, rev: '', status: 'loading', dirty: false }) ws.tabs.push(tab) if (!options?.background) ws.active = path try { const { content, rev } = await readNoteFile(path) user.assertSession(version) if (epoch !== workspaceEpoch || tabOf(path) !== tab) return editorStates.set(path, buildState(path, content)) const t = tabOf(path) if (t) Object.assign(t, { rev, status: 'ready' }) } catch (err) { if (isCancel(err)) return if (epoch !== workspaceEpoch || tabOf(path) !== tab) return const t = tabOf(path) if (t) t.status = 'error' toast(translate('workspace.could_not_open_0ed1e1', { p0: errorText(err) })) } } async function syncPinnedTabs() { if (settings.pinnedTabs === undefined) return const pinned = new Set(settings.pinnedTabs) for (const tab of ws.tabs) tab.pinned = pinned.has(tab.path) // Remote unpins never close tabs or discard local drafts, and remote pins never steal focus. const notes = new Set(ws.entries.filter(entry => !entry.dir && entry.path.endsWith(NOTE_EXT)).map(entry => entry.path)) await Promise.all([...pinned].filter(path => notes.has(path) && !tabOf(path)).map(path => openNote(path, { background: true }))) } async function syncSettings() { await loadSettings() await syncPinnedTabs() } async function savePinnedTabs(paths: string[]): Promise { try { const saving = updateSettings({ pinnedTabs: paths }) for (const tab of ws.tabs) tab.pinned = paths.includes(tab.path) await saving return true } catch (err) { if (isCancel(err)) return false if (err instanceof SettingsAppearanceError) { toast(err.message) return true } toast(translate('common.could_not_save_settings_1cdd7f', { p0: err instanceof Error ? err.message : errorText(err) })) try { await syncSettings() } catch (reload) { if (!isCancel(reload)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(reload) })) } return false } } export async function toggleTabPin(path: string) { if (!tabOf(path)) return const pinned = settings.pinnedTabs ?? ws.tabs.filter(tab => tab.pinned).map(tab => tab.path) await savePinnedTabs(pinned.includes(path) ? pinned.filter(p => p !== path) : [...pinned, path]) } async function remapPinnedTabs(from: string, to: string) { const pinned = settings.pinnedTabs if (pinned?.some(path => remapPath(path, from, to) !== null)) { await savePinnedTabs(pinned.map(path => remapPath(path, from, to) ?? path)) } } export async function closeTab(path: string): Promise { const version = user.getSessionVersion() const busy = () => Boolean(slots.get(path)?.uploads || slots.get(path)?.paused) if (busy()) { toast(translate('workspace.this_note_is_uploading_or_349b3d')) return false } await flush(path) user.assertSession(version) const i = ws.tabs.findIndex(t => t.path === path) if (i < 0) return true const tab = ws.tabs[i] if (busy()) { toast(translate('workspace.this_note_is_uploading_or_349b3d')) return false } if (tab.dirty) { const seq = slot(path).seq const content = docOf(path) if (content === undefined) throw new Error(translate('workspace.the_editor_state_for_this_248447')) try { await drafts.put({ path, content, baseRev: tab.rev, updatedAt: Date.now() }) user.assertSession(version) } catch (err) { if (isCancel(err)) return false toast(translate('workspace.could_not_save_the_local_cc0e42', { p0: errorText(err) })) return false } if (tabOf(path) !== tab || slot(path).seq !== seq || busy()) return false toast(translate('workspace.unsaved_changes_retained_as_a_8cb2ab')) } if (tab.pinned) { const seq = slot(path).seq const pinned = settings.pinnedTabs ?? ws.tabs.filter(tab => tab.pinned).map(tab => tab.path) if (!(await savePinnedTabs(pinned.filter(p => p !== path)))) return false user.assertSession(version) if (!ws.tabs.includes(tab)) return true // Saving the pin is asynchronous; retain a tab changed by new input or another operation while it was pending. if (tab.path !== path || slot(path).seq !== seq || busy()) { const current = settings.pinnedTabs ?? ws.tabs.filter(tab => tab.pinned).map(tab => tab.path) if (!current.includes(tab.path)) await savePinnedTabs([...current, tab.path]) return false } } const index = ws.tabs.indexOf(tab) if (index < 0) return true ws.tabs.splice(index, 1) forget(path) if (ws.active === path) ws.active = ws.tabs[Math.min(index, ws.tabs.length - 1)]?.path ?? null return true } function forget(path: string) { editorStates.delete(path) noteRefs.delete(path) const s = slots.get(path) if (s) clearTimeout(s.timer) slots.delete(path) } // Replace path prefixes; moving a folder changes every descendant path together. function remapPath(p: string, from: string, to: string): string | null { if (p === from) return to if (p.startsWith(`${from}/`)) return to + p.slice(from.length) return null } function remapOpenNotes(from: string, to: string) { for (const tab of ws.tabs) { const next = remapPath(tab.path, from, to) if (next === null) continue const old = tab.path tab.path = next if (ws.active === old) ws.active = next for (const map of [editorStates, noteRefs, slots] as Map[]) { if (map.has(old)) { map.set(next, map.get(old)) map.delete(old) } } const ref = noteRefs.get(next) if (ref) ref.path = next } reconfigurePluginStates() } function remapRecent(from: string, to: string) { ws.recent = ws.recent.map(p => remapPath(p, from, to) ?? p) } function affected(path: string): string[] { return ws.tabs.map(t => t.path).filter(p => p === path || p.startsWith(`${path}/`)) } // ---- Directory tree ---- // Tree refresh permits one request at a time; discard and refetch if another refresh or local operation makes its result stale. // Local operations update the tree optimistically; stale results would erase new entries, such as a new note's rename input. // Waiting callers settle together after the next accepted result or request failure. let treeTimer: ReturnType | undefined let treeFetching = false let treeAgain = false let localTreeEdits = 0 const treeWaiters: { resolve: () => void, reject: (reason: unknown) => void }[] = [] export function refreshTree(): Promise { return new Promise((resolve, reject) => { treeWaiters.push({ resolve, reject }) scheduleTreeRefresh() }) } function scheduleTreeRefresh() { if (treeFetching) { treeAgain = true return } clearTimeout(treeTimer) treeTimer = setTimeout(() => void fetchTree(), 50) } async function fetchTree() { const version = user.getSessionVersion() treeFetching = true treeAgain = false const edits = localTreeEdits let entries: Entry[] try { entries = (await getTreeApi()).data.entries user.assertSession(version) } catch (err) { if (isCancel(err)) return treeFetching = false for (const waiter of treeWaiters.splice(0)) waiter.reject(err) return } treeFetching = false if (treeAgain || edits !== localTreeEdits || opsInFlight > 0) { // During active operations, withOp reschedules once all operations finish. if (opsInFlight === 0) scheduleTreeRefresh() return } const oldImages = new Map(ws.entries.filter(e => !e.dir && isImage(e.path)).map(e => [e.path, e])) const nextPaths = new Set(entries.map(e => e.path)) const imagesChanged = entries.some(e => !e.dir && isImage(e.path) && (e.mtime !== oldImages.get(e.path)?.mtime || e.size !== oldImages.get(e.path)?.size)) || [...oldImages.keys()].some(p => !nextPaths.has(p)) ws.entries = entries if (imagesChanged) view?.dispatch({ effects: refreshImages.of(null) }) markMissingTabsDeleted() for (const waiter of treeWaiters.splice(0)) waiter.resolve() } function markMissingTabsDeleted() { const exists = new Set(ws.entries.map(e => e.path)) for (const tab of ws.tabs) { if (tab.status === 'ready' && !exists.has(tab.path)) { tab.status = 'deleted' tab.conflictRev = '' } } } function uniqueName(dir: string, base: string, ext: string): string { const taken = new Set(ws.entries.map(e => e.path)) for (let i = 0; ; i++) { const name = base + (i ? ` ${i}` : '') + ext if (!taken.has(joinPath(dir, name))) return joinPath(dir, name) } } async function withOp(fn: () => Promise): Promise { const epoch = workspaceEpoch opsInFlight++ localTreeEdits++ try { return await fn() } finally { if (epoch === workspaceEpoch) { localTreeEdits++ if (--opsInFlight === 0 && treeWaiters.length) scheduleTreeRefresh() } } } export async function saveTreeIcon(path: string, icon: TreeIcon | null) { await withOp(() => setTreeIconApi({ path, icon }).then(({ data }) => data)) try { await refreshTree() } catch (err) { if (isCancel(err)) return toast(translate('workspace.icon_saved_but_the_file_72a5c2', { p0: errorText(err) })) } } export async function createNote(dir: string) { const path = uniqueName(dir, translate('workspace.untitled_67bee6'), NOTE_EXT) ws.renaming = path await createNoteAt(path) } // In-flight creation: immediately show the rename input, but wait for actual file creation before renaming. const creating = new Map>() /** Create and open an empty note at the requested path and report success; the server creates missing parent folders. */ export async function createNoteAt(path: string, content = ''): Promise { ws.view = 'notes' ws.images = null const done = createNoteFile(path, content) creating.set(path, done) try { return await done } finally { if (creating.get(path) === done) creating.delete(path) } } async function createNoteFile(path: string, content: string): Promise { const exists = new Set(ws.entries.map(e => e.path)) const added = [path] for (let d = dirname(path); d && !exists.has(d); d = dirname(d)) added.push(d) ws.entries.push(...added.map((p, i) => ({ path: p, dir: i > 0, size: 0, mtime: Date.now() }))) return withOp(async () => { try { const rev = (await writeFileApi({ path, content, ifMatch: '' })).data.rev ws.tabs.push({ path, rev, status: 'ready', dirty: false }) editorStates.set(path, buildState(path, content)) ws.active = path return true } catch (err) { if (isCancel(err)) return false ws.entries = ws.entries.filter(e => !added.includes(e.path)) if (ws.renaming === path) ws.renaming = null toast(translate('workspace.could_not_create_note_2760ce', { p0: errorText(err) })) return false } }) } export async function importNotes(dir: string, folder: string, files: File[]) { // refreshTree must wait until withOp ends, or it would wait for its own operation count to be released. const result = await withOp(() => importNotesApi({ dir, folder, files }).then(({ data }) => data)) ws.searchOpen = false try { await refreshTree() } catch (err) { if (isCancel(err)) throw err toast(translate('workspace.import_complete_but_the_file_52460f', { p0: errorText(err) })) } return result } export async function createFolder(dir: string) { const path = uniqueName(dir, translate('workspace.new_folder_84244a'), '') ws.entries.push({ path, dir: true, size: 0, mtime: Date.now() }) ws.renaming = path await withOp(async () => { try { await createFolderApi({ path }) } catch (err) { if (isCancel(err)) return ws.entries = ws.entries.filter(e => e.path !== path) if (ws.renaming === path) ws.renaming = null toast(translate('workspace.could_not_create_folder_bc6447', { p0: errorText(err) })) } }) } export function renameTarget(path: string, name: string): string | null { name = name.trim().replace(/\//g, '') if (!name || name.startsWith('.')) return null const entry = ws.entries.find(e => e.path === path) if (entry && !entry.dir && path.endsWith(NOTE_EXT) && !name.endsWith(NOTE_EXT)) name += NOTE_EXT return joinPath(dirname(path), name) } export type FileResult = { ok: true } | { ok: false, error: string } function operationFailed(error: string, owner: 'operation' | 'caller'): FileResult { if (owner === 'operation') toast(error) return { ok: false, error } } export async function move(from: string, to: string, errorOwner: 'operation' | 'caller' = 'operation'): Promise { const version = user.getSessionVersion() if (from === to) return { ok: true } if (creating.has(from) && !(await creating.get(from))) return operationFailed(translate('workspace.this_note_has_not_been_77a7a9'), errorOwner) user.assertSession(version) if (ws.entries.some(e => e.path === to)) { return operationFailed(translate('workspace.already_exists_6af434', { p0: basename(to) }), errorOwner) } const result = await withOp(async () => { // Moves update relative vault links; save all open notes first, retaining revision-conflict protection. const impacted = () => ws.tabs.map(t => t.path) const busy = () => impacted().some(p => slots.get(p)?.uploads || slots.get(p)?.paused) if (busy()) { return operationFailed(translate('workspace.this_note_is_processing_a_8c2cce'), errorOwner) } // Save pending edits first so the latest contents move; pause saving during the move to avoid writing to old paths. await Promise.all(impacted().map(p => flush(p))) user.assertSession(version) // Pasting may start while awaiting saves; check again before acquiring the move lock. if (busy()) { return operationFailed(translate('workspace.this_note_is_processing_a_8c2cce'), errorOwner) } if (impacted().some((p) => { const tab = tabOf(p); return tab && (tab.dirty || tab.status !== 'ready') })) { return operationFailed(translate('workspace.save_your_notes_and_resolve_5500d4'), errorOwner) } const paused = impacted().map(slot) paused.forEach(s => s.paused++) const snapshot = ws.entries let draftsMoved = false let remapped = false try { // Verify persistent drafts can migrate first; on failure, keep original paths on both the server and interface. await drafts.move(from, to) user.assertSession(version) draftsMoved = true remapped = true ws.entries = ws.entries.map(e => ({ ...e, path: remapPath(e.path, from, to) ?? e.path })) remapOpenNotes(from, to) remapRecent(from, to) if (ws.image) ws.image = remapPath(ws.image, from, to) ?? ws.image if (ws.images) ws.images = remapPath(ws.images, from, to) ?? ws.images await moveFileApi({ from, to }) } catch (err) { if (isCancel(err)) throw err if (remapped) { ws.entries = snapshot remapOpenNotes(to, from) remapRecent(to, from) if (ws.image) ws.image = remapPath(ws.image, to, from) ?? ws.image if (ws.images) ws.images = remapPath(ws.images, to, from) ?? ws.images } if (draftsMoved) { try { await drafts.move(to, from) } catch (cause) { toast(translate('workspace.could_not_restore_the_draft_e5f464', { p0: cause instanceof Error ? cause.message : errorText(cause) })) } } return operationFailed(translate('workspace.move_failed_04b0a9', { p0: err instanceof Error ? err.message : errorText(err) }), errorOwner) } finally { paused.forEach(s => s.paused--) } await remapPinnedTabs(from, to) user.assertSession(version) // The server rewrites image links when renaming notes, changing their revisions and requiring resynchronization. await Promise.all(ws.tabs.map(t => t.path).map(async (p) => { const tab = tabOf(p) if (!tab) return if (tab.dirty) { // Input during the move still contains old links; let the user resolve it. const { rev } = await readNoteFile(p) if (rev !== tab.rev) Object.assign(tab, { status: 'conflict', conflictRev: rev }) } else { await syncOpenTab(tab) } })).catch((err) => { if (isCancel(err)) throw err toast(translate('workspace.file_moved_but_note_synchronization_b9ff79', { p0: errorText(err) })) }) return { ok: true } }) void refreshTree().catch((err) => { if (!isCancel(err)) toast(translate('workspace.could_not_refresh_the_file_b5f68b', { p0: errorText(err) })) }) return result } export async function remove(path: string, errorOwner: 'operation' | 'caller' = 'operation'): Promise { const version = user.getSessionVersion() const entry = ws.entries.find(e => e.path === path) if (!entry) return operationFailed(translate('workspace.file_no_longer_exists_4eb446'), errorOwner) return withOp(async () => { const paths = affected(path) await Promise.all(paths.map(p => flush(p))) user.assertSession(version) if (paths.some((p) => { const tab = tabOf(p); return tab && (tab.dirty || tab.status !== 'ready') })) { return operationFailed(translate('workspace.save_your_notes_and_resolve_5981a5'), errorOwner) } const snapshot = ws.entries ws.entries = ws.entries.filter(e => remapPath(e.path, path, path) === null) // Remove the item and all descendants. for (const p of paths) { if (!(await closeTab(p))) { ws.entries = snapshot; return operationFailed(translate('workspace.could_not_close_the_note_cd2a80'), errorOwner) } } user.assertSession(version) try { const item = (await deleteFileApi(path)).data if (ws.image && remapPath(ws.image, path, path) !== null) ws.image = null toast(translate('workspace.deleted_86a46b', { p0: basename(path).replace(/\.md$/, '') }), { get label() { return translate('workspace.undo_926a50') }, run: () => void restore(item.id), }) return { ok: true } } catch (err) { if (isCancel(err)) throw err ws.entries = snapshot return operationFailed(translate('common.could_not_delete_9619d7', { p0: errorText(err) }), errorOwner) } }) } export async function restore(id: string) { try { const item = (await restoreTrashApi(id)).data await refreshTree() if (!item.dir && item.path.endsWith(NOTE_EXT)) void openNote(item.path) else if (!item.dir && isImage(item.path)) ws.image = item.path } catch (err) { if (isCancel(err)) return toast(translate('workspace.restore_failed_283a6e', { p0: errorText(err) })) } } // The version refreshes browser images only; relative Markdown paths remain unchanged. export function imageUrl(path: string): string { const entry = ws.entries.find(e => e.path === path) return imageResourceRoot() + encodePath(path) + (entry ? `?v=${entry.mtime}-${entry.size}` : '') } export async function replaceImage(path: string, file: File) { await withOp(() => replaceImageApi({ path, file }).then(({ data }) => data)) try { await refreshTree() } catch (err) { if (isCancel(err)) return toast(translate('workspace.image_replaced_but_the_file_bb4848', { p0: errorText(err) })) } } // ---- Live synchronization ---- async function syncOpenTab(tab: Tab) { const s = slots.get(tab.path) if (tab.status !== 'ready' || tab.dirty || s?.inflight) return try { const { content, rev } = await readNoteFile(tab.path) if (rev === tab.rev || tab.dirty) return replaceDoc(tab.path, content) tab.rev = rev } catch (err) { if (isCancel(err)) return if (err instanceof ApiError && err.status === 404) { tab.status = 'deleted' tab.conflictRev = '' } else { throw err } } } async function handleEvents(evs: VaultEvent[]) { const version = user.getSessionVersion() const epoch = workspaceEpoch for (const ev of evs) { user.assertSession(version) if (ev.type === 'indexed') { void refreshMetadata() } else if (ev.type === 'settings') { await syncSettings().catch((err) => { if (!isCancel(err)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(err) })) }) await refreshPlugins() } else if (ev.type === 'plugins') { await refreshPlugins() } else if (ev.type === 'changed') { const tab = tabOf(ev.path!) // Own-save events match the local rev; do not overwrite local edits, and let the next save detect conflicts. if (tab && tab.rev !== ev.rev) await syncOpenTab(tab) } else if (ev.type === 'moved') { if (ws.image) ws.image = remapPath(ws.image, ev.from!, ev.path!) ?? ws.image if (ws.images) ws.images = remapPath(ws.images, ev.from!, ev.path!) ?? ws.images const paused = affected(ev.from!).map(slot) paused.forEach(s => s.paused++) try { // In-flight saves may still write the old key; wait for their draft transactions first. await Promise.all(paused.map(s => s.inflight)) user.assertSession(version) if (epoch !== workspaceEpoch) return try { await drafts.move(ev.from!, ev.path!) } catch (cause) { // The external file already moved, so draft failures must not prevent editor tracking; retain the original record and report failure explicitly. toast(translate('workspace.could_not_migrate_the_local_4c513a', { p0: cause instanceof Error ? cause.message : errorText(cause) })) } user.assertSession(version) if (epoch !== workspaceEpoch) return if (affected(ev.from!).length && !affected(ev.path!).length) remapOpenNotes(ev.from!, ev.path!) remapRecent(ev.from!, ev.path!) await remapPinnedTabs(ev.from!, ev.path!) } finally { paused.forEach(s => s.paused--) } } else if (ev.type === 'deleted') { if (ws.image === ev.path && opsInFlight === 0) ws.image = null const tab = tabOf(ev.path!) if (tab && tab.status === 'ready' && opsInFlight === 0) { tab.status = 'deleted' tab.conflictRev = '' } } user.assertSession(version) if (epoch !== workspaceEpoch) return if (ev.type === 'changed' || ev.type === 'moved' || ev.type === 'deleted') pluginEvents.dispatchEvent(new CustomEvent(ev.type, { detail: ev.type === 'moved' ? { from: ev.from!, path: ev.path! } : ev.type === 'changed' ? { path: ev.path!, rev: ev.rev! } : { path: ev.path! } })) } if (evs.some(event => event.type === 'tree')) await resync() } async function resync() { const version = user.getSessionVersion() const epoch = workspaceEpoch void refreshMetadata() await refreshTree() user.assertSession(version) if (epoch !== workspaceEpoch) return await syncSettings().catch((err) => { if (!isCancel(err)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(err) })) }) user.assertSession(version) if (epoch !== workspaceEpoch) return await Promise.all(ws.tabs.map(syncOpenTab)) user.assertSession(version) if (epoch !== workspaceEpoch) return pluginEvents.dispatchEvent(new CustomEvent('resync')) await refreshPlugins() } // ---- Startup ---- const TABS_KEY = 'rawmark.tabs' function localTabs() { return { paths: ws.tabs.map(t => t.path), active: ws.active, ...(settings.pinnedTabs === undefined ? { pinned: ws.tabs.filter(t => t.pinned).map(t => t.path) } : {}) } } // Restore unsaved drafts: continue saving if the server revision is unchanged; otherwise enter conflict state for user resolution. async function recoverDrafts() { const version = user.getSessionVersion() for (const d of await drafts.all()) { user.assertSession(version) try { const { content, rev } = await readNoteFile(d.path) if (content === d.content) { await drafts.delete(d.path) continue } if (!tabOf(d.path)) ws.tabs.push({ path: d.path, rev, status: 'ready', dirty: false }) const tab = tabOf(d.path)! editorStates.set(d.path, buildState(d.path, d.content)) tab.rev = rev if (rev === d.baseRev) { tab.status = 'ready' scheduleSave(d.path) } else { Object.assign(tab, { status: 'conflict', conflictRev: rev, dirty: true }) } } catch (err) { if (isCancel(err)) throw err if (!(err instanceof ApiError) || err.status !== 404) throw err ws.tabs.push({ path: d.path, rev: '', status: 'deleted', dirty: true, conflictRev: '' }) editorStates.set(d.path, buildState(d.path, d.content)) } } } let disconnect: (() => void) | undefined let listenersInstalled = false export async function startWorkspace() { const version = user.getSessionVersion() await draftPreserved user.assertSession(version) workspaceRunning = false await prepareResources() user.assertSession(version) // Settings-read failures do not prevent use; retain locally cached values. const settingsLoaded = loadSettings().then(() => true).catch((err) => { if (!isCancel(err)) toast(translate('workspace.could_not_load_settings_246415', { p0: errorText(err) })) return false }) await refreshTree() const confirmedSettings = await settingsLoaded user.assertSession(version) const saved = JSON.parse(localStorage.getItem(TABS_KEY) ?? '{"paths":[],"active":null}') as { paths: string[], active: string | null, pinned?: string[] } const exists = new Set(ws.entries.map(e => e.path)) await Promise.all(saved.paths.filter(p => exists.has(p)).map(path => openNote(path))) if (settings.pinnedTabs === undefined) { for (const tab of ws.tabs) tab.pinned = saved.pinned?.includes(tab.path) ?? false if (confirmedSettings && saved.pinned?.length) await savePinnedTabs(saved.pinned.filter(path => exists.has(path))) } if (settings.pinnedTabs !== undefined) await syncPinnedTabs() try { await recoverDrafts() } catch (err) { if (isCancel(err)) throw err toast(translate('workspace.could_not_restore_the_local_a275e8', { p0: errorText(err) })) } user.assertSession(version) ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null) workspaceRunning = true localStorage.setItem(TABS_KEY, JSON.stringify(localTabs())) let eventQueue = Promise.resolve() let stopped = false const enqueue = (work: () => Promise) => { // Synchronization reads are asynchronous; later batches must not overtake earlier moves or content updates. eventQueue = eventQueue.then(() => { if (!stopped) return work() }).catch((err) => { if (!stopped && version === user.getSessionVersion() && !isCancel(err)) toast(translate('workspace.synchronization_failed_c6eeea', { p0: errorText(err) })) }) } const stopEvents = connectEventsApi({ onEvents: events => enqueue(() => handleEvents(events)), onResync: () => enqueue(resync) }) disconnect = () => { stopped = true; stopEvents() } setPluginEditorHandlers(reconfigurePluginStates, pluginEditorFailure) startPlugins({ notice: toast, openNote, writeNote: pluginWrite, getActiveNote: () => { const tab = activeTab() if (!tab) return null const state = view && ws.active === tab.path ? view.state : editorStates.get(tab.path) if (!state) return null return { path: tab.path, content: state.doc.toString(), rev: tab.rev, dirty: tab.dirty } }, }) // The workspace starts again after signing in; register the following global listeners only once. if (listenersInstalled) return listenersInstalled = true watch( localTabs, (tabs) => { if (workspaceRunning) localStorage.setItem(TABS_KEY, JSON.stringify(tabs)) }, ) watch( () => ws.active, (p) => { if (p) ws.recent = [p, ...ws.recent.filter(r => r !== p)].slice(0, RECENT_MAX) }, { immediate: true }, ) watch( () => ws.recent, r => localStorage.setItem(RECENT_KEY, JSON.stringify(r)), ) watch( () => ({ sidebarCollapsed: ws.sidebarCollapsed, outlineOpen: ws.outlineOpen, focusMode: ws.focusMode }), (l) => { localStorage.setItem(LAYOUT_KEY, JSON.stringify(l)) // Editor width changed; request remeasurement. requestAnimationFrame(() => view?.requestMeasure()) }, ) watch(locale, (value) => { const effects = localeCompartment.reconfigure(editorLocale.of(value)) for (const [path, state] of editorStates) { if (path === ws.active && view) { view.dispatch({ effects }) editorStates.set(path, view.state) } else { editorStates.set(path, state.update({ effects }).state) } } }) // Font-size and text-width changes affect line heights; request editor remeasurement. watch( () => [settings.uiFont, settings.uiFontSize, settings.fontSize, settings.contentWidth, settings.sidebarWidth, settings.textFont, settings.codeFont, settings.lineHeight], () => view?.requestMeasure(), ) watch( () => [settings.spellcheck, settings.tabSize], () => { const effects = preferencesCompartment.reconfigure([ EditorState.tabSize.of(settings.tabSize), EditorView.contentAttributes.of({ spellcheck: String(settings.spellcheck) }), ]) for (const [path, state] of editorStates) { if (path === ws.active && view) { view.dispatch({ effects }) editorStates.set(path, view.state) } else { editorStates.set(path, state.update({ effects }).state) } } }, ) window.addEventListener('rawmark-appearance', () => requestAnimationFrame(() => view?.requestMeasure())) window.addEventListener('rawmark-plugin-notice', event => toast((event as CustomEvent).detail)) // Attempt to send pending saves before page hide/close; some browsers fire only pagehide on tab close. document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') void flushAll(true) }) window.addEventListener('pagehide', () => void flushAll(true)) window.addEventListener('beforeunload', (event) => { if (!readyToReload()) event.preventDefault() }) window.addEventListener('keydown', (event) => { if (workspaceRunning) handleShortcut(event) }, true) // Keep the cursor visible when the software keyboard reduces editor height. trackVisualViewport(() => { if (view?.hasFocus) view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'nearest', yMargin: 24 }) }) }) } // Built-in commands do not depend on workspace state; register at module load before component commands such as sign-out. registerBuiltinCommands() function registerBuiltinCommands() { for (const command of editorCommands) { registerCommand({ ...command, get title() { return command.title }, when: () => ws.view === 'notes' && ws.settings === null && ws.images === null && ws.active !== null && !!view, run: () => { if (view) { command.run(view); view.focus() } } }) } const hasNote = () => ws.active !== null const toggleSwitcher = (initial: string) => { ws.switcher = ws.switcher === null ? initial : null } for (const cmd of [ { id: 'app.quickOpen', get title() { return translate('workspace.quick_open_note_0d376f') }, run: () => toggleSwitcher('') }, { id: 'app.commandPalette', get title() { return translate('workspace.command_palette_4b495d') }, run: () => toggleSwitcher('>') }, { id: 'app.search', get title() { return translate('workspace.full_text_search_a6c47a') }, run: openSearch }, { id: 'note.new', get title() { return translate('workspace.new_note_d3d2d9') }, run: () => withSidebar(() => createNote('')) }, { id: 'folder.new', get title() { return translate('workspace.new_folder_84244a') }, run: () => withSidebar(() => createFolder('')) }, { id: 'note.save', get title() { return translate('workspace.save_current_note_5a7e29') }, when: hasNote, run: () => void flush(ws.active!) }, { id: 'note.rename', get title() { return translate('workspace.rename_current_note_de288b') }, when: hasNote, run: () => withSidebar(() => (ws.renaming = ws.active)) }, { id: 'note.move', get title() { return translate('workspace.move_current_note_to_f1fc16') }, when: hasNote, run: () => (ws.moving = ws.active) }, { id: 'note.delete', get title() { return translate('workspace.delete_current_note_657a00') }, when: hasNote, run: () => void remove(ws.active!) }, { id: 'note.insertImage', get title() { return translate('workspace.insert_image_52c554') }, when: hasNote, run: insertImagesFromPicker }, { id: 'tab.close', get title() { return translate('workspace.close_current_tab_cc8a4e') }, when: hasNote, run: () => void closeTab(ws.active!) }, { id: 'app.settings', get title() { return translate('common.settings_df3d58') }, run: () => (ws.settings = 'index') }, { id: 'view.sidebar', get title() { return translate('workspace.show_hide_sidebar_96b1a4') }, run: toggleSidebar }, { id: 'view.outline', get title() { return translate('workspace.show_hide_outline_1da8d2') }, when: hasNote, run: () => (ws.outlineOpen = !ws.outlineOpen) }, { id: 'view.source', get title() { return translate('workspace.toggle_source_editing_602b33') }, when: hasNote, run: toggleSourceMode }, { id: 'view.focus', get title() { return translate('workspace.toggle_focus_mode_b52fb6') }, when: hasNote, run: () => (ws.focusMode = !ws.focusMode) }, { id: 'app.trash', get title() { return translate('workspace.open_trash_8a939d') }, run: () => (ws.settings = 'trash') }, { id: 'app.images', get title() { return translate('workspace.manage_root_folder_images_fed368') }, run: () => { ws.imagesAll = false; ws.images = '' } }, { id: 'app.tokens', get title() { return translate('common.api_tokens_79db53') }, run: () => (ws.settings = 'account') }, ]) { registerCommand({ ...cmd, get title() { return cmd.title }, keys: typoraKeys(cmd.id) }) } } // The sidebar is a drawer on phones and collapsible on desktop. function toggleSidebar() { if (matchMedia('(max-width: 768px)').matches) ws.sidebarOpen = !ws.sidebarOpen else ws.sidebarCollapsed = !ws.sidebarCollapsed } // For tree operations, switch back to the tree and open the sidebar on mobile. function withSidebar(fn: () => unknown) { ws.searchOpen = false ws.sidebarOpen = true ws.sidebarCollapsed = false void fn() } export function openSearch() { ws.searchOpen = true ws.sidebarOpen = true ws.sidebarCollapsed = false // If already open, refocus and select all so users can immediately enter a new query. void nextTick(() => { const input = document.querySelector('.rm-search-input') input?.focus() input?.select() }) } export function stopWorkspace() { workspaceRunning = false workspaceEpoch++ const dirty = ws.tabs.filter(tab => tab.dirty).map((tab) => { const content = docOf(tab.path) return content === undefined ? Promise.resolve() : drafts.put({ path: tab.path, content, baseRev: tab.rev, updatedAt: Date.now() }) }) draftPreserved = Promise.all(dirty).then(() => {}).catch((error) => { console.error('保留本地草稿失败', error); toast(translate('workspace.could_not_save_the_local_4b21e1')) }) for (const slot of slots.values()) clearTimeout(slot.timer) clearTimeout(treeTimer) for (const waiter of treeWaiters.splice(0)) waiter.reject(new CanceledError(translate('workspace.workspace_closed_221ea3'))) treeFetching = false treeAgain = false opsInFlight = 0 localTreeEdits++ creating.clear() slots.clear() editorStates.clear() noteRefs.clear() view = null ws.tabs = [] ws.active = null ws.entries = [] ws.switcher = null ws.settings = null ws.moving = null ws.importing = null ws.renaming = null ws.image = null ws.images = null stopPlugins() disconnect?.() disconnect = undefined } async function openMarkdownLink(note: string, href: string) { const decoded = decodeLinkDestination(href) let url: URL try { url = new URL(decoded, `https://rawmark.local/${encodePath(note)}`) } catch { toast(translate('workspace.invalid_link_format_651be5')); return } if (url.origin !== 'https://rawmark.local') { if (['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol)) window.open(url.href, '_blank', 'noopener') else toast(translate('workspace.unsupported_link_protocol_cf426f')) return } let target: string try { target = decodeURIComponent(url.pathname.slice(1)); decodeURIComponent(url.hash.slice(1)) } catch { toast(translate('workspace.invalid_link_encoding_2c6a7e')); return } if (!ws.entries.some(e => e.path === target && !e.dir)) { toast(translate('workspace.link_target_does_not_exist_2503d1', { p0: target })); return } if (!target.endsWith('.md')) { const response = await getRawFileApi(target) const url = URL.createObjectURL(response.data) const link = Object.assign(document.createElement('a'), { href: url, download: basename(target) }); link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) return } await openNote(target) if (url.hash) { requestAnimationFrame(() => { const state = editorStateOf(target); const v = editorView() if (!state || !v || ws.active !== target) return const heading = decodeURIComponent(url.hash.slice(1)).toLocaleLowerCase() for (let i = 1; i <= state.doc.lines; i++) { const line = state.doc.line(i) const title = line.text.replace(/^#{1,6}\s+/, '').trim().toLocaleLowerCase() if (/^#{1,6}\s/.test(line.text) && (title === heading || title.replace(/\s+/g, '-') === heading)) { v.dispatch({ selection: { anchor: line.from }, effects: EditorView.scrollIntoView(line.from, { y: 'start' }) }); break } } }) } } export async function openReference(path: string, line: number) { await openNote(path) requestAnimationFrame(() => { const v = editorView() if (!v || ws.active !== path) return const pos = v.state.doc.line(Math.min(line, v.state.doc.lines)).from v.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: 'center' }) }) v.focus() }) } export async function uploadFolderImages(folder: string, files: File[]) { const version = user.getSessionVersion() const failures: string[] = [] let uploaded = 0 await withOp(async () => { for (const file of files) { try { user.assertSession(version) await uploadFolderImageApi({ folder, file }); uploaded++ } catch (error) { if (isCancel(error)) throw error failures.push(`${file.name}:${errorText(error)}`) } } }) await refreshTree() return { uploaded, failures } } export async function cleanupImages(paths: string[]) { const version = user.getSessionVersion() await flushAll() user.assertSession(version) if (!readyToReload()) throw new Error(translate('workspace.finish_saving_pasting_uploading_or_e67d3d')) const result = await withOp(() => cleanupImagesApi({ paths }).then(({ data }) => data)) await refreshTree() await refreshMetadata() return result }