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 (