✨ context menu for main content
This commit is contained in:
@@ -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<typeof EditorVi
|
||||
export function MarkdownEditor(): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(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()
|
||||
|
||||
// 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 (
|
||||
<div className="editor-container">
|
||||
<div className="editor-container" onContextMenu={handleContextMenu}>
|
||||
{!activeFilePath && (
|
||||
<div className="editor-empty">
|
||||
<p>Select a chapter from the sidebar to begin editing.</p>
|
||||
@@ -427,6 +496,14 @@ export function MarkdownEditor(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="codemirror-host" />
|
||||
{menuPos && (
|
||||
<ContextMenu
|
||||
x={menuPos.x}
|
||||
y={menuPos.y}
|
||||
items={editorMenuItems}
|
||||
onClose={() => setMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
55
src/renderer/components/FileTree/ContextMenu.css
Normal file
55
src/renderer/components/FileTree/ContextMenu.css
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
<button
|
||||
key={i}
|
||||
className={`context-menu-item${item.danger ? ' danger' : ''}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
onClose()
|
||||
item.action()
|
||||
|
||||
@@ -110,49 +110,4 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Context menu ──────────────────────────────────────────────────────── */
|
||||
|
||||
.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;
|
||||
}
|
||||
/* Context menu styles live in ContextMenu.css (imported by ContextMenu.tsx) */
|
||||
|
||||
Reference in New Issue
Block a user