Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/app-core/src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1679,6 +1697,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
[
openEditorContextMenu,
paneId,
rememberCurrentTabScroll,
schedulePreviewSyncFromEditorViewport,
scheduleSelectionCommentAction,
setActiveCommentId,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
75 changes: 64 additions & 11 deletions packages/app-core/src/components/ExcalidrawView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,36 @@ import '@excalidraw/excalidraw/index.css'
import { parseExcalidrawDocument } from '@shared/excalidraw'

type InitialData = ComponentProps<typeof Excalidraw>['initialData']
type ExcalidrawProps = ComponentProps<typeof Excalidraw>
type OnChange = NonNullable<ExcalidrawProps['onChange']>
type SceneElements = Parameters<OnChange>[0]
type AppState = Parameters<OnChange>[1]
type BinaryFiles = Parameters<OnChange>[2]

interface LatestScene {
elements: SceneElements
appState: AppState
files: BinaryFiles
}

type ViewportState = Pick<AppState, 'scrollX' | 'scrollY' | 'zoom'>

const VIEWPORT_MEMORY_LIMIT = 60
const viewportMemory = new Map<string, ViewportState>()

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' &&
Expand All @@ -22,9 +52,32 @@ export function ExcalidrawView({ path }: { path: string }): JSX.Element {
const [initialData, setInitialData] = useState<InitialData | undefined>(undefined)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastSaved = useRef<string>('')
const latestScene = useRef<LatestScene | null>(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
// `<html data-theme-mode>`, maintained in App.tsx (it already accounts for
// built-in themes, custom themes, and auto/system) — custom theme ids aren't
Expand All @@ -50,24 +103,29 @@ 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)
})
.catch(() => {
if (!cancelled) setInitialData({} as InitialData)
})
return () => {
flushPendingSave(path)
cancelled = true
}
}, [path])

useEffect(
() => () => {
if (saveTimer.current) clearTimeout(saveTimer.current)
flushPendingSave()
},
[]
)
Expand All @@ -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)
}}
/>
Expand Down
64 changes: 58 additions & 6 deletions packages/app-core/src/components/VimNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -477,7 +516,7 @@ export function VimNav(): JSX.Element | null {
getKeymapBinding(overrides, 'vim.bufferPrevious'),
previousBufferPending,
previousBufferTimer,
() => navigateActiveBuffer(useStore.getState(), -1),
() => navigateBuffer(-1),
consumeBufferKey
)
) {
Expand All @@ -489,7 +528,7 @@ export function VimNav(): JSX.Element | null {
getKeymapBinding(overrides, 'vim.bufferNext'),
nextBufferPending,
nextBufferTimer,
() => navigateActiveBuffer(useStore.getState(), 1),
() => navigateBuffer(1),
consumeBufferKey
)
) {
Expand All @@ -503,29 +542,42 @@ 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]) {
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.
}
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
}
}
}

Expand Down
Loading