snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
13
packages/ai-toolkit/CHANGELOG.md
Normal file
13
packages/ai-toolkit/CHANGELOG.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# @tiptap/ai-toolkit
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 32b7d87: Add the `AiInsertReveal` extension (`@tiptap/ai-toolkit/streaming-reveal`) to fade in text as the AI streams it into a collaborative document.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c8c174e: Rename `@tiptap/server-ai-toolkit` to `@tiptap/ai-toolkit`. The former package remains available as a deprecated alias; update imports to use `@tiptap/ai-toolkit`.
|
||||
18
packages/ai-toolkit/README.md
Normal file
18
packages/ai-toolkit/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/ai-toolkit
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/ai-toolkit)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/ai-toolkit)
|
||||
[](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).
|
||||
80
packages/ai-toolkit/package.json
Normal file
80
packages/ai-toolkit/package.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@tiptap/ai-toolkit",
|
||||
"version": "0.4.0",
|
||||
"description": "Integrate AI capabilities into the Tiptap editor. This package contains the SDK for connecting to the Server AI Toolkit service.",
|
||||
"keywords": [
|
||||
"ai toolkit",
|
||||
"server",
|
||||
"server ai toolkit",
|
||||
"tiptap",
|
||||
"tiptap ai"
|
||||
],
|
||||
"homepage": "https://tiptap.dev/docs/editor/extensions/functionality/ai-toolkit",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ueberdosis/tiptap/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
"directory": "packages/ai-toolkit"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": {
|
||||
"import": "./dist/index.d.ts",
|
||||
"require": "./dist/index.d.cts"
|
||||
},
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./streaming-reveal": {
|
||||
"types": {
|
||||
"import": "./dist/streaming-reveal.d.ts",
|
||||
"require": "./dist/streaming-reveal.d.cts"
|
||||
},
|
||||
"import": "./dist/streaming-reveal.js",
|
||||
"require": "./dist/streaming-reveal.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack",
|
||||
"dev": "vp pack --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"es-toolkit": "^1.41.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "workspace:^",
|
||||
"@tiptap/pm": "workspace:^",
|
||||
"@tiptap/y-tiptap": "^3.0.7",
|
||||
"yjs": "^13.6.23"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.1",
|
||||
"@tiptap/pm": "^3.0.1",
|
||||
"@tiptap/y-tiptap": "^3.0.0",
|
||||
"yjs": "^13.6.23"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@tiptap/y-tiptap": {
|
||||
"optional": true
|
||||
},
|
||||
"yjs": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
/**
|
||||
* The attribute name used to store the hash on nodes.
|
||||
*/
|
||||
const ATTRIBUTE_NAME = '_hash'
|
||||
|
||||
/**
|
||||
* Checks whether the editor already includes the full {@link AiToolkit} extension.
|
||||
*
|
||||
* @param extensionNames - Extension names available in the current editor setup.
|
||||
* @return `true` when the AI Toolkit extension is installed.
|
||||
*/
|
||||
function hasAiToolkitExtension(extensionNames: string[]): boolean {
|
||||
return extensionNames.includes('aiToolkit')
|
||||
}
|
||||
|
||||
/**
|
||||
* A Tiptap extension that registers `_hash` on non-inline nodes.
|
||||
*
|
||||
* The server package only contributes the attribute definition when AI Toolkit
|
||||
* itself is not already installed.
|
||||
*/
|
||||
export const ServerAiToolkitHashExtension = Extension.create({
|
||||
name: 'serverAiToolkitHash',
|
||||
|
||||
/**
|
||||
* Registers the hash attribute on all non-inline, non-text, non-doc node types.
|
||||
*
|
||||
* @return The global attribute configuration.
|
||||
*/
|
||||
addGlobalAttributes() {
|
||||
if (hasAiToolkitExtension(this.extensions.map(extension => extension.name))) {
|
||||
return []
|
||||
}
|
||||
|
||||
const types = this.extensions
|
||||
.filter(ext => {
|
||||
if (
|
||||
// Only add hashes to nodes, not to marks
|
||||
ext.type !== 'node' ||
|
||||
// Exclude certain node types
|
||||
ext.name === 'text' ||
|
||||
ext.name === 'doc' ||
|
||||
ext.name === 'tableHeader' ||
|
||||
ext.name === 'tableCell' ||
|
||||
// Exclude inline nodes
|
||||
(typeof ext.config?.group === 'string' && ext.config.group.includes('inline'))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(ext => ext.name)
|
||||
|
||||
return [
|
||||
{
|
||||
types,
|
||||
attributes: {
|
||||
[ATTRIBUTE_NAME]: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute(ATTRIBUTE_NAME),
|
||||
renderHTML: (attributes: Record<string, unknown>) => {
|
||||
if (!attributes[ATTRIBUTE_NAME]) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
[ATTRIBUTE_NAME]: attributes[ATTRIBUTE_NAME],
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
2
packages/ai-toolkit/src/index.ts
Normal file
2
packages/ai-toolkit/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './schema-awareness/index.js'
|
||||
export * from './server-ai-toolkit-extension.js'
|
||||
@@ -0,0 +1,86 @@
|
||||
import { type Editor, getExtensionField, Mark } from '@tiptap/core'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { EditorContext } from './types/editor-context.js'
|
||||
import type { AddJsonSchemaAwareness, JsonItem } from './types/json-item.js'
|
||||
import { defaultJsonItems } from './utils/default-json-items.js'
|
||||
import { mergeJsonItems } from './utils/merge-json-items.js'
|
||||
import { serializeSchema } from './utils/serialize-schema.js'
|
||||
|
||||
/**
|
||||
* Options for {@link getEditorContext}.
|
||||
*/
|
||||
export interface GetEditorContextOptions {
|
||||
/**
|
||||
* Custom schema awareness items to include in addition to the default ones.
|
||||
*
|
||||
* This lets callers extend the serialized editor context with custom node
|
||||
* definitions or overrides.
|
||||
* @default []
|
||||
*/
|
||||
customNodes?: JsonItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns editor context data including a serialized schema and merged custom nodes.
|
||||
*
|
||||
* This function collects schema information from extensions and converts it to a format
|
||||
* suitable for AI model consumption. Zod schemas in attributes are converted to JSON schemas.
|
||||
*
|
||||
* @param editor - The Tiptap editor instance
|
||||
* @param options - Configuration options for the editor context
|
||||
* @return An object containing the serialized schema and merged custom nodes with JSON schema attributes
|
||||
*/
|
||||
export function getEditorContext(
|
||||
editor: Editor,
|
||||
options: GetEditorContextOptions = {},
|
||||
): EditorContext {
|
||||
const extensionNamesSet = new Set(editor.extensionManager.extensions.map(ext => ext.name))
|
||||
|
||||
const customNodesFromExtensions = editor.extensionManager.extensions.flatMap(extension => {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
editor,
|
||||
}
|
||||
const addJsonSchemaAwareness = getExtensionField<AddJsonSchemaAwareness>(
|
||||
extension,
|
||||
'addJsonSchemaAwareness',
|
||||
context,
|
||||
)
|
||||
if (addJsonSchemaAwareness) {
|
||||
return {
|
||||
...addJsonSchemaAwareness(),
|
||||
isMark: extension instanceof Mark,
|
||||
extensionName: extension.name,
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const items = [defaultJsonItems, customNodesFromExtensions, options.customNodes ?? []]
|
||||
.reduce(mergeJsonItems, [])
|
||||
.filter((item: JsonItem) => extensionNamesSet.has(item.extensionName))
|
||||
|
||||
// Convert Zod schemas to JSON schemas
|
||||
const mergedCustomNodes = items.map((item: JsonItem) => {
|
||||
const { attributes, ...rest } = item
|
||||
if (!attributes) {
|
||||
return rest
|
||||
}
|
||||
|
||||
const attributesSchema = z.object(attributes)
|
||||
const jsonSchema = z.toJSONSchema(attributesSchema)
|
||||
|
||||
return {
|
||||
...rest,
|
||||
attributes: jsonSchema,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
serializedSchema: serializeSchema(editor.schema),
|
||||
items: mergedCustomNodes,
|
||||
}
|
||||
}
|
||||
2
packages/ai-toolkit/src/schema-awareness/index.ts
Normal file
2
packages/ai-toolkit/src/schema-awareness/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './get-editor-context.js'
|
||||
export * from './types/index.js'
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SerializedSchema } from '../utils/serialize-schema.js'
|
||||
import type { SerializedJsonItem } from './json-item.js'
|
||||
|
||||
/**
|
||||
* Result of {@link getEditorContext} containing information about the
|
||||
* editor's extensions that the Server AI Toolkit needs to work.
|
||||
*/
|
||||
export interface EditorContext {
|
||||
/**
|
||||
* The serialized ProseMirror schema
|
||||
*/
|
||||
serializedSchema: SerializedSchema
|
||||
/**
|
||||
* The merged custom nodes with Zod schemas converted to JSON schemas
|
||||
*/
|
||||
items: SerializedJsonItem[]
|
||||
}
|
||||
2
packages/ai-toolkit/src/schema-awareness/types/index.ts
Normal file
2
packages/ai-toolkit/src/schema-awareness/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './editor-context.js'
|
||||
export * from './json-item.js'
|
||||
51
packages/ai-toolkit/src/schema-awareness/types/json-item.ts
Normal file
51
packages/ai-toolkit/src/schema-awareness/types/json-item.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Represents a JSON schema awareness item for AI model understanding
|
||||
*
|
||||
* This interface defines the structure of schema information that is provided
|
||||
* to AI models to help them understand what JSON elements and attributes are
|
||||
* available in the editor's schema.
|
||||
*/
|
||||
export interface JsonItem {
|
||||
/**
|
||||
* The name of the extension that provides this element
|
||||
*/
|
||||
extensionName: string
|
||||
/**
|
||||
* If `true`, the item is a mark instead of a node.
|
||||
*/
|
||||
isMark?: boolean
|
||||
/**
|
||||
* The human-readable name of the element in English
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Explanation of the element in English for the AI model
|
||||
*/
|
||||
description?: string | null
|
||||
/**
|
||||
* Possible attributes of the JSON element. If `undefined`, there are no attributes.
|
||||
* The keys are attribute names and the values are Zod schemas that define the attribute structure.
|
||||
*/
|
||||
attributes?: Record<string, z.ZodTypeAny>
|
||||
}
|
||||
|
||||
export type SerializedJsonItem = Omit<JsonItem, 'attributes'> & { attributes?: unknown }
|
||||
|
||||
export type AddJsonSchemaAwareness<Options = any, Storage = any> = (this: {
|
||||
name: string
|
||||
options: Options
|
||||
storage: Storage
|
||||
editor?: Editor
|
||||
}) => Omit<JsonItem, 'extensionName' | 'isMark'>
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface NodeConfig<Options, Storage> {
|
||||
addJsonSchemaAwareness?: AddJsonSchemaAwareness<Options, Storage>
|
||||
}
|
||||
interface MarkConfig<Options, Storage> {
|
||||
addJsonSchemaAwareness?: AddJsonSchemaAwareness<Options, Storage>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { JsonItem } from '../types/json-item.js'
|
||||
|
||||
export const defaultJsonItems: JsonItem[] = [
|
||||
// Core nodes
|
||||
// Omit the root 'doc' node because the AI does not read it or generate it.
|
||||
{
|
||||
extensionName: 'paragraph',
|
||||
name: 'Paragraph',
|
||||
},
|
||||
{
|
||||
extensionName: 'text',
|
||||
name: 'Text',
|
||||
},
|
||||
{
|
||||
extensionName: 'heading',
|
||||
name: 'Heading',
|
||||
description: 'h1 to h6',
|
||||
attributes: {
|
||||
level: z.number().min(1).max(6),
|
||||
},
|
||||
},
|
||||
{
|
||||
extensionName: 'blockquote',
|
||||
name: 'Blockquote',
|
||||
description: 'for quoted content',
|
||||
},
|
||||
{
|
||||
extensionName: 'codeBlock',
|
||||
name: 'Code Block',
|
||||
attributes: {
|
||||
language: z.string().nullable().optional(),
|
||||
},
|
||||
},
|
||||
{
|
||||
extensionName: 'hardBreak',
|
||||
name: 'Line Break',
|
||||
},
|
||||
{
|
||||
extensionName: 'horizontalRule',
|
||||
name: 'Horizontal Rule',
|
||||
description: 'horizontal divider line',
|
||||
},
|
||||
{
|
||||
extensionName: 'image',
|
||||
name: 'Image',
|
||||
attributes: {
|
||||
src: z.string(),
|
||||
alt: z.string().nullable().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
width: z.number().nullable().optional(),
|
||||
height: z.number().nullable().optional(),
|
||||
},
|
||||
},
|
||||
// List nodes
|
||||
{
|
||||
extensionName: 'bulletList',
|
||||
name: 'Bullet List',
|
||||
description: 'Unordered list with bullet points',
|
||||
},
|
||||
{
|
||||
extensionName: 'orderedList',
|
||||
name: 'Ordered List',
|
||||
description: 'Numbered list',
|
||||
attributes: {
|
||||
start: z.number().optional(),
|
||||
type: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'Numbering style: "1" for numbers, "a" for lowercase letters, "A" for uppercase letters, "i" for lowercase roman numerals, "I" for uppercase roman numerals',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
extensionName: 'listItem',
|
||||
name: 'List Item',
|
||||
description: 'Item within a bulletList or orderedList',
|
||||
},
|
||||
{
|
||||
extensionName: 'taskList',
|
||||
name: 'Task List',
|
||||
description: 'Checklist with task items',
|
||||
},
|
||||
{
|
||||
extensionName: 'taskItem',
|
||||
name: 'Task Item',
|
||||
description: 'Item within a taskList',
|
||||
attributes: {
|
||||
checked: z.boolean(),
|
||||
},
|
||||
},
|
||||
// Table nodes
|
||||
{
|
||||
extensionName: 'table',
|
||||
name: 'Table',
|
||||
description: 'Table container node',
|
||||
},
|
||||
{
|
||||
extensionName: 'tableRow',
|
||||
name: 'Table Row',
|
||||
description: 'Row within a table',
|
||||
},
|
||||
{
|
||||
extensionName: 'tableCell',
|
||||
name: 'Table Cell',
|
||||
description: 'Cell within a tableRow',
|
||||
},
|
||||
{
|
||||
extensionName: 'tableHeader',
|
||||
name: 'Table Header',
|
||||
description: 'Header cell within a tableRow',
|
||||
},
|
||||
// Special nodes
|
||||
{
|
||||
extensionName: 'mention',
|
||||
name: 'Mention',
|
||||
description: 'Mention node for @mentions and similar',
|
||||
attributes: {
|
||||
id: z.string(),
|
||||
label: z.string().nullable().optional(),
|
||||
mentionSuggestionChar: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The character that triggers this mention type (e.g., "@" for users, "#" for tags). Used to distinguish between multiple mention types',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
extensionName: 'emoji',
|
||||
name: 'Emoji',
|
||||
attributes: {
|
||||
name: z
|
||||
.string()
|
||||
.describe(
|
||||
'The unique name/shortcode identifier for the emoji (e.g., "smile", "heart", "thumbs_up")',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
extensionName: 'youtube',
|
||||
name: 'YouTube',
|
||||
attributes: {
|
||||
src: z.string(),
|
||||
start: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Start time in seconds for when the video should begin playing'),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
},
|
||||
},
|
||||
{
|
||||
extensionName: 'details',
|
||||
name: 'Details',
|
||||
description: 'Collapsible details/summary',
|
||||
attributes: {
|
||||
open: z.boolean().optional(),
|
||||
},
|
||||
},
|
||||
{
|
||||
extensionName: 'detailsSummary',
|
||||
name: 'Details Summary',
|
||||
description: 'Summary text for a details node',
|
||||
},
|
||||
{
|
||||
extensionName: 'detailsContent',
|
||||
name: 'Details Content',
|
||||
description: 'Collapsible content within a details node',
|
||||
},
|
||||
{
|
||||
extensionName: 'blockMath',
|
||||
name: 'Block Math',
|
||||
attributes: {
|
||||
latex: z.string(),
|
||||
},
|
||||
},
|
||||
{
|
||||
extensionName: 'inlineMath',
|
||||
name: 'Inline Math',
|
||||
attributes: {
|
||||
latex: z.string(),
|
||||
},
|
||||
},
|
||||
// Marks
|
||||
{
|
||||
extensionName: 'bold',
|
||||
name: 'Bold',
|
||||
isMark: true,
|
||||
},
|
||||
{
|
||||
extensionName: 'italic',
|
||||
name: 'Italic',
|
||||
isMark: true,
|
||||
},
|
||||
{
|
||||
extensionName: 'code',
|
||||
name: 'Code',
|
||||
isMark: true,
|
||||
},
|
||||
{
|
||||
extensionName: 'link',
|
||||
name: 'Link',
|
||||
isMark: true,
|
||||
attributes: {
|
||||
href: z.string(),
|
||||
target: z.string().nullable().optional(),
|
||||
rel: z.string().nullable().optional(),
|
||||
class: z.string().nullable().optional(),
|
||||
},
|
||||
},
|
||||
{
|
||||
extensionName: 'strike',
|
||||
name: 'Strike',
|
||||
isMark: true,
|
||||
},
|
||||
{
|
||||
extensionName: 'underline',
|
||||
name: 'Underline',
|
||||
isMark: true,
|
||||
},
|
||||
{
|
||||
extensionName: 'highlight',
|
||||
name: 'Highlight',
|
||||
isMark: true,
|
||||
attributes: {
|
||||
color: z.string().nullable().optional(),
|
||||
},
|
||||
},
|
||||
{
|
||||
extensionName: 'subscript',
|
||||
name: 'Subscript',
|
||||
isMark: true,
|
||||
},
|
||||
{
|
||||
extensionName: 'superscript',
|
||||
name: 'Superscript',
|
||||
isMark: true,
|
||||
},
|
||||
{
|
||||
extensionName: 'textStyle',
|
||||
name: 'Text Style',
|
||||
isMark: true,
|
||||
attributes: {
|
||||
fontFamily: z.string(),
|
||||
fontSize: z.string(),
|
||||
lineHeight: z.string(),
|
||||
color: z.string(),
|
||||
backgroundColor: z.string(),
|
||||
},
|
||||
},
|
||||
// tiptap-pro nodes
|
||||
{
|
||||
extensionName: 'iframely',
|
||||
name: 'Iframely',
|
||||
description: 'Embed content from URLs using Iframely service',
|
||||
attributes: {
|
||||
url: z.string().nullable(),
|
||||
data: z.any().nullable().optional(),
|
||||
},
|
||||
},
|
||||
{
|
||||
extensionName: 'blockThread',
|
||||
name: 'Block Thread',
|
||||
description: 'Block-level comment thread',
|
||||
attributes: {
|
||||
'data-thread-id': z.string(),
|
||||
},
|
||||
},
|
||||
// tiptap-pro marks
|
||||
{
|
||||
extensionName: 'inlineThread',
|
||||
name: 'Inline Thread',
|
||||
isMark: true,
|
||||
description: 'Inline comment thread',
|
||||
attributes: {
|
||||
'data-thread-id': z.string(),
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { JsonItem } from '../types/json-item.js'
|
||||
|
||||
/**
|
||||
* When new JSON items are added, we need to remove from `items` the items that
|
||||
* are duplicated in `newItems`
|
||||
* @param items - The original array of JSON items
|
||||
* @param newItems - The new array of JSON items to add
|
||||
* @returns A new array of JSON items where the items from `newItems` are added
|
||||
* and the items from `items` that are duplicated in `newItems` are removed
|
||||
*/
|
||||
export function mergeJsonItems(items: JsonItem[], newItems: JsonItem[]): JsonItem[] {
|
||||
const newItemsSet = new Set(newItems.map(item => item.extensionName))
|
||||
return [...items.filter(item => !newItemsSet.has(item.extensionName)), ...newItems]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { AttributeSpec, MarkSpec, NodeSpec, Schema } from '@tiptap/pm/model'
|
||||
import { omit } from 'es-toolkit'
|
||||
|
||||
/**
|
||||
* A serialized version of AttributeSpec where the validate function is converted to a string.
|
||||
* This allows the attribute specification to be JSON-serializable.
|
||||
*/
|
||||
export type SerializedAttributeSpec = Omit<AttributeSpec, 'validate'> & {
|
||||
validate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A type representing serialized attributes as a record of attribute names to their serialized specs.
|
||||
*/
|
||||
type SerializedAttributes = { attrs?: Record<string, SerializedAttributeSpec> }
|
||||
|
||||
/**
|
||||
* A serialized version of NodeSpec with non-serializable properties
|
||||
* (toDOM, parseDOM, toDebugString, leafText, toText) removed and attrs converted to
|
||||
* SerializedAttributeSpec format.
|
||||
*/
|
||||
export type SerializedNodeSpec = Omit<
|
||||
NodeSpec,
|
||||
'toDOM' | 'parseDOM' | 'toDebugString' | 'leafText' | 'toText' | 'attrs'
|
||||
> &
|
||||
SerializedAttributes
|
||||
|
||||
/**
|
||||
* A serialized version of MarkSpec with non-serializable properties (toDOM, parseDOM) removed
|
||||
* and attrs converted to SerializedAttributeSpec format.
|
||||
*/
|
||||
export type SerializedMarkSpec = Omit<MarkSpec, 'toDOM' | 'parseDOM' | 'attrs'> &
|
||||
SerializedAttributes
|
||||
|
||||
/**
|
||||
* A JSON-serializable representation of a ProseMirror Schema.
|
||||
* Contains the top node name, and serialized node and mark specifications.
|
||||
*/
|
||||
export interface SerializedSchema {
|
||||
topNode?: string
|
||||
nodes: Record<string, SerializedNodeSpec>
|
||||
marks: Record<string, SerializedMarkSpec>
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an AttributeSpec by converting the validate function to a string if it exists.
|
||||
* This makes the attribute specification JSON-serializable.
|
||||
*
|
||||
* @param attributeSpec - The AttributeSpec to serialize
|
||||
* @returns A SerializedAttributeSpec with validate as a string or undefined
|
||||
*/
|
||||
function serializeAttributeSpec(attributeSpec: AttributeSpec): SerializedAttributeSpec {
|
||||
return {
|
||||
...omit(attributeSpec, ['validate']),
|
||||
validate: typeof attributeSpec.validate === 'string' ? attributeSpec.validate : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a record of AttributeSpec objects by converting each one using serializeAttributeSpec.
|
||||
*
|
||||
* @param attributeSpecs - A record of attribute names to their AttributeSpec definitions
|
||||
* @returns A record of attribute names to their SerializedAttributeSpec definitions
|
||||
*/
|
||||
function serializeAttributeSpecs(
|
||||
attributeSpecs: Record<string, AttributeSpec>,
|
||||
): Record<string, SerializedAttributeSpec> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(attributeSpecs).map(([key, value]) => [key, serializeAttributeSpec(value)]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a NodeSpec by removing non-serializable properties
|
||||
* (toDOM, parseDOM, toDebugString, leafText, toText) and converting the attrs to
|
||||
* SerializedAttributeSpec format.
|
||||
*
|
||||
* @param nodeSpec - The NodeSpec to serialize
|
||||
* @returns A SerializedNodeSpec with only JSON-serializable properties
|
||||
*/
|
||||
function serializeNodeSpec(nodeSpec: NodeSpec): SerializedNodeSpec {
|
||||
return {
|
||||
...omit(nodeSpec, ['toDOM', 'parseDOM', 'toDebugString', 'leafText', 'toText', 'attrs']),
|
||||
attrs: nodeSpec.attrs ? serializeAttributeSpecs(nodeSpec.attrs) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a MarkSpec by removing non-serializable properties (toDOM, parseDOM)
|
||||
* and converting the attrs to SerializedAttributeSpec format.
|
||||
*
|
||||
* @param markSpec - The MarkSpec to serialize
|
||||
* @returns A SerializedMarkSpec with only JSON-serializable properties
|
||||
*/
|
||||
function serializeMarkSpec(markSpec: MarkSpec): SerializedMarkSpec {
|
||||
return {
|
||||
...omit(markSpec, ['toDOM', 'parseDOM', 'attrs']),
|
||||
attrs: markSpec.attrs ? serializeAttributeSpecs(markSpec.attrs) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a ProseMirror Schema to a JSON-serializable format.
|
||||
* Converts all node and mark specifications by removing non-serializable properties
|
||||
* and converting attribute validation functions to strings.
|
||||
*
|
||||
* Non-serializable properties are properties related to DOM serialization and parsing,
|
||||
* and functions that validate attribute values. These are just removed from the schema.
|
||||
*
|
||||
* @param schema - The ProseMirror Schema to serialize
|
||||
* @returns A SerializedSchema containing the top node name and serialized node/mark specs
|
||||
*/
|
||||
export function serializeSchema(schema: Schema): SerializedSchema {
|
||||
return {
|
||||
topNode: schema.spec.topNode,
|
||||
nodes: Object.fromEntries(
|
||||
Object.values(schema.nodes).map(node => [node.name, serializeNodeSpec(node.spec)]),
|
||||
),
|
||||
marks: Object.fromEntries(
|
||||
Object.values(schema.marks).map(mark => [mark.name, serializeMarkSpec(mark.spec)]),
|
||||
),
|
||||
}
|
||||
}
|
||||
23
packages/ai-toolkit/src/server-ai-toolkit-extension.ts
Normal file
23
packages/ai-toolkit/src/server-ai-toolkit-extension.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
import { ServerAiToolkitHashExtension } from './hash-extension/server-ai-toolkit-hash-extension.js'
|
||||
|
||||
/**
|
||||
* Server AI Toolkit extension.
|
||||
*
|
||||
* This package-level extension registers the internal hash extension used by
|
||||
* the Server AI Toolkit so server-side read and edit flows can rely on stable
|
||||
* `_hash` attributes already stored in the document.
|
||||
*/
|
||||
export const ServerAiToolkit = Extension.create({
|
||||
name: 'serverAiToolkit',
|
||||
|
||||
/**
|
||||
* Registers internal extensions required by the Server AI Toolkit.
|
||||
*
|
||||
* @return The list of internal extensions.
|
||||
*/
|
||||
addExtensions() {
|
||||
return [ServerAiToolkitHashExtension]
|
||||
},
|
||||
})
|
||||
509
packages/ai-toolkit/src/streaming-reveal.test.ts
Normal file
509
packages/ai-toolkit/src/streaming-reveal.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { Collaboration } from '@tiptap/extension-collaboration'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import { AiInsertReveal } from './streaming-reveal.js'
|
||||
|
||||
const AI_CLIENT_ID = 111111
|
||||
const HUMAN_CLIENT_ID = 222222
|
||||
|
||||
/** The awareness payload the Tiptap AI server publishes while it streams. */
|
||||
const AI_USER = { name: 'AI', color: '#8B5CF6', aiInstanceId: 'ai-instance-1' }
|
||||
|
||||
/** Stands in for a collab provider, exposing only the awareness surface we read. */
|
||||
function createProvider() {
|
||||
const states = new Map<number, Record<string, any>>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
return {
|
||||
awareness: {
|
||||
getStates: () => states,
|
||||
on: (_event: string, listener: () => void) => listeners.add(listener),
|
||||
off: (_event: string, listener: () => void) => listeners.delete(listener),
|
||||
},
|
||||
announce(clientId: number, user: Record<string, any>) {
|
||||
states.set(clientId, { user })
|
||||
listeners.forEach(listener => listener())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates an editor with {@link AiInsertReveal} but no collaboration (no y-sync plugin). */
|
||||
function createEditor(): Promise<Editor> {
|
||||
return new Promise(resolve => {
|
||||
const editor = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [StarterKit, AiInsertReveal.configure({ provider: createProvider() })],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }],
|
||||
},
|
||||
onCreate: () => {
|
||||
resolve(editor)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** A remote peer that keeps its clientID across writes. */
|
||||
function createPeer(ydoc: Y.Doc, clientID: number): Y.Doc {
|
||||
const peer = new Y.Doc()
|
||||
peer.clientID = clientID
|
||||
Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc))
|
||||
return peer
|
||||
}
|
||||
|
||||
/** Creates a collaborative editor seeded with one `Hello` paragraph, plus two remote peers. */
|
||||
function createCollabEditor(options?: {
|
||||
durationMs?: number
|
||||
}): Promise<{ editor: Editor; ydoc: Y.Doc; ai: Y.Doc; human: Y.Doc }> {
|
||||
const ydoc = new Y.Doc()
|
||||
const provider = createProvider()
|
||||
provider.announce(AI_CLIENT_ID, AI_USER)
|
||||
provider.announce(HUMAN_CLIENT_ID, { name: 'Someone else', color: '#0EA5E9' })
|
||||
|
||||
return new Promise(resolve => {
|
||||
new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit.configure({ undoRedo: false }),
|
||||
Collaboration.configure({ document: ydoc }),
|
||||
AiInsertReveal.configure({
|
||||
provider,
|
||||
...(options?.durationMs === undefined ? {} : { durationMs: options.durationMs }),
|
||||
}),
|
||||
],
|
||||
onCreate: ({ editor }) => {
|
||||
// Collaboration ignores the `content` prop (the empty Y.Doc wins when the
|
||||
// y-sync plugin binds), so seed the shared doc with a local edit instead.
|
||||
editor.commands.setContent('<p>Hello</p>')
|
||||
resolve({
|
||||
editor,
|
||||
ydoc,
|
||||
ai: createPeer(ydoc, AI_CLIENT_ID),
|
||||
human: createPeer(ydoc, HUMAN_CLIENT_ID),
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Applies an insert from `peer`, so it lands as a remote transaction authored by it. */
|
||||
function remoteInsert(peer: Y.Doc, ydoc: Y.Doc, index: number, text: string): void {
|
||||
Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer)))
|
||||
const paragraph = peer.getXmlFragment('default').get(0) as Y.XmlElement
|
||||
const xmlText = paragraph.get(0) as Y.XmlText
|
||||
xmlText.insert(index, text)
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc)))
|
||||
}
|
||||
|
||||
/** Applies a whole new block from `peer`, the way the AI writes a fresh paragraph. */
|
||||
function remoteInsertBlock(peer: Y.Doc, ydoc: Y.Doc, text: string): void {
|
||||
Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer)))
|
||||
const fragment = peer.getXmlFragment('default')
|
||||
const block = new Y.XmlElement('paragraph')
|
||||
const blockText = new Y.XmlText()
|
||||
block.insert(0, [blockText])
|
||||
fragment.insert(0, [block])
|
||||
if (text.length > 0) blockText.insert(0, text)
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc)))
|
||||
}
|
||||
|
||||
/** Collects the extension's current reveal decorations, resolved against `state`. */
|
||||
function revealDecorations(
|
||||
editor: Editor,
|
||||
state: unknown = editor.state,
|
||||
): Array<{ from: number; to: number; style: string }> {
|
||||
for (const plugin of editor.state.plugins) {
|
||||
const set = (plugin as any).props?.decorations?.call(plugin, state)
|
||||
const found = (set?.find?.() ?? []).filter(
|
||||
(d: any) => d.type?.attrs?.class === 'ai-insert-reveal',
|
||||
)
|
||||
if (found.length > 0) {
|
||||
return found.map((d: any) => ({ from: d.from, to: d.to, style: d.type.attrs.style ?? '' }))
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
describe('AiInsertReveal', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('is a named Tiptap extension', () => {
|
||||
expect(AiInsertReveal.name).toBe('aiInsertReveal')
|
||||
})
|
||||
|
||||
it('warns and degrades to a no-op when no collaboration y-sync plugin is present', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const editor = await createEditor()
|
||||
|
||||
expect(editor.extensionManager.extensions.some(e => e.name === 'aiInsertReveal')).toBe(true)
|
||||
// Without a y-sync plugin the decorations source resolves to nothing, so the
|
||||
// editor renders normally rather than throwing.
|
||||
expect(editor.getText()).toBe('Hello')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Collaboration extension'))
|
||||
|
||||
warn.mockRestore()
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('applies configured className and durationMs', async () => {
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
const created = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit,
|
||||
AiInsertReveal.configure({
|
||||
className: 'custom-reveal',
|
||||
durationMs: 300,
|
||||
provider: createProvider(),
|
||||
}),
|
||||
],
|
||||
onCreate: () => resolve(created),
|
||||
})
|
||||
})
|
||||
|
||||
const reveal = editor.extensionManager.extensions.find(e => e.name === 'aiInsertReveal')
|
||||
expect(reveal?.options).toMatchObject({ className: 'custom-reveal', durationMs: 300 })
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals a remote insert as a decoration over exactly the inserted run', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsert(ai, ydoc, 5, ' WORLD')
|
||||
|
||||
expect(editor.getText()).toBe('Hello WORLD')
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe(' WORLD'.length)
|
||||
// The age-seeded animation-delay is present so the fade survives re-renders.
|
||||
expect(decorations[0].style).toMatch(/animation-delay: -\d+ms/)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals a block that arrives as a whole new node', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsertBlock(ai, ydoc, 'A brand new title')
|
||||
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe('A brand new title'.length)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals text streamed into a block that was created empty', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsertBlock(ai, ydoc, '')
|
||||
remoteInsert(ai, ydoc, 0, 'Streamed title')
|
||||
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe('Streamed title'.length)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('merges touching runs that share an animation offset into one decoration', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
// Only the clock is faked: the editor is created through a real `setTimeout`.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
|
||||
remoteInsert(ai, ydoc, 5, ' one')
|
||||
remoteInsert(ai, ydoc, 9, ' two')
|
||||
|
||||
expect(editor.getText()).toBe('Hello one two')
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe(' one two'.length)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals an insert at the very end of the document', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsert(ai, ydoc, 5, '!')
|
||||
|
||||
expect(editor.getText()).toBe('Hello!')
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe(1)
|
||||
expect(decorations[0].to).toBeLessThanOrEqual(editor.state.doc.content.size)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('clamps a decoration that resolves past the end of the document', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsert(ai, ydoc, 5, ' WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(1)
|
||||
|
||||
// Y.Doc can be ahead of the PM doc mid-sync, so resolve against a shorter doc.
|
||||
const shortDoc = editor.schema.nodeFromJSON({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello!' }] }],
|
||||
})
|
||||
const decorations = revealDecorations(editor, { ...editor.state, doc: shortDoc })
|
||||
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to).toBe(shortDoc.content.size)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it("does not reveal another collaborator's remote insert", async () => {
|
||||
const { editor, ydoc, human } = await createCollabEditor()
|
||||
|
||||
remoteInsert(human, ydoc, 5, ' WORLD')
|
||||
|
||||
// The insert lands like any remote edit, it just must not fade.
|
||||
expect(editor.getText()).toBe('Hello WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals the AI while a collaborator types alongside it', async () => {
|
||||
const { editor, ydoc, ai, human } = await createCollabEditor()
|
||||
|
||||
remoteInsert(human, ydoc, 5, ' HUMAN')
|
||||
remoteInsert(ai, ydoc, 11, ' AI')
|
||||
|
||||
expect(editor.getText()).toBe('Hello HUMAN AI')
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe(' AI'.length)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals a run that arrived before awareness identified the AI', async () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const provider = createProvider()
|
||||
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit.configure({ undoRedo: false }),
|
||||
Collaboration.configure({ document: ydoc }),
|
||||
AiInsertReveal.configure({ provider }),
|
||||
],
|
||||
onCreate: ({ editor: created }) => {
|
||||
created.commands.setContent('<p>Hello</p>')
|
||||
resolve(created)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// The AI's first tokens can land before its awareness entry does.
|
||||
remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
|
||||
provider.announce(AI_CLIENT_ID, AI_USER)
|
||||
|
||||
expect(revealDecorations(editor)).toHaveLength(1)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals a multi-block structure the AI writes as one node', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
Y.applyUpdate(ai, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(ai)))
|
||||
const list = new Y.XmlElement('bulletList')
|
||||
for (let item = 0; item < 3; item++) {
|
||||
const listItem = new Y.XmlElement('listItem')
|
||||
const paragraph = new Y.XmlElement('paragraph')
|
||||
const text = new Y.XmlText()
|
||||
paragraph.insert(0, [text])
|
||||
listItem.insert(0, [paragraph])
|
||||
list.insert(list.length, [listItem])
|
||||
text.insert(0, `Item ${item}`)
|
||||
}
|
||||
ai.getXmlFragment('default').insert(0, [list])
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ai, Y.encodeStateVector(ydoc)))
|
||||
|
||||
// One structure, so the block cap must not mistake it for a document sync.
|
||||
expect(revealDecorations(editor)).toHaveLength(3)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals nothing when a whole AI-authored document arrives at once', async () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const provider = createProvider()
|
||||
provider.announce(AI_CLIENT_ID, AI_USER)
|
||||
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
const created = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit.configure({ undoRedo: false }),
|
||||
Collaboration.configure({ document: ydoc }),
|
||||
AiInsertReveal.configure({ provider }),
|
||||
],
|
||||
onCreate: () => resolve(created),
|
||||
})
|
||||
})
|
||||
|
||||
// Joining a room hands over everything the AI wrote before, in one update.
|
||||
const server = new Y.Doc()
|
||||
server.clientID = AI_CLIENT_ID
|
||||
const fragment = server.getXmlFragment('default')
|
||||
for (let block = 0; block < 5; block++) {
|
||||
const element = new Y.XmlElement('paragraph')
|
||||
const text = new Y.XmlText()
|
||||
element.insert(0, [text])
|
||||
fragment.insert(fragment.length, [element])
|
||||
text.insert(0, `Paragraph ${block} written earlier by the AI.`)
|
||||
}
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(server))
|
||||
|
||||
expect(editor.getText().length).toBeGreaterThan(0)
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('warns when the provider has no awareness', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
const created = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [StarterKit, AiInsertReveal.configure({ provider: {} })],
|
||||
onCreate: () => resolve(created),
|
||||
})
|
||||
})
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('no awareness'))
|
||||
|
||||
warn.mockRestore()
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('warns when durationMs cannot hold a reveal', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
const created = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit,
|
||||
AiInsertReveal.configure({ provider: createProvider(), durationMs: 0 }),
|
||||
],
|
||||
onCreate: () => resolve(created),
|
||||
})
|
||||
})
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('durationMs'))
|
||||
|
||||
warn.mockRestore()
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('warns and reveals nothing when no provider is configured', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit.configure({ undoRedo: false }),
|
||||
Collaboration.configure({ document: ydoc }),
|
||||
AiInsertReveal,
|
||||
],
|
||||
onCreate: ({ editor: created }) => {
|
||||
created.commands.setContent('<p>Hello</p>')
|
||||
resolve(created)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD')
|
||||
|
||||
// Without awareness the AI cannot be identified, so it fails closed.
|
||||
expect(editor.getText()).toBe('Hello WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('"provider" option is required'))
|
||||
|
||||
warn.mockRestore()
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it("does not reveal the local user's own typing", async () => {
|
||||
const { editor } = await createCollabEditor()
|
||||
|
||||
// A local transaction (transaction.local === true) must be ignored.
|
||||
editor.commands.insertContentAt(6, 'X')
|
||||
|
||||
expect(editor.getText()).toBe('HelloX')
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('drops the reveal once its duration has elapsed', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor({ durationMs: 30 })
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
|
||||
remoteInsert(ai, ydoc, 5, ' WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(1)
|
||||
|
||||
vi.setSystemTime(Date.now() + 60)
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('clamps a run longer than the max reveal range to that many characters', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsert(ai, ydoc, 5, 'x'.repeat(401))
|
||||
|
||||
expect(editor.getText()).toBe(`Hello${'x'.repeat(401)}`)
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
// The whole run inserts; only the fade is capped (MAX_REVEAL_RANGE = 400).
|
||||
expect(decorations[0].to - decorations[0].from).toBe(400)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('drops a run whose resolved span no longer matches its inserted length', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsert(ai, ydoc, 5, ' WORLD')
|
||||
// 'XYZ' lands inside the first run, so its span drifts from 6 to 9 and drops as stale.
|
||||
remoteInsert(ai, ydoc, 8, 'XYZ')
|
||||
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe(3)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('tears down cleanly after a reveal without throwing', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
remoteInsert(ai, ydoc, 5, ' WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(1)
|
||||
|
||||
expect(() => editor.destroy()).not.toThrow()
|
||||
})
|
||||
})
|
||||
392
packages/ai-toolkit/src/streaming-reveal.ts
Normal file
392
packages/ai-toolkit/src/streaming-reveal.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import { relativePositionToAbsolutePosition, ySyncPluginKey } from '@tiptap/y-tiptap'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
/**
|
||||
* Configuration for {@link AiInsertReveal}.
|
||||
*/
|
||||
export type AiInsertRevealOptions = {
|
||||
/** CSS class on each revealed run; style it in your app to define the fade. */
|
||||
className: string
|
||||
/**
|
||||
* How long (ms) each run keeps its reveal decoration before it is dropped.
|
||||
* Keep it at or above your CSS animation duration so the fade can finish.
|
||||
*/
|
||||
durationMs: number
|
||||
/**
|
||||
* The collaboration provider, e.g. `HocuspocusProvider` or `TiptapCloudProvider`.
|
||||
* Its awareness is what identifies the AI, so nothing is revealed without it.
|
||||
*/
|
||||
provider: RevealProvider | null
|
||||
}
|
||||
|
||||
/** The awareness surface this extension reads from the provider. */
|
||||
export type RevealProvider = {
|
||||
awareness?: {
|
||||
getStates: () => Map<number, Record<string, any> | undefined>
|
||||
on: (event: 'change', listener: () => void) => void
|
||||
off: (event: 'change', listener: () => void) => void
|
||||
}
|
||||
}
|
||||
|
||||
/** Caps the fade to a run's first N chars; the rest reveals instantly. */
|
||||
const MAX_REVEAL_RANGE = 400
|
||||
|
||||
/** ProseMirror compares decoration attrs by value, so an unrounded offset rebuilds the DOM. */
|
||||
const REVEAL_AGE_STEP = 100
|
||||
|
||||
/** Above this, an update is a document sync, such as joining a room, not live streaming. */
|
||||
const MAX_BLOCKS_PER_UPDATE = 2
|
||||
|
||||
/** Yjs relative positions so each run survives y-tiptap doc rebuilds. */
|
||||
type RevealEntry = {
|
||||
start: Y.RelativePosition
|
||||
end: Y.RelativePosition
|
||||
at: number
|
||||
/** Inserted char count; a resolved span that drifts from it is stale. */
|
||||
length: number
|
||||
/** Author, resolved against awareness at render time rather than on arrival. */
|
||||
client: number
|
||||
}
|
||||
|
||||
/** Minimal shape of the y-sync plugin state we read. */
|
||||
type YSyncState = {
|
||||
doc: Y.Doc
|
||||
type: Y.XmlFragment
|
||||
binding: { mapping: Map<Y.AbstractType<unknown>, PMNode | PMNode[]> } | null
|
||||
}
|
||||
|
||||
const aiInsertRevealKey = new PluginKey('aiInsertReveal')
|
||||
|
||||
function resolveRevealRange(
|
||||
ystate: YSyncState,
|
||||
entry: RevealEntry,
|
||||
now: number,
|
||||
durationMs: number,
|
||||
docSize: number,
|
||||
): { from: number; to: number; age: number } | null {
|
||||
const age = now - entry.at
|
||||
if (age >= durationMs) return null
|
||||
|
||||
const span = resolveSpan(ystate, entry, docSize)
|
||||
return span === null ? null : { ...span, age }
|
||||
}
|
||||
|
||||
function resolveSpan(
|
||||
ystate: YSyncState,
|
||||
entry: RevealEntry,
|
||||
docSize: number,
|
||||
): { from: number; to: number } | null {
|
||||
if (!ystate.binding) return null
|
||||
|
||||
const from = relativePositionToAbsolutePosition(
|
||||
ystate.doc,
|
||||
ystate.type,
|
||||
entry.start,
|
||||
ystate.binding.mapping,
|
||||
)
|
||||
const to = relativePositionToAbsolutePosition(
|
||||
ystate.doc,
|
||||
ystate.type,
|
||||
entry.end,
|
||||
ystate.binding.mapping,
|
||||
)
|
||||
return from === null || to === null ? null : clampSpan(from, to, entry.length, docSize)
|
||||
}
|
||||
|
||||
/** Drops a span whose width drifted from the insert, so stale mappings never paint. */
|
||||
function clampSpan(
|
||||
from: number,
|
||||
to: number,
|
||||
length: number,
|
||||
docSize: number,
|
||||
): { from: number; to: number } | null {
|
||||
const lo = Math.min(from, to)
|
||||
const hi = Math.max(from, to)
|
||||
if (hi - lo !== length) return null
|
||||
|
||||
const start = Math.max(0, lo)
|
||||
const end = Math.min(docSize, hi, start + MAX_REVEAL_RANGE)
|
||||
return start >= end ? null : { from: start, to: end }
|
||||
}
|
||||
|
||||
/** Merges touching runs with the same offset, so a stream paints a few spans, not one per token. */
|
||||
function mergeRuns(
|
||||
ranges: Array<{ from: number; to: number; age: number }>,
|
||||
): Array<{ from: number; to: number; delay: number }> {
|
||||
const merged: Array<{ from: number; to: number; delay: number }> = []
|
||||
|
||||
for (const range of ranges) {
|
||||
const delay = Math.round(range.age / REVEAL_AGE_STEP) * REVEAL_AGE_STEP
|
||||
const last = merged[merged.length - 1]
|
||||
|
||||
if (last !== undefined && last.delay === delay && last.to === range.from) {
|
||||
last.to = range.to
|
||||
} else {
|
||||
merged.push({ from: range.from, to: range.to, delay })
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
/** The Tiptap AI server tags the identity it publishes with an instance id. */
|
||||
function isAiUser(user: Record<string, any> | undefined): boolean {
|
||||
return Boolean(user?.aiInstanceId)
|
||||
}
|
||||
|
||||
/** Clients whose clock advanced in this transaction, i.e. who authored it. */
|
||||
function transactionAuthors(transaction: Y.Transaction): number[] {
|
||||
const authors: number[] = []
|
||||
transaction.afterState.forEach((clock, client) => {
|
||||
if ((transaction.beforeState.get(client) ?? 0) < clock) authors.push(client)
|
||||
})
|
||||
return authors
|
||||
}
|
||||
|
||||
function collectInsertedRuns(
|
||||
event: Y.YEvent<Y.AbstractType<unknown>>,
|
||||
now: number,
|
||||
client: number,
|
||||
): RevealEntry[] {
|
||||
const target = event.target
|
||||
const runs: RevealEntry[] = []
|
||||
|
||||
if (target instanceof Y.XmlText) {
|
||||
let index = 0
|
||||
for (const op of event.delta) {
|
||||
const { advance, inserted } = scanDeltaOp(op)
|
||||
if (inserted > 0) runs.push(makeRun(target, index, inserted, now, client))
|
||||
index += advance
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
// Whole nodes arrive when the AI writes a heading or a fresh block, so their
|
||||
// text never shows up as an insert into existing content.
|
||||
for (const op of event.delta) {
|
||||
if (!Array.isArray(op.insert)) continue
|
||||
if (op.insert.length > MAX_BLOCKS_PER_UPDATE) continue
|
||||
for (const node of op.insert) collectNodeText(node, now, client, runs)
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
function collectNodeText(node: unknown, now: number, client: number, runs: RevealEntry[]): void {
|
||||
if (node instanceof Y.XmlText) {
|
||||
if (node.length > 0) runs.push(makeRun(node, 0, node.length, now, client))
|
||||
return
|
||||
}
|
||||
if (node instanceof Y.XmlElement) {
|
||||
for (const child of node.toArray()) collectNodeText(child, now, client, runs)
|
||||
}
|
||||
}
|
||||
|
||||
function scanDeltaOp(op: { retain?: number; insert?: unknown }): {
|
||||
advance: number
|
||||
inserted: number
|
||||
} {
|
||||
if (typeof op.retain === 'number') return { advance: op.retain, inserted: 0 }
|
||||
if (typeof op.insert === 'string')
|
||||
return { advance: op.insert.length, inserted: op.insert.length }
|
||||
// A non-string insert (embed) advances one position but is not a revealed run.
|
||||
return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* End anchor assoc < 0 so an appended token starts a new run, not extends it.
|
||||
* At index 0 the start needs the same assoc, or it resolves to nothing.
|
||||
*/
|
||||
function makeRun(
|
||||
target: Y.XmlText,
|
||||
index: number,
|
||||
length: number,
|
||||
now: number,
|
||||
client: number,
|
||||
): RevealEntry {
|
||||
return {
|
||||
start: Y.createRelativePositionFromTypeIndex(target, index, index === 0 ? -1 : 0),
|
||||
end: Y.createRelativePositionFromTypeIndex(target, index + length, -1),
|
||||
at: now,
|
||||
length,
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fades in text the AI streams into the document, using view-only decorations.
|
||||
* It never mutates the document, so it is inert to accept/reject and persistence.
|
||||
* Requires Collaboration, a provider, and CSS for `className` (default `ai-insert-reveal`).
|
||||
*/
|
||||
export const AiInsertReveal = Extension.create<AiInsertRevealOptions>({
|
||||
name: 'aiInsertReveal',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
className: 'ai-insert-reveal',
|
||||
durationMs: 550,
|
||||
provider: null,
|
||||
}
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
const { provider, durationMs } = this.options
|
||||
|
||||
if (!provider) {
|
||||
console.warn(
|
||||
'[tiptap warn]: The "provider" option is required for "AiInsertReveal" to tell the AI apart from other collaborators. Nothing is revealed without it.',
|
||||
)
|
||||
} else if (!provider.awareness) {
|
||||
console.warn(
|
||||
'[tiptap warn]: The provider passed to "AiInsertReveal" has no awareness, so the AI cannot be identified. Nothing is revealed.',
|
||||
)
|
||||
}
|
||||
|
||||
if (durationMs <= 0) {
|
||||
console.warn(
|
||||
`[tiptap warn]: "AiInsertReveal" needs a positive "durationMs" to hold a reveal, got ${durationMs}. Nothing is revealed.`,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { className, durationMs, provider } = this.options
|
||||
|
||||
// Runs in insertion (time) order, so expired entries are always a prefix.
|
||||
const entries: RevealEntry[] = []
|
||||
|
||||
// Never pruned: the AI clears its awareness entry when it stops, and its first
|
||||
// tokens can arrive before that entry lands.
|
||||
const aiClients = new Set<number>()
|
||||
|
||||
const dropExpired = (now: number) => {
|
||||
let firstActive = 0
|
||||
while (firstActive < entries.length && now - entries[firstActive].at >= durationMs) {
|
||||
firstActive += 1
|
||||
}
|
||||
if (firstActive > 0) entries.splice(0, firstActive)
|
||||
}
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: aiInsertRevealKey,
|
||||
|
||||
props: {
|
||||
decorations: state => {
|
||||
const ystate = ySyncPluginKey.getState(state) as YSyncState | undefined
|
||||
if (entries.length === 0 || !ystate?.binding) return null
|
||||
|
||||
const now = Date.now()
|
||||
const ranges = entries
|
||||
.filter(entry => aiClients.has(entry.client))
|
||||
.map(entry =>
|
||||
resolveRevealRange(ystate, entry, now, durationMs, state.doc.content.size),
|
||||
)
|
||||
.filter((range): range is NonNullable<typeof range> => range !== null)
|
||||
|
||||
// y-tiptap rebuilds the whole doc per token, restarting the CSS
|
||||
// animation; offset by the run's age to resume the fade instead.
|
||||
const decorations = mergeRuns(ranges).map(range =>
|
||||
Decoration.inline(range.from, range.to, {
|
||||
class: className,
|
||||
style: `animation-delay: -${range.delay}ms`,
|
||||
}),
|
||||
)
|
||||
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
},
|
||||
},
|
||||
|
||||
view: view => {
|
||||
const initialState = ySyncPluginKey.getState(view.state) as YSyncState | undefined
|
||||
const fragment = initialState?.type ?? null
|
||||
const awareness = provider?.awareness ?? null
|
||||
|
||||
if (fragment === null) {
|
||||
console.warn(
|
||||
'[tiptap warn]: "AiInsertReveal" needs the Collaboration extension to see incoming text. Nothing is revealed.',
|
||||
)
|
||||
}
|
||||
|
||||
const trackAiClients = () => {
|
||||
const known = aiClients.size
|
||||
awareness
|
||||
?.getStates()
|
||||
.forEach((state: Record<string, any> | undefined, clientId: number) => {
|
||||
if (isAiUser(state?.user)) aiClients.add(clientId)
|
||||
})
|
||||
// Runs that arrived before the AI was identified can now be revealed.
|
||||
if (aiClients.size > known) scheduleRerender()
|
||||
}
|
||||
|
||||
let raf: number | null = null
|
||||
let pruneTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const rerender = () => {
|
||||
if (view.isDestroyed) return
|
||||
// Empty transaction: re-runs `decorations` so new entries paint and expired ones drop.
|
||||
view.dispatch(view.state.tr.setMeta('addToHistory', false))
|
||||
}
|
||||
|
||||
// Coalesce bursts of tokens landing in the same frame into one render.
|
||||
const scheduleRerender = () => {
|
||||
if (raf !== null) return
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = null
|
||||
rerender()
|
||||
})
|
||||
}
|
||||
|
||||
// After the stream pauses, one delayed render removes the last runs'
|
||||
// decorations once their fades have completed.
|
||||
const schedulePrune = () => {
|
||||
if (pruneTimer !== null) clearTimeout(pruneTimer)
|
||||
pruneTimer = setTimeout(() => {
|
||||
pruneTimer = null
|
||||
dropExpired(Date.now())
|
||||
rerender()
|
||||
}, durationMs + 80)
|
||||
}
|
||||
|
||||
const onChange = (
|
||||
events: Array<Y.YEvent<Y.AbstractType<unknown>>>,
|
||||
transaction: Y.Transaction,
|
||||
) => {
|
||||
if (transaction.local) return
|
||||
|
||||
// A batch mixing several authors cannot be attributed run by run.
|
||||
const authors = transactionAuthors(transaction)
|
||||
if (authors.length !== 1) return
|
||||
|
||||
const now = Date.now()
|
||||
dropExpired(now)
|
||||
|
||||
const runs = events.flatMap(event => collectInsertedRuns(event, now, authors[0]))
|
||||
if (runs.length === 0) return
|
||||
|
||||
entries.push(...runs)
|
||||
scheduleRerender()
|
||||
schedulePrune()
|
||||
}
|
||||
|
||||
trackAiClients()
|
||||
awareness?.on('change', trackAiClients)
|
||||
fragment?.observeDeep(onChange)
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
awareness?.off('change', trackAiClients)
|
||||
fragment?.unobserveDeep(onChange)
|
||||
if (raf !== null) cancelAnimationFrame(raf)
|
||||
if (pruneTimer !== null) clearTimeout(pruneTimer)
|
||||
entries.length = 0
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
140
packages/ai-toolkit/tests/integration/serverAiToolkit.test.ts
Normal file
140
packages/ai-toolkit/tests/integration/serverAiToolkit.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { Editor, Extension } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { ServerAiToolkit } from '@tiptap/ai-toolkit'
|
||||
|
||||
/**
|
||||
* Options for creating a test editor.
|
||||
*/
|
||||
interface CreateEditorOptions {
|
||||
/**
|
||||
* Initial document JSON.
|
||||
*/
|
||||
content?: Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Additional extensions to include alongside the default ones.
|
||||
*/
|
||||
extensions?: Extension[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an editor configured with the {@link ServerAiToolkit} extension.
|
||||
*
|
||||
* @param options - Editor configuration options.
|
||||
* @return Promise resolving once the editor create lifecycle has finished.
|
||||
*/
|
||||
function createEditor(options: CreateEditorOptions): Promise<Editor> {
|
||||
return new Promise(resolve => {
|
||||
const editor = new Editor({
|
||||
element: document.createElement('div'),
|
||||
content: options.content,
|
||||
extensions: [StarterKit, ServerAiToolkit, ...(options.extensions ?? [])],
|
||||
onCreate: () => {
|
||||
resolve(editor)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an editor with an explicit extension order.
|
||||
*
|
||||
* @param extensions - Extensions to register in the desired order.
|
||||
* @param content - Initial document JSON.
|
||||
* @return Promise resolving once the editor create lifecycle has finished.
|
||||
*/
|
||||
function createEditorWithExplicitExtensions(
|
||||
extensions: Extension[],
|
||||
content?: Record<string, unknown>,
|
||||
): Promise<Editor> {
|
||||
return new Promise(resolve => {
|
||||
const editor = new Editor({
|
||||
element: document.createElement('div'),
|
||||
content,
|
||||
extensions,
|
||||
onCreate: () => {
|
||||
resolve(editor)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const MockAiToolkit = Extension.create({
|
||||
name: 'aiToolkit',
|
||||
})
|
||||
|
||||
describe('ServerAiToolkit', () => {
|
||||
it('registers _hash attributes without generating values on creation', async () => {
|
||||
const editor = await createEditor({
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const paragraphs = editor.getJSON().content ?? []
|
||||
|
||||
expect(paragraphs).toHaveLength(2)
|
||||
// oxlint-disable-next-line no-underscore-dangle
|
||||
expect(paragraphs[0]?.attrs?._hash ?? null).toBeNull()
|
||||
// oxlint-disable-next-line no-underscore-dangle
|
||||
expect(paragraphs[1]?.attrs?._hash ?? null).toBeNull()
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('preserves provided hashes through a JSON round-trip', async () => {
|
||||
const firstEditor = await createEditor({
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { _hash: 'ABC123' },
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const firstJson = firstEditor.getJSON()
|
||||
// oxlint-disable-next-line no-underscore-dangle
|
||||
const firstHash = firstJson.content?.[0]?.attrs?._hash
|
||||
|
||||
firstEditor.destroy()
|
||||
|
||||
const secondEditor = await createEditor({
|
||||
content: firstJson,
|
||||
})
|
||||
|
||||
const secondJson = secondEditor.getJSON()
|
||||
// oxlint-disable-next-line no-underscore-dangle
|
||||
const secondHash = secondJson.content?.[0]?.attrs?._hash
|
||||
|
||||
expect(secondHash).toBe(firstHash)
|
||||
|
||||
secondEditor.destroy()
|
||||
})
|
||||
|
||||
it('does not synthesize hashes when the AI Toolkit extension is present', async () => {
|
||||
const editor = await createEditorWithExplicitExtensions(
|
||||
[StarterKit, ServerAiToolkit, MockAiToolkit],
|
||||
{
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }],
|
||||
},
|
||||
)
|
||||
|
||||
// oxlint-disable-next-line no-underscore-dangle
|
||||
expect(editor.getJSON().content?.[0]?.attrs?._hash ?? null).toBeNull()
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
})
|
||||
9
packages/ai-toolkit/vite.config.ts
Normal file
9
packages/ai-toolkit/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { basePackConfig } from '../../pack.config.mjs'
|
||||
|
||||
export default defineConfig({
|
||||
pack: {
|
||||
entry: ['src/index.ts', 'src/streaming-reveal.ts'],
|
||||
...basePackConfig(),
|
||||
},
|
||||
})
|
||||
18
packages/core/README.md
Normal file
18
packages/core/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/core
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/core)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/core)
|
||||
[](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).
|
||||
1
packages/core/jsx-dev-runtime/index.cjs
Normal file
1
packages/core/jsx-dev-runtime/index.cjs
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require('../dist/jsx-runtime/jsx-runtime.cjs')
|
||||
1
packages/core/jsx-dev-runtime/index.d.cts
Normal file
1
packages/core/jsx-dev-runtime/index.d.cts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../src/jsx-runtime.ts'
|
||||
1
packages/core/jsx-dev-runtime/index.d.ts
vendored
Normal file
1
packages/core/jsx-dev-runtime/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export type * from '../src/jsx-runtime.js'
|
||||
1
packages/core/jsx-dev-runtime/index.js
Normal file
1
packages/core/jsx-dev-runtime/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../dist/jsx-runtime/jsx-runtime.js'
|
||||
1
packages/core/jsx-runtime/index.cjs
Normal file
1
packages/core/jsx-runtime/index.cjs
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require('../dist/jsx-runtime/jsx-runtime.cjs')
|
||||
1
packages/core/jsx-runtime/index.d.cts
Normal file
1
packages/core/jsx-runtime/index.d.cts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../src/jsx-runtime.ts'
|
||||
1
packages/core/jsx-runtime/index.d.ts
vendored
Normal file
1
packages/core/jsx-runtime/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export type * from '../src/jsx-runtime.ts'
|
||||
1
packages/core/jsx-runtime/index.js
Normal file
1
packages/core/jsx-runtime/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../dist/jsx-runtime/jsx-runtime.js'
|
||||
73
packages/core/package.json
Normal file
73
packages/core/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
220
packages/core/src/CommandManager.test.ts
Normal file
220
packages/core/src/CommandManager.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
208
packages/core/src/CommandManager.ts
Normal file
208
packages/core/src/CommandManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
681
packages/core/src/Editor.test.ts
Normal file
681
packages/core/src/Editor.test.ts
Normal 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
888
packages/core/src/Editor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
71
packages/core/src/EventEmitter.ts
Normal file
71
packages/core/src/EventEmitter.ts
Normal 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 = {}
|
||||
}
|
||||
}
|
||||
764
packages/core/src/Extendable.test.ts
Normal file
764
packages/core/src/Extendable.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
647
packages/core/src/Extendable.ts
Normal file
647
packages/core/src/Extendable.ts
Normal 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
|
||||
}
|
||||
}
|
||||
59
packages/core/src/Extension.ts
Normal file
59
packages/core/src/Extension.ts
Normal 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>
|
||||
}
|
||||
}
|
||||
852
packages/core/src/ExtensionManager.test.ts
Normal file
852
packages/core/src/ExtensionManager.test.ts
Normal 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(/&/g, '&')
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const result = editor.view.props.transformPastedHTML?.('<p>&test&</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()
|
||||
})
|
||||
})
|
||||
583
packages/core/src/ExtensionManager.ts
Normal file
583
packages/core/src/ExtensionManager.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
113
packages/core/src/InputRule.test.ts
Normal file
113
packages/core/src/InputRule.test.ts
Normal 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>')
|
||||
})
|
||||
})
|
||||
317
packages/core/src/InputRule.ts
Normal file
317
packages/core/src/InputRule.ts
Normal 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
231
packages/core/src/Mark.ts
Normal 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>
|
||||
}
|
||||
}
|
||||
53
packages/core/src/MarkView.test.ts
Normal file
53
packages/core/src/MarkView.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
132
packages/core/src/MarkView.ts
Normal file
132
packages/core/src/MarkView.ts
Normal 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
390
packages/core/src/Node.ts
Normal 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>
|
||||
}
|
||||
}
|
||||
722
packages/core/src/NodePos.test.ts
Normal file
722
packages/core/src/NodePos.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
270
packages/core/src/NodePos.ts
Normal file
270
packages/core/src/NodePos.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
104
packages/core/src/NodeView.test.ts
Normal file
104
packages/core/src/NodeView.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
361
packages/core/src/NodeView.ts
Normal file
361
packages/core/src/NodeView.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
386
packages/core/src/PasteRule.ts
Normal file
386
packages/core/src/PasteRule.ts
Normal 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
|
||||
}
|
||||
38
packages/core/src/Tracker.ts
Normal file
38
packages/core/src/Tracker.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
29
packages/core/src/commands/blur.ts
Normal file
29
packages/core/src/commands/blur.ts
Normal 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
|
||||
}
|
||||
25
packages/core/src/commands/clearContent.ts
Normal file
25
packages/core/src/commands/clearContent.ts
Normal 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 })
|
||||
}
|
||||
57
packages/core/src/commands/clearNodes.ts
Normal file
57
packages/core/src/commands/clearNodes.ts
Normal 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
|
||||
}
|
||||
22
packages/core/src/commands/command.ts
Normal file
22
packages/core/src/commands/command.ts
Normal 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)
|
||||
}
|
||||
21
packages/core/src/commands/createParagraphNear.ts
Normal file
21
packages/core/src/commands/createParagraphNear.ts
Normal 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)
|
||||
}
|
||||
38
packages/core/src/commands/cut.test.ts
Normal file
38
packages/core/src/commands/cut.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
36
packages/core/src/commands/cut.ts
Normal file
36
packages/core/src/commands/cut.ts
Normal 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
|
||||
}
|
||||
44
packages/core/src/commands/deleteCurrentNode.ts
Normal file
44
packages/core/src/commands/deleteCurrentNode.ts
Normal 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
|
||||
}
|
||||
41
packages/core/src/commands/deleteNode.ts
Normal file
41
packages/core/src/commands/deleteNode.ts
Normal 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
|
||||
}
|
||||
26
packages/core/src/commands/deleteRange.ts
Normal file
26
packages/core/src/commands/deleteRange.ts
Normal 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
|
||||
}
|
||||
125
packages/core/src/commands/deleteSelection.test.ts
Normal file
125
packages/core/src/commands/deleteSelection.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
109
packages/core/src/commands/deleteSelection.ts
Normal file
109
packages/core/src/commands/deleteSelection.ts
Normal 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
|
||||
}
|
||||
19
packages/core/src/commands/enter.ts
Normal file
19
packages/core/src/commands/enter.ts
Normal 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')
|
||||
}
|
||||
21
packages/core/src/commands/exitCode.ts
Normal file
21
packages/core/src/commands/exitCode.ts
Normal 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)
|
||||
}
|
||||
455
packages/core/src/commands/extendMarkRange.test.ts
Normal file
455
packages/core/src/commands/extendMarkRange.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
52
packages/core/src/commands/extendMarkRange.ts
Normal file
52
packages/core/src/commands/extendMarkRange.ts
Normal 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
|
||||
}
|
||||
26
packages/core/src/commands/first.ts
Normal file
26
packages/core/src/commands/first.ts
Normal 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
|
||||
}
|
||||
108
packages/core/src/commands/focus.ts
Normal file
108
packages/core/src/commands/focus.ts
Normal 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
|
||||
}
|
||||
24
packages/core/src/commands/forEach.ts
Normal file
24
packages/core/src/commands/forEach.ts
Normal 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 }))
|
||||
}
|
||||
59
packages/core/src/commands/index.ts
Normal file
59
packages/core/src/commands/index.ts
Normal 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'
|
||||
197
packages/core/src/commands/insertContent.test.ts
Normal file
197
packages/core/src/commands/insertContent.test.ts
Normal 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<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>')
|
||||
})
|
||||
})
|
||||
50
packages/core/src/commands/insertContent.ts
Normal file
50
packages/core/src/commands/insertContent.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
133
packages/core/src/commands/insertContentAt.test.ts
Normal file
133
packages/core/src/commands/insertContentAt.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
208
packages/core/src/commands/insertContentAt.ts
Normal file
208
packages/core/src/commands/insertContentAt.ts
Normal 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
|
||||
}
|
||||
178
packages/core/src/commands/insertDefaultBlock.test.ts
Normal file
178
packages/core/src/commands/insertDefaultBlock.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
97
packages/core/src/commands/insertDefaultBlock.ts
Normal file
97
packages/core/src/commands/insertDefaultBlock.ts
Normal 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
|
||||
}
|
||||
69
packages/core/src/commands/join.ts
Normal file
69
packages/core/src/commands/join.ts
Normal 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)
|
||||
}
|
||||
37
packages/core/src/commands/joinItemBackward.ts
Normal file
37
packages/core/src/commands/joinItemBackward.ts
Normal 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
|
||||
}
|
||||
}
|
||||
37
packages/core/src/commands/joinItemForward.ts
Normal file
37
packages/core/src/commands/joinItemForward.ts
Normal 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
|
||||
}
|
||||
}
|
||||
20
packages/core/src/commands/joinTextblockBackward.ts
Normal file
20
packages/core/src/commands/joinTextblockBackward.ts
Normal 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)
|
||||
}
|
||||
20
packages/core/src/commands/joinTextblockForward.ts
Normal file
20
packages/core/src/commands/joinTextblockForward.ts
Normal 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)
|
||||
}
|
||||
100
packages/core/src/commands/keyboardShortcut.ts
Normal file
100
packages/core/src/commands/keyboardShortcut.ts
Normal 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
|
||||
}
|
||||
34
packages/core/src/commands/lift.ts
Normal file
34
packages/core/src/commands/lift.ts
Normal 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)
|
||||
}
|
||||
21
packages/core/src/commands/liftEmptyBlock.ts
Normal file
21
packages/core/src/commands/liftEmptyBlock.ts
Normal 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)
|
||||
}
|
||||
26
packages/core/src/commands/liftListItem.ts
Normal file
26
packages/core/src/commands/liftListItem.ts
Normal 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)
|
||||
}
|
||||
21
packages/core/src/commands/newlineInCode.ts
Normal file
21
packages/core/src/commands/newlineInCode.ts
Normal 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)
|
||||
}
|
||||
80
packages/core/src/commands/resetAttributes.ts
Normal file
80
packages/core/src/commands/resetAttributes.ts
Normal 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
|
||||
}
|
||||
23
packages/core/src/commands/scrollIntoView.ts
Normal file
23
packages/core/src/commands/scrollIntoView.ts
Normal 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
|
||||
}
|
||||
27
packages/core/src/commands/selectAll.ts
Normal file
27
packages/core/src/commands/selectAll.ts
Normal 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
|
||||
}
|
||||
21
packages/core/src/commands/selectNodeBackward.ts
Normal file
21
packages/core/src/commands/selectNodeBackward.ts
Normal 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)
|
||||
}
|
||||
21
packages/core/src/commands/selectNodeForward.ts
Normal file
21
packages/core/src/commands/selectNodeForward.ts
Normal 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)
|
||||
}
|
||||
21
packages/core/src/commands/selectParentNode.ts
Normal file
21
packages/core/src/commands/selectParentNode.ts
Normal 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)
|
||||
}
|
||||
23
packages/core/src/commands/selectTextblockEnd.ts
Normal file
23
packages/core/src/commands/selectTextblockEnd.ts
Normal 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)
|
||||
}
|
||||
23
packages/core/src/commands/selectTextblockStart.ts
Normal file
23
packages/core/src/commands/selectTextblockStart.ts
Normal 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)
|
||||
}
|
||||
177
packages/core/src/commands/setContent.test.ts
Normal file
177
packages/core/src/commands/setContent.test.ts
Normal 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<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>')
|
||||
})
|
||||
})
|
||||
79
packages/core/src/commands/setContent.ts
Normal file
79
packages/core/src/commands/setContent.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
122
packages/core/src/commands/setMark.ts
Normal file
122
packages/core/src/commands/setMark.ts
Normal 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)
|
||||
}
|
||||
25
packages/core/src/commands/setMeta.ts
Normal file
25
packages/core/src/commands/setMeta.ts
Normal 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
|
||||
}
|
||||
57
packages/core/src/commands/setNode.ts
Normal file
57
packages/core/src/commands/setNode.ts
Normal 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()
|
||||
)
|
||||
}
|
||||
31
packages/core/src/commands/setNodeSelection.ts
Normal file
31
packages/core/src/commands/setNodeSelection.ts
Normal 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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user