gdpr audit implemented, email log, vollmachten, pdf delete cancel data privacy and vollmachten, removed message no id card in engergy car, and other contracts that are not telecom contracts, added insert counter for engery
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025, Tiptap GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+18
@@ -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).
|
||||
+7017
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+4887
File diff suppressed because one or more lines are too long
+4887
File diff suppressed because one or more lines are too long
+6883
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+56
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// src/jsx-runtime.ts
|
||||
var jsx_runtime_exports = {};
|
||||
__export(jsx_runtime_exports, {
|
||||
Fragment: () => Fragment,
|
||||
createElement: () => h,
|
||||
h: () => h,
|
||||
jsx: () => h,
|
||||
jsxDEV: () => h,
|
||||
jsxs: () => h
|
||||
});
|
||||
module.exports = __toCommonJS(jsx_runtime_exports);
|
||||
function Fragment(props) {
|
||||
return props.children;
|
||||
}
|
||||
var h = (tag, attributes) => {
|
||||
if (tag === "slot") {
|
||||
return 0;
|
||||
}
|
||||
if (tag instanceof Function) {
|
||||
return tag(attributes);
|
||||
}
|
||||
const { children, ...rest } = attributes != null ? attributes : {};
|
||||
if (tag === "svg") {
|
||||
throw new Error("SVG elements are not supported in the JSX syntax, use the array syntax instead");
|
||||
}
|
||||
return [tag, rest, children];
|
||||
};
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
Fragment,
|
||||
createElement,
|
||||
h,
|
||||
jsx,
|
||||
jsxDEV,
|
||||
jsxs
|
||||
});
|
||||
//# sourceMappingURL=jsx-runtime.cjs.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../src/jsx-runtime.ts"],"sourcesContent":["export type Attributes = Record<string, any>\n\nexport type DOMOutputSpecElement = 0 | Attributes | DOMOutputSpecArray\n/**\n * Better describes the output of a `renderHTML` function in prosemirror\n * @see https://prosemirror.net/docs/ref/#model.DOMOutputSpec\n */\nexport type DOMOutputSpecArray =\n | [string]\n | [string, Attributes]\n | [string, 0]\n | [string, Attributes, 0]\n | [string, Attributes, DOMOutputSpecArray | 0]\n | [string, DOMOutputSpecArray]\n\n// JSX types for Tiptap's JSX runtime\n// These types only apply when using @jsxImportSource @tiptap/core\n// eslint-disable-next-line @typescript-eslint/no-namespace\nexport namespace JSX {\n export type Element = DOMOutputSpecArray\n export interface IntrinsicElements {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n [key: string]: any\n }\n export interface ElementChildrenAttribute {\n children: unknown\n }\n}\n\nexport type JSXRenderer = (\n tag: 'slot' | string | ((props?: Attributes) => DOMOutputSpecArray | DOMOutputSpecElement),\n props?: Attributes,\n ...children: JSXRenderer[]\n) => DOMOutputSpecArray | DOMOutputSpecElement\n\nexport function Fragment(props: { children: JSXRenderer[] }) {\n return props.children\n}\n\nexport const h: JSXRenderer = (tag, attributes) => {\n // Treat the slot tag as the Prosemirror hole to render content into\n if (tag === 'slot') {\n return 0\n }\n\n // If the tag is a function, call it with the props\n if (tag instanceof Function) {\n return tag(attributes)\n }\n\n const { children, ...rest } = attributes ?? {}\n\n if (tag === 'svg') {\n throw new Error('SVG elements are not supported in the JSX syntax, use the array syntax instead')\n }\n\n // Otherwise, return the tag, attributes, and children\n return [tag, rest, children]\n}\n\n// See\n// https://esbuild.github.io/api/#jsx-import-source\n// https://www.typescriptlang.org/tsconfig/#jsxImportSource\n\nexport { h as createElement, h as jsx, h as jsxDEV, h as jsxs }\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmCO,SAAS,SAAS,OAAoC;AAC3D,SAAO,MAAM;AACf;AAEO,IAAM,IAAiB,CAAC,KAAK,eAAe;AAEjD,MAAI,QAAQ,QAAQ;AAClB,WAAO;AAAA,EACT;AAGA,MAAI,eAAe,UAAU;AAC3B,WAAO,IAAI,UAAU;AAAA,EACvB;AAEA,QAAM,EAAE,UAAU,GAAG,KAAK,IAAI,kCAAc,CAAC;AAE7C,MAAI,QAAQ,OAAO;AACjB,UAAM,IAAI,MAAM,gFAAgF;AAAA,EAClG;AAGA,SAAO,CAAC,KAAK,MAAM,QAAQ;AAC7B;","names":[]}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
type Attributes = Record<string, any>;
|
||||
type DOMOutputSpecElement = 0 | Attributes | DOMOutputSpecArray;
|
||||
/**
|
||||
* Better describes the output of a `renderHTML` function in prosemirror
|
||||
* @see https://prosemirror.net/docs/ref/#model.DOMOutputSpec
|
||||
*/
|
||||
type DOMOutputSpecArray = [string] | [string, Attributes] | [string, 0] | [string, Attributes, 0] | [string, Attributes, DOMOutputSpecArray | 0] | [string, DOMOutputSpecArray];
|
||||
declare namespace JSX {
|
||||
type Element = DOMOutputSpecArray;
|
||||
interface IntrinsicElements {
|
||||
[key: string]: any;
|
||||
}
|
||||
interface ElementChildrenAttribute {
|
||||
children: unknown;
|
||||
}
|
||||
}
|
||||
type JSXRenderer = (tag: 'slot' | string | ((props?: Attributes) => DOMOutputSpecArray | DOMOutputSpecElement), props?: Attributes, ...children: JSXRenderer[]) => DOMOutputSpecArray | DOMOutputSpecElement;
|
||||
declare function Fragment(props: {
|
||||
children: JSXRenderer[];
|
||||
}): JSXRenderer[];
|
||||
declare const h: JSXRenderer;
|
||||
|
||||
export { type Attributes, type DOMOutputSpecArray, type DOMOutputSpecElement, Fragment, JSX, type JSXRenderer, h as createElement, h, h as jsx, h as jsxDEV, h as jsxs };
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
type Attributes = Record<string, any>;
|
||||
type DOMOutputSpecElement = 0 | Attributes | DOMOutputSpecArray;
|
||||
/**
|
||||
* Better describes the output of a `renderHTML` function in prosemirror
|
||||
* @see https://prosemirror.net/docs/ref/#model.DOMOutputSpec
|
||||
*/
|
||||
type DOMOutputSpecArray = [string] | [string, Attributes] | [string, 0] | [string, Attributes, 0] | [string, Attributes, DOMOutputSpecArray | 0] | [string, DOMOutputSpecArray];
|
||||
declare namespace JSX {
|
||||
type Element = DOMOutputSpecArray;
|
||||
interface IntrinsicElements {
|
||||
[key: string]: any;
|
||||
}
|
||||
interface ElementChildrenAttribute {
|
||||
children: unknown;
|
||||
}
|
||||
}
|
||||
type JSXRenderer = (tag: 'slot' | string | ((props?: Attributes) => DOMOutputSpecArray | DOMOutputSpecElement), props?: Attributes, ...children: JSXRenderer[]) => DOMOutputSpecArray | DOMOutputSpecElement;
|
||||
declare function Fragment(props: {
|
||||
children: JSXRenderer[];
|
||||
}): JSXRenderer[];
|
||||
declare const h: JSXRenderer;
|
||||
|
||||
export { type Attributes, type DOMOutputSpecArray, type DOMOutputSpecElement, Fragment, JSX, type JSXRenderer, h as createElement, h, h as jsx, h as jsxDEV, h as jsxs };
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// src/jsx-runtime.ts
|
||||
function Fragment(props) {
|
||||
return props.children;
|
||||
}
|
||||
var h = (tag, attributes) => {
|
||||
if (tag === "slot") {
|
||||
return 0;
|
||||
}
|
||||
if (tag instanceof Function) {
|
||||
return tag(attributes);
|
||||
}
|
||||
const { children, ...rest } = attributes != null ? attributes : {};
|
||||
if (tag === "svg") {
|
||||
throw new Error("SVG elements are not supported in the JSX syntax, use the array syntax instead");
|
||||
}
|
||||
return [tag, rest, children];
|
||||
};
|
||||
export {
|
||||
Fragment,
|
||||
h as createElement,
|
||||
h,
|
||||
h as jsx,
|
||||
h as jsxDEV,
|
||||
h as jsxs
|
||||
};
|
||||
//# sourceMappingURL=jsx-runtime.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../src/jsx-runtime.ts"],"sourcesContent":["export type Attributes = Record<string, any>\n\nexport type DOMOutputSpecElement = 0 | Attributes | DOMOutputSpecArray\n/**\n * Better describes the output of a `renderHTML` function in prosemirror\n * @see https://prosemirror.net/docs/ref/#model.DOMOutputSpec\n */\nexport type DOMOutputSpecArray =\n | [string]\n | [string, Attributes]\n | [string, 0]\n | [string, Attributes, 0]\n | [string, Attributes, DOMOutputSpecArray | 0]\n | [string, DOMOutputSpecArray]\n\n// JSX types for Tiptap's JSX runtime\n// These types only apply when using @jsxImportSource @tiptap/core\n// eslint-disable-next-line @typescript-eslint/no-namespace\nexport namespace JSX {\n export type Element = DOMOutputSpecArray\n export interface IntrinsicElements {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n [key: string]: any\n }\n export interface ElementChildrenAttribute {\n children: unknown\n }\n}\n\nexport type JSXRenderer = (\n tag: 'slot' | string | ((props?: Attributes) => DOMOutputSpecArray | DOMOutputSpecElement),\n props?: Attributes,\n ...children: JSXRenderer[]\n) => DOMOutputSpecArray | DOMOutputSpecElement\n\nexport function Fragment(props: { children: JSXRenderer[] }) {\n return props.children\n}\n\nexport const h: JSXRenderer = (tag, attributes) => {\n // Treat the slot tag as the Prosemirror hole to render content into\n if (tag === 'slot') {\n return 0\n }\n\n // If the tag is a function, call it with the props\n if (tag instanceof Function) {\n return tag(attributes)\n }\n\n const { children, ...rest } = attributes ?? {}\n\n if (tag === 'svg') {\n throw new Error('SVG elements are not supported in the JSX syntax, use the array syntax instead')\n }\n\n // Otherwise, return the tag, attributes, and children\n return [tag, rest, children]\n}\n\n// See\n// https://esbuild.github.io/api/#jsx-import-source\n// https://www.typescriptlang.org/tsconfig/#jsxImportSource\n\nexport { h as createElement, h as jsx, h as jsxDEV, h as jsxs }\n"],"mappings":";AAmCO,SAAS,SAAS,OAAoC;AAC3D,SAAO,MAAM;AACf;AAEO,IAAM,IAAiB,CAAC,KAAK,eAAe;AAEjD,MAAI,QAAQ,QAAQ;AAClB,WAAO;AAAA,EACT;AAGA,MAAI,eAAe,UAAU;AAC3B,WAAO,IAAI,UAAU;AAAA,EACvB;AAEA,QAAM,EAAE,UAAU,GAAG,KAAK,IAAI,kCAAc,CAAC;AAE7C,MAAI,QAAQ,OAAO;AACjB,UAAM,IAAI,MAAM,gFAAgF;AAAA,EAClG;AAGA,SAAO,CAAC,KAAK,MAAM,QAAQ;AAC7B;","names":[]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
module.exports = require('../dist/jsx-runtime/jsx-runtime.cjs')
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from '../src/jsx-runtime.ts'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type * from '../src/jsx-runtime.js'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from '../dist/jsx-runtime/jsx-runtime.js'
|
||||
+1
@@ -0,0 +1 @@
|
||||
module.exports = require('../dist/jsx-runtime/jsx-runtime.cjs')
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from '../src/jsx-runtime.ts'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type * from '../src/jsx-runtime.ts'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from '../dist/jsx-runtime/jsx-runtime.js'
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@tiptap/core",
|
||||
"description": "headless rich text editor",
|
||||
"version": "3.19.0",
|
||||
"homepage": "https://tiptap.dev",
|
||||
"keywords": [
|
||||
"tiptap",
|
||||
"headless",
|
||||
"wysiwyg",
|
||||
"text editor",
|
||||
"prosemirror"
|
||||
],
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"type": "module",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"jsx-runtime",
|
||||
"jsx-dev-runtime"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@tiptap/pm": "^3.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "^3.19.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
"directory": "packages/core"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"lint": "prettier ./src/ --check && eslint --cache --quiet --no-error-on-unmatched-pattern ./src/"
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+801
@@ -0,0 +1,801 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
||||
import type { MarkType, Node as ProseMirrorNode, NodeType, Schema } from '@tiptap/pm/model'
|
||||
import type { Plugin, PluginKey, Transaction } from '@tiptap/pm/state'
|
||||
import { EditorState } from '@tiptap/pm/state'
|
||||
import { EditorView } from '@tiptap/pm/view'
|
||||
|
||||
import { CommandManager } from './CommandManager.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 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 { 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 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)
|
||||
|
||||
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()
|
||||
const selection = resolveFocusPosition(initialDoc, this.options.autofocus)
|
||||
|
||||
// Set editor state immediately, so that it's available independently from the view
|
||||
this.editorState = EditorState.create({
|
||||
doc: initialDoc,
|
||||
schema: this.schema,
|
||||
selection: selection || undefined,
|
||||
})
|
||||
|
||||
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) {
|
||||
// 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 {
|
||||
return this.commandManager.chain()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command or a command chain can be executed. Without executing it.
|
||||
*/
|
||||
public can(): CanCommands {
|
||||
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 state.
|
||||
*/
|
||||
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 (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,
|
||||
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(): ProseMirrorNode {
|
||||
let doc: ProseMirrorNode
|
||||
|
||||
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
|
||||
}
|
||||
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()
|
||||
},
|
||||
})
|
||||
|
||||
// Content is invalid, but attempt to create it anyway, stripping out the invalid parts
|
||||
doc = createDocument(this.options.content, this.schema, this.options.parseOptions, {
|
||||
errorOnInvalidContent: false,
|
||||
})
|
||||
}
|
||||
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 any).dispatchTransaction || this.dispatchTransaction.bind(this)
|
||||
const dispatch = enableExtensionDispatchTransaction
|
||||
? this.extensionManager.dispatchTransaction(baseDispatch)
|
||||
: baseDispatch
|
||||
|
||||
this.editorView = new EditorView(element, {
|
||||
...editorProps,
|
||||
attributes: {
|
||||
// add `role="textbox"` to the editor element
|
||||
role: 'textbox',
|
||||
...editorProps?.attributes,
|
||||
},
|
||||
dispatchTransaction: dispatch,
|
||||
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,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
transaction: mostRecentFocusTr!,
|
||||
})
|
||||
}
|
||||
|
||||
if (blur) {
|
||||
this.emit('blur', {
|
||||
editor: this,
|
||||
event: blur.event,
|
||||
// eslint-disable-next-line @typescript-eslint/no-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 {
|
||||
this.emit('destroy')
|
||||
|
||||
this.unmount()
|
||||
|
||||
this.removeAllListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
return new NodePos($pos, this)
|
||||
}
|
||||
|
||||
get $doc() {
|
||||
return this.$pos(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a set of utilities for working with positions and ranges.
|
||||
*/
|
||||
public utils: Utils = {
|
||||
getUpdatedPosition,
|
||||
createMappablePosition,
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
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 = {}
|
||||
}
|
||||
}
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
import type { Plugin } from '@tiptap/pm/state'
|
||||
|
||||
import type { Editor } from './Editor.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[]
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import type { Editor } from './Editor.js'
|
||||
import { type ExtendableConfig, Extendable } from './Extendable.js'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-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>
|
||||
}
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
import { keymap } from '@tiptap/pm/keymap'
|
||||
import type { Schema } from '@tiptap/pm/model'
|
||||
import type { Plugin, Transaction } from '@tiptap/pm/state'
|
||||
import type { MarkViewConstructor, NodeViewConstructor } from '@tiptap/pm/view'
|
||||
|
||||
import type { Editor } from './Editor.js'
|
||||
import {
|
||||
flattenExtensions,
|
||||
getAttributesFromExtensions,
|
||||
getExtensionField,
|
||||
getNodeType,
|
||||
getRenderedAttributes,
|
||||
getSchemaByResolvedExtensions,
|
||||
getSchemaTypeByName,
|
||||
isExtensionRulesEnabled,
|
||||
resolveExtensions,
|
||||
sortExtensions,
|
||||
splitExtensions,
|
||||
} from './helpers/index.js'
|
||||
import { type MarkConfig, type NodeConfig, type Storage, getMarkType, updateMarkViewAttributes } from './index.js'
|
||||
import { inputRulesPlugin } from './InputRule.js'
|
||||
import { Mark } from './Mark.js'
|
||||
import { pasteRulesPlugin } from './PasteRule.js'
|
||||
import type { AnyConfig, Extensions, RawCommands } from './types.js'
|
||||
import { callOrReturn } from './utilities/callOrReturn.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[] = []
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
return allPlugins
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import type { Node as ProseMirrorNode } 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
|
||||
}
|
||||
|
||||
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 | ProseMirrorNode | 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
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
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)
|
||||
|
||||
/**
|
||||
* 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
|
||||
// eslint-disable-next-line @typescript-eslint/no-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>
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
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.dom.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
|
||||
}
|
||||
}
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
import type { DOMOutputSpec, Node as ProseMirrorNode, 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: ProseMirrorNode
|
||||
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: ProseMirrorNode
|
||||
pos: number
|
||||
parent: ProseMirrorNode
|
||||
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
|
||||
// eslint-disable-next-line @typescript-eslint/no-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>
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
import type { Fragment, Node, 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: Node | null = null) {
|
||||
this.isBlock = isBlock
|
||||
this.resolvedPos = pos
|
||||
this.editor = editor
|
||||
this.currentNode = node
|
||||
}
|
||||
|
||||
private currentNode: Node | null = null
|
||||
|
||||
get node(): Node {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
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 = props.getPos
|
||||
this.mount()
|
||||
}
|
||||
|
||||
mount() {
|
||||
// eslint-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 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 || 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.dom.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 })
|
||||
}
|
||||
}
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
import type { Node as ProseMirrorNode } 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 | ProseMirrorNode | 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
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
+36
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { deleteSelection as originalDeleteSelection } from '@tiptap/pm/commands'
|
||||
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
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 }) => {
|
||||
return originalDeleteSelection(state, dispatch)
|
||||
}
|
||||
+19
@@ -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
@@ -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)
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
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 of the mark.
|
||||
* @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
@@ -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
@@ -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
@@ -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 }))
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
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 './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 './wrapIn.js'
|
||||
export * from './wrapInList.js'
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { Fragment, Node as ProseMirrorNode, 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 | ProseMirrorNode | 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)
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import type { Node as ProseMirrorNode, ParseOptions } from '@tiptap/pm/model'
|
||||
import { Fragment } from '@tiptap/pm/model'
|
||||
|
||||
import { createNodeFromContent } from '../helpers/createNodeFromContent.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 | ProseMirrorNode | Fragment,
|
||||
|
||||
/**
|
||||
* Optional options
|
||||
*/
|
||||
options?: InsertContentAtOptions,
|
||||
) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isFragment = (nodeOrFragment: ProseMirrorNode | Fragment): nodeOrFragment is Fragment => {
|
||||
return !('type' in nodeOrFragment)
|
||||
}
|
||||
|
||||
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 | ProseMirrorNode
|
||||
|
||||
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 = isFragment(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(v => v.text || '').join('')
|
||||
} else if (value instanceof Fragment) {
|
||||
let text = ''
|
||||
|
||||
value.forEach(node => {
|
||||
if (node.text) {
|
||||
text += node.text
|
||||
}
|
||||
})
|
||||
|
||||
newContent = text
|
||||
} else if (typeof value === 'object' && !!value && !!value.text) {
|
||||
newContent = value.text
|
||||
} else {
|
||||
newContent = value as string
|
||||
}
|
||||
|
||||
tr.insertText(newContent, from, to)
|
||||
} else {
|
||||
newContent = content
|
||||
|
||||
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) {
|
||||
from = Math.max(0, from - 1)
|
||||
}
|
||||
|
||||
tr.replaceWith(from, to, newContent)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
+69
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import type { Fragment, Node as ProseMirrorNode, ParseOptions } from '@tiptap/pm/model'
|
||||
|
||||
import { createDocument } from '../helpers/createDocument.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 | ProseMirrorNode,
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
tr.replaceWith(0, doc.content.size, document).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,
|
||||
})
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import type { Range, RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
setTextDirection: {
|
||||
/**
|
||||
* Set the text direction for nodes.
|
||||
* If no position is provided, it will use the current selection.
|
||||
* @param direction The text direction to set ('ltr', 'rtl', or 'auto')
|
||||
* @param position Optional position or range to apply the direction to
|
||||
* @example editor.commands.setTextDirection('rtl')
|
||||
* @example editor.commands.setTextDirection('ltr', { from: 0, to: 10 })
|
||||
*/
|
||||
setTextDirection: (direction: 'ltr' | 'rtl' | 'auto', position?: number | Range) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const setTextDirection: RawCommands['setTextDirection'] =
|
||||
(direction, position) =>
|
||||
({ tr, state, dispatch }) => {
|
||||
const { selection } = state
|
||||
let from: number
|
||||
let to: number
|
||||
|
||||
if (typeof position === 'number') {
|
||||
from = position
|
||||
to = position
|
||||
} else if (position && 'from' in position && 'to' in position) {
|
||||
from = position.from
|
||||
to = position.to
|
||||
} else {
|
||||
from = selection.from
|
||||
to = selection.to
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
tr.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (node.isText) {
|
||||
return
|
||||
}
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
dir: direction,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
|
||||
import type { Range, RawCommands } from '../types.js'
|
||||
import { minMax } from '../utilities/minMax.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
setTextSelection: {
|
||||
/**
|
||||
* Creates a TextSelection.
|
||||
* @param position The position of the selection.
|
||||
* @example editor.commands.setTextSelection(10)
|
||||
*/
|
||||
setTextSelection: (position: number | Range) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const setTextSelection: RawCommands['setTextSelection'] =
|
||||
position =>
|
||||
({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
const { doc } = tr
|
||||
const { from, to } = typeof position === 'number' ? { from: position, to: position } : position
|
||||
const minPos = TextSelection.atStart(doc).from
|
||||
const maxPos = TextSelection.atEnd(doc).to
|
||||
const resolvedFrom = minMax(from, minPos, maxPos)
|
||||
const resolvedEnd = minMax(to, minPos, maxPos)
|
||||
const selection = TextSelection.create(doc, resolvedFrom, resolvedEnd)
|
||||
|
||||
tr.setSelection(selection)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { NodeType } from '@tiptap/pm/model'
|
||||
import { sinkListItem as originalSinkListItem } from '@tiptap/pm/schema-list'
|
||||
|
||||
import { getNodeType } from '../helpers/getNodeType.js'
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
sinkListItem: {
|
||||
/**
|
||||
* Sink the list item down into an inner list.
|
||||
* @param typeOrName The type or name of the node.
|
||||
* @example editor.commands.sinkListItem('listItem')
|
||||
*/
|
||||
sinkListItem: (typeOrName: string | NodeType) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sinkListItem: RawCommands['sinkListItem'] =
|
||||
typeOrName =>
|
||||
({ state, dispatch }) => {
|
||||
const type = getNodeType(typeOrName, state.schema)
|
||||
|
||||
return originalSinkListItem(type)(state, dispatch)
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
|
||||
import { canSplit } from '@tiptap/pm/transform'
|
||||
|
||||
import { defaultBlockAt } from '../helpers/defaultBlockAt.js'
|
||||
import { getSplittedAttributes } from '../helpers/getSplittedAttributes.js'
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
function ensureMarks(state: EditorState, splittableMarks?: string[]) {
|
||||
const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks())
|
||||
|
||||
if (marks) {
|
||||
const filteredMarks = marks.filter(mark => splittableMarks?.includes(mark.type.name))
|
||||
|
||||
state.tr.ensureMarks(filteredMarks)
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
splitBlock: {
|
||||
/**
|
||||
* Forks a new node from an existing node.
|
||||
* @param options.keepMarks Keep marks from the previous node.
|
||||
* @example editor.commands.splitBlock()
|
||||
* @example editor.commands.splitBlock({ keepMarks: true })
|
||||
*/
|
||||
splitBlock: (options?: { keepMarks?: boolean }) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const splitBlock: RawCommands['splitBlock'] =
|
||||
({ keepMarks = true } = {}) =>
|
||||
({ tr, state, dispatch, editor }) => {
|
||||
const { selection, doc } = tr
|
||||
const { $from, $to } = selection
|
||||
const extensionAttributes = editor.extensionManager.attributes
|
||||
const newAttributes = getSplittedAttributes(extensionAttributes, $from.node().type.name, $from.node().attrs)
|
||||
|
||||
if (selection instanceof NodeSelection && selection.node.isBlock) {
|
||||
if (!$from.parentOffset || !canSplit(doc, $from.pos)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
if (keepMarks) {
|
||||
ensureMarks(state, editor.extensionManager.splittableMarks)
|
||||
}
|
||||
|
||||
tr.split($from.pos).scrollIntoView()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (!$from.parent.isBlock) {
|
||||
return false
|
||||
}
|
||||
|
||||
const atEnd = $to.parentOffset === $to.parent.content.size
|
||||
|
||||
const deflt = $from.depth === 0 ? undefined : defaultBlockAt($from.node(-1).contentMatchAt($from.indexAfter(-1)))
|
||||
|
||||
let types =
|
||||
atEnd && deflt
|
||||
? [
|
||||
{
|
||||
type: deflt,
|
||||
attrs: newAttributes,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
|
||||
let can = canSplit(tr.doc, tr.mapping.map($from.pos), 1, types)
|
||||
|
||||
if (!types && !can && canSplit(tr.doc, tr.mapping.map($from.pos), 1, deflt ? [{ type: deflt }] : undefined)) {
|
||||
can = true
|
||||
types = deflt
|
||||
? [
|
||||
{
|
||||
type: deflt,
|
||||
attrs: newAttributes,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
if (can) {
|
||||
if (selection instanceof TextSelection) {
|
||||
tr.deleteSelection()
|
||||
}
|
||||
|
||||
tr.split(tr.mapping.map($from.pos), 1, types)
|
||||
|
||||
if (deflt && !atEnd && !$from.parentOffset && $from.parent.type !== deflt) {
|
||||
const first = tr.mapping.map($from.before())
|
||||
const $first = tr.doc.resolve(first)
|
||||
|
||||
if ($from.node(-1).canReplaceWith($first.index(), $first.index() + 1, deflt)) {
|
||||
tr.setNodeMarkup(tr.mapping.map($from.before()), deflt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (keepMarks) {
|
||||
ensureMarks(state, editor.extensionManager.splittableMarks)
|
||||
}
|
||||
|
||||
tr.scrollIntoView()
|
||||
}
|
||||
|
||||
return can
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import type { Node as ProseMirrorNode, NodeType } from '@tiptap/pm/model'
|
||||
import { Fragment, Slice } from '@tiptap/pm/model'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import { canSplit } from '@tiptap/pm/transform'
|
||||
|
||||
import { getNodeType } from '../helpers/getNodeType.js'
|
||||
import { getSplittedAttributes } from '../helpers/getSplittedAttributes.js'
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
splitListItem: {
|
||||
/**
|
||||
* Splits one list item into two list items.
|
||||
* @param typeOrName The type or name of the node.
|
||||
* @param overrideAttrs The attributes to ensure on the new node.
|
||||
* @example editor.commands.splitListItem('listItem')
|
||||
*/
|
||||
splitListItem: (typeOrName: string | NodeType, overrideAttrs?: Record<string, any>) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const splitListItem: RawCommands['splitListItem'] =
|
||||
(typeOrName, overrideAttrs = {}) =>
|
||||
({ tr, state, dispatch, editor }) => {
|
||||
const type = getNodeType(typeOrName, state.schema)
|
||||
const { $from, $to } = state.selection
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line
|
||||
const node: ProseMirrorNode = state.selection.node
|
||||
|
||||
if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const grandParent = $from.node(-1)
|
||||
|
||||
if (grandParent.type !== type) {
|
||||
return false
|
||||
}
|
||||
|
||||
const extensionAttributes = editor.extensionManager.attributes
|
||||
|
||||
if ($from.parent.content.size === 0 && $from.node(-1).childCount === $from.indexAfter(-1)) {
|
||||
// In an empty block. If this is a nested list, the wrapping
|
||||
// list item should be split. Otherwise, bail out and let next
|
||||
// command handle lifting.
|
||||
if ($from.depth === 2 || $from.node(-3).type !== type || $from.index(-2) !== $from.node(-2).childCount - 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
let wrap = Fragment.empty
|
||||
// eslint-disable-next-line
|
||||
const depthBefore = $from.index(-1) ? 1 : $from.index(-2) ? 2 : 3
|
||||
|
||||
// Build a fragment containing empty versions of the structure
|
||||
// from the outer list item to the parent node of the cursor
|
||||
for (let d = $from.depth - depthBefore; d >= $from.depth - 3; d -= 1) {
|
||||
wrap = Fragment.from($from.node(d).copy(wrap))
|
||||
}
|
||||
|
||||
const depthAfter =
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
$from.indexAfter(-1) < $from.node(-2).childCount
|
||||
? 1
|
||||
: $from.indexAfter(-2) < $from.node(-3).childCount
|
||||
? 2
|
||||
: 3
|
||||
|
||||
// Add a second list item with an empty default start node
|
||||
const newNextTypeAttributes = {
|
||||
...getSplittedAttributes(extensionAttributes, $from.node().type.name, $from.node().attrs),
|
||||
...overrideAttrs,
|
||||
}
|
||||
const nextType = type.contentMatch.defaultType?.createAndFill(newNextTypeAttributes) || undefined
|
||||
|
||||
wrap = wrap.append(Fragment.from(type.createAndFill(null, nextType) || undefined))
|
||||
|
||||
const start = $from.before($from.depth - (depthBefore - 1))
|
||||
|
||||
tr.replace(start, $from.after(-depthAfter), new Slice(wrap, 4 - depthBefore, 0))
|
||||
|
||||
let sel = -1
|
||||
|
||||
tr.doc.nodesBetween(start, tr.doc.content.size, (n, pos) => {
|
||||
if (sel > -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (n.isTextblock && n.content.size === 0) {
|
||||
sel = pos + 1
|
||||
}
|
||||
})
|
||||
|
||||
if (sel > -1) {
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(sel)))
|
||||
}
|
||||
|
||||
tr.scrollIntoView()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const nextType = $to.pos === $from.end() ? grandParent.contentMatchAt(0).defaultType : null
|
||||
|
||||
const newTypeAttributes = {
|
||||
...getSplittedAttributes(extensionAttributes, grandParent.type.name, grandParent.attrs),
|
||||
...overrideAttrs,
|
||||
}
|
||||
const newNextTypeAttributes = {
|
||||
...getSplittedAttributes(extensionAttributes, $from.node().type.name, $from.node().attrs),
|
||||
...overrideAttrs,
|
||||
}
|
||||
|
||||
tr.delete($from.pos, $to.pos)
|
||||
|
||||
const types = nextType
|
||||
? [
|
||||
{ type, attrs: newTypeAttributes },
|
||||
{ type: nextType, attrs: newNextTypeAttributes },
|
||||
]
|
||||
: [{ type, attrs: newTypeAttributes }]
|
||||
|
||||
if (!canSplit(tr.doc, $from.pos, 2)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
const { selection, storedMarks } = state
|
||||
const { splittableMarks } = editor.extensionManager
|
||||
const marks = storedMarks || (selection.$to.parentOffset && selection.$from.marks())
|
||||
|
||||
tr.split($from.pos, 2, types).scrollIntoView()
|
||||
|
||||
if (!marks || !dispatch) {
|
||||
return true
|
||||
}
|
||||
|
||||
const filteredMarks = marks.filter(mark => splittableMarks.includes(mark.type.name))
|
||||
|
||||
tr.ensureMarks(filteredMarks)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import type { NodeType } from '@tiptap/pm/model'
|
||||
import type { Transaction } from '@tiptap/pm/state'
|
||||
import { canJoin } from '@tiptap/pm/transform'
|
||||
|
||||
import { findParentNode } from '../helpers/findParentNode.js'
|
||||
import { getNodeType } from '../helpers/getNodeType.js'
|
||||
import { isList } from '../helpers/isList.js'
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
const joinListBackwards = (tr: Transaction, listType: NodeType): boolean => {
|
||||
const list = findParentNode(node => node.type === listType)(tr.selection)
|
||||
|
||||
if (!list) {
|
||||
return true
|
||||
}
|
||||
|
||||
const before = tr.doc.resolve(Math.max(0, list.pos - 1)).before(list.depth)
|
||||
|
||||
if (before === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
const nodeBefore = tr.doc.nodeAt(before)
|
||||
const canJoinBackwards = list.node.type === nodeBefore?.type && canJoin(tr.doc, list.pos)
|
||||
|
||||
if (!canJoinBackwards) {
|
||||
return true
|
||||
}
|
||||
|
||||
tr.join(list.pos)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const joinListForwards = (tr: Transaction, listType: NodeType): boolean => {
|
||||
const list = findParentNode(node => node.type === listType)(tr.selection)
|
||||
|
||||
if (!list) {
|
||||
return true
|
||||
}
|
||||
|
||||
const after = tr.doc.resolve(list.start).after(list.depth)
|
||||
|
||||
if (after === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
const nodeAfter = tr.doc.nodeAt(after)
|
||||
const canJoinForwards = list.node.type === nodeAfter?.type && canJoin(tr.doc, after)
|
||||
|
||||
if (!canJoinForwards) {
|
||||
return true
|
||||
}
|
||||
|
||||
tr.join(after)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
toggleList: {
|
||||
/**
|
||||
* Toggle between different list types.
|
||||
* @param listTypeOrName The type or name of the list.
|
||||
* @param itemTypeOrName The type or name of the list item.
|
||||
* @param keepMarks Keep marks when toggling.
|
||||
* @param attributes Attributes for the new list.
|
||||
* @example editor.commands.toggleList('bulletList', 'listItem')
|
||||
*/
|
||||
toggleList: (
|
||||
listTypeOrName: string | NodeType,
|
||||
itemTypeOrName: string | NodeType,
|
||||
keepMarks?: boolean,
|
||||
attributes?: Record<string, any>,
|
||||
) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleList: RawCommands['toggleList'] =
|
||||
(listTypeOrName, itemTypeOrName, keepMarks, attributes = {}) =>
|
||||
({ editor, tr, state, dispatch, chain, commands, can }) => {
|
||||
const { extensions, splittableMarks } = editor.extensionManager
|
||||
const listType = getNodeType(listTypeOrName, state.schema)
|
||||
const itemType = getNodeType(itemTypeOrName, state.schema)
|
||||
const { selection, storedMarks } = state
|
||||
const { $from, $to } = selection
|
||||
const range = $from.blockRange($to)
|
||||
|
||||
const marks = storedMarks || (selection.$to.parentOffset && selection.$from.marks())
|
||||
|
||||
if (!range) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parentList = findParentNode(node => isList(node.type.name, extensions))(selection)
|
||||
|
||||
if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) {
|
||||
// remove list
|
||||
if (parentList.node.type === listType) {
|
||||
return commands.liftListItem(itemType)
|
||||
}
|
||||
|
||||
// change list type
|
||||
if (isList(parentList.node.type.name, extensions) && listType.validContent(parentList.node.content) && dispatch) {
|
||||
return chain()
|
||||
.command(() => {
|
||||
tr.setNodeMarkup(parentList.pos, listType)
|
||||
|
||||
return true
|
||||
})
|
||||
.command(() => joinListBackwards(tr, listType))
|
||||
.command(() => joinListForwards(tr, listType))
|
||||
.run()
|
||||
}
|
||||
}
|
||||
if (!keepMarks || !marks || !dispatch) {
|
||||
return (
|
||||
chain()
|
||||
// try to convert node to default node if needed
|
||||
.command(() => {
|
||||
const canWrapInList = can().wrapInList(listType, attributes)
|
||||
|
||||
if (canWrapInList) {
|
||||
return true
|
||||
}
|
||||
|
||||
return commands.clearNodes()
|
||||
})
|
||||
.wrapInList(listType, attributes)
|
||||
.command(() => joinListBackwards(tr, listType))
|
||||
.command(() => joinListForwards(tr, listType))
|
||||
.run()
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
chain()
|
||||
// try to convert node to default node if needed
|
||||
.command(() => {
|
||||
const canWrapInList = can().wrapInList(listType, attributes)
|
||||
|
||||
const filteredMarks = marks.filter(mark => splittableMarks.includes(mark.type.name))
|
||||
|
||||
tr.ensureMarks(filteredMarks)
|
||||
|
||||
if (canWrapInList) {
|
||||
return true
|
||||
}
|
||||
|
||||
return commands.clearNodes()
|
||||
})
|
||||
.wrapInList(listType, attributes)
|
||||
.command(() => joinListBackwards(tr, listType))
|
||||
.command(() => joinListForwards(tr, listType))
|
||||
.run()
|
||||
)
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import type { MarkType } from '@tiptap/pm/model'
|
||||
|
||||
import { getMarkType } from '../helpers/getMarkType.js'
|
||||
import { isMarkActive } from '../helpers/isMarkActive.js'
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
toggleMark: {
|
||||
/**
|
||||
* Toggle a mark on and off.
|
||||
* @param typeOrName The mark type or name.
|
||||
* @param attributes The attributes of the mark.
|
||||
* @param options.extendEmptyMarkRange Removes the mark even across the current selection. Defaults to `false`.
|
||||
* @example editor.commands.toggleMark('bold')
|
||||
*/
|
||||
toggleMark: (
|
||||
/**
|
||||
* The mark type or name.
|
||||
*/
|
||||
typeOrName: string | MarkType,
|
||||
|
||||
/**
|
||||
* The attributes of the mark.
|
||||
*/
|
||||
attributes?: Record<string, any>,
|
||||
|
||||
options?: {
|
||||
/**
|
||||
* Removes the mark even across the current selection. Defaults to `false`.
|
||||
*/
|
||||
extendEmptyMarkRange?: boolean
|
||||
},
|
||||
) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleMark: RawCommands['toggleMark'] =
|
||||
(typeOrName, attributes = {}, options = {}) =>
|
||||
({ state, commands }) => {
|
||||
const { extendEmptyMarkRange = false } = options
|
||||
const type = getMarkType(typeOrName, state.schema)
|
||||
const isActive = isMarkActive(state, type, attributes)
|
||||
|
||||
if (isActive) {
|
||||
return commands.unsetMark(type, { extendEmptyMarkRange })
|
||||
}
|
||||
|
||||
return commands.setMark(type, attributes)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import type { NodeType } from '@tiptap/pm/model'
|
||||
|
||||
import { getNodeType } from '../helpers/getNodeType.js'
|
||||
import { isNodeActive } from '../helpers/isNodeActive.js'
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
toggleNode: {
|
||||
/**
|
||||
* Toggle a node with another node.
|
||||
* @param typeOrName The type or name of the node.
|
||||
* @param toggleTypeOrName The type or name of the node to toggle.
|
||||
* @param attributes The attributes of the node.
|
||||
* @example editor.commands.toggleNode('heading', 'paragraph')
|
||||
*/
|
||||
toggleNode: (
|
||||
typeOrName: string | NodeType,
|
||||
toggleTypeOrName: string | NodeType,
|
||||
attributes?: Record<string, any>,
|
||||
) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleNode: RawCommands['toggleNode'] =
|
||||
(typeOrName, toggleTypeOrName, attributes = {}) =>
|
||||
({ state, commands }) => {
|
||||
const type = getNodeType(typeOrName, state.schema)
|
||||
const toggleType = getNodeType(toggleTypeOrName, state.schema)
|
||||
const isActive = isNodeActive(state, type, attributes)
|
||||
|
||||
let attributesToCopy: Record<string, any> | undefined
|
||||
|
||||
if (state.selection.$anchor.sameParent(state.selection.$head)) {
|
||||
// only copy attributes if the selection is pointing to a node of the same type
|
||||
attributesToCopy = state.selection.$anchor.parent.attrs
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
return commands.setNode(toggleType, attributesToCopy)
|
||||
}
|
||||
|
||||
// If the node is not active, we want to set the new node type with the given attributes
|
||||
// Copying over the attributes from the current node if the selection is pointing to a node of the same type
|
||||
return commands.setNode(type, { ...attributesToCopy, ...attributes })
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { NodeType } from '@tiptap/pm/model'
|
||||
|
||||
import { getNodeType } from '../helpers/getNodeType.js'
|
||||
import { isNodeActive } from '../helpers/isNodeActive.js'
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
toggleWrap: {
|
||||
/**
|
||||
* Wraps nodes in another node, or removes an existing wrap.
|
||||
* @param typeOrName The type or name of the node.
|
||||
* @param attributes The attributes of the node.
|
||||
* @example editor.commands.toggleWrap('blockquote')
|
||||
*/
|
||||
toggleWrap: (typeOrName: string | NodeType, attributes?: Record<string, any>) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleWrap: RawCommands['toggleWrap'] =
|
||||
(typeOrName, attributes = {}) =>
|
||||
({ state, commands }) => {
|
||||
const type = getNodeType(typeOrName, state.schema)
|
||||
const isActive = isNodeActive(state, type, attributes)
|
||||
|
||||
if (isActive) {
|
||||
return commands.lift(type)
|
||||
}
|
||||
|
||||
return commands.wrapIn(type, attributes)
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
undoInputRule: {
|
||||
/**
|
||||
* Undo an input rule.
|
||||
* @example editor.commands.undoInputRule()
|
||||
*/
|
||||
undoInputRule: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const undoInputRule: RawCommands['undoInputRule'] =
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
const plugins = state.plugins
|
||||
|
||||
for (let i = 0; i < plugins.length; i += 1) {
|
||||
const plugin = plugins[i]
|
||||
let undoable
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line
|
||||
if (plugin.spec.isInputRules && (undoable = plugin.getState(state))) {
|
||||
if (dispatch) {
|
||||
const tr = state.tr
|
||||
const toUndo = undoable.transform
|
||||
|
||||
for (let j = toUndo.steps.length - 1; j >= 0; j -= 1) {
|
||||
tr.step(toUndo.steps[j].invert(toUndo.docs[j]))
|
||||
}
|
||||
|
||||
if (undoable.text) {
|
||||
const marks = tr.doc.resolve(undoable.from).marks()
|
||||
|
||||
tr.replaceWith(undoable.from, undoable.to, state.schema.text(undoable.text, marks))
|
||||
} else {
|
||||
tr.delete(undoable.from, undoable.to)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
unsetAllMarks: {
|
||||
/**
|
||||
* Remove all marks in the current selection.
|
||||
* @example editor.commands.unsetAllMarks()
|
||||
*/
|
||||
unsetAllMarks: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const unsetAllMarks: RawCommands['unsetAllMarks'] =
|
||||
() =>
|
||||
({ tr, dispatch }) => {
|
||||
const { selection } = tr
|
||||
const { empty, ranges } = selection
|
||||
|
||||
if (empty) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
ranges.forEach(range => {
|
||||
tr.removeMark(range.$from.pos, range.$to.pos)
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import type { MarkType } from '@tiptap/pm/model'
|
||||
|
||||
import { getMarkRange } from '../helpers/getMarkRange.js'
|
||||
import { getMarkType } from '../helpers/getMarkType.js'
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
unsetMark: {
|
||||
/**
|
||||
* Remove all marks in the current selection.
|
||||
* @param typeOrName The mark type or name.
|
||||
* @param options.extendEmptyMarkRange Removes the mark even across the current selection. Defaults to `false`.
|
||||
* @example editor.commands.unsetMark('bold')
|
||||
*/
|
||||
unsetMark: (
|
||||
/**
|
||||
* The mark type or name.
|
||||
*/
|
||||
typeOrName: string | MarkType,
|
||||
|
||||
options?: {
|
||||
/**
|
||||
* Removes the mark even across the current selection. Defaults to `false`.
|
||||
*/
|
||||
extendEmptyMarkRange?: boolean
|
||||
},
|
||||
) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const unsetMark: RawCommands['unsetMark'] =
|
||||
(typeOrName, options = {}) =>
|
||||
({ tr, state, dispatch }) => {
|
||||
const { extendEmptyMarkRange = false } = options
|
||||
const { selection } = tr
|
||||
const type = getMarkType(typeOrName, state.schema)
|
||||
const { $from, empty, ranges } = selection
|
||||
|
||||
if (!dispatch) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (empty && extendEmptyMarkRange) {
|
||||
let { from, to } = selection
|
||||
const attrs = $from.marks().find(mark => mark.type === type)?.attrs
|
||||
const range = getMarkRange($from, type, attrs)
|
||||
|
||||
if (range) {
|
||||
from = range.from
|
||||
to = range.to
|
||||
}
|
||||
|
||||
tr.removeMark(from, to, type)
|
||||
} else {
|
||||
ranges.forEach(range => {
|
||||
tr.removeMark(range.$from.pos, range.$to.pos, type)
|
||||
})
|
||||
}
|
||||
|
||||
tr.removeStoredMark(type)
|
||||
|
||||
return true
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import type { Range, RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
unsetTextDirection: {
|
||||
/**
|
||||
* Remove the text direction attribute from nodes.
|
||||
* If no position is provided, it will use the current selection.
|
||||
* @param position Optional position or range to remove the direction from
|
||||
* @example editor.commands.unsetTextDirection()
|
||||
* @example editor.commands.unsetTextDirection({ from: 0, to: 10 })
|
||||
*/
|
||||
unsetTextDirection: (position?: number | Range) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const unsetTextDirection: RawCommands['unsetTextDirection'] =
|
||||
position =>
|
||||
({ tr, state, dispatch }) => {
|
||||
const { selection } = state
|
||||
let from: number
|
||||
let to: number
|
||||
|
||||
if (typeof position === 'number') {
|
||||
from = position
|
||||
to = position
|
||||
} else if (position && 'from' in position && 'to' in position) {
|
||||
from = position.from
|
||||
to = position.to
|
||||
} else {
|
||||
from = selection.from
|
||||
to = selection.to
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
tr.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (node.isText) {
|
||||
return
|
||||
}
|
||||
|
||||
const newAttrs = { ...node.attrs }
|
||||
|
||||
delete newAttrs.dir
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, newAttrs)
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import type { Mark, MarkType, Node, NodeType } from '@tiptap/pm/model'
|
||||
import type { SelectionRange } from '@tiptap/pm/state'
|
||||
|
||||
import { getMarkType } from '../helpers/getMarkType.js'
|
||||
import { getNodeType } from '../helpers/getNodeType.js'
|
||||
import { getSchemaTypeNameByName } from '../helpers/getSchemaTypeNameByName.js'
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
updateAttributes: {
|
||||
/**
|
||||
* Update attributes of a node or mark.
|
||||
* @param typeOrName The type or name of the node or mark.
|
||||
* @param attributes The attributes of the node or mark.
|
||||
* @example editor.commands.updateAttributes('mention', { userId: "2" })
|
||||
*/
|
||||
updateAttributes: (
|
||||
/**
|
||||
* The type or name of the node or mark.
|
||||
*/
|
||||
typeOrName: string | NodeType | MarkType,
|
||||
|
||||
/**
|
||||
* The attributes of the node or mark.
|
||||
*/
|
||||
attributes: Record<string, any>,
|
||||
) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const updateAttributes: RawCommands['updateAttributes'] =
|
||||
(typeOrName, attributes = {}) =>
|
||||
({ tr, state, dispatch }) => {
|
||||
let nodeType: NodeType | null = null
|
||||
let markType: MarkType | null = null
|
||||
|
||||
const schemaType = getSchemaTypeNameByName(
|
||||
typeof typeOrName === 'string' ? typeOrName : typeOrName.name,
|
||||
state.schema,
|
||||
)
|
||||
|
||||
if (!schemaType) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (schemaType === 'node') {
|
||||
nodeType = getNodeType(typeOrName as NodeType, state.schema)
|
||||
}
|
||||
|
||||
if (schemaType === 'mark') {
|
||||
markType = getMarkType(typeOrName as MarkType, state.schema)
|
||||
}
|
||||
|
||||
let canUpdate = false
|
||||
|
||||
tr.selection.ranges.forEach((range: SelectionRange) => {
|
||||
const from = range.$from.pos
|
||||
const to = range.$to.pos
|
||||
|
||||
let lastPos: number | undefined
|
||||
let lastNode: Node | undefined
|
||||
let trimmedFrom: number
|
||||
let trimmedTo: number
|
||||
|
||||
if (tr.selection.empty) {
|
||||
state.doc.nodesBetween(from, to, (node: Node, pos: number) => {
|
||||
if (nodeType && nodeType === node.type) {
|
||||
canUpdate = true
|
||||
trimmedFrom = Math.max(pos, from)
|
||||
trimmedTo = Math.min(pos + node.nodeSize, to)
|
||||
lastPos = pos
|
||||
lastNode = node
|
||||
}
|
||||
})
|
||||
} else {
|
||||
state.doc.nodesBetween(from, to, (node: Node, pos: number) => {
|
||||
if (pos < from && nodeType && nodeType === node.type) {
|
||||
canUpdate = true
|
||||
trimmedFrom = Math.max(pos, from)
|
||||
trimmedTo = Math.min(pos + node.nodeSize, to)
|
||||
lastPos = pos
|
||||
lastNode = node
|
||||
}
|
||||
|
||||
if (pos >= from && pos <= to) {
|
||||
if (nodeType && nodeType === node.type) {
|
||||
canUpdate = true
|
||||
|
||||
if (dispatch) {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
...attributes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (markType && node.marks.length) {
|
||||
node.marks.forEach((mark: Mark) => {
|
||||
if (markType === mark.type) {
|
||||
canUpdate = true
|
||||
|
||||
if (dispatch) {
|
||||
const trimmedFrom2 = Math.max(pos, from)
|
||||
const trimmedTo2 = Math.min(pos + node.nodeSize, to)
|
||||
|
||||
tr.addMark(
|
||||
trimmedFrom2,
|
||||
trimmedTo2,
|
||||
markType.create({
|
||||
...mark.attrs,
|
||||
...attributes,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (lastNode) {
|
||||
if (lastPos !== undefined && dispatch) {
|
||||
tr.setNodeMarkup(lastPos, undefined, {
|
||||
...lastNode.attrs,
|
||||
...attributes,
|
||||
})
|
||||
}
|
||||
|
||||
if (markType && lastNode.marks.length) {
|
||||
lastNode.marks.forEach((mark: Mark) => {
|
||||
if (markType === mark.type && dispatch) {
|
||||
tr.addMark(
|
||||
trimmedFrom,
|
||||
trimmedTo,
|
||||
markType.create({
|
||||
...mark.attrs,
|
||||
...attributes,
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return canUpdate
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { wrapIn as originalWrapIn } from '@tiptap/pm/commands'
|
||||
import type { NodeType } from '@tiptap/pm/model'
|
||||
|
||||
import { getNodeType } from '../helpers/getNodeType.js'
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
wrapIn: {
|
||||
/**
|
||||
* Wraps nodes in another node.
|
||||
* @param typeOrName The type or name of the node.
|
||||
* @param attributes The attributes of the node.
|
||||
* @example editor.commands.wrapIn('blockquote')
|
||||
*/
|
||||
wrapIn: (typeOrName: string | NodeType, attributes?: Record<string, any>) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const wrapIn: RawCommands['wrapIn'] =
|
||||
(typeOrName, attributes = {}) =>
|
||||
({ state, dispatch }) => {
|
||||
const type = getNodeType(typeOrName, state.schema)
|
||||
|
||||
return originalWrapIn(type, attributes)(state, dispatch)
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import type { NodeType } from '@tiptap/pm/model'
|
||||
import { wrapInList as originalWrapInList } from '@tiptap/pm/schema-list'
|
||||
|
||||
import { getNodeType } from '../helpers/getNodeType.js'
|
||||
import type { RawCommands } from '../types.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
wrapInList: {
|
||||
/**
|
||||
* Wrap a node in a list.
|
||||
* @param typeOrName The type or name of the node.
|
||||
* @param attributes The attributes of the node.
|
||||
* @example editor.commands.wrapInList('bulletList')
|
||||
*/
|
||||
wrapInList: (typeOrName: string | NodeType, attributes?: Record<string, any>) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const wrapInList: RawCommands['wrapInList'] =
|
||||
(typeOrName, attributes = {}) =>
|
||||
({ state, dispatch }) => {
|
||||
const type = getNodeType(typeOrName, state.schema)
|
||||
|
||||
return originalWrapInList(type, attributes)(state, dispatch)
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
import { Extension } from '../Extension.js'
|
||||
import { getTextBetween } from '../helpers/getTextBetween.js'
|
||||
import { getTextSerializersFromSchema } from '../helpers/getTextSerializersFromSchema.js'
|
||||
|
||||
export type ClipboardTextSerializerOptions = {
|
||||
blockSeparator?: string
|
||||
}
|
||||
|
||||
export const ClipboardTextSerializer = Extension.create<ClipboardTextSerializerOptions>({
|
||||
name: 'clipboardTextSerializer',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
blockSeparator: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('clipboardTextSerializer'),
|
||||
props: {
|
||||
clipboardTextSerializer: () => {
|
||||
const { editor } = this
|
||||
const { state, schema } = editor
|
||||
const { doc, selection } = state
|
||||
const { ranges } = selection
|
||||
const from = Math.min(...ranges.map(range => range.$from.pos))
|
||||
const to = Math.max(...ranges.map(range => range.$to.pos))
|
||||
const textSerializers = getTextSerializersFromSchema(schema)
|
||||
const range = { from, to }
|
||||
|
||||
return getTextBetween(doc, range, {
|
||||
...(this.options.blockSeparator !== undefined ? { blockSeparator: this.options.blockSeparator } : {}),
|
||||
textSerializers,
|
||||
})
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import * as commands from '../commands/index.js'
|
||||
import { Extension } from '../Extension.js'
|
||||
|
||||
export * from '../commands/index.js'
|
||||
|
||||
export const Commands = Extension.create({
|
||||
name: 'commands',
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
...commands,
|
||||
}
|
||||
},
|
||||
})
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { RemoveMarkStep } from '@tiptap/pm/transform'
|
||||
|
||||
import { Extension } from '../Extension.js'
|
||||
import { combineTransactionSteps, getChangedRanges } from '../helpers/index.js'
|
||||
|
||||
/**
|
||||
* This extension allows you to be notified when the user deletes content you are interested in.
|
||||
*/
|
||||
export const Delete = Extension.create({
|
||||
name: 'delete',
|
||||
|
||||
onUpdate({ transaction, appendedTransactions }) {
|
||||
const callback = () => {
|
||||
if (
|
||||
this.editor.options.coreExtensionOptions?.delete?.filterTransaction?.(transaction) ??
|
||||
transaction.getMeta('y-sync$')
|
||||
) {
|
||||
return
|
||||
}
|
||||
const nextTransaction = combineTransactionSteps(transaction.before, [transaction, ...appendedTransactions])
|
||||
const changes = getChangedRanges(nextTransaction)
|
||||
|
||||
changes.forEach(change => {
|
||||
if (
|
||||
nextTransaction.mapping.mapResult(change.oldRange.from).deletedAfter &&
|
||||
nextTransaction.mapping.mapResult(change.oldRange.to).deletedBefore
|
||||
) {
|
||||
nextTransaction.before.nodesBetween(change.oldRange.from, change.oldRange.to, (node, from) => {
|
||||
const to = from + node.nodeSize - 2
|
||||
const isFullyWithinRange = change.oldRange.from <= from && to <= change.oldRange.to
|
||||
|
||||
this.editor.emit('delete', {
|
||||
type: 'node',
|
||||
node,
|
||||
from,
|
||||
to,
|
||||
newFrom: nextTransaction.mapping.map(from),
|
||||
newTo: nextTransaction.mapping.map(to),
|
||||
deletedRange: change.oldRange,
|
||||
newRange: change.newRange,
|
||||
partial: !isFullyWithinRange,
|
||||
editor: this.editor,
|
||||
transaction,
|
||||
combinedTransform: nextTransaction,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const mapping = nextTransaction.mapping
|
||||
nextTransaction.steps.forEach((step, index) => {
|
||||
if (step instanceof RemoveMarkStep) {
|
||||
const newStart = mapping.slice(index).map(step.from, -1)
|
||||
const newEnd = mapping.slice(index).map(step.to)
|
||||
const oldStart = mapping.invert().map(newStart, -1)
|
||||
const oldEnd = mapping.invert().map(newEnd)
|
||||
|
||||
const foundBeforeMark = nextTransaction.doc.nodeAt(newStart - 1)?.marks.some(mark => mark.eq(step.mark))
|
||||
const foundAfterMark = nextTransaction.doc.nodeAt(newEnd)?.marks.some(mark => mark.eq(step.mark))
|
||||
|
||||
this.editor.emit('delete', {
|
||||
type: 'mark',
|
||||
mark: step.mark,
|
||||
from: step.from,
|
||||
to: step.to,
|
||||
deletedRange: {
|
||||
from: oldStart,
|
||||
to: oldEnd,
|
||||
},
|
||||
newRange: {
|
||||
from: newStart,
|
||||
to: newEnd,
|
||||
},
|
||||
partial: Boolean(foundAfterMark || foundBeforeMark),
|
||||
editor: this.editor,
|
||||
transaction,
|
||||
combinedTransform: nextTransaction,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (this.editor.options.coreExtensionOptions?.delete?.async ?? true) {
|
||||
setTimeout(callback, 0)
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
})
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
import { Extension } from '../Extension.js'
|
||||
|
||||
export const Drop = Extension.create({
|
||||
name: 'drop',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('tiptapDrop'),
|
||||
|
||||
props: {
|
||||
handleDrop: (_, e, slice, moved) => {
|
||||
this.editor.emit('drop', {
|
||||
editor: this.editor,
|
||||
event: e,
|
||||
slice,
|
||||
moved,
|
||||
})
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
import { Extension } from '../Extension.js'
|
||||
|
||||
export const Editable = Extension.create({
|
||||
name: 'editable',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('editable'),
|
||||
props: {
|
||||
editable: () => this.editor.options.editable,
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user