diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index f566ed0134..89d5b58008 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -173,6 +173,10 @@ export default defineConfig({ }, ], }, + { + label: 'Releases', + items: [{slug: 'editor/releases/v7', label: 'v7'}], + }, ], }, { diff --git a/apps/docs/package.json b/apps/docs/package.json index b8fdc38ba1..3ffee321da 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -17,6 +17,7 @@ "@astrojs/starlight-tailwind": "^5.0.0", "@portabletext/editor": "workspace:*", "@portabletext/keyboard-shortcuts": "workspace:*", + "@portabletext/markdown": "workspace:*", "@portabletext/plugin-markdown-shortcuts": "workspace:*", "@portabletext/plugin-typography": "workspace:*", "@portabletext/react": "^6.2.0", diff --git a/apps/docs/src/components/markdown-demo/markdown-demo.tsx b/apps/docs/src/components/markdown-demo/markdown-demo.tsx new file mode 100644 index 0000000000..a32129d8eb --- /dev/null +++ b/apps/docs/src/components/markdown-demo/markdown-demo.tsx @@ -0,0 +1,995 @@ +import { + defineContainer, + defineLeaf, + defineSchema, + EditorProvider, + PortableTextEditable, + useEditor, + type PortableTextBlock, +} from '@portabletext/editor' +import { + ContainerPlugin, + EventListenerPlugin, + LeafPlugin, +} from '@portabletext/editor/plugins' +import { + DefaultBlockquoteObjectRenderer, + DefaultCalloutRenderer, + DefaultHorizontalRuleRenderer, + DefaultImageRenderer, + DefaultListRenderer, + DefaultTableRenderer, + markdownToPortableText, + portableTextToMarkdown, +} from '@portabletext/markdown' +import {MarkdownShortcutsPlugin} from '@portabletext/plugin-markdown-shortcuts' +import {useEffect, useRef, useState} from 'react' + +// Schema mirrors @portabletext/markdown's defaults so values produced by +// markdownToPortableText pass the editor's schema validation. Lists are +// declared as structural `list` block-objects (not flat `lists` items) so +// list items can hold rich nested content like code blocks and callouts. +const schemaDefinition = defineSchema({ + block: { + fields: [{name: 'checked', type: 'boolean'}], + }, + styles: [ + {name: 'normal'}, + {name: 'h1'}, + {name: 'h2'}, + {name: 'h3'}, + {name: 'h4'}, + {name: 'h5'}, + {name: 'h6'}, + ], + decorators: [ + {name: 'strong'}, + {name: 'em'}, + {name: 'code'}, + {name: 'strike-through'}, + ], + annotations: [ + { + name: 'link', + fields: [ + {name: 'href', type: 'string'}, + {name: 'title', type: 'string'}, + ], + }, + ], + blockObjects: [ + { + name: 'callout', + fields: [ + {name: 'tone', type: 'string'}, + { + name: 'content', + type: 'array', + of: [ + { + type: 'block', + decorators: [ + {name: 'strong'}, + {name: 'em'}, + {name: 'code'}, + {name: 'strike-through'}, + ], + styles: [{name: 'normal'}], + annotations: [ + { + name: 'link', + fields: [ + {name: 'href', type: 'string'}, + {name: 'title', type: 'string'}, + ], + }, + ], + }, + ], + }, + ], + }, + { + name: 'blockquote', + fields: [ + { + name: 'content', + type: 'array', + of: [ + { + type: 'block', + decorators: [ + {name: 'strong'}, + {name: 'em'}, + {name: 'code'}, + {name: 'strike-through'}, + ], + styles: [{name: 'normal'}], + annotations: [ + { + name: 'link', + fields: [ + {name: 'href', type: 'string'}, + {name: 'title', type: 'string'}, + ], + }, + ], + }, + { + type: 'code-block', + fields: [ + {name: 'language', type: 'string'}, + { + name: 'lines', + type: 'array', + of: [ + { + type: 'block', + styles: [], + decorators: [], + annotations: [], + inlineObjects: [], + }, + ], + }, + ], + }, + { + type: 'image', + fields: [ + {name: 'src', type: 'string'}, + {name: 'alt', type: 'string'}, + {name: 'title', type: 'string'}, + ], + }, + ], + }, + ], + }, + { + name: 'code-block', + fields: [ + {name: 'language', type: 'string'}, + { + name: 'lines', + type: 'array', + of: [ + { + type: 'block', + styles: [], + decorators: [], + annotations: [], + inlineObjects: [], + }, + ], + }, + ], + }, + {name: 'horizontal-rule'}, + {name: 'html', fields: [{name: 'html', type: 'string'}]}, + { + name: 'image', + fields: [ + {name: 'src', type: 'string'}, + {name: 'alt', type: 'string'}, + {name: 'title', type: 'string'}, + ], + }, + { + name: 'list', + fields: [ + {name: 'kind', type: 'string'}, + { + name: 'items', + type: 'array', + of: [ + { + type: 'object', + name: 'list-item', + fields: [ + {name: 'checked', type: 'boolean'}, + { + name: 'content', + type: 'array', + of: [ + { + type: 'block', + decorators: [ + {name: 'strong'}, + {name: 'em'}, + {name: 'code'}, + {name: 'strike-through'}, + ], + styles: [{name: 'normal'}], + annotations: [ + { + name: 'link', + fields: [ + {name: 'href', type: 'string'}, + {name: 'title', type: 'string'}, + ], + }, + ], + }, + { + type: 'code-block', + fields: [ + {name: 'language', type: 'string'}, + { + name: 'lines', + type: 'array', + of: [ + { + type: 'block', + styles: [], + decorators: [], + annotations: [], + inlineObjects: [], + }, + ], + }, + ], + }, + { + type: 'callout', + fields: [ + {name: 'tone', type: 'string'}, + { + name: 'content', + type: 'array', + of: [ + { + type: 'block', + decorators: [ + {name: 'strong'}, + {name: 'em'}, + {name: 'code'}, + {name: 'strike-through'}, + ], + styles: [{name: 'normal'}], + annotations: [ + { + name: 'link', + fields: [ + {name: 'href', type: 'string'}, + {name: 'title', type: 'string'}, + ], + }, + ], + }, + ], + }, + ], + }, + {type: 'list'}, + {type: 'image'}, + ], + }, + ], + }, + ], + }, + ], + }, + { + name: 'table', + fields: [ + {name: 'headerRows', type: 'number'}, + { + name: 'rows', + type: 'array', + of: [ + { + type: 'object', + name: 'row', + fields: [ + { + name: 'cells', + type: 'array', + of: [ + { + type: 'object', + name: 'cell', + fields: [ + { + name: 'value', + type: 'array', + of: [ + { + type: 'block', + decorators: [ + {name: 'strong'}, + {name: 'em'}, + {name: 'code'}, + {name: 'strike-through'}, + ], + styles: [{name: 'normal'}], + annotations: [ + { + name: 'link', + fields: [ + {name: 'href', type: 'string'}, + {name: 'title', type: 'string'}, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + inlineObjects: [ + { + name: 'image', + fields: [ + {name: 'src', type: 'string'}, + {name: 'alt', type: 'string'}, + {name: 'title', type: 'string'}, + ], + }, + ], +}) + +const tableContainer = defineContainer({ + scope: '$..table', + field: 'rows', + render: ({attributes, children, node}) => { + const headerRows = (node as {headerRows?: number}).headerRows ?? 0 + return ( + + {children} +
+ ) + }, +}) + +const rowContainer = defineContainer({ + scope: '$..table.row', + field: 'cells', + render: ({attributes, children}) => {children}, +}) + +const cellContainer = defineContainer({ + scope: '$..table.row.cell', + field: 'value', + render: ({attributes, children}) => ( + + {children} + + ), +}) + +const calloutToneClassName: Record = { + note: 'border-sky-400 bg-sky-50 text-sky-900 dark:bg-sky-950/40 dark:text-sky-100', + tip: 'border-emerald-400 bg-emerald-50 text-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100', + important: + 'border-violet-400 bg-violet-50 text-violet-900 dark:bg-violet-950/40 dark:text-violet-100', + warning: + 'border-amber-400 bg-amber-50 text-amber-900 dark:bg-amber-950/40 dark:text-amber-100', + caution: + 'border-rose-400 bg-rose-50 text-rose-900 dark:bg-rose-950/40 dark:text-rose-100', +} + +const calloutContainer = defineContainer({ + scope: '$..callout', + field: 'content', + render: ({attributes, children, node}) => { + const tone = (node as {tone?: string}).tone ?? 'note' + const toneClass = calloutToneClassName[tone] ?? calloutToneClassName.note! + return ( + + ) + }, +}) + +const calloutBlockContainer = defineContainer({ + scope: '$..callout.block', + field: 'children', + render: ({attributes, children}) => ( +

+ {children} +

+ ), +}) + +const blockquoteContainer = defineContainer({ + scope: '$..blockquote', + field: 'content', + render: ({attributes, children}) => ( +
+ {children} +
+ ), +}) + +const codeBlockContainer = defineContainer({ + scope: '$..code-block', + field: 'lines', + render: ({attributes, children}) => ( +
+      {children}
+    
+ ), +}) + +const listContainer = defineContainer({ + scope: '$..list', + field: 'items', + render: ({attributes, children, node}) => { + const kind = (node as {kind?: string}).kind ?? 'bullet' + if (kind === 'number') { + return ( +
    + {children} +
+ ) + } + return ( +
    + {children} +
+ ) + }, +}) + +const listItemContainer = defineContainer({ + scope: '$..list.list-item', + field: 'content', + render: ({attributes, children, node}) => { + const checked = (node as {checked?: boolean}).checked + if (typeof checked === 'boolean') { + return ( +
  • + +
    + {children} +
    +
  • + ) + } + return
  • {children}
  • + }, +}) + +const codeBlockSpanLeaf = defineLeaf({ + scope: '$..code-block.block.span', + render: ({attributes, children}) => ( + + {children} + + ), +}) + +const horizontalRuleLeaf = defineLeaf({ + scope: '$..horizontal-rule', + render: ({attributes, children}) => ( +
    +
    +
    +
    + {children} +
    + ), +}) + +const imageLeaf = defineLeaf({ + scope: '$..image', + render: ({attributes, children, node, focused, selected}) => { + const v = node as {src?: string; alt?: string; title?: string} + return ( +
    + {children} +
    + {v.alt + {v.alt && ( +
    + {v.alt} +
    + )} +
    +
    + ) + }, +}) + +const markdownShortcutsProps = { + boldDecorator: ({ + context, + }: { + context: {schema: {decorators: Array<{name: string}>}} + }) => context.schema.decorators.find((d) => d.name === 'strong')?.name, + italicDecorator: ({ + context, + }: { + context: {schema: {decorators: Array<{name: string}>}} + }) => context.schema.decorators.find((d) => d.name === 'em')?.name, + codeDecorator: ({ + context, + }: { + context: {schema: {decorators: Array<{name: string}>}} + }) => context.schema.decorators.find((d) => d.name === 'code')?.name, + strikeThroughDecorator: ({ + context, + }: { + context: {schema: {decorators: Array<{name: string}>}} + }) => + context.schema.decorators.find((d) => d.name === 'strike-through')?.name, + defaultStyle: ({ + context, + }: { + context: {schema: {styles: Array<{value?: string; name: string}>}} + }) => context.schema.styles[0]?.value ?? context.schema.styles[0]?.name, + headingStyle: ({ + context, + props, + }: { + context: {schema: {styles: Array<{name: string}>}} + props: {level: number} + }) => context.schema.styles.find((s) => s.name === `h${props.level}`)?.name, + horizontalRuleObject: ({ + context, + }: { + context: {schema: {blockObjects: Array<{name: string}>}} + }) => { + const schemaType = context.schema.blockObjects.find( + (o) => o.name === 'horizontal-rule', + ) + if (!schemaType) return undefined + return {_type: schemaType.name} + }, + linkObject: ({ + context, + props, + }: { + context: { + schema: { + annotations: Array<{ + name: string + fields: Array<{name: string; type: string}> + }> + } + } + props: {href: string} + }) => { + const schemaType = context.schema.annotations.find((a) => a.name === 'link') + const hrefField = schemaType?.fields.find( + (f) => f.name === 'href' && f.type === 'string', + ) + if (!schemaType || !hrefField) return undefined + return {_type: schemaType.name, [hrefField.name]: props.href} + }, +} as const + +const markdownOptions = { + types: { + // md → pt: split fenced code into one text block per line, mirroring + // playground's plugin.markdown-deserializer.tsx. + code: ({ + context, + value, + isInline, + }: { + context: {keyGenerator: () => string} + value: {language: string | undefined; code: string} + isInline: boolean + }) => { + if (isInline) { + return undefined + } + const sourceLines = (value.code ?? '').split('\n') + // markdown-it always emits a trailing empty line for fenced blocks. + if ( + sourceLines.length > 0 && + sourceLines[sourceLines.length - 1] === '' + ) { + sourceLines.pop() + } + const lines = sourceLines.map((text) => ({ + _type: 'block', + _key: context.keyGenerator(), + style: 'normal', + children: [ + { + _type: 'span', + _key: context.keyGenerator(), + text, + marks: [], + }, + ], + markDefs: [], + })) + return { + _type: 'code-block', + _key: context.keyGenerator(), + language: value.language, + lines, + } + }, + table: ({ + context, + value, + }: { + context: {keyGenerator: () => string} + value: { + headerRows: number | undefined + rows: Array<{_key: string; _type: 'row'; cells: unknown[]}> + } + }) => ({ + _key: context.keyGenerator(), + _type: 'table', + headerRows: value.headerRows, + rows: value.rows, + }), + list: ({ + context, + value, + }: { + context: {keyGenerator: () => string} + value: { + kind: 'bullet' | 'number' | 'task' + items: Array + } + }) => ({ + _key: context.keyGenerator(), + _type: 'list', + kind: value.kind, + items: value.items, + }), + blockquote: ({ + context, + value, + }: { + context: {keyGenerator: () => string} + value: {content: Array} + }) => ({ + _key: context.keyGenerator(), + _type: 'blockquote', + content: value.content, + }), + }, +} as const + +const kitchenSink = `# Portable Text Editor v7 + +Two new APIs, one new plugin, the same editor you've been using. + +## Containers + +In v7, nested editable structures are first-class. A callout, a code block, a list - all *containers*. Each one declares its own sub-schema, and the editor enforces it everywhere - typing, pasting, decorator toggles, drag. + +> [!NOTE] +> A container's content is editable Portable Text. Toggle marks, paste rich content, drag across boundaries - it all works at every depth. + +> [!TIP] +> Your existing **flat** content keeps working. Containers are opt-in. + +## What you write + +Two new APIs cover the entire shape: \`defineContainer\` for nodes that hold editable children, \`defineLeaf\` for nodes that own their own DOM: + +\`\`\`ts +import {defineContainer} from '@portabletext/editor' + +const calloutContainer = defineContainer({ + scope: '$..callout', + field: 'content', + render: ({attributes, children}) => ( + + ), +}) +\`\`\` + +The container says *where* in the schema it applies and *how* to render the wrapper. The editor handles selection, paste, schema enforcement, drag-and-drop. + +## Lists, your call + +The flat \`listItem\` + \`level\` shape from v6 still works. Pick it when your lists are simple and you'd rather not reach for containers. + +When you *want* containers - because you want list items to hold a code block, or a callout, or another list - declare a \`list\` container in your schema and the editor will treat each item as a container too. This demo registers them so we can show the shape: + +- A bullet item. +- A list item that holds a code block: + \`\`\`ts + const works = 'A code block, inside a list item' + \`\`\` +- A list item that holds a callout: + > [!TIP] + > A callout, inside a list item. +- A list item that holds another list: + - Two levels in. + - Three levels in. Same \`defineContainer\` registration handles every depth. + + ![Image, three lists deep](https://placehold.co/400x80/0ea5e9/white?text=Image+three+lists+deep) + +And task lists, with sub-tasks: + +- [x] Schema enforcement applies at every depth. +- [ ] Migrate your custom plugins. + - [ ] Audit \`renderBlock\` callbacks. + - [ ] Register containers where it pays off. + +## Markdown round-trips + +\`@portabletext/markdown\` exposes \`markdownToPortableText\` and \`portableTextToMarkdown\` and handles every shape in this document. + +| Feature | v6 | v7 | +| ----------- | --------------------- | ---------------------- | +| Code blocks | flat string | container, line blocks | +| Tables | block-object, opaque | container, editable | +| Callouts | not supported | container with tone | +| Lists | flat \`listItem\` field | flat *or* container | + +## Live, both ways + +Type on either side. The Portable Text shape is the truth - markdown is one *serialization* of it, this editor is another. + +> Blockquotes are containers in v7, so they can hold whatever the schema allows - a code block, for example: +> +> \`\`\`ts +> markdownToPortableText(input) +> // ↓ +> portableTextToMarkdown(value) === input +> \`\`\` +> +> Same Portable Text. More shapes the editor can build with it. + +--- + +![Portable Text Editor v7](https://placehold.co/600x200/0ea5e9/white?text=Portable+Text+v7) +` + +export function MarkdownDemo() { + const [markdown, setMarkdown] = useState(kitchenSink) + const [initialValue] = useState>(() => + markdownToPortableText(kitchenSink, markdownOptions), + ) + const focused = useRef<'markdown' | 'editor' | null>(null) + const [value, setValue] = useState>(initialValue) + const replaceEditorValueRef = useRef< + ((blocks: Array) => void) | null + >(null) + + // Markdown -> editor: when the textarea has focus, deserialize and feed. + useEffect(() => { + if (focused.current !== 'markdown') { + return + } + const blocks = markdownToPortableText(markdown, markdownOptions) + replaceEditorValueRef.current?.(blocks) + }, [markdown]) + + // Editor -> markdown: when the editor has focus, derive markdown from value. + useEffect(() => { + if (focused.current !== 'editor') { + return + } + setMarkdown( + portableTextToMarkdown(value, { + types: { + 'blockquote': DefaultBlockquoteObjectRenderer, + 'callout': DefaultCalloutRenderer, + 'code-block': ({value}) => { + const v = value as { + language?: string + lines?: Array<{ + children?: Array<{_type: string; text?: string}> + }> + } + const code = (v.lines ?? []) + .map((line) => + (line.children ?? []) + .map((c) => (c._type === 'span' ? (c.text ?? '') : '')) + .join(''), + ) + .join('\n') + return `\`\`\`${v.language ?? ''}\n${code}\n\`\`\`` + }, + 'horizontal-rule': DefaultHorizontalRuleRenderer, + 'image': DefaultImageRenderer, + 'list': DefaultListRenderer, + 'table': DefaultTableRenderer, + }, + }), + ) + }, [value]) + + return ( +
    +
    +
    + + Markdown + +
    +