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:
parent
4b32afeff2
commit
5c2ed9c6ce
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -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 & B</summary><p>Body</p></details>\n')).toBe(
|
||||
'<details class="orca-details">\n<summary>A & 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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue