mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat: add rich markdown editor mode (#264)
* feat: add rich markdown editor mode * fix: preserve rich search navigation focus
This commit is contained in:
parent
bf15a5ae84
commit
22f916e421
15 changed files with 2089 additions and 52 deletions
|
|
@ -35,6 +35,15 @@
|
|||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tiptap/extension-image": "^3.22.1",
|
||||
"@tiptap/extension-link": "^3.22.1",
|
||||
"@tiptap/extension-placeholder": "^3.22.1",
|
||||
"@tiptap/extension-task-item": "^3.22.1",
|
||||
"@tiptap/extension-task-list": "^3.22.1",
|
||||
"@tiptap/markdown": "^3.22.1",
|
||||
"@tiptap/pm": "^3.22.1",
|
||||
"@tiptap/react": "^3.22.1",
|
||||
"@tiptap/starter-kit": "^3.22.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
|
|
|
|||
710
pnpm-lock.yaml
710
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -442,6 +442,294 @@
|
|||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── Rich Markdown ─────────────────────────────────── */
|
||||
|
||||
.rich-markdown-editor-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
background: var(--editor-surface);
|
||||
}
|
||||
|
||||
.rich-markdown-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--background) 84%, transparent);
|
||||
}
|
||||
|
||||
.rich-markdown-toolbar-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-markdown-toolbar-button:hover,
|
||||
.rich-markdown-toolbar-button.is-active {
|
||||
border-color: color-mix(in srgb, var(--border) 82%, transparent);
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.rich-markdown-search {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
width: fit-content;
|
||||
max-width: min(100%, 460px);
|
||||
margin-left: auto;
|
||||
margin-right: 12px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 6px;
|
||||
padding: 0 2px 0 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--background);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.rich-markdown-search-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
width: 220px;
|
||||
border: 1px solid var(--ring);
|
||||
background: var(--background);
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.rich-markdown-search-status {
|
||||
min-width: 0;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.rich-markdown-search-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
margin: 0 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.rich-markdown-search-input {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rich-markdown-search-button {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.rich-markdown-search-button:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.rich-markdown-search-match {
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, #facc15 50%, transparent);
|
||||
}
|
||||
|
||||
.rich-markdown-search-match[data-active='true'] {
|
||||
background: color-mix(in srgb, #fb923c 60%, transparent);
|
||||
}
|
||||
|
||||
.rich-markdown-editor {
|
||||
min-height: 100%;
|
||||
padding: 24px 32px 96px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rich-markdown-editor p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.rich-markdown-editor h1,
|
||||
.rich-markdown-editor h2,
|
||||
.rich-markdown-editor h3,
|
||||
.rich-markdown-editor h4,
|
||||
.rich-markdown-editor h5,
|
||||
.rich-markdown-editor h6 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rich-markdown-editor h1 {
|
||||
font-size: 1.75em;
|
||||
}
|
||||
|
||||
.rich-markdown-editor h2 {
|
||||
font-size: 1.4em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rich-markdown-editor h3 {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
.rich-markdown-editor p,
|
||||
.rich-markdown-editor ul,
|
||||
.rich-markdown-editor ol,
|
||||
.rich-markdown-editor blockquote,
|
||||
.rich-markdown-editor pre {
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul,
|
||||
.rich-markdown-editor ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.rich-markdown-editor ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul[data-type='taskList'] {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul[data-type='taskList'] li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rich-markdown-editor ul[data-type='taskList'] li > label {
|
||||
margin-top: 0.35em;
|
||||
}
|
||||
|
||||
.rich-markdown-editor blockquote {
|
||||
padding: 0.25em 1em;
|
||||
border-left: 3px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.rich-markdown-editor code {
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
font-size: 0.9em;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.rich-markdown-editor pre {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
}
|
||||
|
||||
.rich-markdown-editor pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rich-markdown-editor hr {
|
||||
margin: 1.5em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rich-markdown-editor a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rich-markdown-editor img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rich-markdown-editor .ProseMirror-selectednode {
|
||||
outline: 2px solid color-mix(in srgb, var(--ring) 78%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.rich-markdown-slash-menu {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
width: min(320px, calc(100% - 24px));
|
||||
max-height: 280px;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--background) 92%, transparent);
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.rich-markdown-slash-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rich-markdown-slash-item:hover,
|
||||
.rich-markdown-slash-item.is-active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.rich-markdown-slash-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* ── Markdown Preview ────────────────────────────────── */
|
||||
|
||||
.markdown-preview {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ import { useAppStore } from '@/store'
|
|||
import { ConflictBanner, ConflictPlaceholderView, ConflictReviewPanel } from './ConflictComponents'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { GitStatusEntry, GitDiffResult } from '../../../../shared/types'
|
||||
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
|
||||
|
||||
const MonacoEditor = lazy(() => import('./MonacoEditor'))
|
||||
const DiffViewer = lazy(() => import('./DiffViewer'))
|
||||
const CombinedDiffViewer = lazy(() => import('./CombinedDiffViewer'))
|
||||
const MarkdownPreview = lazy(() => import('./MarkdownPreview'))
|
||||
const RichMarkdownEditor = lazy(() => import('./RichMarkdownEditor'))
|
||||
const ImageViewer = lazy(() => import('./ImageViewer'))
|
||||
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ type FileContent = {
|
|||
mimeType?: string
|
||||
}
|
||||
|
||||
type MarkdownViewMode = 'source' | 'preview'
|
||||
type MarkdownViewMode = 'source' | 'rich'
|
||||
|
||||
export function EditorContent({
|
||||
activeFile,
|
||||
|
|
@ -92,10 +93,28 @@ export function EditorContent({
|
|||
|
||||
const renderMarkdownContent = (fc: FileContent): React.JSX.Element => {
|
||||
const currentContent = editBuffers[activeFile.id] ?? fc.content
|
||||
if (mdViewMode === 'preview') {
|
||||
return <MarkdownPreview content={currentContent} filePath={activeFile.filePath} />
|
||||
const richModeUnsupportedMessage = getMarkdownRichModeUnsupportedMessage(currentContent)
|
||||
|
||||
if (mdViewMode === 'rich' && !richModeUnsupportedMessage) {
|
||||
return (
|
||||
<RichMarkdownEditor
|
||||
content={currentContent}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return renderMonacoEditor(fc)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{mdViewMode === 'rich' && richModeUnsupportedMessage ? (
|
||||
<div className="border-b border-border/60 bg-amber-500/10 px-3 py-2 text-xs text-amber-950 dark:text-amber-100">
|
||||
{richModeUnsupportedMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1">{renderMonacoEditor(fc)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeFile.mode === 'conflict-review') {
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ export default function EditorPanel(): React.JSX.Element | null {
|
|||
const isMarkdown = resolvedLanguage === 'markdown'
|
||||
const mdViewMode: MarkdownViewMode =
|
||||
isMarkdown && activeFile.mode === 'edit'
|
||||
? (markdownViewMode[activeFile.id] ?? 'source')
|
||||
? (markdownViewMode[activeFile.id] ?? 'rich')
|
||||
: 'source'
|
||||
|
||||
const handleOpenDiffTargetFile = (): void => {
|
||||
|
|
@ -374,7 +374,7 @@ export default function EditorPanel(): React.JSX.Element | null {
|
|||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{openFileState.canOpen
|
||||
? isMarkdown
|
||||
? 'Open file tab to use markdown preview'
|
||||
? 'Open file tab to use rich markdown editing'
|
||||
: 'Open file tab'
|
||||
: 'This diff has no modified-side file to open'}
|
||||
</TooltipContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import { Code, BookOpen } from 'lucide-react'
|
||||
import { Code, Eye } from 'lucide-react'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import type { MarkdownViewMode } from '@/store/slices/editor'
|
||||
|
||||
|
|
@ -28,8 +28,8 @@ export default function MarkdownViewToggle({
|
|||
<ToggleGroupItem value="source" aria-label="Source" title="Source">
|
||||
<Code className="h-2 w-2" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="preview" aria-label="Preview" title="Preview">
|
||||
<BookOpen className="h-2 w-2" />
|
||||
<ToggleGroupItem value="rich" aria-label="Rich" title="Rich">
|
||||
<Eye className="h-2 w-2" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
|
|
|
|||
399
src/renderer/src/components/editor/RichMarkdownEditor.tsx
Normal file
399
src/renderer/src/components/editor/RichMarkdownEditor.tsx
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { List, ListOrdered, Quote } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
|
||||
import { slashCommands } from './rich-markdown-commands'
|
||||
import type { SlashCommand } from './rich-markdown-commands'
|
||||
import { RichMarkdownSearchBar } from './RichMarkdownSearchBar'
|
||||
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
|
||||
|
||||
type RichMarkdownEditorProps = {
|
||||
content: string
|
||||
onContentChange: (content: string) => void
|
||||
onSave: (content: string) => void
|
||||
}
|
||||
|
||||
type SlashMenuState = {
|
||||
query: string
|
||||
from: number
|
||||
to: number
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
|
||||
export default function RichMarkdownEditor({
|
||||
content,
|
||||
onContentChange,
|
||||
onSave
|
||||
}: RichMarkdownEditorProps): React.JSX.Element {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [slashMenu, setSlashMenu] = useState<SlashMenuState | null>(null)
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0)
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const lastCommittedMarkdownRef = useRef(content)
|
||||
const slashMenuRef = useRef<SlashMenuState | null>(null)
|
||||
const filteredSlashCommandsRef = useRef<SlashCommand[]>(slashCommands)
|
||||
const selectedCommandIndexRef = useRef(0)
|
||||
const onContentChangeRef = useRef(onContentChange)
|
||||
const onSaveRef = useRef(onSave)
|
||||
|
||||
useEffect(() => {
|
||||
onContentChangeRef.current = onContentChange
|
||||
}, [onContentChange])
|
||||
|
||||
useEffect(() => {
|
||||
onSaveRef.current = onSave
|
||||
}, [onSave])
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({
|
||||
openOnClick: false
|
||||
}),
|
||||
Image,
|
||||
Placeholder.configure({
|
||||
placeholder: 'Write markdown… Type / for blocks.'
|
||||
}),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true
|
||||
}),
|
||||
Markdown.configure({
|
||||
markedOptions: {
|
||||
gfm: true
|
||||
}
|
||||
})
|
||||
],
|
||||
content,
|
||||
contentType: 'markdown',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'rich-markdown-editor'
|
||||
},
|
||||
handleKeyDown: (_view, event) => {
|
||||
const mod = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
if (isMarkdownPreviewFindShortcut(event, isMac)) {
|
||||
event.preventDefault()
|
||||
openSearch()
|
||||
return true
|
||||
}
|
||||
if (mod && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault()
|
||||
const markdown = editor?.getMarkdown() ?? lastCommittedMarkdownRef.current
|
||||
onSaveRef.current(markdown)
|
||||
return true
|
||||
}
|
||||
|
||||
const currentSlashMenu = slashMenuRef.current
|
||||
if (!currentSlashMenu) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentFilteredSlashCommands = filteredSlashCommandsRef.current
|
||||
if (currentFilteredSlashCommands.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const activeEditor = editor
|
||||
if (!activeEditor) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelectedCommandIndex(
|
||||
(currentIndex) => (currentIndex + 1) % currentFilteredSlashCommands.length
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelectedCommandIndex(
|
||||
(currentIndex) =>
|
||||
(currentIndex - 1 + currentFilteredSlashCommands.length) %
|
||||
currentFilteredSlashCommands.length
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
// Why: ProseMirror keeps this key handler stable for the editor's
|
||||
// lifetime, so reading React state directly here can use a stale
|
||||
// slash-menu index and execute the wrong command after keyboard
|
||||
// navigation. The ref mirrors the latest highlighted item.
|
||||
const selectedCommand = currentFilteredSlashCommands[selectedCommandIndexRef.current]
|
||||
if (selectedCommand) {
|
||||
runSlashCommand(activeEditor, currentSlashMenu, selectedCommand)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setSlashMenu(null)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
onCreate: ({ editor: nextEditor }) => {
|
||||
lastCommittedMarkdownRef.current = nextEditor.getMarkdown()
|
||||
},
|
||||
onUpdate: ({ editor: nextEditor }) => {
|
||||
const markdown = nextEditor.getMarkdown()
|
||||
lastCommittedMarkdownRef.current = markdown
|
||||
onContentChangeRef.current(markdown)
|
||||
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
|
||||
},
|
||||
onSelectionUpdate: ({ editor: nextEditor }) => {
|
||||
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
activeMatchIndex,
|
||||
closeSearch,
|
||||
isSearchOpen,
|
||||
matchCount,
|
||||
moveToMatch,
|
||||
openSearch,
|
||||
searchInputRef,
|
||||
searchQuery,
|
||||
setSearchQuery
|
||||
} = useRichMarkdownSearch({
|
||||
editor,
|
||||
isMac,
|
||||
rootRef
|
||||
})
|
||||
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
const query = slashMenu?.query.trim().toLowerCase() ?? ''
|
||||
if (!query) {
|
||||
return slashCommands
|
||||
}
|
||||
return slashCommands.filter((command) => {
|
||||
const haystack = [command.label, ...command.aliases].join(' ').toLowerCase()
|
||||
return haystack.includes(query)
|
||||
})
|
||||
}, [slashMenu?.query])
|
||||
|
||||
useEffect(() => {
|
||||
slashMenuRef.current = slashMenu
|
||||
}, [slashMenu])
|
||||
|
||||
useEffect(() => {
|
||||
filteredSlashCommandsRef.current = filteredSlashCommands
|
||||
}, [filteredSlashCommands])
|
||||
|
||||
useEffect(() => {
|
||||
selectedCommandIndexRef.current = selectedCommandIndex
|
||||
}, [selectedCommandIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSlashCommands.length === 0) {
|
||||
setSelectedCommandIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedCommandIndex((currentIndex) =>
|
||||
Math.min(currentIndex, filteredSlashCommands.length - 1)
|
||||
)
|
||||
}, [filteredSlashCommands.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentMarkdown = editor.getMarkdown()
|
||||
if (currentMarkdown === content) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: markdown files on disk remain the source of truth for rich mode in
|
||||
// Orca. External file changes, tab replacement, and save-after-reload must
|
||||
// overwrite the editor state so the rich view never drifts from repo text.
|
||||
editor.commands.setContent(content, { contentType: 'markdown' })
|
||||
lastCommittedMarkdownRef.current = content
|
||||
syncSlashMenu(editor, rootRef.current, setSlashMenu)
|
||||
}, [content, editor])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="rich-markdown-editor-shell">
|
||||
<div className="rich-markdown-editor-toolbar">
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('bold') ?? false}
|
||||
label="Bold"
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
>
|
||||
B
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('italic') ?? false}
|
||||
label="Italic"
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
I
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('strike') ?? false}
|
||||
label="Strike"
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
>
|
||||
S
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('bulletList') ?? false}
|
||||
label="Bullet list"
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<List className="size-3.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('orderedList') ?? false}
|
||||
label="Numbered list"
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ListOrdered className="size-3.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('blockquote') ?? false}
|
||||
label="Quote"
|
||||
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
<Quote className="size-3.5" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
<RichMarkdownSearchBar
|
||||
activeMatchIndex={activeMatchIndex}
|
||||
isOpen={isSearchOpen}
|
||||
matchCount={matchCount}
|
||||
onClose={closeSearch}
|
||||
onMoveToMatch={moveToMatch}
|
||||
onQueryChange={setSearchQuery}
|
||||
query={searchQuery}
|
||||
searchInputRef={searchInputRef}
|
||||
/>
|
||||
<EditorContent editor={editor} className="min-h-0 flex-1 overflow-auto" />
|
||||
{slashMenu && filteredSlashCommands.length > 0 ? (
|
||||
<div
|
||||
className="rich-markdown-slash-menu"
|
||||
style={{ left: slashMenu.left, top: slashMenu.top }}
|
||||
role="listbox"
|
||||
aria-label="Slash commands"
|
||||
>
|
||||
{filteredSlashCommands.map((command, index) => {
|
||||
const Icon = command.icon
|
||||
return (
|
||||
<button
|
||||
key={command.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rich-markdown-slash-item',
|
||||
index === selectedCommandIndex && 'is-active'
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => editor && runSlashCommand(editor, slashMenu, command)}
|
||||
>
|
||||
<span className="rich-markdown-slash-icon">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start">
|
||||
<span className="truncate text-sm font-medium">{command.label}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{command.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
active,
|
||||
label,
|
||||
onClick,
|
||||
children
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn('rich-markdown-toolbar-button', active && 'is-active')}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function syncSlashMenu(
|
||||
editor: Editor,
|
||||
root: HTMLDivElement | null,
|
||||
setSlashMenu: React.Dispatch<React.SetStateAction<SlashMenuState | null>>
|
||||
): void {
|
||||
if (!root || editor.view.composing || !editor.isEditable) {
|
||||
setSlashMenu(null)
|
||||
return
|
||||
}
|
||||
|
||||
const { state, view } = editor
|
||||
const { selection } = state
|
||||
if (!selection.empty) {
|
||||
setSlashMenu(null)
|
||||
return
|
||||
}
|
||||
|
||||
const { $from } = selection
|
||||
if (!$from.parent.isTextblock) {
|
||||
setSlashMenu(null)
|
||||
return
|
||||
}
|
||||
|
||||
const blockTextBeforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
|
||||
const slashMatch = blockTextBeforeCursor.match(/^\s*\/([a-z0-9-]*)$/i)
|
||||
if (!slashMatch) {
|
||||
setSlashMenu(null)
|
||||
return
|
||||
}
|
||||
|
||||
const slashOffset = blockTextBeforeCursor.lastIndexOf('/')
|
||||
const start = selection.from - ($from.parentOffset - slashOffset)
|
||||
const coords = view.coordsAtPos(selection.from)
|
||||
const rect = root.getBoundingClientRect()
|
||||
|
||||
setSlashMenu({
|
||||
query: slashMatch[1] ?? '',
|
||||
from: start,
|
||||
to: selection.from,
|
||||
left: coords.left - rect.left,
|
||||
top: coords.bottom - rect.top + 8
|
||||
})
|
||||
}
|
||||
|
||||
function runSlashCommand(editor: Editor, slashMenu: SlashMenuState, command: SlashCommand): void {
|
||||
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run()
|
||||
command.run(editor)
|
||||
}
|
||||
112
src/renderer/src/components/editor/RichMarkdownSearchBar.tsx
Normal file
112
src/renderer/src/components/editor/RichMarkdownSearchBar.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React from 'react'
|
||||
import { ChevronDown, ChevronUp, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
type RichMarkdownSearchBarProps = {
|
||||
activeMatchIndex: number
|
||||
isOpen: boolean
|
||||
matchCount: number
|
||||
onClose: () => void
|
||||
onMoveToMatch: (direction: 1 | -1) => void
|
||||
onQueryChange: (query: string) => void
|
||||
query: string
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
export function RichMarkdownSearchBar({
|
||||
activeMatchIndex,
|
||||
isOpen,
|
||||
matchCount,
|
||||
onClose,
|
||||
onMoveToMatch,
|
||||
onQueryChange,
|
||||
query,
|
||||
searchInputRef
|
||||
}: RichMarkdownSearchBarProps): React.JSX.Element | null {
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
const keepSearchFocus = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
// Why: rich-mode find drives navigation through the ProseMirror selection.
|
||||
// Letting the toolbar buttons take focus interrupts that selection flow and
|
||||
// makes mouse-based next/previous navigation appear broken.
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rich-markdown-search" onKeyDown={(event) => event.stopPropagation()}>
|
||||
<div className="rich-markdown-search-field">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && event.shiftKey) {
|
||||
event.preventDefault()
|
||||
onMoveToMatch(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onMoveToMatch(1)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
placeholder="Find in rich editor"
|
||||
className="rich-markdown-search-input h-7 !border-0 bg-transparent px-2 shadow-none focus-visible:!border-0 focus-visible:ring-0"
|
||||
aria-label="Find in rich markdown editor"
|
||||
/>
|
||||
</div>
|
||||
<div className="rich-markdown-search-status">
|
||||
{query && matchCount === 0
|
||||
? 'No results'
|
||||
: `${matchCount === 0 ? 0 : activeMatchIndex + 1}/${matchCount}`}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onMouseDown={keepSearchFocus}
|
||||
onClick={() => onMoveToMatch(-1)}
|
||||
disabled={matchCount === 0}
|
||||
title="Previous match"
|
||||
aria-label="Previous match"
|
||||
className="rich-markdown-search-button"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onMouseDown={keepSearchFocus}
|
||||
onClick={() => onMoveToMatch(1)}
|
||||
disabled={matchCount === 0}
|
||||
title="Next match"
|
||||
aria-label="Next match"
|
||||
className="rich-markdown-search-button"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
<div className="rich-markdown-search-divider" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onMouseDown={keepSearchFocus}
|
||||
onClick={onClose}
|
||||
title="Close search"
|
||||
aria-label="Close search"
|
||||
className="rich-markdown-search-button"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
|
||||
|
||||
describe('getMarkdownRichModeUnsupportedMessage', () => {
|
||||
it('blocks markdown tables that rich mode cannot round-trip', () => {
|
||||
expect(getMarkdownRichModeUnsupportedMessage('| a | b |\n| - | - |\n| 1 | 2 |\n')).toBe(
|
||||
'Markdown tables are only editable in source mode.'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows plain markdown content', () => {
|
||||
expect(getMarkdownRichModeUnsupportedMessage('# Title\n\n- one\n- two\n')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows common raw html in markdown files', () => {
|
||||
expect(getMarkdownRichModeUnsupportedMessage('Before <span>hi</span> after\n')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows markdown autolinks wrapped in angle brackets', () => {
|
||||
expect(
|
||||
getMarkdownRichModeUnsupportedMessage('See <https://example.com/docs> for details.\n')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('allows code fences with language info strings', () => {
|
||||
expect(getMarkdownRichModeUnsupportedMessage('```ts\nconst answer = 42\n```\n')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores table syntax inside fenced code blocks', () => {
|
||||
expect(
|
||||
getMarkdownRichModeUnsupportedMessage('```md\n| a | b |\n| - | - |\n| 1 | 2 |\n```\n')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores jsx-looking tags inside code spans and fences', () => {
|
||||
expect(getMarkdownRichModeUnsupportedMessage('Use `<Widget />` in docs.\n')).toBeNull()
|
||||
expect(getMarkdownRichModeUnsupportedMessage('```tsx\n<Widget />\n```\n')).toBeNull()
|
||||
})
|
||||
})
|
||||
87
src/renderer/src/components/editor/markdown-rich-mode.ts
Normal file
87
src/renderer/src/components/editor/markdown-rich-mode.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
export type MarkdownRichModeUnsupportedReason =
|
||||
| 'frontmatter'
|
||||
| 'jsx'
|
||||
| 'reference-links'
|
||||
| 'footnotes'
|
||||
| 'tables'
|
||||
|
||||
type UnsupportedMatch = {
|
||||
reason: MarkdownRichModeUnsupportedReason
|
||||
message: string
|
||||
pattern: RegExp
|
||||
}
|
||||
|
||||
const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [
|
||||
{
|
||||
reason: 'frontmatter',
|
||||
message: 'Frontmatter is only editable in source mode.',
|
||||
// Why: Tiptap markdown support is beta and frontmatter is often consumed by
|
||||
// static-site tooling. Falling back to source mode avoids silently dropping
|
||||
// metadata that rich mode does not explicitly own.
|
||||
pattern: /^(?:---|\+\+\+)\r?\n[\s\S]*?\r?\n(?:---|\+\+\+)(?:\r?\n|$)/
|
||||
},
|
||||
{
|
||||
reason: 'jsx',
|
||||
message: 'JSX or MDX content is only editable in source mode.',
|
||||
// Why: uppercase tags are a strong MDX/JSX signal, and those files usually
|
||||
// carry component semantics that the markdown editor does not own. Raw HTML
|
||||
// is intentionally allowed because blocking ordinary .md files with common
|
||||
// inline tags proved too disruptive; source mode remains the fallback if a
|
||||
// specific document does not round-trip the way the user expects.
|
||||
pattern: /<[A-Z][\w.]*(?:\s[^<>]*)?\/?>/
|
||||
},
|
||||
{
|
||||
reason: 'reference-links',
|
||||
message: 'Reference-style links are only editable in source mode.',
|
||||
pattern: /^\[[^\]]+\]:\s+\S+/m
|
||||
},
|
||||
{
|
||||
reason: 'footnotes',
|
||||
message: 'Footnotes are only editable in source mode.',
|
||||
pattern: /^\[\^[^\]]+\]:\s+/m
|
||||
},
|
||||
{
|
||||
reason: 'tables',
|
||||
message: 'Markdown tables are only editable in source mode.',
|
||||
// Why: the current rich-mode extension set does not include table nodes, so
|
||||
// Tiptap collapses GFM tables instead of round-tripping them verbatim.
|
||||
pattern: /^(?:\|?.+\|.+\|?.*)\r?\n\|?(?:\s*:?-{1,}:?\s*\|){1,}\s*:?-{1,}:?\s*\|?/m
|
||||
}
|
||||
]
|
||||
|
||||
export function getMarkdownRichModeUnsupportedMessage(content: string): string | null {
|
||||
const contentWithoutCode = stripMarkdownCode(content)
|
||||
|
||||
for (const matcher of UNSUPPORTED_PATTERNS) {
|
||||
if (matcher.pattern.test(contentWithoutCode)) {
|
||||
return matcher.message
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function stripMarkdownCode(content: string): string {
|
||||
const lines = content.split(/\r?\n/)
|
||||
const sanitizedLines: string[] = []
|
||||
let activeFence: '`' | '~' | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/)
|
||||
if (fenceMatch) {
|
||||
const fenceMarker = fenceMatch[1][0] as '`' | '~'
|
||||
activeFence = activeFence === fenceMarker ? null : fenceMarker
|
||||
sanitizedLines.push('')
|
||||
continue
|
||||
}
|
||||
|
||||
if (activeFence) {
|
||||
sanitizedLines.push('')
|
||||
continue
|
||||
}
|
||||
|
||||
sanitizedLines.push(line.replace(/`+[^`\n]*`+/g, ''))
|
||||
}
|
||||
|
||||
return sanitizedLines.join('\n')
|
||||
}
|
||||
142
src/renderer/src/components/editor/rich-markdown-commands.tsx
Normal file
142
src/renderer/src/components/editor/rich-markdown-commands.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import React from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { Heading1, Heading2, Heading3, ImageIcon, List, ListOrdered, Quote } from 'lucide-react'
|
||||
|
||||
export type SlashCommandId =
|
||||
| 'text'
|
||||
| 'heading-1'
|
||||
| 'heading-2'
|
||||
| 'heading-3'
|
||||
| 'task-list'
|
||||
| 'bullet-list'
|
||||
| 'ordered-list'
|
||||
| 'blockquote'
|
||||
| 'code-block'
|
||||
| 'divider'
|
||||
| 'image'
|
||||
|
||||
export type SlashCommand = {
|
||||
id: SlashCommandId
|
||||
label: string
|
||||
aliases: string[]
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
description: string
|
||||
run: (editor: Editor) => void
|
||||
}
|
||||
|
||||
export const slashCommands: SlashCommand[] = [
|
||||
{
|
||||
id: 'text',
|
||||
label: 'Text',
|
||||
aliases: ['paragraph', 'plain'],
|
||||
icon: List,
|
||||
description: 'Start a normal paragraph.',
|
||||
run: (editor) => {
|
||||
editor.chain().focus().setParagraph().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'heading-1',
|
||||
label: 'Heading 1',
|
||||
aliases: ['h1', 'title'],
|
||||
icon: Heading1,
|
||||
description: 'Large section heading.',
|
||||
run: (editor) => {
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'heading-2',
|
||||
label: 'Heading 2',
|
||||
aliases: ['h2'],
|
||||
icon: Heading2,
|
||||
description: 'Medium section heading.',
|
||||
run: (editor) => {
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'heading-3',
|
||||
label: 'Heading 3',
|
||||
aliases: ['h3'],
|
||||
icon: Heading3,
|
||||
description: 'Small section heading.',
|
||||
run: (editor) => {
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'task-list',
|
||||
label: 'To-do List',
|
||||
aliases: ['todo', 'task', 'checkbox'],
|
||||
icon: List,
|
||||
description: 'Create a checklist.',
|
||||
run: (editor) => {
|
||||
editor.chain().focus().toggleTaskList().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'bullet-list',
|
||||
label: 'Bullet List',
|
||||
aliases: ['bullet', 'ul', 'list'],
|
||||
icon: List,
|
||||
description: 'Create an unordered list.',
|
||||
run: (editor) => {
|
||||
editor.chain().focus().toggleBulletList().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'ordered-list',
|
||||
label: 'Numbered List',
|
||||
aliases: ['ordered', 'ol', 'numbered'],
|
||||
icon: ListOrdered,
|
||||
description: 'Create an ordered list.',
|
||||
run: (editor) => {
|
||||
editor.chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'blockquote',
|
||||
label: 'Quote',
|
||||
aliases: ['quote', 'blockquote'],
|
||||
icon: Quote,
|
||||
description: 'Insert a blockquote.',
|
||||
run: (editor) => {
|
||||
editor.chain().focus().toggleBlockquote().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'code-block',
|
||||
label: 'Code Block',
|
||||
aliases: ['code', 'snippet'],
|
||||
icon: List,
|
||||
description: 'Insert a fenced code block.',
|
||||
run: (editor) => {
|
||||
editor.chain().focus().toggleCodeBlock().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'divider',
|
||||
label: 'Divider',
|
||||
aliases: ['divider', 'rule', 'hr'],
|
||||
icon: List,
|
||||
description: 'Insert a horizontal rule.',
|
||||
run: (editor) => {
|
||||
editor.chain().focus().setHorizontalRule().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
label: 'Image',
|
||||
aliases: ['image', 'img'],
|
||||
icon: ImageIcon,
|
||||
description: 'Insert an image from a URL.',
|
||||
run: (editor) => {
|
||||
const src = window.prompt('Image URL')
|
||||
if (!src) {
|
||||
return
|
||||
}
|
||||
editor.chain().focus().setImage({ src }).run()
|
||||
}
|
||||
}
|
||||
]
|
||||
116
src/renderer/src/components/editor/rich-markdown-search.ts
Normal file
116
src/renderer/src/components/editor/rich-markdown-search.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import { findTextMatchRanges } from './markdown-preview-search'
|
||||
|
||||
export type RichMarkdownSearchMatch = {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
type RichMarkdownSearchState = {
|
||||
activeIndex: number
|
||||
decorations: DecorationSet
|
||||
query: string
|
||||
}
|
||||
|
||||
type RichMarkdownSearchMeta = {
|
||||
activeIndex: number
|
||||
query: string
|
||||
}
|
||||
|
||||
export const richMarkdownSearchPluginKey = new PluginKey<RichMarkdownSearchState>(
|
||||
'richMarkdownSearch'
|
||||
)
|
||||
|
||||
export function findRichMarkdownSearchMatches(
|
||||
doc: ProseMirrorNode,
|
||||
query: string
|
||||
): RichMarkdownSearchMatch[] {
|
||||
if (!query) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matches: RichMarkdownSearchMatch[] = []
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = node.text ?? ''
|
||||
if (!text.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const ranges = findTextMatchRanges(text, query)
|
||||
for (const range of ranges) {
|
||||
matches.push({
|
||||
from: pos + range.start,
|
||||
to: pos + range.end
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
export function createRichMarkdownSearchPlugin(): Plugin<RichMarkdownSearchState> {
|
||||
return new Plugin<RichMarkdownSearchState>({
|
||||
key: richMarkdownSearchPluginKey,
|
||||
state: {
|
||||
init: () => ({
|
||||
activeIndex: -1,
|
||||
decorations: DecorationSet.empty,
|
||||
query: ''
|
||||
}),
|
||||
apply: (tr, pluginState) => {
|
||||
const meta = tr.getMeta(richMarkdownSearchPluginKey) as RichMarkdownSearchMeta | undefined
|
||||
const query = meta?.query ?? pluginState.query
|
||||
const activeIndex = meta?.activeIndex ?? pluginState.activeIndex
|
||||
|
||||
if (!query) {
|
||||
return {
|
||||
activeIndex: -1,
|
||||
decorations: DecorationSet.empty,
|
||||
query: ''
|
||||
}
|
||||
}
|
||||
|
||||
if (!meta && !tr.docChanged) {
|
||||
return pluginState
|
||||
}
|
||||
|
||||
return {
|
||||
activeIndex,
|
||||
decorations: buildSearchDecorations(tr.doc, query, activeIndex),
|
||||
query
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return richMarkdownSearchPluginKey.getState(state)?.decorations ?? DecorationSet.empty
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildSearchDecorations(
|
||||
doc: ProseMirrorNode,
|
||||
query: string,
|
||||
activeIndex: number
|
||||
): DecorationSet {
|
||||
const matches = findRichMarkdownSearchMatches(doc, query)
|
||||
if (matches.length === 0) {
|
||||
return DecorationSet.empty
|
||||
}
|
||||
|
||||
const decorations = matches.map((match, index) =>
|
||||
Decoration.inline(match.from, match.to, {
|
||||
class: 'rich-markdown-search-match',
|
||||
'data-active': index === activeIndex ? 'true' : undefined
|
||||
})
|
||||
)
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
194
src/renderer/src/components/editor/useRichMarkdownSearch.ts
Normal file
194
src/renderer/src/components/editor/useRichMarkdownSearch.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
|
||||
import {
|
||||
createRichMarkdownSearchPlugin,
|
||||
findRichMarkdownSearchMatches,
|
||||
richMarkdownSearchPluginKey
|
||||
} from './rich-markdown-search'
|
||||
|
||||
export function useRichMarkdownSearch({
|
||||
editor,
|
||||
isMac,
|
||||
rootRef
|
||||
}: {
|
||||
editor: Editor | null
|
||||
isMac: boolean
|
||||
rootRef: RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [matchCount, setMatchCount] = useState(0)
|
||||
const [activeMatchIndex, setActiveMatchIndex] = useState(-1)
|
||||
const [searchRevision, setSearchRevision] = useState(0)
|
||||
|
||||
const openSearch = useCallback(() => {
|
||||
setIsSearchOpen(true)
|
||||
}, [])
|
||||
|
||||
const closeSearch = useCallback(() => {
|
||||
setIsSearchOpen(false)
|
||||
setSearchQuery('')
|
||||
setActiveMatchIndex(-1)
|
||||
setMatchCount(0)
|
||||
}, [])
|
||||
|
||||
const moveToMatch = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
if (matchCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setActiveMatchIndex((currentIndex) => {
|
||||
const baseIndex = currentIndex >= 0 ? currentIndex : direction === 1 ? -1 : 0
|
||||
return (baseIndex + direction + matchCount) % matchCount
|
||||
})
|
||||
},
|
||||
[matchCount]
|
||||
)
|
||||
|
||||
const handleEditorUpdate = useCallback(() => {
|
||||
setSearchRevision((current) => current + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const plugin = createRichMarkdownSearchPlugin()
|
||||
editor.registerPlugin(plugin)
|
||||
|
||||
return () => {
|
||||
editor.unregisterPlugin(richMarkdownSearchPluginKey)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
editor.on('update', handleEditorUpdate)
|
||||
return () => {
|
||||
editor.off('update', handleEditorUpdate)
|
||||
}
|
||||
}, [editor, handleEditorUpdate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearchOpen) {
|
||||
return
|
||||
}
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
}, [isSearchOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSearchOpen || !searchQuery) {
|
||||
setMatchCount(0)
|
||||
setActiveMatchIndex(-1)
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setMeta(richMarkdownSearchPluginKey, {
|
||||
activeIndex: -1,
|
||||
query: ''
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const matches = findRichMarkdownSearchMatches(editor.state.doc, searchQuery)
|
||||
setMatchCount(matches.length)
|
||||
setActiveMatchIndex((currentIndex) => {
|
||||
if (matches.length === 0) {
|
||||
return -1
|
||||
}
|
||||
if (currentIndex >= 0 && currentIndex < matches.length) {
|
||||
return currentIndex
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}, [editor, isSearchOpen, searchQuery, searchRevision])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const query = isSearchOpen ? searchQuery : ''
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setMeta(richMarkdownSearchPluginKey, {
|
||||
activeIndex: activeMatchIndex,
|
||||
query
|
||||
})
|
||||
)
|
||||
|
||||
if (!query || activeMatchIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const matches = findRichMarkdownSearchMatches(editor.state.doc, query)
|
||||
const activeMatch = matches[activeMatchIndex]
|
||||
if (!activeMatch) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: rich-mode find should navigate within the editor model instead of
|
||||
// the rendered DOM so highlight positions stay correct while the user edits.
|
||||
// Updating the ProseMirror selection keeps scroll-to-match aligned with the
|
||||
// actual markdown document rather than the transient browser layout.
|
||||
const tr = editor.state.tr
|
||||
tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to))
|
||||
tr.scrollIntoView()
|
||||
editor.view.dispatch(tr)
|
||||
}, [activeMatchIndex, editor, isSearchOpen, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
const root = rootRef.current
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
const targetInsideEditor = target instanceof Node && root.contains(target)
|
||||
if (isMarkdownPreviewFindShortcut(event, isMac) && targetInsideEditor) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openSearch()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
isSearchOpen &&
|
||||
(targetInsideEditor || target === searchInputRef.current)
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeSearch()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
}, [closeSearch, isMac, isSearchOpen, openSearch, rootRef])
|
||||
|
||||
return {
|
||||
activeMatchIndex,
|
||||
closeSearch,
|
||||
isSearchOpen,
|
||||
matchCount,
|
||||
moveToMatch,
|
||||
openSearch,
|
||||
searchInputRef,
|
||||
searchQuery,
|
||||
setSearchQuery
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ describe('createEditorSlice openDiff', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('createEditorSlice markdown preview state', () => {
|
||||
describe('createEditorSlice markdown view state', () => {
|
||||
it('drops markdown view mode for a replaced preview tab', () => {
|
||||
const store = createEditorStore()
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ describe('createEditorSlice markdown preview state', () => {
|
|||
},
|
||||
{ preview: true }
|
||||
)
|
||||
store.getState().setMarkdownViewMode('/repo/docs/README.md', 'preview')
|
||||
store.getState().setMarkdownViewMode('/repo/docs/README.md', 'rich')
|
||||
|
||||
store.getState().openFile(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export type OpenFile = {
|
|||
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks'
|
||||
export type ActivityBarPosition = 'top' | 'side'
|
||||
|
||||
export type MarkdownViewMode = 'source' | 'preview'
|
||||
export type MarkdownViewMode = 'source' | 'rich'
|
||||
|
||||
export type EditorSlice = {
|
||||
// Markdown view mode per file (fileId -> mode)
|
||||
|
|
|
|||
Loading…
Reference in a new issue