Skip to content
Merged
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
1 change: 1 addition & 0 deletions frontend/css.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module '*.css'
8,202 changes: 5,530 additions & 2,672 deletions frontend/package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
"test": "node --import ./tests/setup-monaco.mjs --test tests/**/*.test.mjs"
},
"dependencies": {
"@milkdown/crepe": "^7.22.1",
"@milkdown/kit": "^7.22.1",
"@milkdown/plugin-math": "^7.5.9",
"@milkdown/react": "^7.22.1",
"@monaco-editor/react": "^4.6.0",
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-dialog": "2.7.0",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ html, body, #__next {
.monaco-editor {
height: 100% !important;
}

.focus-mode-editor .milkdown {
--crepe-base-font-size: var(--fme-font-size);
}
126 changes: 84 additions & 42 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import MenuBar, { type MenuGroup } from "@/components/MenuBar";
import EditorPane from "@/components/EditorPane";
import PreviewPane from "@/components/PreviewPane";
import SplitPane from "@/components/SplitPane";
import FocusModePane from "@/components/FocusModePane";
import AIPanel, { type AIPanelTab } from "@/components/AIPanel";
import CitationPanel from "@/components/CitationPanel";
import SlashCommandMenu from "@/components/SlashCommandMenu";
Expand All @@ -33,6 +34,8 @@ import {
} from "@/lib/keyboardShortcuts";

export default function HomePage() {
const [focusMode, setFocusMode] = useState(false);
const [stupidWorkaround, setStupidWorkaround] = useState(false);
const editor = useEditor();
const [showPreview] = useState(true);
const [showAIPanel, setShowAIPanel] = useState(false);
Expand Down Expand Up @@ -128,11 +131,33 @@ export default function HomePage() {
[replaceSelection]
);

const insertMarkdownBlock = useCallback((markdown: string) => {
if (focusMode) {
const updated = `${editor.activeTab.content.trimEnd()}\n\n${markdown}\n`;
editor.handleContentChange(updated);
setStupidWorkaround(s => !s);
return;
}

const mono = monacoRef.current;
const model = mono?.getModel();
const sel = mono?.getSelection();

if (mono && model && sel) {
replaceSelection("insert-table", () => ({
text: markdown,
}));
return;
}
editor.handleContentChange(`${editor.activeTab.content.trimEnd()}\n\n${markdown}\n`);
setStupidWorkaround(s => !s);
}, [focusMode, editor, replaceSelection]);

const insertTable = useCallback(() => {
replaceSelection("insert-table", () => ({
text: "| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |",
}));
}, [replaceSelection]);
insertMarkdownBlock(
"| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |",
);
}, [insertMarkdownBlock]);

const insertCitation = useCallback(
(citationKey: string) => {
Expand All @@ -154,8 +179,8 @@ export default function HomePage() {
"file.exportHtml": () => { void handleExport("html"); },
"file.exportPdf": () => { void handleExport("pdf"); },
"file.exportDocx": () => { void handleExport("docx"); },
"edit.undo": () => runMonacoAction("undo"),
"edit.redo": () => runMonacoAction("redo"),
"edit.undo": () => {if (!focusMode) runMonacoAction("undo")},
"edit.redo": () => {if (!focusMode) runMonacoAction("redo")},
"edit.search": () => runMonacoAction("actions.find"),
"edit.replace": () => runMonacoAction("editor.action.startFindReplaceAction"),
"format.bold": () => wrapSelection("bold", "**"),
Expand All @@ -167,6 +192,7 @@ export default function HomePage() {
"format.normal": () => applyHeading(0),
"table.insert": insertTable,
"view.toggleDarkMode": () => editor.setDarkMode((dark) => !dark),
"view.toggleFocusMode": () => setFocusMode((focus) => !focus),
"view.toggleAIPanel": () => setShowAIPanel((visible) => !visible),
"view.openBrowserPreview": () => { void handleOpenBrowserPreview(); },
"view.fullEditor": () => setSplit(100),
Expand All @@ -183,6 +209,7 @@ export default function HomePage() {
insertTable,
runMonacoAction,
wrapSelection,
focusMode
]
);

Expand Down Expand Up @@ -272,7 +299,8 @@ export default function HomePage() {
const matches = shortcut.bindings.some((binding) => shortcutMatchesEvent(binding, event));
if (!matches) continue;
if (shortcut.scope === "editor" && editableTarget && !isMonacoTarget) return;

if ( focusMode && (shortcut.id === "edit.undo" || shortcut.id === "edit.redo") )
return;
event.preventDefault();
actions[shortcut.id]();
return;
Expand All @@ -281,7 +309,7 @@ export default function HomePage() {

window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [actions, shortcuts]);
}, [actions, shortcuts, focusMode]);

// ── Slash commands ──────────────────────────────────────────────────────────
const slash = useSlashCommands();
Expand Down Expand Up @@ -469,11 +497,13 @@ export default function HomePage() {
onToggleDark={() => editor.setDarkMode((d) => !d)}
onToggleAIPanel={() => setShowAIPanel((v) => !v)}
onToggleCitationPanel={() => setShowCitationPanel((v) => !v)}
onToggleFocusMode={() => {setFocusMode((f) => !f); setMonacoReady(false)}}
darkMode={editor.darkMode}
fontSize={editor.fontSize}
onFontSizeChange={editor.setFontSize}
showAIPanel={showAIPanel}
showCitationPanel={showCitationPanel}
showFocusPanel={focusMode}
backendStatus={showPackagedBackendStatus ? backendStatus : "ready"}
backendMessage={showPackagedBackendStatus ? backendMessage : null}
/>
Expand All @@ -496,41 +526,53 @@ export default function HomePage() {

{/* Main content area */}
<div className="flex flex-1 overflow-hidden">
<SplitPane
split={split}
onSplitChange={setSplit}
left={
<div className="relative h-full">
<EditorPane
path={editor.activeTab.id}
value={editor.activeTab.content}
onChange={editor.handleContentChange}
darkMode={editor.darkMode}
fontSize={editor.fontSize}
onMount={(e) => { monacoRef.current = e; setMonacoReady(true); }}
/>
{slash.isOpen && slashMenuPosition && (
<SlashCommandMenu
commands={slash.filteredCommands}
selectedIndex={slash.selectedIndex}
top={slashMenuPosition.top + slashMenuPosition.height}
left={slashMenuPosition.left}
onSelect={(cmd) => { void executeSlashCommand(cmd); }}
onClose={slash.close}
{focusMode ? (
<FocusModePane
value={editor.activeTab.content}
onChange={editor.handleContentChange}
darkMode={editor.darkMode}
fontSize={editor.fontSize}
key={`${editor.activeTabId}-${stupidWorkaround}`}
slashCommands={slash.filteredCommands}
onSelect={(cmd) => { void executeSlashCommand(cmd); }}
/>
) : (
<SplitPane
split={split}
onSplitChange={setSplit}
left={
<div className="relative h-full">
<EditorPane
path={editor.activeTab.id}
value={editor.activeTab.content}
onChange={editor.handleContentChange}
darkMode={editor.darkMode}
fontSize={editor.fontSize}
onMount={(e) => { monacoRef.current = e; setMonacoReady(true); }}
/>
)}
</div>
}
right={
showPreview ? (
<PreviewPane
html={editor.previewHtml}
loading={showPackagedBackendStatus && backendStatus === "starting"}
error={showPackagedBackendStatus && backendStatus === "error" ? backendMessage : null}
/>
) : null
}
/>
{slash.isOpen && slashMenuPosition && (
<SlashCommandMenu
commands={slash.filteredCommands}
selectedIndex={slash.selectedIndex}
top={slashMenuPosition.top + slashMenuPosition.height}
left={slashMenuPosition.left}
onSelect={(cmd) => { void executeSlashCommand(cmd); }}
onClose={slash.close}
/>
)}
</div>
}
right={
showPreview ? (
<PreviewPane
html={editor.previewHtml}
loading={showPackagedBackendStatus && backendStatus === "starting"}
error={showPackagedBackendStatus && backendStatus === "error" ? backendMessage : null}
/>
) : null
}
/>
)}

{/* AI Panel */}
{showAIPanel && (
Expand Down
116 changes: 116 additions & 0 deletions frontend/src/components/FocusModePane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"use client";

/**
* FocusModePane.tsx
* ==============
* Milkdown based focus mode editor pane.
*/

import { Crepe } from '@milkdown/crepe'
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react'
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'
import { commonmark } from '@milkdown/kit/preset/commonmark'
import { gfm } from '@milkdown/kit/preset/gfm'
import { clipboard } from '@milkdown/kit/plugin/clipboard'
import { cursor } from '@milkdown/kit/plugin/cursor'
import { indent } from '@milkdown/kit/plugin/indent'
import { block } from '@milkdown/kit/plugin/block'
import { history } from '@milkdown/kit/plugin/history'
import { editorViewCtx } from '@milkdown/kit/core'
import { math } from '@milkdown/plugin-math'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
import '@/lib/focusModePaneDark.css'
import { SlashCommand } from '@/hooks/useSlashCommands'
import React from 'react';

type Props = {
value: string,
onChange: (value: string | undefined) => void,
darkMode: boolean,
fontSize: number,
slashCommands: SlashCommand[],
onSelect: (cmd: SlashCommand) => void
};

function CrepeEditor({ value, onChange, darkMode, fontSize, slashCommands, onSelect }: Props) {
useEditor((root) => {
const removeSlash = () => {
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { state } = view
const { $from } = state.selection
const textBeforeCursor = $from.parent.textContent.slice(0, $from.parentOffset)
const slashMatch = /(^|\s)(\/[A-Za-z0-9-]*)$/.exec(textBeforeCursor)
if (!slashMatch) return

const slashOffset = $from.parentOffset - slashMatch[2].length
const from = $from.start() + slashOffset
view.dispatch(state.tr.delete(from, $from.pos))
})
}

const crepe = new Crepe({
root,
defaultValue: value,
featureConfigs: {
[Crepe.Feature.BlockEdit]: {
buildMenu: (builder) => {
const group = builder.addGroup('slash', 'Slash Commands');
for (const i of slashCommands)
group.addItem(i.label, {
label: i.label,
icon: '',
onRun: () => {
removeSlash();
onSelect(i);
}
});
}
},
}
});
crepe
.editor
.use(history)
.use(math)
.use(listener)
.use(commonmark)
.use(gfm)
.use(clipboard)
.use(cursor)
.use(indent)
.use(block)
.config((ctx) => {
const listener = ctx.get(listenerCtx)

listener.markdownUpdated((ctx, markdown, prevMarkdown) => {
if (markdown !== prevMarkdown) {
onChange(markdown)
}
})
})
return crepe
})

return (
<div className={`
text-balance
flex-1 min-w-0 min-h-0 overflow-auto
focus-mode-editor
${darkMode ? "fme-dark-mode" : ""}
`}
style={{ "--fme-font-size": `${fontSize}px` } as React.CSSProperties & { "--fme-font-size": string }}
>
<Milkdown/>
</div>
)
}

export default function FocusModePane (props: Props) {
return (
<MilkdownProvider>
<CrepeEditor {...props} />
</MilkdownProvider>
)
}
11 changes: 11 additions & 0 deletions frontend/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ type Props = {
onToggleDark: () => void;
onToggleAIPanel: () => void;
onToggleCitationPanel: () => void;
onToggleFocusMode: () => void;
darkMode: boolean;
fontSize: number;
onFontSizeChange: (size: number) => void;
showAIPanel: boolean;
showCitationPanel: boolean;
showFocusPanel: boolean;
backendStatus?: "starting" | "ready" | "error";
backendMessage?: string | null;
};
Expand All @@ -36,11 +38,13 @@ export default function Toolbar({
onToggleDark,
onToggleAIPanel,
onToggleCitationPanel,
onToggleFocusMode,
darkMode,
fontSize,
onFontSizeChange,
showAIPanel,
showCitationPanel,
showFocusPanel,
backendStatus = "ready",
backendMessage = null,
}: Props) {
Expand Down Expand Up @@ -107,6 +111,13 @@ export default function Toolbar({
)}

{/* Toggles */}
<button
onClick={onToggleFocusMode}
className={`${btnCls} ${showFocusPanel ? "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300" : ""}`}
title="Focus Mode"
>
🧠 Focus
</button>
<button
onPointerDown={keepEditorFocusedOnPointerDown}
onMouseDown={keepEditorFocused}
Expand Down
Loading