From 4068844e5879d5da68ee3073026f595e6e2e203c Mon Sep 17 00:00:00 2001 From: Junerey Date: Tue, 14 Jul 2026 21:14:32 +0700 Subject: [PATCH 1/3] ui: preserve tab position across Excalidraw switches --- .../app-core/src/components/EditorPane.tsx | 28 +++++++ .../src/components/ExcalidrawView.tsx | 75 ++++++++++++++++--- 2 files changed, 92 insertions(+), 11 deletions(-) diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 079ca029..36d7a2b2 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -886,6 +886,23 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { return true }, [paneId, setActivePane, setFocusedPanel]) + const rememberCurrentTabScroll = useCallback((path = viewPathRef.current): void => { + if (!path) return + const prev = recallTabScroll(path) + const view = viewRef.current + const previewEl = previewScrollRef.current + const next: TabScrollPosition = { + editor: view?.scrollDOM.scrollTop ?? prev?.editor ?? 0, + preview: previewEl?.scrollTop ?? prev?.preview ?? 0 + } + const selection = view && viewPathRef.current === path ? view.state.selection.main : null + if (selection) { + next.editorSelectionAnchor = selection.anchor + next.editorSelectionHead = selection.head + } + rememberTabScroll(path, next) + }, []) + const toggleConnectionsPanel = useCallback(() => { setConnectionsOpen((open) => { const next = !open @@ -1448,6 +1465,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { richMarkdownDeferredRef.current = false setSelectionCommentAction(null) const existingView = viewRef.current + rememberCurrentTabScroll() if ( existingView && useStore.getState().editorViewRef === existingView @@ -1679,6 +1697,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { [ openEditorContextMenu, paneId, + rememberCurrentTabScroll, schedulePreviewSyncFromEditorViewport, scheduleSelectionCommentAction, setActiveCommentId, @@ -3170,6 +3189,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const applyEditor = (): void => { const view = viewRef.current if (!view) return + let restoredHead: number | null = null if ( remembered.editorSelectionAnchor != null && remembered.editorSelectionHead != null @@ -3183,12 +3203,20 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { 0, Math.min(docLength, remembered.editorSelectionHead) ) + restoredHead = head const current = view.state.selection.main if (current.anchor !== anchor || current.head !== head) { view.dispatch({ selection: { anchor, head } }) } } view.scrollDOM.scrollTop = remembered.editor + if (remembered.editor <= 0 && restoredHead != null) { + const cursor = view.coordsAtPos(restoredHead) + const scroller = view.scrollDOM.getBoundingClientRect() + if (!cursor || cursor.bottom < scroller.top || cursor.top > scroller.bottom) { + view.dispatch({ effects: EditorView.scrollIntoView(restoredHead, { y: 'center' }) }) + } + } } applyEditor() const raf = requestAnimationFrame(applyEditor) diff --git a/packages/app-core/src/components/ExcalidrawView.tsx b/packages/app-core/src/components/ExcalidrawView.tsx index edb1734b..ac9d487a 100644 --- a/packages/app-core/src/components/ExcalidrawView.tsx +++ b/packages/app-core/src/components/ExcalidrawView.tsx @@ -5,6 +5,36 @@ import '@excalidraw/excalidraw/index.css' import { parseExcalidrawDocument } from '@shared/excalidraw' type InitialData = ComponentProps['initialData'] +type ExcalidrawProps = ComponentProps +type OnChange = NonNullable +type SceneElements = Parameters[0] +type AppState = Parameters[1] +type BinaryFiles = Parameters[2] + +interface LatestScene { + elements: SceneElements + appState: AppState + files: BinaryFiles +} + +type ViewportState = Pick + +const VIEWPORT_MEMORY_LIMIT = 60 +const viewportMemory = new Map() + +function rememberViewport(path: string, appState: AppState): void { + viewportMemory.delete(path) + viewportMemory.set(path, { + scrollX: appState.scrollX, + scrollY: appState.scrollY, + zoom: appState.zoom + }) + while (viewportMemory.size > VIEWPORT_MEMORY_LIMIT) { + const oldest = viewportMemory.keys().next().value + if (oldest === undefined) break + viewportMemory.delete(oldest) + } +} function readThemeMode(): 'light' | 'dark' { return typeof document !== 'undefined' && @@ -22,9 +52,32 @@ export function ExcalidrawView({ path }: { path: string }): JSX.Element { const [initialData, setInitialData] = useState(undefined) const saveTimer = useRef | null>(null) const lastSaved = useRef('') + const latestScene = useRef(null) const pathRef = useRef(path) pathRef.current = path + const writeLatestScene = (savePath: string): void => { + const scene = latestScene.current + if (!scene) return + let json: string + try { + json = serializeAsJSON(scene.elements, scene.appState, scene.files, 'local') + } catch { + return + } + if (json === lastSaved.current) return + lastSaved.current = json + void window.zen.writeNote(savePath, json) + } + + const flushPendingSave = (savePath = pathRef.current): void => { + if (saveTimer.current) { + clearTimeout(saveTimer.current) + saveTimer.current = null + } + writeLatestScene(savePath) + } + // Follow the app's resolved light/dark mode. That mode lives on // ``, maintained in App.tsx (it already accounts for // built-in themes, custom themes, and auto/system) — custom theme ids aren't @@ -50,10 +103,14 @@ export function ExcalidrawView({ path }: { path: string }): JSX.Element { .then((res) => { if (cancelled) return lastSaved.current = res?.body ?? '' + latestScene.current = null const doc = parseExcalidrawDocument(res?.body ?? '') + const rememberedViewport = viewportMemory.get(path) setInitialData({ elements: doc.elements, - appState: doc.appState, + appState: rememberedViewport + ? { ...doc.appState, ...rememberedViewport } + : doc.appState, files: doc.files } as InitialData) }) @@ -61,13 +118,14 @@ export function ExcalidrawView({ path }: { path: string }): JSX.Element { if (!cancelled) setInitialData({} as InitialData) }) return () => { + flushPendingSave(path) cancelled = true } }, [path]) useEffect( () => () => { - if (saveTimer.current) clearTimeout(saveTimer.current) + flushPendingSave() }, [] ) @@ -86,18 +144,13 @@ export function ExcalidrawView({ path }: { path: string }): JSX.Element { initialData={initialData} theme={excalidrawTheme} onChange={(elements, appState, files) => { + latestScene.current = { elements, appState, files } + rememberViewport(pathRef.current, appState) if (saveTimer.current) clearTimeout(saveTimer.current) saveTimer.current = setTimeout(() => { - let json: string - try { - json = serializeAsJSON(elements, appState, files, 'local') - } catch { - return - } + saveTimer.current = null // Skip no-op writes (Excalidraw fires onChange on load and on hover). - if (json === lastSaved.current) return - lastSaved.current = json - void window.zen.writeNote(pathRef.current, json) + writeLatestScene(pathRef.current) }, 700) }} /> From 4b9a0c3cf8b5117540ba3d41e7ccaba32c3ba30b Mon Sep 17 00:00:00 2001 From: Junerey Date: Tue, 14 Jul 2026 21:22:48 +0700 Subject: [PATCH 2/3] vim: fix gt/gT on Excalidraw tabs --- packages/app-core/src/components/VimNav.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index df3db1ef..fa152f4e 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -503,7 +503,11 @@ export function VimNav(): JSX.Element | null { const gTabTokens = getSequenceTokens(overrides, 'vim.tabNext') const gPrevTokens = getSequenceTokens(overrides, 'vim.tabPrevious') const gTok = sequenceTokenFromEvent(e) + const inExcalidrawView = !!target?.closest('[data-excalidraw-view]') if (gTabPending.current) { + // Shift is delivered as its own keydown before `T`; keep the pending + // `g` prefix alive so Excalidraw can complete Vim-style `gT`. + if (!gTok) return gTabPending.current = false if (gTabTimer.current) clearTimeout(gTabTimer.current) if (gTabTokens.length === 2 && gTok === gTabTokens[1]) { @@ -520,12 +524,21 @@ export function VimNav(): JSX.Element | null { } // Not a tab completion (e.g. gg, gd): fall through without consuming. } - if (gTok && gTabTokens.length === 2 && gTok === gTabTokens[0]) { + const startsTabSequence = + !!gTok && + ((gTabTokens.length === 2 && gTok === gTabTokens[0]) || + (gPrevTokens.length === 2 && gTok === gPrevTokens[0])) + if (startsTabSequence) { gTabPending.current = true if (gTabTimer.current) clearTimeout(gTabTimer.current) gTabTimer.current = setTimeout(() => { gTabPending.current = false }, 500) + if (inExcalidrawView) { + e.preventDefault() + e.stopImmediatePropagation() + return + } } } From c276b06616a37bb94f18d70eedb84c9ac5b2a03e Mon Sep 17 00:00:00 2001 From: Junerey Date: Tue, 14 Jul 2026 21:35:48 +0700 Subject: [PATCH 3/3] vim: focus editor after tab navigation --- packages/app-core/src/components/VimNav.tsx | 49 ++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index fa152f4e..10796f70 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -31,8 +31,14 @@ import { dispatchKeyboardContextMenu, findTabContextMenuTarget } from '../lib/keyboard-context-menu' -import { navigateActiveBuffer } from '../lib/buffer-navigation' +import { getBufferNavigationTarget } from '../lib/buffer-navigation' import { focusEditorNormalMode } from '../lib/editor-focus' +import { isWorkspaceVirtualTabPath } from '../lib/workspace-tabs' +import { + isExcalidrawPath, + isObsidianExcalidrawMarkdown, + isObsidianExcalidrawPath +} from '@shared/excalidraw' function escapeForAttr(value: string): string { if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(value) @@ -137,6 +143,39 @@ export function VimNav(): JSX.Element | null { }) }) }, []) + const navigateBuffer = useCallback((delta: 1 | -1): void => { + const focusIfCurrentNoteTab = (paneId: string, path: string): void => { + const latest = useStore.getState() + const leaf = findLeaf(latest.paneLayout, paneId) + if (latest.activePaneId !== paneId || leaf?.activeTab !== path) return + if (isWorkspaceVirtualTabPath(path)) return + if (isExcalidrawPath(path) || isObsidianExcalidrawPath(path)) return + if (isObsidianExcalidrawMarkdown(latest.noteContents[path]?.body)) return + focusEditorNormalMode() + } + const state = useStore.getState() + const target = getBufferNavigationTarget( + state.paneLayout, + state.activePaneId, + state.notes, + delta + ) + if (target.kind === 'focus') { + void state.focusTabInPane(target.paneId, target.path).then(() => { + focusIfCurrentNoteTab(target.paneId, target.path) + }) + return + } + if (target.kind === 'open') { + void state.openNoteInPane(target.paneId, target.path).then(() => { + focusIfCurrentNoteTab(target.paneId, target.path) + }) + return + } + if (target.kind === 'create-quick') { + void state.createAndOpen('quick', '', { focusTitle: true }) + } + }, []) const cancelHints = useCallback(() => { setHint(false) focusEditor() @@ -477,7 +516,7 @@ export function VimNav(): JSX.Element | null { getKeymapBinding(overrides, 'vim.bufferPrevious'), previousBufferPending, previousBufferTimer, - () => navigateActiveBuffer(useStore.getState(), -1), + () => navigateBuffer(-1), consumeBufferKey ) ) { @@ -489,7 +528,7 @@ export function VimNav(): JSX.Element | null { getKeymapBinding(overrides, 'vim.bufferNext'), nextBufferPending, nextBufferTimer, - () => navigateActiveBuffer(useStore.getState(), 1), + () => navigateBuffer(1), consumeBufferKey ) ) { @@ -513,13 +552,13 @@ export function VimNav(): JSX.Element | null { if (gTabTokens.length === 2 && gTok === gTabTokens[1]) { e.preventDefault() e.stopImmediatePropagation() - navigateActiveBuffer(useStore.getState(), 1) + navigateBuffer(1) return } if (gPrevTokens.length === 2 && gTok === gPrevTokens[1]) { e.preventDefault() e.stopImmediatePropagation() - navigateActiveBuffer(useStore.getState(), -1) + navigateBuffer(-1) return } // Not a tab completion (e.g. gg, gd): fall through without consuming.