context menu for main content

This commit is contained in:
2026-02-26 11:11:39 +10:00
parent 13f5386420
commit cce3d0de7e
4 changed files with 139 additions and 49 deletions

View File

@@ -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 { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap } from '@codemirror/view'
import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment, Transaction } from '@codemirror/state' import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment, Transaction } from '@codemirror/state'
import { markdown } from '@codemirror/lang-markdown' import { markdown } from '@codemirror/lang-markdown'
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language' 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 { marked } from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { useEditorStore } from '../../store/editorStore' import { useEditorStore } from '../../store/editorStore'
import type { TextAnnotation } from '../../types/editor' import type { TextAnnotation } from '../../types/editor'
import { ContextMenu } from '../FileTree/ContextMenu'
import type { MenuItem } from '../FileTree/ContextMenu'
import './Editor.css' import './Editor.css'
// StateEffect to push new annotations into the editor // StateEffect to push new annotations into the editor
@@ -306,6 +308,8 @@ function buildTheme(fontSize: number, dark: boolean): ReturnType<typeof EditorVi
export function MarkdownEditor(): JSX.Element { export function MarkdownEditor(): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null) const viewRef = useRef<EditorView | null>(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() const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme, scrollPositions } = useEditorStore()
// Initialize CodeMirror once // Initialize CodeMirror once
@@ -330,6 +334,10 @@ export function MarkdownEditor(): JSX.Element {
if (update.docChanged) { if (update.docChanged) {
setContent(update.state.doc.toString()) 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 // When the user Cmd+Z's through an Apply, invertedEffects fires a
// setAnnotationsEffect restoring the old list inside CM. Sync that // setAnnotationsEffect restoring the old list inside CM. Sync that
// back to Zustand so the React decoration effect also reflects it. // 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')) }) view.dispatch({ effects: themeCompartment.reconfigure(buildTheme(fontSize, theme === 'dark')) })
}, [fontSize, theme]) }, [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 ( return (
<div className="editor-container"> <div className="editor-container" onContextMenu={handleContextMenu}>
{!activeFilePath && ( {!activeFilePath && (
<div className="editor-empty"> <div className="editor-empty">
<p>Select a chapter from the sidebar to begin editing.</p> <p>Select a chapter from the sidebar to begin editing.</p>
@@ -427,6 +496,14 @@ export function MarkdownEditor(): JSX.Element {
</div> </div>
)} )}
<div ref={containerRef} className="codemirror-host" /> <div ref={containerRef} className="codemirror-host" />
{menuPos && (
<ContextMenu
x={menuPos.x}
y={menuPos.y}
items={editorMenuItems}
onClose={() => setMenuPos(null)}
/>
)}
</div> </div>
) )
} }

View File

@@ -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;
}

View File

@@ -1,10 +1,12 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import './ContextMenu.css'
export interface MenuItem { export interface MenuItem {
label: string label: string
action: () => void action: () => void
danger?: boolean danger?: boolean
disabled?: boolean
} }
interface Props { interface Props {
@@ -43,6 +45,7 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element {
<button <button
key={i} key={i}
className={`context-menu-item${item.danger ? ' danger' : ''}`} className={`context-menu-item${item.danger ? ' danger' : ''}`}
disabled={item.disabled}
onClick={() => { onClick={() => {
onClose() onClose()
item.action() item.action()

View File

@@ -110,49 +110,4 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* ── Context menu ──────────────────────────────────────────────────────── */ /* Context menu styles live in ContextMenu.css (imported by ContextMenu.tsx) */
.context-menu {
position: fixed;
min-width: 140px;
background: var(--bg-secondary, #1e1e1e);
border: 1px solid var(--border);
border-radius: 5px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
padding: 4px 0;
z-index: 9999;
user-select: none;
}
.context-menu-item {
display: block;
width: 100%;
background: none;
border: none;
color: var(--text-secondary);
font-size: 12px;
padding: 6px 14px;
text-align: left;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.context-menu-item:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
.context-menu-item.danger {
color: #e06c6c;
}
.context-menu-item.danger:hover {
background: rgba(224, 108, 108, 0.12);
color: #e06c6c;
}
.context-menu-separator {
height: 1px;
background: var(--border);
margin: 4px 0;
}