diff --git a/frontend/css.d.ts b/frontend/css.d.ts new file mode 100644 index 0000000..e332379 --- /dev/null +++ b/frontend/css.d.ts @@ -0,0 +1 @@ +declare module '*.css' \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 50d5425..1799a54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index fc56640..7c22224 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -12,3 +12,7 @@ html, body, #__next { .monaco-editor { height: 100% !important; } + +.focus-mode-editor .milkdown { + --crepe-base-font-size: var(--fme-font-size); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index cbaa6f4..e3c5baf 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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"; @@ -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); @@ -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) => { @@ -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", "**"), @@ -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), @@ -183,6 +209,7 @@ export default function HomePage() { insertTable, runMonacoAction, wrapSelection, + focusMode ] ); @@ -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; @@ -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(); @@ -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} /> @@ -496,40 +583,53 @@ export default function HomePage() { {/* Main content area */}
- - { monacoRef.current = e; setMonacoReady(true); }} - /> - {slash.isOpen && slashMenuPosition && ( - { void executeSlashCommand(cmd); }} - onClose={slash.close} + {focusMode ? ( + { void executeSlashCommand(cmd); }} + /> + ) : + ( + + { monacoRef.current = e; setMonacoReady(true); }} /> - )} -
- } - right={ - showPreview ? ( - - ) : null - } - /> + {slash.isOpen && slashMenuPosition && ( + { void executeSlashCommand(cmd); }} + onClose={slash.close} + /> + )} + + } + right={ + showPreview ? ( + + ) : null + } + /> + )} {/* AI Panel */} {showAIPanel && ( diff --git a/frontend/src/components/FocusModePane.tsx b/frontend/src/components/FocusModePane.tsx new file mode 100644 index 0000000..0db4453 --- /dev/null +++ b/frontend/src/components/FocusModePane.tsx @@ -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 ( +
+ +
+ ) +} + +export default function FocusModePane (props: Props) { + return ( + + + + ) +} diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 3ff68a8..23f41d0 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -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; }; @@ -36,11 +38,13 @@ export default function Toolbar({ onToggleDark, onToggleAIPanel, onToggleCitationPanel, + onToggleFocusMode, darkMode, fontSize, onFontSizeChange, showAIPanel, showCitationPanel, + showFocusPanel, backendStatus = "ready", backendMessage = null, }: Props) { @@ -107,6 +111,13 @@ export default function Toolbar({ )} {/* Toggles */} +