diff --git a/packages/core/src/extensions/commands.test.ts b/packages/core/src/extensions/commands.test.ts index 6d556b7f..f09cd746 100644 --- a/packages/core/src/extensions/commands.test.ts +++ b/packages/core/src/extensions/commands.test.ts @@ -76,6 +76,36 @@ describe('insertMarkdown', () => { `) }) + it('keeps a block suffix outside and places the cursor in its paragraph', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('BeforeAfter'))) + + editor.commands.insertMarkdown('```collection\ncollection: people\n```', { + selection: 'after-block', + }) + editor.commands.insertText({ text: 'Next ' }) + + expect(docToMarkdown(fixture.doc)).toBe( + 'Before\n\n```collection\ncollection: people\n```\n\nNext After\n', + ) + }) + + it('creates a trailing paragraph when an inserted block ends the document', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('Before'))) + + editor.commands.insertMarkdown('```collection\ncollection: people\n```', { + selection: 'after-block', + }) + editor.commands.insertText({ text: 'Next' }) + + expect(docToMarkdown(fixture.doc)).toBe( + 'Before\n\n```collection\ncollection: people\n```\n\nNext\n', + ) + }) + it('undoes an inserted fragment as a single history entry', () => { using fixture = setupFixture() const { editor, n } = fixture diff --git a/packages/core/src/extensions/commands.ts b/packages/core/src/extensions/commands.ts index 6b9f7ef5..0dd7e90b 100644 --- a/packages/core/src/extensions/commands.ts +++ b/packages/core/src/extensions/commands.ts @@ -10,6 +10,15 @@ import { markdownToDoc } from '../converters/md-to-pm.ts' import { isNodeOfType, type NodeName } from './node-names.ts' import { getNodeBuildersForSchema } from './schema.ts' +export interface InsertMarkdownOptions { + /** + * Where the caret lands after a code-block fragment. `after-block` keeps + * any paragraph suffix outside the inserted block and creates an ordinary + * following paragraph when needed. + */ + selection?: 'end' | 'after-block' +} + function selectText(anchor: number, head?: number): Command { return (state, dispatch) => { if (dispatch) { @@ -32,7 +41,7 @@ function selectTextBetween($anchor: ResolvedPos, $head: ResolvedPos, bias?: numb } } -function insertMarkdown(markdown: string): Command { +function insertMarkdown(markdown: string, options: InsertMarkdownOptions = {}): Command { return (state, dispatch) => { if (!markdown.trim()) return false const nodes = getNodeBuildersForSchema(state.schema) @@ -42,14 +51,30 @@ function insertMarkdown(markdown: string): Command { content.childCount === 1 && isNodeOfType(content.child(0), 'paragraph') const slice = isSingleParagraph ? new Slice(content, 1, 1) - : new Slice(content, 0, Slice.maxOpen(content).openEnd) + : options.selection === 'after-block' + ? new Slice(content, 0, 0) + : new Slice(content, 0, Slice.maxOpen(content).openEnd) if (dispatch) { const tr = state.tr const selection = tr.selection if (!isTextSelection(selection) || !selection.empty) { tr.setSelection(TextSelection.near(selection.$from)) } - dispatch(tr.replaceSelection(slice).scrollIntoView()) + tr.replaceSelection(slice) + if (options.selection === 'after-block') { + const $selection = tr.selection.$from + for (let depth = $selection.depth; depth > 0; depth -= 1) { + if (!isNodeOfType($selection.node(depth), 'codeBlock')) continue + const after = $selection.after(depth) + const nodeAfter = tr.doc.resolve(after).nodeAfter + if (nodeAfter === null || !isNodeOfType(nodeAfter, 'paragraph')) { + tr.insert(after, nodes.paragraph()) + } + tr.setSelection(TextSelection.near(tr.doc.resolve(after), 1)) + break + } + } + dispatch(tr.scrollIntoView()) } return true } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 756ed5ee..82e7d1cd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -85,6 +85,7 @@ export { type InlineMarkOptions, } from './extensions/inline-text-to-mark-chunks.ts' export { EDITOR_KEY_BINDINGS } from './extensions/key-bindings.ts' +export type { InsertMarkdownOptions } from './extensions/commands.ts' export { defineLinkClickHandler, type LinkClickHandler, diff --git a/packages/react/src/components/code-block-view.module.css b/packages/react/src/components/code-block-view.module.css index b32c14f4..6011de92 100644 --- a/packages/react/src/components/code-block-view.module.css +++ b/packages/react/src/components/code-block-view.module.css @@ -10,6 +10,23 @@ display: none; } +.Root[data-custom] pre { + display: none; +} + +/* Host widgets own their list layout. Keep the outer editor's prose list + * rules out, while a Markdown view nested inside the widget remains styled by + * its own `.ProseMirror` surface. */ +.Root .CustomContent ul:not(.CustomContent :global(.ProseMirror) ul), +.Root .CustomContent ol:not(.CustomContent :global(.ProseMirror) ol) { + padding-left: 0; + list-style: none; +} + +.Root .CustomContent li:not(.CustomContent :global(.ProseMirror) li) { + margin-top: 0; +} + .Root[data-preview] .Toolbar { display: none; } diff --git a/packages/react/src/components/code-block-view.module.d.css.ts b/packages/react/src/components/code-block-view.module.d.css.ts index 0a101365..057c0333 100644 --- a/packages/react/src/components/code-block-view.module.d.css.ts +++ b/packages/react/src/components/code-block-view.module.d.css.ts @@ -3,6 +3,16 @@ declare const styles = { 'Root': '' as string, 'Root': '' as string, 'Root': '' as string, + 'Root': '' as string, + 'CustomContent': '' as string, + 'CustomContent': '' as string, + 'Root': '' as string, + 'CustomContent': '' as string, + 'CustomContent': '' as string, + 'Root': '' as string, + 'CustomContent': '' as string, + 'CustomContent': '' as string, + 'Root': '' as string, 'Toolbar': '' as string, 'Preview': '' as string, 'MermaidPreview': '' as string, diff --git a/packages/react/src/components/code-block-view.test.tsx b/packages/react/src/components/code-block-view.test.tsx index 79915373..1a744a40 100644 --- a/packages/react/src/components/code-block-view.test.tsx +++ b/packages/react/src/components/code-block-view.test.tsx @@ -1,7 +1,8 @@ import '../testing/index.ts' +import { isNodeOfType } from '@meowdown/core' import { readClipboard } from '@meowdown/vitest/clipboard' -import { TextSelection } from '@prosekit/pm/state' +import { NodeSelection, TextSelection } from '@prosekit/pm/state' import type { EditorView } from '@prosekit/pm/view' import { createRef } from 'react' import { describe, expect, it, vi } from 'vitest' @@ -21,6 +22,151 @@ const tokens = page.locate('.ProseMirror pre code [class*="tok-"]') const CODE_BLOCK_MD = '```rust\nfn main() {}\n```' describe('code block language selector', () => { + it('falls back to the built-in code block when the host declines to render it', async () => { + await render( null} />) + await expect.element(selector).toHaveTextContent('Rust') + await expect.element(page.locate('.ProseMirror pre[data-language="rust"]')).toBeVisible() + }) + + it('keeps host widget lists outside the editor prose list styling', async () => { + await render( + { + return language === 'collection' ? ( +
    +
  • Person
  • +
+ ) : null + }} + />, + ) + + const list = page.getByTestId('collection-list').element() + const item = page.getByTestId('collection-list-item').element() + expect(getComputedStyle(list).listStyleType).toBe('none') + expect(getComputedStyle(list).paddingLeft).toBe('0px') + expect(getComputedStyle(item).marginTop).toBe('0px') + }) + + it('renders host content in document order and updates the body as one undoable change', async () => { + const ref = createRef() + const onDocChange = vi.fn() + const markdown = 'before\n\n```collection\ntag: people\n```\n\nafter' + + await render( + { + return language === 'collection' ? ( + + ) : null + }} + />, + ) + + const customView = page.getByTestId('collection-view') + await expect.element(customView).toHaveTextContent('tag: people') + await expect + .element(page.locate('.ProseMirror pre[data-language="collection"]')) + .not.toBeVisible() + const before = page.getByText('before').element() + const custom = customView.element() + const after = page.getByText('after').element() + expect(before.compareDocumentPosition(custom) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(custom.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + const editor = ref.current?.editor + if (!editor) throw new Error('editor not mounted') + let codeBlockPosition: number | undefined + editor.state.doc.descendants((node, pos) => { + if (isNodeOfType(node, 'codeBlock')) { + codeBlockPosition = pos + return false + } + return true + }) + if (codeBlockPosition == null) throw new Error('code block not found') + editor.view.dispatch( + editor.state.tr.setSelection( + TextSelection.near(editor.state.doc.resolve(codeBlockPosition + 1)), + ), + ) + await expect.element(page.locate('.ProseMirror pre[data-language="collection"]')).toBeVisible() + await expect.element(customView).toBeVisible() + const currentCodeBlock = editor.state.doc.nodeAt(codeBlockPosition) + if (!currentCodeBlock) throw new Error('code block not found') + editor.view.dispatch( + editor.state.tr.setSelection( + TextSelection.near(editor.state.doc.resolve(codeBlockPosition + currentCodeBlock.nodeSize)), + ), + ) + await expect + .element(page.locate('.ProseMirror pre[data-language="collection"]')) + .not.toBeVisible() + + await customView.click() + await vi.waitFor(() => { + expect(ref.current?.getMarkdown()).toContain('```collection\ntag: people\nview: board\n```') + }) + expect(onDocChange).toHaveBeenCalled() + + ref.current?.editor?.commands.undo() + await vi.waitFor(() => { + expect(ref.current?.getMarkdown()).toBe(`${markdown}\n`) + }) + await expect.element(customView).toHaveTextContent('tag: people') + }) + + it('removes a host-rendered code block as a node selection and restores it with undo', async () => { + const ref = createRef() + const markdown = 'before\n\n```collection\ntag: people\n```\n\nafter' + + await render( + { + return language === 'collection' ? ( +
Collection
+ ) : null + }} + />, + ) + + const editor = ref.current?.editor + if (!editor) throw new Error('editor not mounted') + let codeBlockPosition: number | undefined + editor.state.doc.descendants((node, pos) => { + if (isNodeOfType(node, 'codeBlock')) { + codeBlockPosition = pos + return false + } + return true + }) + if (codeBlockPosition == null) throw new Error('code block not found') + editor.view.dispatch( + editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, codeBlockPosition)), + ) + editor.view.focus() + await userEvent.keyboard('{Backspace}') + + await expect.element(page.getByTestId('collection-view')).not.toBeInTheDocument() + expect(ref.current?.getMarkdown()).not.toContain('```collection') + + ref.current?.editor?.commands.undo() + await expect.element(page.getByTestId('collection-view')).toBeInTheDocument() + expect(ref.current?.getMarkdown()).toBe(`${markdown}\n`) + }) + it('shows the current language for a code block', async () => { await render() await expect.element(selector).toBeInTheDocument() diff --git a/packages/react/src/components/code-block-view.tsx b/packages/react/src/components/code-block-view.tsx index cd787737..87a87040 100644 --- a/packages/react/src/components/code-block-view.tsx +++ b/packages/react/src/components/code-block-view.tsx @@ -5,6 +5,7 @@ import { type CodeBlockAttrs, type LanguageItem, } from '@meowdown/core' +import { Fragment } from '@prosekit/pm/model' import { TextSelection } from '@prosekit/pm/state' import type { ReactNodeViewProps } from '@prosekit/react' import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react' @@ -15,6 +16,7 @@ import { useState, type MouseEvent, type ReactElement, + type ReactNode, } from 'react' import { useBeautifulMermaid } from '../hooks/use-beautiful-mermaid.ts' @@ -26,16 +28,60 @@ import { CopyButton } from './copy-button.tsx' import { MathRender } from './math-render.tsx' import { MermaidRender } from './mermaid-render.tsx' -export function CodeBlockView(props: ReactNodeViewProps): ReactElement { +export interface CodeBlockRenderOptions { + /** + * The language written after the opening fence, or an empty string. + */ + language: string + /** + * The code block body without its fences. + */ + code: string + /** + * Replace the block body as one undoable editor transaction. + */ + updateCode: (code: string) => void +} + +/** + * Return custom content for a code block, or null to use Meowdown's built-in view. + */ +export type CodeBlockRenderer = (options: CodeBlockRenderOptions) => ReactNode | null + +/** + * Props for {@link CodeBlockView}. + */ +export interface CodeBlockViewProps extends ReactNodeViewProps { + renderCodeBlock?: CodeBlockRenderer +} + +export function CodeBlockView(props: CodeBlockViewProps): ReactElement { const { node, view, getPos, decorations, selected, setAttrs, contentRef } = props const attrs = node.attrs as CodeBlockAttrs const language = attrs.language || '' - const isMath = language === 'math' - const isMermaid = language === 'mermaid' const code = node.textContent + const updateCode = useCallback( + (nextCode: string) => { + const pos = getPos() + if (pos == null) return + const currentNode = view.state.doc.nodeAt(pos) + if (currentNode?.type !== view.state.schema.nodes.codeBlock) return + const from = pos + 1 + const to = from + currentNode.content.size + const content = nextCode === '' ? Fragment.empty : view.state.schema.text(nextCode) + view.dispatch(view.state.tr.replaceWith(from, to, content)) + }, + [getPos, view], + ) + const caretInside = decorations.some(isCodeBlockPreviewHiddenDecoration) + const customContent = props.renderCodeBlock?.({ language, code, updateCode }) ?? null + const hasCustomContent = customContent !== null + const customPreviewOnly = hasCustomContent && !caretInside + const isMath = !hasCustomContent && language === 'math' + const isMermaid = !hasCustomContent && language === 'mermaid' const katex = useKaTeX(isMath) const mermaid = useBeautifulMermaid(isMermaid) @@ -48,7 +94,7 @@ export function CodeBlockView(props: ReactNodeViewProps): ReactElement { // caret inside, the source stays on top and the preview updates live below. // An empty or not-yet-rendered block keeps its source, so it never turns // invisible and unclickable. - const previewOnly = showPreview && !caretInside && code.trim() !== '' + const previewOnly = !hasCustomContent && showPreview && !caretInside && code.trim() !== '' const focusSource = useCallback( (event: MouseEvent) => { @@ -70,14 +116,25 @@ export function CodeBlockView(props: ReactNodeViewProps): ReactElement { ) return ( -
+

-      {
+      {hasCustomContent ? (
+        
+ {customContent} +
+ ) : ( /* Skip rendering the toolbar during dragging to improve the performance of rendering the drag preview image in Safari */ - selected ? null : ( - - ) - } + !selected && + )} {showMathPreview && ( void + insertMarkdown: (markdown: string, options?: InsertMarkdownOptions) => void /** * Returns the current Markdown and selection. diff --git a/packages/react/src/extensions/code-block-view.ts b/packages/react/src/extensions/code-block-view.ts index 412d6f21..40c45dbc 100644 --- a/packages/react/src/extensions/code-block-view.ts +++ b/packages/react/src/extensions/code-block-view.ts @@ -1,15 +1,27 @@ import { defineCodeBlockPreviewPlugin } from '@meowdown/core' import { union, type Extension } from '@prosekit/core' import { defineReactNodeView, type ReactNodeViewComponent } from '@prosekit/react' +import { createElement } from 'react' -import { CodeBlockView } from '../components/code-block-view.tsx' +import { + CodeBlockView, + type CodeBlockRenderer, + type CodeBlockViewProps, +} from '../components/code-block-view.tsx' + +export function defineCodeBlockView(renderCodeBlock?: CodeBlockRenderer): Extension { + const component: ReactNodeViewComponent = (props) => { + return createElement(CodeBlockView, { + ...props, + renderCodeBlock, + } satisfies CodeBlockViewProps) + } -export function defineCodeBlockView(): Extension { return union( defineReactNodeView({ name: 'codeBlock', contentAs: 'code', - component: CodeBlockView satisfies ReactNodeViewComponent, + component, }), // Decorates the code block under the caret; `CodeBlockView` reads it to // decide whether the preview shows alone or below the source. diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d187d059..4efce28d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,5 @@ export { MeowdownEditor, type EditorMode, type EditorProps } from './components/editor.tsx' +export type { CodeBlockRenderer, CodeBlockRenderOptions } from './components/code-block-view.tsx' export { MarkdownView, type MarkdownViewProps,