mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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,
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
Generated
+621
-48
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user