fix: stabilize preview-to-editor SyncTeX jumps

- Replace jumpHandledRef boolean with lastHandledTargetRef so rapid
  preview clicks are tracked per-target and not swallowed by a stale
  global flag.
- Wrap the Monaco editor in a ResizeObserver container and call
  editor.layout() before revealRangeInCenter so container resizes
  (sidebar, window) don't break scroll-to-target.
- Harden tinymist/preview/scrollSource handler against missing or
  non-string filepath payloads.
This commit is contained in:
2026-07-02 00:46:29 +05:30
parent bfb4d8bef8
commit 7618476b60
2 changed files with 36 additions and 11 deletions
+27 -10
View File
@@ -142,10 +142,32 @@ export function EditorView() {
);
};
// Jump target effect — reactive (no polling)
const jumpHandledRef = useRef(false);
// Resize observer keeps Monaco laid out when the flex container changes
// size (sidebar toggles, window resize, etc.). Without this, revealRange
// and cursor positioning can be off after a layout change.
const editorContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!editorReady || !editorContainerRef.current || !editorRef.current) {
return;
}
const editor = editorRef.current;
const ro = new ResizeObserver(() => editor.layout());
ro.observe(editorContainerRef.current);
return () => ro.disconnect();
}, [editorReady]);
// Jump target effect — reactive (no polling). Track the last handled
// target by object identity so rapid clicks don't get swallowed by a stale
// boolean flag, and call editor.layout() before reveal so container
// resizes don't break the scroll.
const lastHandledTargetRef = useRef<{
file: string;
start?: [number, number];
end?: [number, number];
} | null>(null);
useEffect(() => {
if (!state.jumpTarget || !editorRef.current || !monaco || !model) return;
if (state.jumpTarget === lastHandledTargetRef.current) return;
const target = state.jumpTarget;
const modelUri = model.uri.toString();
@@ -155,8 +177,7 @@ export function EditorView() {
: null;
if (!expectedModelUri || modelUri !== expectedModelUri) return;
if (jumpHandledRef.current) return;
jumpHandledRef.current = true;
lastHandledTargetRef.current = target;
let selection: Monaco["Selection"] | null = null;
if (target.start && target.end) {
@@ -176,6 +197,7 @@ export function EditorView() {
}
if (selection) {
editorRef.current.layout();
editorRef.current.setSelection(selection);
editorRef.current.revealRangeInCenter(selection);
editorRef.current.focus();
@@ -184,11 +206,6 @@ export function EditorView() {
dispatch({ type: "clearJumpTarget" });
}, [state.jumpTarget, model, monaco, uri]);
// Reset jumpHandledRef when a new jump target arrives
if (state.jumpTarget === null) {
jumpHandledRef.current = false;
}
if (!project) {
return (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
@@ -240,7 +257,7 @@ export function EditorView() {
</button>
</span>
</div>
<div className="flex-1 min-h-0">
<div ref={editorContainerRef} className="flex-1 min-h-0">
<Editor
defaultLanguage="typst"
theme="vs"
+9 -1
View File
@@ -500,8 +500,16 @@ export async function startLsp(
// SyncTeX: when user clicks in the preview, tinymist emits this notification
// with the source file and position. We forward it to the jump handler.
client.onNotification("tinymist/preview/scrollSource", (params) => {
const p = params as { filepath: string; start?: [number, number]; end?: [number, number] };
const p = params as {
filepath?: string;
start?: [number, number];
end?: [number, number];
};
if (!onJumpToSource) return;
if (typeof p.filepath !== "string") {
console.warn("[lsp] tinymist/preview/scrollSource missing filepath", params);
return;
}
const filepath = p.filepath.includes("://")
? decodeURIComponent(new URL(p.filepath).pathname)
: p.filepath.replaceAll("\\", "/");