From cce3d0de7e944295af40750f5e0687532be37b9d Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Thu, 26 Feb 2026 11:11:39 +1000 Subject: [PATCH] :sparkles: context menu for main content --- .../components/Editor/MarkdownEditor.tsx | 83 ++++++++++++++++++- .../components/FileTree/ContextMenu.css | 55 ++++++++++++ .../components/FileTree/ContextMenu.tsx | 3 + src/renderer/components/FileTree/FileTree.css | 47 +---------- 4 files changed, 139 insertions(+), 49 deletions(-) create mode 100644 src/renderer/components/FileTree/ContextMenu.css diff --git a/src/renderer/components/Editor/MarkdownEditor.tsx b/src/renderer/components/Editor/MarkdownEditor.tsx index 35a01d9..fed3a7e 100644 --- a/src/renderer/components/Editor/MarkdownEditor.tsx +++ b/src/renderer/components/Editor/MarkdownEditor.tsx @@ -1,13 +1,15 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap } from '@codemirror/view' import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment, Transaction } from '@codemirror/state' import { markdown } from '@codemirror/lang-markdown' import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language' -import { history, defaultKeymap, historyKeymap, invertedEffects } from '@codemirror/commands' +import { history, defaultKeymap, historyKeymap, invertedEffects, selectAll } from '@codemirror/commands' import { marked } from 'marked' import DOMPurify from 'dompurify' import { useEditorStore } from '../../store/editorStore' import type { TextAnnotation } from '../../types/editor' +import { ContextMenu } from '../FileTree/ContextMenu' +import type { MenuItem } from '../FileTree/ContextMenu' import './Editor.css' // StateEffect to push new annotations into the editor @@ -306,6 +308,8 @@ function buildTheme(fontSize: number, dark: boolean): ReturnType(null) const viewRef = useRef(null) + const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) + const [hasSelection, setHasSelection] = useState(false) const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme, scrollPositions } = useEditorStore() // Initialize CodeMirror once @@ -330,6 +334,10 @@ export function MarkdownEditor(): JSX.Element { if (update.docChanged) { setContent(update.state.doc.toString()) } + if (update.selectionSet) { + const { from, to } = update.state.selection.main + setHasSelection(from !== to) + } // When the user Cmd+Z's through an Apply, invertedEffects fires a // setAnnotationsEffect restoring the old list inside CM. Sync that // back to Zustand so the React decoration effect also reflects it. @@ -418,8 +426,69 @@ export function MarkdownEditor(): JSX.Element { view.dispatch({ effects: themeCompartment.reconfigure(buildTheme(fontSize, theme === 'dark')) }) }, [fontSize, theme]) + function handleContextMenu(e: React.MouseEvent): void { + if (!activeFilePath) return + e.preventDefault() + const view = viewRef.current + if (view) { + const { from, to } = view.state.selection.main + setHasSelection(from !== to) + } + setMenuPos({ x: e.clientX, y: e.clientY }) + } + + const editorMenuItems: (MenuItem | 'separator')[] = [ + { + label: 'Cut', + disabled: !hasSelection, + action: () => { + const view = viewRef.current + if (!view) return + const { from, to } = view.state.selection.main + if (from === to) return + navigator.clipboard.writeText(view.state.sliceDoc(from, to)).catch(console.error) + view.dispatch({ changes: { from, to, insert: '' } }) + view.focus() + } + }, + { + label: 'Copy', + disabled: !hasSelection, + action: () => { + const view = viewRef.current + if (!view) return + const { from, to } = view.state.selection.main + if (from === to) return + navigator.clipboard.writeText(view.state.sliceDoc(from, to)).catch(console.error) + view.focus() + } + }, + { + label: 'Paste', + action: () => { + const view = viewRef.current + if (!view) return + navigator.clipboard.readText().then((text) => { + const { from, to } = view.state.selection.main + view.dispatch({ changes: { from, to, insert: text } }) + view.focus() + }).catch(console.error) + } + }, + 'separator', + { + label: 'Select All', + action: () => { + const view = viewRef.current + if (!view) return + selectAll(view) + view.focus() + } + } + ] + return ( -
+
{!activeFilePath && (

Select a chapter from the sidebar to begin editing.

@@ -427,6 +496,14 @@ export function MarkdownEditor(): JSX.Element {
)}
+ {menuPos && ( + setMenuPos(null)} + /> + )}
) } diff --git a/src/renderer/components/FileTree/ContextMenu.css b/src/renderer/components/FileTree/ContextMenu.css new file mode 100644 index 0000000..2ca892c --- /dev/null +++ b/src/renderer/components/FileTree/ContextMenu.css @@ -0,0 +1,55 @@ +.context-menu { + position: fixed; + min-width: 148px; + background: var(--message-bg); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); + padding: 3px 0; + z-index: 9999; + user-select: none; +} + +.context-menu-item { + display: block; + width: 100%; + background: none; + border: none; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 12.5px; + padding: 6px 14px; + text-align: left; + cursor: pointer; + transition: background 0.1s; +} + +.context-menu-item:hover:not(:disabled) { + background: var(--active-bg); +} + +/* Disabled items (e.g. Cut/Copy with no selection) */ +.context-menu-item:disabled { + color: var(--text-muted); + cursor: default; + pointer-events: none; +} + +/* Danger items (e.g. Delete) */ +.context-menu-item.danger { + color: #c05050; +} + +html.light .context-menu-item.danger { + color: #a03030; +} + +.context-menu-item.danger:hover:not(:disabled) { + background: rgba(192, 80, 80, 0.1); +} + +.context-menu-separator { + height: 1px; + background: var(--border); + margin: 3px 0; +} diff --git a/src/renderer/components/FileTree/ContextMenu.tsx b/src/renderer/components/FileTree/ContextMenu.tsx index 47f9158..ff14d3e 100644 --- a/src/renderer/components/FileTree/ContextMenu.tsx +++ b/src/renderer/components/FileTree/ContextMenu.tsx @@ -1,10 +1,12 @@ import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' +import './ContextMenu.css' export interface MenuItem { label: string action: () => void danger?: boolean + disabled?: boolean } interface Props { @@ -43,6 +45,7 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element {