Add Notion style editable markdown toggle blocks (#1584)

* implement h1 and text toggle

* Review:
  - Details parsing is now depth-aware, so nested <details> blocks are not partially consumed.
  - Existing details blocks with raw HTML inside the summary/body now fall back to Orca’s raw HTML passthrough instead of
    becoming editable toggle nodes and escaping tags.
  - Simple supported details blocks still become editable rich toggle nodes.
  - Added regression tests for raw HTML inside details and nested details.

* placeholder and fix enter behaviour

* review findings

* Review: align toggle commands with grouped slash menu

Co-authored-by: Orca <help@stably.ai>

* Review: finish toggle command rebase fixes

Co-authored-by: Orca <help@stably.ai>

* Review: preserve unsupported details HTML

Co-authored-by: Orca <help@stably.ai>

* Review: delete toggle body back into summary

Co-authored-by: Orca <help@stably.ai>

* Review: make empty toggle backspace two-step

Co-authored-by: Orca <help@stably.ai>

* Review: make empty toggle enter exit

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Pim de Jong 2026-05-28 23:48:59 +02:00 committed by GitHub
parent 4b32afeff2
commit 5c2ed9c6ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1040 additions and 4 deletions

View File

@ -68,6 +68,7 @@
"@parcel/watcher": "^2.5.6",
"@tanstack/react-virtual": "^3.13.24",
"@tiptap/extension-code-block-lowlight": "^3.22.5",
"@tiptap/extension-details": "^3.22.5",
"@tiptap/extension-image": "^3.22.5",
"@tiptap/extension-link": "^3.22.5",
"@tiptap/extension-mathematics": "3.22.5",

View File

@ -49,6 +49,9 @@ importers:
'@tiptap/extension-code-block-lowlight':
specifier: ^3.22.5
version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(highlight.js@11.11.1)(lowlight@3.3.0)
'@tiptap/extension-details':
specifier: ^3.22.5
version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)))(@tiptap/pm@3.22.5)
'@tiptap/extension-image':
specifier: ^3.22.5
version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))
@ -2549,6 +2552,13 @@ packages:
peerDependencies:
'@tiptap/core': 3.22.5
'@tiptap/extension-details@3.22.5':
resolution: {integrity: sha512-+vg7wSO9DL8veAzC4jlHu4lQ4qL2iRxj/ONTfP+jnffN0TzrjAdkMLCUEX059gYyks22AXgoI5vEzWs0K7yeuw==}
peerDependencies:
'@tiptap/core': 3.22.5
'@tiptap/extension-text-style': 3.22.5
'@tiptap/pm': 3.22.5
'@tiptap/extension-document@3.22.5':
resolution: {integrity: sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==}
peerDependencies:
@ -2677,6 +2687,11 @@ packages:
peerDependencies:
'@tiptap/extension-list': 3.22.5
'@tiptap/extension-text-style@3.22.5':
resolution: {integrity: sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==}
peerDependencies:
'@tiptap/core': 3.22.5
'@tiptap/extension-text@3.22.5':
resolution: {integrity: sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==}
peerDependencies:
@ -8376,6 +8391,12 @@ snapshots:
dependencies:
'@tiptap/core': 3.22.5(@tiptap/pm@3.22.5)
'@tiptap/extension-details@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)))(@tiptap/pm@3.22.5)':
dependencies:
'@tiptap/core': 3.22.5(@tiptap/pm@3.22.5)
'@tiptap/extension-text-style': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))
'@tiptap/pm': 3.22.5
'@tiptap/extension-document@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))':
dependencies:
'@tiptap/core': 3.22.5(@tiptap/pm@3.22.5)
@ -8482,6 +8503,10 @@ snapshots:
dependencies:
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)
'@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))':
dependencies:
'@tiptap/core': 3.22.5(@tiptap/pm@3.22.5)
'@tiptap/extension-text@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))':
dependencies:
'@tiptap/core': 3.22.5(@tiptap/pm@3.22.5)

View File

@ -513,6 +513,51 @@ li:focus-within > .markdown-annotation-controls > .markdown-annotation-add {
margin: 0.75em 0;
}
.markdown-body details.orca-details {
margin: 0.85em 0;
padding-left: 0.75em;
}
.markdown-body details.orca-details > summary {
position: relative;
list-style: none;
min-height: 1.6em;
padding-left: 1.35em;
cursor: pointer;
font-weight: 600;
line-height: 1.35;
}
.markdown-body details.orca-details > summary::before {
position: absolute;
top: 0.12em;
left: 0;
color: var(--muted-foreground);
content: '\203A';
font-size: 1.25em;
line-height: 1;
transition: transform 120ms ease;
}
.markdown-body details.orca-details[open] > summary::before {
transform: rotate(90deg);
}
.markdown-body details.orca-details > summary::-webkit-details-marker {
display: none;
}
.markdown-body details.orca-details[data-orca-toggle='heading-1'] > summary {
font-size: 1.85em;
font-weight: 700;
letter-spacing: 0;
line-height: 1.3;
}
.markdown-body details.orca-details > :not(summary) {
margin-left: 0.15em;
}
/* Links use a proper blue instead of --primary (which is near-black/white and
visually indistinguishable from body text). */
.markdown-body a {

View File

@ -255,6 +255,14 @@
color: var(--muted-foreground);
}
.rich-markdown-editor summary.is-empty::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: color-mix(in srgb, var(--muted-foreground) 75%, transparent);
}
.rich-markdown-editor h1,
.rich-markdown-editor h2,
.rich-markdown-editor h3,
@ -410,6 +418,79 @@
color: var(--muted-foreground);
}
.rich-markdown-editor .orca-details {
margin: 0.85em 0;
}
.rich-markdown-editor details.orca-details,
.rich-markdown-editor .orca-details[data-type='details'] {
padding-left: 0.75em;
}
.rich-markdown-editor details.orca-details > summary,
.rich-markdown-editor .orca-details[data-type='details'] summary {
display: block;
list-style: none;
min-height: 1.6em;
cursor: pointer;
font-weight: 600;
line-height: 1.35;
}
.rich-markdown-editor details.orca-details > summary::-webkit-details-marker,
.rich-markdown-editor .orca-details[data-type='details'] summary::-webkit-details-marker {
display: none;
}
.rich-markdown-editor .orca-details[data-type='details'] {
position: relative;
padding-left: 1.65em;
}
.rich-markdown-editor .orca-details[data-type='details'] > button {
position: absolute;
top: 0;
left: 0.15em;
display: flex;
width: 1.2em;
height: 1.6em;
align-items: center;
justify-content: center;
border: 0;
padding: 0;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
}
.rich-markdown-editor .orca-details[data-type='details'] > button::before {
content: '\203A';
font-size: 1.25em;
line-height: 1;
transition: transform 120ms ease;
}
.rich-markdown-editor .orca-details[data-type='details'].is-open > button::before {
transform: rotate(90deg);
}
.rich-markdown-editor details.orca-details > div[data-type='detailsContent'],
.rich-markdown-editor .orca-details[data-type='details'] div[data-type='detailsContent'] {
padding-left: 0.15em;
}
.rich-markdown-editor details.orca-details[data-orca-toggle='heading-1'] > summary,
.rich-markdown-editor .orca-details[data-type='details'][data-orca-toggle='heading-1'] summary {
font-size: 1.85em;
font-weight: 700;
letter-spacing: 0;
line-height: 1.3;
}
.rich-markdown-editor .orca-details[data-type='details'][data-orca-toggle='heading-1'] > button {
height: 2.405em;
}
.rich-markdown-editor blockquote {
padding: 0.5em 1em;
border-left: 3px solid var(--border);

View File

@ -214,7 +214,12 @@ const markdownPreviewSanitizeSchema = {
['className', /^language-[\w-]+$/, 'math-inline', 'math-display']
],
div: [...(defaultSchema.attributes?.div ?? []), ['className', /^language-[\w-]+$/], 'align'],
details: [...(defaultSchema.attributes?.details ?? []), 'open'],
details: [
...(defaultSchema.attributes?.details ?? []),
'open',
['className', 'orca-details'],
['dataOrcaToggle', 'heading-1']
],
h1: [...(defaultSchema.attributes?.h1 ?? []), 'id'],
h2: [...(defaultSchema.attributes?.h2 ?? []), 'id'],
h3: [...(defaultSchema.attributes?.h3 ?? []), 'id'],

View File

@ -0,0 +1,193 @@
import type { MarkdownToken } from '@tiptap/core'
export const DETAILS_CLOSE_TAG = '</details>'
export type DetailsHtmlToken = MarkdownToken & {
attributes?: Record<string, unknown>
bodyTokens?: MarkdownToken[]
summaryTokens?: MarkdownToken[]
}
export type DetailsHtmlBlock = {
raw: string
openingAttributes: string
inner: string
hasNestedDetails: boolean
}
export function escapeDetailsHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
export function parseDetailsAttributes(rawAttributes: string): Record<string, unknown> {
return {
open: /\sopen(?:\s|=|$)/i.test(rawAttributes),
variant: /\sdata-orca-toggle=(?:"heading-1"|'heading-1'|heading-1)(?:\s|$)/i.test(rawAttributes)
? 'heading-1'
: null
}
}
export function detailsBodyHtmlToMarkdown(body: string): string {
return body
.replace(/<p\b[^>]*>/gi, '')
.replace(/<\/p>/gi, '\n\n')
.replace(/<br\s*\/?>/gi, '\n')
.trim()
}
export function renderDetailsAttributes(attrs: Record<string, unknown> | undefined): string {
const attributes = ['class="orca-details"']
if (attrs?.variant === 'heading-1') {
attributes.push('data-orca-toggle="heading-1"')
}
if (attrs?.open === true) {
attributes.push('open')
}
return attributes.join(' ')
}
function markdownFenceRanges(content: string): [number, number][] {
const ranges: [number, number][] = []
let offset = 0
let openFence: { marker: '`' | '~'; length: number; start: number } | null = null
for (const lineMatch of content.matchAll(/[^\r\n]*(?:\r\n|\n|\r|$)/g)) {
const line = lineMatch[0]
if (line === '') {
break
}
const lineText = line.replace(/(?:\r\n|\n|\r)$/u, '')
if (openFence) {
const closingFencePattern =
openFence.marker === '`'
? new RegExp(`^ {0,3}\`{${openFence.length},}\\s*$`)
: new RegExp(`^ {0,3}~{${openFence.length},}\\s*$`)
if (closingFencePattern.test(lineText)) {
ranges.push([openFence.start, offset + line.length])
openFence = null
}
} else {
const openingFenceMatch = lineText.match(/^ {0,3}(`{3,}|~{3,})/u)
if (openingFenceMatch?.[1]) {
openFence = {
marker: openingFenceMatch[1][0] as '`' | '~',
length: openingFenceMatch[1].length,
start: offset
}
}
}
offset += line.length
}
if (openFence) {
ranges.push([openFence.start, content.length])
}
return ranges
}
function isInsideRange(index: number, ranges: [number, number][]): boolean {
return ranges.some(([start, end]) => index >= start && index < end)
}
export function matchDetailsHtmlBlock(content: string, start: number): DetailsHtmlBlock | null {
const openingMatch = content.slice(start).match(/^<details\b[^>]*>/i)
if (!openingMatch) {
return null
}
const detailsTagPattern = /<\/?details\b[^>]*>/gi
detailsTagPattern.lastIndex = start
const fenceRanges = markdownFenceRanges(content)
let depth = 0
let hasNestedDetails = false
for (;;) {
const tagMatch = detailsTagPattern.exec(content)
if (!tagMatch) {
return null
}
const tag = tagMatch[0]
if (tagMatch.index !== start && isInsideRange(tagMatch.index, fenceRanges)) {
continue
}
const isClosingTag = /^<\/details\b/i.test(tag)
if (isClosingTag) {
depth -= 1
if (depth === 0) {
const closingEnd = tagMatch.index + tag.length
return {
raw: content.slice(start, closingEnd),
openingAttributes: openingMatch[0].replace(/^<details\b/i, '').replace(/>$/u, ''),
inner: content.slice(start + openingMatch[0].length, tagMatch.index),
hasNestedDetails
}
}
} else {
if (depth > 0) {
hasNestedDetails = true
}
depth += 1
}
}
}
function hasOnlySupportedDetailsAttributes(rawAttributes: string): boolean {
return (
rawAttributes
.replace(/\s+open(?:\s*=\s*(?:""|"open"|''|'open'|open))?(?=\s|$)/giu, '')
.replace(/\s+class\s*=\s*(?:"orca-details"|'orca-details'|orca-details)(?=\s|$)/giu, '')
.replace(/\s+data-orca-toggle\s*=\s*(?:"heading-1"|'heading-1'|heading-1)(?=\s|$)/giu, '')
.trim() === ''
)
}
function hasOnlyPlainParagraphAndBreakTags(content: string): boolean {
return !/<p\b(?!\s*>)[^>]*>|<br\b(?!\s*\/?>)[^>]*>/iu.test(content)
}
export function isEditableDetailsHtmlBlock(block: DetailsHtmlBlock): boolean {
if (block.hasNestedDetails) {
return false
}
if (!hasOnlySupportedDetailsAttributes(block.openingAttributes)) {
return false
}
const summaryMatch = block.inner.match(/^\s*<summary\b([^>]*)>([\s\S]*?)<\/summary>/i)
if (!summaryMatch) {
return false
}
if (summaryMatch[1]?.trim()) {
return false
}
if (/<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/.test(summaryMatch[2] ?? '')) {
return false
}
const bodyHtml = block.inner.replace(/^\s*<summary\b[^>]*>[\s\S]*?<\/summary>/i, '')
if (!hasOnlyPlainParagraphAndBreakTags(bodyHtml)) {
return false
}
const allowedHtmlRemoved = bodyHtml.replace(/<\/?p\b[^>]*>/gi, '').replace(/<br\s*\/?>/gi, '')
return !/<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/.test(allowedHtmlRemoved)
}

View File

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import type { SlashCommandId } from './rich-markdown-commands'
import { slashCommands } from './rich-markdown-commands'
function roundTripMarkdown(content: string): string {
const editor = new Editor({
@ -18,6 +20,48 @@ function roundTripMarkdown(content: string): string {
}
}
function slashCommandMarkdown(commandId: SlashCommandId): string {
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
content: '',
contentType: 'markdown'
})
try {
const command = slashCommands.find((item) => item.id === commandId)
if (!command) {
throw new Error(`Missing slash command: ${commandId}`)
}
command.run(editor)
return editor.getMarkdown().trimEnd()
} finally {
editor.destroy()
}
}
function slashCommandSelectionParent(commandId: SlashCommandId): string {
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
content: '',
contentType: 'markdown'
})
try {
const command = slashCommands.find((item) => item.id === commandId)
if (!command) {
throw new Error(`Missing slash command: ${commandId}`)
}
command.run(editor)
return editor.state.selection.$from.parent.type.name
} finally {
editor.destroy()
}
}
describe('rich markdown round trip', () => {
it('preserves inline html inside paragraphs', () => {
expect(roundTripMarkdown('Before <span>hi</span> after\n')).toBe('Before <span>hi</span> after')
@ -32,6 +76,78 @@ describe('rich markdown round trip', () => {
expect(roundTripMarkdown('<!-- comment -->\n')).toBe('<!-- comment -->')
})
it('preserves editable details blocks', () => {
expect(roundTripMarkdown('<details><summary>Toggle</summary><p>Body</p></details>\n')).toBe(
'<details class="orca-details">\n<summary>Toggle</summary>\n\nBody\n\n</details>'
)
})
it('does not double-escape entities in editable details summaries', () => {
expect(roundTripMarkdown('<details><summary>A &amp; B</summary><p>Body</p></details>\n')).toBe(
'<details class="orca-details">\n<summary>A &amp; B</summary>\n\nBody\n\n</details>'
)
})
it('preserves heading-styled details blocks', () => {
expect(
roundTripMarkdown(
'<details data-orca-toggle="heading-1"><summary>Toggle</summary><p>Body</p></details>\n'
)
).toBe(
'<details class="orca-details" data-orca-toggle="heading-1">\n<summary>Toggle</summary>\n\nBody\n\n</details>'
)
})
it('preserves details blocks with raw html as passthrough html', () => {
const input = '<details><summary><span>Toggle</span></summary><p><em>Body</em></p></details>\n'
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
})
it('preserves details blocks with unsupported attributes as passthrough html', () => {
const input =
'<details id="x"><summary class="s">Toggle</summary><p data-x="1">Body</p></details>\n'
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
})
it('preserves details blocks with closing tags inside fenced code as passthrough html', () => {
const input = [
'<details><summary>Toggle</summary>',
'',
'```',
'</details>',
'```',
'',
'</details>',
''
].join('\n')
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
})
it('preserves nested details blocks as passthrough html', () => {
const input =
'<details><summary>Outer</summary><details><summary>Inner</summary><p>Body</p></details></details>\n'
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
})
it('preserves loose details and summary tags as passthrough html', () => {
expect(roundTripMarkdown('<summary>Loose</summary>\n')).toBe('<summary>Loose</summary>')
expect(roundTripMarkdown('<details>\n')).toBe('<details>')
})
it('inserts editable text toggles from slash commands', () => {
expect(slashCommandMarkdown('toggle-text')).toBe(
'<details class="orca-details" open>\n<summary></summary>\n\n\n\n</details>'
)
expect(slashCommandSelectionParent('toggle-text')).toBe('detailsSummary')
})
it('inserts editable heading toggles from slash commands', () => {
expect(slashCommandMarkdown('toggle-h1')).toBe(
'<details class="orca-details" data-orca-toggle="heading-1" open>\n<summary></summary>\n\n\n\n</details>'
)
expect(slashCommandSelectionParent('toggle-h1')).toBe('detailsSummary')
})
it('preserves markdown tables', () => {
expect(roundTripMarkdown('| a | b |\n| - | - |\n| 1 | 2 |\n')).toContain('| a')
})

View File

@ -1,4 +1,5 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock } from './details-markdown-html'
import { getMarkdownDocLinkTarget } from './markdown-doc-links'
const INLINE_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_INLINE:'
@ -157,6 +158,21 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
}
if (isLineStart) {
const detailsHtml = matchDetailsHtmlBlock(content, index)
if (detailsHtml && isEditableDetailsHtmlBlock(detailsHtml)) {
// Why: <details>/<summary> is an editable rich-mode node; raw passthrough
// would make toggle blocks reopen as inert HTML instead.
result += detailsHtml.raw
index += detailsHtml.raw.length
continue
}
if (detailsHtml) {
result += createPlaceholder('block', detailsHtml.raw)
index += detailsHtml.raw.length
continue
}
const blockHtml = matchBlockHtml(content, index)
if (blockHtml) {
result += createPlaceholder('block', blockHtml)

View File

@ -39,6 +39,7 @@ describe('rich markdown slash commands', () => {
it('orders commands under section headers', () => {
expect(slashCommands.map((command) => `${command.group}:${command.id}`)).toEqual([
'Headings:heading-1',
'Headings:toggle-h1',
'Headings:heading-2',
'Headings:heading-3',
'Basic blocks:blockquote',
@ -46,6 +47,7 @@ describe('rich markdown slash commands', () => {
'Basic blocks:bullet-list',
'Basic blocks:task-list',
'Basic blocks:text',
'Basic blocks:toggle-text',
'Basic blocks:code-block',
'Basic blocks:divider',
'Advanced:table',

View File

@ -4,6 +4,7 @@ import type { Editor } from '@tiptap/react'
import { TextSelection } from '@tiptap/pm/state'
import type {} from '@tiptap/extension-mathematics'
import {
ChevronRight,
Heading1,
Heading2,
Heading3,
@ -45,7 +46,9 @@ export type DocLinkMenuRow =
export type SlashCommandId =
| 'text'
| 'toggle-text'
| 'heading-1'
| 'toggle-h1'
| 'heading-2'
| 'heading-3'
| 'task-list'
@ -121,6 +124,32 @@ function insertCodeBlock(editor: Editor, language: string, text: string): void {
})
}
function insertToggle(editor: Editor, variant?: 'heading-1'): void {
const insertAt = editor.state.selection.from
editor
.chain()
.focus()
.insertContentAt(insertAt, {
type: 'details',
attrs: {
open: true,
...(variant ? { variant } : {})
},
content: [
{
type: 'detailsSummary'
},
{
type: 'detailsContent',
content: [{ type: 'paragraph' }]
}
]
})
.setTextSelection(insertAt + 1)
.run()
}
/**
* Executes a slash command by first deleting the typed slash text, then
* delegating to the command's run method. Image is special-cased because
@ -161,6 +190,17 @@ export const slashCommands: SlashCommand[] = [
editor.chain().focus().setHeading({ level: 1 }).run()
}
},
{
id: 'toggle-h1',
label: 'Toggle Heading 1',
aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'],
icon: icon(ChevronRight),
group: 'Headings',
description: 'Create a collapsible section with a large heading summary.',
run: (editor) => {
insertToggle(editor, 'heading-1')
}
},
{
id: 'heading-2',
label: 'Heading 2',
@ -242,6 +282,17 @@ export const slashCommands: SlashCommand[] = [
editor.chain().focus().setParagraph().run()
}
},
{
id: 'toggle-text',
label: 'Toggle Text',
aliases: ['toggle', 'details', 'collapse', 'toggle-text'],
icon: icon(ChevronRight),
group: 'Basic blocks',
description: 'Create a collapsible text section.',
run: (editor) => {
insertToggle(editor)
}
},
{
id: 'code-block',
label: 'Code Block',

View File

@ -0,0 +1,301 @@
import { decodeHtmlEntities, type AnyExtension, type Editor } from '@tiptap/core'
import { Details, DetailsContent, DetailsSummary } from '@tiptap/extension-details'
import type { PlaceholderOptions } from '@tiptap/extension-placeholder'
import { TextSelection } from '@tiptap/pm/state'
import {
detailsBodyHtmlToMarkdown,
escapeDetailsHtml,
isEditableDetailsHtmlBlock,
matchDetailsHtmlBlock,
parseDetailsAttributes,
renderDetailsAttributes,
type DetailsHtmlToken
} from './details-markdown-html'
const RICH_MARKDOWN_PLACEHOLDER = 'Write markdown… Type / for blocks.'
const TOGGLE_TEXT_PLACEHOLDER = 'text'
const TOGGLE_HEADING_PLACEHOLDER = 'Heading 1'
export function getRichMarkdownPlaceholder({
editor,
node,
pos
}: Parameters<
Extract<PlaceholderOptions['placeholder'], (...args: never[]) => string>
>[0]): string {
if (node.type.name !== 'detailsSummary') {
return RICH_MARKDOWN_PLACEHOLDER
}
const parent = editor.state.doc.resolve(pos).parent
return parent.type.name === 'details' && parent.attrs.variant === 'heading-1'
? TOGGLE_HEADING_PLACEHOLDER
: TOGGLE_TEXT_PLACEHOLDER
}
export function moveDetailsSummarySelectionToContent(editor: Editor): boolean {
const { state, view } = editor
const { selection } = state
const { $from, empty } = selection
if (!empty || $from.parent.type.name !== 'detailsSummary') {
return false
}
const detailsDepth = $from.depth - 1
if (detailsDepth < 1) {
return false
}
const detailsNode = $from.node(detailsDepth)
if (detailsNode.type.name !== 'details' || detailsNode.attrs.open === false) {
return false
}
const detailsContent = detailsNode.child(1)
if (detailsContent?.type.name !== 'detailsContent') {
return false
}
const detailsStart = $from.before(detailsDepth)
const detailsContentStart = detailsStart + 1 + detailsNode.child(0).nodeSize
const firstBodyNode = detailsContent.firstChild
if (!firstBodyNode?.isTextblock) {
return false
}
const targetPos = detailsContentStart + 2
const tr = state.tr.setSelection(TextSelection.near(state.doc.resolve(targetPos), 1))
tr.scrollIntoView()
view.dispatch(tr)
return true
}
export function moveFromEmptyDetailsBodyToSummary(editor: Editor): boolean {
const { state, view } = editor
const { selection } = state
const { $from, empty } = selection
if (!empty || !$from.parent.isTextblock || $from.parent.content.size !== 0) {
return false
}
if ($from.parentOffset !== 0) {
return false
}
const detailsContentDepth = $from.depth - 1
if (detailsContentDepth < 1) {
return false
}
const detailsContentNode = $from.node(detailsContentDepth)
if (
detailsContentNode.type.name !== 'detailsContent' ||
detailsContentNode.childCount !== 1 ||
$from.index(detailsContentDepth) !== 0
) {
return false
}
const detailsDepth = detailsContentDepth - 1
const detailsNode = $from.node(detailsDepth)
if (detailsNode.type.name !== 'details' || detailsNode.childCount < 2) {
return false
}
const summaryNode = detailsNode.child(0)
if (summaryNode.type.name !== 'detailsSummary') {
return false
}
const detailsStart = $from.before(detailsDepth)
const summaryStart = detailsStart + 1
const summaryTextEnd = summaryStart + summaryNode.nodeSize - 1
// Why: Backspace from an empty toggle body should first make the summary
// cursor visible; deletion is reserved for the next Backspace.
const tr = state.tr.setSelection(TextSelection.near(state.doc.resolve(summaryTextEnd), -1))
tr.scrollIntoView()
view.dispatch(tr)
return true
}
export function exitEmptyDetailsBody(editor: Editor): boolean {
const { state, view } = editor
const { selection } = state
const { $from, empty } = selection
if (!empty || !$from.parent.isTextblock || $from.parent.content.size !== 0) {
return false
}
const detailsContentDepth = $from.depth - 1
if (detailsContentDepth < 1) {
return false
}
const detailsContentNode = $from.node(detailsContentDepth)
if (detailsContentNode.type.name !== 'detailsContent') {
return false
}
const childIndex = $from.index(detailsContentDepth)
if (childIndex !== detailsContentNode.childCount - 1) {
return false
}
const detailsDepth = detailsContentDepth - 1
const detailsNode = $from.node(detailsDepth)
if (detailsNode.type.name !== 'details') {
return false
}
const paragraphType = state.schema.nodes.paragraph
const paragraph = paragraphType?.createAndFill()
if (!paragraph) {
return false
}
const currentBlockFrom = $from.before($from.depth)
const currentBlockTo = $from.after($from.depth)
const shouldRemoveTrailingEmptyBlock = detailsContentNode.childCount > 1
let insertPos = $from.after(detailsDepth)
const tr = state.tr
if (shouldRemoveTrailingEmptyBlock) {
tr.delete(currentBlockFrom, currentBlockTo)
insertPos -= currentBlockTo - currentBlockFrom
}
// Why: an empty toggle body must have an Enter escape hatch, while the
// detailsContent node still needs one child to stay schema-valid.
tr.insert(insertPos, paragraph)
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1))
tr.scrollIntoView()
view.dispatch(tr)
return true
}
const OrcaDetails = Details.extend({
// Why: details summary Enter must run before StarterKit's generic paragraph
// splitting so typing a toggle title then pressing Enter moves into the body.
priority: 1000,
addAttributes() {
return {
...this.parent?.(),
variant: {
default: null,
parseHTML: (element) =>
element.getAttribute('data-orca-toggle') === 'heading-1' ? 'heading-1' : null,
renderHTML: ({ variant }) =>
variant === 'heading-1' ? { 'data-orca-toggle': 'heading-1' } : {}
}
}
},
addKeyboardShortcuts() {
const parentShortcuts = this.parent?.() ?? {}
return {
...parentShortcuts,
Enter: ({ editor }) =>
moveDetailsSummarySelectionToContent(editor) || parentShortcuts.Enter?.({ editor }) || false
}
},
markdownTokenizer: {
name: 'details',
level: 'block',
start: '<details',
tokenize(src, _tokens, lexer) {
const detailsBlock = matchDetailsHtmlBlock(src, 0)
if (!detailsBlock || !isEditableDetailsHtmlBlock(detailsBlock)) {
return undefined
}
const summaryMatch = detailsBlock.inner.match(/^\s*<summary\b[^>]*>([\s\S]*?)<\/summary>/i)
if (!summaryMatch) {
return undefined
}
const summary = decodeHtmlEntities(summaryMatch[1].trim())
const body = detailsBlock.inner.slice((summaryMatch.index ?? 0) + summaryMatch[0].length)
return {
type: 'details',
raw: detailsBlock.raw,
block: true,
attributes: parseDetailsAttributes(detailsBlock.openingAttributes),
summaryTokens: lexer.inlineTokens(summary),
bodyTokens: lexer.blockTokens(detailsBodyHtmlToMarkdown(body))
} as DetailsHtmlToken
}
},
parseMarkdown: (token, helpers) => {
const detailsToken = token as DetailsHtmlToken
if (detailsToken.type !== 'details') {
return []
}
const summary = helpers.createNode(
'detailsSummary',
{},
helpers.parseInline(detailsToken.summaryTokens ?? [])
)
const body = helpers.parseChildren(detailsToken.bodyTokens ?? [])
const content = helpers.createNode(
'detailsContent',
{},
body.length > 0 ? body : [helpers.createNode('paragraph')]
)
return helpers.createNode('details', detailsToken.attributes ?? {}, [summary, content])
},
renderMarkdown: (node, helpers) => {
const summary = node.content?.find((child) => child.type === 'detailsSummary')
const content = node.content?.find((child) => child.type === 'detailsContent')
const summaryText = escapeDetailsHtml(
decodeHtmlEntities(helpers.renderChildren(summary?.content ?? [], ''))
)
const body = helpers.renderChildren(content?.content ?? [], '\n\n').trim()
const attrs = renderDetailsAttributes(node.attrs)
return `<details ${attrs}>\n<summary>${summaryText}</summary>\n\n${body}\n\n</details>`
}
})
const OrcaDetailsContent = DetailsContent.extend({
// Why: detailsContent's double-Enter escape must run before StarterKit's
// generic paragraph split, otherwise users can get stuck inside a toggle.
priority: 1000,
addKeyboardShortcuts() {
const parentShortcuts = this.parent?.() ?? {}
return {
...parentShortcuts,
Enter: ({ editor }) =>
exitEmptyDetailsBody(editor) || parentShortcuts.Enter?.({ editor }) || false,
Backspace: ({ editor }) =>
moveFromEmptyDetailsBodyToSummary(editor) ||
parentShortcuts.Backspace?.({ editor }) ||
false
}
}
})
export function createOrcaDetailsExtensions(): AnyExtension[] {
return [
OrcaDetails.configure({
persist: true,
HTMLAttributes: {
class: 'orca-details'
}
}),
DetailsSummary,
OrcaDetailsContent
]
}

View File

@ -0,0 +1,196 @@
import { describe, expect, it } from 'vitest'
import { Editor, type JSONContent } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import {
exitEmptyDetailsBody,
moveFromEmptyDetailsBodyToSummary
} from './rich-markdown-details-extension'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
function createEditor(content: string | JSONContent) {
return new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
content,
contentType: 'markdown'
})
}
function firstDetailsBodyCursorPosition(editor: Editor): number {
let position: number | null = null
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'paragraph' && node.content.size === 0) {
const parent = editor.state.doc.resolve(pos).parent
if (parent.type.name === 'detailsContent') {
position = pos + 1
return false
}
}
return true
})
if (position === null) {
throw new Error('Expected an empty details body paragraph')
}
return position
}
function firstTextEndPosition(editor: Editor, text: string): number {
let position: number | null = null
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text === text) {
position = pos + text.length
return false
}
return true
})
if (position === null) {
throw new Error(`Expected text: ${text}`)
}
return position
}
function selectionHasAncestor(editor: Editor, typeName: string): boolean {
const { $from } = editor.state.selection
for (let depth = $from.depth; depth >= 0; depth -= 1) {
if ($from.node(depth).type.name === typeName) {
return true
}
}
return false
}
function firstDetailsContent(editor: Editor): ProseMirrorNode {
let content: ProseMirrorNode | null = null
editor.state.doc.descendants((node) => {
if (node.type.name === 'detailsContent') {
content = node
return false
}
return true
})
if (!content) {
throw new Error('Expected details content')
}
return content
}
describe('rich markdown details keyboard behavior', () => {
it.each([
[
'text toggle',
'<details><summary>Toggle</summary><p></p></details>',
'<details class="orca-details">\n<summary>Toggle</summary>\n\n\n\n</details>'
],
[
'heading toggle',
'<details data-orca-toggle="heading-1"><summary>Toggle</summary><p></p></details>',
'<details class="orca-details" data-orca-toggle="heading-1">\n<summary>Toggle</summary>\n\n\n\n</details>'
]
])('moves backspace from an empty %s body to the summary', (_name, content, expected) => {
const editor = createEditor(content)
try {
editor.commands.setTextSelection(firstDetailsBodyCursorPosition(editor))
expect(moveFromEmptyDetailsBodyToSummary(editor)).toBe(true)
expect(editor.getMarkdown().trimEnd()).toBe(expected)
expect(editor.state.selection.$from.parent.type.name).toBe('detailsSummary')
expect(editor.state.selection.$from.parentOffset).toBe('Toggle'.length)
} finally {
editor.destroy()
}
})
it('does not hijack backspace when an empty first toggle body line has content after it', () => {
const editor = createEditor({
type: 'doc',
content: [
{
type: 'details',
attrs: { open: true, variant: null },
content: [
{ type: 'detailsSummary', content: [{ type: 'text', text: 'Toggle' }] },
{
type: 'detailsContent',
content: [
{ type: 'paragraph' },
{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }
]
}
]
}
]
})
try {
editor.commands.setTextSelection(firstDetailsBodyCursorPosition(editor))
expect(moveFromEmptyDetailsBodyToSummary(editor)).toBe(false)
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
} finally {
editor.destroy()
}
})
it.each([
['text toggle', '<details><summary>Toggle</summary><p></p></details>'],
[
'heading toggle',
'<details data-orca-toggle="heading-1"><summary>Toggle</summary><p></p></details>'
]
])('exits an empty %s body on Enter', (_name, content) => {
const editor = createEditor(content)
try {
editor.commands.setTextSelection(firstDetailsBodyCursorPosition(editor))
expect(exitEmptyDetailsBody(editor)).toBe(true)
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
expect(selectionHasAncestor(editor, 'detailsContent')).toBe(false)
expect(firstDetailsContent(editor).childCount).toBe(1)
} finally {
editor.destroy()
}
})
it('creates another paragraph inside a non-empty toggle body on Enter', () => {
const editor = createEditor('<details><summary>Toggle</summary><p>Body</p></details>')
try {
editor.commands.setTextSelection(firstTextEndPosition(editor, 'Body'))
expect(exitEmptyDetailsBody(editor)).toBe(false)
expect(editor.commands.splitBlock()).toBe(true)
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
expect(selectionHasAncestor(editor, 'detailsContent')).toBe(true)
expect(firstDetailsContent(editor).childCount).toBe(2)
} finally {
editor.destroy()
}
})
it('inserts a soft break inside a toggle body on Shift+Enter', () => {
const editor = createEditor('<details><summary>Toggle</summary><p>Body</p></details>')
try {
editor.commands.setTextSelection(firstTextEndPosition(editor, 'Body'))
expect(editor.commands.setHardBreak()).toBe(true)
expect(selectionHasAncestor(editor, 'detailsContent')).toBe(true)
expect(firstDetailsContent(editor).firstChild?.firstChild?.type.name).toBe('text')
expect(firstDetailsContent(editor).firstChild?.child(1).type.name).toBe('hardBreak')
} finally {
editor.destroy()
}
})
})

View File

@ -16,6 +16,10 @@ import { createLowlight, common } from 'lowlight'
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html'
import {
createOrcaDetailsExtensions,
getRichMarkdownPlaceholder
} from './rich-markdown-details-extension'
import { MarkdownDocLink } from './rich-markdown-doc-link'
import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock'
import { safeReactNodeViewRenderer } from './safe-react-node-view-renderer'
@ -24,8 +28,6 @@ import { createRichMarkdownAnnotationHighlightExtension } from './rich-markdown-
const lowlight = createLowlight(common)
const RICH_MARKDOWN_PLACEHOLDER = 'Write markdown… Type / for blocks.'
export function createRichMarkdownExtensions({
includePlaceholder = false
}: {
@ -141,6 +143,7 @@ export function createRichMarkdownExtensions({
TaskItem.configure({
nested: true
}),
...createOrcaDetailsExtensions(),
Table.configure({
resizable: false
}),
@ -173,7 +176,8 @@ export function createRichMarkdownExtensions({
if (includePlaceholder) {
extensions.push(
Placeholder.configure({
placeholder: RICH_MARKDOWN_PLACEHOLDER
includeChildren: true,
placeholder: getRichMarkdownPlaceholder
})
)
}