feat: add rich markdown editor mode (#264)

* feat: add rich markdown editor mode

* fix: preserve rich search navigation focus
This commit is contained in:
Jinjing 2026-04-02 22:53:47 -07:00 committed by GitHub
parent bf15a5ae84
commit 22f916e421
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2089 additions and 52 deletions

View file

@ -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",

File diff suppressed because it is too large Load diff

View file

@ -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 {

View file

@ -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') {

View file

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

View file

@ -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>
)

View 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)
}

View 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>
)
}

View file

@ -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()
})
})

View 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')
}

View 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()
}
}
]

View 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)
}

View 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
}
}

View file

@ -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(
{

View file

@ -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)