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
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'
4 changes: 4 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
"lint": "next lint"
},
"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);
}
182 changes: 141 additions & 41 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": () => editor.setDarkMode((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,64 @@ export default function HomePage() {

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

const handleAIApplyAction = useCallback(
(type: string, content: string) => {
if (type === "replace_document") {
editor.handleContentChange(content);
setStupidWorkaround((s) => !s);
} else if (type === "insert_below_document") {
const currentContent = editor.activeTab.content;
const separator = currentContent.endsWith("\n") ? "\n" : "\n\n";
editor.handleContentChange(`${currentContent}${separator}${content}`);
} else if (type === "replace_selection") {
const mono = monacoRef.current;
if (mono) {
const sel = mono.getSelection();
if (sel) {
mono.executeEdits("ai-replace", [{ range: sel, text: content }]);
} else {
editor.handleContentChange(content);
}
}
} else if (type === "insert_below_selection" || type === "insert_below") {
const mono = monacoRef.current;
const model = mono?.getModel();
const sel = mono?.getSelection();
if (mono && model && sel && !sel.isEmpty()) {
const selectedText = model.getValueInRange(sel);
const separator = selectedText.endsWith("\n") ? "\n" : "\n\n";
const range = {
startLineNumber: sel.endLineNumber,
startColumn: sel.endColumn,
endLineNumber: sel.endLineNumber,
endColumn: sel.endColumn,
};
mono.executeEdits("ai-insert-below", [
{ range, text: `${separator}${content}` },
]);
} else {
const currentContent = editor.activeTab.content;
const separator = currentContent.endsWith("\n") ? "\n" : "\n\n";
editor.handleContentChange(`${currentContent}${separator}${content}`);
}
}
},
[editor]
);

const getSelectedText = useCallback(() => {
const mono = monacoRef.current;
if (!mono) return "";
const sel = mono.getSelection();
if (!sel) return "";
return mono.getModel()?.getValueInRange(sel) ?? "";
}, []);

const syncSelectedText = useCallback(() => {
setSelectedText(getSelectedText());
}, [getSelectedText]);

// ── Slash commands ──────────────────────────────────────────────────────────
const slash = useSlashCommands();
Expand Down Expand Up @@ -469,11 +554,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,40 +583,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
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
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>
)
}
Loading