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 (
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