import { Editor as TextEditor, Extensions, JSONContent } from '@tiptap/core'
import { Editor, TLRichText, toRichText } from '@tldraw/editor'
import {
isEditingRichTextList,
isEmptyRichText,
renderHtmlFromRichTextWithExtensions,
tipTapDefaultExtensions,
} from './richText'
import { TaskItem, TaskList } from './tiptap'
const render = (content: TLRichText['content']) =>
renderHtmlFromRichTextWithExtensions(
{ type: 'doc', content } as TLRichText,
tipTapDefaultExtensions
)
describe('renderHtmlFromRichTextWithExtensions', () => {
it('fills an empty paragraph with a line break so the browser does not collapse it', () => {
expect(render([{ type: 'paragraph' }])).toBe('
')
})
it('keeps an explicit direction on an empty paragraph', () => {
// A `dir` of `ltr`/`rtl` rather than the default `auto` comes from pasted HTML that carried
// one, or from TipTap's `setTextDirection`. Dropping it would re-align the blank line.
expect(render([{ type: 'paragraph', attrs: { dir: 'rtl' } }])).toBe('
')
expect(render([{ type: 'paragraph', attrs: { dir: 'ltr' } }])).toBe('
')
})
it('fills a paragraph with no attributes at all', () => {
// Extension sets without TipTap's TextDirection render a bare ``.
expect(render([{ type: 'paragraph', attrs: { dir: null } }])).toBe('
')
})
it('leaves paragraphs with content alone', () => {
expect(render([{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }])).toBe(
'hello
'
)
})
it('fills every empty paragraph, including ones nested in a list item', () => {
expect(
render([
{ type: 'paragraph' },
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
{ type: 'paragraph' },
{
type: 'bulletList',
content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }],
},
])
).toBe(
'
hello
' +
''
)
})
})
describe('isEmptyRichText', () => {
it('treats a paragraph with no content key as empty (interactive editor output)', () => {
expect(isEmptyRichText(toRichText(''))).toBe(true)
})
it('treats a paragraph with an empty content array as empty (programmatic authoring)', () => {
const richText: TLRichText = {
type: 'doc',
content: [{ type: 'paragraph', attrs: { dir: 'auto' }, content: [] }],
}
expect(isEmptyRichText(richText)).toBe(true)
})
it('treats a doc with an empty content array as empty (hand-authored / importer form)', () => {
const richText: TLRichText = { type: 'doc', content: [] }
expect(isEmptyRichText(richText)).toBe(true)
})
it('treats a paragraph with text as non-empty', () => {
expect(isEmptyRichText(toRichText('Hello'))).toBe(false)
})
it('treats multiple paragraphs as non-empty', () => {
const richText: TLRichText = {
type: 'doc',
content: [
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
],
}
expect(isEmptyRichText(richText)).toBe(false)
})
})
describe('isEditingRichTextList', () => {
const editingWith = (extensions: Extensions, content: JSONContent) => {
const textEditor = new TextEditor({ extensions, content })
return { getRichTextEditor: () => textEditor } as unknown as Editor
}
const listItem = (type: string, itemType: string): JSONContent => ({
type,
content: [
{ type: itemType, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] }] },
],
})
it('is false in a plain paragraph', () => {
const editor = editingWith(tipTapDefaultExtensions, toRichText('a') as JSONContent)
expect(isEditingRichTextList(editor)).toBe(false)
})
it('is true in the default lists', () => {
for (const [list, item] of [
['bulletList', 'listItem'],
['orderedList', 'listItem'],
]) {
const editor = editingWith(tipTapDefaultExtensions, {
type: 'doc',
content: [listItem(list, item)],
})
expect(isEditingRichTextList(editor)).toBe(true)
}
})
it('is true in a task list, whose items bind Tab themselves', () => {
// Without this, our Tab handler runs alongside TaskItem's and one keypress both indents the
// text and nests the item.
const editor = editingWith([...tipTapDefaultExtensions, TaskList, TaskItem], {
type: 'doc',
content: [listItem('taskList', 'taskItem')],
})
expect(isEditingRichTextList(editor)).toBe(true)
})
})
describe('trailing node', () => {
const blockTypesOf = (textEditor: TextEditor) => textEditor.getJSON().content?.map((n) => n.type)
it('does not append an empty paragraph after any enabled block type', () => {
const textEditor = new TextEditor({ extensions: tipTapDefaultExtensions, content: '' })
try {
const blockTypes = Object.values(textEditor.schema.nodes).filter((type) =>
type.isInGroup('block')
)
expect(blockTypes.map((type) => type.name)).toContain('heading')
for (const type of blockTypes) {
const { state } = textEditor
textEditor.view.dispatch(
state.tr.replaceWith(0, state.doc.content.size, type.createAndFill()!)
)
expect(blockTypesOf(textEditor)).toEqual([type.name])
}
} finally {
textEditor.destroy()
}
})
it('lets the user delete the empty line under a heading', () => {
const textEditor = new TextEditor({
extensions: tipTapDefaultExtensions,
content: 'Heading
',
})
try {
textEditor.commands.setTextSelection(textEditor.state.doc.content.size - 1)
textEditor.commands.keyboardShortcut('Backspace')
expect(blockTypesOf(textEditor)).toEqual(['heading'])
} finally {
textEditor.destroy()
}
})
})