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 (
diff --git a/frontend/src/hooks/editor/useActions.ts b/frontend/src/hooks/editor/useActions.ts
index 34c68e8e..06d0a0b6 100644
--- a/frontend/src/hooks/editor/useActions.ts
+++ b/frontend/src/hooks/editor/useActions.ts
@@ -35,6 +35,9 @@ export type UseActionsOptions = {
onOpenProject: () => void;
onFileSaved: () => void;
onShowPreview: () => void;
+ /** Whether clicking in the editor snaps the preview to that spot. */
+ snapOnClick?: boolean;
+ onToggleSnapOnClick?: () => void;
};
export function useActions({
@@ -49,6 +52,8 @@ export function useActions({
onOpenProject,
onFileSaved,
onShowPreview,
+ snapOnClick = true,
+ onToggleSnapOnClick,
}: UseActionsOptions) {
const { handleOpenFile, handleExport } = fileIO;
const {
@@ -125,6 +130,7 @@ export function useActions({
"view.fullEditor": () => setSplit(100),
"view.balancedSplit": () => setSplit(50),
"view.fullPreview": () => setSplit(0),
+ "view.toggleSnapOnClick": () => onToggleSnapOnClick?.(),
}),
[
applyHeading,
@@ -140,6 +146,7 @@ export function useActions({
setSplit,
onOpenProject,
onShowPreview,
+ onToggleSnapOnClick,
wrapSelection,
]
);
@@ -199,6 +206,12 @@ export function useActions({
{ id: "view.fullEditor", label: "Full Width Editor", onSelect: actions["view.fullEditor"] },
{ id: "view.balancedSplit", label: "Balanced Split View", onSelect: actions["view.balancedSplit"] },
{ id: "view.fullPreview", label: "Full Width Preview", onSelect: actions["view.fullPreview"] },
+ "separator",
+ {
+ id: "view.toggleSnapOnClick",
+ label: `Snap Preview on Click: ${snapOnClick ? "On" : "Off"}`,
+ onSelect: actions["view.toggleSnapOnClick"],
+ },
],
},
{
@@ -214,7 +227,7 @@ export function useActions({
})),
},
],
- [actions, backendDisabled, shortcuts]
+ [actions, backendDisabled, shortcuts, snapOnClick]
);
return {
diff --git a/frontend/src/hooks/editor/useEditorCommands.ts b/frontend/src/hooks/editor/useEditorCommands.ts
index 51793bec..a44097c1 100644
--- a/frontend/src/hooks/editor/useEditorCommands.ts
+++ b/frontend/src/hooks/editor/useEditorCommands.ts
@@ -28,6 +28,8 @@ export type UseEditorCommandsOptions = {
onShowPreview: () => void;
executeAIPrompt: (prompt: string, documentText: string) => Promise;
syncSelectedText: () => void;
+ snapOnClick?: boolean;
+ onToggleSnapOnClick?: () => void;
};
/**
@@ -49,6 +51,8 @@ export function useEditorCommands({
onShowPreview,
executeAIPrompt,
syncSelectedText,
+ snapOnClick,
+ onToggleSnapOnClick,
}: UseEditorCommandsOptions) {
// 1. Markdown formatting logic (bold, italic, headings, tables, citations)
const formatting = useEditorFormatting(monacoRef);
@@ -66,6 +70,8 @@ export function useEditorCommands({
onOpenProject,
onFileSaved,
onShowPreview,
+ snapOnClick,
+ onToggleSnapOnClick,
});
// 3. Register keyboard shortcuts
diff --git a/frontend/src/hooks/usePreviewSnapSetting.ts b/frontend/src/hooks/usePreviewSnapSetting.ts
new file mode 100644
index 00000000..e867514f
--- /dev/null
+++ b/frontend/src/hooks/usePreviewSnapSetting.ts
@@ -0,0 +1,38 @@
+"use client";
+
+/**
+ * usePreviewSnapSetting.ts
+ * ========================
+ * Whether clicking in the editor glides the preview to the clicked spot.
+ * On by default; the choice is remembered in localStorage.
+ */
+
+import { useCallback, useEffect, useState } from "react";
+
+const STORAGE_KEY = "markdown-reader.snapPreviewOnClick";
+
+export function usePreviewSnapSetting() {
+ const [snapOnClick, setSnapOnClick] = useState(true);
+
+ useEffect(() => {
+ try {
+ if (localStorage.getItem(STORAGE_KEY) === "false") setSnapOnClick(false);
+ } catch {
+ // Storage unavailable: keep the default.
+ }
+ }, []);
+
+ const toggleSnapOnClick = useCallback(() => {
+ setSnapOnClick((on) => {
+ const next = !on;
+ try {
+ localStorage.setItem(STORAGE_KEY, String(next));
+ } catch {
+ // Storage unavailable: the setting just won't persist.
+ }
+ return next;
+ });
+ }, []);
+
+ return { snapOnClick, toggleSnapOnClick };
+}
diff --git a/frontend/src/lib/keyboardShortcuts.ts b/frontend/src/lib/keyboardShortcuts.ts
index f2337d8e..c26313c7 100644
--- a/frontend/src/lib/keyboardShortcuts.ts
+++ b/frontend/src/lib/keyboardShortcuts.ts
@@ -28,7 +28,8 @@ export type ActionId =
| "view.openBrowserPreview"
| "view.fullEditor"
| "view.balancedSplit"
- | "view.fullPreview";
+ | "view.fullPreview"
+ | "view.toggleSnapOnClick";
export type ShortcutBinding = {
key: string;
diff --git a/frontend/src/lib/preview-sync.mjs b/frontend/src/lib/preview-sync.mjs
new file mode 100644
index 00000000..e1cc636c
--- /dev/null
+++ b/frontend/src/lib/preview-sync.mjs
@@ -0,0 +1,108 @@
+// preview-sync.mjs
+// Scroll sync between the Monaco editor and the sandboxed preview iframe.
+//
+// The preview iframe has no same-origin access, so the two sides talk through
+// postMessage. A small script injected into the preview HTML handles the
+// iframe side. Plain .mjs (not .ts) so the Node test suite can import it.
+
+export const MSG_TO_PREVIEW = "mdr-scroll";
+export const MSG_FROM_PREVIEW = "mdr-preview-scroll";
+export const MSG_PREVIEW_READY = "mdr-preview-ready";
+export const MSG_SNAP = "mdr-snap";
+// After one side scrolls the other, ignore the echo for this long.
+export const ECHO_GUARD_MS = 150;
+// Long enough to cover the preview's smooth-scroll animation.
+export const SNAP_GUARD_MS = 900;
+
+const HEADING_RE = /^#{1,6}\s/;
+const FENCE_RE = /^\s*(```|~~~)/;
+
+/**
+ * Locate a source line relative to the Markdown headings around it.
+ * Returns the index of the last heading at or above the line (-1 if none) and
+ * how far the line sits between that heading and the next one (0..1).
+ * Headings inside fenced code blocks are ignored, matching the rendered HTML.
+ *
+ * @param {string[]} lines document lines
+ * @param {number} lineNumber 1-based line number
+ * @returns {{ heading: number, fraction: number }}
+ */
+export function locateLine(lines, lineNumber) {
+ const headingLines = [];
+ let inFence = false;
+ lines.forEach((text, i) => {
+ if (FENCE_RE.test(text)) inFence = !inFence;
+ else if (!inFence && HEADING_RE.test(text)) headingLines.push(i + 1);
+ });
+ let heading = -1;
+ while (heading + 1 < headingLines.length && headingLines[heading + 1] <= lineNumber) heading++;
+ const start = heading >= 0 ? headingLines[heading] : 1;
+ const end = headingLines[heading + 1] ?? lines.length + 1;
+ const fraction = end > start ? Math.min(1, Math.max(0, (lineNumber - start) / (end - start))) : 0;
+ return { heading, fraction };
+}
+
+/** Script that runs inside the preview iframe. */
+export const SYNC_SCRIPT = ``;
+
+/**
+ * Insert the sync script into preview HTML, before
x