Merge branch 'develop' into next

This commit is contained in:
Nick the Sick 2024-08-07 06:26:41 +02:00
commit 0fa9e1a4b0
No known key found for this signature in database
GPG Key ID: F575992F156E5BCC
31 changed files with 655 additions and 389 deletions

View File

@ -1,5 +0,0 @@
---
"@tiptap/core": patch
---
fix(core): findDuplicates - use Array.from when converting Set

View File

@ -1,5 +0,0 @@
---
"@tiptap/extension-task-item": patch
---
allow task items to be parsed when only having `<li data-checked` instead of only when `<li data-checked="true"` (re-fix of #5366)

View File

@ -8,6 +8,15 @@ module.exports = {
node: true,
},
overrides: [
{
files: [
'./**/*.ts',
'./**/*.tsx',
'./**/*.js',
'./**/*.jsx',
],
extends: ['plugin:react-hooks/recommended'],
},
{
files: [
'./**/*.ts',

View File

@ -103,7 +103,7 @@ jobs:
- name: Test ${{ matrix.test-spec.name }}
id: cypress
uses: cypress-io/github-action@v6.7.1
uses: cypress-io/github-action@v6.7.2
with:
cache-key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
start: npm run serve
@ -114,7 +114,7 @@ jobs:
quiet: true
- name: Export screenshots (on failure only)
uses: actions/upload-artifact@v4.3.3
uses: actions/upload-artifact@v4.3.5
if: failure()
with:
name: cypress-screenshots
@ -122,7 +122,7 @@ jobs:
retention-days: 7
- name: Export screen recordings (on failure only)
uses: actions/upload-artifact@v4.3.3
uses: actions/upload-artifact@v4.3.5
if: failure()
with:
name: cypress-videos

View File

@ -1,5 +1,11 @@
# Change Log
## 2.4.2
### Patch Changes
- d6e56c4: declare lowlight to be a peer dep of extension-code-block-lowlight, update usage to v3
## 2.4.1
### Patch Changes

View File

@ -6,7 +6,6 @@ highlight.js/lib/languages/xml
highlight.js/lib/core
linkifyjs
lowlight
lowlight/lib/core
prosemirror-commands
prosemirror-dropcursor
prosemirror-gapcursor
@ -21,6 +20,7 @@ prosemirror-view
react
react-dom
react-dom/client
use-sync-external-store/shim/with-selector
shiki
simplify-js
@floating-ui/dom

View File

@ -1,6 +1,6 @@
{
"name": "tiptap-demos",
"version": "2.4.1",
"version": "2.4.2",
"private": true,
"type": "module",
"scripts": {
@ -14,13 +14,13 @@
"@lexical/react": "^0.11.1",
"d3": "^7.3.0",
"fast-glob": "^3.2.11",
"highlight.js": "^11.6.0",
"highlight.js": "^11.10.0",
"lexical": "^0.11.1",
"lowlight": "^2.7.0",
"lowlight": "^3.1.0",
"remixicon": "^2.5.0",
"shiki": "^1.10.3",
"simplify-js": "^1.2.4",
"y-prosemirror": "^1.2.9",
"y-prosemirror": "^1.2.11",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.18"
},

View File

@ -205,8 +205,7 @@ form {
font-size: 0.75rem;
gap: 0.25rem;
line-height: 1.15;
min-height: 1.75rem;
padding: 0.25rem 0.5rem;
padding: 0.3rem 0.5rem;
&.purple-spinner,
&.error {
@ -215,6 +214,17 @@ form {
width: 100%;
}
.badge {
background-color: var(--gray-1);
border: 1px solid var(--gray-3);
border-radius: 2rem;
color: var(--gray-5);
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
padding: 0.25rem 0.5rem;
}
&.purple-spinner {
background-color: var(--purple-light);

View File

@ -8,10 +8,6 @@ import StarterKit from '@tiptap/starter-kit'
import React, { useCallback } from 'react'
const MenuBar = ({ editor }) => {
if (!editor) {
return null
}
const onCutToStart = useCallback(() => {
editor.chain().cut({ from: editor.state.selection.$from.pos, to: editor.state.selection.$to.pos }, 1).run()
}, [editor])
@ -20,6 +16,10 @@ const MenuBar = ({ editor }) => {
editor.chain().cut({ from: editor.state.selection.$from.pos, to: editor.state.selection.$to.pos }, editor.state.doc.nodeSize - 2).run()
}, [editor])
if (!editor) {
return null
}
return (
<div className="control-group">
<div className="button-group">

View File

@ -1,7 +1,3 @@
// load specific languages only
// import { lowlight } from 'lowlight/lib/core'
// import javascript from 'highlight.js/lib/languages/javascript'
// lowlight.registerLanguage('javascript', javascript)
import './styles.scss'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
@ -13,16 +9,21 @@ import css from 'highlight.js/lib/languages/css'
import js from 'highlight.js/lib/languages/javascript'
import ts from 'highlight.js/lib/languages/typescript'
import html from 'highlight.js/lib/languages/xml'
// load all highlight.js languages
import { lowlight } from 'lowlight'
// load all languages with "all" or common languages with "common"
import { all, createLowlight } from 'lowlight'
import React from 'react'
import CodeBlockComponent from './CodeBlockComponent.jsx'
// eslint-disable-next-line
import CodeBlockComponent from './CodeBlockComponent'
lowlight.registerLanguage('html', html)
lowlight.registerLanguage('css', css)
lowlight.registerLanguage('js', js)
lowlight.registerLanguage('ts', ts)
// create a lowlight instance
const lowlight = createLowlight(all)
// you can also register individual languages
lowlight.register('html', html)
lowlight.register('css', css)
lowlight.register('js', js)
lowlight.register('ts', ts)
const MenuBar = ({ editor }) => {
if (!editor) {
@ -56,7 +57,7 @@ export default () => {
],
content: `
<p>
Thats a boring paragraph followed by a fenced code block:
That's a boring paragraph followed by a fenced code block:
</p>
<pre><code class="language-javascript">for (var i=1; i <= 20; i++)
{

View File

@ -21,20 +21,19 @@ import css from 'highlight.js/lib/languages/css'
import js from 'highlight.js/lib/languages/javascript'
import ts from 'highlight.js/lib/languages/typescript'
import html from 'highlight.js/lib/languages/xml'
// load all highlight.js languages
import { lowlight } from 'lowlight'
// load all languages with "all" or common languages with "common"
import { all, createLowlight } from 'lowlight'
import CodeBlockComponent from './CodeBlockComponent.vue'
lowlight.registerLanguage('html', html)
lowlight.registerLanguage('css', css)
lowlight.registerLanguage('js', js)
lowlight.registerLanguage('ts', ts)
// create a lowlight instance
const lowlight = createLowlight(all)
// load specific languages only
// import { lowlight } from 'lowlight/lib/core'
// import javascript from 'highlight.js/lib/languages/javascript'
// lowlight.registerLanguage('javascript', javascript)
// you can also register languages
lowlight.register('html', html)
lowlight.register('css', css)
lowlight.register('js', js)
lowlight.register('ts', ts)
export default {
components: {
@ -63,7 +62,7 @@ export default {
],
content: `
<p>
Thats a boring paragraph followed by a fenced code block:
That's a boring paragraph followed by a fenced code block:
</p>
<pre><code class="language-javascript">for (var i=1; i <= 20; i++)
{

View File

@ -62,7 +62,7 @@ function EditorInstance({ shouldOptimizeRendering }) {
})
return (
<>
<div>
<div className="control-group">
<div>Number of renders: <span id="render-count">{countRenderRef.current}</span></div>
</div>
@ -89,12 +89,13 @@ function EditorInstance({ shouldOptimizeRendering }) {
</BubbleMenu>
)}
<EditorContent editor={editor} />
</>
</div>
)
}
const EditorControls = () => {
const [shouldOptimizeRendering, setShouldOptimizeRendering] = React.useState(true)
const [rendered, setRendered] = React.useState(true)
return (
<>
@ -123,8 +124,9 @@ const EditorControls = () => {
Render every transaction (default behavior)
</label>
</div>
<button onClick={() => setRendered(a => !a)}>Toggle rendered</button>
</div>
<EditorInstance shouldOptimizeRendering={shouldOptimizeRendering} />
{rendered && <EditorInstance shouldOptimizeRendering={shouldOptimizeRendering} />}
</>
)
}

View File

@ -108,7 +108,9 @@ import TextAlign from '@tiptap/extension-text-align'
import TextStyle from '@tiptap/extension-text-style'
import Underline from '@tiptap/extension-underline'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { lowlight } from 'lowlight'
import { all, createLowlight } from 'lowlight'
const lowlight = createLowlight(all)
export default {
components: {
@ -182,14 +184,14 @@ export default {
</p>
<ul>
<li>
Thats a bullet list with one
That's a bullet list with one
</li>
<li>
or two list items.
</li>
</ul>
<p>
Isnt that great? And all of that is editable. But wait, theres more. Lets try a code block:
Isn't that great? And all of that is editable. But wait, there's more. Let's try a code block:
</p>
<pre><code class="language-javascript">for (var i=1; i <= 20; i++)
{
@ -203,10 +205,10 @@ export default {
console.log(i);
}</code></pre>
<p>
I know, I know, this is impressive. Its only the tip of the iceberg though. Give it a try and click a little bit around. Dont forget to check the other examples too.
I know, I know, this is impressive. It's only the tip of the iceberg though. Give it a try and click a little bit around. Dont forget to check the other examples too.
</p>
<blockquote>
Wow, thats amazing. Good work, boy! 👏
Wow, that's amazing. Good work, boy! 👏
<br />
Mom
</blockquote>
@ -214,9 +216,9 @@ export default {
<p style="text-align: center">first paragraph</p>
<p style="text-align: right">second paragraph</p>
<h2>Color</h2>
<p><span style="color: #958DF1">Oh, for some reason thats purple.</span></p>
<p><span style="color: #958DF1">Oh, for some reason that's purple.</span></p>
<h2>Highlight</h2>
<p>This isnt highlighted.</s></p>
<p>This isn't highlighted.</s></p>
<p><mark>But that one is.</mark></p>
<p><mark style="background-color: red;">And this is highlighted too, but in a different color.</mark></p>
<p><mark data-color="#ffa8a8">And this one has a data attribute.</mark></p>

View File

@ -7,6 +7,7 @@ import React, { useEffect, useState } from 'react'
export default () => {
const [editable, setEditable] = useState(false)
const editor = useEditor({
shouldRerenderOnTransaction: false,
editable,
content: `
<p>

View File

@ -1,7 +1,3 @@
// load specific languages only
// import { lowlight } from 'lowlight/lib/core'
// import javascript from 'highlight.js/lib/languages/javascript'
// lowlight.registerLanguage('javascript', javascript)
import './styles.scss'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
@ -13,14 +9,29 @@ import css from 'highlight.js/lib/languages/css'
import js from 'highlight.js/lib/languages/javascript'
import ts from 'highlight.js/lib/languages/typescript'
import html from 'highlight.js/lib/languages/xml'
// load all highlight.js languages
import { lowlight } from 'lowlight'
// load all languages with "all" or common languages with "common"
import { all, createLowlight } from 'lowlight'
import React from 'react'
lowlight.registerLanguage('html', html)
lowlight.registerLanguage('css', css)
lowlight.registerLanguage('js', js)
lowlight.registerLanguage('ts', ts)
// create a lowlight instance with all languages loaded
const lowlight = createLowlight(all)
// This is only an example, all supported languages are already loaded above
// but you can also register only specific languages to reduce bundle-size
lowlight.register('html', html)
lowlight.register('css', css)
lowlight.register('js', js)
lowlight.register('ts', ts)
/**
* Lowlight version 2.x had a different API
* import { lowlight } from 'lowlight'
*
* lowlight.registerLanguage('html', html)
* lowlight.registerLanguage('css', css)
* lowlight.registerLanguage('js', js)
* lowlight.registerLanguage('ts', ts)
*/
export default () => {
const editor = useEditor({
@ -34,7 +45,7 @@ export default () => {
],
content: `
<p>
Thats a boring paragraph followed by a fenced code block:
That's a boring paragraph followed by a fenced code block:
</p>
<pre><code class="language-javascript">for (var i=1; i <= 20; i++)
{

View File

@ -25,13 +25,17 @@ import css from 'highlight.js/lib/languages/css'
import js from 'highlight.js/lib/languages/javascript'
import ts from 'highlight.js/lib/languages/typescript'
import html from 'highlight.js/lib/languages/xml'
// load all highlight.js languages
import { lowlight } from 'lowlight'
// load all languages with "all" or common languages with "common"
import { all, createLowlight } from 'lowlight'
lowlight.registerLanguage('html', html)
lowlight.registerLanguage('css', css)
lowlight.registerLanguage('js', js)
lowlight.registerLanguage('ts', ts)
// create a lowlight instance
const lowlight = createLowlight(all)
// you can also register languages
lowlight.register('html', html)
lowlight.register('css', css)
lowlight.register('js', js)
lowlight.register('ts', ts)
export default {
components: {
@ -56,7 +60,7 @@ export default {
],
content: `
<p>
Thats a boring paragraph followed by a fenced code block:
That's a boring paragraph followed by a fenced code block:
</p>
<pre><code class="language-javascript">for (var i=1; i <= 20; i++)
{

213
package-lock.json generated
View File

@ -35,6 +35,7 @@
"eslint-plugin-cypress": "^2.15.2",
"eslint-plugin-html": "^6.2.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-vue": "^9.27.0",
"husky": "^8.0.3",
@ -53,19 +54,19 @@
},
"demos": {
"name": "tiptap-demos",
"version": "2.4.1",
"version": "2.4.2",
"dependencies": {
"@hocuspocus/provider": "^2.13.5",
"@lexical/react": "^0.11.1",
"d3": "^7.3.0",
"fast-glob": "^3.2.11",
"highlight.js": "^11.6.0",
"highlight.js": "^11.10.0",
"lexical": "^0.11.1",
"lowlight": "^2.7.0",
"lowlight": "^3.1.0",
"remixicon": "^2.5.0",
"shiki": "^1.10.3",
"simplify-js": "^1.2.4",
"y-prosemirror": "^1.2.9",
"y-prosemirror": "^1.2.11",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.18"
},
@ -322,6 +323,14 @@
"node": ">=8"
}
},
"demos/node_modules/highlight.js": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.10.0.tgz",
"integrity": "sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==",
"engines": {
"node": ">=12.0.0"
}
},
"demos/node_modules/hosted-git-info": {
"version": "4.1.0",
"dev": true,
@ -348,6 +357,28 @@
"node": ">=8"
}
},
"demos/node_modules/lowlight": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.1.0.tgz",
"integrity": "sha512-CEbNVoSikAxwDMDPjXlqlFYiZLkDJHwyGu/MfOsJnF3d7f3tds5J3z8s/l9TMXhzfsJCCJEAsD78842mwmg0PQ==",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.0.0",
"highlight.js": "~11.9.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"demos/node_modules/lowlight/node_modules/highlight.js": {
"version": "11.9.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz",
"integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==",
"engines": {
"node": ">=12.0.0"
}
},
"demos/node_modules/lru-cache": {
"version": "6.0.0",
"dev": true,
@ -744,6 +775,29 @@
}
}
},
"demos/node_modules/y-prosemirror": {
"version": "1.2.11",
"resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.2.11.tgz",
"integrity": "sha512-MUGMYyokOb9DpBRHr4Cadob2KheDCKW2LHceAM2yrWp9dfX+3HZZUNEubEPd4zszq4DF2fGCFhE3N66zOTLoxA==",
"dependencies": {
"lib0": "^0.2.42"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"prosemirror-model": "^1.7.1",
"prosemirror-state": "^1.2.3",
"prosemirror-view": "^1.9.10",
"y-protocols": "^1.0.1",
"yjs": "^13.5.38"
}
},
"demos/node_modules/yallist": {
"version": "4.0.0",
"dev": true,
@ -3364,21 +3418,6 @@
"ms": "^2.1.1"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.0",
"dev": true,
@ -4172,30 +4211,6 @@
}
}
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.18.0",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.18.0",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@shikijs/core": {
"version": "1.10.3",
"license": "MIT",
@ -4548,13 +4563,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "2.3.10",
"license": "MIT",
"dependencies": {
"@types/unist": "^2"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"dev": true,
@ -7207,6 +7215,14 @@
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-file": {
"version": "1.0.0",
"dev": true,
@ -7223,6 +7239,18 @@
"node": ">=8"
}
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
"dependencies": {
"dequal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"dev": true,
@ -7798,6 +7826,18 @@
"node": ">=0.10.0"
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
"integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==",
"dev": true,
"engines": {
"node": ">=10"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0"
}
},
"node_modules/eslint-plugin-simple-import-sort": {
"version": "7.0.0",
"dev": true,
@ -8370,17 +8410,6 @@
"reusify": "^1.0.4"
}
},
"node_modules/fault": {
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"dev": true,
@ -8621,12 +8650,6 @@
"node": ">= 0.12"
}
},
"node_modules/format": {
"version": "0.2.2",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"dev": true,
@ -9074,13 +9097,6 @@
"node": ">= 0.4"
}
},
"node_modules/highlight.js": {
"version": "11.9.0",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/homedir-polyfill": {
"version": "1.0.3",
"dev": true,
@ -10909,26 +10925,6 @@
"loose-envify": "cli.js"
}
},
"node_modules/lowlight": {
"version": "2.9.0",
"license": "MIT",
"dependencies": {
"@types/hast": "^2.0.0",
"fault": "^2.0.0",
"highlight.js": "~11.8.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lowlight/node_modules/highlight.js": {
"version": "11.8.0",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"dev": true,
@ -14502,18 +14498,6 @@
"turbo-windows-arm64": "2.0.6"
}
},
"node_modules/turbo-linux-64": {
"version": "2.0.6",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"dev": true,
@ -14855,22 +14839,6 @@
}
}
},
"node_modules/vite/node_modules/@esbuild/linux-x64": {
"version": "0.18.20",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/esbuild": {
"version": "0.18.20",
"dev": true,
@ -15431,6 +15399,7 @@
},
"node_modules/y-prosemirror": {
"version": "1.2.9",
"dev": true,
"license": "MIT",
"dependencies": {
"lib0": "^0.2.42"

View File

@ -58,6 +58,7 @@
"eslint-plugin-cypress": "^2.15.2",
"eslint-plugin-html": "^6.2.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-vue": "^9.27.0",
"husky": "^8.0.3",

View File

@ -12,7 +12,7 @@ export function getMarksBetween(from: number, to: number, doc: ProseMirrorNode):
.resolve(from)
.marks()
.forEach(mark => {
const $pos = doc.resolve(from - 1)
const $pos = doc.resolve(from)
const range = getMarkRange($pos, mark.type)
if (!range) {

View File

@ -1,40 +1,61 @@
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
/**
* Returns true if the given node is empty.
* When `checkChildren` is true (default), it will also check if all children are empty.
* Returns true if the given prosemirror node is empty.
*/
export function isNodeEmpty(
node: ProseMirrorNode,
{ checkChildren }: { checkChildren: boolean } = { checkChildren: true },
{
checkChildren = true,
ignoreWhitespace = false,
}: {
/**
* When true (default), it will also check if all children are empty.
*/
checkChildren?: boolean;
/**
* When true, it will ignore whitespace when checking for emptiness.
*/
ignoreWhitespace?: boolean;
} = {},
): boolean {
if (ignoreWhitespace) {
if (node.type.name === 'hardBreak') {
// Hard breaks are considered empty
return true
}
if (node.isText) {
return /^\s*$/m.test(node.text ?? '')
}
}
if (node.isText) {
return !node.text
}
if (node.isAtom || node.isLeaf) {
return false
}
if (node.content.childCount === 0) {
return true
}
if (node.isLeaf) {
return false
}
if (checkChildren) {
let hasSameContent = true
let isContentEmpty = true
node.content.forEach(childNode => {
if (hasSameContent === false) {
if (isContentEmpty === false) {
// Exit early for perf
return
}
if (!isNodeEmpty(childNode)) {
hasSameContent = false
if (!isNodeEmpty(childNode, { ignoreWhitespace, checkChildren })) {
isContentEmpty = false
}
})
return hasSameContent
return isContentEmpty
}
return false

View File

@ -7,13 +7,6 @@ export interface CodeBlockLowlightOptions extends CodeBlockOptions {
* The lowlight instance.
*/
lowlight: any,
/**
* The default language.
* @default null
* @example 'javascript'
*/
defaultLanguage: string | null | undefined,
}
/**
@ -25,7 +18,6 @@ export const CodeBlockLowlight = CodeBlock.extend<CodeBlockLowlightOptions>({
return {
...this.parent?.(),
lowlight: {},
defaultLanguage: null,
}
},

View File

@ -22,6 +22,12 @@ export interface CodeBlockOptions {
* @default true
*/
exitOnArrowDown: boolean
/**
* The default language.
* @default null
* @example 'js'
*/
defaultLanguage: string | null | undefined
/**
* Custom HTML attributes that should be added to the rendered HTML tag.
* @default {}
@ -71,6 +77,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
languageClassPrefix: 'language-',
exitOnTripleEnter: true,
exitOnArrowDown: true,
defaultLanguage: null,
HTMLAttributes: {},
}
},
@ -88,7 +95,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
addAttributes() {
return {
language: {
default: null,
default: this.options.defaultLanguage,
parseHTML: element => {
const { languageClassPrefix } = this.options
const classNames = [...(element.firstElementChild?.classList || [])]

View File

@ -127,6 +127,9 @@ export const CollaborationCursor = Extension.create<CollaborationCursorOptions,
if (this.options.onUpdate !== defaultOnUpdate) {
console.warn('[tiptap warn]: DEPRECATED: The "onUpdate" option is deprecated. Please use `editor.storage.collaborationCursor.users` instead. Read more: https://tiptap.dev/api/extensions/collaboration-cursor')
}
if (!this.options.provider) {
throw new Error('The "provider" option is required for the CollaborationCursor extension')
}
},
addStorage() {

View File

@ -167,8 +167,8 @@ export const Collaboration = Extension.create<CollaborationOptions>({
}
const ySyncPluginOptions: YSyncOpts = {
...(this.options.ySyncOptions ? { ...this.options.ySyncOptions } : {}),
...(this.options.onFirstRender ? { ...this.options.onFirstRender } : {}),
...this.options.ySyncOptions,
onFirstRender: this.options.onFirstRender,
}
const ySyncPluginInstance = ySyncPlugin(fragment, ySyncPluginOptions)

View File

@ -31,6 +31,12 @@ export interface PlaceholderOptions {
}) => string)
| string
/**
* See https://github.com/ueberdosis/tiptap/pull/5278 for more information.
* @deprecated This option is no longer respected and this type will be removed in the next major version.
*/
considerAnyAsEmpty?: boolean
/**
* **Checks if the placeholder should be only shown when the editor is editable.**
*

View File

@ -1,8 +1,13 @@
import { EditorOptions } from '@tiptap/core'
import {
DependencyList, MutableRefObject,
useDebugValue, useEffect, useRef, useState,
DependencyList,
MutableRefObject,
useDebugValue,
useEffect,
useRef,
useState,
} from 'react'
import { useSyncExternalStore } from 'use-sync-external-store/shim'
import { Editor } from './Editor.js'
import { useEditorState } from './useEditorState.js'
@ -31,22 +36,241 @@ export type UseEditorOptions = Partial<EditorOptions> & {
};
/**
* Create a new editor instance. And attach event listeners.
* This class handles the creation, destruction, and re-creation of the editor instance.
*/
function createEditor(options: MutableRefObject<UseEditorOptions>): Editor {
const editor = new Editor(options.current)
class EditorInstanceManager {
/**
* The current editor instance.
*/
private editor: Editor | null = null
editor.on('beforeCreate', (...args) => options.current.onBeforeCreate?.(...args))
editor.on('blur', (...args) => options.current.onBlur?.(...args))
editor.on('create', (...args) => options.current.onCreate?.(...args))
editor.on('destroy', (...args) => options.current.onDestroy?.(...args))
editor.on('focus', (...args) => options.current.onFocus?.(...args))
editor.on('selectionUpdate', (...args) => options.current.onSelectionUpdate?.(...args))
editor.on('transaction', (...args) => options.current.onTransaction?.(...args))
editor.on('update', (...args) => options.current.onUpdate?.(...args))
editor.on('contentError', (...args) => options.current.onContentError?.(...args))
/**
* The most recent options to apply to the editor.
*/
private options: MutableRefObject<UseEditorOptions>
return editor
/**
* The subscriptions to notify when the editor instance
* has been created or destroyed.
*/
private subscriptions = new Set<() => void>()
/**
* A timeout to destroy the editor if it was not mounted within a time frame.
*/
private scheduledDestructionTimeout: ReturnType<typeof setTimeout> | undefined
/**
* Whether the editor has been mounted.
*/
private isComponentMounted = false
/**
* The most recent dependencies array.
*/
private previousDeps: DependencyList | null = null
/**
* The unique instance ID. This is used to identify the editor instance. And will be re-generated for each new instance.
*/
public instanceId = ''
constructor(options: MutableRefObject<UseEditorOptions>) {
this.options = options
this.subscriptions = new Set<() => void>()
this.setEditor(this.getInitialEditor())
this.getEditor = this.getEditor.bind(this)
this.getServerSnapshot = this.getServerSnapshot.bind(this)
this.subscribe = this.subscribe.bind(this)
this.refreshEditorInstance = this.refreshEditorInstance.bind(this)
this.scheduleDestroy = this.scheduleDestroy.bind(this)
this.onRender = this.onRender.bind(this)
this.createEditor = this.createEditor.bind(this)
}
private setEditor(editor: Editor | null) {
this.editor = editor
this.instanceId = Math.random().toString(36).slice(2, 9)
// Notify all subscribers that the editor instance has been created
this.subscriptions.forEach(cb => cb())
}
private getInitialEditor() {
if (this.options.current.immediatelyRender === undefined) {
if (isSSR || isNext) {
// TODO in the next major release, we should throw an error here
if (isDev) {
/**
* Throw an error in development, to make sure the developer is aware that tiptap cannot be SSR'd
* and that they need to set `immediatelyRender` to `false` to avoid hydration mismatches.
*/
console.warn(
'Tiptap Error: SSR has been detected, please set `immediatelyRender` explicitly to `false` to avoid hydration mismatches.',
)
}
// Best faith effort in production, run the code in the legacy mode to avoid hydration mismatches and errors in production
return null
}
// Default to immediately rendering when client-side rendering
return this.createEditor()
}
if (this.options.current.immediatelyRender && isSSR && isDev) {
// Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches.
throw new Error(
'Tiptap Error: SSR has been detected, and `immediatelyRender` has been set to `true` this is an unsupported configuration that may result in errors, explicitly set `immediatelyRender` to `false` to avoid hydration mismatches.',
)
}
if (this.options.current.immediatelyRender) {
return this.createEditor()
}
return null
}
/**
* Create a new editor instance. And attach event listeners.
*/
private createEditor(): Editor {
const editor = new Editor(this.options.current)
// Always call the most recent version of the callback function by default
editor.on('beforeCreate', (...args) => this.options.current.onBeforeCreate?.(...args))
editor.on('blur', (...args) => this.options.current.onBlur?.(...args))
editor.on('create', (...args) => this.options.current.onCreate?.(...args))
editor.on('destroy', (...args) => this.options.current.onDestroy?.(...args))
editor.on('focus', (...args) => this.options.current.onFocus?.(...args))
editor.on('selectionUpdate', (...args) => this.options.current.onSelectionUpdate?.(...args))
editor.on('transaction', (...args) => this.options.current.onTransaction?.(...args))
editor.on('update', (...args) => this.options.current.onUpdate?.(...args))
editor.on('contentError', (...args) => this.options.current.onContentError?.(...args))
// no need to keep track of the event listeners, they will be removed when the editor is destroyed
return editor
}
/**
* Get the current editor instance.
*/
getEditor(): Editor | null {
return this.editor
}
/**
* Always disable the editor on the server-side.
*/
getServerSnapshot(): null {
return null
}
/**
* Subscribe to the editor instance's changes.
*/
subscribe(onStoreChange: () => void) {
this.subscriptions.add(onStoreChange)
return () => {
this.subscriptions.delete(onStoreChange)
}
}
/**
* On each render, we will create, update, or destroy the editor instance.
* @param deps The dependencies to watch for changes
* @returns A cleanup function
*/
onRender(deps: DependencyList) {
// The returned callback will run on each render
return () => {
this.isComponentMounted = true
// Cleanup any scheduled destructions, since we are currently rendering
clearTimeout(this.scheduledDestructionTimeout)
if (this.editor && !this.editor.isDestroyed && deps.length === 0) {
// if the editor does exist & deps are empty, we don't need to re-initialize the editor
// we can fast-path to update the editor options on the existing instance
this.editor.setOptions(this.options.current)
} else {
// When the editor:
// - does not yet exist
// - is destroyed
// - the deps array changes
// We need to destroy the editor instance and re-initialize it
this.refreshEditorInstance(deps)
}
return () => {
this.isComponentMounted = false
this.scheduleDestroy()
}
}
}
/**
* Recreate the editor instance if the dependencies have changed.
*/
private refreshEditorInstance(deps: DependencyList) {
if (this.editor && !this.editor.isDestroyed) {
// Editor instance already exists
if (this.previousDeps === null) {
// If lastDeps has not yet been initialized, reuse the current editor instance
this.previousDeps = deps
return
}
const depsAreEqual = this.previousDeps.length === deps.length
&& this.previousDeps.every((dep, index) => dep === deps[index])
if (depsAreEqual) {
// deps exist and are equal, no need to recreate
return
}
}
if (this.editor && !this.editor.isDestroyed) {
// Destroy the editor instance if it exists
this.editor.destroy()
}
this.setEditor(this.createEditor())
// Update the lastDeps to the current deps
this.previousDeps = deps
}
/**
* Schedule the destruction of the editor instance.
* This will only destroy the editor if it was not mounted on the next tick.
* This is to avoid destroying the editor instance when it's actually still mounted.
*/
private scheduleDestroy() {
const currentInstanceId = this.instanceId
const currentEditor = this.editor
// Wait a tick to see if the component is still mounted
this.scheduledDestructionTimeout = setTimeout(() => {
if (this.isComponentMounted && this.instanceId === currentInstanceId) {
// If still mounted on the next tick, with the same instanceId, do not destroy the editor
if (currentEditor) {
// just re-apply options as they might have changed
currentEditor.setOptions(this.options.current)
}
return
}
if (currentEditor && !currentEditor.isDestroyed) {
currentEditor.destroy()
if (this.instanceId === currentInstanceId) {
this.setEditor(null)
}
}
}, 0)
}
}
/**
@ -68,99 +292,29 @@ export function useEditor(
* @returns The editor instance
* @example const editor = useEditor({ extensions: [...] })
*/
export function useEditor(
options?: UseEditorOptions,
deps?: DependencyList
): Editor | null;
export function useEditor(options?: UseEditorOptions, deps?: DependencyList): Editor | null;
export function useEditor(
options: UseEditorOptions = {},
deps: DependencyList = [],
): Editor | null {
const mostRecentOptions = useRef(options)
const [editor, setEditor] = useState(() => {
if (options.immediatelyRender === undefined) {
if (isSSR || isNext) {
// TODO in the next major release, we should throw an error here
if (isDev) {
/**
* Throw an error in development, to make sure the developer is aware that tiptap cannot be SSR'd
* and that they need to set `immediatelyRender` to `false` to avoid hydration mismatches.
*/
console.warn(
'Tiptap Error: SSR has been detected, please set `immediatelyRender` explicitly to `false` to avoid hydration mismatches.',
)
}
// Best faith effort in production, run the code in the legacy mode to avoid hydration mismatches and errors in production
return null
}
mostRecentOptions.current = options
// Default to immediately rendering when client-side rendering
return createEditor(mostRecentOptions)
}
const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions))
if (options.immediatelyRender && isSSR && isDev) {
// Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches.
throw new Error(
'Tiptap Error: SSR has been detected, and `immediatelyRender` has been set to `true` this is an unsupported configuration that may result in errors, explicitly set `immediatelyRender` to `false` to avoid hydration mismatches.',
)
}
if (options.immediatelyRender) {
return createEditor(mostRecentOptions)
}
return null
})
const mostRecentEditor = useRef<Editor | null>(editor)
mostRecentEditor.current = editor
const editor = useSyncExternalStore(
instanceManager.subscribe,
instanceManager.getEditor,
instanceManager.getServerSnapshot,
)
useDebugValue(editor)
// This effect will handle creating/updating the editor instance
useEffect(() => {
const destroyUnusedEditor = (editorInstance: Editor | null) => {
if (editorInstance) {
// We need to destroy the editor asynchronously to avoid memory leaks
// because the editor instance is still being used in the component.
setTimeout(() => {
// re-use the editor instance if it hasn't been replaced yet
// otherwise, asynchronously destroy the old editor instance
if (editorInstance !== mostRecentEditor.current && !editorInstance.isDestroyed) {
editorInstance.destroy()
}
})
}
}
let editorInstance = mostRecentEditor.current
if (!editorInstance) {
editorInstance = createEditor(mostRecentOptions)
setEditor(editorInstance)
return () => destroyUnusedEditor(editorInstance)
}
if (!Array.isArray(deps) || deps.length === 0) {
// if the editor does exist & deps are empty, we don't need to re-initialize the editor
// we can fast-path to update the editor options on the existing instance
editorInstance.setOptions(options)
return () => destroyUnusedEditor(editorInstance)
}
// We need to destroy the editor instance and re-initialize it
// when the deps array changes
editorInstance.destroy()
// the deps array is used to re-initialize the editor instance
editorInstance = createEditor(mostRecentOptions)
setEditor(editorInstance)
return () => destroyUnusedEditor(editorInstance)
}, deps)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(instanceManager.onRender(deps))
// The default behavior is to re-render on each transaction
// This is legacy behavior that will be removed in future versions

View File

@ -30,68 +30,83 @@ export type UseEditorStateOptions<
* To synchronize the editor instance with the component state,
* we need to create a separate instance that is not affected by the component re-renders.
*/
function makeEditorStateInstance<TEditor extends Editor | null = Editor | null>(initialEditor: TEditor) {
let transactionNumber = 0
let lastTransactionNumber = 0
let lastSnapshot: EditorStateSnapshot<TEditor> = { editor: initialEditor, transactionNumber: 0 }
let editor = initialEditor
const subscribers = new Set<() => void>()
class EditorStateManager<TEditor extends Editor | null = Editor | null> {
private transactionNumber = 0
const editorInstance = {
/**
* Get the current editor instance.
*/
getSnapshot(): EditorStateSnapshot<TEditor> {
if (transactionNumber === lastTransactionNumber) {
return lastSnapshot
}
lastTransactionNumber = transactionNumber
lastSnapshot = { editor, transactionNumber }
return lastSnapshot
},
/**
* Always disable the editor on the server-side.
*/
getServerSnapshot(): EditorStateSnapshot<null> {
return { editor: null, transactionNumber: 0 }
},
/**
* Subscribe to the editor instance's changes.
*/
subscribe(callback: () => void) {
subscribers.add(callback)
return () => {
subscribers.delete(callback)
}
},
/**
* Watch the editor instance for changes.
*/
watch(nextEditor: Editor | null) {
editor = nextEditor as TEditor
private lastTransactionNumber = 0
if (editor) {
/**
* This will force a re-render when the editor state changes.
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`.
* This could be more efficient, but it's a good trade-off for now.
*/
const fn = () => {
transactionNumber += 1
subscribers.forEach(callback => callback())
}
private lastSnapshot: EditorStateSnapshot<TEditor>
const currentEditor = editor
private editor: TEditor
currentEditor.on('transaction', fn)
return () => {
currentEditor.off('transaction', fn)
}
}
},
private subscribers = new Set<() => void>()
constructor(initialEditor: TEditor) {
this.editor = initialEditor
this.lastSnapshot = { editor: initialEditor, transactionNumber: 0 }
this.getSnapshot = this.getSnapshot.bind(this)
this.getServerSnapshot = this.getServerSnapshot.bind(this)
this.watch = this.watch.bind(this)
this.subscribe = this.subscribe.bind(this)
}
return editorInstance
/**
* Get the current editor instance.
*/
getSnapshot(): EditorStateSnapshot<TEditor> {
if (this.transactionNumber === this.lastTransactionNumber) {
return this.lastSnapshot
}
this.lastTransactionNumber = this.transactionNumber
this.lastSnapshot = { editor: this.editor, transactionNumber: this.transactionNumber }
return this.lastSnapshot
}
/**
* Always disable the editor on the server-side.
*/
getServerSnapshot(): EditorStateSnapshot<null> {
return { editor: null, transactionNumber: 0 }
}
/**
* Subscribe to the editor instance's changes.
*/
subscribe(callback: () => void): () => void {
this.subscribers.add(callback)
return () => {
this.subscribers.delete(callback)
}
}
/**
* Watch the editor instance for changes.
*/
watch(nextEditor: Editor | null): undefined | (() => void) {
this.editor = nextEditor as TEditor
if (this.editor) {
/**
* This will force a re-render when the editor state changes.
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`.
* This could be more efficient, but it's a good trade-off for now.
*/
const fn = () => {
this.transactionNumber += 1
this.subscribers.forEach(callback => callback())
}
const currentEditor = this.editor
currentEditor.on('transaction', fn)
return () => {
currentEditor.off('transaction', fn)
}
}
return undefined
}
}
export function useEditorState<TSelectorResult>(
@ -104,7 +119,7 @@ export function useEditorState<TSelectorResult>(
export function useEditorState<TSelectorResult>(
options: UseEditorStateOptions<TSelectorResult, Editor> | UseEditorStateOptions<TSelectorResult, Editor | null>,
): TSelectorResult | null {
const [editorInstance] = useState(() => makeEditorStateInstance(options.editor))
const [editorInstance] = useState(() => new EditorStateManager(options.editor))
// Using the `useSyncExternalStore` hook to sync the editor instance with the component state
const selectedState = useSyncExternalStoreWithSelector(
@ -117,7 +132,7 @@ export function useEditorState<TSelectorResult>(
useEffect(() => {
return editorInstance.watch(options.editor)
}, [options.editor])
}, [options.editor, editorInstance])
useDebugValue(selectedState)

View File

@ -1,3 +1,4 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { Editor as CoreEditor, EditorOptions } from '@tiptap/core'
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
import {

View File

@ -3,13 +3,53 @@
import { getSchema, isNodeEmpty } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Image from '@tiptap/extension-image'
import Mention from '@tiptap/extension-mention'
import StarterKit from '@tiptap/starter-kit'
const schema = getSchema([StarterKit])
const modifiedSchema = getSchema([StarterKit.configure({ document: false }), Document.extend({ content: 'heading block*' })])
const imageSchema = getSchema([StarterKit.configure({ document: false }), Document.extend({ content: 'image block*' }), Image])
const schema = getSchema([StarterKit, Mention])
const modifiedSchema = getSchema([
StarterKit.configure({ document: false }),
Document.extend({ content: 'heading block*' }),
])
const imageSchema = getSchema([
StarterKit.configure({ document: false }),
Document.extend({ content: 'image block*' }),
Image,
])
describe('isNodeEmpty', () => {
describe('ignoreWhitespace=true', () => {
it('should return true when text has only whitespace', () => {
const node = schema.nodeFromJSON({ type: 'text', text: ' \n\t\r\n' })
expect(isNodeEmpty(node, { ignoreWhitespace: true })).to.eq(true)
})
it('should return true when a paragraph has only whitespace', () => {
const node = schema.nodeFromJSON({
type: 'paragraph',
content: [{ type: 'text', text: ' \n\t\r\n' }],
})
expect(isNodeEmpty(node, { ignoreWhitespace: true })).to.eq(true)
})
it('should return true for a hardbreak', () => {
const node = schema.nodeFromJSON({ type: 'hardBreak' })
expect(isNodeEmpty(node, { ignoreWhitespace: true })).to.eq(true)
})
it('should return true when a paragraph has only a hardbreak', () => {
const node = schema.nodeFromJSON({
type: 'paragraph',
content: [{ type: 'hardBreak' }],
})
expect(isNodeEmpty(node, { ignoreWhitespace: true })).to.eq(true)
})
})
describe('with default schema', () => {
it('should return false when text has content', () => {
const node = schema.nodeFromJSON({ type: 'text', text: 'Hello world!' })
@ -26,6 +66,32 @@ describe('isNodeEmpty', () => {
expect(isNodeEmpty(node)).to.eq(false)
})
it('should return false when a paragraph has hardbreaks', () => {
const node = schema.nodeFromJSON({
type: 'paragraph',
content: [{ type: 'hardBreak' }],
})
expect(isNodeEmpty(node)).to.eq(false)
})
it('should return false when a paragraph has a mention', () => {
const node = schema.nodeFromJSON({
type: 'paragraph',
content: [
{
type: 'mention',
attrs: {
id: 'Winona Ryder',
label: null,
},
},
],
})
expect(isNodeEmpty(node)).to.eq(false)
})
it('should return true when a paragraph has no content', () => {
const node = schema.nodeFromJSON({
type: 'paragraph',
@ -95,9 +161,7 @@ describe('isNodeEmpty', () => {
content: [
{
type: 'heading',
content: [
{ type: 'text', text: 'Hello world!' },
],
content: [{ type: 'text', text: 'Hello world!' }],
},
],
})
@ -112,9 +176,7 @@ describe('isNodeEmpty', () => {
{ type: 'heading' },
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Hello world!' },
],
content: [{ type: 'text', text: 'Hello world!' }],
},
],
})
@ -137,9 +199,7 @@ describe('isNodeEmpty', () => {
it('should return true when a document has an empty heading with attrs', () => {
const node = modifiedSchema.nodeFromJSON({
type: 'doc',
content: [
{ type: 'heading', content: [], attrs: { level: 2 } },
],
content: [{ type: 'heading', content: [], attrs: { level: 2 } }],
})
expect(isNodeEmpty(node)).to.eq(true)
@ -177,7 +237,7 @@ describe('isNodeEmpty', () => {
],
})
expect(isNodeEmpty(node)).to.eq(true)
expect(isNodeEmpty(node)).to.eq(false)
})
})
})

View File

@ -5,7 +5,9 @@ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
import { Document } from '@tiptap/extension-document'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { lowlight } from 'lowlight'
import { all, createLowlight } from 'lowlight'
const lowlight = createLowlight(all)
describe('code block highlight', () => {
let Frontmatter

View File

@ -6,7 +6,7 @@
"sourceMap": false,
"types": ["cypress", "react", "react-dom"],
"paths": {
"@tiptap/*": ["packages/*/dist", "packages/*/src"],
"@tiptap/*": ["packages/*/src", "packages/*/dist"],
"@tiptap/pm/*": ["../../pm/*/dist"]
},
"typeRoots": ["../../node_modules/@types", "../../node_modules/"],