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

18
packages/core/README.md Normal file
View File

@@ -0,0 +1,18 @@
# @tiptap/core
[![Version](https://img.shields.io/npm/v/@tiptap/core.svg?label=version)](https://www.npmjs.com/package/@tiptap/core)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/core.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/core.svg)](https://www.npmjs.com/package/@tiptap/core)
[![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 @@
module.exports = require('../dist/jsx-runtime/jsx-runtime.cjs')

View File

@@ -0,0 +1 @@
export * from '../src/jsx-runtime.ts'

View File

@@ -0,0 +1 @@
export type * from '../src/jsx-runtime.js'

View File

@@ -0,0 +1 @@
export * from '../dist/jsx-runtime/jsx-runtime.js'

View File

@@ -0,0 +1 @@
module.exports = require('../dist/jsx-runtime/jsx-runtime.cjs')

View File

@@ -0,0 +1 @@
export * from '../src/jsx-runtime.ts'

1
packages/core/jsx-runtime/index.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export type * from '../src/jsx-runtime.ts'

View File

@@ -0,0 +1 @@
export * from '../dist/jsx-runtime/jsx-runtime.js'

View File

@@ -0,0 +1,73 @@
{
"name": "@tiptap/core",
"version": "3.30.3",
"description": "headless rich text editor",
"keywords": [
"headless",
"prosemirror",
"text editor",
"tiptap",
"wysiwyg"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/core"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist",
"jsx-runtime",
"jsx-dev-runtime"
],
"type": "module",
"sideEffects": false,
"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"
},
"./jsx-runtime": {
"types": {
"import": "./jsx-runtime/index.d.ts",
"require": "./jsx-runtime/index.d.cts"
},
"import": "./jsx-runtime/index.js",
"require": "./jsx-runtime/index.cjs"
},
"./jsx-dev-runtime": {
"types": {
"import": "./jsx-dev-runtime/index.d.ts",
"require": "./jsx-dev-runtime/index.d.cts"
},
"import": "./jsx-dev-runtime/index.js",
"require": "./jsx-dev-runtime/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/pm": "workspace:^",
"prosemirror-model": "^1.25.11"
},
"peerDependencies": {
"@tiptap/pm": "workspace:*"
}
}

View File

@@ -0,0 +1,220 @@
import Bold from '@tiptap/extension-bold'
import Code from '@tiptap/extension-code'
import CodeBlock from '@tiptap/extension-code-block'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { UndoRedo } from '@tiptap/extensions'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { Editor } from './Editor.js'
describe('can', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
it('not undo', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo],
})
const canUndo = editor.can().undo()
expect(canUndo).toBe(false)
})
it('undo', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo],
})
editor.commands.setContent('foo')
const canUndo = editor.can().undo()
expect(canUndo).toBe(true)
})
it('not chain undo', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo],
})
const canUndo = editor.can().chain().undo().run()
expect(canUndo).toBe(false)
})
it('chain undo', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo],
})
editor.commands.setContent('foo')
const canUndo = editor.can().chain().undo().run()
expect(canUndo).toBe(true)
})
it('returns false for non-applicable marks when cursor is positioned in node that disallows marks', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo, CodeBlock, Bold],
})
// Set up a code block and position cursor inside it (no selection)
editor
.chain()
.setCodeBlock()
.insertContent('Test code block')
.setTextSelection(5) // Position cursor in the middle of the text
.run()
const canSetMarkToBold = editor.can().setMark('bold')
expect(canSetMarkToBold).toBe(false)
})
it('returns false for non-applicable marks when selection contains node in conflict', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo, CodeBlock, Bold],
})
editor
.chain()
.setCodeBlock()
.insertContent('Test code block')
.setTextSelection({ from: 2, to: 3 })
.selectAll()
.run()
const canSetMarkToBold = editor.can().setMark('bold')
expect(canSetMarkToBold).toBe(false)
})
it('returns false for non-applicable marks when selection contains marks in conflict', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
})
editor.chain().setContent('<code>test</code>').setTextSelection({ from: 2, to: 3 }).run()
const canSetMarkToBold = editor.can().setMark('bold')
expect(canSetMarkToBold).toBe(false)
})
it('returns false for non-applicable marks when stored marks in conflict', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
})
editor.chain().setContent('<code>test</code>').run()
const canSetMarkToBold = editor.can().setMark('bold')
expect(canSetMarkToBold).toBe(false)
})
it('returns false for non-applicable marks when selecting multiple nodes in conflict', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
})
editor.chain().setContent('<code>test</code><code>123</code>').selectAll().run()
const canSetMarkToBold = editor.can().setMark('bold')
expect(canSetMarkToBold).toBe(false)
})
it('returns true for applicable marks when selection does not contain nodes in conflict', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo, CodeBlock, Bold],
})
editor
.chain()
.setCodeBlock()
.insertContent('Test code block')
.exitCode()
.insertContent('Additional paragraph node')
.selectAll()
.run()
const canSetMarkToBold = editor.can().setMark('bold')
expect(canSetMarkToBold).toBe(true)
})
it('returns true for applicable marks when stored marks are not in conflict', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
})
editor.chain().setContent('<code>test</code>').toggleCode().run()
const canSetMarkToBold = editor.can().setMark('bold')
expect(canSetMarkToBold).toBe(true)
})
it('returns true for applicable marks when selection does not contain marks in conflict', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
})
editor
.chain()
.setContent('<code>test</code>')
.setTextSelection({ from: 2, to: 3 })
.toggleCode()
.run()
const canSetMarkToBold = editor.can().setMark('bold')
expect(canSetMarkToBold).toBe(true)
})
it('returns true for applicable marks if at least one node in selection has no marks in conflict', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
})
editor.chain().setContent('<code>test</code><i>123</i>').selectAll().run()
const canSetMarkToBold = editor.can().setMark('bold')
expect(canSetMarkToBold).toBe(true)
})
it('builds and passes down an undefined dispatch for nested "can" chain', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, UndoRedo],
})
let capturedOuterDispatch: ((args?: any) => any) | undefined
let capturedInnerDispatch: ((args?: any) => any) | undefined
editor
.can()
.chain()
.command(({ chain, dispatch: outterDispatch }) => {
capturedOuterDispatch = outterDispatch
return chain()
.command(({ dispatch: innerDispatch }) => {
capturedInnerDispatch = innerDispatch
return true
})
.run()
})
.run()
expect(capturedOuterDispatch).toBe(undefined)
expect(capturedInnerDispatch).toBe(undefined)
})
})

View File

@@ -0,0 +1,208 @@
import type { EditorState, Transaction } from '@tiptap/pm/state'
import type { Editor } from './Editor.js'
import { createChainableState } from './helpers/createChainableState.js'
import type {
AnyCommands,
CanCommands,
ChainedCommands,
CommandProps,
SingleCommands,
} from './types.js'
export class CommandManager {
editor: Editor
rawCommands: AnyCommands
customState?: EditorState
constructor(props: { editor: Editor; state?: EditorState }) {
this.editor = props.editor
this.rawCommands = this.editor.extensionManager.commands
this.customState = props.state
}
get hasCustomState(): boolean {
return !!this.customState
}
get state(): EditorState {
return this.customState || this.editor.state
}
get commands(): SingleCommands {
const { rawCommands, editor, state } = this
const { view } = editor
const { tr } = state
const props = this.buildProps(tr)
return Object.fromEntries(
Object.entries(rawCommands).map(([name, command]) => {
const method = (...args: any[]) => {
const callback = command(...args)(props)
if (!tr.getMeta('preventDispatch') && !this.hasCustomState) {
view.dispatch(tr)
}
return callback
}
return [name, method]
}),
) as unknown as SingleCommands
}
get chain(): () => ChainedCommands {
return () => this.createChain()
}
get can(): () => CanCommands {
return () => this.createCan()
}
public createChain(startTr?: Transaction, shouldDispatch = true): ChainedCommands {
const { rawCommands, editor, state } = this
const { view } = editor
const callbacks: boolean[] = []
const hasStartTransaction = !!startTr
const tr = startTr || state.tr
const run = () => {
if (
!hasStartTransaction &&
shouldDispatch &&
!tr.getMeta('preventDispatch') &&
!this.hasCustomState
) {
view.dispatch(tr)
}
return callbacks.every(callback => callback === true)
}
const chain = {
...Object.fromEntries(
Object.entries(rawCommands).map(([name, command]) => {
const chainedCommand = (...args: never[]) => {
const props = this.buildProps(tr, shouldDispatch)
const callback = command(...args)(props)
callbacks.push(callback)
return chain
}
return [name, chainedCommand]
}),
),
run,
} as unknown as ChainedCommands
return chain
}
/**
* Creates a chain that safely returns `false` when run.
* @returns A non-dispatching command chain.
* @example
* const chain = CommandManager.createFakeChain()
* chain.focus().run() // false
*/
public static createFakeChain(): ChainedCommands {
const chain = new Proxy(
{},
{
get: (_target, property) => {
if (property === 'then') {
return undefined
}
if (property === 'run') {
return () => false
}
return () => chain
},
},
) as ChainedCommands
return chain
}
public createCan(startTr?: Transaction): CanCommands {
const { rawCommands, state } = this
const dispatch = false
const tr = startTr || state.tr
const props = this.buildProps(tr, dispatch)
const formattedCommands = Object.fromEntries(
Object.entries(rawCommands).map(([name, command]) => {
return [name, (...args: never[]) => command(...args)({ ...props, dispatch: undefined })]
}),
) as unknown as SingleCommands
return {
...formattedCommands,
chain: () => this.createChain(tr, dispatch),
} as CanCommands
}
/**
* Creates capability checks that safely return `false`.
* @returns A non-dispatching capability checker.
* @example
* const can = CommandManager.createFallbackCan()
* can.focus() // false
*/
public static createFallbackCan(): CanCommands {
const chain = CommandManager.createFakeChain()
const can = new Proxy(
{
chain: () => chain,
},
{
get: (target, property) => {
if (property === 'then') {
return undefined
}
if (property === 'chain') {
return target.chain
}
return () => false
},
},
) as CanCommands
return can
}
public buildProps(tr: Transaction, shouldDispatch = true): CommandProps {
const { rawCommands, editor, state } = this
const { view } = editor
const props: CommandProps = {
tr,
editor,
view,
state: createChainableState({
state,
transaction: tr,
}),
dispatch: shouldDispatch ? () => undefined : undefined,
chain: () => this.createChain(tr, shouldDispatch),
can: () => this.createCan(tr),
get commands() {
return Object.fromEntries(
Object.entries(rawCommands).map(([name, command]) => {
return [name, (...args: never[]) => command(...args)(props)]
}),
) as unknown as SingleCommands
},
}
return props
}
}

View File

@@ -0,0 +1,681 @@
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import StarterKit from '@tiptap/starter-kit'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
import { Editor } from './Editor.js'
import { Extension } from './Extension.js'
const createTestEditor = () =>
new Editor({
element: document.createElement('div'),
extensions: [StarterKit],
})
describe('Editor', () => {
it('keeps command fallbacks available while commands initialize', async () => {
let chain: ReturnType<Editor['chain']> | undefined
let can: ReturnType<Editor['can']> | undefined
const editor = new Editor({
element: document.createElement('div'),
extensions: [
StarterKit,
Extension.create({
name: 'initialization-test',
addCommands() {
chain = this.editor.chain()
can = this.editor.can()
return {}
},
}),
],
})
expect(chain?.focus().run()).toBe(false)
expect(can?.focus()).toBe(false)
expect(await Promise.resolve(chain)).toBe(chain)
expect(await Promise.resolve(can)).toBe(can)
editor.destroy()
})
describe('destroy', () => {
it('should keep the command chain accessible after the editor is destroyed', () => {
const editor = createTestEditor()
expect(editor.isDestroyed).toBe(false)
expect(editor.chain).not.toBe(null)
editor.destroy()
expect(editor.isDestroyed).toBe(true)
expect(editor.chain).not.toBe(null)
expect(editor.chain().setContent('').run()).toBe(false)
})
it('should keep the command can accessible after the editor is destroyed', () => {
const editor = createTestEditor()
expect(editor.isDestroyed).toBe(false)
expect(editor.can).not.toBe(null)
editor.destroy()
expect(editor.isDestroyed).toBe(true)
expect(editor.can).not.toBe(null)
// can on command
expect(editor.can().setContent('')).toBe(false)
// can on chain
expect(editor.can().chain().setContent('').blur().run()).toBe(false)
})
})
})
describe('editorProps', () => {
const editors: Editor[] = []
afterEach(() => {
editors.forEach(editor => editor.destroy())
editors.length = 0
})
it('editorProps can be set while constructing Editor', () => {
function transformPastedHTML(html: string) {
return html
}
const editor = new Editor({
extensions: [Document, Paragraph, Text],
editorProps: { transformPastedHTML },
})
editors.push(editor)
expect(transformPastedHTML).toBe(editor.view.props.transformPastedHTML)
})
it('editorProps can be set through setOptions', () => {
function transformPastedHTML(html: string) {
return html
}
const editor = new Editor({
extensions: [Document, Paragraph, Text],
})
editors.push(editor)
editor.setOptions({ editorProps: { transformPastedHTML } })
expect(transformPastedHTML).toBe(editor.view.props.transformPastedHTML)
})
})
describe('editor.getHTML / editor.getJSON', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [StarterKit],
content: '<p>Example Text</p>',
})
})
afterEach(() => {
editor.destroy()
})
it('returns html', () => {
expect(editor.getHTML()).toBe('<p>Example Text</p>')
})
it('returns json', () => {
expect(editor.getJSON()).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Example Text' }],
},
],
})
})
})
describe('onContentError', () => {
it('does not emit a contentError on invalid content (by default)', () => {
const json = {
invalid: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example Text',
},
],
},
],
}
const editor = new Editor({
content: json,
extensions: [Document, Paragraph, Text],
onContentError: () => {
expect(false).toBe(true)
},
})
expect(editor.getText()).toBe('')
})
it('does not emit a contentError on invalid content (when enableContentCheck = false)', () => {
const json = {
invalid: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example Text',
},
],
},
],
}
const editor = new Editor({
content: json,
extensions: [Document, Paragraph, Text],
enableContentCheck: false,
onContentError: () => {
expect(false).toBe(true)
},
})
expect(editor.getText()).toBe('')
})
it('emits a contentError on invalid content (when enableContentCheck = true)', async () => {
const json = {
invalid: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example Text',
},
],
},
],
}
let contentErrorCalled = false
let errorMessage = ''
const editor = new Editor({
content: json,
extensions: [Document, Paragraph, Text],
enableContentCheck: true,
onContentError: ({ error }) => {
contentErrorCalled = true
errorMessage = error.message
},
})
// Wait for async initialization to complete
await new Promise<void>(resolve => {
setTimeout(resolve, 0)
})
expect(contentErrorCalled).toBe(true)
expect(errorMessage).toBe('[tiptap error]: Invalid JSON content')
expect(editor.getText()).toBe('')
})
it('does not emit a contentError on valid content', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example Text',
},
],
},
],
}
const editor = new Editor({
content: json,
extensions: [Document, Paragraph, Text],
enableContentCheck: true,
onContentError: () => {
expect(false).toBe(true)
},
})
expect(editor.getText()).toBe('Example Text')
})
it('removes the collaboration extension when has invalid content (when enableContentCheck = true)', () => {
const json = {
invalid: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example Text',
},
],
},
],
}
const editor = new Editor({
content: json,
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'collaboration',
addStorage() {
return {
isDisabled: false,
}
},
}),
],
enableContentCheck: true,
onContentError: args => {
args.disableCollaboration()
expect(args.editor.storage.collaboration).toBe(undefined)
},
})
expect(editor.getText()).toBe('')
expect(editor.storage.collaboration).toBe(undefined)
})
it('does not remove the collaboration extension when has valid content (when enableContentCheck = true)', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example Text',
},
],
},
],
}
const editor = new Editor({
content: json,
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'collaboration',
addStorage() {
return {
isDisabled: false,
}
},
}),
],
enableContentCheck: true,
onContentError: () => {
// Should not be called, so we fail the test
expect(true).toBe(false)
},
})
expect(editor.getText()).toBe('Example Text')
expect(editor.storage.collaboration.isDisabled).toBe(false)
})
it('does not throw when calling setContent(valid) from the onContentError handler', () => {
const json = {
invalid: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example Text',
},
],
},
],
}
const validJson = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Recovered',
},
],
},
],
}
let editor: Editor | undefined
expect(() => {
editor = new Editor({
content: json,
extensions: [Document, Paragraph, Text],
enableContentCheck: true,
onContentError: ({ editor: contentErrorEditor }) => {
contentErrorEditor.commands.setContent(validJson)
},
})
}).not.toThrow()
expect(editor?.getText()).toBe('Recovered')
})
// Invalid HTML whose fallback keeps a distinguishable "keepme" paragraph, so these
// tests fail if the fallback doc is ever discarded rather than preserved.
const invalidHtmlWithSalvageableText = '<p>keepme</p><foobar></foobar>'
it('preserves the fallback content when the handler runs a non-setContent command', () => {
const editor = new Editor({
content: invalidHtmlWithSalvageableText,
extensions: [Document, Paragraph, Text],
enableContentCheck: true,
onContentError: ({ editor: contentErrorEditor }) => {
contentErrorEditor.commands.setTextSelection(0)
},
})
expect(editor.getText()).toBe('keepme')
})
it('keeps the stripped fallback doc when the handler does nothing', () => {
const editor = new Editor({
content: invalidHtmlWithSalvageableText,
extensions: [Document, Paragraph, Text],
enableContentCheck: true,
onContentError: () => {},
})
expect(editor.getText()).toBe('keepme')
})
})
describe('unmounted', () => {
it('should not throw an error when the editor is unmounted', () => {
expect(() => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
expect(!!editor).toBe(true)
}).not.toThrow()
})
it('should have a view property that is not null', () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
expect(!!editor.view).toBe(true)
})
it('should emit a mount event when the editor is mounted', async () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
let called = false
editor.on('mount', () => {
called = true
expect(called).toBe(true)
})
editor.mount(document.createElement('div'))
await new Promise(resolve => {
setTimeout(resolve, 0)
})
expect(called).toBe(true)
editor.unmount()
})
it('should inject CSS when the editor is mounted', async () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
let called = false
editor.on('mount', () => {
called = true
expect(document.head.querySelectorAll('style[data-tiptap-style]')).toHaveLength(1)
})
expect(document.head.querySelectorAll('style[data-tiptap-style]')).toHaveLength(0)
editor.mount(document.createElement('div'))
await new Promise(resolve => {
setTimeout(resolve, 0)
})
expect(called).toBe(true)
editor.unmount()
})
it('should emit an unmount event when the editor is unmounted', async () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
editor.mount(document.createElement('div'))
let called = false
editor.on('unmount', () => {
called = true
expect(called).toBe(true)
})
editor.unmount()
await new Promise(resolve => {
setTimeout(resolve, 0)
})
expect(called).toBe(true)
})
it('should only remove injected CSS when the editor is unmounted if no other editors exist', async () => {
const elementA = document.createElement('div')
document.body.appendChild(elementA)
const elementB = document.createElement('div')
document.body.appendChild(elementB)
const editorA = new Editor({
element: elementA,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
const editorB = new Editor({
element: elementB,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
let called = false
editorA.on('unmount', () => {
expect(document.head.querySelectorAll('style[data-tiptap-style]')).toHaveLength(1)
editorB.unmount()
})
editorB.on('unmount', () => {
called = true
expect(document.head.querySelectorAll('style[data-tiptap-style]')).toHaveLength(0)
})
editorA.unmount()
await new Promise(resolve => {
setTimeout(resolve, 0)
})
expect(called).toBe(true)
elementA.remove()
elementB.remove()
})
it('should emit a destroy event when the editor is destroyed', async () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
let called = false
editor.on('destroy', () => {
called = true
expect(called).toBe(true)
})
editor.destroy()
await new Promise(resolve => {
setTimeout(resolve, 0)
})
expect(called).toBe(true)
expect(editor.isDestroyed).toBe(true)
})
it('should emit an update event when the editor is updated', async () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
let called = false
editor.on('update', () => {
called = true
expect(called).toBe(true)
})
editor.chain().setContent('<p>Test</p>').run()
await new Promise(resolve => {
setTimeout(resolve, 0)
})
expect(called).toBe(true)
})
it('should emit a transaction event when the editor is updated', async () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
let called = false
editor.on('transaction', () => {
called = true
expect(called).toBe(true)
})
editor.chain().setContent('<p>Test</p>').run()
await new Promise(resolve => {
setTimeout(resolve, 0)
})
expect(called).toBe(true)
})
it('should emit a selectionUpdate event when the editor is updated', async () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
let called = false
editor.on('selectionUpdate', () => {
called = true
expect(called).toBe(true)
})
editor.chain().setContent('<p>Test</p>').run()
await new Promise(resolve => {
setTimeout(resolve, 0)
})
expect(called).toBe(true)
})
it('should be able to make changes to the editor', () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
editor.chain().setContent('<p>Test</p>').run()
expect(editor.state.doc.toJSON()).toEqual({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test' }] }],
})
})
it('should be able to make multiple changes to the editor', () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
editor.chain().setContent('<p>Test</p>').run()
editor.chain().setContent('<p>Test 2</p>').run()
expect(editor.state.doc.toJSON()).toEqual({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test 2' }] }],
})
})
it('should be able to read state from the editor view', () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
editor.chain().setContent('<p>Test</p>').run()
expect(editor.view.state.doc.toJSON()).toEqual({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test' }] }],
})
})
it('should have some commonly accessed properties that are not null', () => {
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
expect(editor.view.editable).toBe(true)
expect(editor.view.composing).toBe(false)
expect(editor.view.dragging).toBe(null)
expect(editor.view.isDestroyed).toBe(false)
})
})

888
packages/core/src/Editor.ts Normal file
View File

@@ -0,0 +1,888 @@
/* oslint-disableno-empty-object-type */
import type { MarkType, Node as PMNode, NodeType, Schema } from '@tiptap/pm/model'
import type { Plugin, PluginKey, Transaction } from '@tiptap/pm/state'
import { EditorState } from '@tiptap/pm/state'
import { type DirectEditorProps, EditorView } from '@tiptap/pm/view'
import { CommandManager } from './CommandManager.js'
import { isInDecorationApplyScope } from './decorations/decorationApplyScope.js'
import { EventEmitter } from './EventEmitter.js'
import { ExtensionManager } from './ExtensionManager.js'
import {
ClipboardTextSerializer,
Commands,
Delete,
Drop,
Editable,
FocusEvents,
Keymap,
Paste,
Tabindex,
TextDirection,
} from './extensions/index.js'
import { createDocument } from './helpers/createDocument.js'
import { getAttributes } from './helpers/getAttributes.js'
import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js'
import { getText } from './helpers/getText.js'
import { getTextSerializersFromSchema } from './helpers/getTextSerializersFromSchema.js'
import { isActive } from './helpers/isActive.js'
import { isNodeEmpty } from './helpers/isNodeEmpty.js'
import { createMappablePosition, getUpdatedPosition } from './helpers/MappablePosition.js'
import { resolveFocusPosition } from './helpers/resolveFocusPosition.js'
import { warnOnDuplicatedProseMirrorModel } from './helpers/warnOnDuplicatedProseMirrorModel.js'
import type { Storage } from './index.js'
import { NodePos } from './NodePos.js'
import { style } from './style.js'
import type {
CanCommands,
ChainedCommands,
DocumentType,
EditorEvents,
EditorOptions,
NodeType as TNodeType,
SingleCommands,
TextSerializer,
TextType as TTextType,
Utils,
} from './types.js'
import { createStyleTag } from './utilities/createStyleTag.js'
import { isDev } from './utilities/isDev.js'
import { isFunction } from './utilities/isFunction.js'
export * as extensions from './extensions/index.js'
// @ts-ignore
export interface TiptapEditorHTMLElement extends HTMLElement {
editor?: Editor
}
export class Editor extends EventEmitter<EditorEvents> {
private commandManager!: CommandManager
public extensionManager!: ExtensionManager
private css: HTMLStyleElement | null = null
private className = 'tiptap'
public schema!: Schema
private editorView: EditorView | null = null
public isFocused = false
private destroyed = false
private editorState!: EditorState
/**
* The editor is considered initialized after the `create` event has been emitted.
*/
public isInitialized = false
public extensionStorage: Storage = {} as Storage
/**
* A unique ID for this editor instance.
*/
public instanceId = Math.random().toString(36).slice(2, 9)
private hasWarnedStaleDecorationRead = false
public options: EditorOptions = {
element: typeof document !== 'undefined' ? document.createElement('div') : null,
content: '',
injectCSS: true,
injectNonce: undefined,
extensions: [],
autofocus: false,
editable: true,
textDirection: undefined,
editorProps: {},
parseOptions: {},
coreExtensionOptions: {},
enableInputRules: true,
enablePasteRules: true,
enableCoreExtensions: true,
enableContentCheck: false,
emitContentError: false,
onBeforeCreate: () => null,
onCreate: () => null,
onMount: () => null,
onUnmount: () => null,
onUpdate: () => null,
onSelectionUpdate: () => null,
onTransaction: () => null,
onFocus: () => null,
onBlur: () => null,
onDestroy: () => null,
onContentError: ({ error }) => {
throw error
},
onPaste: () => null,
onDrop: () => null,
onDelete: () => null,
enableExtensionDispatchTransaction: true,
}
constructor(options: Partial<EditorOptions> = {}) {
super()
this.setOptions(options)
this.createExtensionManager()
this.createCommandManager()
this.createSchema()
this.on('beforeCreate', this.options.onBeforeCreate)
this.emit('beforeCreate', { editor: this })
this.on('mount', this.options.onMount)
this.on('unmount', this.options.onUnmount)
this.on('contentError', this.options.onContentError)
this.on('create', this.options.onCreate)
this.on('update', this.options.onUpdate)
this.on('selectionUpdate', this.options.onSelectionUpdate)
this.on('transaction', this.options.onTransaction)
this.on('focus', this.options.onFocus)
this.on('blur', this.options.onBlur)
this.on('destroy', this.options.onDestroy)
this.on('drop', ({ event, slice, moved }) => this.options.onDrop(event, slice, moved))
this.on('paste', ({ event, slice }) => this.options.onPaste(event, slice))
this.on('delete', this.options.onDelete)
const initialDoc = this.createDoc()
// createDoc() already seeds editorState from the fallback doc on a content error
if (!this.editorState) {
const selection = resolveFocusPosition(initialDoc, this.options.autofocus)
this.editorState = EditorState.create({
doc: initialDoc,
schema: this.schema,
selection: selection || undefined,
})
}
warnOnDuplicatedProseMirrorModel(this.schema)
if (this.options.element) {
this.mount(this.options.element)
}
}
/**
* Attach the editor to the DOM, creating a new editor view.
*/
public mount(el: NonNullable<EditorOptions['element']> & {}) {
if (typeof document === 'undefined') {
throw new Error(
`[tiptap error]: The editor cannot be mounted because there is no 'document' defined in this environment.`,
)
}
this.createView(el)
this.emit('mount', { editor: this })
if (this.css && !document.head.contains(this.css)) {
document.head.appendChild(this.css)
}
window.setTimeout(() => {
if (this.isDestroyed) {
return
}
if (this.options.autofocus !== false && this.options.autofocus !== null) {
this.commands.focus(this.options.autofocus)
}
this.emit('create', { editor: this })
this.isInitialized = true
}, 0)
}
/**
* Remove the editor from the DOM, but still allow remounting at a different point in time
*/
public unmount() {
if (this.editorView) {
// Keep the cached state in sync so plugin state stays available after unmount.
this.editorState = this.editorView.state
// Cleanup our reference to prevent circular references which caused memory leaks
// @ts-ignore
const dom = this.editorView.dom as TiptapEditorHTMLElement
if (dom?.editor) {
delete dom.editor
}
this.editorView.destroy()
}
this.editorView = null
this.isInitialized = false
// Safely remove CSS element with fallback for test environments
// Only remove CSS if no other editors exist in the document after unmount
if (this.css && !document.querySelectorAll(`.${this.className}`).length) {
try {
if (typeof this.css.remove === 'function') {
this.css.remove()
} else if (this.css.parentNode) {
this.css.parentNode.removeChild(this.css)
}
} catch (error) {
// Silently handle any unexpected DOM removal errors in test environments
console.warn('Failed to remove CSS element:', error)
}
}
this.css = null
this.emit('unmount', { editor: this })
}
/**
* Returns the editor storage.
*/
public get storage(): Storage {
return this.extensionStorage
}
/**
* An object of all registered commands.
*/
public get commands(): SingleCommands {
return this.commandManager.commands
}
/**
* Create a command chain to call multiple commands at once.
*/
public chain(): ChainedCommands {
if (!this.commandManager) {
return CommandManager.createFakeChain()
}
return this.commandManager.chain()
}
/**
* Check if a command or a command chain can be executed. Without executing it.
*/
public can(): CanCommands {
if (!this.commandManager) {
return CommandManager.createFallbackCan()
}
return this.commandManager.can()
}
/**
* Inject CSS styles.
*/
private injectCSS(): void {
if (this.options.injectCSS && typeof document !== 'undefined') {
this.css = createStyleTag(style, this.options.injectNonce)
}
}
/**
* Update editor options.
*
* @param options A list of options
*/
public setOptions(options: Partial<EditorOptions> = {}): void {
this.options = {
...this.options,
...options,
}
if (!this.editorView || !this.state || this.isDestroyed) {
return
}
if (this.options.editorProps) {
this.view.setProps(this.options.editorProps)
}
this.view.updateState(this.state)
}
/**
* Update editable state of the editor.
*/
public setEditable(editable: boolean, emitUpdate = true): void {
this.setOptions({ editable })
if (emitUpdate) {
this.emit('update', { editor: this, transaction: this.state.tr, appendedTransactions: [] })
}
}
/**
* Returns whether the editor is editable.
*/
public get isEditable(): boolean {
// since plugins are applied after creating the view
// `editable` is always `true` for one tick.
// that’s why we also have to check for `options.editable`
return this.options.editable && this.view && this.view.editable
}
/**
* Returns the editor view.
*/
public get view(): EditorView {
if (this.editorView) {
return this.editorView
}
return new Proxy(
{
state: this.editorState,
updateState: (state: EditorState): ReturnType<EditorView['updateState']> => {
this.editorState = state
},
dispatch: (tr: Transaction): ReturnType<EditorView['dispatch']> => {
this.dispatchTransaction(tr)
},
// Stub some commonly accessed properties to prevent errors
composing: false,
dragging: null,
editable: true,
isDestroyed: false,
} as EditorView,
{
get: (obj, key) => {
if (this.editorView) {
// If the editor view is available, but the caller has a stale reference to the proxy,
// Just return what the editor view has.
return this.editorView[key as keyof EditorView]
}
// Specifically always return the most recent editorState
if (key === 'state') {
return this.editorState
}
if (key in obj) {
return Reflect.get(obj, key)
}
// We throw an error here, because we know the view is not available
throw new Error(
`[tiptap error]: The editor view is not available. Cannot access view['${key as string}']. The editor may not be mounted yet.`,
)
},
},
) as EditorView
}
/**
* Returns the editor state.
*/
public get state(): EditorState {
if (isDev && !this.hasWarnedStaleDecorationRead && isInDecorationApplyScope(this)) {
// Warn once per editor. The same read repeats on every transaction, and
// a decoration that dispatches from `create()` would flood the console.
this.hasWarnedStaleDecorationRead = true
console.warn(
'[tiptap warn]: `editor.state` was read while decoration `create()` was running. ' +
'It returns the pre-transaction document. Use the `state` argument passed to ' +
'`create()` instead. Helpers like `editor.isActive()` read `editor.state` too, ' +
'so pass `state` to their standalone versions instead of calling them on the editor.',
)
}
if (this.editorView) {
this.editorState = this.view.state
}
return this.editorState
}
/**
* Register a ProseMirror plugin.
*
* @param plugin A ProseMirror plugin
* @param handlePlugins Control how to merge the plugin into the existing plugins.
* @returns The new editor state
*/
public registerPlugin(
plugin: Plugin,
handlePlugins?: (newPlugin: Plugin, plugins: Plugin[]) => Plugin[],
): EditorState {
const plugins = isFunction(handlePlugins)
? handlePlugins(plugin, [...this.state.plugins])
: [...this.state.plugins, plugin]
const state = this.state.reconfigure({ plugins })
this.view.updateState(state)
return state
}
/**
* Unregister a ProseMirror plugin.
*
* @param nameOrPluginKeyToRemove The plugins name
* @returns The new editor state or undefined if the editor is destroyed
*/
public unregisterPlugin(
nameOrPluginKeyToRemove: string | PluginKey | (string | PluginKey)[],
): EditorState | undefined {
if (this.isDestroyed) {
return undefined
}
const prevPlugins = this.state.plugins
let plugins = prevPlugins
;([] as (string | PluginKey)[]).concat(nameOrPluginKeyToRemove).forEach(nameOrPluginKey => {
// @ts-ignore
const name = typeof nameOrPluginKey === 'string' ? `${nameOrPluginKey}$` : nameOrPluginKey.key
// @ts-ignore
plugins = plugins.filter(plugin => !plugin.key.startsWith(name))
})
if (prevPlugins.length === plugins.length) {
// No plugin was removed, so we don’t need to update the state
return undefined
}
const state = this.state.reconfigure({
plugins,
})
this.view.updateState(state)
return state
}
/**
* Creates an extension manager.
*/
private createExtensionManager(): void {
const coreExtensions = this.options.enableCoreExtensions
? [
Editable,
ClipboardTextSerializer.configure({
blockSeparator:
this.options.coreExtensionOptions?.clipboardTextSerializer?.blockSeparator,
}),
Commands,
FocusEvents,
Keymap,
Tabindex.configure({
value: this.options.coreExtensionOptions?.tabindex?.value,
}),
Drop,
Paste,
Delete,
TextDirection.configure({
direction: this.options.textDirection,
}),
].filter(ext => {
if (typeof this.options.enableCoreExtensions === 'object') {
return (
this.options.enableCoreExtensions[
ext.name as keyof typeof this.options.enableCoreExtensions
] !== false
)
}
return true
})
: []
const allExtensions = [...coreExtensions, ...this.options.extensions].filter(extension => {
return ['extension', 'node', 'mark'].includes(extension?.type)
})
this.extensionManager = new ExtensionManager(allExtensions, this)
}
/**
* Creates an command manager.
*/
private createCommandManager(): void {
this.commandManager = new CommandManager({
editor: this,
})
}
/**
* Creates a ProseMirror schema.
*/
private createSchema(): void {
this.schema = this.extensionManager.schema
}
/**
* Creates the initial document.
*/
private createDoc(): PMNode {
let doc: PMNode
try {
doc = createDocument(this.options.content, this.schema, this.options.parseOptions, {
errorOnInvalidContent: this.options.enableContentCheck,
})
} catch (e) {
if (
!(e instanceof Error) ||
!['[tiptap error]: Invalid JSON content', '[tiptap error]: Invalid HTML content'].includes(
e.message,
)
) {
// Not the content error we were expecting
throw e
}
// Content is invalid, but attempt to create it anyway, stripping out the invalid parts
const fallbackDoc = createDocument(
this.options.content,
this.schema,
this.options.parseOptions,
{
errorOnInvalidContent: false,
},
)
// Seed editorState with the fallback doc so a handler can safely use `editor.commands`
this.editorState = EditorState.create({
doc: fallbackDoc,
schema: this.schema,
selection: resolveFocusPosition(fallbackDoc, this.options.autofocus) || undefined,
})
this.emit('contentError', {
editor: this,
error: e as Error,
disableCollaboration: () => {
if (
'collaboration' in this.storage &&
typeof this.storage.collaboration === 'object' &&
this.storage.collaboration
) {
;(this.storage.collaboration as any).isDisabled = true
}
// To avoid syncing back invalid content, reinitialize the extensions without the collaboration extension
this.options.extensions = this.options.extensions.filter(
extension => extension.name !== 'collaboration',
)
// Restart the initialization process by recreating the extension manager with the new set of extensions
this.createExtensionManager()
},
})
return this.editorState.doc
}
return doc
}
/**
* Creates a ProseMirror view.
*/
private createView(element: NonNullable<EditorOptions['element']>): void {
const { editorProps, enableExtensionDispatchTransaction } = this.options
// If a user provided a custom `dispatchTransaction` through `editorProps`,
// we use that as the base dispatch function.
// Otherwise, we use Tiptap's internal `dispatchTransaction` method.
const baseDispatch =
(editorProps as DirectEditorProps).dispatchTransaction || this.dispatchTransaction.bind(this)
const dispatch = enableExtensionDispatchTransaction
? this.extensionManager.dispatchTransaction(baseDispatch)
: baseDispatch
// Compose transformPastedHTML from extensions and user-provided editorProps
const baseTransformPastedHTML = (editorProps as DirectEditorProps).transformPastedHTML
const transformPastedHTML = this.extensionManager.transformPastedHTML(baseTransformPastedHTML)
this.editorView = new EditorView(element, {
...editorProps,
attributes: {
// add `role="textbox"` to the editor element
role: 'textbox',
...editorProps?.attributes,
},
dispatchTransaction: dispatch,
transformPastedHTML,
state: this.editorState,
markViews: this.extensionManager.markViews,
nodeViews: this.extensionManager.nodeViews,
})
// `editor.view` is not yet available at this time.
// Therefore we will add all plugins and node views directly afterwards.
const newState = this.state.reconfigure({
plugins: this.extensionManager.plugins,
})
this.view.updateState(newState)
this.prependClass()
this.injectCSS()
// Let’s store the editor instance in the DOM element.
// So we’ll have access to it for tests.
// @ts-ignore
const dom = this.view.dom as TiptapEditorHTMLElement
dom.editor = this
}
/**
* Creates all node and mark views.
*/
public createNodeViews(): void {
if (this.view.isDestroyed) {
return
}
this.view.setProps({
markViews: this.extensionManager.markViews,
nodeViews: this.extensionManager.nodeViews,
})
}
/**
* Prepend class name to element.
*/
public prependClass(): void {
this.view.dom.className = `${this.className} ${this.view.dom.className}`
}
public isCapturingTransaction = false
private capturedTransaction: Transaction | null = null
public captureTransaction(fn: () => void) {
this.isCapturingTransaction = true
fn()
this.isCapturingTransaction = false
const tr = this.capturedTransaction
this.capturedTransaction = null
return tr
}
/**
* The callback over which to send transactions (state updates) produced by the view.
*
* @param transaction An editor state transaction
*/
private dispatchTransaction(transaction: Transaction): void {
// if the editor / the view of the editor was destroyed
// the transaction should not be dispatched as there is no view anymore.
if (this.view.isDestroyed) {
return
}
if (this.isCapturingTransaction) {
if (!this.capturedTransaction) {
this.capturedTransaction = transaction
return
}
transaction.steps.forEach(step => this.capturedTransaction?.step(step))
return
}
// Apply transaction and get resulting state and transactions
const { state, transactions } = this.state.applyTransaction(transaction)
const selectionHasChanged = !this.state.selection.eq(state.selection)
const rootTrWasApplied = transactions.includes(transaction)
const prevState = this.state
this.emit('beforeTransaction', {
editor: this,
transaction,
nextState: state,
})
// If transaction was filtered out, we can return early
if (!rootTrWasApplied) {
return
}
this.view.updateState(state)
// Emit transaction event with appended transactions info
this.emit('transaction', {
editor: this,
transaction,
appendedTransactions: transactions.slice(1),
})
if (selectionHasChanged) {
this.emit('selectionUpdate', {
editor: this,
transaction,
})
}
// Only emit the latest between focus and blur events
const mostRecentFocusTr = transactions.findLast(tr => tr.getMeta('focus') || tr.getMeta('blur'))
const focus = mostRecentFocusTr?.getMeta('focus')
const blur = mostRecentFocusTr?.getMeta('blur')
if (focus) {
this.emit('focus', {
editor: this,
event: focus.event,
// oxlint-disable-next-lineno-non-null-assertion
transaction: mostRecentFocusTr!,
})
}
if (blur) {
this.emit('blur', {
editor: this,
event: blur.event,
// oxlint-disable-next-lineno-non-null-assertion
transaction: mostRecentFocusTr!,
})
}
// Compare states for update event
if (
transaction.getMeta('preventUpdate') ||
!transactions.some(tr => tr.docChanged) ||
prevState.doc.eq(state.doc)
) {
return
}
this.emit('update', {
editor: this,
transaction,
appendedTransactions: transactions.slice(1),
})
}
/**
* Get attributes of the currently selected node or mark.
*/
public getAttributes(nameOrType: string | NodeType | MarkType): Record<string, any> {
return getAttributes(this.state, nameOrType)
}
/**
* Returns if the currently selected node or mark is active.
*
* @param name Name of the node or mark
* @param attributes Attributes of the node or mark
*/
public isActive(name: string, attributes?: {}): boolean
public isActive(attributes: {}): boolean
public isActive(nameOrAttributes: string, attributesOrUndefined?: {}): boolean {
const name = typeof nameOrAttributes === 'string' ? nameOrAttributes : null
const attributes =
typeof nameOrAttributes === 'string' ? attributesOrUndefined : nameOrAttributes
return isActive(this.state, name, attributes)
}
/**
* Get the document as JSON.
*/
public getJSON(): DocumentType<
Record<string, any> | undefined,
TNodeType<string, undefined | Record<string, any>, any, (TNodeType | TTextType)[]>[]
> {
return this.state.doc.toJSON()
}
/**
* Get the document as HTML.
*/
public getHTML(): string {
return getHTMLFromFragment(this.state.doc.content, this.schema)
}
/**
* Get the document as text.
*/
public getText(options?: {
blockSeparator?: string
textSerializers?: Record<string, TextSerializer>
}): string {
const { blockSeparator = '\n\n', textSerializers = {} } = options || {}
return getText(this.state.doc, {
blockSeparator,
textSerializers: {
...getTextSerializersFromSchema(this.schema),
...textSerializers,
},
})
}
/**
* Check if there is no content.
*/
public get isEmpty(): boolean {
return isNodeEmpty(this.state.doc)
}
/**
* Destroy the editor.
*/
public destroy(): void {
if (this.destroyed) {
return
}
this.destroyed = true
this.emit('destroy')
this.unmount()
this.removeAllListeners()
this.extensionManager.destroy()
this.extensionManager = null as any
this.schema = null as any
this.commandManager = null as unknown as CommandManager
this.extensionStorage = {} as Storage
}
/**
* Check if the editor is already destroyed.
*/
public get isDestroyed(): boolean {
return this.editorView?.isDestroyed ?? true
}
public $node(selector: string, attributes?: { [key: string]: any }): NodePos | null {
return this.$doc?.querySelector(selector, attributes) || null
}
public $nodes(selector: string, attributes?: { [key: string]: any }): NodePos[] | null {
return this.$doc?.querySelectorAll(selector, attributes) || null
}
public $pos(pos: number) {
const $pos = this.state.doc.resolve(pos)
// For positions directly before a non-text atom, resolvedPos.node() returns
// the parent, so pass the atom explicitly. Container nodes and $pos(0) keep
// resolving to the parent.
const node =
pos > 0 && $pos.nodeAfter && !$pos.nodeAfter.isText && $pos.nodeAfter.isAtom
? $pos.nodeAfter
: null
return new NodePos($pos, this, false, node)
}
get $doc() {
return this.$pos(0)
}
/**
* Returns a set of utilities for working with positions and ranges.
*/
public utils: Utils = {
getUpdatedPosition,
createMappablePosition,
}
}

View File

@@ -0,0 +1,71 @@
type StringKeyOf<T> = Extract<keyof T, string>
type CallbackType<
T extends Record<string, any>,
EventName extends StringKeyOf<T>,
> = T[EventName] extends any[] ? T[EventName] : [T[EventName]]
type CallbackFunction<T extends Record<string, any>, EventName extends StringKeyOf<T>> = (
...props: CallbackType<T, EventName>
) => any
export class EventEmitter<T extends Record<string, any>> {
private callbacks: { [key: string]: Array<(...args: any[]) => void> } = {}
public on<EventName extends StringKeyOf<T>>(
event: EventName,
fn: CallbackFunction<T, EventName>,
): this {
if (!this.callbacks[event]) {
this.callbacks[event] = []
}
this.callbacks[event].push(fn)
return this
}
public emit<EventName extends StringKeyOf<T>>(
event: EventName,
...args: CallbackType<T, EventName>
): this {
const callbacks = this.callbacks[event]
if (callbacks) {
callbacks.forEach(callback => callback.apply(this, args))
}
return this
}
public off<EventName extends StringKeyOf<T>>(
event: EventName,
fn?: CallbackFunction<T, EventName>,
): this {
const callbacks = this.callbacks[event]
if (callbacks) {
if (fn) {
this.callbacks[event] = callbacks.filter(callback => callback !== fn)
} else {
delete this.callbacks[event]
}
}
return this
}
public once<EventName extends StringKeyOf<T>>(
event: EventName,
fn: CallbackFunction<T, EventName>,
): this {
const onceFn = (...args: CallbackType<T, EventName>) => {
this.off(event, onceFn)
fn.apply(this, args)
}
return this.on(event, onceFn)
}
public removeAllListeners(): void {
this.callbacks = {}
}
}

View File

@@ -0,0 +1,764 @@
import { describe, expect, it } from 'vite-plus/test'
import { Extension } from './Extension.js'
import { Mark } from './Mark.js'
import { Node } from './Node.js'
import { getExtensionField } from './helpers/getExtensionField.js'
declare module './Extension.js' {
// Extension does not have a addAttributes defined, but we just want to test it anyway
interface ExtensionConfig {
// @ts-ignore - this is a dynamic key
[key: string]: any
}
}
describe('extend extensions', () => {
;[Extension, Node, Mark].forEach(Extendable => {
describe(Extendable.create().type, () => {
it('should define a config', () => {
const extension = Extendable.create({
addAttributes() {
return {
foo: {},
}
},
})
const attributes = getExtensionField(extension, 'addAttributes')()
expect(attributes).toEqual({
foo: {},
})
})
it('should overwrite a config', () => {
const extension = Extendable.create({
addAttributes() {
return {
foo: {},
}
},
}).extend({
addAttributes() {
return {
bar: {},
}
},
})
const attributes = getExtensionField(extension, 'addAttributes')()
expect(attributes).toEqual({
bar: {},
})
})
it('should have a parent', () => {
const extension = Extendable.create({
addAttributes() {
return {
foo: {},
}
},
})
const newExtension = extension.extend({
addAttributes() {
return {
bar: {},
}
},
})
const parent = newExtension.parent
expect(parent).toBe(extension)
})
it('should merge configs', () => {
const extension = Extendable.create({
addAttributes() {
return {
foo: {},
}
},
}).extend({
addAttributes() {
return {
...this.parent?.(),
bar: {},
}
},
})
const attributes = getExtensionField(extension, 'addAttributes')()
expect(attributes).toEqual({
foo: {},
bar: {},
})
})
it('should merge configs multiple times', () => {
const extension = Extendable.create({
addAttributes() {
return {
foo: {},
}
},
})
.extend({
addAttributes() {
return {
...this.parent?.(),
bar: {},
}
},
})
.extend({
addAttributes() {
return {
...this.parent?.(),
baz: {},
}
},
})
const attributes = getExtensionField(extension, 'addAttributes')()
expect(attributes).toEqual({
foo: {},
bar: {},
baz: {},
})
})
it('should set parents multiple times', () => {
const grandparentExtension = Extendable.create({
addAttributes() {
return {
foo: {},
}
},
})
const parentExtension = grandparentExtension.extend({
addAttributes() {
return {
...this.parent?.(),
bar: {},
}
},
})
const childExtension = parentExtension.extend({
addAttributes() {
return {
...this.parent?.(),
baz: {},
}
},
})
expect(parentExtension.parent).toBe(grandparentExtension)
expect(childExtension.parent).toBe(parentExtension)
})
it('should merge configs without direct parent configuration', () => {
const extension = Extendable.create({
addAttributes() {
return {
foo: {},
}
},
})
.extend()
.extend({
addAttributes() {
return {
...this.parent?.(),
bar: {},
}
},
})
const attributes = getExtensionField(extension, 'addAttributes')()
expect(attributes).toEqual({
foo: {},
bar: {},
})
})
it('should call ancestors only once', () => {
const callCounts = {
grandparent: 0,
parent: 0,
child: 0,
}
const extension = Extendable.create({
addAttributes() {
callCounts.grandparent += 1
return {
foo: {},
}
},
})
.extend({
addAttributes() {
callCounts.parent += 1
return {
...this.parent?.(),
bar: {},
}
},
})
.extend({
addAttributes() {
callCounts.child += 1
return {
...this.parent?.(),
bar: {},
}
},
})
getExtensionField(extension, 'addAttributes')()
expect(callCounts).toEqual({
grandparent: 1,
parent: 1,
child: 1,
})
})
it('should call ancestors only once on configure', () => {
const callCounts = {
grandparent: 0,
parent: 0,
child: 0,
}
const extension = Extendable.create({
addAttributes() {
callCounts.grandparent += 1
return {
foo: {},
}
},
})
.extend({
addAttributes() {
callCounts.parent += 1
return {
...this.parent?.(),
bar: {},
}
},
})
.extend({
addAttributes() {
callCounts.child += 1
return {
...this.parent?.(),
bar: {},
}
},
})
.configure({
baz: {},
})
getExtensionField(extension, 'addAttributes')()
expect(callCounts).toEqual({
grandparent: 1,
parent: 1,
child: 1,
})
})
it('should use grandparent as parent on configure (not parent)', () => {
const grandparentExtension = Extendable.create({
addAttributes() {
return {
foo: {},
}
},
})
const parentExtension = grandparentExtension.extend({
addAttributes() {
return {
...this.parent?.(),
bar: {},
}
},
})
const childExtension = parentExtension.configure({
baz: {},
})
expect(parentExtension.parent).toBe(grandparentExtension)
expect(childExtension.parent).toBe(grandparentExtension)
})
it("should use parent's config on `configure`", () => {
const grandparentExtension = Extendable.create({
name: 'grandparent',
addAttributes() {
return {
foo: {},
}
},
})
const parentExtension = grandparentExtension.extend({
name: 'parent',
addAttributes() {
return {
...this.parent?.(),
bar: {},
}
},
})
const childExtension = parentExtension.configure({
baz: {},
})
expect(childExtension.config.name).toBe('parent')
})
it('should allow extending a configure', () => {
const parentExtension = Extendable.create({
addAttributes() {
return { foo: 'bar' }
},
})
const childExtension = parentExtension.configure().extend()
const attributes = getExtensionField(childExtension, 'addAttributes')()
expect(attributes).toEqual({
foo: 'bar',
})
})
it('should allow calling this.parent when extending a configure', () => {
const parentExtension = Extendable.create({
name: 'parentExtension',
addAttributes() {
return {
foo: {},
}
},
})
const childExtension = parentExtension.configure({}).extend({
addAttributes() {
return {
...this.parent?.(),
bar: {},
}
},
})
const attributes = getExtensionField(childExtension, 'addAttributes')()
expect(attributes).toEqual({
foo: {},
bar: {},
})
})
it('should configure to be in addition to the parent options', () => {
const parentExtension = Extendable.create({
name: 'parentExtension',
addOptions() {
return { parent: 'exists', overwrite: 'parent' }
},
})
const childExtension = parentExtension.configure({
child: 'exists-too',
overwrite: 'child',
})
expect(childExtension.options).toEqual({
parent: 'exists',
child: 'exists-too',
overwrite: 'child',
})
})
it('should deeply merge options when extending a configured extension', () => {
const parentExtension = Extendable.create({
name: 'parentExtension',
addOptions() {
return { defaultOptions: 'exists', overwrite: 'parent' }
},
})
const childExtension = parentExtension
.configure({ configuredOptions: 'exists-too', overwrite: 'configure' })
.extend({
name: 'childExtension',
addOptions() {
return { ...this.parent?.(), additionalOptions: 'exist-too', overwrite: 'child' }
},
})
expect(childExtension.options).toEqual({
defaultOptions: 'exists',
configuredOptions: 'exists-too',
additionalOptions: 'exist-too',
overwrite: 'child',
})
})
})
})
})
describe('parent/child cleanup on destroy', () => {
it('should not leak child reference when configure() is called on a singleton', () => {
const singleton = Extension.create({
name: 'testExtension',
addOptions() {
return { foo: 'bar' }
},
})
const configuredExtension = singleton.configure({ foo: 'baz' })
expect(singleton.child).toBeNull()
expect(configuredExtension.parent).toBeNull()
})
})
describe('extension options', () => {
;[Extension, Node, Mark].forEach(Extendable => {
describe(Extendable.create().type, () => {
it('should set options', () => {
const extension = Extendable.create({
addOptions() {
return {
foo: 1,
bar: 1,
}
},
})
expect(extension.options).toEqual({
foo: 1,
bar: 1,
})
})
it('should pass through', () => {
const extension = Extendable.create({
addOptions() {
return {
foo: 1,
bar: 1,
}
},
})
.extend()
.configure()
expect(extension.options).toEqual({
foo: 1,
bar: 1,
})
})
it('should be configurable', () => {
const extension = Extendable.create({
addOptions() {
return {
foo: 1,
bar: 1,
}
},
}).configure({
bar: 2,
})
expect(extension.options).toEqual({
foo: 1,
bar: 2,
})
})
it('should be extendable', () => {
const extension = Extendable.create({
addOptions() {
return {
foo: 1,
bar: 1,
}
},
})
const newExtension = extension.extend({
addOptions() {
return {
...this.parent?.(),
baz: 1,
}
},
})
expect(newExtension.options).toEqual({
foo: 1,
bar: 1,
baz: 1,
})
})
it('should be extendable multiple times', () => {
const extension = Extendable.create({
addOptions() {
return {
foo: 1,
bar: 1,
}
},
}).extend({
addOptions() {
return {
...this.parent?.(),
baz: 1,
}
},
})
const newExtension = extension.extend({
addOptions() {
return {
...this.parent?.(),
bax: 1,
}
},
})
expect(newExtension.options).toEqual({
foo: 1,
bar: 1,
baz: 1,
bax: 1,
})
})
it('should be overwritable', () => {
const extension = Extendable.create({
addOptions() {
return {
foo: 1,
bar: 1,
}
},
}).extend({
addOptions() {
return {
baz: 1,
}
},
})
expect(extension.options).toEqual({
baz: 1,
})
})
it('should configure nested objects', () => {
const extension = Extendable.create<{
foo: number[]
HTMLAttributes: Record<string, any>
}>({
addOptions() {
return {
foo: [1, 2, 3],
HTMLAttributes: {
class: 'foo',
},
}
},
}).configure({
foo: [1],
HTMLAttributes: {
id: 'bar',
},
})
expect(extension.options).toEqual({
foo: [1],
HTMLAttributes: {
class: 'foo',
id: 'bar',
},
})
})
it('should configure retaining existing config', () => {
const extension = Extendable.create({
name: 'parent',
addOptions() {
return {
foo: 1,
bar: 1,
}
},
})
const newExtension = extension.configure()
expect(newExtension.config.name).toBe('parent')
})
it('should create its own instance on configure', () => {
const extension = Extendable.create({
addOptions() {
return {
foo: 1,
bar: 2,
}
},
})
const extension1 = extension.configure({
foo: 2,
bar: 4,
})
const extension2 = extension.configure({
foo: 3,
})
expect(extension1.options).toEqual({
foo: 2,
bar: 4,
})
expect(extension2.options).toEqual({
foo: 3,
bar: 2,
})
})
})
})
})
describe('extension storage', () => {
;[Extension, Node, Mark].forEach(Extendable => {
describe(Extendable.create().type, () => {
it('should be an empty object if not defined', () => {
const extension = Extendable.create({})
expect(extension.storage).toEqual({})
})
it('should be be the return of `addStorage` if defined', () => {
const extension = Extendable.create({
addStorage() {
return { a: 1 }
},
})
expect(extension.storage).toEqual({ a: 1 })
})
it('should be able to be extended', () => {
const extension = Extendable.create({
addStorage() {
return { a: 1 }
},
}).extend()
expect(extension.storage).toEqual({ a: 1 })
})
it('should be able to be configured', () => {
const extension = Extendable.create({
addStorage() {
return { a: 1 }
},
}).configure({
anything: 'else',
})
expect(extension.storage).toEqual({ a: 1 })
})
it('should be able to be extended and configured', () => {
const extension = Extendable.create({
addStorage() {
return { a: 1 }
},
})
.extend()
.configure({
anything: 'else',
})
expect(extension.storage).toEqual({ a: 1 })
})
it('should be overwrite parents addStorage', () => {
const extension = Extendable.create({
addStorage() {
expect(false, 'This should not be called').toBe(true)
return { a: 1 }
},
}).extend({
addStorage() {
return { b: 1 }
},
})
expect(extension.storage).toEqual({ b: 1 })
})
it('grandchild should overwrite grandparent & parents addStorage', () => {
const extension = Extendable.create({
addStorage() {
expect(false, 'This should not be called').toBe(true)
return { a: 1 }
},
})
.extend({
addStorage() {
expect(false, 'This should not be called').toBe(true)
return { b: 1 }
},
})
.extend({
addStorage() {
return { c: 1 }
},
})
expect(extension.storage).toEqual({ c: 1 })
})
it('should return a new object on each access', () => {
const extension = Extendable.create({
addStorage() {
return { a: 1 }
},
})
const storage1 = extension.storage
const storage2 = extension.storage
expect(storage1).toEqual({ a: 1 })
expect(storage2).toEqual({ a: 1 })
expect(storage1).not.toBe(storage2)
})
})
})
})

View File

@@ -0,0 +1,647 @@
import type { Plugin } from '@tiptap/pm/state'
import type { Editor } from './Editor.js'
import type { DecorationSpec } from './decorations/index.js'
import { getExtensionField } from './helpers/getExtensionField.js'
import type { ExtensionConfig, MarkConfig, NodeConfig } from './index.js'
import type { InputRule } from './InputRule.js'
import type { Mark } from './Mark.js'
import type { Node } from './Node.js'
import type { PasteRule } from './PasteRule.js'
import type {
AnyConfig,
DispatchTransactionProps,
EditorEvents,
Extensions,
GlobalAttributes,
JSONContent,
KeyboardShortcutCommand,
MarkdownParseHelpers,
MarkdownParseResult,
MarkdownRendererHelpers,
MarkdownToken,
MarkdownTokenizer,
ParentConfig,
RawCommands,
RenderContext,
} from './types.js'
import { callOrReturn } from './utilities/callOrReturn.js'
import { mergeDeep } from './utilities/mergeDeep.js'
export interface ExtendableConfig<
Options = any,
Storage = any,
Config extends
| ExtensionConfig<Options, Storage>
| NodeConfig<Options, Storage>
| MarkConfig<Options, Storage>
| ExtendableConfig<Options, Storage> = ExtendableConfig<Options, Storage, any, any>,
PMType = any,
> {
/**
* The extension name - this must be unique.
* It will be used to identify the extension.
*
* @example 'myExtension'
*/
name: string
/**
* The priority of your extension. The higher, the earlier it will be called
* and will take precedence over other extensions with a lower priority.
* @default 100
* @example 101
*/
priority?: number
/**
* This method will add options to this extension
* @see https://tiptap.dev/docs/editor/guide/custom-extensions#settings
* @example
* addOptions() {
* return {
* myOption: 'foo',
* myOtherOption: 10,
* }
*/
addOptions?: (this: { name: string; parent: ParentConfig<Config>['addOptions'] }) => Options
/**
* The default storage this extension can save data to.
* @see https://tiptap.dev/docs/editor/guide/custom-extensions#storage
* @example
* defaultStorage: {
* prefetchedUsers: [],
* loading: false,
* }
*/
addStorage?: (this: {
name: string
options: Options
parent: ParentConfig<Config>['addStorage']
}) => Storage
/**
* This function adds globalAttributes to specific nodes.
* @see https://tiptap.dev/docs/editor/guide/custom-extensions#global-attributes
* @example
* addGlobalAttributes() {
* return [
* {
// Extend the following extensions
* types: [
* 'heading',
* 'paragraph',
* ],
* // … with those attributes
* attributes: {
* textAlign: {
* default: 'left',
* renderHTML: attributes => ({
* style: `text-align: ${attributes.textAlign}`,
* }),
* parseHTML: element => element.style.textAlign || 'left',
* },
* },
* },
* ]
* }
*/
addGlobalAttributes?: (this: {
name: string
options: Options
storage: Storage
extensions: (Node | Mark)[]
parent: ParentConfig<Config>['addGlobalAttributes']
}) => GlobalAttributes
/**
* This function adds commands to the editor
* @see https://tiptap.dev/docs/editor/guide/custom-extensions#commands
* @example
* addCommands() {
* return {
* myCommand: () => ({ chain }) => chain().setMark('type', 'foo').run(),
* }
* }
*/
addCommands?: (this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['addCommands']
}) => Partial<RawCommands>
/**
* This function registers keyboard shortcuts.
* @see https://tiptap.dev/docs/editor/guide/custom-extensions#keyboard-shortcuts
* @example
* addKeyboardShortcuts() {
* return {
* 'Mod-l': () => this.editor.commands.toggleBulletList(),
* }
* },
*/
addKeyboardShortcuts?: (this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['addKeyboardShortcuts']
}) => {
[key: string]: KeyboardShortcutCommand
}
/**
* This function adds input rules to the editor.
* @see https://tiptap.dev/docs/editor/guide/custom-extensions#input-rules
* @example
* addInputRules() {
* return [
* markInputRule({
* find: inputRegex,
* type: this.type,
* }),
* ]
* },
*/
addInputRules?: (this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['addInputRules']
}) => InputRule[]
/**
* This function adds paste rules to the editor.
* @see https://tiptap.dev/docs/editor/guide/custom-extensions#paste-rules
* @example
* addPasteRules() {
* return [
* markPasteRule({
* find: pasteRegex,
* type: this.type,
* }),
* ]
* },
*/
addPasteRules?: (this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['addPasteRules']
}) => PasteRule[]
/**
* This function adds Prosemirror plugins to the editor
* @see https://tiptap.dev/docs/editor/guide/custom-extensions#prosemirror-plugins
* @example
* addProseMirrorPlugins() {
* return [
* customPlugin(),
* ]
* }
*/
addProseMirrorPlugins?: (this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['addProseMirrorPlugins']
}) => Plugin[]
/**
* Adds editor decorations (node, inline, widget). Return a spec with a `create`
* function that builds instances via `Decoration.Node`/`Inline`/`Widget`. Use
* `shouldUpdate`, `update: 'changedRanges'` + `createInRange`, or `update: 'manual'` to control recomputation.
* @see https://tiptap.dev/docs/editor/core-concepts/decorations
* @example
* addDecorations() {
* return {
* create: ({ state }) =>
* findChildren(state.doc, node => node.type.name === 'heading').map(
* ({ pos, node }) =>
* Decoration.Node(pos, pos + node.nodeSize, { class: 'is-heading' }),
* ),
* }
* }
*
* For framework widgets, use `ReactWidgetRenderer` or `VueWidgetRenderer`
* from the matching framework package. Give stateful widgets a stable key.
*
* `create` and `createInRange` must not throw. An exception escapes through
* `editor.commands.*` and stops the document from updating until it stops.
*/
addDecorations?: (this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['addDecorations']
}) => DecorationSpec | null
/**
* This function transforms pasted HTML content before it's parsed.
* Extensions can use this to modify or clean up pasted HTML.
* The transformations are chained - each extension's transform receives
* the output from the previous extension's transform.
* @see https://tiptap.dev/docs/editor/guide/custom-extensions#transform-pasted-html
* @example
* transformPastedHTML(html) {
* // Remove all style attributes
* return html.replace(/style="[^"]*"/g, '')
* }
*/
transformPastedHTML?: (
this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['transformPastedHTML']
},
html: string,
) => string
/**
* This function adds additional extensions to the editor. This is useful for
* building extension kits.
* @example
* addExtensions() {
* return [
* BulletList,
* OrderedList,
* ListItem
* ]
* }
*/
addExtensions?: (this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<Config>['addExtensions']
}) => Extensions
/**
* The markdown token name
*
* This is the name of the token that this extension uses to parse and render markdown and comes from the Marked Lexer.
*
* @see https://github.com/markedjs/marked/blob/master/src/Tokens.ts
*
*/
markdownTokenName?: string
/**
* The parse function used by the markdown parser to convert markdown tokens to ProseMirror nodes.
*/
parseMarkdown?: (token: MarkdownToken, helpers: MarkdownParseHelpers) => MarkdownParseResult
/**
* The serializer function used by the markdown serializer to convert ProseMirror nodes to markdown tokens.
*/
renderMarkdown?: (
node: JSONContent,
helpers: MarkdownRendererHelpers,
ctx: RenderContext,
) => string
/**
* The markdown tokenizer responsible for turning a markdown string into tokens
*
* Custom tokenizers are only needed when you want to parse non-standard markdown token.
*/
markdownTokenizer?: MarkdownTokenizer
/**
* Optional markdown options for indentation
*/
markdownOptions?: {
/**
* Defines if this markdown element should indent it's child elements
*/
indentsContent?: boolean
/**
* Lets a mark tell the Markdown serializer which inline HTML tags it can
* safely use when plain markdown delimiters would become ambiguous.
*
* This is mainly useful for overlapping marks. For example, bold followed
* by bold+italic followed by italic cannot always be written back with only
* `*` and `**` in a way that still parses correctly. In that case, the
* serializer can close the overlapping section with markdown and reopen the
* remaining tail with HTML instead.
*
* Example:
* - desired formatting: `**123` + `*456*` + `789 italic`
* - serialized result: `**123*456***<em>789</em>`
*
* If your extension defines custom mark names, set `htmlReopen` on that
* extension so the serializer can reuse its HTML form for overlap cases.
*/
htmlReopen?: {
open: string
close: string
}
}
/**
* This function extends the schema of the node.
* @example
* extendNodeSchema() {
* return {
* group: 'inline',
* selectable: false,
* }
* }
*/
extendNodeSchema?:
| ((
this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<Config>['extendNodeSchema']
},
extension: Node,
) => Record<string, any>)
| null
/**
* This function extends the schema of the mark.
* @example
* extendMarkSchema() {
* return {
* group: 'inline',
* selectable: false,
* }
* }
*/
extendMarkSchema?:
| ((
this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<Config>['extendMarkSchema']
},
extension: Mark,
) => Record<string, any>)
| null
/**
* The editor is not ready yet.
*/
onBeforeCreate?:
| ((
this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['onBeforeCreate']
},
event: EditorEvents['beforeCreate'],
) => void)
| null
/**
* The editor is ready.
*/
onCreate?:
| ((
this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['onCreate']
},
event: EditorEvents['create'],
) => void)
| null
/**
* The content has changed.
*/
onUpdate?:
| ((
this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['onUpdate']
},
event: EditorEvents['update'],
) => void)
| null
/**
* The selection has changed.
*/
onSelectionUpdate?:
| ((
this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['onSelectionUpdate']
},
event: EditorEvents['selectionUpdate'],
) => void)
| null
/**
* The editor state has changed.
*/
onTransaction?:
| ((
this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['onTransaction']
},
event: EditorEvents['transaction'],
) => void)
| null
/**
* The editor is focused.
*/
onFocus?:
| ((
this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['onFocus']
},
event: EditorEvents['focus'],
) => void)
| null
/**
* The editor isn’t focused anymore.
*/
onBlur?:
| ((
this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['onBlur']
},
event: EditorEvents['blur'],
) => void)
| null
/**
* The editor is destroyed.
*/
onDestroy?:
| ((
this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['onDestroy']
},
event: EditorEvents['destroy'],
) => void)
| null
/**
* This hook allows you to intercept and modify transactions before they are dispatched.
*
* Example
* ```ts
* dispatchTransaction({ transaction, next }) {
* console.log('Dispatching transaction:', transaction)
* next(transaction)
* }
* ```
*
* @param props - The dispatch transaction props
*/
dispatchTransaction?:
| ((
this: {
name: string
options: Options
storage: Storage
editor: Editor
type: PMType
parent: ParentConfig<Config>['dispatchTransaction']
},
props: DispatchTransactionProps,
) => void)
| null
}
export class Extendable<
Options = any,
Storage = any,
Config =
| ExtensionConfig<Options, Storage>
| NodeConfig<Options, Storage>
| MarkConfig<Options, Storage>,
> {
type = 'extendable'
parent: Extendable | null = null
child: Extendable | null = null
name = ''
config: Config = {
name: this.name,
} as Config
constructor(config: Partial<Config> = {}) {
this.config = {
...this.config,
...config,
}
this.name = (this.config as any).name
}
get options(): Options {
return {
...callOrReturn(
getExtensionField<AnyConfig['addOptions']>(this as any, 'addOptions', {
name: this.name,
}),
),
}
}
get storage(): Readonly<Storage> {
return {
...callOrReturn(
getExtensionField<AnyConfig['addStorage']>(this as any, 'addStorage', {
name: this.name,
options: this.options,
}),
),
}
}
configure(options: Partial<Options> = {}) {
const extension = this.extend<Options, Storage, Config>({
...this.config,
addOptions: () => {
return mergeDeep(this.options as Record<string, any>, options) as Options
},
})
extension.name = this.name
extension.parent = this.parent
this.child = null
return extension
}
extend<
ExtendedOptions = Options,
ExtendedStorage = Storage,
ExtendedConfig =
| ExtensionConfig<ExtendedOptions, ExtendedStorage>
| NodeConfig<ExtendedOptions, ExtendedStorage>
| MarkConfig<ExtendedOptions, ExtendedStorage>,
>(extendedConfig: Partial<ExtendedConfig> = {}): Extendable<ExtendedOptions, ExtendedStorage> {
const extension = new (this.constructor as any)({ ...this.config, ...extendedConfig })
extension.parent = this
this.child = extension
extension.name = 'name' in extendedConfig ? extendedConfig.name : extension.parent.name
return extension
}
}

View File

@@ -0,0 +1,59 @@
import type { Editor } from './Editor.js'
import { type ExtendableConfig, Extendable } from './Extendable.js'
// oxlint-disable-next-lineno-empty-object-type
export interface ExtensionConfig<Options = any, Storage = any> extends ExtendableConfig<
Options,
Storage,
ExtensionConfig<Options, Storage>,
null
> {}
/**
* The Extension class is the base class for all extensions.
* @see https://tiptap.dev/api/extensions#create-a-new-extension
*/
export class Extension<Options = any, Storage = any> extends Extendable<
Options,
Storage,
ExtensionConfig<Options, Storage>
> {
type = 'extension'
/**
* Create a new Extension instance
* @param config - Extension configuration object or a function that returns a configuration object
*/
static create<O = any, S = any>(
config: Partial<ExtensionConfig<O, S>> | (() => Partial<ExtensionConfig<O, S>>) = {},
) {
// If the config is a function, execute it to get the configuration object
const resolvedConfig = typeof config === 'function' ? config() : config
return new Extension<O, S>(resolvedConfig)
}
configure(options?: Partial<Options>) {
return super.configure(options) as Extension<Options, Storage>
}
extend<
ExtendedOptions = Options,
ExtendedStorage = Storage,
ExtendedConfig = ExtensionConfig<ExtendedOptions, ExtendedStorage>,
>(
extendedConfig?:
| (() => Partial<ExtendedConfig>)
| (Partial<ExtendedConfig> &
ThisType<{
name: string
options: ExtendedOptions
storage: ExtendedStorage
editor: Editor
type: null
}>),
): Extension<ExtendedOptions, ExtendedStorage> {
// If the extended config is a function, execute it to get the configuration object
const resolvedConfig = typeof extendedConfig === 'function' ? extendedConfig() : extendedConfig
return super.extend(resolvedConfig) as Extension<ExtendedOptions, ExtendedStorage>
}
}

View File

@@ -0,0 +1,852 @@
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { Editor } from './Editor.js'
import { Extension } from './Extension.js'
describe('dispatchTransaction', () => {
it('should call dispatchTransaction from an extension', () => {
const dispatchTransaction = vi.fn(({ transaction, next }) => next(transaction))
const CustomExtension = Extension.create({
name: 'custom',
dispatchTransaction,
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, CustomExtension],
})
editor.commands.insertContent('foo')
expect(dispatchTransaction).toHaveBeenCalled()
editor.destroy()
})
it('should call multiple dispatchTransaction hooks in priority order', () => {
const order: string[] = []
const Extension1 = Extension.create({
name: 'extension1',
priority: 10,
dispatchTransaction({ transaction, next }) {
order.push('extension1')
next(transaction)
},
})
const Extension2 = Extension.create({
name: 'extension2',
priority: 20,
dispatchTransaction({ transaction, next }) {
order.push('extension2')
next(transaction)
},
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, Extension1, Extension2],
})
editor.commands.insertContent('foo')
expect(order).toEqual(['extension2', 'extension1'])
editor.destroy()
})
it('should block transaction if next is not called', () => {
const Extension1 = Extension.create({
name: 'extension1',
dispatchTransaction() {
// do nothing
},
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, Extension1],
})
editor.commands.insertContent('foo')
expect(editor.getText()).toBe('')
editor.destroy()
})
it('should allow user-provided dispatchTransaction as base', () => {
const userDispatch = vi.fn(_tr => {
// In a real scenario, the user would update the view state here.
// For this test, we just want to see if it's called.
})
const Extension1 = Extension.create({
name: 'extension1',
dispatchTransaction({ transaction, next }) {
next(transaction)
},
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, Extension1],
editorProps: {
dispatchTransaction: userDispatch,
} as any,
})
editor.commands.insertContent('foo')
expect(userDispatch).toHaveBeenCalled()
editor.destroy()
})
it('should bypass extensions if enableExtensionDispatchTransaction is false', () => {
const dispatchTransaction = vi.fn(({ transaction, next }) => next(transaction))
const CustomExtension = Extension.create({
name: 'custom',
dispatchTransaction,
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, CustomExtension],
enableExtensionDispatchTransaction: false,
})
editor.commands.insertContent('foo')
expect(dispatchTransaction).not.toHaveBeenCalled()
editor.destroy()
})
})
describe('pluginOrder', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
it('runs keyboard shortcuts in correct priority order', () => {
const order: number[] = []
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
priority: 1000,
addKeyboardShortcuts() {
return {
a: () => {
order.push(1)
return false
},
}
},
}),
Extension.create({
addKeyboardShortcuts() {
return {
a: () => {
order.push(3)
return false
},
}
},
}),
Extension.create({
addKeyboardShortcuts() {
return {
a: () => {
order.push(2)
return false
},
}
},
}),
],
})
editor.view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
expect(order).toEqual([1, 2, 3])
})
})
describe('transformPastedHTML', () => {
describe('priority ordering', () => {
it('should execute transforms in priority order (higher priority first)', () => {
const executionOrder: number[] = []
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'low-priority',
priority: 50,
transformPastedHTML(html) {
executionOrder.push(3)
return html
},
}),
Extension.create({
name: 'high-priority',
priority: 200,
transformPastedHTML(html) {
executionOrder.push(1)
return html
},
}),
Extension.create({
name: 'medium-priority',
priority: 100,
transformPastedHTML(html) {
executionOrder.push(2)
return html
},
}),
],
})
editor.view.props.transformPastedHTML?.('<p>test</p>')
expect(executionOrder).toEqual([1, 2, 3])
editor.destroy()
})
it('should execute transforms in default priority order when priorities are equal', () => {
const executionOrder: string[] = []
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'first',
transformPastedHTML(html) {
executionOrder.push('first')
return html
},
}),
Extension.create({
name: 'second',
transformPastedHTML(html) {
executionOrder.push('second')
return html
},
}),
],
})
editor.view.props.transformPastedHTML?.('<p>test</p>')
expect(executionOrder).toEqual(['first', 'second'])
editor.destroy()
})
})
describe('transform chaining', () => {
it('should chain transforms correctly', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'first-transform',
priority: 100,
transformPastedHTML(html) {
return html.replace(/foo/g, 'bar')
},
}),
Extension.create({
name: 'second-transform',
priority: 90,
transformPastedHTML(html) {
return html.replace(/bar/g, 'baz')
},
}),
],
})
const result = editor.view.props.transformPastedHTML?.('<p>foo</p>')
expect(result).toBe('<p>baz</p>')
editor.destroy()
})
it('should pass transformed HTML through entire chain', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'add-prefix',
priority: 100,
transformPastedHTML(html) {
return `PREFIX-${html}`
},
}),
Extension.create({
name: 'add-suffix',
priority: 90,
transformPastedHTML(html) {
return `${html}-SUFFIX`
},
}),
Extension.create({
name: 'add-wrapper',
priority: 80,
transformPastedHTML(html) {
return `[${html}]`
},
}),
],
})
const result = editor.view.props.transformPastedHTML?.('TEST')
expect(result).toBe('[PREFIX-TEST-SUFFIX]')
editor.destroy()
})
})
describe('baseTransform integration', () => {
it('should run baseTransform before extension transforms', () => {
const editor = new Editor({
editorProps: {
transformPastedHTML(html) {
return html.replace(/original/g, 'base')
},
},
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'extension-transform',
transformPastedHTML(html) {
return html.replace(/base/g, 'final')
},
}),
],
})
const result = editor.view.props.transformPastedHTML?.('<p>original</p>')
expect(result).toBe('<p>final</p>')
editor.destroy()
})
it('should work when baseTransform is undefined', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'extension-transform',
transformPastedHTML(html) {
return html.replace(/test/g, 'success')
},
}),
],
})
const result = editor.view.props.transformPastedHTML?.('<p>test</p>')
expect(result).toBe('<p>success</p>')
editor.destroy()
})
})
describe('extensions without transforms', () => {
it('should skip extensions without transformPastedHTML', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'no-transform',
// No transformPastedHTML defined
}),
Extension.create({
name: 'with-transform',
transformPastedHTML(html) {
return html.replace(/test/g, 'success')
},
}),
Extension.create({
name: 'another-no-transform',
// No transformPastedHTML defined
}),
],
})
const result = editor.view.props.transformPastedHTML?.('<p>test</p>')
expect(result).toBe('<p>success</p>')
editor.destroy()
})
it('should return original HTML when no transforms are defined', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'extension-1',
}),
Extension.create({
name: 'extension-2',
}),
],
})
const result = editor.view.props.transformPastedHTML?.('<p>unchanged</p>')
expect(result).toBe('<p>unchanged</p>')
editor.destroy()
})
})
describe('extension context', () => {
it('should provide correct context to transformPastedHTML', () => {
let capturedContext: any = null
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'test-extension',
addOptions() {
return {
customOption: 'value',
}
},
addStorage() {
return {
customStorage: 'stored',
}
},
transformPastedHTML(html) {
capturedContext = {
name: this.name,
options: this.options,
storage: this.storage,
editor: this.editor,
}
return html
},
}),
],
})
editor.view.props.transformPastedHTML?.('<p>test</p>')
expect(capturedContext).toBeDefined()
expect(capturedContext.name).toBe('test-extension')
expect(capturedContext.options).toMatchObject({ customOption: 'value' })
expect(capturedContext.storage).toMatchObject({ customStorage: 'stored' })
expect(capturedContext.editor).toBe(editor)
editor.destroy()
})
it('should allow accessing editor state in transformPastedHTML', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'state-aware',
transformPastedHTML(html) {
const isEmpty = this.editor.isEmpty
return isEmpty ? `${html}<!-- empty -->` : html
},
}),
],
})
const result = editor.view.props.transformPastedHTML?.('<p>test</p>')
expect(result).toContain('<!-- empty -->')
editor.destroy()
})
})
describe('edge cases', () => {
it('should handle empty HTML string', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'transform',
transformPastedHTML(html) {
return html || '<p>default</p>'
},
}),
],
})
const result = editor.view.props.transformPastedHTML?.('')
expect(result).toBe('<p>default</p>')
editor.destroy()
})
it('should handle HTML with special characters', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'preserve-special',
transformPastedHTML(html) {
return html.replace(/&amp;/g, '&')
},
}),
],
})
const result = editor.view.props.transformPastedHTML?.('<p>&amp;test&amp;</p>')
expect(result).toBe('<p>&test&</p>')
editor.destroy()
})
it('should handle very long HTML strings', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'transform',
transformPastedHTML(html) {
return html.replace(/test/g, 'success')
},
}),
],
})
const longHtml = `<p>${'test '.repeat(10000)}</p>`
const result = editor.view.props.transformPastedHTML?.(longHtml)
expect(result).toContain('success')
expect(result).not.toContain('test')
editor.destroy()
})
it('should handle malformed HTML gracefully', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'transform',
transformPastedHTML(html) {
return html.replace(/test/g, 'success')
},
}),
],
})
const malformedHtml = '<p>test</span>'
const result = editor.view.props.transformPastedHTML?.(malformedHtml)
expect(result).toBe('<p>success</span>')
editor.destroy()
})
})
describe('view parameter', () => {
it('should pass view parameter to baseTransform', () => {
let viewReceived: any = null
const editor = new Editor({
editorProps: {
transformPastedHTML(html, view) {
viewReceived = view
return html
},
},
extensions: [Document, Paragraph, Text],
})
editor.view.props.transformPastedHTML?.('<p>test</p>', editor.view)
expect(viewReceived).toBe(editor.view)
editor.destroy()
})
it('should work when view parameter is undefined', () => {
const editor = new Editor({
editorProps: {
transformPastedHTML(html, view) {
return view ? html : `${html}<!-- no view -->`
},
},
extensions: [Document, Paragraph, Text],
})
const result = editor.view.props.transformPastedHTML?.('<p>test</p>')
expect(result).toContain('<!-- no view -->')
editor.destroy()
})
})
describe('real-world scenarios', () => {
it('should remove inline styles and dangerous attributes', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'security',
priority: 100,
transformPastedHTML(html) {
return html.replace(/\s+style="[^"]*"/gi, '').replace(/\s+on\w+="[^"]*"/gi, '')
},
}),
],
})
const result = editor.view.props.transformPastedHTML?.(
'<p style="color: red;" onclick="alert(\'xss\')">test</p>',
)
expect(result).toBe('<p>test</p>')
editor.destroy()
})
it('should normalize whitespace from word processors', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'normalize-whitespace',
transformPastedHTML(html) {
return html
.replace(/\t/g, ' ')
.replace(/\u00a0/g, ' ')
.replace(/\s+/g, ' ')
},
}),
],
})
const result = editor.view.props.transformPastedHTML?.(
'<p>test\t\u00a0 multiple spaces</p>',
)
expect(result).toBe('<p>test multiple spaces</p>')
editor.destroy()
})
it('should chain multiple practical transforms', () => {
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Extension.create({
name: 'remove-styles',
priority: 100,
transformPastedHTML(html) {
return html.replace(/\s+style="[^"]*"/gi, '')
},
}),
Extension.create({
name: 'normalize-tags',
priority: 90,
transformPastedHTML(html) {
return html.replace(/<b>/g, '<strong>').replace(/<\/b>/g, '</strong>')
},
}),
Extension.create({
name: 'add-classes',
priority: 80,
transformPastedHTML(html) {
return html.replace(/<p>/g, '<p class="editor-paragraph">')
},
}),
],
})
const result = editor.view.props.transformPastedHTML?.(
'<p style="color: red;"><b>test</b></p>',
)
expect(result).toBe('<p class="editor-paragraph"><strong>test</strong></p>')
editor.destroy()
})
})
describe('performance', () => {
it('should handle many extensions efficiently', () => {
const extensions = [Document, Paragraph, Text]
// Add 50 extensions with transforms
for (let i = 0; i < 50; i += 1) {
extensions.push(
Extension.create({
name: `extension-${i}`,
priority: 1000 - i,
transformPastedHTML(html) {
return html // Pass through
},
}),
)
}
const editor = new Editor({ extensions })
const start = Date.now()
const result = editor.view.props.transformPastedHTML?.('<p>test</p>')
const duration = Date.now() - start
expect(result).toBe('<p>test</p>')
expect(duration).toBeLessThan(100) // Should complete quickly
editor.destroy()
})
})
})
describe('parent/child cleanup on destroy', () => {
it('should break parent/child chain when editor is destroyed (extend path)', () => {
const singleton = Extension.create({
name: 'testExtension',
addOptions() {
return { foo: 'bar' }
},
})
const childExtension = singleton.extend({
addOptions() {
return { ...this.parent?.(), foo: 'baz' }
},
})
expect(singleton.child).toBe(childExtension)
expect(childExtension.parent).toBe(singleton)
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text, childExtension],
})
editor.destroy()
expect(singleton.child).toBeNull()
})
it('should clear forward parent.child links on all extensions after editor.destroy()', () => {
const singletonA = Extension.create({
name: 'extA',
addOptions() {
return { value: 'a' }
},
})
const singletonB = Extension.create({
name: 'extB',
addOptions() {
return { value: 'b' }
},
})
const configuredA = singletonA.configure({ value: 'a-configured' })
const childB = singletonB.extend({ name: 'extB-child' })
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text, configuredA, childB],
})
const { extensions } = editor.extensionManager
editor.destroy()
extensions.forEach(ext => {
if (ext.parent?.child === ext) {
// This should never be true after destroy — the forward link is always broken
expect(ext.parent.child).toBeNull()
}
})
})
it('should break all ancestor child links in a multi-level extend chain after editor.destroy()', () => {
const root = Extension.create({
name: 'root',
addOptions() {
return { level: 0 }
},
})
const child = root.extend({
addOptions() {
return { ...this.parent?.(), level: 1 }
},
})
const grandchild = child.extend({
addOptions() {
return { ...this.parent?.(), level: 2 }
},
})
expect(root.child).toBe(child)
expect(child.child).toBe(grandchild)
expect(grandchild.parent).toBe(child)
expect(child.parent).toBe(root)
const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text, grandchild],
})
editor.destroy()
expect(root.child).toBeNull()
expect(child.child).toBeNull()
})
})

View File

@@ -0,0 +1,583 @@
import { keymap } from '@tiptap/pm/keymap'
import type { Schema } from '@tiptap/pm/model'
import type { Plugin, Transaction } from '@tiptap/pm/state'
import type { EditorView, MarkViewConstructor, NodeViewConstructor } from '@tiptap/pm/view'
import type { Editor } from './Editor.js'
import { DecorationManager, type DecorationManagerEntry } from './decorations/DecorationManager.js'
import {
flattenExtensions,
getAttributesFromExtensions,
getExtensionField,
getMarkType,
getNodeType,
getRenderedAttributes,
getSchemaByResolvedExtensions,
getSchemaTypeByName,
isExtensionRulesEnabled,
resolveExtensions,
sortExtensions,
splitExtensions,
} from './helpers/index.js'
import { updateMarkViewAttributes } from './MarkView.js'
import { inputRulesPlugin } from './InputRule.js'
import { Mark, type MarkConfig } from './Mark.js'
import { pasteRulesPlugin } from './PasteRule.js'
import type { AnyConfig, Extensions, RawCommands, Storage } from './types.js'
import { callOrReturn } from './utilities/callOrReturn.js'
import { type NodeConfig } from './Node.js'
export class ExtensionManager {
editor: Editor
schema: Schema
/**
* A flattened and sorted array of all extensions
*/
extensions: Extensions
/**
* A non-flattened array of base extensions (no sub-extensions)
*/
baseExtensions: Extensions
splittableMarks: string[] = []
nonClearableMarks: string[] = []
decorationManager: DecorationManager | null = null
constructor(extensions: Extensions, editor: Editor) {
this.editor = editor
this.baseExtensions = extensions
this.extensions = resolveExtensions(extensions)
this.schema = getSchemaByResolvedExtensions(this.extensions, editor)
this.setupExtensions()
}
static resolve = resolveExtensions
static sort = sortExtensions
static flatten = flattenExtensions
/**
* Get all commands from the extensions.
* @returns An object with all commands where the key is the command name and the value is the command function
*/
get commands(): RawCommands {
return this.extensions.reduce((commands, extension) => {
const context = {
name: extension.name,
options: extension.options,
storage: this.editor.extensionStorage[extension.name as keyof Storage],
editor: this.editor,
type: getSchemaTypeByName(extension.name, this.schema),
}
const addCommands = getExtensionField<AnyConfig['addCommands']>(
extension,
'addCommands',
context,
)
if (!addCommands) {
return commands
}
return {
...commands,
...addCommands(),
}
}, {} as RawCommands)
}
/**
* Get all registered Prosemirror plugins from the extensions.
* @returns An array of Prosemirror plugins
*/
get plugins(): Plugin[] {
const { editor } = this
// With ProseMirror, first plugins within an array are executed first.
// In Tiptap, we provide the ability to override plugins,
// so it feels more natural to run plugins at the end of an array first.
// That’s why we have to reverse the `extensions` array and sort again
// based on the `priority` option.
const extensions = sortExtensions([...this.extensions].reverse())
const allPlugins = extensions.flatMap(extension => {
const context = {
name: extension.name,
options: extension.options,
storage: this.editor.extensionStorage[extension.name as keyof Storage],
editor,
type: getSchemaTypeByName(extension.name, this.schema),
}
const plugins: Plugin[] = []
const addKeyboardShortcuts = getExtensionField<AnyConfig['addKeyboardShortcuts']>(
extension,
'addKeyboardShortcuts',
context,
)
let defaultBindings: Record<string, () => boolean> = {}
// bind exit handling
if (
extension.type === 'mark' &&
getExtensionField<MarkConfig['exitable']>(extension, 'exitable', context)
) {
defaultBindings.ArrowRight = () => Mark.handleExit({ editor, mark: extension as Mark })
}
if (addKeyboardShortcuts) {
const bindings = Object.fromEntries(
Object.entries(addKeyboardShortcuts()).map(([shortcut, method]) => {
return [shortcut, () => method({ editor })]
}),
)
defaultBindings = { ...defaultBindings, ...bindings }
}
const keyMapPlugin = keymap(defaultBindings)
plugins.push(keyMapPlugin)
const addInputRules = getExtensionField<AnyConfig['addInputRules']>(
extension,
'addInputRules',
context,
)
if (isExtensionRulesEnabled(extension, editor.options.enableInputRules) && addInputRules) {
const rules = addInputRules()
if (rules && rules.length) {
const inputResult = inputRulesPlugin({
editor,
rules,
})
const inputPlugins = Array.isArray(inputResult) ? inputResult : [inputResult]
plugins.push(...inputPlugins)
}
}
const addPasteRules = getExtensionField<AnyConfig['addPasteRules']>(
extension,
'addPasteRules',
context,
)
if (isExtensionRulesEnabled(extension, editor.options.enablePasteRules) && addPasteRules) {
const rules = addPasteRules()
if (rules && rules.length) {
const pasteRules = pasteRulesPlugin({ editor, rules })
plugins.push(...pasteRules)
}
}
const addProseMirrorPlugins = getExtensionField<AnyConfig['addProseMirrorPlugins']>(
extension,
'addProseMirrorPlugins',
context,
)
if (addProseMirrorPlugins) {
const proseMirrorPlugins = addProseMirrorPlugins()
plugins.push(...proseMirrorPlugins)
}
return plugins
})
const decorationPlugin = this.createDecorationPlugin()
if (decorationPlugin) {
allPlugins.push(decorationPlugin)
}
return allPlugins
}
/**
* Aggregates decorations from extensions into a single plugin, or returns null
* if none exist. Destroys the previous manager to avoid orphaned listeners.
* @returns A ProseMirror plugin or `null`
* @example
* const plugin = editor.extensionManager.createDecorationPlugin()
*/
createDecorationPlugin(): Plugin | null {
const { editor } = this
this.decorationManager?.destroy()
const entries: DecorationManagerEntry[] = []
this.extensions.forEach(extension => {
const context = {
name: extension.name,
options: extension.options,
storage: this.editor.extensionStorage[extension.name as keyof Storage],
editor,
type: getSchemaTypeByName(extension.name, this.schema),
}
const addDecorations = getExtensionField<AnyConfig['addDecorations']>(
extension,
'addDecorations',
context,
)
if (!addDecorations) {
return
}
entries.push({ name: extension.name, addDecorations })
})
this.decorationManager = new DecorationManager({ editor, entries })
return this.decorationManager.plugin
}
/**
* Get all attributes from the extensions.
* @returns An array of attributes
*/
get attributes() {
return getAttributesFromExtensions(this.extensions)
}
/**
* Get all node views from the extensions.
* @returns An object with all node views where the key is the node name and the value is the node view function
*/
get nodeViews(): Record<string, NodeViewConstructor> {
const { editor } = this
const { nodeExtensions } = splitExtensions(this.extensions)
return Object.fromEntries(
nodeExtensions
.filter(extension => !!getExtensionField(extension, 'addNodeView'))
.map(extension => {
const extensionAttributes = this.attributes.filter(
attribute => attribute.type === extension.name,
)
const context = {
name: extension.name,
options: extension.options,
storage: this.editor.extensionStorage[extension.name as keyof Storage],
editor,
type: getNodeType(extension.name, this.schema),
}
const addNodeView = getExtensionField<NodeConfig['addNodeView']>(
extension,
'addNodeView',
context,
)
if (!addNodeView) {
return []
}
const nodeViewResult = addNodeView()
if (!nodeViewResult) {
return []
}
const nodeview: NodeViewConstructor = (
node,
view,
getPos,
decorations,
innerDecorations,
) => {
const HTMLAttributes = getRenderedAttributes(node, extensionAttributes)
return nodeViewResult({
// pass-through
node,
view,
getPos: getPos as () => number,
decorations,
innerDecorations,
// tiptap-specific
editor,
extension,
HTMLAttributes,
})
}
return [extension.name, nodeview]
}),
)
}
/**
* Get the composed dispatchTransaction function from all extensions.
* @param baseDispatch The base dispatch function (e.g. from the editor or user props)
* @returns A composed dispatch function
*/
dispatchTransaction(baseDispatch: (tr: Transaction) => void): (tr: Transaction) => void {
const { editor } = this
const extensions = sortExtensions([...this.extensions].reverse())
return extensions.reduceRight((next, extension) => {
const context = {
name: extension.name,
options: extension.options,
storage: this.editor.extensionStorage[extension.name as keyof Storage],
editor,
type: getSchemaTypeByName(extension.name, this.schema),
}
const dispatchTransaction = getExtensionField<AnyConfig['dispatchTransaction']>(
extension,
'dispatchTransaction',
context,
)
if (!dispatchTransaction) {
return next
}
return (transaction: Transaction) => {
dispatchTransaction.call(context, { transaction, next })
}
}, baseDispatch)
}
/**
* Get the composed transformPastedHTML function from all extensions.
* @param baseTransform The base transform function (e.g. from the editor props)
* @returns A composed transform function that chains all extension transforms
*/
transformPastedHTML(
baseTransform?: (html: string, view?: any) => string,
): (html: string, view?: EditorView) => string {
const { editor } = this
const extensions = sortExtensions([...this.extensions])
return extensions.reduce(
(transform, extension) => {
const context = {
name: extension.name,
options: extension.options,
storage: this.editor.extensionStorage[extension.name as keyof Storage],
editor,
type: getSchemaTypeByName(extension.name, this.schema),
}
const extensionTransform = getExtensionField<AnyConfig['transformPastedHTML']>(
extension,
'transformPastedHTML',
context,
)
if (!extensionTransform) {
return transform
}
return (html: string, view?: any) => {
// Chain the transforms: pass the result of the previous transform to the next
const transformedHtml = transform(html, view)
return extensionTransform.call(context, transformedHtml)
}
},
baseTransform || ((html: string) => html),
)
}
get markViews(): Record<string, MarkViewConstructor> {
const { editor } = this
const { markExtensions } = splitExtensions(this.extensions)
return Object.fromEntries(
markExtensions
.filter(extension => !!getExtensionField(extension, 'addMarkView'))
.map(extension => {
const extensionAttributes = this.attributes.filter(
attribute => attribute.type === extension.name,
)
const context = {
name: extension.name,
options: extension.options,
storage: this.editor.extensionStorage[extension.name as keyof Storage],
editor,
type: getMarkType(extension.name, this.schema),
}
const addMarkView = getExtensionField<MarkConfig['addMarkView']>(
extension,
'addMarkView',
context,
)
if (!addMarkView) {
return []
}
const markView: MarkViewConstructor = (mark, view, inline) => {
const HTMLAttributes = getRenderedAttributes(mark, extensionAttributes)
return addMarkView()({
// pass-through
mark,
view,
inline,
// tiptap-specific
editor,
extension,
HTMLAttributes,
updateAttributes: (attrs: Record<string, any>) => {
updateMarkViewAttributes(mark, editor, attrs)
},
})
}
return [extension.name, markView]
}),
)
}
/**
* Destroy the extension manager and clean up all extension references
* to prevent memory leaks through parent/child extension chains.
*
* Walks each extension's full parent chain and nulls every forward
* `parent.child → current` link where the parent still points to the
* current node. This breaks the retention path from module-scope
* singleton roots through deep extend() chains.
*
* Only ancestor `.child` links matching the current chain are cleared.
* The `.parent` pointer on ancestors is never touched — extensions
* may be shared across live editors, so their own backward references
* and non-matching forward links must remain intact.
*/
destroy() {
this.decorationManager?.destroy()
this.extensions.forEach(extension => {
let current: any = extension
while (current.parent) {
const parent = current.parent
if (parent.child === current) {
parent.child = null
}
current = parent
}
})
this.extensions = []
this.baseExtensions = []
this.decorationManager = null
this.schema = null as any
this.editor = null as any
}
/**
* Go through all extensions, create extension storages & setup marks
* & bind editor event listener.
*/
private setupExtensions() {
const extensions = this.extensions
// re-initialize the extension storage object instance
this.editor.extensionStorage = Object.fromEntries(
extensions.map(extension => [extension.name, extension.storage]),
) as unknown as Storage
extensions.forEach(extension => {
const context = {
name: extension.name,
options: extension.options,
storage: this.editor.extensionStorage[extension.name as keyof Storage],
editor: this.editor,
type: getSchemaTypeByName(extension.name, this.schema),
}
if (extension.type === 'mark') {
const keepOnSplit =
callOrReturn(getExtensionField(extension, 'keepOnSplit', context)) ?? true
if (keepOnSplit) {
this.splittableMarks.push(extension.name)
}
const clearable =
callOrReturn(
getExtensionField<MarkConfig['clearable']>(extension, 'clearable', context),
) ?? true
if (!clearable) {
this.nonClearableMarks.push(extension.name)
}
}
const onBeforeCreate = getExtensionField<AnyConfig['onBeforeCreate']>(
extension,
'onBeforeCreate',
context,
)
const onCreate = getExtensionField<AnyConfig['onCreate']>(extension, 'onCreate', context)
const onUpdate = getExtensionField<AnyConfig['onUpdate']>(extension, 'onUpdate', context)
const onSelectionUpdate = getExtensionField<AnyConfig['onSelectionUpdate']>(
extension,
'onSelectionUpdate',
context,
)
const onTransaction = getExtensionField<AnyConfig['onTransaction']>(
extension,
'onTransaction',
context,
)
const onFocus = getExtensionField<AnyConfig['onFocus']>(extension, 'onFocus', context)
const onBlur = getExtensionField<AnyConfig['onBlur']>(extension, 'onBlur', context)
const onDestroy = getExtensionField<AnyConfig['onDestroy']>(extension, 'onDestroy', context)
if (onBeforeCreate) {
this.editor.on('beforeCreate', onBeforeCreate)
}
if (onCreate) {
this.editor.on('create', onCreate)
}
if (onUpdate) {
this.editor.on('update', onUpdate)
}
if (onSelectionUpdate) {
this.editor.on('selectionUpdate', onSelectionUpdate)
}
if (onTransaction) {
this.editor.on('transaction', onTransaction)
}
if (onFocus) {
this.editor.on('focus', onFocus)
}
if (onBlur) {
this.editor.on('blur', onBlur)
}
if (onDestroy) {
this.editor.on('destroy', onDestroy)
}
})
}
}

View File

@@ -0,0 +1,113 @@
import Code from '@tiptap/extension-code'
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 { Editor } from './Editor.js'
import { Node } from './Node.js'
// minimal stand-in for a mention-like node. inline atoms expand to %leaf% (6 chars)
// in getTextContentFromNodes but only take up 1 position in the doc,
// which used to break the input rule range math
const InlineAtom = Node.create({
name: 'inlineAtom',
group: 'inline',
inline: true,
atom: true,
parseHTML: () => [{ tag: 'span[data-atom]' }],
renderHTML: () => ['span', { 'data-atom': '' }],
})
// same as above, but with a custom text representation instead of the %leaf% fallback
const InlineAtomWithText = Node.create({
name: 'inlineAtomWithText',
group: 'inline',
inline: true,
atom: true,
parseHTML: () => [{ tag: 'span[data-atom-text]' }],
renderHTML: () => ['span', { 'data-atom-text': '' }],
renderText: () => '@mention',
})
const createEditor = (content: string) =>
new Editor({
extensions: [Document, Paragraph, Text, InlineAtom, InlineAtomWithText, Code],
content,
})
const typeAtEndOfParagraph = (editor: Editor, char: string) => {
const pos = editor.state.doc.content.size - 1
editor.commands.setTextSelection(pos)
return editor.view.someProp('handleTextInput', f => f(editor.view, pos, pos, char))
}
describe('input rules with inline atom nodes', () => {
let editor: Editor
afterEach(() => {
editor.destroy()
})
it('does not crash when the match spans an inline atom (#7933)', () => {
editor = createEditor('<p>`a<span data-atom></span></p>')
expect(() => typeAtEndOfParagraph(editor, '`')).not.toThrow()
})
it('does not apply the rule when the match spans an inline atom', () => {
editor = createEditor('<p>`a<span data-atom></span></p>')
const handled = typeAtEndOfParagraph(editor, '`')
// someProp returns undefined when the handler declines the input
expect(handled).toBeFalsy()
expect(editor.getHTML()).not.toContain('<code>')
})
it('does not crash with multiple atoms inside the match', () => {
editor = createEditor(
'<p>`a<span data-atom></span><span data-atom></span><span data-atom></span></p>',
)
expect(() => typeAtEndOfParagraph(editor, '`')).not.toThrow()
})
it('does not corrupt preceding text when the match spans an atom (#7833)', () => {
// with enough text before the backtick the miscalculated range stays positive,
// so instead of crashing it used to silently mark/replace the wrong content
editor = createEditor('<p>hello world `a<span data-atom></span></p>')
expect(() => typeAtEndOfParagraph(editor, '`')).not.toThrow()
expect(editor.getHTML()).toBe('<p>hello world `a<span data-atom=""></span></p>')
})
it('does not apply the rule when the match spans an atom with a custom text representation', () => {
editor = createEditor('<p>hello `a<span data-atom-text></span></p>')
const handled = typeAtEndOfParagraph(editor, '`')
expect(handled).toBeFalsy()
expect(editor.getHTML()).toBe('<p>hello `a<span data-atom-text=""></span></p>')
})
it('still applies input rules to plain text', () => {
editor = createEditor('<p>some `code</p>')
const handled = typeAtEndOfParagraph(editor, '`')
expect(handled).toBe(true)
expect(editor.getHTML()).toContain('<code>code</code>')
})
it('still applies input rules when an atom precedes the match', () => {
editor = createEditor('<p><span data-atom></span>x `code</p>')
const handled = typeAtEndOfParagraph(editor, '`')
expect(handled).toBe(true)
expect(editor.getHTML()).toContain('<code>code</code>')
})
})

View File

@@ -0,0 +1,317 @@
import type { Node as PMNode } from '@tiptap/pm/model'
import { Fragment } from '@tiptap/pm/model'
import type { EditorState, TextSelection } from '@tiptap/pm/state'
import { Plugin } from '@tiptap/pm/state'
import { CommandManager } from './CommandManager.js'
import type { Editor } from './Editor.js'
import { createChainableState } from './helpers/createChainableState.js'
import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js'
import { getTextContentFromNodes } from './helpers/getTextContentFromNodes.js'
import type {
CanCommands,
ChainedCommands,
ExtendedRegExpMatchArray,
Range,
SingleCommands,
} from './types.js'
import { isRegExp } from './utilities/isRegExp.js'
export type InputRuleMatch = {
index: number
text: string
replaceWith?: string
match?: RegExpMatchArray
data?: Record<string, any>
}
export type InputRuleFinder = RegExp | ((text: string) => InputRuleMatch | null)
export class InputRule {
find: InputRuleFinder
handler: (props: {
state: EditorState
range: Range
match: ExtendedRegExpMatchArray
commands: SingleCommands
chain: () => ChainedCommands
can: () => CanCommands
}) => void | null
undoable: boolean
constructor(config: {
find: InputRuleFinder
handler: (props: {
state: EditorState
range: Range
match: ExtendedRegExpMatchArray
commands: SingleCommands
chain: () => ChainedCommands
can: () => CanCommands
}) => void | null
undoable?: boolean
}) {
this.find = config.find
this.handler = config.handler
this.undoable = config.undoable ?? true
}
}
const inputRuleMatcherHandler = (
text: string,
find: InputRuleFinder,
): ExtendedRegExpMatchArray | null => {
if (isRegExp(find)) {
return find.exec(text)
}
const inputRuleMatch = find(text)
if (!inputRuleMatch) {
return null
}
const result: ExtendedRegExpMatchArray = [inputRuleMatch.text]
result.index = inputRuleMatch.index
result.input = text
result.data = inputRuleMatch.data
if (inputRuleMatch.replaceWith) {
if (!inputRuleMatch.text.includes(inputRuleMatch.replaceWith)) {
console.warn(
'[tiptap warn]: "inputRuleMatch.replaceWith" must be part of "inputRuleMatch.text".',
)
}
result.push(inputRuleMatch.replaceWith)
}
return result
}
function run(config: {
editor: Editor
from: number
to: number
text: string
rules: InputRule[]
plugin: Plugin
}): boolean {
const { editor, from, to, text, rules, plugin } = config
const { view } = editor
if (view.composing) {
return false
}
const $from = view.state.doc.resolve(from)
if (
// check for code node
$from.parent.type.spec.code ||
// check for code mark
!!($from.nodeBefore || $from.nodeAfter)?.marks.find(mark => mark.type.spec.code)
) {
return false
}
let matched = false
const textBefore = getTextContentFromNodes($from) + text
rules.forEach(rule => {
if (matched) {
return
}
const match = inputRuleMatcherHandler(textBefore, rule.find)
if (!match) {
return
}
// Leaf nodes have a different length in text and the document.
// Skip matches across them to avoid wrong document positions.
const matchedDocLength = match[0].length - text.length
if (matchedDocLength > 0) {
const matchStartOffset = $from.parentOffset - matchedDocLength
if (
matchStartOffset < 0 ||
$from.parent.textBetween(matchStartOffset, $from.parentOffset) !==
match[0].slice(0, matchedDocLength)
) {
return
}
}
const tr = view.state.tr
const state = createChainableState({
state: view.state,
transaction: tr,
})
const range = {
from: from - (match[0].length - text.length),
to,
}
const { commands, chain, can } = new CommandManager({
editor,
state,
})
const handler = rule.handler({
state,
range,
match,
commands,
chain,
can,
})
// stop if there are no changes
if (handler === null || !tr.steps.length) {
return
}
// store transform as meta data
// so we can undo input rules within the `undoInputRules` command
if (rule.undoable) {
tr.setMeta(plugin, {
transform: tr,
from,
to,
text,
})
}
view.dispatch(tr)
matched = true
})
return matched
}
/**
* Create an input rules plugin. When enabled, it will cause text
* input that matches any of the given rules to trigger the rule’s
* action.
*/
export function inputRulesPlugin(props: { editor: Editor; rules: InputRule[] }): Plugin {
const { editor, rules } = props
const plugin = new Plugin({
state: {
init() {
return null
},
apply(tr, prev, state) {
const stored = tr.getMeta(plugin)
if (stored) {
return stored
}
// if InputRule is triggered by insertContent()
const simulatedInputMeta = tr.getMeta('applyInputRules') as
| undefined
| {
from: number
text: string | PMNode | Fragment
}
const isSimulatedInput = !!simulatedInputMeta
if (isSimulatedInput) {
setTimeout(() => {
let { text } = simulatedInputMeta
if (typeof text === 'string') {
text = text as string
} else {
text = getHTMLFromFragment(Fragment.from(text), state.schema)
}
const { from } = simulatedInputMeta
const to = from + text.length
run({
editor,
from,
to,
text,
rules,
plugin,
})
})
}
return tr.selectionSet || tr.docChanged ? null : prev
},
},
props: {
handleTextInput(view, from, to, text) {
return run({
editor,
from,
to,
text,
rules,
plugin,
})
},
handleDOMEvents: {
compositionend: view => {
setTimeout(() => {
const { $cursor } = view.state.selection as TextSelection
if ($cursor) {
run({
editor,
from: $cursor.pos,
to: $cursor.pos,
text: '',
rules,
plugin,
})
}
})
return false
},
},
// add support for input rules to trigger on enter
// this is useful for example for code blocks
handleKeyDown(view, event) {
if (event.key !== 'Enter') {
return false
}
const { $cursor } = view.state.selection as TextSelection
if ($cursor) {
return run({
editor,
from: $cursor.pos,
to: $cursor.pos,
text: '\n',
rules,
plugin,
})
}
return false
},
},
// @ts-ignore
isInputRules: true,
}) as Plugin
return plugin
}

231
packages/core/src/Mark.ts Normal file
View File

@@ -0,0 +1,231 @@
import type { DOMOutputSpec, Mark as ProseMirrorMark, MarkSpec, MarkType } from '@tiptap/pm/model'
import type { Editor } from './Editor.js'
import type { ExtendableConfig } from './Extendable.js'
import { Extendable } from './Extendable.js'
import type { Attributes, MarkViewRenderer, ParentConfig } from './types.js'
export interface MarkConfig<Options = any, Storage = any> extends ExtendableConfig<
Options,
Storage,
MarkConfig<Options, Storage>,
MarkType
> {
/**
* Mark View
*/
addMarkView?:
| ((this: {
name: string
options: Options
storage: Storage
editor: Editor
type: MarkType
parent: ParentConfig<MarkConfig<Options, Storage>>['addMarkView']
}) => MarkViewRenderer)
| null
/**
* Keep mark after split node
*/
keepOnSplit?: boolean | (() => boolean)
/**
* Whether this mark is removed by `unsetAllMarks`.
* Set to `false` for semantic marks (comments, suggestions) that should survive "clear formatting".
* @default true
*/
clearable?: boolean | (() => boolean)
/**
* Inclusive
*/
inclusive?:
| MarkSpec['inclusive']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<MarkConfig<Options, Storage>>['inclusive']
editor?: Editor
}) => MarkSpec['inclusive'])
/**
* Excludes
*/
excludes?:
| MarkSpec['excludes']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<MarkConfig<Options, Storage>>['excludes']
editor?: Editor
}) => MarkSpec['excludes'])
/**
* Marks this Mark as exitable
*/
exitable?: boolean | (() => boolean)
/**
* Group
*/
group?:
| MarkSpec['group']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<MarkConfig<Options, Storage>>['group']
editor?: Editor
}) => MarkSpec['group'])
/**
* Spanning
*/
spanning?:
| MarkSpec['spanning']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<MarkConfig<Options, Storage>>['spanning']
editor?: Editor
}) => MarkSpec['spanning'])
/**
* Code
*/
code?:
| boolean
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<MarkConfig<Options, Storage>>['code']
editor?: Editor
}) => boolean)
/**
* Parse HTML
*/
parseHTML?: (this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<MarkConfig<Options, Storage>>['parseHTML']
editor?: Editor
}) => MarkSpec['parseDOM']
/**
* Render HTML
*/
renderHTML?:
| ((
this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<MarkConfig<Options, Storage>>['renderHTML']
editor?: Editor
},
props: {
mark: ProseMirrorMark
HTMLAttributes: Record<string, any>
},
) => DOMOutputSpec)
| null
/**
* Attributes
*/
addAttributes?: (this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<MarkConfig<Options, Storage>>['addAttributes']
editor?: Editor
// oxlint-disable-next-lineno-empty-object-type
}) => Attributes | {}
}
/**
* The Mark class is used to create custom mark extensions.
* @see https://tiptap.dev/api/extensions#create-a-new-extension
*/
export class Mark<Options = any, Storage = any> extends Extendable<
Options,
Storage,
MarkConfig<Options, Storage>
> {
type = 'mark'
/**
* Create a new Mark instance
* @param config - Mark configuration object or a function that returns a configuration object
*/
static create<O = any, S = any>(
config: Partial<MarkConfig<O, S>> | (() => Partial<MarkConfig<O, S>>) = {},
) {
// If the config is a function, execute it to get the configuration object
const resolvedConfig = typeof config === 'function' ? config() : config
return new Mark<O, S>(resolvedConfig)
}
static handleExit({ editor, mark }: { editor: Editor; mark: Mark }) {
const { tr } = editor.state
const currentPos = editor.state.selection.$from
const isAtEnd = currentPos.pos === currentPos.end()
if (isAtEnd) {
const currentMarks = currentPos.marks()
const isInMark = !!currentMarks.find(m => m?.type.name === mark.name)
if (!isInMark) {
return false
}
const removeMark = currentMarks.find(m => m?.type.name === mark.name)
if (removeMark) {
tr.removeStoredMark(removeMark)
}
tr.insertText(' ', currentPos.pos)
editor.view.dispatch(tr)
return true
}
return false
}
configure(options?: Partial<Options>) {
return super.configure(options) as Mark<Options, Storage>
}
extend<
ExtendedOptions = Options,
ExtendedStorage = Storage,
ExtendedConfig extends MarkConfig<ExtendedOptions, ExtendedStorage> = MarkConfig<
ExtendedOptions,
ExtendedStorage
>,
>(
extendedConfig?:
| (() => Partial<ExtendedConfig>)
| (Partial<ExtendedConfig> &
ThisType<{
name: string
options: ExtendedOptions
storage: ExtendedStorage
editor: Editor
type: MarkType
}>),
): Mark<ExtendedOptions, ExtendedStorage> {
// If the extended config is a function, execute it to get the configuration object
const resolvedConfig = typeof extendedConfig === 'function' ? extendedConfig() : extendedConfig
return super.extend(resolvedConfig) as Mark<ExtendedOptions, ExtendedStorage>
}
}

View File

@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { createViewMutationTestContext } from '../tests/utils/createViewMutationTestContext.js'
import { MarkView } from './MarkView.js'
import type { MarkViewProps } from './types.js'
function createMarkView() {
const context = createViewMutationTestContext()
const { wrapper, contentDOM } = context
class TestMarkView extends MarkView<null> {
get dom() {
return wrapper
}
get contentDOM() {
return contentDOM
}
}
const markView = new TestMarkView(null, {
editor: { isFocused: true },
mark: {},
HTMLAttributes: {},
} as unknown as MarkViewProps)
return { markView, ...context }
}
describe('MarkView ignoreMutation on iOS', () => {
afterEach(() => {
document.body.replaceChildren()
vi.unstubAllGlobals()
})
it('ignores framework chrome mounted inside dom outside contentDOM', () => {
const { markView, chrome, createEditableChild, childListMutation } = createMarkView()
const mounted = createEditableChild()
chrome.append(mounted)
expect(markView.ignoreMutation(childListMutation(chrome, [mounted]))).toBe(true)
})
it('lets ProseMirror handle childList mutations inside contentDOM', () => {
const { markView, contentDOM, createEditableChild, childListMutation } = createMarkView()
const edited = createEditableChild()
contentDOM.append(edited)
expect(markView.ignoreMutation(childListMutation(contentDOM, [edited]))).toBe(false)
})
})

View File

@@ -0,0 +1,132 @@
import type { Mark } from '@tiptap/pm/model'
import type { ViewMutationRecord } from '@tiptap/pm/view'
import type { Editor } from './Editor.js'
import type { MarkViewProps, MarkViewRendererOptions } from './types.js'
import { isAndroid, isiOS } from './utilities/index.js'
export function updateMarkViewAttributes(
checkMark: Mark,
editor: Editor,
attrs: Record<string, any> = {},
): void {
const { state } = editor
const { doc, tr } = state
const thisMark = checkMark
doc.descendants((node, pos) => {
const from = tr.mapping.map(pos)
const to = tr.mapping.map(pos) + node.nodeSize
let foundMark: Mark | null = null
// find the mark on the current node
node.marks.forEach(mark => {
if (mark !== thisMark) {
return false
}
foundMark = mark
})
if (!foundMark) {
return
}
// check if we need to update given the attributes
let needsUpdate = false
Object.keys(attrs).forEach(k => {
if (attrs[k] !== foundMark!.attrs[k]) {
needsUpdate = true
}
})
if (needsUpdate) {
const updatedMark = checkMark.type.create({
...checkMark.attrs,
...attrs,
})
tr.removeMark(from, to, checkMark.type)
tr.addMark(from, to, updatedMark)
}
})
if (tr.docChanged) {
editor.view.dispatch(tr)
}
}
export class MarkView<
Component,
Options extends MarkViewRendererOptions = MarkViewRendererOptions,
> {
component: Component
editor: Editor
options: Options
mark: MarkViewProps['mark']
HTMLAttributes: MarkViewProps['HTMLAttributes']
constructor(component: Component, props: MarkViewProps, options?: Partial<Options>) {
this.component = component
this.editor = props.editor
this.options = { ...options } as Options
this.mark = props.mark
this.HTMLAttributes = props.HTMLAttributes
}
get dom(): HTMLElement {
return this.editor.view.dom
}
get contentDOM(): HTMLElement | null {
return null
}
/**
* Update the attributes of the mark in the document.
* @param attrs The attributes to update.
*/
updateAttributes(attrs: Record<string, any>, checkMark?: Mark): void {
updateMarkViewAttributes(checkMark || this.mark, this.editor, attrs)
}
ignoreMutation(mutation: ViewMutationRecord): boolean {
if (!this.dom || !this.contentDOM) {
return true
}
if (typeof this.options.ignoreMutation === 'function') {
return this.options.ignoreMutation({ mutation })
}
if (mutation.type === 'selection') {
return false
}
if (
this.contentDOM.contains(mutation.target) &&
mutation.type === 'childList' &&
(isiOS() || isAndroid()) &&
this.editor.isFocused
) {
const changedNodes = [
...Array.from(mutation.addedNodes),
...Array.from(mutation.removedNodes),
] as HTMLElement[]
if (changedNodes.every(node => node.isContentEditable)) {
return false
}
}
if (this.contentDOM === mutation.target && mutation.type === 'attributes') {
return true
}
if (this.contentDOM.contains(mutation.target)) {
return false
}
return true
}
}

390
packages/core/src/Node.ts Normal file
View File

@@ -0,0 +1,390 @@
import type { DOMOutputSpec, Node as PMNode, NodeSpec, NodeType } from '@tiptap/pm/model'
import type { Editor } from './Editor.js'
import type { ExtendableConfig } from './Extendable.js'
import { Extendable } from './Extendable.js'
import type { Attributes, NodeViewRenderer, ParentConfig } from './types.js'
export interface NodeConfig<Options = any, Storage = any> extends ExtendableConfig<
Options,
Storage,
NodeConfig<Options, Storage>,
NodeType
> {
/**
* Node View
*/
addNodeView?:
| ((this: {
name: string
options: Options
storage: Storage
editor: Editor
type: NodeType
parent: ParentConfig<NodeConfig<Options, Storage>>['addNodeView']
}) => NodeViewRenderer | null)
| null
/**
* Defines if this node should be a top level node (doc)
* @default false
* @example true
*/
topNode?: boolean
/**
* The content expression for this node, as described in the [schema
* guide](/docs/guide/#schema.content_expressions). When not given,
* the node does not allow any content.
*
* You can read more about it on the Prosemirror documentation here
* @see https://prosemirror.net/docs/guide/#schema.content_expressions
* @default undefined
* @example content: 'block+'
* @example content: 'headline paragraph block*'
*/
content?:
| NodeSpec['content']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['content']
editor?: Editor
}) => NodeSpec['content'])
/**
* The marks that are allowed inside of this node. May be a
* space-separated string referring to mark names or groups, `"_"`
* to explicitly allow all marks, or `""` to disallow marks. When
* not given, nodes with inline content default to allowing all
* marks, other nodes default to not allowing marks.
*
* @example marks: 'strong em'
*/
marks?:
| NodeSpec['marks']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['marks']
editor?: Editor
}) => NodeSpec['marks'])
/**
* The group or space-separated groups to which this node belongs,
* which can be referred to in the content expressions for the
* schema.
*
* By default Tiptap uses the groups 'block' and 'inline' for nodes. You
* can also use custom groups if you want to group specific nodes together
* and handle them in your schema.
* @example group: 'block'
* @example group: 'inline'
* @example group: 'customBlock' // this uses a custom group
*/
group?:
| NodeSpec['group']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['group']
editor?: Editor
}) => NodeSpec['group'])
/**
* Should be set to true for inline nodes. (Implied for text nodes.)
*/
inline?:
| NodeSpec['inline']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['inline']
editor?: Editor
}) => NodeSpec['inline'])
/**
* Can be set to true to indicate that, though this isn't a [leaf
* node](https://prosemirror.net/docs/ref/#model.NodeType.isLeaf), it doesn't have directly editable
* content and should be treated as a single unit in the view.
*
* @example atom: true
*/
atom?:
| NodeSpec['atom']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['atom']
editor?: Editor
}) => NodeSpec['atom'])
/**
* Controls whether nodes of this type can be selected as a [node
* selection](https://prosemirror.net/docs/ref/#state.NodeSelection). Defaults to true for non-text
* nodes.
*
* @default true
* @example selectable: false
*/
selectable?:
| NodeSpec['selectable']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['selectable']
editor?: Editor
}) => NodeSpec['selectable'])
/**
* Determines whether nodes of this type can be dragged without
* being selected. Defaults to false.
*
* @default: false
* @example: draggable: true
*/
draggable?:
| NodeSpec['draggable']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['draggable']
editor?: Editor
}) => NodeSpec['draggable'])
/**
* Can be used to indicate that this node contains code, which
* causes some commands to behave differently.
*/
code?:
| NodeSpec['code']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['code']
editor?: Editor
}) => NodeSpec['code'])
/**
* Controls way whitespace in this a node is parsed. The default is
* `"normal"`, which causes the [DOM parser](https://prosemirror.net/docs/ref/#model.DOMParser) to
* collapse whitespace in normal mode, and normalize it (replacing
* newlines and such with spaces) otherwise. `"pre"` causes the
* parser to preserve spaces inside the node. When this option isn't
* given, but [`code`](https://prosemirror.net/docs/ref/#model.NodeSpec.code) is true, `whitespace`
* will default to `"pre"`. Note that this option doesn't influence
* the way the node is rendered—that should be handled by `toDOM`
* and/or styling.
*/
whitespace?:
| NodeSpec['whitespace']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['whitespace']
editor?: Editor
}) => NodeSpec['whitespace'])
/**
* Allows a **single** node to be set as linebreak equivalent (e.g. hardBreak).
* When converting between block types that have whitespace set to "pre"
* and don't support the linebreak node (e.g. codeBlock) and other block types
* that do support the linebreak node (e.g. paragraphs) - this node will be used
* as the linebreak instead of stripping the newline.
*
* See [linebreakReplacement](https://prosemirror.net/docs/ref/#model.NodeSpec.linebreakReplacement).
*/
linebreakReplacement?:
| NodeSpec['linebreakReplacement']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['linebreakReplacement']
editor?: Editor
}) => NodeSpec['linebreakReplacement'])
/**
* When enabled, enables both
* [`definingAsContext`](https://prosemirror.net/docs/ref/#model.NodeSpec.definingAsContext) and
* [`definingForContent`](https://prosemirror.net/docs/ref/#model.NodeSpec.definingForContent).
*
* @default false
* @example isolating: true
*/
defining?:
| NodeSpec['defining']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['defining']
editor?: Editor
}) => NodeSpec['defining'])
/**
* When enabled (default is false), the sides of nodes of this type
* count as boundaries that regular editing operations, like
* backspacing or lifting, won't cross. An example of a node that
* should probably have this enabled is a table cell.
*/
isolating?:
| NodeSpec['isolating']
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['isolating']
editor?: Editor
}) => NodeSpec['isolating'])
/**
* Associates DOM parser information with this node, which can be
* used by [`DOMParser.fromSchema`](https://prosemirror.net/docs/ref/#model.DOMParser^fromSchema) to
* automatically derive a parser. The `node` field in the rules is
* implied (the name of this node will be filled in automatically).
* If you supply your own parser, you do not need to also specify
* parsing rules in your schema.
*
* @example parseHTML: [{ tag: 'div', attrs: { 'data-id': 'my-block' } }]
*/
parseHTML?: (this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['parseHTML']
editor?: Editor
}) => NodeSpec['parseDOM']
/**
* A description of a DOM structure. Can be either a string, which is
* interpreted as a text node, a DOM node, which is interpreted as
* itself, a `{dom, contentDOM}` object, or an array.
*
* An array describes a DOM element. The first value in the array
* should be a string—the name of the DOM element, optionally prefixed
* by a namespace URL and a space. If the second element is plain
* object, it is interpreted as a set of attributes for the element.
* Any elements after that (including the 2nd if it's not an attribute
* object) are interpreted as children of the DOM elements, and must
* either be valid `DOMOutputSpec` values, or the number zero.
*
* The number zero (pronounced “hole”) is used to indicate the place
* where a node's child nodes should be inserted. If it occurs in an
* output spec, it should be the only child element in its parent
* node.
*
* @example toDOM: ['div[data-id="my-block"]', { class: 'my-block' }, 0]
*/
renderHTML?:
| ((
this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['renderHTML']
editor?: Editor
},
props: {
node: PMNode
HTMLAttributes: Record<string, any>
},
) => DOMOutputSpec)
| null
/**
* renders the node as text
* @example renderText: () => 'foo
*/
renderText?:
| ((
this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['renderText']
editor?: Editor
},
props: {
node: PMNode
pos: number
parent: PMNode
index: number
},
) => string)
| null
/**
* Add attributes to the node
* @example addAttributes: () => ({ class: 'foo' })
*/
addAttributes?: (this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options, Storage>>['addAttributes']
editor?: Editor
// oxlint-disable-next-lineno-empty-object-type
}) => Attributes | {}
}
/**
* The Node class is used to create custom node extensions.
* @see https://tiptap.dev/api/extensions#create-a-new-extension
*/
export class Node<Options = any, Storage = any> extends Extendable<
Options,
Storage,
NodeConfig<Options, Storage>
> {
type = 'node'
/**
* Create a new Node instance
* @param config - Node configuration object or a function that returns a configuration object
*/
static create<O = any, S = any>(
config: Partial<NodeConfig<O, S>> | (() => Partial<NodeConfig<O, S>>) = {},
) {
// If the config is a function, execute it to get the configuration object
const resolvedConfig = typeof config === 'function' ? config() : config
return new Node<O, S>(resolvedConfig)
}
configure(options?: Partial<Options>) {
return super.configure(options) as Node<Options, Storage>
}
extend<
ExtendedOptions = Options,
ExtendedStorage = Storage,
ExtendedConfig extends NodeConfig<ExtendedOptions, ExtendedStorage> = NodeConfig<
ExtendedOptions,
ExtendedStorage
>,
>(
extendedConfig?:
| (() => Partial<ExtendedConfig>)
| (Partial<ExtendedConfig> &
ThisType<{
name: string
options: ExtendedOptions
storage: ExtendedStorage
editor: Editor
type: NodeType
}>),
): Node<ExtendedOptions, ExtendedStorage> {
// If the extended config is a function, execute it to get the configuration object
const resolvedConfig = typeof extendedConfig === 'function' ? extendedConfig() : extendedConfig
return super.extend(resolvedConfig) as Node<ExtendedOptions, ExtendedStorage>
}
}

View File

@@ -0,0 +1,722 @@
import Blockquote from '@tiptap/extension-blockquote'
import BulletList from '@tiptap/extension-bullet-list'
import Document from '@tiptap/extension-document'
import HardBreak from '@tiptap/extension-hard-break'
import HorizontalRule from '@tiptap/extension-horizontal-rule'
import { ListItem } from '@tiptap/extension-list'
import OrderedList from '@tiptap/extension-ordered-list'
import Paragraph from '@tiptap/extension-paragraph'
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { Editor } from './Editor.js'
import { Node } from './Node.js'
// Simple inline node for testing (similar to mention)
const CustomInlineNode = Node.create({
name: 'customInline',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
id: {
default: null,
},
}
},
parseHTML() {
return [
{
tag: 'span[data-type="custom-inline"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['span', { 'data-type': 'custom-inline', ...HTMLAttributes }, 0]
},
})
const CustomBlockAtomNode = Node.create({
name: 'customBlockAtom',
group: 'block',
atom: true,
addAttributes() {
return {
id: {
default: null,
},
}
},
parseHTML() {
return [
{
tag: 'div[data-type="custom-block-atom"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['div', { 'data-type': 'custom-block-atom', ...HTMLAttributes }]
},
})
describe('NodePos', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
describe('$nodes', () => {
it('should return text nodes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello World</p>',
})
const textNodes = editor.$nodes('text')
expect(textNodes).not.toBeNull()
expect(textNodes!.length).toBe(1)
expect(textNodes![0].textContent).toBe('Hello World')
})
it('should return multiple text nodes across paragraphs', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>First</p><p>Second</p><p>Third</p>',
})
const textNodes = editor.$nodes('text')
expect(textNodes).not.toBeNull()
expect(textNodes!.length).toBe(3)
expect(textNodes![0].textContent).toBe('First')
expect(textNodes![1].textContent).toBe('Second')
expect(textNodes![2].textContent).toBe('Third')
})
it('should return inline atom nodes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content: '<p>Hello <span data-type="custom-inline" id="1"></span> World</p>',
})
const inlineNodes = editor.$nodes('customInline')
expect(inlineNodes).not.toBeNull()
expect(inlineNodes!.length).toBe(1)
expect(inlineNodes![0].node.type.name).toBe('customInline')
})
it('should return multiple inline nodes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content:
'<p>Hello <span data-type="custom-inline" id="1"></span> and <span data-type="custom-inline" id="2"></span> World</p>',
})
const inlineNodes = editor.$nodes('customInline')
expect(inlineNodes).not.toBeNull()
expect(inlineNodes!.length).toBe(2)
})
it('should return inline nodes with attribute filtering', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content:
'<p>Hello <span data-type="custom-inline" id="1"></span> and <span data-type="custom-inline" id="2"></span> World</p>',
})
// Note: HTML attribute parsing converts numeric strings to numbers
const inlineNodes = editor.$nodes('customInline', { id: 1 })
expect(inlineNodes).not.toBeNull()
expect(inlineNodes!.length).toBe(1)
expect(inlineNodes![0].node.attrs.id).toBe(1)
})
it('should return paragraph nodes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>First</p><p>Second</p>',
})
const paragraphNodes = editor.$nodes('paragraph')
expect(paragraphNodes).not.toBeNull()
expect(paragraphNodes!.length).toBe(2)
})
it('should return inline nodes nested in multiple paragraphs', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content:
'<p>Para 1 <span data-type="custom-inline" id="1"></span></p><p>Para 2 <span data-type="custom-inline" id="2"></span></p>',
})
const inlineNodes = editor.$nodes('customInline')
expect(inlineNodes).not.toBeNull()
expect(inlineNodes!.length).toBe(2)
// Note: HTML attribute parsing converts numeric strings to numbers
expect(inlineNodes![0].node.attrs.id).toBe(1)
expect(inlineNodes![1].node.attrs.id).toBe(2)
})
it('should find paragraphs inside blockquotes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Blockquote],
content: '<blockquote><p>Quoted text</p></blockquote>',
})
const paragraphs = editor.$nodes('paragraph')
expect(paragraphs).not.toBeNull()
expect(paragraphs!.length).toBe(1)
expect(paragraphs![0].textContent).toBe('Quoted text')
})
it('should find blockquote nodes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Blockquote],
content:
'<blockquote><p>First quote</p></blockquote><p>Normal</p><blockquote><p>Second quote</p></blockquote>',
})
const blockquotes = editor.$nodes('blockquote')
expect(blockquotes).not.toBeNull()
expect(blockquotes!.length).toBe(2)
})
it('should find text nodes inside nested block structures', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Blockquote],
content: '<blockquote><p>Quoted</p></blockquote><p>Normal</p>',
})
const textNodes = editor.$nodes('text')
expect(textNodes).not.toBeNull()
expect(textNodes!.length).toBe(2)
expect(textNodes![0].textContent).toBe('Quoted')
expect(textNodes![1].textContent).toBe('Normal')
})
it('should find list items in bullet lists', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, ListItem],
content: '<ul><li><p>Item 1</p></li><li><p>Item 2</p></li></ul>',
})
const listItems = editor.$nodes('listItem')
expect(listItems).not.toBeNull()
expect(listItems!.length).toBe(2)
})
it('should find text nodes inside list items', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, ListItem],
content: '<ul><li><p>Item 1</p></li><li><p>Item 2</p></li></ul>',
})
const textNodes = editor.$nodes('text')
expect(textNodes).not.toBeNull()
expect(textNodes!.length).toBe(2)
expect(textNodes![0].textContent).toBe('Item 1')
expect(textNodes![1].textContent).toBe('Item 2')
})
it('should find inline nodes inside list items', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, ListItem, CustomInlineNode],
content:
'<ul><li><p>Item <span data-type="custom-inline" id="1"></span></p></li><li><p>Item <span data-type="custom-inline" id="2"></span></p></li></ul>',
})
const inlineNodes = editor.$nodes('customInline')
expect(inlineNodes).not.toBeNull()
expect(inlineNodes!.length).toBe(2)
})
it('should handle empty paragraphs', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p></p><p>Content</p><p></p>',
})
const paragraphs = editor.$nodes('paragraph')
expect(paragraphs).not.toBeNull()
expect(paragraphs!.length).toBe(3)
})
it('should return empty array for text nodes when paragraph is empty', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p></p>',
})
const textNodes = editor.$nodes('text')
expect(textNodes).not.toBeNull()
expect(textNodes!.length).toBe(0)
})
it('should find all node types in mixed content', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Blockquote, CustomInlineNode],
content:
'<p>Normal <span data-type="custom-inline" id="1"></span></p><blockquote><p>Quoted <span data-type="custom-inline" id="2"></span></p></blockquote>',
})
const paragraphs = editor.$nodes('paragraph')
const blockquotes = editor.$nodes('blockquote')
const textNodes = editor.$nodes('text')
const inlineNodes = editor.$nodes('customInline')
expect(paragraphs!.length).toBe(2)
expect(blockquotes!.length).toBe(1)
expect(textNodes!.length).toBe(2)
expect(inlineNodes!.length).toBe(2)
})
})
describe('$node', () => {
it('should find a single text node', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
const textNode = editor.$node('text')
expect(textNode).not.toBeNull()
expect(textNode!.textContent).toBe('Hello')
})
it('should find a single inline node', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content: '<p><span data-type="custom-inline" id="found"></span></p>',
})
const inlineNode = editor.$node('customInline')
expect(inlineNode).not.toBeNull()
expect(inlineNode!.node.attrs.id).toBe('found')
})
})
describe('children', () => {
it('should include text nodes as children of paragraph', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello World</p>',
})
const paragraph = editor.$node('paragraph')
expect(paragraph).not.toBeNull()
const children = paragraph!.children
expect(children.length).toBe(1)
expect(children[0].node.type.name).toBe('text')
expect(children[0].textContent).toBe('Hello World')
})
it('should include inline atom nodes as children of paragraph', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content: '<p>Hello <span data-type="custom-inline" id="test"></span> World</p>',
})
const paragraph = editor.$node('paragraph')
expect(paragraph).not.toBeNull()
const children = paragraph!.children
// Should have: "Hello ", customInline, " World"
expect(children.length).toBe(3)
expect(children[0].node.type.name).toBe('text')
expect(children[0].textContent).toBe('Hello ')
expect(children[1].node.type.name).toBe('customInline')
expect(children[2].node.type.name).toBe('text')
expect(children[2].textContent).toBe(' World')
})
it('should return correct node reference for inline nodes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content: '<p><span data-type="custom-inline" id="test-id"></span></p>',
})
const inlineNodes = editor.$nodes('customInline')
expect(inlineNodes).not.toBeNull()
expect(inlineNodes!.length).toBe(1)
expect(inlineNodes![0].node.attrs.id).toBe('test-id')
})
})
describe('querySelector', () => {
it('should return first matching node', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content:
'<p><span data-type="custom-inline" id="1"></span><span data-type="custom-inline" id="2"></span></p>',
})
const paragraph = editor.$node('paragraph')
const firstInline = paragraph!.querySelector('customInline')
expect(firstInline).not.toBeNull()
expect(firstInline!.node.attrs.id).toBe(1)
})
it('should return all matching nodes with querySelectorAll', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content:
'<p><span data-type="custom-inline" id="1"></span><span data-type="custom-inline" id="2"></span></p>',
})
const paragraph = editor.$node('paragraph')
const allInlines = paragraph!.querySelectorAll('customInline')
expect(allInlines.length).toBe(2)
})
it('should filter correctly with attributes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content:
'<p><span data-type="custom-inline" id="1"></span><span data-type="custom-inline" id="2"></span></p>',
})
const paragraph = editor.$node('paragraph')
const specific = paragraph!.querySelector('customInline', { id: 2 })
expect(specific).not.toBeNull()
expect(specific!.node.attrs.id).toBe(2)
})
})
describe('node position properties', () => {
it('should return correct from/to positions for inline nodes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content: '<p>Hi <span data-type="custom-inline" id="1"></span> there</p>',
})
const inlineNode = editor.$node('customInline')
expect(inlineNode).not.toBeNull()
expect(inlineNode!.from).toBeLessThan(inlineNode!.to)
expect(inlineNode!.size).toBeGreaterThan(0)
})
it('should return correct from/to positions for text nodes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
const textNode = editor.$node('text')
expect(textNode).not.toBeNull()
expect(textNode!.from).toBeLessThan(textNode!.to)
expect(textNode!.size).toBe(5) // "Hello" is 5 characters
})
it('should return correct range for nodes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
const paragraph = editor.$node('paragraph')
expect(paragraph).not.toBeNull()
expect(paragraph!.range.from).toBe(paragraph!.from)
expect(paragraph!.range.to).toBe(paragraph!.to)
})
})
describe('firstChild and lastChild', () => {
it('should return correct firstChild for paragraph with text', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello World</p>',
})
const paragraph = editor.$node('paragraph')
const firstChild = paragraph!.firstChild
expect(firstChild).not.toBeNull()
expect(firstChild!.node.type.name).toBe('text')
expect(firstChild!.textContent).toBe('Hello World')
})
it('should return correct lastChild for paragraph with mixed content', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content: '<p>Hello <span data-type="custom-inline" id="1"></span></p>',
})
const paragraph = editor.$node('paragraph')
const lastChild = paragraph!.lastChild
expect(lastChild).not.toBeNull()
expect(lastChild!.node.type.name).toBe('customInline')
})
it('should return null for firstChild/lastChild on empty paragraph', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p></p>',
})
const paragraph = editor.$node('paragraph')
expect(paragraph!.firstChild).toBeNull()
expect(paragraph!.lastChild).toBeNull()
})
})
describe('parent', () => {
it('should return parent for paragraph node', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
const paragraph = editor.$node('paragraph')
const parent = paragraph!.parent
expect(parent).not.toBeNull()
expect(parent!.node.type.name).toBe('doc')
})
it('should return parent for blockquote node', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Blockquote],
content: '<blockquote><p>Quoted</p></blockquote>',
})
const blockquote = editor.$node('blockquote')
const parent = blockquote!.parent
expect(parent).not.toBeNull()
expect(parent!.node.type.name).toBe('doc')
})
it('should return parent for nested paragraph in blockquote', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Blockquote],
content: '<blockquote><p>Quoted</p></blockquote>',
})
const paragraph = editor.$node('paragraph')
const parent = paragraph!.parent
expect(parent).not.toBeNull()
expect(parent!.node.type.name).toBe('blockquote')
})
})
describe('$pos', () => {
it('should return the correct node when pointing at a non-text atom node', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomInlineNode],
content: '<p><span data-type="custom-inline" id="atom"></span></p>',
})
const inlineNode = editor.$node('customInline')
expect(inlineNode).not.toBeNull()
const nodeAtPos = editor.$pos(inlineNode!.pos)
expect(nodeAtPos.node.type.name).toBe('customInline')
expect(nodeAtPos.node.type.name).not.toBe('doc')
})
it('should return doc node when resolving pos 0', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
const docPos = editor.$pos(0)
expect(docPos.node.type.name).toBe('doc')
})
it('should return the top-level block atom node for positions before non-text block atoms', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomBlockAtomNode],
content: '<p>Before</p><div data-type="custom-block-atom" id="top"></div>',
})
const blockAtom = editor.$node('customBlockAtom')
expect(blockAtom).not.toBeNull()
const nodeAtPos = editor.$pos(blockAtom!.pos)
expect(nodeAtPos.node.type.name).toBe('customBlockAtom')
expect(nodeAtPos.node.type.name).not.toBe('doc')
})
it('should return the containing node for positions inside containers, not the child after them', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, OrderedList, ListItem, Blockquote],
content: '<ol><li><p>Hello</p></li></ol><blockquote><p>World</p></blockquote>',
})
// pos 1 sits before the list item, pos 12 before the inner paragraph
expect(editor.$pos(1).node.type.name).toBe('orderedList')
expect(editor.$pos(12).node.type.name).toBe('blockquote')
})
it('should still resolve text positions to the containing node', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
expect(editor.$pos(3).node.type.name).toBe('paragraph')
})
it('should return the list for the boundary between two list items', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, OrderedList, ListItem],
content: '<ol><li><p>a</p></li><li><p>b</p></li></ol>',
})
expect(editor.$pos(6).node.type.name).toBe('orderedList')
})
it('should resolve positions in nested lists to the containing node', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem],
content: '<ul><li><p>a</p><ol><li><p>b</p></li></ol></li></ul>',
})
expect(editor.$pos(5).node.type.name).toBe('listItem')
expect(editor.$pos(6).node.type.name).toBe('orderedList')
expect(editor.$pos(7).node.type.name).toBe('listItem')
})
it('should resolve positions in tables to the containing node', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Table, TableRow, TableCell, TableHeader],
content: '<table><tr><td><p>a</p></td><td><p>b</p></td></tr></table>',
})
expect(editor.$pos(1).node.type.name).toBe('table')
expect(editor.$pos(2).node.type.name).toBe('tableRow')
expect(editor.$pos(3).node.type.name).toBe('tableCell')
})
it('should return leaf nodes like hard breaks when pointing directly at them', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, HardBreak],
content: '<p>Hello<br>World</p>',
})
expect(editor.$pos(6).node.type.name).toBe('hardBreak')
expect(editor.$pos(3).node.type.name).toBe('paragraph')
})
it('should return leaf nodes like horizontal rules when pointing directly at them', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, HorizontalRule],
content: '<p>a</p><hr><p>b</p>',
})
expect(editor.$pos(3).node.type.name).toBe('horizontalRule')
expect(editor.$pos(4).node.type.name).toBe('doc')
})
it('should return the doc for the position at the very end of the document', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hi</p>',
})
expect(editor.$pos(editor.state.doc.content.size).node.type.name).toBe('doc')
})
it('should return the following atom for the boundary between two block atoms', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, CustomBlockAtomNode],
content:
'<p>a</p><div data-type="custom-block-atom"></div><div data-type="custom-block-atom"></div>',
})
expect(editor.$pos(4).node.type.name).toBe('customBlockAtom')
})
it('should return inline atoms nested inside list items', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, ListItem, CustomInlineNode],
content: '<ul><li><p>x<span data-type="custom-inline"></span></p></li></ul>',
})
expect(editor.$pos(4).node.type.name).toBe('customInline')
})
})
describe('$doc', () => {
it('should correctly traverse $doc children', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>First</p><p>Second</p>',
})
const doc = editor.$doc
expect(doc).not.toBeNull()
expect(doc!.children.length).toBe(2)
expect(doc!.children[0].node.type.name).toBe('paragraph')
expect(doc!.children[1].node.type.name).toBe('paragraph')
})
it('should handle deeply nested structures', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Blockquote, CustomInlineNode],
content:
'<blockquote><p>Quote with <span data-type="custom-inline" id="deep"></span></p></blockquote>',
})
const inlineNode = editor.$node('customInline')
expect(inlineNode).not.toBeNull()
expect(inlineNode!.node.attrs.id).toBe('deep')
// Verify the structure
const blockquote = editor.$node('blockquote')
const paragraph = editor.$node('paragraph')
expect(blockquote).not.toBeNull()
expect(paragraph).not.toBeNull()
expect(paragraph!.parent!.node.type.name).toBe('blockquote')
})
})
})

View File

@@ -0,0 +1,270 @@
import type { Fragment, Node as PMNode, ResolvedPos } from '@tiptap/pm/model'
import type { Editor } from './Editor.js'
import type { Content, Range } from './types.js'
export class NodePos {
private resolvedPos: ResolvedPos
private isBlock: boolean
private editor: Editor
private get name(): string {
return this.node.type.name
}
constructor(pos: ResolvedPos, editor: Editor, isBlock = false, node: PMNode | null = null) {
this.isBlock = isBlock
this.resolvedPos = pos
this.editor = editor
this.currentNode = node
}
private currentNode: PMNode | null = null
get node(): PMNode {
return this.currentNode || this.resolvedPos.node()
}
get element(): HTMLElement {
return this.editor.view.domAtPos(this.pos).node as HTMLElement
}
public actualDepth: number | null = null
get depth(): number {
return this.actualDepth ?? this.resolvedPos.depth
}
get pos(): number {
return this.resolvedPos.pos
}
get content(): Fragment {
return this.node.content
}
set content(content: Content) {
let from = this.from
let to = this.to
if (this.isBlock) {
if (this.content.size === 0) {
console.error(
`You can’t set content on a block node. Tried to set content on ${this.name} at ${this.pos}`,
)
return
}
from = this.from + 1
to = this.to - 1
}
this.editor.commands.insertContentAt({ from, to }, content)
}
get attributes(): { [key: string]: any } {
return this.node.attrs
}
get textContent(): string {
return this.node.textContent
}
get size(): number {
return this.node.nodeSize
}
get from(): number {
if (this.isBlock) {
return this.pos
}
return this.resolvedPos.start(this.resolvedPos.depth)
}
get range(): Range {
return {
from: this.from,
to: this.to,
}
}
get to(): number {
if (this.isBlock) {
return this.pos + this.size
}
return this.resolvedPos.end(this.resolvedPos.depth) + (this.node.isText ? 0 : 1)
}
get parent(): NodePos | null {
if (this.depth === 0) {
return null
}
const parentPos = this.resolvedPos.start(this.resolvedPos.depth - 1)
const $pos = this.resolvedPos.doc.resolve(parentPos)
return new NodePos($pos, this.editor)
}
get before(): NodePos | null {
let $pos = this.resolvedPos.doc.resolve(this.from - (this.isBlock ? 1 : 2))
if ($pos.depth !== this.depth) {
$pos = this.resolvedPos.doc.resolve(this.from - 3)
}
return new NodePos($pos, this.editor)
}
get after(): NodePos | null {
let $pos = this.resolvedPos.doc.resolve(this.to + (this.isBlock ? 2 : 1))
if ($pos.depth !== this.depth) {
$pos = this.resolvedPos.doc.resolve(this.to + 3)
}
return new NodePos($pos, this.editor)
}
get children(): NodePos[] {
const children: NodePos[] = []
this.node.content.forEach((node, offset) => {
const isBlock = node.isBlock && !node.isTextblock
const isNonTextAtom = node.isAtom && !node.isText
const isInline = node.isInline
const targetPos = this.pos + offset + (isNonTextAtom ? 0 : 1)
// Check if targetPos is within valid document range
if (targetPos < 0 || targetPos > this.resolvedPos.doc.nodeSize - 2) {
return
}
const $pos = this.resolvedPos.doc.resolve(targetPos)
// Only apply depth check for non-block, non-inline nodes (i.e., textblocks)
// Inline nodes should always be included as children since we're iterating
// over direct children via this.node.content
if (!isBlock && !isInline && $pos.depth <= this.depth) {
return
}
// Pass the node for both block and inline nodes to ensure correct node reference
const childNodePos = new NodePos(
$pos,
this.editor,
isBlock,
isBlock || isInline ? node : null,
)
if (isBlock) {
childNodePos.actualDepth = this.depth + 1
}
children.push(childNodePos)
})
return children
}
get firstChild(): NodePos | null {
return this.children[0] || null
}
get lastChild(): NodePos | null {
const children = this.children
return children[children.length - 1] || null
}
closest(selector: string, attributes: { [key: string]: any } = {}): NodePos | null {
let node: NodePos | null = null
let currentNode = this.parent
while (currentNode && !node) {
if (currentNode.node.type.name === selector) {
if (Object.keys(attributes).length > 0) {
const nodeAttributes = currentNode.node.attrs
const attrKeys = Object.keys(attributes)
for (let index = 0; index < attrKeys.length; index += 1) {
const key = attrKeys[index]
if (nodeAttributes[key] !== attributes[key]) {
break
}
}
} else {
node = currentNode
}
}
currentNode = currentNode.parent
}
return node
}
querySelector(selector: string, attributes: { [key: string]: any } = {}): NodePos | null {
return this.querySelectorAll(selector, attributes, true)[0] || null
}
querySelectorAll(
selector: string,
attributes: { [key: string]: any } = {},
firstItemOnly = false,
): NodePos[] {
let nodes: NodePos[] = []
if (!this.children || this.children.length === 0) {
return nodes
}
const attrKeys = Object.keys(attributes)
/**
* Finds all children recursively that match the selector and attributes
* If firstItemOnly is true, it will return the first item found
*/
this.children.forEach(childPos => {
// If we already found a node and we only want the first item, we dont need to keep going
if (firstItemOnly && nodes.length > 0) {
return
}
if (childPos.node.type.name === selector) {
const doesAllAttributesMatch = attrKeys.every(
key => attributes[key] === childPos.node.attrs[key],
)
if (doesAllAttributesMatch) {
nodes.push(childPos)
}
}
// If we already found a node and we only want the first item, we can stop here and skip the recursion
if (firstItemOnly && nodes.length > 0) {
return
}
nodes = nodes.concat(childPos.querySelectorAll(selector, attributes, firstItemOnly))
})
return nodes
}
setAttribute(attributes: { [key: string]: any }) {
const { tr } = this.editor.state
tr.setNodeMarkup(this.from, undefined, {
...this.node.attrs,
...attributes,
})
this.editor.view.dispatch(tr)
}
}

View File

@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { createViewMutationTestContext } from '../tests/utils/createViewMutationTestContext.js'
import { NodeView } from './NodeView.js'
import type { NodeViewRendererProps } from './types.js'
const createProps = (getPos: () => number | undefined) => {
return {
editor: {},
extension: {},
node: {},
decorations: [],
innerDecorations: {},
view: {},
HTMLAttributes: {},
getPos,
} as unknown as NodeViewRendererProps
}
describe('NodeView getPos', () => {
it('returns the position from prosemirror', () => {
const nodeView = new NodeView(
null,
createProps(() => 7),
)
expect(nodeView.getPos()).toBe(7)
})
it('returns undefined instead of throwing while the view tree is mid-update', () => {
// prosemirror-view's posBeforeChild throws a TypeError when the node
// view desc is not attached to its parent yet.
const nodeView = new NodeView(
null,
createProps(() => {
throw new TypeError("Cannot read properties of undefined (reading 'size')")
}),
)
expect(nodeView.getPos()).toBeUndefined()
})
})
function createNodeView() {
const context = createViewMutationTestContext()
const { wrapper, contentDOM } = context
class TestNodeView extends NodeView<null> {
get dom() {
return wrapper
}
get contentDOM() {
return contentDOM
}
}
const nodeView = new TestNodeView(null, {
editor: { isFocused: true },
extension: {},
node: { isLeaf: false, isAtom: false },
decorations: [],
innerDecorations: {},
view: {},
HTMLAttributes: {},
getPos: () => 0,
} as unknown as NodeViewRendererProps)
return { nodeView, ...context }
}
describe('NodeView ignoreMutation on iOS', () => {
afterEach(() => {
document.body.replaceChildren()
vi.unstubAllGlobals()
})
it('ignores framework chrome mounted inside dom outside contentDOM', () => {
const { nodeView, chrome, createEditableChild, childListMutation } = createNodeView()
const mounted = createEditableChild()
chrome.append(mounted)
expect(nodeView.ignoreMutation(childListMutation(chrome, [mounted]))).toBe(true)
})
it('ignores a childList mutation whose target is dom itself', () => {
const { nodeView, wrapper, createEditableChild, childListMutation } = createNodeView()
const mounted = createEditableChild()
wrapper.append(mounted)
expect(nodeView.ignoreMutation(childListMutation(wrapper, [mounted]))).toBe(true)
})
it('lets ProseMirror handle childList mutations inside contentDOM', () => {
const { nodeView, contentDOM, createEditableChild, childListMutation } = createNodeView()
const edited = createEditableChild()
contentDOM.append(edited)
expect(nodeView.ignoreMutation(childListMutation(contentDOM, [edited]))).toBe(false)
})
})

View File

@@ -0,0 +1,361 @@
import { NodeSelection } from '@tiptap/pm/state'
import type { NodeView as ProseMirrorNodeView, ViewMutationRecord } from '@tiptap/pm/view'
import type { Editor as CoreEditor } from './Editor.js'
import type { DecorationWithType, NodeViewRendererOptions, NodeViewRendererProps } from './types.js'
import { isAndroid } from './utilities/isAndroid.js'
import { isiOS } from './utilities/isiOS.js'
/**
* Node views are used to customize the rendered DOM structure of a node.
* @see https://tiptap.dev/guide/node-views
*/
export class NodeView<
Component,
NodeEditor extends CoreEditor = CoreEditor,
Options extends NodeViewRendererOptions = NodeViewRendererOptions,
> implements ProseMirrorNodeView {
component: Component
editor: NodeEditor
options: Options
extension: NodeViewRendererProps['extension']
node: NodeViewRendererProps['node']
decorations: NodeViewRendererProps['decorations']
innerDecorations: NodeViewRendererProps['innerDecorations']
view: NodeViewRendererProps['view']
getPos: NodeViewRendererProps['getPos']
HTMLAttributes: NodeViewRendererProps['HTMLAttributes']
isDragging = false
constructor(component: Component, props: NodeViewRendererProps, options?: Partial<Options>) {
this.component = component
this.editor = props.editor as NodeEditor
this.options = {
stopEvent: null,
ignoreMutation: null,
...options,
} as Options
this.extension = props.extension
this.node = props.node
this.decorations = props.decorations as DecorationWithType[]
this.innerDecorations = props.innerDecorations
this.view = props.view
this.HTMLAttributes = props.HTMLAttributes
this.getPos = () => {
// ProseMirror throws while this node view is not attached to its parent yet.
try {
return props.getPos()
} catch {
return undefined
}
}
this.mount()
}
mount() {
// oxlint-disable-next-line
return
}
get dom(): HTMLElement {
return this.editor.view.dom as HTMLElement
}
get contentDOM(): HTMLElement | null {
return null
}
onDragStart(event: DragEvent) {
const { view } = this.editor
const target = event.target as HTMLElement
// get the drag handle element
// `closest` is not available for text nodes so we may have to use its parent
const dragHandle =
target.nodeType === 3
? target.parentElement?.closest('[data-drag-handle]')
: target.closest('[data-drag-handle]')
if (!this.dom || this.contentDOM?.contains(target) || !dragHandle) {
return
}
let x = 0
let y = 0
// calculate offset for drag element if we use a different drag handle element
if (this.dom !== dragHandle) {
const domBox = this.dom.getBoundingClientRect()
const handleBox = dragHandle.getBoundingClientRect()
// In React, we have to go through nativeEvent to reach offsetX/offsetY.
const offsetX = event.offsetX ?? (event as any).nativeEvent?.offsetX
const offsetY = event.offsetY ?? (event as any).nativeEvent?.offsetY
x = handleBox.x - domBox.x + offsetX
y = handleBox.y - domBox.y + offsetY
}
const clonedNode = this.dom.cloneNode(true) as HTMLElement
// Preserve the visual size of the original when using the clone as
// the drag image.
try {
const domBox = this.dom.getBoundingClientRect()
clonedNode.style.width = `${Math.round(domBox.width)}px`
clonedNode.style.height = `${Math.round(domBox.height)}px`
clonedNode.style.boxSizing = 'border-box'
// Ensure the clone doesn't capture pointer events while offscreen
clonedNode.style.pointerEvents = 'none'
} catch {
// ignore measurement errors (e.g. if element not in DOM)
}
// Some browsers (notably Safari) require the element passed to
// setDragImage to be present in the DOM. Using a detached node can
// cause the drag to immediately end.
let dragImageWrapper: HTMLElement | null = null
try {
dragImageWrapper = document.createElement('div')
dragImageWrapper.style.position = 'absolute'
dragImageWrapper.style.top = '-9999px'
dragImageWrapper.style.left = '-9999px'
dragImageWrapper.style.pointerEvents = 'none'
dragImageWrapper.appendChild(clonedNode)
document.body.appendChild(dragImageWrapper)
event.dataTransfer?.setDragImage(clonedNode, x, y)
} finally {
// Remove the wrapper on the next tick so the browser can use the
// element as the drag image. A 0ms timeout is enough in practice.
if (dragImageWrapper) {
setTimeout(() => {
try {
dragImageWrapper?.remove()
} catch {
// ignore removal errors
}
}, 0)
}
}
const pos = this.getPos()
if (typeof pos !== 'number') {
return
}
// we need to tell ProseMirror that we want to move the whole node
// so we create a NodeSelection
const selection = NodeSelection.create(view.state.doc, pos)
const transaction = view.state.tr.setSelection(selection)
view.dispatch(transaction)
}
stopEvent(event: Event) {
if (!this.dom) {
return false
}
if (typeof this.options.stopEvent === 'function') {
return this.options.stopEvent({ event })
}
const target = event.target as HTMLElement
const isInElement = this.dom.contains(target) && !this.contentDOM?.contains(target)
// any event from child nodes should be handled by ProseMirror
if (!isInElement) {
return false
}
const isDragEvent = event.type.startsWith('drag')
const isDragOverEnterEvent = event.type === 'dragover' || event.type === 'dragenter'
const isDropEvent = event.type === 'drop'
const isInput =
['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable
// any input event within node views should be ignored by ProseMirror
if (isInput && !isDropEvent && !isDragEvent) {
return true
}
const { isEditable } = this.editor
const { isDragging } = this
const isDraggable = !!this.node.type.spec.draggable
const isSelectable = NodeSelection.isSelectable(this.node)
const isCopyEvent = event.type === 'copy'
const isPasteEvent = event.type === 'paste'
const isCutEvent = event.type === 'cut'
const isClickEvent = event.type === 'mousedown'
// ProseMirror tries to drag selectable nodes
// even if `draggable` is set to `false`
// this fix prevents that
if (!isDraggable && isSelectable && isDragEvent && event.target === this.dom) {
event.preventDefault()
}
if (isDraggable && isDragEvent && !isDragging && event.target === this.dom) {
event.preventDefault()
return false
}
// we have to store that dragging started
if (isDraggable && isEditable && !isDragging && isClickEvent) {
const dragHandle = target.closest('[data-drag-handle]')
const isValidDragHandle =
dragHandle && (this.dom === dragHandle || this.dom.contains(dragHandle))
if (isValidDragHandle) {
this.isDragging = true
document.addEventListener(
'dragend',
() => {
this.isDragging = false
},
{ once: true },
)
document.addEventListener(
'drop',
() => {
this.isDragging = false
},
{ once: true },
)
document.addEventListener(
'mouseup',
() => {
this.isDragging = false
},
{ once: true },
)
}
}
// these events are handled by prosemirror
if (
isDragging ||
isDragOverEnterEvent ||
isDropEvent ||
isCopyEvent ||
isPasteEvent ||
isCutEvent ||
(isClickEvent && isSelectable)
) {
return false
}
return true
}
/**
* Called when a DOM [mutation](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) or a selection change happens within the view.
* @return `false` if the editor should re-read the selection or re-parse the range around the mutation
* @return `true` if it can safely be ignored.
*/
ignoreMutation(mutation: ViewMutationRecord) {
if (!this.dom || !this.contentDOM) {
return true
}
if (typeof this.options.ignoreMutation === 'function') {
return this.options.ignoreMutation({ mutation })
}
// a leaf/atom node is like a black box for ProseMirror
// and should be fully handled by the node view
if (this.node.isLeaf || this.node.isAtom) {
return true
}
// ProseMirror should handle any selections
if (mutation.type === 'selection') {
return false
}
// try to prevent a bug on iOS and Android that will break node views on enter
// this is because ProseMirror can’t preventDispatch on enter
// this will lead to a re-render of the node view on enter
// see: https://github.com/ueberdosis/tiptap/issues/1214
// see: https://github.com/ueberdosis/tiptap/issues/2534
if (
this.contentDOM.contains(mutation.target) &&
mutation.type === 'childList' &&
(isiOS() || isAndroid()) &&
this.editor.isFocused
) {
const changedNodes = [
...Array.from(mutation.addedNodes),
...Array.from(mutation.removedNodes),
] as HTMLElement[]
// we’ll check if every changed node is contentEditable
// to make sure it’s probably mutated by ProseMirror
if (changedNodes.every(node => node.isContentEditable)) {
return false
}
}
// we will allow mutation contentDOM with attributes
// so we can for example adding classes within our node view
if (this.contentDOM === mutation.target && mutation.type === 'attributes') {
return true
}
// ProseMirror should handle any changes within contentDOM
if (this.contentDOM.contains(mutation.target)) {
return false
}
return true
}
/**
* Update the attributes of the prosemirror node.
*/
updateAttributes(attributes: Record<string, any>): void {
this.editor.commands.command(({ tr }) => {
const pos = this.getPos()
if (typeof pos !== 'number') {
return false
}
tr.setNodeMarkup(pos, undefined, {
...this.node.attrs,
...attributes,
})
return true
})
}
/**
* Delete the node.
*/
deleteNode(): void {
const from = this.getPos()
if (typeof from !== 'number') {
return
}
const to = from + this.node.nodeSize
this.editor.commands.deleteRange({ from, to })
}
}

View File

@@ -0,0 +1,386 @@
import type { Node as PMNode } from '@tiptap/pm/model'
import { Fragment } from '@tiptap/pm/model'
import type { EditorState } from '@tiptap/pm/state'
import { Plugin } from '@tiptap/pm/state'
import { CommandManager } from './CommandManager.js'
import type { Editor } from './Editor.js'
import { createChainableState } from './helpers/createChainableState.js'
import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js'
import type {
CanCommands,
ChainedCommands,
ExtendedRegExpMatchArray,
Range,
SingleCommands,
} from './types.js'
import { isNumber } from './utilities/isNumber.js'
import { isRegExp } from './utilities/isRegExp.js'
export type PasteRuleMatch = {
index: number
text: string
replaceWith?: string
match?: RegExpMatchArray
data?: Record<string, any>
}
export type PasteRuleFinder =
| RegExp
| ((text: string, event?: ClipboardEvent | null) => PasteRuleMatch[] | null | undefined)
/**
* Paste rules are used to react to pasted content.
* @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#paste-rules
*/
export class PasteRule {
find: PasteRuleFinder
handler: (props: {
state: EditorState
range: Range
match: ExtendedRegExpMatchArray
commands: SingleCommands
chain: () => ChainedCommands
can: () => CanCommands
pasteEvent: ClipboardEvent | null
dropEvent: DragEvent | null
}) => void | null
constructor(config: {
find: PasteRuleFinder
handler: (props: {
can: () => CanCommands
chain: () => ChainedCommands
commands: SingleCommands
dropEvent: DragEvent | null
match: ExtendedRegExpMatchArray
pasteEvent: ClipboardEvent | null
range: Range
state: EditorState
}) => void | null
}) {
this.find = config.find
this.handler = config.handler
}
}
const pasteRuleMatcherHandler = (
text: string,
find: PasteRuleFinder,
event?: ClipboardEvent | null,
): ExtendedRegExpMatchArray[] => {
if (isRegExp(find)) {
return [...text.matchAll(find)]
}
const matches = find(text, event)
if (!matches) {
return []
}
return matches.map(pasteRuleMatch => {
const result: ExtendedRegExpMatchArray = [pasteRuleMatch.text]
result.index = pasteRuleMatch.index
result.input = text
result.data = pasteRuleMatch.data
if (pasteRuleMatch.replaceWith) {
if (!pasteRuleMatch.text.includes(pasteRuleMatch.replaceWith)) {
console.warn(
'[tiptap warn]: "pasteRuleMatch.replaceWith" must be part of "pasteRuleMatch.text".',
)
}
result.push(pasteRuleMatch.replaceWith)
}
return result
})
}
function run(config: {
editor: Editor
state: EditorState
from: number
to: number
rule: PasteRule
pasteEvent: ClipboardEvent | null
dropEvent: DragEvent | null
}): boolean {
const { editor, state, from, to, rule, pasteEvent, dropEvent } = config
const { commands, chain, can } = new CommandManager({
editor,
state,
})
const handlers: (void | null)[] = []
state.doc.nodesBetween(from, to, (node, pos) => {
// Skip code blocks and non-textual nodes.
// Be defensive: `node` may be a Fragment without a `type`. Only text,
// inline, or textblock nodes are processed by paste rules.
if (node.type?.spec?.code || !(node.isText || node.isTextblock || node.isInline)) {
return
}
// For textblock and inline/text nodes, compute the range relative to the node.
// Prefer `node.nodeSize` when available (some Node shapes expose this),
// otherwise fall back to `node.content?.size`. Default to 0 if neither exists.
const contentSize = node.content?.size ?? node.nodeSize ?? 0
const resolvedFrom = Math.max(from, pos)
const resolvedTo = Math.min(to, pos + contentSize)
// If the resolved range is empty or invalid for this node, skip it. This
// avoids calling `textBetween` with start > end which can cause internal
// Fragment/Node traversal to access undefined `nodeSize` values.
if (resolvedFrom >= resolvedTo) {
return
}
const textToMatch = node.isText
? node.text || ''
: node.textBetween(resolvedFrom - pos, resolvedTo - pos, undefined, '\ufffc')
const matches = pasteRuleMatcherHandler(textToMatch, rule.find, pasteEvent)
matches.forEach(match => {
if (match.index === undefined) {
return
}
const start = resolvedFrom + match.index + 1
const end = start + match[0].length
const range = {
from: state.tr.mapping.map(start),
to: state.tr.mapping.map(end),
}
const handler = rule.handler({
state,
range,
match,
commands,
chain,
can,
pasteEvent,
dropEvent,
})
handlers.push(handler)
})
})
const success = handlers.every(handler => handler !== null)
return success
}
// When dragging across editors, must get another editor instance to delete selection content.
let tiptapDragFromOtherEditor: Editor | null = null
const createClipboardPasteEvent = (text: string) => {
const event = new ClipboardEvent('paste', {
clipboardData: new DataTransfer(),
})
event.clipboardData?.setData('text/html', text)
return event
}
/**
* Create an paste rules plugin. When enabled, it will cause pasted
* text that matches any of the given rules to trigger the rule’s
* action.
*/
export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }): Plugin[] {
const { editor, rules } = props
let dragSourceElement: Element | null = null
let isPastedFromProseMirror = false
let isDroppedFromProseMirror = false
let pasteEvent = typeof ClipboardEvent !== 'undefined' ? new ClipboardEvent('paste') : null
let dropEvent: DragEvent | null
try {
dropEvent = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : null
} catch {
dropEvent = null
}
const processEvent = ({
state,
from,
to,
rule,
pasteEvt,
}: {
state: EditorState
from: number
to: { b: number }
rule: PasteRule
pasteEvt: ClipboardEvent | null
}) => {
const tr = state.tr
const chainableState = createChainableState({
state,
transaction: tr,
})
const handler = run({
editor,
state: chainableState,
from: Math.max(from - 1, 0),
to: to.b - 1,
rule,
pasteEvent: pasteEvt,
dropEvent,
})
if (!handler || !tr.steps.length) {
return
}
try {
dropEvent = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : null
} catch {
dropEvent = null
}
pasteEvent = typeof ClipboardEvent !== 'undefined' ? new ClipboardEvent('paste') : null
return tr
}
const plugins = rules.map(rule => {
return new Plugin({
// we register a global drag handler to track the current drag source element
view(view) {
const handleDragstart = (event: DragEvent) => {
dragSourceElement = view.dom.parentElement?.contains(event.target as Element)
? view.dom.parentElement
: null
if (dragSourceElement) {
tiptapDragFromOtherEditor = editor
}
}
const handleDragend = () => {
if (tiptapDragFromOtherEditor) {
tiptapDragFromOtherEditor = null
}
}
window.addEventListener('dragstart', handleDragstart)
window.addEventListener('dragend', handleDragend)
return {
destroy() {
window.removeEventListener('dragstart', handleDragstart)
window.removeEventListener('dragend', handleDragend)
},
}
},
props: {
handleDOMEvents: {
drop: (view, event: Event) => {
isDroppedFromProseMirror = dragSourceElement === view.dom.parentElement
dropEvent = event as DragEvent
if (!isDroppedFromProseMirror) {
const dragFromOtherEditor = tiptapDragFromOtherEditor
if (dragFromOtherEditor?.isEditable) {
// setTimeout to avoid the wrong content after drop, timeout arg can't be empty or 0
setTimeout(() => {
const selection = dragFromOtherEditor.state.selection
if (selection) {
dragFromOtherEditor.commands.deleteRange({
from: selection.from,
to: selection.to,
})
}
}, 10)
}
}
return false
},
paste: (_view, event: Event) => {
const html = (event as ClipboardEvent).clipboardData?.getData('text/html')
pasteEvent = event as ClipboardEvent
isPastedFromProseMirror = !!html?.includes('data-pm-slice')
return false
},
},
},
appendTransaction: (transactions, oldState, state) => {
const transaction = transactions[0]
const isPaste = transaction.getMeta('uiEvent') === 'paste' && !isPastedFromProseMirror
const isDrop = transaction.getMeta('uiEvent') === 'drop' && !isDroppedFromProseMirror
// if PasteRule is triggered by insertContent()
const simulatedPasteMeta = transaction.getMeta('applyPasteRules') as
| undefined
| { from: number; text: string | PMNode | Fragment }
const isSimulatedPaste = !!simulatedPasteMeta
if (!isPaste && !isDrop && !isSimulatedPaste) {
return
}
// Handle simulated paste
if (isSimulatedPaste) {
let { text } = simulatedPasteMeta
if (typeof text === 'string') {
text = text as string
} else {
text = getHTMLFromFragment(Fragment.from(text), state.schema)
}
const { from } = simulatedPasteMeta
const to = from + text.length
const pasteEvt = createClipboardPasteEvent(text)
return processEvent({
rule,
state,
from,
to: { b: to },
pasteEvt,
})
}
// handle actual paste/drop
const from = oldState.doc.content.findDiffStart(state.doc.content)
const to = oldState.doc.content.findDiffEnd(state.doc.content)
// stop if there is no changed range
if (!isNumber(from) || !to || from === to.b) {
return
}
return processEvent({
rule,
state,
from,
to,
pasteEvt: pasteEvent,
})
},
})
})
return plugins
}

View File

@@ -0,0 +1,38 @@
import type { Transaction } from '@tiptap/pm/state'
export interface TrackerResult {
position: number
deleted: boolean
}
export class Tracker {
transaction: Transaction
currentStep: number
constructor(transaction: Transaction) {
this.transaction = transaction
this.currentStep = this.transaction.steps.length
}
map(position: number): TrackerResult {
let deleted = false
const mappedPosition = this.transaction.steps
.slice(this.currentStep)
.reduce((newPosition, step) => {
const mapResult = step.getMap().mapResult(newPosition)
if (mapResult.deleted) {
deleted = true
}
return mapResult.pos
}, position)
return {
position: mappedPosition,
deleted,
}
}
}

View File

@@ -0,0 +1,29 @@
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
blur: {
/**
* Removes focus from the editor.
* @example editor.commands.blur()
*/
blur: () => ReturnType
}
}
}
export const blur: RawCommands['blur'] =
() =>
({ editor, view }) => {
requestAnimationFrame(() => {
if (!editor.isDestroyed) {
;(view.dom as HTMLElement).blur()
// Browsers should remove the caret on blur but safari does not.
// See: https://github.com/ueberdosis/tiptap/issues/2405
window?.getSelection()?.removeAllRanges()
}
})
return true
}

View File

@@ -0,0 +1,25 @@
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
clearContent: {
/**
* Clear the whole document.
* @example editor.commands.clearContent()
*/
clearContent: (
/**
* Whether to emit an update event.
* @default true
*/
emitUpdate?: boolean,
) => ReturnType
}
}
}
export const clearContent: RawCommands['clearContent'] =
(emitUpdate = true) =>
({ commands }) => {
return commands.setContent('', { emitUpdate })
}

View File

@@ -0,0 +1,57 @@
import { liftTarget } from '@tiptap/pm/transform'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
clearNodes: {
/**
* Normalize nodes to a simple paragraph.
* @example editor.commands.clearNodes()
*/
clearNodes: () => ReturnType
}
}
}
export const clearNodes: RawCommands['clearNodes'] =
() =>
({ state, tr, dispatch }) => {
const { selection } = tr
const { ranges } = selection
if (!dispatch) {
return true
}
ranges.forEach(({ $from, $to }) => {
state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.type.isText) {
return
}
const { doc, mapping } = tr
const $mappedFrom = doc.resolve(mapping.map(pos))
const $mappedTo = doc.resolve(mapping.map(pos + node.nodeSize))
const nodeRange = $mappedFrom.blockRange($mappedTo)
if (!nodeRange) {
return
}
const targetLiftDepth = liftTarget(nodeRange)
if (node.type.isTextblock) {
const { defaultType } = $mappedFrom.parent.contentMatchAt($mappedFrom.index())
tr.setNodeMarkup(nodeRange.start, defaultType)
}
if (targetLiftDepth || targetLiftDepth === 0) {
tr.lift(nodeRange, targetLiftDepth)
}
})
})
return true
}

View File

@@ -0,0 +1,22 @@
import type { Command, RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
command: {
/**
* Define a command inline.
* @param fn The command function.
* @example
* editor.commands.command(({ tr, state }) => {
* ...
* return true
* })
*/
command: (fn: (props: Parameters<Command>[0]) => boolean) => ReturnType
}
}
}
export const command: RawCommands['command'] = fn => props => {
return fn(props)
}

View File

@@ -0,0 +1,21 @@
import { createParagraphNear as originalCreateParagraphNear } from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
createParagraphNear: {
/**
* Create a paragraph nearby.
* @example editor.commands.createParagraphNear()
*/
createParagraphNear: () => ReturnType
}
}
}
export const createParagraphNear: RawCommands['createParagraphNear'] =
() =>
({ state, dispatch }) => {
return originalCreateParagraphNear(state, dispatch)
}

View File

@@ -0,0 +1,38 @@
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { Editor } from '../Editor.js'
describe('cut command', () => {
const createEditor = () => {
const element = document.createElement('div')
document.body.appendChild(element)
return new Editor({
element,
extensions: [Document, Paragraph, Text],
content: '<p>Hello world</p>',
})
}
it('cuts content to start of document', () => {
const editor = createEditor()
editor.commands.cut({ from: 7, to: 12 }, 1)
expect(editor.getHTML()).toBe('<p>worldHello </p>')
editor.destroy()
})
it('cuts content to end of document', () => {
const editor = createEditor()
editor.commands.cut({ from: 1, to: 6 }, editor.state.doc.nodeSize - 2)
expect(editor.getHTML()).toBe('<p> world</p><p>Hello</p>')
editor.destroy()
})
})

View File

@@ -0,0 +1,36 @@
import { TextSelection } from '@tiptap/pm/state'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
cut: {
/**
* Cuts content from a range and inserts it at a given position.
* @param range The range to cut.
* @param range.from The start position of the range.
* @param range.to The end position of the range.
* @param targetPos The position to insert the content at.
* @example editor.commands.cut({ from: 1, to: 3 }, 5)
*/
cut: ({ from, to }: { from: number; to: number }, targetPos: number) => ReturnType
}
}
}
export const cut: RawCommands['cut'] =
(originRange, targetPos) =>
({ editor, tr }) => {
const { state } = editor
const contentSlice = state.doc.slice(originRange.from, originRange.to)
tr.deleteRange(originRange.from, originRange.to)
const newPos = tr.mapping.map(targetPos)
tr.insert(newPos, contentSlice.content)
tr.setSelection(new TextSelection(tr.doc.resolve(Math.max(newPos - 1, 0))))
return true
}

View File

@@ -0,0 +1,44 @@
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
deleteCurrentNode: {
/**
* Delete the node that currently has the selection anchor.
* @example editor.commands.deleteCurrentNode()
*/
deleteCurrentNode: () => ReturnType
}
}
}
export const deleteCurrentNode: RawCommands['deleteCurrentNode'] =
() =>
({ tr, dispatch }) => {
const { selection } = tr
const currentNode = selection.$anchor.node()
// if there is content inside the current node, break out of this command
if (currentNode.content.size > 0) {
return false
}
const $pos = tr.selection.$anchor
for (let depth = $pos.depth; depth > 0; depth -= 1) {
const node = $pos.node(depth)
if (node.type === currentNode.type) {
if (dispatch) {
const from = $pos.before(depth)
const to = $pos.after(depth)
tr.delete(from, to).scrollIntoView()
}
return true
}
}
return false
}

View File

@@ -0,0 +1,41 @@
import type { NodeType } from '@tiptap/pm/model'
import { getNodeType } from '../helpers/getNodeType.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
deleteNode: {
/**
* Delete a node with a given type or name.
* @param typeOrName The type or name of the node.
* @example editor.commands.deleteNode('paragraph')
*/
deleteNode: (typeOrName: string | NodeType) => ReturnType
}
}
}
export const deleteNode: RawCommands['deleteNode'] =
typeOrName =>
({ tr, state, dispatch }) => {
const type = getNodeType(typeOrName, state.schema)
const $pos = tr.selection.$anchor
for (let depth = $pos.depth; depth > 0; depth -= 1) {
const node = $pos.node(depth)
if (node.type === type) {
if (dispatch) {
const from = $pos.before(depth)
const to = $pos.after(depth)
tr.delete(from, to).scrollIntoView()
}
return true
}
}
return false
}

View File

@@ -0,0 +1,26 @@
import type { Range, RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
deleteRange: {
/**
* Delete a given range.
* @param range The range to delete.
* @example editor.commands.deleteRange({ from: 1, to: 3 })
*/
deleteRange: (range: Range) => ReturnType
}
}
}
export const deleteRange: RawCommands['deleteRange'] =
range =>
({ tr, dispatch }) => {
const { from, to } = range
if (dispatch) {
tr.delete(from, to)
}
return true
}

View File

@@ -0,0 +1,125 @@
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { AllSelection } from '@tiptap/pm/state'
import { describe, expect, it } from 'vite-plus/test'
import { Editor } from '../Editor.js'
import { Node } from '../Node.js'
const TestInlineNode = Node.create({
name: 'testInlineNode',
group: 'inline',
inline: true,
content: 'text*',
renderHTML() {
return ['span', { 'data-test-node': '' }, 0]
},
parseHTML() {
return [
{
tag: 'span[data-test-node]',
},
]
},
addNodeView() {
return () => {
const dom = document.createElement('span')
dom.dataset.testNode = 'true'
const contentDOM = document.createElement('span')
dom.appendChild(contentDOM)
return { dom, contentDOM }
}
},
})
describe('deleteSelection', () => {
it('should return false for empty selection', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>hello</p>',
})
editor.commands.setTextSelection({ from: 1, to: 1 })
const result = editor.commands.deleteSelection()
expect(result).toBe(false)
editor.destroy()
})
it('should delete selected text', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>hello world</p>',
})
editor.chain().setTextSelection({ from: 1, to: 7 }).deleteSelection().run()
expect(editor.getHTML()).toBe('<p>world</p>')
editor.destroy()
})
it('should delete selection across two paragraphs', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>one</p><p>two</p>',
})
editor.chain().setTextSelection({ from: 2, to: 7 }).deleteSelection().run()
expect(editor.getHTML()).toBe('<p>owo</p>')
editor.destroy()
})
it('should delete entire inline node with text* when selecting inside it', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, TestInlineNode],
content: '<p>before <span data-test-node>test</span> after</p>',
})
editor.chain().setTextSelection({ from: 9, to: 13 }).deleteSelection().run()
expect(editor.getHTML()).toBe('<p>before after</p>')
editor.destroy()
})
it('should keep inline node when selecting only half of its text content', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, TestInlineNode],
content: '<p>before <span data-test-node>test</span> after</p>',
})
editor.chain().setTextSelection({ from: 9, to: 11 }).deleteSelection().run()
expect(editor.getHTML()).toBe('<p>before <span data-test-node="">st</span> after</p>')
editor.destroy()
})
it('collapses the selection to a cursor after deleting an AllSelection', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>hello world</p>',
})
editor.chain().selectAll().deleteSelection().run()
expect(editor.getHTML()).toBe('<p></p>')
expect(editor.state.selection.empty).toBe(true)
expect(editor.state.selection instanceof AllSelection).toBe(false)
editor.destroy()
})
it('should delete selection spanning around entire inline node', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, TestInlineNode],
content: '<p>before <span data-test-node>test</span> after</p>',
})
editor.chain().setTextSelection({ from: 4, to: 17 }).deleteSelection().run()
expect(editor.getHTML()).toBe('<p>befter</p>')
editor.destroy()
})
})

View File

@@ -0,0 +1,109 @@
import type { ResolvedPos, Schema } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import type { RawCommands } from '../types.js'
/**
* Check if a node has text content based on its content specification.
* Returns true if the node's content spec matches text* or text+ patterns.
*/
const hasTextContent = (nodeSpec: { content?: string }): boolean => {
if (!nodeSpec.content) {
return false
}
const textRegex = /^text(\*|\+)/
return textRegex.test(nodeSpec.content)
}
/**
* Expand selection position for a specific side (left or right) to handle inline text nodes.
* This function checks if the position is within an inline node with text content and
* expands it to include the entire node boundaries for proper deletion.
* @param $pos - The resolved position to expand
* @param schema - The ProseMirror schema
* @param side - Which side to expand ('left' or 'right')
* @returns The expanded position for deletion
*/
const expandSelectionForSide = (
$pos: ResolvedPos,
schema: Schema,
side: 'left' | 'right',
): number => {
if (!$pos.parent.isInline) {
return $pos.pos
}
if ((side === 'left' && $pos.pos > $pos.start()) || (side === 'right' && $pos.pos < $pos.end())) {
return $pos.pos
}
const parentContent = schema.nodes[$pos.parent.type.name].spec
if (!hasTextContent(parentContent)) {
return $pos.pos
}
return side === 'left' ? $pos.start() - 1 : $pos.end() + 1
}
/**
* Expand selection range to properly handle deletion of inline text nodes.
* Inline text nodes don't collapse correctly when text inside is deleted,
* so we need to expand the selection to include the entire node.
* See: https://code.haverbeke.berlin/prosemirror/prosemirror/issues/1365
*/
const expandSelectionForInlineText = (
$from: ResolvedPos,
$to: ResolvedPos,
schema: Schema,
): { from: number; to: number } => {
const from = expandSelectionForSide($from, schema, 'left')
const to = expandSelectionForSide($to, schema, 'right')
return { from, to }
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
deleteSelection: {
/**
* Delete the selection, if there is one.
* @example editor.commands.deleteSelection()
*/
deleteSelection: () => ReturnType
}
}
}
export const deleteSelection: RawCommands['deleteSelection'] =
() =>
({ state, dispatch }) => {
if (state.selection.empty) {
return false
}
if (dispatch) {
const tr = state.tr
const { ranges } = state.selection
const mapFrom = tr.steps.length
ranges.forEach(range => {
const mapping = tr.mapping.slice(mapFrom)
const $from = tr.doc.resolve(mapping.map(range.$from.pos))
const $to = tr.doc.resolve(mapping.map(range.$to.pos))
const { from, to } = expandSelectionForInlineText($from, $to, state.schema)
tr.deleteRange(from, to)
})
// after deleting the selection we restore a text selection
// near the from position to avoid having confusing selections
// after the deletion (for example after deleting via Ctrl/Cmd+A)
if (!tr.selection.empty) {
tr.setSelection(TextSelection.near(tr.doc.resolve(tr.selection.from)))
}
tr.scrollIntoView()
dispatch(tr)
}
return true
}

View File

@@ -0,0 +1,19 @@
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
enter: {
/**
* Trigger enter.
* @example editor.commands.enter()
*/
enter: () => ReturnType
}
}
}
export const enter: RawCommands['enter'] =
() =>
({ commands }) => {
return commands.keyboardShortcut('Enter')
}

View File

@@ -0,0 +1,21 @@
import { exitCode as originalExitCode } from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
exitCode: {
/**
* Exit from a code block.
* @example editor.commands.exitCode()
*/
exitCode: () => ReturnType
}
}
}
export const exitCode: RawCommands['exitCode'] =
() =>
({ state, dispatch }) => {
return originalExitCode(state, dispatch)
}

View File

@@ -0,0 +1,455 @@
import Document from '@tiptap/extension-document'
import Highlight from '@tiptap/extension-highlight'
import Link from '@tiptap/extension-link'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { Editor } from '../Editor.js'
describe('extendMarkRange', () => {
it('should extend full mark', () => {
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text',
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'foo',
},
},
],
},
{
type: 'text',
text: 'text',
},
],
},
],
}
const editor = new Editor({
content,
extensions: [Document, Paragraph, Text, Link],
})
// debug
// console.log(getDebugJSON(editor.state.doc))
// set cursor in middle of first mark
editor.chain().setTextSelection({ from: 7, to: 7 }).extendMarkRange('link').run()
const { from, to } = editor.state.selection
const expectedSelection = {
from: 5,
to: 9,
}
expect({ from, to }).toEqual(expectedSelection)
editor.destroy()
})
it('should extend to mark with specific attributes', () => {
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text',
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'foo',
},
},
],
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'bar',
},
},
],
},
{
type: 'text',
text: 'text',
},
],
},
],
}
const editor = new Editor({
content,
extensions: [Document, Paragraph, Text, Link],
})
// debug
// console.log(getDebugJSON(editor.state.doc))
// set cursor in middle of first mark
editor
.chain()
.setTextSelection({ from: 7, to: 7 })
.extendMarkRange('link', {
href: 'foo',
})
.run()
const { from, to } = editor.state.selection
const expectedSelection = {
from: 5,
to: 9,
}
expect({ from, to }).toEqual(expectedSelection)
editor.destroy()
})
it('should not extend at all if selection contains no mark', () => {
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text',
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'foo',
},
},
],
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'bar',
},
},
],
},
{
type: 'text',
text: 'text',
},
],
},
],
}
const editor = new Editor({
content,
extensions: [Document, Paragraph, Text, Link],
})
// debug
// console.log(getDebugJSON(editor.state.doc))
// set cursor before any mark
editor.chain().setTextSelection({ from: 2, to: 2 }).extendMarkRange('link').run()
const { from, to } = editor.state.selection
const expectedSelection = {
from: 2,
to: 2,
}
expect({ from, to }).toEqual(expectedSelection)
editor.destroy()
})
it('should not extend at all if selection contains any non-matching mark', () => {
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text',
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'foo',
},
},
],
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'bar',
},
},
],
},
{
type: 'text',
text: 'text',
},
],
},
],
}
const editor = new Editor({
content,
extensions: [Document, Paragraph, Text, Link],
})
// debug
// console.log(getDebugJSON(editor.state.doc))
// set cursor before across non-matching marks
editor
.chain()
.setTextSelection({ from: 7, to: 11 })
.extendMarkRange('link', {
href: 'foo',
})
.run()
const { from, to } = editor.state.selection
const expectedSelection = {
from: 7,
to: 11,
}
expect({ from, to }).toEqual(expectedSelection)
editor.destroy()
})
it('should not extend across adjacent marks with different attributes when attributes is omitted', () => {
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text',
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'foo',
},
},
],
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'bar',
},
},
],
},
{
type: 'text',
text: 'text',
},
],
},
],
}
const editor = new Editor({
content,
extensions: [Document, Paragraph, Text, Link],
})
// set cursor in middle of first link mark
editor.chain().setTextSelection({ from: 7, to: 7 }).extendMarkRange('link').run()
const { from, to } = editor.state.selection
// should only extend to cover the link with href='foo', not the adjacent link with href='bar'
const expectedSelection = {
from: 5,
to: 9,
}
expect({ from, to }).toEqual(expectedSelection)
editor.destroy()
})
it('should default to attributes of the first mark of the given type', () => {
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Highlighted ',
marks: [
{ type: 'highlight', attrs: { color: 'red' } },
{ type: 'link', attrs: { href: 'https://tiptap.dev' } },
],
},
{
type: 'text',
text: 'link.',
marks: [{ type: 'link', attrs: { href: 'https://tiptap.dev' } }],
},
{
type: 'text',
text: 'another link',
marks: [{ type: 'link', attrs: { href: 'https://tiptap.dev/invalid' } }],
},
],
},
],
}
const editor = new Editor({
content,
extensions: [Document, Paragraph, Text, Highlight.configure({ multicolor: true }), Link],
})
// set cursor inside 'Highlighted ' — should extend to cover 'Highlighted link.'
// but not 'another link' (different href), and should not be confused by the
// highlight mark sharing the first text node even though that is the first mark
editor.chain().setTextSelection({ from: 5, to: 5 }).extendMarkRange('link').run()
const { from, to } = editor.state.selection
expect({ from, to }).toEqual({ from: 1, to: 18 })
editor.destroy()
})
it('should extend across adjacent marks with different attributes when attributes is {}', () => {
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text',
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'foo',
},
},
],
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'bar',
},
},
],
},
{
type: 'text',
text: 'text',
},
],
},
],
}
const editor = new Editor({
content,
extensions: [Document, Paragraph, Text, Link],
})
// set cursor in middle of first mark
editor.chain().setTextSelection({ from: 7, to: 7 }).extendMarkRange('link', {}).run()
const { from, to } = editor.state.selection
// should extend to cover both links
const expectedSelection = {
from: 5,
to: 13,
}
expect({ from, to }).toEqual(expectedSelection)
editor.destroy()
})
})

View File

@@ -0,0 +1,52 @@
import type { MarkType } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import { getMarkRange } from '../helpers/getMarkRange.js'
import { getMarkType } from '../helpers/getMarkType.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
extendMarkRange: {
/**
* Extends the text selection to the current mark by type or name.
* @param typeOrName The type or name of the mark.
* @param attributes The attributes to match against.
* If not provided, only the first mark at the position will be matched.
* @example editor.commands.extendMarkRange('bold')
* @example editor.commands.extendMarkRange('mention', { userId: "1" })
*/
extendMarkRange: (
/**
* The type or name of the mark.
*/
typeOrName: string | MarkType,
/**
* The attributes of the mark.
*/
attributes?: Record<string, any>,
) => ReturnType
}
}
}
export const extendMarkRange: RawCommands['extendMarkRange'] =
(typeOrName, attributes) =>
({ tr, state, dispatch }) => {
const type = getMarkType(typeOrName, state.schema)
const { doc, selection } = tr
const { $from, from, to } = selection
if (dispatch) {
const range = getMarkRange($from, type, attributes)
if (range && range.from <= from && range.to >= to) {
const newSelection = TextSelection.create(doc, range.from, range.to)
tr.setSelection(newSelection)
}
}
return true
}

View File

@@ -0,0 +1,26 @@
import type { Command, CommandProps, RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
first: {
/**
* Runs one command after the other and stops at the first which returns true.
* @param commands The commands to run.
* @example editor.commands.first([command1, command2])
*/
first: (commands: Command[] | ((props: CommandProps) => Command[])) => ReturnType
}
}
}
export const first: RawCommands['first'] = commands => props => {
const items = typeof commands === 'function' ? commands(props) : commands
for (let i = 0; i < items.length; i += 1) {
if (items[i](props)) {
return true
}
}
return false
}

View File

@@ -0,0 +1,108 @@
import { isTextSelection } from '../helpers/isTextSelection.js'
import { resolveFocusPosition } from '../helpers/resolveFocusPosition.js'
import type { FocusPosition, RawCommands } from '../types.js'
import { isAndroid } from '../utilities/isAndroid.js'
import { isiOS } from '../utilities/isiOS.js'
import { isSafari } from '../utilities/isSafari.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
focus: {
/**
* Focus the editor at the given position.
* @param position The position to focus at.
* @param options.scrollIntoView Scroll the focused position into view after focusing
* @example editor.commands.focus()
* @example editor.commands.focus(32, { scrollIntoView: false })
*/
focus: (
/**
* The position to focus at.
*/
position?: FocusPosition,
/**
* Optional options
* @default { scrollIntoView: true }
*/
options?: {
scrollIntoView?: boolean
},
) => ReturnType
}
}
}
export const focus: RawCommands['focus'] =
(position = null, options = {}) =>
({ editor, view, tr, dispatch }) => {
options = {
scrollIntoView: true,
...options,
}
const delayedFocus = () => {
// focus within `requestAnimationFrame` breaks focus on iOS and Android
// so we have to call this
if (isiOS() || isAndroid()) {
;(view.dom as HTMLElement).focus()
}
// Safari requires preventScroll to avoid the browser scrolling to the
// top of the editor when focus is called before the selection is set.
// We exclude iOS and Android since they are already handled above.
// see: https://github.com/ueberdosis/tiptap/issues/7318
if (isSafari() && !isiOS() && !isAndroid()) {
;(view.dom as HTMLElement).focus({ preventScroll: true })
}
// For React we have to focus asynchronously. Otherwise wild things happen.
// see: https://github.com/ueberdosis/tiptap/issues/1520
requestAnimationFrame(() => {
if (!editor.isDestroyed) {
view.focus()
if (options?.scrollIntoView) {
editor.commands.scrollIntoView()
}
}
})
}
try {
if ((view.hasFocus() && position === null) || position === false) {
return true
}
} catch {
// if view.hasFocus fails (view not mounted yet)
// we will return false because there's nothing to focus
return false
}
// we don’t try to resolve a NodeSelection or CellSelection
if (dispatch && position === null && !isTextSelection(editor.state.selection)) {
delayedFocus()
return true
}
// pass through tr.doc instead of editor.state.doc
// since transactions could change the editors state before this command has been run
const selection = resolveFocusPosition(tr.doc, position) || editor.state.selection
const isSameSelection = editor.state.selection.eq(selection)
if (dispatch) {
if (!isSameSelection) {
tr.setSelection(selection)
}
// `tr.setSelection` resets the stored marks
// so we’ll restore them if the selection is the same as before
if (isSameSelection && tr.storedMarks) {
tr.setStoredMarks(tr.storedMarks)
}
delayedFocus()
}
return true
}

View File

@@ -0,0 +1,24 @@
import type { CommandProps, RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
forEach: {
/**
* Loop through an array of items.
*/
forEach: <T>(
items: T[],
fn: (
item: T,
props: CommandProps & {
index: number
},
) => boolean,
) => ReturnType
}
}
}
export const forEach: RawCommands['forEach'] = (items, fn) => props => {
return items.every((item, index) => fn(item, { ...props, index }))
}

View File

@@ -0,0 +1,59 @@
export * from './blur.js'
export * from './clearContent.js'
export * from './clearNodes.js'
export * from './command.js'
export * from './createParagraphNear.js'
export * from './cut.js'
export * from './deleteCurrentNode.js'
export * from './deleteNode.js'
export * from './deleteRange.js'
export * from './deleteSelection.js'
export * from './enter.js'
export * from './exitCode.js'
export * from './extendMarkRange.js'
export * from './first.js'
export * from './focus.js'
export * from './forEach.js'
export * from './insertContent.js'
export * from './insertContentAt.js'
export * from './insertDefaultBlock.js'
export * from './join.js'
export * from './joinItemBackward.js'
export * from './joinItemForward.js'
export * from './joinTextblockBackward.js'
export * from './joinTextblockForward.js'
export * from './keyboardShortcut.js'
export * from './lift.js'
export * from './liftEmptyBlock.js'
export * from './liftListItem.js'
export * from './newlineInCode.js'
export * from './resetAttributes.js'
export * from './scrollIntoView.js'
export * from './selectAll.js'
export * from './selectNodeBackward.js'
export * from './selectNodeForward.js'
export * from './selectParentNode.js'
export * from './selectTextblockEnd.js'
export * from './selectTextblockStart.js'
export * from './setContent.js'
export * from './setMark.js'
export * from './setMeta.js'
export * from './setNode.js'
export * from './setNodeSelection.js'
export * from './setTextDirection.js'
export * from './setTextSelection.js'
export * from './sinkListItem.js'
export * from './splitBlock.js'
export * from './splitListItem.js'
export * from './toggleList.js'
export * from './toggleMark.js'
export * from './toggleNode.js'
export * from './toggleWrap.js'
export * from './undoInputRule.js'
export * from './unsetAllMarks.js'
export * from './unsetMark.js'
export * from './unsetTextDirection.js'
export * from './updateAttributes.js'
export * from './updateDecorations.js'
export * from './wrapIn.js'
export * from './wrapInList.js'

View File

@@ -0,0 +1,197 @@
import Document from '@tiptap/extension-document'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { Color, TextStyle } from '@tiptap/extension-text-style'
import StarterKit from '@tiptap/starter-kit'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
import { Editor } from '../Editor.js'
describe('insertContent', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
it('inserts plain text object {type: "text", text: "world"}', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>hello</p>',
})
// Place cursor at the end of "hello" (position 6)
editor.commands.setTextSelection(6)
const result = editor.commands.insertContent({ type: 'text', text: 'world' })
expect(result).toBe(true)
expect(editor.getHTML()).toBe('<p>helloworld</p>')
})
})
describe('insertContent command', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Image, Color, TextStyle, Link, StarterKit],
content: '',
})
})
afterEach(() => {
editor.destroy()
})
it('inserts html content correctly', () => {
editor.commands.insertContent(
'<h1><a href="https://tiptap.dev/">Tiptap</a></h1><p><strong>Hello World</strong></p><p>This is a paragraph<br />with a break.</p><p>And this is some additional string content.</p>',
)
expect(editor.getHTML()).toContain(
'<h1><a target="_blank" rel="noopener noreferrer nofollow" href="https://tiptap.dev/">Tiptap</a></h1><p><strong>Hello World</strong></p><p>This is a paragraph<br>with a break.</p><p>And this is some additional string content.</p>',
)
})
it('keeps spaces inbetween tags in html content', () => {
editor.commands.insertContent('<p><b>Hello</b> <i>World</i></p>')
expect(editor.getHTML()).toContain('<p><strong>Hello</strong> <em>World</em></p>')
})
it('keeps empty spaces', () => {
editor.commands.insertContent(' ')
expect(editor.getHTML()).toContain('<p> </p>')
})
it('inserts text content correctly', () => {
editor.commands.insertContent(`Hello World
This is content with a new line. Is this working?
Lets see if multiple new lines are inserted correctly
And more lines`)
expect(editor.getText()).toContain(
'Hello World\nThis is content with a new line. Is this working?\n\nLets see if multiple new lines are inserted correctly',
)
})
it('keeps newlines in pre tag', () => {
editor.commands.insertContent('<pre><code>foo\nbar</code></pre>')
expect(editor.getHTML()).toContain('<pre><code>foo\nbar</code></pre>')
})
it('keeps newlines and tabs', () => {
editor.commands.insertContent('<p>Hello\n\tworld\n\t\thow\n\t\t\tnice.\ntest\tOK</p>')
expect(editor.getHTML()).toContain('<p>Hello\n\tworld\n\t\thow\n\t\t\tnice.\ntest\tOK</p>')
})
it('keeps newlines and tabs between html fragments', () => {
editor.commands.insertContent('<h1>Tiptap</h1>\n<p><strong>Hello World</strong></p>')
expect(editor.getHTML()).toContain('<h1>Tiptap</h1><p><strong>Hello World</strong></p>')
})
it('allows inserting nothing', () => {
editor.commands.insertContent('')
expect(editor.getHTML()).toBeDefined()
})
it('allows inserting a partial HTML tag', () => {
editor.commands.insertContent('<p>foo')
expect(editor.getHTML()).toContain('<p>foo</p>')
})
it('allows inserting an incomplete HTML tag', () => {
editor.commands.insertContent('foo<p')
expect(editor.getHTML()).toContain('<p>foo&lt;p</p>')
})
it('allows inserting a list', () => {
editor.commands.insertContent('<ul><li>ABC</li><li>123</li></ul>')
expect(editor.getHTML()).toContain('<ul><li><p>ABC</p></li><li><p>123</p></li></ul>')
})
it('removes newlines and tabs when parseOptions.preserveWhitespace=false', () => {
editor.commands.insertContent('\n<h1>Tiptap</h1><p><strong>Hello\n World</strong>\n</p>\n', {
parseOptions: { preserveWhitespace: false },
})
expect(editor.getHTML()).toContain('<h1>Tiptap</h1><p><strong>Hello World</strong></p>')
})
it('splits content when image is inserted inbetween text', () => {
editor.commands.insertContent('<p>HelloWorld</p>')
editor.commands.setTextSelection(6)
editor.commands.insertContent('<img src="https://example.image/1" alt="This is an example" />')
expect(editor.getHTML()).toContain(
'<p>Hello</p><img src="https://example.image/1" alt="This is an example"><p>World</p>',
)
})
it('does not split content when image is inserted at beginning of text', () => {
editor.commands.insertContent('<p>HelloWorld</p>')
editor.commands.setTextSelection(1)
editor.commands.insertContent('<img src="https://example.image/1" alt="This is an example" />')
expect(editor.getHTML()).toContain(
'<img src="https://example.image/1" alt="This is an example"><p>HelloWorld</p>',
)
})
it('respects editor.options.parseOptions if defined to be `false`', () => {
editor.options.parseOptions = { preserveWhitespace: false }
editor.commands.insertContent('\n<h1>Tiptap</h1><p><strong>Hello\n World</strong>\n</p>\n')
expect(editor.getHTML()).toContain('<h1>Tiptap</h1><p><strong>Hello World</strong></p>')
})
it('respects editor.options.parseOptions if defined to be `full`', () => {
editor.options.parseOptions = { preserveWhitespace: 'full' }
editor.commands.insertContent('\n<h1>Tiptap</h1><p><strong>Hello\n World</strong>\n</p>\n')
expect(editor.getHTML()).toContain('<h1>Tiptap</h1><p><strong>Hello\n World</strong></p>')
})
it('respects editor.options.parseOptions if defined to be `true`', () => {
editor.options.parseOptions = { preserveWhitespace: true }
editor.commands.insertContent('<h1>Tiptap</h1><p><strong>Hello\n World</strong>\n</p>')
expect(editor.getHTML()).toContain('<h1>Tiptap</h1><p><strong>Hello<br> World</strong></p>')
})
})
describe('insertContent command — applying rules', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [StarterKit],
content: '',
})
})
afterEach(() => {
editor.destroy()
})
it('applies list InputRule', async () => {
editor.commands.insertContent('-', { applyInputRules: true })
editor.commands.insertContent(' ', { applyInputRules: true })
// applyInputRules schedules rule evaluation via setTimeout(0)
await new Promise(resolve => {
setTimeout(resolve, 0)
})
expect(editor.getHTML()).toContain('<ul><li><p></p></li></ul>')
})
it('applies markdown using a PasteRule', () => {
editor.commands.insertContentAt(1, '*This is an italic text*', { applyPasteRules: true })
expect(editor.getHTML()).toContain('<p><em>This is an italic text</em></p>')
})
})

View File

@@ -0,0 +1,50 @@
import type { Fragment, Node as PMNode, ParseOptions } from '@tiptap/pm/model'
import type { Content, RawCommands } from '../types.js'
export interface InsertContentOptions {
/**
* Options for parsing the content.
*/
parseOptions?: ParseOptions
/**
* Whether to update the selection after inserting the content.
*/
updateSelection?: boolean
applyInputRules?: boolean
applyPasteRules?: boolean
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
insertContent: {
/**
* Insert a node or string of HTML at the current position.
* @example editor.commands.insertContent('<h1>Example</h1>')
* @example editor.commands.insertContent('<h1>Example</h1>', { updateSelection: false })
*/
insertContent: (
/**
* The ProseMirror content to insert.
*/
value: Content | PMNode | Fragment,
/**
* Optional options
*/
options?: InsertContentOptions,
) => ReturnType
}
}
}
export const insertContent: RawCommands['insertContent'] =
(value, options) =>
({ tr, commands }) => {
return commands.insertContentAt(
{ from: tr.selection.from, to: tr.selection.to },
value,
options,
)
}

View File

@@ -0,0 +1,133 @@
import Bold from '@tiptap/extension-bold'
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 { Editor } from '../Editor.js'
describe('insertContentAt', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
describe('from position adjustment', () => {
it('should not adjust the from position for inline content with marks at parentOffset 0', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold],
content: '<p>first</p><p>second</p>',
})
let stepFrom: number | undefined
const origDispatch = editor.view.dispatch.bind(editor.view)
editor.view.dispatch = (tr: any) => {
if (tr.steps.length > 0) {
stepFrom = tr.steps[0].from
}
return origDispatch(tr)
}
// Position 8 is at parentOffset 0 inside the second paragraph
editor.commands.insertContentAt(8, [
{ type: 'text', text: 'hello', marks: [{ type: 'bold' }] },
])
// The from position should NOT be adjusted (should remain 8, not 7)
expect(stepFrom).toBe(8)
})
it('should adjust the from position for block content at parentOffset 0', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold],
content: '<p>first</p><p>second</p>',
})
let stepFrom: number | undefined
const origDispatch = editor.view.dispatch.bind(editor.view)
editor.view.dispatch = (tr: any) => {
if (tr.steps.length > 0) {
stepFrom = tr.steps[0].from
}
return origDispatch(tr)
}
// Inserting block content at parentOffset 0 should use from-1
editor.commands.insertContentAt(8, [
{ type: 'paragraph', content: [{ type: 'text', text: 'new' }] },
])
// The from position SHOULD be adjusted (8 -> 7)
expect(stepFrom).toBe(7)
})
})
describe('content correctness', () => {
it('inserts marked inline content at the start of a paragraph', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold],
content: '<p>existing text</p>',
})
editor.commands.insertContentAt(1, [
{ type: 'text', text: 'hello', marks: [{ type: 'bold' }] },
])
const json = editor.getJSON()
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', marks: [{ type: 'bold' }], text: 'hello' },
{ type: 'text', text: 'existing text' },
],
},
],
})
})
it('inserts block content before a non-empty paragraph', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold],
content: '<p>existing text</p>',
})
editor.commands.insertContentAt(1, [
{ type: 'paragraph', content: [{ type: 'text', text: 'new paragraph' }] },
])
const json = editor.getJSON()
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'new paragraph' }],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'existing text' }],
},
],
})
})
it('inserts plain text at the start of a paragraph', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>existing text</p>',
})
editor.commands.insertContentAt(1, 'hello')
expect(editor.getHTML()).toContain('helloexisting text')
})
})
})

View File

@@ -0,0 +1,208 @@
import type { Node as PMNode, ParseOptions } from '@tiptap/pm/model'
import { Fragment } from '@tiptap/pm/model'
import { createNodeFromContent } from '../helpers/createNodeFromContent.js'
import { isFragment } from '../helpers/isFragment.js'
import { isProseMirrorContent } from '../helpers/isProseMirrorContent.js'
import { selectionToInsertionEnd } from '../helpers/selectionToInsertionEnd.js'
import type { Content, Range, RawCommands } from '../types.js'
export interface InsertContentAtOptions {
/**
* Options for parsing the content.
*/
parseOptions?: ParseOptions
/**
* Whether to update the selection after inserting the content.
*/
updateSelection?: boolean
/**
* Whether to apply input rules after inserting the content.
*/
applyInputRules?: boolean
/**
* Whether to apply paste rules after inserting the content.
*/
applyPasteRules?: boolean
/**
* Whether to throw an error if the content is invalid.
*/
errorOnInvalidContent?: boolean
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
insertContentAt: {
/**
* Insert a node or string of HTML at a specific position.
* @example editor.commands.insertContentAt(0, '<h1>Example</h1>')
*/
insertContentAt: (
/**
* The position to insert the content at.
*/
position: number | Range,
/**
* The ProseMirror content to insert.
*/
value: Content | PMNode | Fragment,
/**
* Optional options
*/
options?: InsertContentAtOptions,
) => ReturnType
}
}
}
export const insertContentAt: RawCommands['insertContentAt'] =
(position, value, options) =>
({ tr, dispatch, editor }) => {
if (dispatch) {
options = {
parseOptions: editor.options.parseOptions,
updateSelection: true,
applyInputRules: false,
applyPasteRules: false,
...options,
}
let content: Fragment | PMNode
const emitContentError = (error: Error) => {
editor.emit('contentError', {
editor,
error,
disableCollaboration: () => {
if (
'collaboration' in editor.storage &&
typeof editor.storage.collaboration === 'object' &&
editor.storage.collaboration
) {
;(editor.storage.collaboration as any).isDisabled = true
}
},
})
}
const parseOptions: ParseOptions = {
preserveWhitespace: 'full',
...options.parseOptions,
}
// If `emitContentError` is enabled, we want to check the content for errors
// but ignore them (do not remove the invalid content from the document)
if (
!options.errorOnInvalidContent &&
!editor.options.enableContentCheck &&
editor.options.emitContentError
) {
try {
createNodeFromContent(value, editor.schema, {
parseOptions,
errorOnInvalidContent: true,
})
} catch (e) {
emitContentError(e as Error)
}
}
try {
content = createNodeFromContent(value, editor.schema, {
parseOptions,
errorOnInvalidContent: options.errorOnInvalidContent ?? editor.options.enableContentCheck,
})
} catch (e) {
emitContentError(e as Error)
return false
}
let { from, to } =
typeof position === 'number'
? { from: position, to: position }
: { from: position.from, to: position.to }
let isOnlyTextContent = true
let isOnlyBlockContent = true
const nodes: readonly PMNode[] = isFragment(content) ? content.content : [content]
nodes.forEach(node => {
// check if added node is valid
node.check()
isOnlyTextContent = isOnlyTextContent ? node.isText && node.marks.length === 0 : false
isOnlyBlockContent = isOnlyBlockContent ? node.isBlock : false
})
// check if we can replace the wrapping node by
// the newly inserted content
// example:
// replace an empty paragraph by an inserted image
// instead of inserting the image below the paragraph
if (from === to && isOnlyBlockContent) {
const { parent } = tr.doc.resolve(from)
const isEmptyTextBlock = parent.isTextblock && !parent.type.spec.code && !parent.childCount
if (isEmptyTextBlock) {
from -= 1
to += 1
}
}
let newContent
// if there is only plain text we have to use `insertText`
// because this will keep the current marks
if (isOnlyTextContent) {
// if value is string, we can use it directly
// otherwise if it is an array, we have to join it
if (Array.isArray(value)) {
newContent = value.map(item => item.text || '').join('')
} else if (isProseMirrorContent(value)) {
newContent = nodes.map(node => node.text ?? '').join('')
} else if (typeof value === 'object' && !!value && !!value.text) {
newContent = value.text
} else {
newContent = value as string
}
tr.insertText(newContent, from, to)
} else {
newContent = Fragment.from(nodes)
const $from = tr.doc.resolve(from)
const $fromNode = $from.node()
const fromSelectionAtStart = $from.parentOffset === 0
const isTextSelection = $fromNode.isText || $fromNode.isTextblock
const hasContent = $fromNode.content.size > 0
if (fromSelectionAtStart && isTextSelection && hasContent && isOnlyBlockContent) {
from = Math.max(0, from - 1)
}
tr.replaceWith(from, to, nodes)
}
// set cursor at end of inserted content
if (options.updateSelection) {
selectionToInsertionEnd(tr, tr.steps.length - 1, -1)
}
if (options.applyInputRules) {
tr.setMeta('applyInputRules', { from, text: newContent })
}
if (options.applyPasteRules) {
tr.setMeta('applyPasteRules', { from, text: newContent })
}
}
return true
}

View File

@@ -0,0 +1,178 @@
import Blockquote from '@tiptap/extension-blockquote'
import Bold from '@tiptap/extension-bold'
import Document from '@tiptap/extension-document'
import Heading from '@tiptap/extension-heading'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { NodeSelection } from '@tiptap/pm/state'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
import { Editor } from '../Editor.js'
describe('insertDefaultBlock', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, Bold, Blockquote, Heading],
content: '<p>hello world</p>',
})
})
afterEach(() => {
editor.destroy()
})
describe('position handling', () => {
it('uses the current selection position when no pos is provided', () => {
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 0)))
expect(editor.commands.insertDefaultBlock()).toBe(true)
expect(editor.getHTML()).toBe('<p></p><p>hello world</p>')
})
it('inserts at a numeric position', () => {
editor.commands.insertDefaultBlock({ pos: 0 })
const json = editor.getJSON()
expect(json.content[0].type).toBe('paragraph')
expect(json.content[0].content).toBeUndefined()
expect(json.content[1].type).toBe('paragraph')
})
it('inserts at a resolved position', () => {
const $pos = editor.state.doc.resolve(0)
editor.commands.insertDefaultBlock({ pos: $pos })
const json = editor.getJSON()
expect(json.content[0].type).toBe('paragraph')
expect(json.content[0].content).toBeUndefined()
})
it('returns false when the current caret is inside a textblock', () => {
editor.commands.setTextSelection(2)
expect(editor.commands.insertDefaultBlock()).toBe(false)
expect(editor.getHTML()).toBe('<p>hello world</p>')
})
})
describe('block type determination', () => {
it('inserts a paragraph inside document node', () => {
editor.commands.insertDefaultBlock({ pos: 0 })
const json = editor.getJSON()
expect(json.content[0].type).toBe('paragraph')
expect(json.content[0].content).toBeUndefined()
})
it('inserts a paragraph inside a blockquote', () => {
editor.commands.setContent('<blockquote><p>quote</p></blockquote>')
editor.commands.insertDefaultBlock({ pos: 1 })
const bq = editor.state.doc.firstChild
expect(bq?.childCount).toBe(2)
expect(bq?.firstChild?.type.name).toBe('paragraph')
expect(bq?.firstChild?.textContent).toBe('')
})
it('does not escape a blockquote when the position is inside its paragraph', () => {
editor.commands.setContent('<blockquote><p>quote</p></blockquote>')
expect(editor.commands.insertDefaultBlock({ pos: 2 })).toBe(false)
expect(editor.getHTML()).toBe('<blockquote><p>quote</p></blockquote>')
})
it('inserts at the end of the document', () => {
editor.commands.insertDefaultBlock({ pos: editor.state.doc.content.size })
const json = editor.getJSON()
expect(json.content).toHaveLength(2)
expect(json.content[0].type).toBe('paragraph')
expect(json.content[0].content).toBeDefined()
expect(json.content[1].type).toBe('paragraph')
expect(json.content[1].content).toBeUndefined()
})
it('inserts default block inside a blockquote', () => {
editor.commands.setContent('<blockquote><p>existing</p></blockquote>')
editor.commands.insertDefaultBlock({ pos: 1, content: 'new content' })
const bq = editor.state.doc.firstChild
expect(bq?.type.name).toBe('blockquote')
expect(bq?.childCount).toBe(2)
expect(bq?.firstChild?.textContent).toBe('new content')
expect(bq?.lastChild?.textContent).toBe('existing')
})
})
describe('content', () => {
it('inserts block with text content', () => {
editor.commands.insertDefaultBlock({ pos: 0, content: 'Hello' })
expect(editor.getHTML()).toBe('<p>Hello</p><p>hello world</p>')
})
it('inserts block with HTML content', () => {
editor.commands.insertDefaultBlock({ pos: 0, content: '<strong>bold</strong>' })
expect(editor.getHTML()).toBe('<p><strong>bold</strong></p><p>hello world</p>')
})
it('inserts empty block when no content provided', () => {
editor.commands.insertDefaultBlock({ pos: 0 })
const json = editor.getJSON()
const firstBlock = json.content[0]
expect(firstBlock.type).toBe('paragraph')
expect(firstBlock.content).toBeUndefined()
})
})
describe('attributes', () => {
it('applies valid attributes to a non-paragraph default block', () => {
editor.destroy()
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document.extend({ content: 'heading+' }), Heading, Text],
content: '<h1>existing</h1>',
})
editor.commands.insertDefaultBlock({ pos: 0, attrs: { level: 2 }, content: 'new' })
expect(editor.getJSON()).toEqual({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'new' }],
},
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'existing' }],
},
],
})
expect(editor.commands.insertDefaultBlock({ pos: 0, attrs: { level: 3, foo: 'bar' } })).toBe(
true,
)
expect(editor.state.doc.firstChild?.attrs).toEqual({ level: 3 })
})
})
describe('selection', () => {
it('updates selection by default', () => {
editor.commands.insertDefaultBlock({ pos: 0, content: 'Hello' })
const selection = editor.state.selection
expect(selection.$from.parent.type.name).toBe('paragraph')
expect(selection.$from.parent.textContent).toBe('Hello')
})
it('does not update selection when updateSelection is false', () => {
editor.commands.setTextSelection(0)
editor.commands.insertDefaultBlock({ pos: 0, updateSelection: false })
// Selection is mapped through the transaction, so position shifts
expect(editor.state.selection.from).toBe(3)
})
})
})

View File

@@ -0,0 +1,97 @@
import type { Node as PMNode, Fragment, ResolvedPos } from '@tiptap/pm/model'
import { createNodeFromContent } from '../helpers/createNodeFromContent.js'
import { defaultBlockAt } from '../helpers/defaultBlockAt.js'
import { selectionToInsertionEnd } from '../helpers/selectionToInsertionEnd.js'
import type { Content, RawCommands } from '../types.js'
export interface InsertDefaultBlockOptions {
/**
* The position to insert the block at.
* Accepts a number or a resolved ProseMirror position.
* Defaults to the current caret position.
*/
pos?: number | ResolvedPos
/**
* Attributes to apply to the inserted node.
* Only attributes that match the node's schema will be applied.
*/
attrs?: Record<string, any>
/**
* Content to insert into the block.
* Accepts string (plain text or HTML), ProseMirror node, or Fragment.
*/
content?: Content | PMNode | Fragment
/**
* Whether to update the selection after inserting the content.
* @default true
*/
updateSelection?: boolean
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
insertDefaultBlock: {
/**
* Insert a default content block at the given position.
* Returns false when the default block can't be inserted at that position.
* @example editor.commands.insertDefaultBlock()
* @example editor.commands.insertDefaultBlock({ pos: 5, content: 'Hello' })
*/
insertDefaultBlock: (options?: InsertDefaultBlockOptions) => ReturnType
}
}
}
export const insertDefaultBlock: RawCommands['insertDefaultBlock'] =
(options = {}) =>
({ tr, dispatch, editor }) => {
const { pos, attrs, content, updateSelection = true } = options
let $pos: ResolvedPos
if (typeof pos === 'number') {
$pos = tr.doc.resolve(pos)
} else if (pos) {
$pos = pos
} else {
$pos = tr.selection.$from
}
const defaultType = defaultBlockAt($pos.parent.contentMatchAt($pos.index()))
if (!defaultType) {
return false
}
const validAttrKeys = Object.keys(defaultType.spec.attrs || {})
const filteredAttrs = attrs
? Object.fromEntries(Object.entries(attrs).filter(([key]) => validAttrKeys.includes(key)))
: {}
let node: PMNode | null | undefined
if (content) {
const parsed = createNodeFromContent(content, editor.schema)
node = defaultType.createAndFill(filteredAttrs, parsed)
} else {
node = defaultType.createAndFill(filteredAttrs)
}
if (!node) {
return false
}
if (dispatch) {
tr.insert($pos.pos, node)
if (updateSelection) {
selectionToInsertionEnd(tr, tr.steps.length - 1, -1)
}
}
return true
}

View File

@@ -0,0 +1,69 @@
import {
joinBackward as originalJoinBackward,
joinDown as originalJoinDown,
joinForward as originalJoinForward,
joinUp as originalJoinUp,
} from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
joinUp: {
/**
* Join the selected block or, if there is a text selection, the closest ancestor block of the selection that can be joined, with the sibling above it.
* @example editor.commands.joinUp()
*/
joinUp: () => ReturnType
}
joinDown: {
/**
* Join the selected block, or the closest ancestor of the selection that can be joined, with the sibling after it.
* @example editor.commands.joinDown()
*/
joinDown: () => ReturnType
}
joinBackward: {
/**
* If the selection is empty and at the start of a textblock, try to reduce the distance between that block and the one before it—if there's a block directly before it that can be joined, join them.
* If not, try to move the selected block closer to the next one in the document structure by lifting it out of its
* parent or moving it into a parent of the previous block. Will use the view for accurate (bidi-aware) start-of-textblock detection if given.
* @example editor.commands.joinBackward()
*/
joinBackward: () => ReturnType
}
joinForward: {
/**
* If the selection is empty and the cursor is at the end of a textblock, try to reduce or remove the boundary between that block and the one after it,
* either by joining them or by moving the other block closer to this one in the tree structure.
* Will use the view for accurate start-of-textblock detection if given.
* @example editor.commands.joinForward()
*/
joinForward: () => ReturnType
}
}
}
export const joinUp: RawCommands['joinUp'] =
() =>
({ state, dispatch }) => {
return originalJoinUp(state, dispatch)
}
export const joinDown: RawCommands['joinDown'] =
() =>
({ state, dispatch }) => {
return originalJoinDown(state, dispatch)
}
export const joinBackward: RawCommands['joinBackward'] =
() =>
({ state, dispatch }) => {
return originalJoinBackward(state, dispatch)
}
export const joinForward: RawCommands['joinForward'] =
() =>
({ state, dispatch }) => {
return originalJoinForward(state, dispatch)
}

View File

@@ -0,0 +1,37 @@
import { joinPoint } from '@tiptap/pm/transform'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
joinItemBackward: {
/**
* Join two items backward.
* @example editor.commands.joinItemBackward()
*/
joinItemBackward: () => ReturnType
}
}
}
export const joinItemBackward: RawCommands['joinItemBackward'] =
() =>
({ state, dispatch, tr }) => {
try {
const point = joinPoint(state.doc, state.selection.$from.pos, -1)
if (point === null || point === undefined) {
return false
}
tr.join(point, 2)
if (dispatch) {
dispatch(tr)
}
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,37 @@
import { joinPoint } from '@tiptap/pm/transform'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
joinItemForward: {
/**
* Join two items Forwards.
* @example editor.commands.joinItemForward()
*/
joinItemForward: () => ReturnType
}
}
}
export const joinItemForward: RawCommands['joinItemForward'] =
() =>
({ state, dispatch, tr }) => {
try {
const point = joinPoint(state.doc, state.selection.$from.pos, +1)
if (point === null || point === undefined) {
return false
}
tr.join(point, 2)
if (dispatch) {
dispatch(tr)
}
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,20 @@
import { joinTextblockBackward as originalCommand } from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
joinTextblockBackward: {
/**
* A more limited form of joinBackward that only tries to join the current textblock to the one before it, if the cursor is at the start of a textblock.
*/
joinTextblockBackward: () => ReturnType
}
}
}
export const joinTextblockBackward: RawCommands['joinTextblockBackward'] =
() =>
({ state, dispatch }) => {
return originalCommand(state, dispatch)
}

View File

@@ -0,0 +1,20 @@
import { joinTextblockForward as originalCommand } from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
joinTextblockForward: {
/**
* A more limited form of joinForward that only tries to join the current textblock to the one after it, if the cursor is at the end of a textblock.
*/
joinTextblockForward: () => ReturnType
}
}
}
export const joinTextblockForward: RawCommands['joinTextblockForward'] =
() =>
({ state, dispatch }) => {
return originalCommand(state, dispatch)
}

View File

@@ -0,0 +1,100 @@
import type { RawCommands } from '../types.js'
import { isiOS } from '../utilities/isiOS.js'
import { isMacOS } from '../utilities/isMacOS.js'
function normalizeKeyName(name: string) {
const parts = name.split(/-(?!$)/)
let result = parts[parts.length - 1]
if (result === 'Space') {
result = ' '
}
let alt
let ctrl
let shift
let meta
for (let i = 0; i < parts.length - 1; i += 1) {
const mod = parts[i]
if (/^(cmd|meta|m)$/i.test(mod)) {
meta = true
} else if (/^a(lt)?$/i.test(mod)) {
alt = true
} else if (/^(c|ctrl|control)$/i.test(mod)) {
ctrl = true
} else if (/^s(hift)?$/i.test(mod)) {
shift = true
} else if (/^mod$/i.test(mod)) {
if (isiOS() || isMacOS()) {
meta = true
} else {
ctrl = true
}
} else {
throw new Error(`Unrecognized modifier name: ${mod}`)
}
}
if (alt) {
result = `Alt-${result}`
}
if (ctrl) {
result = `Ctrl-${result}`
}
if (meta) {
result = `Meta-${result}`
}
if (shift) {
result = `Shift-${result}`
}
return result
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
keyboardShortcut: {
/**
* Trigger a keyboard shortcut.
* @param name The name of the keyboard shortcut.
* @example editor.commands.keyboardShortcut('Mod-b')
*/
keyboardShortcut: (name: string) => ReturnType
}
}
}
export const keyboardShortcut: RawCommands['keyboardShortcut'] =
name =>
({ editor, view, tr, dispatch }) => {
const keys = normalizeKeyName(name).split(/-(?!$)/)
const key = keys.find(item => !['Alt', 'Ctrl', 'Meta', 'Shift'].includes(item))
const event = new KeyboardEvent('keydown', {
key: key === 'Space' ? ' ' : key,
altKey: keys.includes('Alt'),
ctrlKey: keys.includes('Ctrl'),
metaKey: keys.includes('Meta'),
shiftKey: keys.includes('Shift'),
bubbles: true,
cancelable: true,
})
const capturedTransaction = editor.captureTransaction(() => {
view.someProp('handleKeyDown', f => f(view, event))
})
capturedTransaction?.steps.forEach(step => {
const newStep = step.map(tr.mapping)
if (newStep && dispatch) {
tr.maybeStep(newStep)
}
})
return true
}

View File

@@ -0,0 +1,34 @@
import { lift as originalLift } from '@tiptap/pm/commands'
import type { NodeType } from '@tiptap/pm/model'
import { getNodeType } from '../helpers/getNodeType.js'
import { isNodeActive } from '../helpers/isNodeActive.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
lift: {
/**
* Removes an existing wrap if possible lifting the node out of it
* @param typeOrName The type or name of the node.
* @param attributes The attributes of the node.
* @example editor.commands.lift('paragraph')
* @example editor.commands.lift('heading', { level: 1 })
*/
lift: (typeOrName: string | NodeType, attributes?: Record<string, any>) => ReturnType
}
}
}
export const lift: RawCommands['lift'] =
(typeOrName, attributes = {}) =>
({ state, dispatch }) => {
const type = getNodeType(typeOrName, state.schema)
const isActive = isNodeActive(state, type, attributes)
if (!isActive) {
return false
}
return originalLift(state, dispatch)
}

View File

@@ -0,0 +1,21 @@
import { liftEmptyBlock as originalLiftEmptyBlock } from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
liftEmptyBlock: {
/**
* If the cursor is in an empty textblock that can be lifted, lift the block.
* @example editor.commands.liftEmptyBlock()
*/
liftEmptyBlock: () => ReturnType
}
}
}
export const liftEmptyBlock: RawCommands['liftEmptyBlock'] =
() =>
({ state, dispatch }) => {
return originalLiftEmptyBlock(state, dispatch)
}

View File

@@ -0,0 +1,26 @@
import type { NodeType } from '@tiptap/pm/model'
import { liftListItem as originalLiftListItem } from '@tiptap/pm/schema-list'
import { getNodeType } from '../helpers/getNodeType.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
liftListItem: {
/**
* Create a command to lift the list item around the selection up into a wrapping list.
* @param typeOrName The type or name of the node.
* @example editor.commands.liftListItem('listItem')
*/
liftListItem: (typeOrName: string | NodeType) => ReturnType
}
}
}
export const liftListItem: RawCommands['liftListItem'] =
typeOrName =>
({ state, dispatch }) => {
const type = getNodeType(typeOrName, state.schema)
return originalLiftListItem(type)(state, dispatch)
}

View File

@@ -0,0 +1,21 @@
import { newlineInCode as originalNewlineInCode } from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
newlineInCode: {
/**
* Add a newline character in code.
* @example editor.commands.newlineInCode()
*/
newlineInCode: () => ReturnType
}
}
}
export const newlineInCode: RawCommands['newlineInCode'] =
() =>
({ state, dispatch }) => {
return originalNewlineInCode(state, dispatch)
}

View File

@@ -0,0 +1,80 @@
import type { MarkType, NodeType } from '@tiptap/pm/model'
import { getMarkType } from '../helpers/getMarkType.js'
import { getNodeType } from '../helpers/getNodeType.js'
import { getSchemaTypeNameByName } from '../helpers/getSchemaTypeNameByName.js'
import type { RawCommands } from '../types.js'
import { deleteProps } from '../utilities/deleteProps.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
resetAttributes: {
/**
* Resets some node attributes to the default value.
* @param typeOrName The type or name of the node.
* @param attributes The attributes of the node to reset.
* @example editor.commands.resetAttributes('heading', 'level')
*/
resetAttributes: (
typeOrName: string | NodeType | MarkType,
attributes: string | string[],
) => ReturnType
}
}
}
export const resetAttributes: RawCommands['resetAttributes'] =
(typeOrName, attributes) =>
({ tr, state, dispatch }) => {
let nodeType: NodeType | null = null
let markType: MarkType | null = null
const schemaType = getSchemaTypeNameByName(
typeof typeOrName === 'string' ? typeOrName : typeOrName.name,
state.schema,
)
if (!schemaType) {
return false
}
if (schemaType === 'node') {
nodeType = getNodeType(typeOrName as NodeType, state.schema)
}
if (schemaType === 'mark') {
markType = getMarkType(typeOrName as MarkType, state.schema)
}
let canReset = false
tr.selection.ranges.forEach(range => {
state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
if (nodeType && nodeType === node.type) {
canReset = true
if (dispatch) {
tr.setNodeMarkup(pos, undefined, deleteProps(node.attrs, attributes))
}
}
if (markType && node.marks.length) {
node.marks.forEach(mark => {
if (markType === mark.type) {
canReset = true
if (dispatch) {
tr.addMark(
pos,
pos + node.nodeSize,
markType.create(deleteProps(mark.attrs, attributes)),
)
}
}
})
}
})
})
return canReset
}

View File

@@ -0,0 +1,23 @@
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
scrollIntoView: {
/**
* Scroll the selection into view.
* @example editor.commands.scrollIntoView()
*/
scrollIntoView: () => ReturnType
}
}
}
export const scrollIntoView: RawCommands['scrollIntoView'] =
() =>
({ tr, dispatch }) => {
if (dispatch) {
tr.scrollIntoView()
}
return true
}

View File

@@ -0,0 +1,27 @@
import { AllSelection } from '@tiptap/pm/state'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
selectAll: {
/**
* Select the whole document.
* @example editor.commands.selectAll()
*/
selectAll: () => ReturnType
}
}
}
export const selectAll: RawCommands['selectAll'] =
() =>
({ tr, dispatch }) => {
if (dispatch) {
const selection = new AllSelection(tr.doc)
tr.setSelection(selection)
}
return true
}

View File

@@ -0,0 +1,21 @@
import { selectNodeBackward as originalSelectNodeBackward } from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
selectNodeBackward: {
/**
* Select a node backward.
* @example editor.commands.selectNodeBackward()
*/
selectNodeBackward: () => ReturnType
}
}
}
export const selectNodeBackward: RawCommands['selectNodeBackward'] =
() =>
({ state, dispatch }) => {
return originalSelectNodeBackward(state, dispatch)
}

View File

@@ -0,0 +1,21 @@
import { selectNodeForward as originalSelectNodeForward } from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
selectNodeForward: {
/**
* Select a node forward.
* @example editor.commands.selectNodeForward()
*/
selectNodeForward: () => ReturnType
}
}
}
export const selectNodeForward: RawCommands['selectNodeForward'] =
() =>
({ state, dispatch }) => {
return originalSelectNodeForward(state, dispatch)
}

View File

@@ -0,0 +1,21 @@
import { selectParentNode as originalSelectParentNode } from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
selectParentNode: {
/**
* Select the parent node.
* @example editor.commands.selectParentNode()
*/
selectParentNode: () => ReturnType
}
}
}
export const selectParentNode: RawCommands['selectParentNode'] =
() =>
({ state, dispatch }) => {
return originalSelectParentNode(state, dispatch)
}

View File

@@ -0,0 +1,23 @@
// @ts-ignore
// TODO: add types to @types/prosemirror-commands
import { selectTextblockEnd as originalSelectTextblockEnd } from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
selectTextblockEnd: {
/**
* Moves the cursor to the end of current text block.
* @example editor.commands.selectTextblockEnd()
*/
selectTextblockEnd: () => ReturnType
}
}
}
export const selectTextblockEnd: RawCommands['selectTextblockEnd'] =
() =>
({ state, dispatch }) => {
return originalSelectTextblockEnd(state, dispatch)
}

View File

@@ -0,0 +1,23 @@
// @ts-ignore
// TODO: add types to @types/prosemirror-commands
import { selectTextblockStart as originalSelectTextblockStart } from '@tiptap/pm/commands'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
selectTextblockStart: {
/**
* Moves the cursor to the start of current text block.
* @example editor.commands.selectTextblockStart()
*/
selectTextblockStart: () => ReturnType
}
}
}
export const selectTextblockStart: RawCommands['selectTextblockStart'] =
() =>
({ state, dispatch }) => {
return originalSelectTextblockStart(state, dispatch)
}

View File

@@ -0,0 +1,177 @@
import Mention from '@tiptap/extension-mention'
import StarterKit from '@tiptap/starter-kit'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
import { Editor } from '../Editor.js'
describe('setContent command', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [StarterKit, Mention],
content: '',
})
})
afterEach(() => {
editor.destroy()
})
it('inserts raw text content', () => {
editor.commands.setContent('Hello World.')
expect(editor.getHTML()).toContain('<p>Hello World.</p>')
})
it('inserts raw JSON content', () => {
editor.commands.setContent({
type: 'paragraph',
content: [{ type: 'text', text: 'Hello World.' }],
})
expect(editor.getHTML()).toContain('<p>Hello World.</p>')
})
it('inserts a Prosemirror Node as content', () => {
editor.commands.setContent(
editor.schema.node('paragraph', null, editor.schema.text('Hello World.')),
)
expect(editor.getHTML()).toContain('<p>Hello World.</p>')
})
it('inserts a Prosemirror Fragment as content', () => {
editor.commands.setContent(
editor.schema.node(
'doc',
null,
editor.schema.node('paragraph', null, editor.schema.text('Hello World.')),
).content,
)
expect(editor.getHTML()).toContain('<p>Hello World.</p>')
})
it('respects the emitUpdate option', () => {
let updateCount = 0
const callback = () => {
updateCount += 1
}
editor.on('update', callback)
editor.commands.setContent('Hello World.', { emitUpdate: true })
const first = updateCount
updateCount = 0
editor.commands.setContent('Hello World again.', { emitUpdate: false })
const second = updateCount
editor.off('update', callback)
expect(first).toBe(1)
expect(second).toBe(0)
})
it('inserts more complex html content', () => {
editor.commands.setContent(
'<h1>Welcome to Tiptap</h1><p>This is a paragraph.</p><ul><li><p>List Item A</p></li><li><p>List Item B</p><ul><li><p>Subchild</p></li></ul></li></ul>',
)
expect(editor.getHTML()).toContain(
'<h1>Welcome to Tiptap</h1><p>This is a paragraph.</p><ul><li><p>List Item A</p></li><li><p>List Item B</p><ul><li><p>Subchild</p></li></ul></li></ul>',
)
})
it('removes newlines and tabs', () => {
editor.commands.setContent('<p>Hello\n\tworld\n\t\thow\n\t\t\tnice.</p>')
expect(editor.getHTML()).toContain('<p>Hello world how nice.</p>')
})
it('keeps newlines and tabs when preserveWhitespace = full', () => {
editor.commands.setContent('<p>Hello\n\tworld\n\t\thow\n\t\t\tnice.</p>', {
parseOptions: { preserveWhitespace: 'full' },
})
expect(editor.getHTML()).toContain('<p>Hello\n\tworld\n\t\thow\n\t\t\tnice.</p>')
})
it('overwrites existing content', () => {
editor.commands.setContent('<p>Initial Content</p>')
expect(editor.getHTML()).toContain('<p>Initial Content</p>')
editor.commands.setContent('<p>Overwritten Content</p>')
expect(editor.getHTML()).toContain('<p>Overwritten Content</p>')
editor.commands.setContent('Content without tags')
expect(editor.getHTML()).toContain('<p>Content without tags</p>')
})
it('inserts mentions', () => {
editor.commands.setContent(
'<p><span data-type="mention" data-id="1" data-label="John Doe">@John Doe</span></p>',
)
expect(editor.getHTML()).toContain(
'<span data-type="mention" data-id="1" data-label="John Doe" data-mention-suggestion-char="@">@John Doe</span>',
)
})
it('removes newlines and tabs between html fragments', () => {
editor.commands.setContent('<h1>Tiptap</h1>\n\t<p><strong>Hello World</strong></p>')
expect(editor.getHTML()).toContain('<h1>Tiptap</h1><p><strong>Hello World</strong></p>')
})
it('keeps newlines and tabs between html fragments when preserveWhitespace = full', () => {
editor.commands.setContent('<h1>Tiptap</h1>\n\t<p><strong>Hello World</strong></p>', {
parseOptions: { preserveWhitespace: 'full' },
})
expect(editor.getHTML()).toContain(
'<h1>Tiptap</h1><p>\n\t</p><p><strong>Hello World</strong></p>',
)
})
it('allows inserting nothing', () => {
editor.commands.setContent('')
expect(editor.getHTML()).toBeDefined()
})
it('allows inserting nothing when preserveWhitespace = full', () => {
editor.commands.setContent('', { parseOptions: { preserveWhitespace: 'full' } })
expect(editor.getHTML()).toBeDefined()
})
it('allows inserting a partial HTML tag', () => {
editor.commands.setContent('<p>foo')
expect(editor.getHTML()).toContain('<p>foo</p>')
})
it('allows inserting a partial HTML tag when preserveWhitespace = full', () => {
editor.commands.setContent('<p>foo', { parseOptions: { preserveWhitespace: 'full' } })
expect(editor.getHTML()).toContain('<p>foo</p>')
})
it('removes an incomplete HTML tag', () => {
editor.commands.setContent('foo<p')
expect(editor.getHTML()).toContain('<p>foo</p>')
})
it('allows inserting an incomplete HTML tag when preserveWhitespace = full', () => {
editor.commands.setContent('foo<p', { parseOptions: { preserveWhitespace: 'full' } })
expect(editor.getHTML()).toContain('<p>foo&lt;p</p>')
})
it('allows inserting a list', () => {
editor.commands.setContent('<ul><li>ABC</li><li>123</li></ul>')
expect(editor.getHTML()).toContain('<ul><li><p>ABC</p></li><li><p>123</p></li></ul>')
})
it('allows inserting a list when preserveWhitespace = full', () => {
editor.commands.setContent('<ul><li>ABC</li><li>123</li></ul>', {
parseOptions: { preserveWhitespace: 'full' },
})
expect(editor.getHTML()).toContain('<ul><li><p>ABC</p></li><li><p>123</p></li></ul>')
})
it('removes newlines and tabs when parseOptions.preserveWhitespace=false', () => {
editor.commands.setContent('\n<h1>Tiptap</h1><p><strong>Hello\n World</strong>\n</p>\n', {
parseOptions: { preserveWhitespace: false },
})
expect(editor.getHTML()).toContain('<h1>Tiptap</h1><p><strong>Hello World</strong></p>')
})
})

View File

@@ -0,0 +1,79 @@
import type { Fragment, Node as PMNode, ParseOptions } from '@tiptap/pm/model'
import { createDocument } from '../helpers/createDocument.js'
import { isFragment } from '../helpers/isFragment.js'
import type { Content, RawCommands } from '../types.js'
export interface SetContentOptions {
/**
* Options for parsing the content.
* @default {}
*/
parseOptions?: ParseOptions
/**
* Whether to throw an error if the content is invalid.
*/
errorOnInvalidContent?: boolean
/**
* Whether to emit an update event.
* @default true
*/
emitUpdate?: boolean
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
setContent: {
/**
* Replace the whole document with new content.
* @param content The new content.
* @param emitUpdate Whether to emit an update event.
* @param parseOptions Options for parsing the content.
* @example editor.commands.setContent('<p>Example text</p>')
*/
setContent: (
/**
* The new content.
*/
content: Content | Fragment | PMNode,
/**
* Options for `setContent`.
*/
options?: SetContentOptions,
) => ReturnType
}
}
}
export const setContent: RawCommands['setContent'] =
(content, { errorOnInvalidContent, emitUpdate = true, parseOptions = {} } = {}) =>
({ editor, tr, dispatch, commands }) => {
const { doc } = tr
// This is to keep backward compatibility with the previous behavior
// TODO remove this in the next major version
if (parseOptions.preserveWhitespace !== 'full') {
const document = createDocument(content, editor.schema, parseOptions, {
errorOnInvalidContent: errorOnInvalidContent ?? editor.options.enableContentCheck,
})
if (dispatch) {
const nodes = isFragment(document) ? document.content : [document]
tr.replaceWith(0, doc.content.size, nodes).setMeta('preventUpdate', !emitUpdate)
}
return true
}
if (dispatch) {
tr.setMeta('preventUpdate', !emitUpdate)
}
return commands.insertContentAt({ from: 0, to: doc.content.size }, content, {
parseOptions,
errorOnInvalidContent: errorOnInvalidContent ?? editor.options.enableContentCheck,
})
}

View File

@@ -0,0 +1,122 @@
import type { MarkType, ResolvedPos } from '@tiptap/pm/model'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { getMarkAttributes } from '../helpers/getMarkAttributes.js'
import { getMarkType } from '../helpers/getMarkType.js'
import { isTextSelection } from '../helpers/index.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
setMark: {
/**
* Add a mark with new attributes.
* @param typeOrName The mark type or name.
* @example editor.commands.setMark('bold', { level: 1 })
*/
setMark: (typeOrName: string | MarkType, attributes?: Record<string, any>) => ReturnType
}
}
}
function canSetMark(state: EditorState, tr: Transaction, newMarkType: MarkType) {
const { selection } = tr
let cursor: ResolvedPos | null = null
if (isTextSelection(selection)) {
cursor = selection.$cursor
}
if (cursor) {
const currentMarks = state.storedMarks ?? cursor.marks()
const parentAllowsMarkType = cursor.parent.type.allowsMarkType(newMarkType)
// There can be no current marks that exclude the new mark, and the parent must allow this mark type
return (
parentAllowsMarkType &&
(!!newMarkType.isInSet(currentMarks) ||
!currentMarks.some(mark => mark.type.excludes(newMarkType)))
)
}
const { ranges } = selection
return ranges.some(({ $from, $to }) => {
let someNodeSupportsMark =
$from.depth === 0
? state.doc.inlineContent && state.doc.type.allowsMarkType(newMarkType)
: false
state.doc.nodesBetween($from.pos, $to.pos, (node, _pos, parent) => {
// If we already found a mark that we can enable, return false to bypass the remaining search
if (someNodeSupportsMark) {
return false
}
if (node.isInline) {
const parentAllowsMarkType = !parent || parent.type.allowsMarkType(newMarkType)
const currentMarksAllowMarkType =
!!newMarkType.isInSet(node.marks) ||
!node.marks.some(otherMark => otherMark.type.excludes(newMarkType))
someNodeSupportsMark = parentAllowsMarkType && currentMarksAllowMarkType
}
return !someNodeSupportsMark
})
return someNodeSupportsMark
})
}
export const setMark: RawCommands['setMark'] =
(typeOrName, attributes = {}) =>
({ tr, state, dispatch }) => {
const { selection } = tr
const { empty, ranges } = selection
const type = getMarkType(typeOrName, state.schema)
if (dispatch) {
if (empty) {
const oldAttributes = getMarkAttributes(state, type)
tr.addStoredMark(
type.create({
...oldAttributes,
...attributes,
}),
)
} else {
ranges.forEach(range => {
const from = range.$from.pos
const to = range.$to.pos
state.doc.nodesBetween(from, to, (node, pos) => {
const trimmedFrom = Math.max(pos, from)
const trimmedTo = Math.min(pos + node.nodeSize, to)
const someHasMark = node.marks.find(mark => mark.type === type)
// if there is already a mark of this type
// we know that we have to merge its attributes
// otherwise we add a fresh new mark
if (someHasMark) {
node.marks.forEach(mark => {
if (type === mark.type) {
tr.addMark(
trimmedFrom,
trimmedTo,
type.create({
...mark.attrs,
...attributes,
}),
)
}
})
} else {
tr.addMark(trimmedFrom, trimmedTo, type.create(attributes))
}
})
})
}
}
return canSetMark(state, tr, type)
}

View File

@@ -0,0 +1,25 @@
import type { Plugin, PluginKey } from '@tiptap/pm/state'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
setMeta: {
/**
* Store a metadata property in the current transaction.
* @param key The key of the metadata property.
* @param value The value to store.
* @example editor.commands.setMeta('foo', 'bar')
*/
setMeta: (key: string | Plugin | PluginKey, value: any) => ReturnType
}
}
}
export const setMeta: RawCommands['setMeta'] =
(key, value) =>
({ tr }) => {
tr.setMeta(key, value)
return true
}

View File

@@ -0,0 +1,57 @@
import { setBlockType } from '@tiptap/pm/commands'
import type { NodeType } from '@tiptap/pm/model'
import { getNodeType } from '../helpers/getNodeType.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
setNode: {
/**
* Replace a given range with a node.
* @param typeOrName The type or name of the node
* @param attributes The attributes of the node
* @example editor.commands.setNode('paragraph')
*/
setNode: (typeOrName: string | NodeType, attributes?: Record<string, any>) => ReturnType
}
}
}
export const setNode: RawCommands['setNode'] =
(typeOrName, attributes = {}) =>
({ state, dispatch, chain }) => {
const type = getNodeType(typeOrName, state.schema)
let attributesToCopy: Record<string, any> | undefined
if (state.selection.$anchor.sameParent(state.selection.$head)) {
// only copy attributes if the selection is pointing to a node of the same type
attributesToCopy = state.selection.$anchor.parent.attrs
}
// TODO: use a fallback like insertContent?
if (!type.isTextblock) {
console.warn('[tiptap warn]: Currently "setNode()" only supports text block nodes.')
return false
}
return (
chain()
// try to convert node to default node if needed
.command(({ commands }) => {
const canSetBlock = setBlockType(type, { ...attributesToCopy, ...attributes })(state)
if (canSetBlock) {
return true
}
return commands.clearNodes()
})
.command(({ state: updatedState }) => {
return setBlockType(type, { ...attributesToCopy, ...attributes })(updatedState, dispatch)
})
.run()
)
}

View File

@@ -0,0 +1,31 @@
import { NodeSelection } from '@tiptap/pm/state'
import type { RawCommands } from '../types.js'
import { minMax } from '../utilities/minMax.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
setNodeSelection: {
/**
* Creates a NodeSelection.
* @param position - Position of the node.
* @example editor.commands.setNodeSelection(10)
*/
setNodeSelection: (position: number) => ReturnType
}
}
}
export const setNodeSelection: RawCommands['setNodeSelection'] =
position =>
({ tr, dispatch }) => {
if (dispatch) {
const { doc } = tr
const from = minMax(position, 0, doc.content.size)
const selection = NodeSelection.create(doc, from)
tr.setSelection(selection)
}
return true
}

View File

@@ -0,0 +1,51 @@
import type { Range, RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
setTextDirection: {
/**
* Set the text direction for nodes.
* If no position is provided, it will use the current selection.
* @param direction The text direction to set ('ltr', 'rtl', or 'auto')
* @param position Optional position or range to apply the direction to
* @example editor.commands.setTextDirection('rtl')
* @example editor.commands.setTextDirection('ltr', { from: 0, to: 10 })
*/
setTextDirection: (direction: 'ltr' | 'rtl' | 'auto', position?: number | Range) => ReturnType
}
}
}
export const setTextDirection: RawCommands['setTextDirection'] =
(direction, position) =>
({ tr, state, dispatch }) => {
const { selection } = state
let from: number
let to: number
if (typeof position === 'number') {
from = position
to = position
} else if (position && 'from' in position && 'to' in position) {
from = position.from
to = position.to
} else {
from = selection.from
to = selection.to
}
if (dispatch) {
tr.doc.nodesBetween(from, to, (node, pos) => {
if (node.isText) {
return
}
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
dir: direction,
})
})
}
return true
}

View File

@@ -0,0 +1,36 @@
import { TextSelection } from '@tiptap/pm/state'
import type { Range, RawCommands } from '../types.js'
import { minMax } from '../utilities/minMax.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
setTextSelection: {
/**
* Creates a TextSelection.
* @param position The position of the selection.
* @example editor.commands.setTextSelection(10)
*/
setTextSelection: (position: number | Range) => ReturnType
}
}
}
export const setTextSelection: RawCommands['setTextSelection'] =
position =>
({ tr, dispatch }) => {
if (dispatch) {
const { doc } = tr
const { from, to } =
typeof position === 'number' ? { from: position, to: position } : position
const minPos = TextSelection.atStart(doc).from
const maxPos = TextSelection.atEnd(doc).to
const resolvedFrom = minMax(from, minPos, maxPos)
const resolvedEnd = minMax(to, minPos, maxPos)
const selection = TextSelection.create(doc, resolvedFrom, resolvedEnd)
tr.setSelection(selection)
}
return true
}

View File

@@ -0,0 +1,26 @@
import type { NodeType } from '@tiptap/pm/model'
import { sinkListItem as originalSinkListItem } from '@tiptap/pm/schema-list'
import { getNodeType } from '../helpers/getNodeType.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
sinkListItem: {
/**
* Sink the list item down into an inner list.
* @param typeOrName The type or name of the node.
* @example editor.commands.sinkListItem('listItem')
*/
sinkListItem: (typeOrName: string | NodeType) => ReturnType
}
}
}
export const sinkListItem: RawCommands['sinkListItem'] =
typeOrName =>
({ state, dispatch }) => {
const type = getNodeType(typeOrName, state.schema)
return originalSinkListItem(type)(state, dispatch)
}

View File

@@ -0,0 +1,127 @@
import type { EditorState } from '@tiptap/pm/state'
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
import { canSplit } from '@tiptap/pm/transform'
import { defaultBlockAt } from '../helpers/defaultBlockAt.js'
import { getSplittedAttributes } from '../helpers/getSplittedAttributes.js'
import type { RawCommands } from '../types.js'
function ensureMarks(state: EditorState, splittableMarks?: string[]) {
const marks =
state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks())
if (marks) {
const filteredMarks = marks.filter(mark => splittableMarks?.includes(mark.type.name))
state.tr.ensureMarks(filteredMarks)
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
splitBlock: {
/**
* Forks a new node from an existing node.
* @param options.keepMarks Keep marks from the previous node.
* @example editor.commands.splitBlock()
* @example editor.commands.splitBlock({ keepMarks: true })
*/
splitBlock: (options?: { keepMarks?: boolean }) => ReturnType
}
}
}
export const splitBlock: RawCommands['splitBlock'] =
({ keepMarks = true } = {}) =>
({ tr, state, dispatch, editor }) => {
const { selection, doc } = tr
const { $from, $to } = selection
const extensionAttributes = editor.extensionManager.attributes
const newAttributes = getSplittedAttributes(
extensionAttributes,
$from.node().type.name,
$from.node().attrs,
)
if (selection instanceof NodeSelection && selection.node.isBlock) {
if (!$from.parentOffset || !canSplit(doc, $from.pos)) {
return false
}
if (dispatch) {
if (keepMarks) {
ensureMarks(state, editor.extensionManager.splittableMarks)
}
tr.split($from.pos).scrollIntoView()
}
return true
}
if (!$from.parent.isBlock) {
return false
}
const atEnd = $to.parentOffset === $to.parent.content.size
const deflt =
$from.depth === 0
? undefined
: defaultBlockAt($from.node(-1).contentMatchAt($from.indexAfter(-1)))
let types =
atEnd && deflt
? [
{
type: deflt,
attrs: newAttributes,
},
]
: undefined
let can = canSplit(tr.doc, tr.mapping.map($from.pos), 1, types)
if (
!types &&
!can &&
canSplit(tr.doc, tr.mapping.map($from.pos), 1, deflt ? [{ type: deflt }] : undefined)
) {
can = true
types = deflt
? [
{
type: deflt,
attrs: newAttributes,
},
]
: undefined
}
if (dispatch) {
if (can) {
if (selection instanceof TextSelection) {
tr.deleteSelection()
}
tr.split(tr.mapping.map($from.pos), 1, types)
if (deflt && !atEnd && !$from.parentOffset && $from.parent.type !== deflt) {
const first = tr.mapping.map($from.before())
const $first = tr.doc.resolve(first)
if ($from.node(-1).canReplaceWith($first.index(), $first.index() + 1, deflt)) {
tr.setNodeMarkup(tr.mapping.map($from.before()), deflt)
}
}
}
if (keepMarks) {
ensureMarks(state, editor.extensionManager.splittableMarks)
}
tr.scrollIntoView()
}
return can
}

View File

@@ -0,0 +1,157 @@
import type { Node as PMNode, NodeType } from '@tiptap/pm/model'
import { Fragment, Slice } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import { canSplit } from '@tiptap/pm/transform'
import { getNodeType } from '../helpers/getNodeType.js'
import { getSplittedAttributes } from '../helpers/getSplittedAttributes.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
splitListItem: {
/**
* Splits one list item into two list items.
* @param typeOrName The type or name of the node.
* @param overrideAttrs The attributes to ensure on the new node.
* @example editor.commands.splitListItem('listItem')
*/
splitListItem: (
typeOrName: string | NodeType,
overrideAttrs?: Record<string, any>,
) => ReturnType
}
}
}
export const splitListItem: RawCommands['splitListItem'] =
(typeOrName, overrideAttrs = {}) =>
({ tr, state, dispatch, editor }) => {
const type = getNodeType(typeOrName, state.schema)
const { $from, $to } = state.selection
// @ts-ignore
// oxlint-disable-next-line
const node: PMNode = state.selection.node
if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) {
return false
}
const grandParent = $from.node(-1)
if (grandParent.type !== type) {
return false
}
const extensionAttributes = editor.extensionManager.attributes
if ($from.parent.content.size === 0 && $from.node(-1).childCount === $from.indexAfter(-1)) {
// In an empty block. If this is a nested list, the wrapping
// list item should be split. Otherwise, bail out and let next
// command handle lifting.
if (
$from.depth === 2 ||
$from.node(-3).type !== type ||
$from.index(-2) !== $from.node(-2).childCount - 1
) {
return false
}
if (dispatch) {
let wrap = Fragment.empty
// oxlint-disable-next-line
const depthBefore = $from.index(-1) ? 1 : $from.index(-2) ? 2 : 3
// Build a fragment containing empty versions of the structure
// from the outer list item to the parent node of the cursor
for (let d = $from.depth - depthBefore; d >= $from.depth - 3; d -= 1) {
wrap = Fragment.from($from.node(d).copy(wrap))
}
const depthAfter =
// oxlint-disable-next-line no-nested-ternary
$from.indexAfter(-1) < $from.node(-2).childCount
? 1
: $from.indexAfter(-2) < $from.node(-3).childCount
? 2
: 3
// Add a second list item with an empty default start node
const newNextTypeAttributes = {
...getSplittedAttributes(extensionAttributes, $from.node().type.name, $from.node().attrs),
...overrideAttrs,
}
const nextType =
type.contentMatch.defaultType?.createAndFill(newNextTypeAttributes) || undefined
wrap = wrap.append(Fragment.from(type.createAndFill(null, nextType) || undefined))
const start = $from.before($from.depth - (depthBefore - 1))
tr.replace(start, $from.after(-depthAfter), new Slice(wrap, 4 - depthBefore, 0))
let sel = -1
tr.doc.nodesBetween(start, tr.doc.content.size, (n, pos) => {
if (sel > -1) {
return false
}
if (n.isTextblock && n.content.size === 0) {
sel = pos + 1
}
})
if (sel > -1) {
tr.setSelection(TextSelection.near(tr.doc.resolve(sel)))
}
tr.scrollIntoView()
}
return true
}
const nextType = $to.pos === $from.end() ? grandParent.contentMatchAt(0).defaultType : null
const newTypeAttributes = {
...getSplittedAttributes(extensionAttributes, grandParent.type.name, grandParent.attrs),
...overrideAttrs,
}
const newNextTypeAttributes = {
...getSplittedAttributes(extensionAttributes, $from.node().type.name, $from.node().attrs),
...overrideAttrs,
}
tr.delete($from.pos, $to.pos)
const types = nextType
? [
{ type, attrs: newTypeAttributes },
{ type: nextType, attrs: newNextTypeAttributes },
]
: [{ type, attrs: newTypeAttributes }]
if (!canSplit(tr.doc, $from.pos, 2)) {
return false
}
if (dispatch) {
const { selection, storedMarks } = state
const { splittableMarks } = editor.extensionManager
const marks = storedMarks || (selection.$to.parentOffset && selection.$from.marks())
tr.split($from.pos, 2, types).scrollIntoView()
if (!marks || !dispatch) {
return true
}
const filteredMarks = marks.filter(mark => splittableMarks.includes(mark.type.name))
tr.ensureMarks(filteredMarks)
}
return true
}

View File

@@ -0,0 +1,258 @@
import type { NodeType } from '@tiptap/pm/model'
import type { Transaction } from '@tiptap/pm/state'
import { TextSelection } from '@tiptap/pm/state'
import { canJoin } from '@tiptap/pm/transform'
import { findParentNode } from '../helpers/findParentNode.js'
import { getNodeType } from '../helpers/getNodeType.js'
import { isList } from '../helpers/isList.js'
import type { RawCommands } from '../types.js'
/**
* Normalise a list type attribute for comparison.
* Treats null, undefined, and "1" as equivalent (the default numeric type).
*/
function normalizeListType(type: string | null | undefined): string | null {
return !type || type === '1' ? null : type
}
/**
* Check if two list type attributes are compatible for joining.
* Lists can only join when they have the same type (both default, or both the same non-default type).
*/
function areListTypesCompatible(
typeA: string | null | undefined,
typeB: string | null | undefined,
): boolean {
return normalizeListType(typeA) === normalizeListType(typeB)
}
const joinListBackwards = (tr: Transaction, listType: NodeType): boolean => {
const list = findParentNode(node => node.type === listType)(tr.selection)
if (!list) {
return true
}
const before = tr.doc.resolve(Math.max(0, list.pos - 1)).before(list.depth)
if (before === undefined) {
return true
}
const nodeBefore = tr.doc.nodeAt(before)
const canJoinBackwards = list.node.type === nodeBefore?.type && canJoin(tr.doc, list.pos)
if (!canJoinBackwards) {
return true
}
// Don't join if the type attributes are incompatible
// (e.g. a default-type list should not merge with a type="a" list)
if (!areListTypesCompatible(list.node.attrs.type, nodeBefore?.attrs.type)) {
return true
}
tr.join(list.pos)
return true
}
const joinListForwards = (tr: Transaction, listType: NodeType): boolean => {
const list = findParentNode(node => node.type === listType)(tr.selection)
if (!list) {
return true
}
const after = tr.doc.resolve(list.start).after(list.depth)
if (after === undefined) {
return true
}
const nodeAfter = tr.doc.nodeAt(after)
const canJoinForwards = list.node.type === nodeAfter?.type && canJoin(tr.doc, after)
if (!canJoinForwards) {
return true
}
// Don't join if the type attributes are incompatible
if (!areListTypesCompatible(list.node.attrs.type, nodeAfter?.attrs.type)) {
return true
}
tr.join(after)
return true
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
toggleList: {
/**
* Toggle between different list types.
* @param listTypeOrName The type or name of the list.
* @param itemTypeOrName The type or name of the list item.
* @param keepMarks Keep marks when toggling.
* @param attributes Attributes for the new list.
* @example editor.commands.toggleList('bulletList', 'listItem')
*/
toggleList: (
listTypeOrName: string | NodeType,
itemTypeOrName: string | NodeType,
keepMarks?: boolean,
attributes?: Record<string, any>,
) => ReturnType
}
}
}
function createInnerSelectionForWholeDocList(tr: Transaction) {
const doc = tr.doc
const list = doc.firstChild
if (!list) {
return null
}
// Place the selection inside the list node so that ProseMirror's
// liftListItem command can operate. AllSelection sits at the doc root.
// Use TextSelection.between to resolve positions into valid inline
// content positions, so the selection survives position mapping after
// liftListItem removes list/item wrappers.
const $start = doc.resolve(1)
const $end = doc.resolve(list.nodeSize - 1)
return TextSelection.between($start, $end)
}
export const toggleList: RawCommands['toggleList'] =
(listTypeOrName, itemTypeOrName, keepMarks, attributes = {}) =>
({ editor, tr, state, dispatch, chain, commands, can }) => {
const { extensions, splittableMarks } = editor.extensionManager
const listType = getNodeType(listTypeOrName, state.schema)
const itemType = getNodeType(itemTypeOrName, state.schema)
const { selection, storedMarks } = state
const { $from, $to } = selection
const range = $from.blockRange($to)
const marks = storedMarks || (selection.$to.parentOffset && selection.$from.marks())
if (!range) {
return false
}
const parentList = findParentNode(node => isList(node.type.name, extensions))(selection)
// When the user presses Ctrl/Cmd+A, ProseMirror creates an `AllSelection`
// covering the entire document (0..doc.content.size). In that case
// `findParentNode` cannot detect the surrounding list because the
// selection sits at the document root. If the document consists of a
// single top-level list node, treat that list as the active list so the
// toggle logic can correctly lift or change it.
const isAllSelection = selection.from === 0 && selection.to === state.doc.content.size
const topLevelNodes = state.doc.content.content
const soleTopLevelNode = topLevelNodes.length === 1 ? topLevelNodes[0] : null
const allSelectionList =
isAllSelection && soleTopLevelNode && isList(soleTopLevelNode.type.name, extensions)
? {
node: soleTopLevelNode,
pos: 0,
depth: 0,
}
: null
const currentList = parentList ?? allSelectionList
const isInsideExistingList =
!!parentList && range.depth >= 1 && range.depth - parentList.depth <= 1
const hasWholeDocSelectedList = !!allSelectionList
if ((isInsideExistingList || hasWholeDocSelectedList) && currentList) {
// remove list
if (currentList.node.type === listType) {
if (isAllSelection && hasWholeDocSelectedList) {
return chain()
.command(({ tr: trx, dispatch: disp }) => {
// Ctrl/Cmd+A creates an AllSelection at the document root.
// When the whole document is a single top-level list, normalize the
// selection into that list before lifting, since liftListItem expects
// a selection inside a list item.
const nextSelection = createInnerSelectionForWholeDocList(trx)
if (!nextSelection) {
return false
}
trx.setSelection(nextSelection)
if (disp) {
disp(trx)
}
return true
})
.liftListItem(itemType)
.run()
}
return commands.liftListItem(itemType)
}
// change list type
if (
isList(currentList.node.type.name, extensions) &&
listType.validContent(currentList.node.content)
) {
return chain()
.command(() => {
tr.setNodeMarkup(currentList.pos, listType)
return true
})
.command(() => joinListBackwards(tr, listType))
.command(() => joinListForwards(tr, listType))
.run()
}
}
if (!keepMarks || !marks || !dispatch) {
return (
chain()
// try to convert node to default node if needed
.command(() => {
const canWrapInList = can().wrapInList(listType, attributes)
if (canWrapInList) {
return true
}
return commands.clearNodes()
})
.wrapInList(listType, attributes)
.command(() => joinListBackwards(tr, listType))
.command(() => joinListForwards(tr, listType))
.run()
)
}
return (
chain()
// try to convert node to default node if needed
.command(() => {
const canWrapInList = can().wrapInList(listType, attributes)
const filteredMarks = marks.filter(mark => splittableMarks.includes(mark.type.name))
tr.ensureMarks(filteredMarks)
if (canWrapInList) {
return true
}
return commands.clearNodes()
})
.wrapInList(listType, attributes)
.command(() => joinListBackwards(tr, listType))
.command(() => joinListForwards(tr, listType))
.run()
)
}

View File

@@ -0,0 +1,51 @@
import type { MarkType } from '@tiptap/pm/model'
import { getMarkType } from '../helpers/getMarkType.js'
import { isMarkActive } from '../helpers/isMarkActive.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
toggleMark: {
/**
* Toggle a mark on and off.
* @param typeOrName The mark type or name.
* @param attributes The attributes of the mark.
* @param options.extendEmptyMarkRange Removes the mark even across the current selection. Defaults to `false`.
* @example editor.commands.toggleMark('bold')
*/
toggleMark: (
/**
* The mark type or name.
*/
typeOrName: string | MarkType,
/**
* The attributes of the mark.
*/
attributes?: Record<string, any>,
options?: {
/**
* Removes the mark even across the current selection. Defaults to `false`.
*/
extendEmptyMarkRange?: boolean
},
) => ReturnType
}
}
}
export const toggleMark: RawCommands['toggleMark'] =
(typeOrName, attributes = {}, options = {}) =>
({ state, commands }) => {
const { extendEmptyMarkRange = false } = options
const type = getMarkType(typeOrName, state.schema)
const isActive = isMarkActive(state, type, attributes)
if (isActive) {
return commands.unsetMark(type, { extendEmptyMarkRange })
}
return commands.setMark(type, attributes)
}

View File

@@ -0,0 +1,47 @@
import type { NodeType } from '@tiptap/pm/model'
import { getNodeType } from '../helpers/getNodeType.js'
import { isNodeActive } from '../helpers/isNodeActive.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
toggleNode: {
/**
* Toggle a node with another node.
* @param typeOrName The type or name of the node.
* @param toggleTypeOrName The type or name of the node to toggle.
* @param attributes The attributes of the node.
* @example editor.commands.toggleNode('heading', 'paragraph')
*/
toggleNode: (
typeOrName: string | NodeType,
toggleTypeOrName: string | NodeType,
attributes?: Record<string, any>,
) => ReturnType
}
}
}
export const toggleNode: RawCommands['toggleNode'] =
(typeOrName, toggleTypeOrName, attributes = {}) =>
({ state, commands }) => {
const type = getNodeType(typeOrName, state.schema)
const toggleType = getNodeType(toggleTypeOrName, state.schema)
const isActive = isNodeActive(state, type, attributes)
let attributesToCopy: Record<string, any> | undefined
if (state.selection.$anchor.sameParent(state.selection.$head)) {
// only copy attributes if the selection is pointing to a node of the same type
attributesToCopy = state.selection.$anchor.parent.attrs
}
if (isActive) {
return commands.setNode(toggleType, attributesToCopy)
}
// If the node is not active, we want to set the new node type with the given attributes
// Copying over the attributes from the current node if the selection is pointing to a node of the same type
return commands.setNode(type, { ...attributesToCopy, ...attributes })
}

View File

@@ -0,0 +1,32 @@
import type { NodeType } from '@tiptap/pm/model'
import { getNodeType } from '../helpers/getNodeType.js'
import { isNodeActive } from '../helpers/isNodeActive.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
toggleWrap: {
/**
* Wraps nodes in another node, or removes an existing wrap.
* @param typeOrName The type or name of the node.
* @param attributes The attributes of the node.
* @example editor.commands.toggleWrap('blockquote')
*/
toggleWrap: (typeOrName: string | NodeType, attributes?: Record<string, any>) => ReturnType
}
}
}
export const toggleWrap: RawCommands['toggleWrap'] =
(typeOrName, attributes = {}) =>
({ state, commands }) => {
const type = getNodeType(typeOrName, state.schema)
const isActive = isNodeActive(state, type, attributes)
if (isActive) {
return commands.lift(type)
}
return commands.wrapIn(type, attributes)
}

View File

@@ -0,0 +1,49 @@
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
undoInputRule: {
/**
* Undo an input rule.
* @example editor.commands.undoInputRule()
*/
undoInputRule: () => ReturnType
}
}
}
export const undoInputRule: RawCommands['undoInputRule'] =
() =>
({ state, dispatch }) => {
const plugins = state.plugins
for (let i = 0; i < plugins.length; i += 1) {
const plugin = plugins[i]
let undoable
// @ts-ignore
// oxlint-disable-next-line
if (plugin.spec.isInputRules && (undoable = plugin.getState(state))) {
if (dispatch) {
const tr = state.tr
const toUndo = undoable.transform
for (let j = toUndo.steps.length - 1; j >= 0; j -= 1) {
tr.step(toUndo.steps[j].invert(toUndo.docs[j]))
}
if (undoable.text) {
const marks = tr.doc.resolve(undoable.from).marks()
tr.replaceWith(undoable.from, undoable.to, state.schema.text(undoable.text, marks))
} else {
tr.delete(undoable.from, undoable.to)
}
}
return true
}
}
return false
}

View File

@@ -0,0 +1,109 @@
import Bold from '@tiptap/extension-bold'
import Document from '@tiptap/extension-document'
import Italic from '@tiptap/extension-italic'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { Editor } from '../Editor.js'
import { Mark } from '../Mark.js'
const Annotation = Mark.create({
name: 'annotation',
clearable: false,
parseHTML() {
return [{ tag: 'span[data-annotation]' }]
},
renderHTML() {
return ['span', { 'data-annotation': 'true' }, 0]
},
})
describe('unsetAllMarks', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
it('removes all clearable marks in the selection', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold, Italic],
content: '<p><strong><em>hello</em></strong></p>',
})
editor.commands.selectAll()
editor.commands.unsetAllMarks()
expect(editor.getHTML()).toBe('<p>hello</p>')
})
it('preserves marks with clearable: false', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold, Annotation],
content: '<p><span data-annotation="true"><strong>hello</strong></span></p>',
})
editor.commands.selectAll()
editor.commands.unsetAllMarks()
expect(editor.getHTML()).toBe('<p><span data-annotation="true">hello</span></p>')
})
it('returns true for can() when the selection only has non-clearable marks (no-op)', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Annotation],
content: '<p><span data-annotation="true">hello</span></p>',
})
editor.commands.selectAll()
expect(editor.can().unsetAllMarks()).toBe(true)
})
it('returns true for can() when the selection has clearable marks', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold, Annotation],
content: '<p><span data-annotation="true"><strong>hello</strong></span></p>',
})
editor.commands.selectAll()
expect(editor.can().unsetAllMarks()).toBe(true)
})
it('removes all marks including non-clearable ones with ignoreClearable option', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold, Annotation],
content: '<p><span data-annotation="true"><strong>hello</strong></span></p>',
})
editor.commands.selectAll()
editor.commands.unsetAllMarks({ ignoreClearable: true })
expect(editor.getHTML()).toBe('<p>hello</p>')
})
it('returns true for can() with ignoreClearable even with only non-clearable marks', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Annotation],
content: '<p><span data-annotation="true">hello</span></p>',
})
editor.commands.selectAll()
expect(editor.can().unsetAllMarks({ ignoreClearable: true })).toBe(true)
})
it('allows explicit unsetMark on non-clearable marks', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Annotation],
content: '<p><span data-annotation="true">hello</span></p>',
})
editor.commands.selectAll()
editor.commands.unsetMark('annotation')
expect(editor.getHTML()).toBe('<p>hello</p>')
})
})

View File

@@ -0,0 +1,44 @@
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
unsetAllMarks: {
/**
* Remove all clearable marks in the current selection.
* Marks with `clearable: false` are preserved
* @param options.ignoreClearable If true, removes all marks regardless of `clearable` setting. Defaults to `false`.
* @example editor.commands.unsetAllMarks()
* @example editor.commands.unsetAllMarks({ ignoreClearable: true })
*/
unsetAllMarks: (options?: { ignoreClearable?: boolean }) => ReturnType
}
}
}
export const unsetAllMarks: RawCommands['unsetAllMarks'] =
(options = {}) =>
({ tr, dispatch, editor }) => {
const { ignoreClearable = false } = options
const { selection } = tr
const { empty, ranges } = selection
if (empty) {
return true
}
const { nonClearableMarks } = editor.extensionManager
if (dispatch) {
const clearableMarkTypes = Object.values(editor.schema.marks).filter(
markType => ignoreClearable || !nonClearableMarks.includes(markType.name),
)
ranges.forEach(range => {
for (const markType of clearableMarkTypes) {
tr.removeMark(range.$from.pos, range.$to.pos, markType)
}
})
}
return true
}

View File

@@ -0,0 +1,65 @@
import type { MarkType } from '@tiptap/pm/model'
import { getMarkRange } from '../helpers/getMarkRange.js'
import { getMarkType } from '../helpers/getMarkType.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
unsetMark: {
/**
* Remove all marks in the current selection.
* @param typeOrName The mark type or name.
* @param options.extendEmptyMarkRange Removes the mark even across the current selection. Defaults to `false`.
* @example editor.commands.unsetMark('bold')
*/
unsetMark: (
/**
* The mark type or name.
*/
typeOrName: string | MarkType,
options?: {
/**
* Removes the mark even across the current selection. Defaults to `false`.
*/
extendEmptyMarkRange?: boolean
},
) => ReturnType
}
}
}
export const unsetMark: RawCommands['unsetMark'] =
(typeOrName, options = {}) =>
({ tr, state, dispatch }) => {
const { extendEmptyMarkRange = false } = options
const { selection } = tr
const type = getMarkType(typeOrName, state.schema)
const { $from, empty, ranges } = selection
if (!dispatch) {
return true
}
if (empty && extendEmptyMarkRange) {
let { from, to } = selection
const attrs = $from.marks().find(mark => mark.type === type)?.attrs
const range = getMarkRange($from, type, attrs)
if (range) {
from = range.from
to = range.to
}
tr.removeMark(from, to, type)
} else {
ranges.forEach(range => {
tr.removeMark(range.$from.pos, range.$to.pos, type)
})
}
tr.removeStoredMark(type)
return true
}

View File

@@ -0,0 +1,51 @@
import type { Range, RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
unsetTextDirection: {
/**
* Remove the text direction attribute from nodes.
* If no position is provided, it will use the current selection.
* @param position Optional position or range to remove the direction from
* @example editor.commands.unsetTextDirection()
* @example editor.commands.unsetTextDirection({ from: 0, to: 10 })
*/
unsetTextDirection: (position?: number | Range) => ReturnType
}
}
}
export const unsetTextDirection: RawCommands['unsetTextDirection'] =
position =>
({ tr, state, dispatch }) => {
const { selection } = state
let from: number
let to: number
if (typeof position === 'number') {
from = position
to = position
} else if (position && 'from' in position && 'to' in position) {
from = position.from
to = position.to
} else {
from = selection.from
to = selection.to
}
if (dispatch) {
tr.doc.nodesBetween(from, to, (node, pos) => {
if (node.isText) {
return
}
const newAttrs = { ...node.attrs }
delete newAttrs.dir
tr.setNodeMarkup(pos, undefined, newAttrs)
})
}
return true
}

View File

@@ -0,0 +1,150 @@
import type { Mark, MarkType, Node as PMNode, NodeType } from '@tiptap/pm/model'
import type { SelectionRange } from '@tiptap/pm/state'
import { getMarkType } from '../helpers/getMarkType.js'
import { getNodeType } from '../helpers/getNodeType.js'
import { getSchemaTypeNameByName } from '../helpers/getSchemaTypeNameByName.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
updateAttributes: {
/**
* Update attributes of a node or mark.
* @param typeOrName The type or name of the node or mark.
* @param attributes The attributes of the node or mark.
* @example editor.commands.updateAttributes('mention', { userId: "2" })
*/
updateAttributes: (
/**
* The type or name of the node or mark.
*/
typeOrName: string | NodeType | MarkType,
/**
* The attributes of the node or mark.
*/
attributes: Record<string, any>,
) => ReturnType
}
}
}
export const updateAttributes: RawCommands['updateAttributes'] =
(typeOrName, attributes = {}) =>
({ tr, state, dispatch }) => {
let nodeType: NodeType | null = null
let markType: MarkType | null = null
const schemaType = getSchemaTypeNameByName(
typeof typeOrName === 'string' ? typeOrName : typeOrName.name,
state.schema,
)
if (!schemaType) {
return false
}
if (schemaType === 'node') {
nodeType = getNodeType(typeOrName as NodeType, state.schema)
}
if (schemaType === 'mark') {
markType = getMarkType(typeOrName as MarkType, state.schema)
}
let canUpdate = false
tr.selection.ranges.forEach((range: SelectionRange) => {
const from = range.$from.pos
const to = range.$to.pos
let lastPos: number | undefined
let lastNode: PMNode | undefined
let trimmedFrom: number
let trimmedTo: number
if (tr.selection.empty) {
state.doc.nodesBetween(from, to, (node: PMNode, pos: number) => {
if (nodeType && nodeType === node.type) {
canUpdate = true
trimmedFrom = Math.max(pos, from)
trimmedTo = Math.min(pos + node.nodeSize, to)
lastPos = pos
lastNode = node
}
})
} else {
state.doc.nodesBetween(from, to, (node: PMNode, pos: number) => {
if (pos < from && nodeType && nodeType === node.type) {
canUpdate = true
trimmedFrom = Math.max(pos, from)
trimmedTo = Math.min(pos + node.nodeSize, to)
lastPos = pos
lastNode = node
}
if (pos >= from && pos <= to) {
if (nodeType && nodeType === node.type) {
canUpdate = true
if (dispatch) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
...attributes,
})
}
}
if (markType && node.marks.length) {
node.marks.forEach((mark: Mark) => {
if (markType === mark.type) {
canUpdate = true
if (dispatch) {
const trimmedFrom2 = Math.max(pos, from)
const trimmedTo2 = Math.min(pos + node.nodeSize, to)
tr.addMark(
trimmedFrom2,
trimmedTo2,
markType.create({
...mark.attrs,
...attributes,
}),
)
}
}
})
}
}
})
}
if (lastNode) {
if (lastPos !== undefined && dispatch) {
tr.setNodeMarkup(lastPos, undefined, {
...lastNode.attrs,
...attributes,
})
}
if (markType && lastNode.marks.length) {
lastNode.marks.forEach((mark: Mark) => {
if (markType === mark.type && dispatch) {
tr.addMark(
trimmedFrom,
trimmedTo,
markType.create({
...mark.attrs,
...attributes,
}),
)
}
})
}
}
})
return canUpdate
}

View File

@@ -0,0 +1,28 @@
import { DECORATION_MANAGER_PLUGIN_KEY } from '../decorations/constants.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
updateDecorations: {
/**
* Force declarative decorations to recompute, bypassing each extension's
* `shouldUpdate` gate. Pass an extension name to recompute only that
* extension's decorations, or omit it to recompute all of them.
* @param extensionName The name of the extension to recompute (optional).
* @example editor.commands.updateDecorations()
* @example editor.commands.updateDecorations('myExtension')
*/
updateDecorations: (extensionName?: string) => ReturnType
}
}
}
export const updateDecorations: RawCommands['updateDecorations'] =
extensionName =>
({ tr, dispatch }) => {
if (dispatch) {
tr.setMeta(DECORATION_MANAGER_PLUGIN_KEY, { type: 'force', name: extensionName })
}
return true
}

View File

@@ -0,0 +1,27 @@
import { wrapIn as originalWrapIn } from '@tiptap/pm/commands'
import type { NodeType } from '@tiptap/pm/model'
import { getNodeType } from '../helpers/getNodeType.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
wrapIn: {
/**
* Wraps nodes in another node.
* @param typeOrName The type or name of the node.
* @param attributes The attributes of the node.
* @example editor.commands.wrapIn('blockquote')
*/
wrapIn: (typeOrName: string | NodeType, attributes?: Record<string, any>) => ReturnType
}
}
}
export const wrapIn: RawCommands['wrapIn'] =
(typeOrName, attributes = {}) =>
({ state, dispatch }) => {
const type = getNodeType(typeOrName, state.schema)
return originalWrapIn(type, attributes)(state, dispatch)
}

View File

@@ -0,0 +1,27 @@
import type { NodeType } from '@tiptap/pm/model'
import { wrapInList as originalWrapInList } from '@tiptap/pm/schema-list'
import { getNodeType } from '../helpers/getNodeType.js'
import type { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
wrapInList: {
/**
* Wrap a node in a list.
* @param typeOrName The type or name of the node.
* @param attributes The attributes of the node.
* @example editor.commands.wrapInList('bulletList')
*/
wrapInList: (typeOrName: string | NodeType, attributes?: Record<string, any>) => ReturnType
}
}
}
export const wrapInList: RawCommands['wrapInList'] =
(typeOrName, attributes = {}) =>
({ state, dispatch }) => {
const type = getNodeType(typeOrName, state.schema)
return originalWrapInList(type, attributes)(state, dispatch)
}

Some files were not shown because too many files have changed in this diff Show More