diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 38bc4744..c595688f 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -3,6 +3,7 @@ import type { ProjectEntry, FileEntry } from './types/project'; import ProjectSelector from './components/ProjectSelector'; import Editor from './components/Editor'; import Toast from './components/Toast'; +import { ViewModeProvider } from './components/ViewModeContext'; import { connect, disconnect, @@ -395,17 +396,19 @@ function App() { onClearPendingShare={handleClearPendingShare} /> ) : ( - { - navigateToFile(project.id, filePath, options); - }} - /> + + { + navigateToFile(project.id, filePath, options); + }} + /> + )} { if (route.type === 'file' && route.filePath) { @@ -152,6 +158,9 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC // Share dialog state const [showShareDialog, setShowShareDialog] = useState(false); + // Preview scroll function for external control (from MarkdownSummary) + const previewScrollToLineRef = useRef<((line: number) => void) | null>(null); + // Editor drag-drop state for image insertion const [isEditorDragOver, setIsEditorDragOver] = useState(false); const pendingDropPositionRef = useRef(null); @@ -646,7 +655,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC )} -
+
{(activeTab) => { switch (activeTab) { @@ -705,37 +714,59 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC }}
- + {/* Show MarkdownSummary overlay in preview mode */} + {viewMode === 'preview' && ( +
+ { + if (previewScrollToLineRef.current) { + previewScrollToLineRef.current(lineNumber); + } + }} + /> +
+ )} + {/* Always render Monaco but hide in preview mode */} +
+ +
+ + {/* Divider with view toggle control */} +
+ +
+ { previewScrollToLineRef.current = fn; }} />
diff --git a/hub-client/src/components/MarkdownSummary.css b/hub-client/src/components/MarkdownSummary.css new file mode 100644 index 00000000..115e1b6e --- /dev/null +++ b/hub-client/src/components/MarkdownSummary.css @@ -0,0 +1,41 @@ +.markdown-summary { + height: 100%; + overflow-y: auto; + overflow-x: hidden; + background: #1e1e1e; + cursor: pointer; +} + +.markdown-summary-content { + margin: 0; + padding: 8px; + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-size: 6px; + line-height: 1.4; + color: #d4d4d4; + white-space: pre-wrap; + word-wrap: break-word; +} + +/* Highlight headers */ +.markdown-summary-content::before { + content: ''; +} + +/* Scrollbar styling */ +.markdown-summary::-webkit-scrollbar { + width: 6px; +} + +.markdown-summary::-webkit-scrollbar-track { + background: #1e1e1e; +} + +.markdown-summary::-webkit-scrollbar-thumb { + background: #4a4a4a; + border-radius: 3px; +} + +.markdown-summary::-webkit-scrollbar-thumb:hover { + background: #5a5a5a; +} diff --git a/hub-client/src/components/MarkdownSummary.tsx b/hub-client/src/components/MarkdownSummary.tsx new file mode 100644 index 00000000..fc4c5bf6 --- /dev/null +++ b/hub-client/src/components/MarkdownSummary.tsx @@ -0,0 +1,36 @@ +import { useRef, useCallback } from 'react'; +import './MarkdownSummary.css'; + +interface MarkdownSummaryProps { + /** The markdown content to display */ + content: string; + /** Callback when user clicks to navigate */ + onLineClick?: (lineNumber: number) => void; +} + +/** + * A simplified, zoomed-out view of markdown content. + * Displays the text at a small scale for use as a navigation minimap. + */ +export default function MarkdownSummary({ content, onLineClick }: MarkdownSummaryProps) { + const containerRef = useRef(null); + + const handleClick = useCallback((e: React.MouseEvent) => { + if (!containerRef.current || !onLineClick) return; + + const container = containerRef.current; + const rect = container.getBoundingClientRect(); + const clickY = e.clientY - rect.top + container.scrollTop; + + // Estimate line number from click position (assuming ~12px line height at scale) + const lineHeight = 12; + const lineNumber = Math.floor(clickY / lineHeight) + 1; + onLineClick(lineNumber); + }, [onLineClick]); + + return ( +
+
{content}
+
+ ); +} diff --git a/hub-client/src/components/Preview.tsx b/hub-client/src/components/Preview.tsx index 04cdaf3d..19c1b21d 100644 --- a/hub-client/src/components/Preview.tsx +++ b/hub-client/src/components/Preview.tsx @@ -35,6 +35,8 @@ interface PreviewProps { onOpenNewFileDialog: (initialFilename: string) => void; onDiagnosticsChange: (diagnostics: Diagnostic[]) => void; onWasmStatusChange?: (status: 'loading' | 'ready' | 'error', error: string | null) => void; + /** Callback to register scrollToLine function for external use */ + onRegisterScrollToLine?: (fn: (line: number) => void) => void; } // Fallback for when WASM isn't ready yet @@ -271,6 +273,7 @@ export default function Preview({ onOpenNewFileDialog, onDiagnosticsChange, onWasmStatusChange, + onRegisterScrollToLine, }: PreviewProps) { const [wasmStatus, setWasmStatus] = useState<'loading' | 'ready' | 'error'>('loading'); const [wasmError, setWasmError] = useState(null); @@ -294,6 +297,15 @@ export default function Preview({ // Ref to MorphIframe to access its imperative methods const doubleBufferedIframeRef = useRef(null); + // Register scrollToLine function with parent for external control + useEffect(() => { + if (onRegisterScrollToLine) { + onRegisterScrollToLine((line: number) => { + doubleBufferedIframeRef.current?.scrollToLine(line); + }); + } + }, [onRegisterScrollToLine]); + // Rendered HTML to display in iframe const [renderedHtml, setRenderedHtml] = useState(''); diff --git a/hub-client/src/components/ViewModeContext.tsx b/hub-client/src/components/ViewModeContext.tsx new file mode 100644 index 00000000..896698f7 --- /dev/null +++ b/hub-client/src/components/ViewModeContext.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'; + +export type ViewMode = 'both' | 'markup' | 'preview'; + +interface ViewModeContextType { + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; + /** Navigate left: preview -> both -> markup */ + goLeft: () => void; + /** Navigate right: markup -> both -> preview */ + goRight: () => void; +} + +const ViewModeContext = createContext(null); + +const STORAGE_KEY = 'qh-view-mode'; + +export function ViewModeProvider({ children }: { children: ReactNode }) { + const [viewMode, setViewModeState] = useState(() => { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved === 'markup' || saved === 'preview' || saved === 'both') { + return saved; + } + return 'both'; + }); + + // Persist to localStorage + useEffect(() => { + localStorage.setItem(STORAGE_KEY, viewMode); + }, [viewMode]); + + const setViewMode = useCallback((mode: ViewMode) => { + setViewModeState(mode); + }, []); + + const goLeft = useCallback(() => { + setViewModeState((current) => { + switch (current) { + case 'preview': return 'both'; + case 'both': return 'markup'; + case 'markup': return 'markup'; // Already at leftmost + } + }); + }, []); + + const goRight = useCallback(() => { + setViewModeState((current) => { + switch (current) { + case 'markup': return 'both'; + case 'both': return 'preview'; + case 'preview': return 'preview'; // Already at rightmost + } + }); + }, []); + + return ( + + {children} + + ); +} + +export function useViewMode(): ViewModeContextType { + const context = useContext(ViewModeContext); + if (!context) { + throw new Error('useViewMode must be used within a ViewModeProvider'); + } + return context; +} diff --git a/hub-client/src/components/ViewToggleControl.css b/hub-client/src/components/ViewToggleControl.css new file mode 100644 index 00000000..831d5851 --- /dev/null +++ b/hub-client/src/components/ViewToggleControl.css @@ -0,0 +1,76 @@ +.view-toggle-control { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 100; + display: flex; + flex-direction: row; + align-items: center; + gap: 2px; + background: #1a1a2e; + border-radius: 20px; + padding: 4px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5); + border: 1px solid #4a7aba; +} + +.view-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: #3a5a8a; + border: none; + border-radius: 14px; + color: #ffffff; + cursor: pointer; + transition: all 0.15s ease; +} + + +.view-toggle-btn:hover:not(.disabled) { + background: #5a8aca; + color: #fff; +} + +.view-toggle-btn:active:not(.disabled) { + background: #6a9ada; +} + +.view-toggle-btn.disabled { + background: #2a3a4a; + color: #6a7a8a; + cursor: default; +} + +.view-toggle-btn.active { + background: #5a8aca; + color: #ffffff; +} + +.view-toggle-btn.view-toggle-center { + width: 24px; + background: #3a5a8a; + color: #ffffff; + font-weight: bold; + font-size: 16px; +} + +.view-toggle-btn.view-toggle-center:hover:not(.disabled) { + background: #5a8aca; + color: #fff; +} + +/* Position the control on the divider between editor and preview */ +.pane-divider { + position: relative; + width: 1px; + background: #1f3460; + flex-shrink: 0; +} + +.pane-divider .view-toggle-control { + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/hub-client/src/components/ViewToggleControl.tsx b/hub-client/src/components/ViewToggleControl.tsx new file mode 100644 index 00000000..371c4919 --- /dev/null +++ b/hub-client/src/components/ViewToggleControl.tsx @@ -0,0 +1,49 @@ +import { useViewMode } from './ViewModeContext'; +import './ViewToggleControl.css'; + +/** + * A toggle control that sits at the middle center of the divider between panes. + * Allows users to switch between markup-focused, both, and preview-focused views. + * + * Layout: [◀] [|] [▶] + * Arrows indicate divider movement direction: + * - ◀ moves divider left (expands preview) + * - | returns to even split (both) + * - ▶ moves divider right (expands markup) + */ +export default function ViewToggleControl() { + const { viewMode, setViewMode } = useViewMode(); + + const isMarkup = viewMode === 'markup'; + const isPreview = viewMode === 'preview'; + const isBoth = viewMode === 'both'; + + return ( +
+ + + +
+ ); +} diff --git a/hub-client/src/utils/iframePostProcessor.ts b/hub-client/src/utils/iframePostProcessor.ts index d9fd0122..9a6b6207 100644 --- a/hub-client/src/utils/iframePostProcessor.ts +++ b/hub-client/src/utils/iframePostProcessor.ts @@ -151,6 +151,45 @@ export function postProcessIframe( window.parent.postMessage({ type: 'hub-client-save' }, '*'); } }); + + // Inject responsive CSS for hub-client preview + // Hides TOC and adjusts layout for narrow containers since media queries + // check viewport width (not iframe/container width) + injectPreviewStyles(doc); +} + +/** + * Inject CSS to make the preview more responsive in narrow containers. + * This is needed because Quarto's media queries check viewport width, + * not the iframe container width. + */ +function injectPreviewStyles(doc: Document): void { + const style = doc.createElement('style'); + style.setAttribute('data-hub-client', 'true'); + style.textContent = ` + /* Hub-client preview overrides */ + /* Hide TOC - it doesn't work well in narrow iframe containers */ + nav[role="doc-toc"] { + display: none !important; + } + + /* Ensure body content doesn't overflow */ + body { + overflow-x: hidden; + } + + /* Constrain page columns to container width */ + .page-columns { + max-width: 100%; + } + + /* Ensure main content doesn't overflow */ + main { + max-width: 100%; + overflow-x: auto; + } + `; + doc.head.appendChild(style); } /** Resolve a relative path against the current file's directory */