Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<FocusEditorHandle | null>(null);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -170,6 +174,9 @@ export default function HomePage() {
[PANELS.PREVIEW]: (
<PreviewPane
html={editor.previewHtml}
editorRef={monacoRef}
editorKey={`${revision}-${monacoReady}`}
snapOnClick={snapOnClick}
loading={
showPackagedBackendStatus && backendStatus === "starting"
}
Expand Down
90 changes: 88 additions & 2 deletions frontend/src/components/PreviewPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,112 @@
* PreviewPane.tsx
* ===============
* Rendered Markdown HTML preview in an isolated iframe.
*
* Synced scrolling: when an editor instance is passed in, scrolling either
* side scrolls the other to the same relative position, and (optionally)
* clicking in the editor glides the preview to the clicked spot. The sync
* logic lives in lib/preview-sync.mjs so the Node test suite can cover it.
*/

import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import type { editor as MonacoEditor } from "monaco-editor";
import {
ECHO_GUARD_MS,
MSG_FROM_PREVIEW,
MSG_PREVIEW_READY,
MSG_SNAP,
MSG_TO_PREVIEW,
locateLine,
scrollRatio,
withSyncScript,
} from "@/lib/preview-sync.mjs";

type Props = {
html: string;
loading?: boolean;
error?: string | null;
/** Editor to keep in sync with the preview scroll position. */
editorRef?: MutableRefObject<MonacoEditor.IStandaloneCodeEditor | null>;
/** 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<HTMLIFrameElement>(null);
const ignoreEditorUntil = useRef(0);

const placeholder = error
? `<p style='color:#b91c1c;padding:24px;font-family:sans-serif'>Backend unavailable.</p>`
: loading
? "<p style='color:#999;padding:24px;font-family:sans-serif'>Starting backend...</p>"
: "<p style='color:#999;padding:24px;font-family:sans-serif'>Preview will appear here…</p>";

const srcDoc = useMemo(
() => (html && editorRef ? withSyncScript(html) : html || placeholder),
[html, editorRef, placeholder],
);

const postToPreview = useCallback((message: Record<string, unknown>) => {
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 (
<div className="flex-1 overflow-hidden h-full bg-white dark:bg-[#1e1e1e] border-l border-gray-200 dark:border-gray-700">
<iframe
ref={iframeRef}
title="Markdown Preview"
srcDoc={html || placeholder}
srcDoc={srcDoc}
className="w-full h-full border-none"
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
/>
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/hooks/editor/useActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -49,6 +52,8 @@ export function useActions({
onOpenProject,
onFileSaved,
onShowPreview,
snapOnClick = true,
onToggleSnapOnClick,
}: UseActionsOptions) {
const { handleOpenFile, handleExport } = fileIO;
const {
Expand Down Expand Up @@ -125,6 +130,7 @@ export function useActions({
"view.fullEditor": () => setSplit(100),
"view.balancedSplit": () => setSplit(50),
"view.fullPreview": () => setSplit(0),
"view.toggleSnapOnClick": () => onToggleSnapOnClick?.(),
}),
[
applyHeading,
Expand All @@ -140,6 +146,7 @@ export function useActions({
setSplit,
onOpenProject,
onShowPreview,
onToggleSnapOnClick,
wrapSelection,
]
);
Expand Down Expand Up @@ -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"],
},
],
},
{
Expand All @@ -214,7 +227,7 @@ export function useActions({
})),
},
],
[actions, backendDisabled, shortcuts]
[actions, backendDisabled, shortcuts, snapOnClick]
);

return {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/hooks/editor/useEditorCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export type UseEditorCommandsOptions = {
onShowPreview: () => void;
executeAIPrompt: (prompt: string, documentText: string) => Promise<string | null | undefined>;
syncSelectedText: () => void;
snapOnClick?: boolean;
onToggleSnapOnClick?: () => void;
};

/**
Expand All @@ -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);
Expand All @@ -66,6 +70,8 @@ export function useEditorCommands({
onOpenProject,
onFileSaved,
onShowPreview,
snapOnClick,
onToggleSnapOnClick,
});

// 3. Register keyboard shortcuts
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/hooks/usePreviewSnapSetting.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
3 changes: 2 additions & 1 deletion frontend/src/lib/keyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export type ActionId =
| "view.openBrowserPreview"
| "view.fullEditor"
| "view.balancedSplit"
| "view.fullPreview";
| "view.fullPreview"
| "view.toggleSnapOnClick";

export type ShortcutBinding = {
key: string;
Expand Down
108 changes: 108 additions & 0 deletions frontend/src/lib/preview-sync.mjs
Original file line number Diff line number Diff line change
@@ -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 = `<script>(function(){
var ignoreUntil=0,snapUntil=0;
function el(){return document.scrollingElement||document.documentElement;}
function maxScroll(){return Math.max(0,el().scrollHeight-window.innerHeight);}
function pageTop(n){return n.getBoundingClientRect().top+el().scrollTop;}
function snap(m){
var hs=document.body.querySelectorAll("h1,h2,h3,h4,h5,h6");
var docEnd=el().scrollHeight;
var start=m.heading>=0&&hs[m.heading]?pageTop(hs[m.heading]):0;
var next=hs[m.heading+1]?pageTop(hs[m.heading+1]):docEnd;
var y=start+m.fraction*Math.max(0,next-start);
var blocks=document.body.querySelectorAll("h1,h2,h3,h4,h5,h6,p,li,pre,blockquote,table,img,hr");
var target=null,best=Infinity;
for(var i=0;i<blocks.length;i++){var d=Math.abs(pageTop(blocks[i])-y);if(d<best){best=d;target=blocks[i];}}
if(target)y=pageTop(target);
ignoreUntil=snapUntil=Date.now()+${SNAP_GUARD_MS};
window.scrollTo({top:Math.max(0,y-window.innerHeight*0.3),behavior:"smooth"});
if(target){
var prev=target.style.backgroundColor,prevT=target.style.transition;
target.style.transition="background-color 0.6s ease";
target.style.backgroundColor="rgba(59,130,246,0.22)";
setTimeout(function(){target.style.backgroundColor=prev;setTimeout(function(){target.style.transition=prevT;},600);},500);
}
}
window.addEventListener("message",function(e){
if(e.source!==parent)return;
var m=e.data;
if(!m)return;
if(m.type==="${MSG_SNAP}"){snap(m);return;}
// A click can nudge the editor's scroll; don't let that cancel the glide.
if(m.type!=="${MSG_TO_PREVIEW}"||Date.now()<snapUntil)return;
ignoreUntil=Date.now()+${ECHO_GUARD_MS};
el().scrollTop=m.ratio*maxScroll();
});
window.addEventListener("scroll",function(){
if(Date.now()<ignoreUntil)return;
var max=maxScroll();
parent.postMessage({type:"${MSG_FROM_PREVIEW}",ratio:max?el().scrollTop/max:0},"*");
},{passive:true});
parent.postMessage({type:"${MSG_PREVIEW_READY}"},"*");
})();</script>`;

/**
* Insert the sync script into preview HTML, before </body> when present.
* @param {string} html
* @returns {string}
*/
export function withSyncScript(html) {
const closeBody = html.lastIndexOf("</body>");
if (closeBody === -1) return html + SYNC_SCRIPT;
return html.slice(0, closeBody) + SYNC_SCRIPT + html.slice(closeBody);
}

/**
* Scroll position as a 0..1 fraction of the scrollable range.
* @param {number} scrollTop
* @param {number} scrollHeight
* @param {number} viewportHeight
*/
export function scrollRatio(scrollTop, scrollHeight, viewportHeight) {
const max = scrollHeight - viewportHeight;
return max > 0 ? Math.min(1, Math.max(0, scrollTop / max)) : 0;
}
Loading
Loading