snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-details
[![Version](https://img.shields.io/npm/v/@tiptap/extension-details.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-details)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-details.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-details.svg)](https://www.npmjs.com/package/@tiptap/extension-details)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,54 @@
{
"name": "@tiptap/extension-details",
"version": "3.30.3",
"description": "details extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev/api/nodes/details",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-details"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/extension-text-style": "workspace:^",
"@tiptap/pm": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/extension-text-style": "workspace:*",
"@tiptap/pm": "workspace:*"
}
}

View File

@@ -0,0 +1,170 @@
import {
createBlockMarkdownSpec,
defaultBlockAt,
findParentNode,
mergeAttributes,
Node,
} from '@tiptap/core'
import { Selection } from '@tiptap/pm/state'
import type { ViewMutationRecord } from '@tiptap/pm/view'
export interface DetailsContentOptions {
/**
* Custom HTML attributes that should be added to the rendered HTML tag.
*/
HTMLAttributes: {
[key: string]: any
}
}
export const DetailsContent = Node.create<DetailsContentOptions>({
name: 'detailsContent',
content: 'block+',
defining: true,
selectable: false,
addOptions() {
return {
HTMLAttributes: {},
}
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': this.name }),
0,
]
},
addNodeView() {
return ({ HTMLAttributes }) => {
const dom = document.createElement('div')
const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': this.name,
hidden: 'hidden',
})
Object.entries(attributes).forEach(([key, value]) => dom.setAttribute(key, value))
dom.addEventListener('toggleDetailsContent', () => {
dom.toggleAttribute('hidden')
})
return {
dom,
contentDOM: dom,
ignoreMutation(mutation: ViewMutationRecord) {
if (mutation.type === 'selection') {
return false
}
return !dom.contains(mutation.target) || dom === mutation.target
},
update: updatedNode => {
if (updatedNode.type !== this.type) {
return false
}
return true
},
}
}
},
addKeyboardShortcuts() {
return {
// Escape node on double enter
Enter: ({ editor }) => {
const { state, view } = editor
const { selection } = state
const { $from, empty } = selection
const detailsContent = findParentNode(node => node.type === this.type)(selection)
if (!empty || !detailsContent || !detailsContent.node.childCount) {
return false
}
const fromIndex = $from.index(detailsContent.depth)
const { childCount } = detailsContent.node
const isAtEnd = childCount === fromIndex + 1
if (!isAtEnd) {
return false
}
const defaultChildType = detailsContent.node.type.contentMatch.defaultType
const defaultChildNode = defaultChildType?.createAndFill()
if (!defaultChildNode) {
return false
}
const $childPos = state.doc.resolve(detailsContent.pos + 1)
const lastChildIndex = childCount - 1
const lastChildNode = detailsContent.node.child(lastChildIndex)
const lastChildPos = $childPos.posAtIndex(lastChildIndex, detailsContent.depth)
const lastChildNodeIsEmpty = lastChildNode.eq(defaultChildNode)
if (!lastChildNodeIsEmpty) {
return false
}
// get parent of details node
const above = $from.node(-3)
if (!above) {
return false
}
// get default node type after details node
const after = $from.indexAfter(-3)
const type = defaultBlockAt(above.contentMatchAt(after))
if (!type || !above.canReplaceWith(after, after, type)) {
return false
}
const node = type.createAndFill()
if (!node) {
return false
}
const { tr } = state
const pos = $from.after(-2)
tr.replaceWith(pos, pos, node)
const $pos = tr.doc.resolve(pos)
const newSelection = Selection.near($pos, 1)
tr.setSelection(newSelection)
const deleteFrom = lastChildPos
const deleteTo = lastChildPos + lastChildNode.nodeSize
tr.delete(deleteFrom, deleteTo)
tr.scrollIntoView()
view.dispatch(tr)
return true
},
}
},
...createBlockMarkdownSpec({
nodeName: 'detailsContent',
}),
})

View File

@@ -0,0 +1,5 @@
import { DetailsContent } from './details-content.js'
export * from './details-content.js'
export default DetailsContent

View File

@@ -0,0 +1,514 @@
import {
createBlockMarkdownSpec,
defaultBlockAt,
findChildren,
findParentNode,
isActive,
mergeAttributes,
Node,
} from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import { Plugin, PluginKey, Selection, TextSelection } from '@tiptap/pm/state'
import type { ViewMutationRecord } from '@tiptap/pm/view'
import { findClosestVisibleNode } from './helpers/findClosestVisibleNode.js'
import { isNodeVisible } from './helpers/isNodeVisible.js'
import { setGapCursor } from './helpers/setGapCursor.js'
export interface DetailsRenderToggleButtonOptions {
/**
* The toggle button element rendered by the node view.
*/
element: HTMLButtonElement
/**
* The current open state of the details node.
*/
isOpen: boolean
/**
* The current node used to derive toggle button state.
*/
node: PMNode
}
export interface DetailsOptions {
/**
* Specify if the open status should be saved in the document. Defaults to `false`.
*/
persist: boolean
/**
* Specifies a CSS class that is set when toggling the content. Defaults to `is-open`.
*/
openClassName: string
/**
* Custom HTML attributes that should be added to the rendered HTML tag.
*/
HTMLAttributes: {
[key: string]: any
}
/**
* Customizes the rendered toggle button.
*/
renderToggleButton: (options: DetailsRenderToggleButtonOptions) => void
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
details: {
/**
* Set a details node
*/
setDetails: () => ReturnType
/**
* Unset a details node
*/
unsetDetails: () => ReturnType
}
}
}
export const Details = Node.create<DetailsOptions>({
name: 'details',
content: 'detailsSummary detailsContent',
group: 'block',
defining: true,
isolating: true,
// @ts-ignore reason: `allowGapCursor` is not a valid property by default, but the `GapCursor` extension adds it to the Nodeconfig type
allowGapCursor: false,
addOptions() {
return {
persist: false,
openClassName: 'is-open',
HTMLAttributes: {},
renderToggleButton: ({ element, isOpen }) => {
element.setAttribute(
'aria-label',
isOpen ? 'Collapse details content' : 'Expand details content',
)
},
}
},
addAttributes() {
if (!this.options.persist) {
return []
}
return {
open: {
default: false,
parseHTML: element => element.hasAttribute('open'),
renderHTML: ({ open }) => {
if (!open) {
return {}
}
return { open: '' }
},
},
}
},
parseHTML() {
return [
{
tag: 'details',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['details', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
...createBlockMarkdownSpec({
nodeName: 'details',
content: 'block',
}),
addNodeView() {
return ({ editor, getPos, node, HTMLAttributes }) => {
const dom = document.createElement('div')
const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': this.name,
})
Object.entries(attributes).forEach(([key, value]) => dom.setAttribute(key, value))
const toggle = document.createElement('button')
toggle.type = 'button'
const renderToggleButton = (options: Omit<DetailsRenderToggleButtonOptions, 'element'>) => {
this.options.renderToggleButton({
element: toggle,
...options,
})
}
dom.append(toggle)
const content = document.createElement('div')
dom.append(content)
const toggleDetailsContent = (options?: { setToValue?: boolean; node?: PMNode }) => {
const { setToValue, node: currentNode = node } = options || {}
if (setToValue !== undefined) {
if (setToValue) {
if (dom.classList.contains(this.options.openClassName)) {
return
}
dom.classList.add(this.options.openClassName)
} else {
if (!dom.classList.contains(this.options.openClassName)) {
return
}
dom.classList.remove(this.options.openClassName)
}
} else {
dom.classList.toggle(this.options.openClassName)
}
const isOpen = dom.classList.contains(this.options.openClassName)
renderToggleButton({
isOpen,
node: currentNode,
})
const event = new Event('toggleDetailsContent')
const detailsContent = content.querySelector(':scope > div[data-type="detailsContent"]')
detailsContent?.dispatchEvent(event)
}
renderToggleButton({
isOpen: Boolean(node.attrs.open),
node,
})
if (node.attrs.open) {
setTimeout(() => toggleDetailsContent())
}
toggle.addEventListener('click', () => {
toggleDetailsContent()
if (!this.options.persist) {
editor.commands.focus(undefined, { scrollIntoView: false })
return
}
if (editor.isEditable && typeof getPos === 'function') {
const { from, to } = editor.state.selection
editor
.chain()
.command(({ tr }) => {
const pos = getPos()
if (typeof pos !== 'number') {
return false
}
const currentNode = tr.doc.nodeAt(pos)
if (currentNode?.type !== this.type) {
return false
}
tr.setNodeMarkup(pos, undefined, {
open: !currentNode.attrs.open,
})
return true
})
.setTextSelection({
from,
to,
})
.focus(undefined, { scrollIntoView: false })
.run()
}
})
return {
dom,
contentDOM: content,
ignoreMutation(mutation: ViewMutationRecord) {
if (mutation.type === 'selection') {
return false
}
const target = mutation.target
const isInsideWrapper = dom.contains(target)
const isInsideToggleButton = toggle.contains(target)
return isInsideToggleButton || !isInsideWrapper || dom === target
},
update: updatedNode => {
if (updatedNode.type !== this.type) {
return false
}
// Only update the open state if set
if (updatedNode.attrs.open !== undefined) {
toggleDetailsContent({
setToValue: updatedNode.attrs.open,
node: updatedNode,
})
} else {
renderToggleButton({
isOpen: dom.classList.contains(this.options.openClassName),
node: updatedNode,
})
}
return true
},
}
}
},
addCommands() {
return {
setDetails:
() =>
({ state, chain }) => {
const { schema, selection } = state
const { $from, $to } = selection
const range = $from.blockRange($to)
if (!range) {
return false
}
const slice = state.doc.slice(range.start, range.end)
const match = schema.nodes.detailsContent.contentMatch.matchFragment(slice.content)
if (!match) {
return false
}
const content = slice.toJSON()?.content || []
return chain()
.insertContentAt(
{ from: range.start, to: range.end },
{
type: this.name,
content: [
{
type: 'detailsSummary',
},
{
type: 'detailsContent',
content,
},
],
},
)
.setTextSelection(range.start + 2)
.run()
},
unsetDetails:
() =>
({ state, chain }) => {
const { selection, schema } = state
const details = findParentNode(node => node.type === this.type)(selection)
if (!details) {
return false
}
const detailsSummaries = findChildren(
details.node,
node => node.type === schema.nodes.detailsSummary,
)
const detailsContents = findChildren(
details.node,
node => node.type === schema.nodes.detailsContent,
)
if (!detailsSummaries.length || !detailsContents.length) {
return false
}
const detailsSummary = detailsSummaries[0]
const detailsContent = detailsContents[0]
const from = details.pos
const $from = state.doc.resolve(from)
const to = from + details.node.nodeSize
const range = { from, to }
const content = (detailsContent.node.content.toJSON() as []) || []
const defaultTypeForSummary = $from.parent.type.contentMatch.defaultType
// TODO: this may break for some custom schemas
const summaryContent = defaultTypeForSummary
?.create(null, detailsSummary.node.content)
.toJSON()
const mergedContent = [summaryContent, ...content]
return chain()
.insertContentAt(range, mergedContent)
.setTextSelection(from + 1)
.run()
},
}
},
addKeyboardShortcuts() {
return {
Backspace: () => {
const { schema, selection } = this.editor.state
const { empty, $anchor } = selection
if (!empty || $anchor.parent.type !== schema.nodes.detailsSummary) {
return false
}
// for some reason safari removes the whole text content within a `<summary>`tag on backspace
// so we have to remove the text manually
// see: https://discuss.prosemirror.net/t/safari-backspace-bug-with-details-tag/4223
if ($anchor.parentOffset !== 0) {
return this.editor.commands.command(({ tr }) => {
const from = $anchor.pos - 1
const to = $anchor.pos
tr.delete(from, to)
return true
})
}
return this.editor.commands.unsetDetails()
},
// Creates a new node below it if it is closed.
// Otherwise inside `DetailsContent`.
Enter: ({ editor }) => {
const { state, view } = editor
const { schema, selection } = state
const { $head } = selection
if ($head.parent.type !== schema.nodes.detailsSummary) {
return false
}
const isVisible = isNodeVisible($head.after() + 1, editor)
const above = isVisible ? state.doc.nodeAt($head.after()) : $head.node(-2)
if (!above) {
return false
}
const after = isVisible ? 0 : $head.indexAfter(-1)
const type = defaultBlockAt(above.contentMatchAt(after))
if (!type || !above.canReplaceWith(after, after, type)) {
return false
}
const node = type.createAndFill()
if (!node) {
return false
}
const pos = isVisible ? $head.after() + 1 : $head.after(-1)
const tr = state.tr.replaceWith(pos, pos, node)
const $pos = tr.doc.resolve(pos)
const newSelection = Selection.near($pos, 1)
tr.setSelection(newSelection)
tr.scrollIntoView()
view.dispatch(tr)
return true
},
// The default gapcursor implementation can’t handle hidden content, so we need to fix this.
ArrowRight: ({ editor }) => {
return setGapCursor(editor, 'right')
},
// The default gapcursor implementation can’t handle hidden content, so we need to fix this.
ArrowDown: ({ editor }) => {
return setGapCursor(editor, 'down')
},
}
},
addProseMirrorPlugins() {
return [
// This plugin prevents text selections within the hidden content in `DetailsContent`.
// The cursor is moved to the next visible position.
new Plugin({
key: new PluginKey('detailsSelection'),
appendTransaction: (transactions, oldState, newState) => {
const { editor, type } = this
const isComposing = editor.view.composing
if (isComposing) {
return
}
const selectionSet = transactions.some(transaction => transaction.selectionSet)
if (!selectionSet || !oldState.selection.empty || !newState.selection.empty) {
return
}
const detailsIsActive = isActive(newState, type.name)
if (!detailsIsActive) {
return
}
const { $from } = newState.selection
const isVisible = isNodeVisible($from.pos, editor)
if (isVisible) {
return
}
const details = findClosestVisibleNode($from, node => node.type === type, editor)
if (!details) {
return
}
const detailsSummaries = findChildren(
details.node,
node => node.type === newState.schema.nodes.detailsSummary,
)
if (!detailsSummaries.length) {
return
}
const detailsSummary = detailsSummaries[0]
const selectionDirection =
oldState.selection.from < newState.selection.from ? 'forward' : 'backward'
const correctedPosition =
selectionDirection === 'forward'
? details.start + detailsSummary.pos
: details.pos + detailsSummary.pos + detailsSummary.node.nodeSize
const selection = TextSelection.create(newState.doc, correctedPosition)
const transaction = newState.tr.setSelection(selection)
return transaction
},
}),
]
},
})

View File

@@ -0,0 +1,32 @@
import type { Editor, Predicate } from '@tiptap/core'
import type { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'
import { isNodeVisible } from './isNodeVisible.js'
export const findClosestVisibleNode = (
$pos: ResolvedPos,
predicate: Predicate,
editor: Editor,
):
| {
pos: number
start: number
depth: number
node: PMNode
}
| undefined => {
for (let i = $pos.depth; i > 0; i -= 1) {
const node = $pos.node(i)
const match = predicate(node)
const isVisible = isNodeVisible($pos.start(i), editor)
if (match && isVisible) {
return {
pos: i > 0 ? $pos.before(i) : 0,
start: $pos.start(i),
depth: i,
node,
}
}
}
}

View File

@@ -0,0 +1,16 @@
import type { Editor } from '@tiptap/core'
export const isNodeVisible = (position: number, editor: Editor): boolean => {
const node = editor.view.domAtPos(position).node
// Use the node for elements and its parent for text nodes.
const element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement
if (!element) {
return false
}
// Hidden elements have no offset parent.
const isOpen = element.offsetParent !== null
return isOpen
}

View File

@@ -0,0 +1,61 @@
import type { Editor } from '@tiptap/core'
import { findChildren, findParentNode } from '@tiptap/core'
import { GapCursor } from '@tiptap/pm/gapcursor'
import type { ResolvedPos } from '@tiptap/pm/model'
import type { Selection } from '@tiptap/pm/state'
import { isNodeVisible } from './isNodeVisible.js'
export const setGapCursor = (editor: Editor, direction: 'down' | 'right') => {
const { state, view, extensionManager } = editor
const { schema, selection } = state
const { empty, $anchor } = selection
const hasGapCursorExtension = !!extensionManager.extensions.find(
extension => extension.name === 'gapCursor',
)
if (!empty || $anchor.parent.type !== schema.nodes.detailsSummary || !hasGapCursorExtension) {
return false
}
if (direction === 'right' && $anchor.parentOffset !== $anchor.parent.nodeSize - 2) {
return false
}
const details = findParentNode(node => node.type === schema.nodes.details)(selection)
if (!details) {
return false
}
const detailsContent = findChildren(
details.node,
node => node.type === schema.nodes.detailsContent,
)
if (!detailsContent.length) {
return false
}
const isOpen = isNodeVisible(details.start + detailsContent[0].pos + 1, editor)
if (isOpen) {
return false
}
const $position = state.doc.resolve(details.pos + details.node.nodeSize)
const $validPosition = GapCursor.findFrom($position, 1, false) as unknown as null | ResolvedPos
if (!$validPosition) {
return false
}
const { tr } = state
const gapCursorSelection = new GapCursor($validPosition) as Selection
tr.setSelection(gapCursorSelection)
tr.scrollIntoView()
view.dispatch(tr)
return true
}

View File

@@ -0,0 +1,7 @@
import { Details } from './details.js'
export * from './content/index.js'
export * from './details.js'
export * from './summary/index.js'
export default Details

View File

@@ -0,0 +1,45 @@
import { createBlockMarkdownSpec, mergeAttributes, Node } from '@tiptap/core'
export interface DetailsSummaryOptions {
/**
* Custom HTML attributes that should be added to the rendered HTML tag.
*/
HTMLAttributes: {
[key: string]: any
}
}
export const DetailsSummary = Node.create<DetailsSummaryOptions>({
name: 'detailsSummary',
content: 'text*',
defining: true,
selectable: false,
isolating: true,
addOptions() {
return {
HTMLAttributes: {},
}
},
parseHTML() {
return [
{
tag: 'summary',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['summary', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
...createBlockMarkdownSpec({
nodeName: 'detailsSummary',
content: 'inline',
}),
})

View File

@@ -0,0 +1,5 @@
import { DetailsSummary } from './details-summary.js'
export * from './details-summary.js'
export default DetailsSummary

View File

@@ -0,0 +1,261 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { Details, DetailsContent, DetailsSummary } from '@tiptap/extension-details'
describe('Details', () => {
let editor: Editor
let editorElement: HTMLDivElement | undefined
afterEach(() => {
editor?.destroy()
editorElement?.remove()
editorElement = undefined
})
it('adds a default aria-label to the toggle button', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Details, DetailsSummary, DetailsContent],
content: {
type: 'doc',
content: [
{
type: 'details',
content: [
{
type: 'detailsSummary',
content: [{ type: 'text', text: 'Summary' }],
},
{
type: 'detailsContent',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Content' }],
},
],
},
],
},
],
},
})
const toggleButton = editor.view.dom.querySelector('div[data-type="details"] > button')
expect(toggleButton?.getAttribute('aria-label')).toBe('Expand details content')
})
it('supports custom toggle button rendering and updates it when the node open state changes', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Details.configure({
persist: true,
renderToggleButton: ({ element, isOpen, node }) => {
element.setAttribute(
'aria-label',
`${isOpen ? 'Collapse' : 'Expand'} ${node.textContent || 'details content'}`,
)
},
}),
DetailsSummary,
DetailsContent,
],
content: {
type: 'doc',
content: [
{
type: 'details',
attrs: {
open: false,
},
content: [
{
type: 'detailsSummary',
content: [{ type: 'text', text: 'Summary' }],
},
{
type: 'detailsContent',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Content' }],
},
],
},
],
},
],
},
})
const getToggleButton = () => editor.view.dom.querySelector('div[data-type="details"] > button')
expect(getToggleButton()?.getAttribute('aria-label')).toBe('Expand SummaryContent')
editor.commands.updateAttributes('details', { open: true })
expect(getToggleButton()?.getAttribute('aria-label')).toBe('Collapse SummaryContent')
})
it('updates a persisted details node at the start of the document when toggled', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Details.configure({ persist: true }),
DetailsSummary,
DetailsContent,
],
content: {
type: 'doc',
content: [
{
type: 'details',
attrs: {
open: true,
},
content: [
{
type: 'detailsSummary',
content: [{ type: 'text', text: 'Summary' }],
},
{
type: 'detailsContent',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Content' }],
},
],
},
],
},
],
},
})
const toggleButton = editor.view.dom.querySelector<HTMLButtonElement>(
'div[data-type="details"] > button',
)
toggleButton?.click()
expect(editor.state.doc.firstChild?.attrs.open).toBe(false)
expect(editor.getHTML()).toBe(
'<details><summary>Summary</summary><div data-type="detailsContent"><p>Content</p></div></details>',
)
})
it('keeps the cursor in details content after typing at the start of the document', () => {
editorElement = document.createElement('div')
document.body.appendChild(editorElement)
editor = new Editor({
element: editorElement,
extensions: [Document, Paragraph, Text, Details, DetailsSummary, DetailsContent],
content: {
type: 'doc',
content: [
{
type: 'details',
content: [
{
type: 'detailsSummary',
content: [{ type: 'text', text: 'Summary' }],
},
{
type: 'detailsContent',
content: [{ type: 'paragraph' }],
},
],
},
],
},
})
Object.defineProperty(editor.view.dom, 'offsetParent', { value: document.body })
const paragraph = editor.view.dom.querySelector('p')
if (!paragraph) {
throw new Error('Expected details content to contain a paragraph')
}
Object.defineProperty(paragraph, 'offsetParent', {
value: editor.view.dom,
})
const toggleButton = editor.view.dom.querySelector<HTMLButtonElement>(
'div[data-type="details"] > button',
)
const summary = editor.state.doc.firstChild?.firstChild
const contentPosition = 1 + (summary?.nodeSize ?? 0) + 2
toggleButton?.click()
editor.commands.setTextSelection(contentPosition)
editor.commands.insertContent('a')
editor.commands.setTextSelection(contentPosition + 1)
expect(editor.state.selection.$from.parent.type).toBe(editor.schema.nodes.paragraph)
expect(editor.state.doc.textContent).toBe('Summarya')
})
it('ignores button attribute mutations triggered by renderToggleButton', async () => {
let renderToggleButtonCallCount = 0
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Details.configure({
renderToggleButton: ({ element, isOpen }) => {
renderToggleButtonCallCount += 1
element.setAttribute(
'aria-label',
isOpen ? 'Collapse details content' : 'Expand details content',
)
},
}),
DetailsSummary,
DetailsContent,
],
content: {
type: 'doc',
content: [
{
type: 'details',
content: [
{
type: 'detailsSummary',
content: [{ type: 'text', text: 'Summary' }],
},
{
type: 'detailsContent',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Content' }],
},
],
},
],
},
],
},
})
await new Promise<void>(resolve => {
setTimeout(resolve, 0)
})
expect(renderToggleButtonCallCount).toBe(1)
})
})

View File

@@ -0,0 +1,50 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
import { Details, DetailsContent, DetailsSummary } from '@tiptap/extension-details'
describe('Details commands', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, Details, DetailsSummary, DetailsContent],
content: '<p>Example Text</p>',
})
editor.commands.selectAll()
})
afterEach(() => {
editor.destroy()
})
it('parses <details> tags correctly', () => {
editor.commands.setContent('<details><summary>Summary</summary><p>Content</p></details>')
expect(editor.getHTML()).toBe(
'<details><summary>Summary</summary><div data-type="detailsContent"><p>Content</p></div></details>',
)
})
it('setDetails wraps the selection in a details node', () => {
expect(editor.view.dom.querySelectorAll('[data-type="details"]').length).toBe(0)
editor.commands.setDetails()
const content = editor.view.dom.querySelector(
'[data-type="details"] [data-type="detailsContent"]',
)
expect(content?.textContent).toContain('Example Text')
})
it('unsetDetails turns the details node back into a paragraph', () => {
editor.commands.setDetails()
expect(editor.view.dom.querySelectorAll('[data-type="details"]').length).toBe(1)
editor.commands.unsetDetails()
expect(editor.view.dom.querySelectorAll('[data-type="details"]').length).toBe(0)
})
})

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts'],
...basePackConfig(),
},
})