✨ 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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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()
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user