From 661482f76bda46ee9e6e0ca8d0e19e3e501f3f8b Mon Sep 17 00:00:00 2001 From: Hilton Vusi Kholokholo <68604335+Damox101za@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:35:05 +0200 Subject: [PATCH] Sync editor and preview scrolling, with optional snap on click Scrolling the editor or the preview now moves the other to the same relative position, and the preview keeps its place when it re-renders after an edit. Clicking a line in the editor glides the preview to the matching block and briefly highlights it; this can be turned off from View > Snap Preview on Click and the choice is remembered. The preview iframe stays sandboxed: the two sides talk through postMessage via a small injected script, and the preview only accepts messages from the editor window. The logic lives in lib/preview-sync.mjs with Node tests. Co-Authored-By: Claude Opus 5.5 --- frontend/src/app/page.tsx | 7 ++ frontend/src/components/PreviewPane.tsx | 90 ++++++++++++++- frontend/src/hooks/editor/useActions.ts | 15 ++- .../src/hooks/editor/useEditorCommands.ts | 6 + frontend/src/hooks/usePreviewSnapSetting.ts | 38 ++++++ frontend/src/lib/keyboardShortcuts.ts | 3 +- frontend/src/lib/preview-sync.mjs | 108 ++++++++++++++++++ frontend/tests/preview-sync.test.mjs | 89 +++++++++++++++ 8 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 frontend/src/hooks/usePreviewSnapSetting.ts create mode 100644 frontend/src/lib/preview-sync.mjs create mode 100644 frontend/tests/preview-sync.test.mjs diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ed846b0e..d08eb805 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -10,6 +10,7 @@ import { usePanels } from "@/hooks/usePanels"; import { useTauriBackend } from "@/hooks/useTauriBackend"; import { useEditorCommands } from "@/hooks/editor/useEditorCommands"; import { useEditorWorkspace } from "@/hooks/editor/useEditorWorkspace"; +import { usePreviewSnapSetting } from "@/hooks/usePreviewSnapSetting"; import TabBar from "@/components/TabBar"; import Toolbar from "@/components/Toolbar"; import MenuBar from "@/components/MenuBar"; @@ -38,6 +39,7 @@ export default function HomePage() { reset: resetEditor, } = editorWorkspace; const [focusMode, setFocusMode] = useState(false); + const { snapOnClick, toggleSnapOnClick } = usePreviewSnapSetting(); const { activePanel, togglePanel, hidePanel, setShowAIPanel } = usePanels(); // Focus Mode undo/redo handle (issue #341) — published by FocusModePane. const focusEditorRef = useRef(null); @@ -92,6 +94,8 @@ export default function HomePage() { onShowPreview: () => selectPanel(PANELS.PREVIEW), executeAIPrompt, syncSelectedText, + snapOnClick, + onToggleSnapOnClick: toggleSnapOnClick, }); const handleTabSelect = useCallback((id: string) => { editor.setActiveTabId(id); @@ -170,6 +174,9 @@ export default function HomePage() { [PANELS.PREVIEW]: ( ; + /** Changes whenever a new editor instance mounts, so listeners re-attach. */ + editorKey?: string | number | boolean; + /** When true, clicking in the editor glides the preview to that spot. */ + snapOnClick?: boolean; }; -export default function PreviewPane({ html, loading = false, error = null }: Props) { +function editorRatio(ed: MonacoEditor.IStandaloneCodeEditor): number { + return scrollRatio(ed.getScrollTop(), ed.getScrollHeight(), ed.getLayoutInfo().height); +} + +export default function PreviewPane({ html, loading = false, error = null, editorRef, editorKey, snapOnClick = true }: Props) { + const iframeRef = useRef(null); + const ignoreEditorUntil = useRef(0); + const placeholder = error ? `

Backend unavailable.

` : loading ? "

Starting backend...

" : "

Preview will appear here…

"; + const srcDoc = useMemo( + () => (html && editorRef ? withSyncScript(html) : html || placeholder), + [html, editorRef, placeholder], + ); + + const postToPreview = useCallback((message: Record) => { + iframeRef.current?.contentWindow?.postMessage(message, "*"); + }, []); + + // Editor -> preview + useEffect(() => { + const ed = editorRef?.current; + if (!ed) return; + const sub = ed.onDidScrollChange((e) => { + if (!e.scrollTopChanged || Date.now() < ignoreEditorUntil.current) return; + postToPreview({ type: MSG_TO_PREVIEW, ratio: editorRatio(ed) }); + }); + return () => sub.dispose(); + }, [editorRef, editorKey, postToPreview]); + + // Click in editor -> preview glides to that spot + useEffect(() => { + const ed = editorRef?.current; + if (!ed || !snapOnClick) return; + const sub = ed.onDidChangeCursorPosition((e) => { + if (e.source !== "mouse") return; + const model = ed.getModel(); + if (!model) return; + const { heading, fraction } = locateLine(model.getLinesContent(), e.position.lineNumber); + postToPreview({ type: MSG_SNAP, heading, fraction }); + }); + return () => sub.dispose(); + }, [editorRef, editorKey, snapOnClick, postToPreview]); + + // Preview -> editor, and restore position after each re-render + useEffect(() => { + if (!editorRef) return; + const onMessage = (e: MessageEvent) => { + if (e.source !== iframeRef.current?.contentWindow) return; + const ed = editorRef.current; + if (!ed || !e.data) return; + if (e.data.type === MSG_PREVIEW_READY) { + // The iframe reloads on every edit; put it back where the editor is. + postToPreview({ type: MSG_TO_PREVIEW, ratio: editorRatio(ed) }); + } else if (e.data.type === MSG_FROM_PREVIEW && typeof e.data.ratio === "number") { + const max = ed.getScrollHeight() - ed.getLayoutInfo().height; + ignoreEditorUntil.current = Date.now() + ECHO_GUARD_MS; + ed.setScrollTop(e.data.ratio * Math.max(0, max)); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [editorRef, postToPreview]); + return (