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
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:
1186
packages/extension-details/CHANGELOG.md
Normal file
1186
packages/extension-details/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extension-details/README.md
Normal file
18
packages/extension-details/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-details
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-details)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-details)
|
||||
[](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).
|
||||
54
packages/extension-details/package.json
Normal file
54
packages/extension-details/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
170
packages/extension-details/src/content/details-content.ts
Normal file
170
packages/extension-details/src/content/details-content.ts
Normal 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',
|
||||
}),
|
||||
})
|
||||
5
packages/extension-details/src/content/index.ts
Normal file
5
packages/extension-details/src/content/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DetailsContent } from './details-content.js'
|
||||
|
||||
export * from './details-content.js'
|
||||
|
||||
export default DetailsContent
|
||||
514
packages/extension-details/src/details.ts
Normal file
514
packages/extension-details/src/details.ts
Normal 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
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/extension-details/src/helpers/isNodeVisible.ts
Normal file
16
packages/extension-details/src/helpers/isNodeVisible.ts
Normal 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
|
||||
}
|
||||
61
packages/extension-details/src/helpers/setGapCursor.ts
Normal file
61
packages/extension-details/src/helpers/setGapCursor.ts
Normal 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
|
||||
}
|
||||
7
packages/extension-details/src/index.ts
Normal file
7
packages/extension-details/src/index.ts
Normal 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
|
||||
45
packages/extension-details/src/summary/details-summary.ts
Normal file
45
packages/extension-details/src/summary/details-summary.ts
Normal 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',
|
||||
}),
|
||||
})
|
||||
5
packages/extension-details/src/summary/index.ts
Normal file
5
packages/extension-details/src/summary/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DetailsSummary } from './details-summary.js'
|
||||
|
||||
export * from './details-summary.js'
|
||||
|
||||
export default DetailsSummary
|
||||
261
packages/extension-details/tests/integration/details.test.ts
Normal file
261
packages/extension-details/tests/integration/details.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
9
packages/extension-details/vite.config.ts
Normal file
9
packages/extension-details/vite.config.ts
Normal 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(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user