Files
Joshen Lim c7880e328f 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 -->
2026-09-22 19:09:25 +08:00

51 lines
1.7 KiB
TypeScript

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