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:
@@ -142,10 +142,32 @@ export function EditorView() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Jump target effect — reactive (no polling)
|
// Resize observer keeps Monaco laid out when the flex container changes
|
||||||
const jumpHandledRef = useRef(false);
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!state.jumpTarget || !editorRef.current || !monaco || !model) return;
|
if (!state.jumpTarget || !editorRef.current || !monaco || !model) return;
|
||||||
|
if (state.jumpTarget === lastHandledTargetRef.current) return;
|
||||||
|
|
||||||
const target = state.jumpTarget;
|
const target = state.jumpTarget;
|
||||||
const modelUri = model.uri.toString();
|
const modelUri = model.uri.toString();
|
||||||
@@ -155,8 +177,7 @@ export function EditorView() {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!expectedModelUri || modelUri !== expectedModelUri) return;
|
if (!expectedModelUri || modelUri !== expectedModelUri) return;
|
||||||
if (jumpHandledRef.current) return;
|
lastHandledTargetRef.current = target;
|
||||||
jumpHandledRef.current = true;
|
|
||||||
|
|
||||||
let selection: Monaco["Selection"] | null = null;
|
let selection: Monaco["Selection"] | null = null;
|
||||||
if (target.start && target.end) {
|
if (target.start && target.end) {
|
||||||
@@ -176,6 +197,7 @@ export function EditorView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selection) {
|
if (selection) {
|
||||||
|
editorRef.current.layout();
|
||||||
editorRef.current.setSelection(selection);
|
editorRef.current.setSelection(selection);
|
||||||
editorRef.current.revealRangeInCenter(selection);
|
editorRef.current.revealRangeInCenter(selection);
|
||||||
editorRef.current.focus();
|
editorRef.current.focus();
|
||||||
@@ -184,11 +206,6 @@ export function EditorView() {
|
|||||||
dispatch({ type: "clearJumpTarget" });
|
dispatch({ type: "clearJumpTarget" });
|
||||||
}, [state.jumpTarget, model, monaco, uri]);
|
}, [state.jumpTarget, model, monaco, uri]);
|
||||||
|
|
||||||
// Reset jumpHandledRef when a new jump target arrives
|
|
||||||
if (state.jumpTarget === null) {
|
|
||||||
jumpHandledRef.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
|
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
|
||||||
@@ -240,7 +257,7 @@ export function EditorView() {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0">
|
<div ref={editorContainerRef} className="flex-1 min-h-0">
|
||||||
<Editor
|
<Editor
|
||||||
defaultLanguage="typst"
|
defaultLanguage="typst"
|
||||||
theme="vs"
|
theme="vs"
|
||||||
|
|||||||
@@ -500,8 +500,16 @@ export async function startLsp(
|
|||||||
// SyncTeX: when user clicks in the preview, tinymist emits this notification
|
// SyncTeX: when user clicks in the preview, tinymist emits this notification
|
||||||
// with the source file and position. We forward it to the jump handler.
|
// with the source file and position. We forward it to the jump handler.
|
||||||
client.onNotification("tinymist/preview/scrollSource", (params) => {
|
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 (!onJumpToSource) return;
|
||||||
|
if (typeof p.filepath !== "string") {
|
||||||
|
console.warn("[lsp] tinymist/preview/scrollSource missing filepath", params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const filepath = p.filepath.includes("://")
|
const filepath = p.filepath.includes("://")
|
||||||
? decodeURIComponent(new URL(p.filepath).pathname)
|
? decodeURIComponent(new URL(p.filepath).pathname)
|
||||||
: p.filepath.replaceAll("\\", "/");
|
: p.filepath.replaceAll("\\", "/");
|
||||||
|
|||||||
Reference in New Issue
Block a user