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
25 changes: 14 additions & 11 deletions hub-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -395,17 +396,19 @@ function App() {
onClearPendingShare={handleClearPendingShare}
/>
) : (
<Editor
project={project}
files={files}
fileContents={fileContents}
onDisconnect={handleDisconnect}
onContentChange={handleContentChange}
route={route}
onNavigateToFile={(filePath, options) => {
navigateToFile(project.id, filePath, options);
}}
/>
<ViewModeProvider>
<Editor
project={project}
files={files}
fileContents={fileContents}
onDisconnect={handleDisconnect}
onContentChange={handleContentChange}
route={route}
onNavigateToFile={(filePath, options) => {
navigateToFile(project.id, filePath, options);
}}
/>
</ViewModeProvider>
)}
<Toast
message="Auto-saved"
Expand Down
68 changes: 67 additions & 1 deletion hub-client/src/components/Editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,76 @@
.pane {
flex: 1;
min-width: 0;
transition: flex 0.3s ease;
}

/* Pane 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%);
}

.editor-pane {
border-right: 1px solid #1f3460;
position: relative;
overflow: hidden;
}

/* View Mode: Both (default) - equal split */
.editor-main.view-mode-both .editor-pane {
flex: 1;
}

.editor-main.view-mode-both .preview-pane {
flex: 1;
}

/* View Mode: Markup - editor expanded, preview as summary strip */
.editor-main.view-mode-markup .editor-pane {
flex: 4;
}

.editor-main.view-mode-markup .preview-pane {
flex: 1;
min-width: 120px;
max-width: 180px;
overflow: hidden;
}

/* Scale down the preview iframe content for minimap effect */
.editor-main.view-mode-markup .preview-pane iframe {
transform: scale(0.3);
transform-origin: top left;
width: 333%;
height: 333%;
}

/* View Mode: Preview - preview expanded, editor as summary strip */
.editor-main.view-mode-preview .editor-pane {
flex: 0 0 180px;
min-width: 150px;
max-width: 220px;
}

.editor-main.view-mode-preview .preview-pane {
flex: 1;
overflow: hidden;
}

/* Markdown summary overlay for preview mode */
.markdown-summary-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
}

/* Editor drag-over state for image drop */
Expand All @@ -214,6 +279,7 @@
.preview-pane {
background: #fff;
position: relative;
overflow: hidden;
}

.preview-pane iframe {
Expand Down
94 changes: 63 additions & 31 deletions hub-client/src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import ProjectTab from './tabs/ProjectTab';
import StatusTab from './tabs/StatusTab';
import SettingsTab from './tabs/SettingsTab';
import AboutTab from './tabs/AboutTab';
import ViewToggleControl from './ViewToggleControl';
import { useViewMode } from './ViewModeContext';
import MarkdownSummary from './MarkdownSummary';
import './Editor.css';

interface Props {
Expand Down Expand Up @@ -62,6 +65,9 @@ function selectDefaultFile(files: FileEntry[]): FileEntry | null {
}

export default function Editor({ project, files, fileContents, onDisconnect, onContentChange, route, onNavigateToFile }: Props) {
// View mode for pane sizing
const { viewMode } = useViewMode();

// Select initial file based on URL route or default
const getInitialFile = useCallback((): FileEntry | null => {
if (route.type === 'file' && route.filePath) {
Expand Down Expand Up @@ -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<Monaco.IPosition | null>(null);
Expand Down Expand Up @@ -646,7 +655,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
</div>
)}

<main className="editor-main">
<main className={`editor-main view-mode-${viewMode}`}>
<SidebarTabs>
{(activeTab) => {
switch (activeTab) {
Expand Down Expand Up @@ -705,37 +714,59 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
}}
</SidebarTabs>
<div className={`pane editor-pane${isEditorDragOver ? ' drag-over' : ''}`}>
<MonacoEditor
// Use key to force remount when switching files (resets editor state cleanly)
key={currentFile?.path ?? ''}
height="100%"
language="markdown"
theme="vs-dark"
// Use defaultValue instead of value to make Monaco uncontrolled.
// This prevents the wrapper from calling setValue() on re-renders,
// which would reset cursor position. We manage content via executeEdits().
defaultValue={content}
onChange={handleEditorChange}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on',
padding: { top: 16 },
scrollBeyondLastLine: false,
// Disable paste-as to prevent snippet expansion (e.g., URLs from browser
// address bar being pasted with $0 appended). See quarto-dev/kyoto#3.
pasteAs: { enabled: false },
// Move hover/diagnostic widgets to a fixed container outside the editor's
// overflow:hidden boundary, preventing them from being clipped by the navbar.
fixedOverflowWidgets: true,
// Prefer showing hover below the line. This prevents diagnostic popups near
// the top of the editor from overlapping the navbar.
hover: { above: false },
}}
/>
{/* Show MarkdownSummary overlay in preview mode */}
{viewMode === 'preview' && (
<div className="markdown-summary-overlay">
<MarkdownSummary
content={content}
onLineClick={(lineNumber) => {
if (previewScrollToLineRef.current) {
previewScrollToLineRef.current(lineNumber);
}
}}
/>
</div>
)}
{/* Always render Monaco but hide in preview mode */}
<div style={{ display: viewMode === 'preview' ? 'none' : 'block', height: '100%' }}>
<MonacoEditor
// Use key to force remount when switching files (resets editor state cleanly)
key={currentFile?.path ?? ''}
height="100%"
language="markdown"
theme="vs-dark"
// Use defaultValue instead of value to make Monaco uncontrolled.
// This prevents the wrapper from calling setValue() on re-renders,
// which would reset cursor position. We manage content via executeEdits().
defaultValue={content}
onChange={handleEditorChange}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on',
padding: { top: 16 },
scrollBeyondLastLine: false,
// Disable paste-as to prevent snippet expansion (e.g., URLs from browser
// address bar being pasted with $0 appended). See quarto-dev/kyoto#3.
pasteAs: { enabled: false },
// Move hover/diagnostic widgets to a fixed container outside the editor's
// overflow:hidden boundary, preventing them from being clipped by the navbar.
fixedOverflowWidgets: true,
// Prefer showing hover below the line. This prevents diagnostic popups near
// the top of the editor from overlapping the navbar.
hover: { above: false },
}}
/>
</div>
</div>

{/* Divider with view toggle control */}
<div className="pane-divider">
<ViewToggleControl />
</div>

<Preview
content={content}
currentFile={currentFile}
Expand All @@ -748,6 +779,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
onOpenNewFileDialog={handlePreviewOpenNewFileDialog}
onDiagnosticsChange={handleDiagnosticsChange}
onWasmStatusChange={handleWasmStatusChange}
onRegisterScrollToLine={(fn) => { previewScrollToLineRef.current = fn; }}
/>
</main>

Expand Down
41 changes: 41 additions & 0 deletions hub-client/src/components/MarkdownSummary.css
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 36 additions & 0 deletions hub-client/src/components/MarkdownSummary.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className="markdown-summary" ref={containerRef} onClick={handleClick}>
<pre className="markdown-summary-content">{content}</pre>
</div>
);
}
12 changes: 12 additions & 0 deletions hub-client/src/components/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string | null>(null);
Expand All @@ -294,6 +297,15 @@ export default function Preview({
// Ref to MorphIframe to access its imperative methods
const doubleBufferedIframeRef = useRef<MorphIframeHandle>(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<string>('');

Expand Down
Loading
Loading