Add a live preview markdown editor for explorer (#50717)

## Context

We've gotten some requests for a better markdown editing experience in
the Explorer so this PR adds a proper live preview markdown editor,
similar UX to how you'd edit notion or obsidian documents. Opting to use
`lexical` as the main library to support this UI for its size and
composability without going too low level like prose mirror

<img width="933" height="452" alt="image"
src="https://github.com/user-attachments/assets/7161ae6c-3dff-4b36-818f-01b5e573f121"
/>

Actual file changes are just this size excluding the package-lock
<img width="150" height="39" alt="image"
src="https://github.com/user-attachments/assets/bb01f2cf-8ef7-4daf-975b-614b2d817b54"
/>

## Changes involved

This adjusts the UX for markdown cells a little
- Clicking on a markdown cell immediately allows you to edit
- Instead of the existing behaviour which requires a double click to
enter "Edit" state
- Editing doesn't eject you into a monaco editor, but rather inline
editor
  - Nicer transition
- Clicking out of the cell persists the cell's content
  - No more "Cancel" or "Save" buttons

## To test
- [ ] Verify that editing markdown cells are still working
- [ ] Verify that tabs do get marked with unsaved changes if you edit a
markdown cell


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Replaced the previous Markdown editing workflow with an always-visible
inline editor.
- Added rich Markdown editing for headings, lists, links, blockquotes,
code blocks, inline code, and text formatting.
  - Changes are saved automatically when leaving the editor.
  - Markdown formatting shortcuts take precedence within the editor.

- **Bug Fixes**
  - Avoids unnecessary saves when content has not changed.

- **Tests**
- Added coverage verifying Markdown formatting conversions remain
consistent and repeatable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-09-22 19:09:25 +08:00
1 parent ef7b2a5211
commit c7880e328f
7 files changed
+922 -166

No files matched your search

@@ -1,15 +1,10 @@
import { Edit } from 'lucide-react'
import { useState } from 'react'
import { Button, cn } from 'ui'
import { cn } from 'ui'
import { Markdown } from '../Markdown'
import { AddCellDropdown } from './AddCellDropdown'
import { MoveCellDropdownContent } from './MoveCellDropdownContent'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
import { MarkdownEditor } from '@/components/ui/MarkdownEditor/MarkdownEditor'
import { SortableSection } from '@/components/ui/SortableSection'
import { type MarkdownCell as MarkdownCellSchema } from '@/data/content/notebooks/notebook-schema'
import { useLatest } from '@/hooks/misc/useLatest'
import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
interface MarkdownCellProps {
@@ -17,39 +12,20 @@ interface MarkdownCellProps {
onEdit?: () => void
}
// [Joshen] handleUpdateMarkdown could be shifted into notebook-state as a updateCell action
export const MarkdownCell = ({ cell, onEdit }: MarkdownCellProps) => {
const snap = useNotebooksStateSnapshot()
const currentNotebook = useCurrentNotebook()
const cells = currentNotebook?.notebook.content?.cells ?? []
const [value, setValue] = useState(cell.text)
const [isEditing, setIsEditing] = useState(false)
const valueRef = useLatest(value)
const handleStartEditing = () => {
setValue(cell.text)
setIsEditing(true)
}
const handleCancel = () => {
setIsEditing(false)
}
const handleUpdateMarkdown = (cellId: string, text: string) => {
const handleUpdateMarkdown = (text: string) => {
const notebookId = currentNotebook?.notebook.id
if (!notebookId) return
if (!notebookId || text === cell.text) return
onEdit?.()
const nextCells = cells.map((c) => (c._id === cellId ? { ...c, text } : c))
const nextCells = cells.map((c) => (c._id === cell._id ? { ...c, text } : c))
snap.updateCells({ id: notebookId, cells: nextCells })
setIsEditing(false)
}
const handleUpdateMarkdownRef = useLatest(handleUpdateMarkdown)
return (
<SortableSection
id={cell._id}
@@ -58,95 +34,19 @@ export const MarkdownCell = ({ cell, onEdit }: MarkdownCellProps) => {
gripDropdownContent={<MoveCellDropdownContent cellId={cell._id} />}
gripClassName="mt-1.5 sm:opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition"
>
{isEditing ? (
<div className={cn('w-full transition', 'overflow-hidden border rounded-md')}>
<CodeEditor
hideLineNumbers
language="markdown"
value={value}
onInputChange={(e) => setValue(e ?? '')}
className="h-[150px]"
options={{ quickSuggestions: false, wordBasedSuggestions: 'off' }}
onMount={(editor, monaco) => {
editor.addCommand(
monaco.KeyCode.Escape,
handleCancel,
[
'editorTextFocus',
'!editorHasSelection',
'!editorHasMultipleSelections',
'!suggestWidgetVisible',
'!findWidgetVisible',
'!parameterHintsVisible',
'!renameInputVisible',
'!inSnippetMode',
'!accessibilityHelpWidgetVisible',
'!inlineSuggestionVisible',
].join(' && ')
)
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () =>
handleUpdateMarkdownRef.current(cell._id, valueRef.current)
)
editor.onDidBlurEditorWidget(() =>
handleUpdateMarkdownRef.current(cell._id, valueRef.current)
)
}}
/>
<div className="border-t flex items-center justify-between pl-3 pr-1 py-1">
<p className="text-xs text-foreground-lighter">Markdown</p>
<div className="flex items-center gap-x-1">
<Button variant="text" onMouseDown={(e) => e.preventDefault()} onClick={handleCancel}>
Cancel
</Button>
<Button
variant="text"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleUpdateMarkdown(cell._id, value)}
>
Done
</Button>
</div>
</div>
</div>
) : (
<div
onDoubleClick={handleStartEditing}
className={cn(
'group/mdcell relative w-full px-3 py-2 transition',
'hover:bg-alternative/50',
'border border-transparent rounded-md hover:border-default'
)}
>
<ButtonTooltip
variant="text"
className={cn(
'absolute right-1 top-1 px-1',
'opacity-0 group-hover/mdcell:opacity-100 focus-visible:opacity-100'
)}
icon={<Edit size={14} />}
onClick={handleStartEditing}
tooltip={{ content: { side: 'bottom', text: 'Edit' } }}
/>
{cell.text ? (
<Markdown
className={cn(
'prose prose-sm max-w-none text-muted-foreground prose-headings:text-foreground',
'[&>h1]:mb-2 [&>h2]:mb-2',
'[&_ol>li]:pl-3',
'[--tw-prose-body:var(--foreground-muted)]',
'[--tw-prose-headings:var(--foreground-default)]',
'[--tw-prose-links:var(--foreground-muted)]',
'[--tw-prose-bold:var(--foreground-muted)]',
'[--tw-prose-quotes:var(--foreground-muted)]'
)}
>
{cell.text}
</Markdown>
) : (
<p className="text-foreground-lighter text-sm italic">This cell has no content</p>
)}
</div>
)}
<div
className={cn(
'w-full transition',
'border border-transparent rounded-md',
'hover:border-default focus-within:border-default'
)}
>
<MarkdownEditor
value={cell.text}
onBlur={handleUpdateMarkdown}
placeholder="This cell has no content"
/>
</div>
</SortableSection>
)
}
@@ -0,0 +1,11 @@
import type { EditorThemeClasses } from 'lexical'
export const markdownEditorTheme: EditorThemeClasses = {
code: 'block whitespace-pre-wrap rounded-md bg-surface-200 px-3 py-2 font-mono text-xs before:content-none after:content-none',
list: {
listitem: 'before:top-1/2 before:-translate-y-1/2 [ol>&]:before:content-[counter(item)]',
},
text: {
strikethrough: 'line-through',
},
}
@@ -0,0 +1,191 @@
import {
$convertFromMarkdownString,
$convertToMarkdownString,
registerMarkdownShortcuts,
} from '@lexical/markdown'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { EditorRefPlugin } from '@lexical/react/LexicalEditorRefPlugin'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin'
import type { LexicalEditor } from 'lexical'
import { useEffect, useRef, type RefObject } from 'react'
import { cn } from 'ui'
import { markdownEditorTheme } from './MarkdownEditor.theme'
import { MARKDOWN_EDITOR_NODES, MARKDOWN_TRANSFORMERS } from './MarkdownEditor.utils'
interface MarkdownEditorProps {
value: string
onChange?: (markdown: string) => void
onBlur?: (markdown: string) => void
placeholder?: string
autoFocus?: boolean
className?: string
}
const MarkdownClassName = cn(
'outline-none',
'prose prose-sm max-w-none px-3 py-2 text-muted-foreground prose-headings:text-foreground',
'[&>h1]:mb-2 [&>h2]:mb-2',
'[&_ol>li]:pl-3 [&_ol>li]:my-0',
'[&_ul>li]:my-0',
'[--tw-prose-body:var(--foreground-muted)]',
'[--tw-prose-headings:var(--foreground-default)]',
'[--tw-prose-links:var(--foreground-muted)]',
'[--tw-prose-bold:var(--foreground-muted)]',
'[--tw-prose-quotes:var(--foreground-muted)]'
)
const EDITOR_OWNED_SHORTCUT_KEYS = new Set(['b', 'i', 'u', 'z'])
const isEditorOwnedShortcut = (event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'key'>) =>
(event.metaKey || event.ctrlKey) && EDITOR_OWNED_SHORTCUT_KEYS.has(event.key.toLowerCase())
const EditorKeyboardShortcutsPlugin = () => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const unregisterMarkdownShortcuts = registerMarkdownShortcuts(editor, MARKDOWN_TRANSFORMERS)
/**
* [Joshen] This just ensures that the markdown editor shortcuts take precedence.
* e.g Cmd+I to italicize a text should not toggle the inline editor panel
*/
const unregisterRootListener = editor.registerRootListener((rootElement) => {
if (!rootElement) return
const handleKeyDown = (event: KeyboardEvent) => {
if (isEditorOwnedShortcut(event)) event.stopPropagation()
}
rootElement.addEventListener('keydown', handleKeyDown)
return () => rootElement.removeEventListener('keydown', handleKeyDown)
})
return () => {
unregisterMarkdownShortcuts()
unregisterRootListener()
}
}, [editor])
return null
}
const SyncExternalValuePlugin = ({
value,
isFocused,
lastKnownValueRef,
}: {
value: string
isFocused: () => boolean
lastKnownValueRef: RefObject<string>
}) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (value === lastKnownValueRef.current || isFocused()) return
lastKnownValueRef.current = value
editor.update(() => $convertFromMarkdownString(value, MARKDOWN_TRANSFORMERS))
}, [editor, value, isFocused, lastKnownValueRef])
return null
}
/**
* [Joshen] Deliberately omitted support for MD tables for now, can investigate separately
* Also deliberately having this in components/ui for now since Explorer is its only consumer
* Can shift to packages/ui if deem necessary (e.g if a second consumer needs it - e.g
* TextEditor in the side panel for the table editor when editing a text cell)
*/
export const MarkdownEditor = ({
value,
onChange,
onBlur,
placeholder,
autoFocus,
className,
}: MarkdownEditorProps) => {
const isFocusedRef = useRef(false)
const editorRef = useRef<LexicalEditor | null>(null)
const lastKnownValueRef = useRef(value)
const initialConfig = {
namespace: 'MarkdownEditor',
theme: markdownEditorTheme,
nodes: MARKDOWN_EDITOR_NODES,
onError: (error: Error) => {
throw error
},
editorState: () => $convertFromMarkdownString(value, MARKDOWN_TRANSFORMERS),
}
const handleBlur = () => {
isFocusedRef.current = false
if (!onBlur) return
editorRef.current?.getEditorState().read(() => {
const markdown = $convertToMarkdownString(MARKDOWN_TRANSFORMERS)
lastKnownValueRef.current = markdown
onBlur(markdown)
})
}
const placeholderProps = placeholder
? {
'aria-placeholder': placeholder,
placeholder: (
<div className="pointer-events-none absolute top-[11px] left-3 inset-0 text-sm italic text-foreground-lighter">
{placeholder}
</div>
),
}
: { 'aria-placeholder': undefined, placeholder: null }
return (
<LexicalComposer initialConfig={initialConfig}>
<EditorRefPlugin editorRef={editorRef} />
<div className="relative">
<RichTextPlugin
contentEditable={
<ContentEditable
autoFocus={autoFocus}
className={cn(MarkdownClassName, className)}
onFocus={() => {
isFocusedRef.current = true
}}
onBlur={handleBlur}
{...placeholderProps}
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ListPlugin />
<LinkPlugin />
<TabIndentationPlugin />
<EditorKeyboardShortcutsPlugin />
<SyncExternalValuePlugin
value={value}
isFocused={() => isFocusedRef.current}
lastKnownValueRef={lastKnownValueRef}
/>
{onChange && (
<OnChangePlugin
onChange={(editorState) =>
editorState.read(() => {
const markdown = $convertToMarkdownString(MARKDOWN_TRANSFORMERS)
lastKnownValueRef.current = markdown
onChange(markdown)
})
}
/>
)}
</div>
</LexicalComposer>
)
}
@@ -0,0 +1,50 @@
import { createHeadlessEditor } from '@lexical/headless'
import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown'
import { describe, expect, it } from 'vitest'
import { MARKDOWN_EDITOR_NODES, MARKDOWN_TRANSFORMERS } from './MarkdownEditor.utils'
function roundTrip(markdown: string): string {
const editor = createHeadlessEditor({
nodes: MARKDOWN_EDITOR_NODES,
onError: (error) => {
throw error
},
})
editor.update(() => $convertFromMarkdownString(markdown, MARKDOWN_TRANSFORMERS), {
discrete: true,
})
let result = ''
editor.getEditorState().read(() => {
result = $convertToMarkdownString(MARKDOWN_TRANSFORMERS)
})
return result
}
describe('MarkdownEditor markdown <-> editor state round trip', () => {
it.each([
['heading', '# Heading one'],
['sub-heading', '## Heading two'],
['bold', 'Some **bold** text'],
['italic', 'Some *italic* text'],
['strikethrough', 'Some ~~struck out~~ text'],
['inline code', 'Some `inline code` text'],
['blockquote', '> A quote'],
['unordered list', '- one\n- two\n- three'],
['ordered list', '1. one\n2. two\n3. three'],
['link', '[Supabase](https://supabase.com)'],
['fenced code block', '```\nconst x = 1\n```'],
['fenced code block with language', '```ts\nconst x = 1\n```'],
])('round-trips %s unchanged', (_name, markdown) => {
expect(roundTrip(markdown)).toBe(markdown)
})
it('is idempotent on a second round trip', () => {
const markdown =
'# Title\n\nSome **bold** and *italic* text with a [link](https://supabase.com).\n\n- one\n- two'
const once = roundTrip(markdown)
const twice = roundTrip(once)
expect(twice).toBe(once)
})
})
@@ -0,0 +1,23 @@
import { CodeNode } from '@lexical/code'
import { LinkNode } from '@lexical/link'
import { ListItemNode, ListNode } from '@lexical/list'
import { TRANSFORMERS } from '@lexical/markdown'
import { HeadingNode, QuoteNode } from '@lexical/rich-text'
import type { Klass, LexicalNode } from 'lexical'
/**
* [Joshen] Passthrough export here in case we want to expand more transformers
* in the future - the default TRANSFORMERS cover the basics like headings, blockquotes,
* lists, code blocks, links, and standard text formats.
* If we'd like to support tables for example then we can expand this const
*/
export const MARKDOWN_TRANSFORMERS = TRANSFORMERS
export const MARKDOWN_EDITOR_NODES: Klass<LexicalNode>[] = [
HeadingNode,
QuoteNode,
ListNode,
ListItemNode,
CodeNode,
LinkNode,
]
+8
View File
@@ -53,6 +53,12 @@
"@hcaptcha/react-hcaptcha": "^1.12.0",
"@heroicons/react": "^2.1.3",
"@hookform/resolvers": "^3.1.1",
"@lexical/code": "0.51.0",
"@lexical/link": "0.51.0",
"@lexical/list": "0.51.0",
"@lexical/markdown": "0.51.0",
"@lexical/react": "0.51.0",
"@lexical/rich-text": "0.51.0",
"@mjackson/multipart-parser": "^0.10.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/server": "^2.0.0",
@@ -116,6 +122,7 @@
"idb": "^8.0.2",
"ip-num": "^1.5.1",
"json-logic-js": "^2.0.2",
"lexical": "0.51.0",
"libpg-query": "17.6.0",
"lodash": "catalog:",
"lodash-es": "catalog:",
@@ -168,6 +175,7 @@
"@faker-js/faker": "^9.9.0",
"@graphql-codegen/cli": "5.0.5",
"@graphql-typed-document-node/core": "^3.2.0",
"@lexical/headless": "0.51.0",
"@tailwindcss/postcss": "^4.2.4",
"@tailwindcss/vite": "4.2.4",
"@tanstack/devtools-vite": "^0.6.0",
+621 -48
View File
File diff suppressed because it is too large. Load diff