mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-07 01:12:56 +08:00
Merge branch 'develop' into next
This commit is contained in:
commit
0fa9e1a4b0
@ -1,5 +0,0 @@
|
||||
---
|
||||
"@tiptap/core": patch
|
||||
---
|
||||
|
||||
fix(core): findDuplicates - use Array.from when converting Set
|
@ -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)
|
@ -8,6 +8,15 @@ module.exports = {
|
||||
node: true,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'./**/*.ts',
|
||||
'./**/*.tsx',
|
||||
'./**/*.js',
|
||||
'./**/*.jsx',
|
||||
],
|
||||
extends: ['plugin:react-hooks/recommended'],
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'./**/*.ts',
|
||||
|
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
That’s 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++)
|
||||
{
|
||||
|
@ -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>
|
||||
That’s 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++)
|
||||
{
|
||||
|
@ -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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
That’s a bullet list with one …
|
||||
That's a bullet list with one …
|
||||
</li>
|
||||
<li>
|
||||
… or two list items.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Isn’t that great? And all of that is editable. But wait, there’s more. Let’s 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. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t 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. Don’t forget to check the other examples too.
|
||||
</p>
|
||||
<blockquote>
|
||||
Wow, that’s 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 that’s purple.</span></p>
|
||||
<p><span style="color: #958DF1">Oh, for some reason that's purple.</span></p>
|
||||
<h2>Highlight</h2>
|
||||
<p>This isn’t 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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
That’s 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++)
|
||||
{
|
||||
|
@ -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>
|
||||
That’s 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
213
package-lock.json
generated
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -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 || [])]
|
||||
|
@ -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() {
|
||||
|
@ -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)
|
||||
|
@ -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.**
|
||||
*
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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/"],
|
||||
|
Loading…
Reference in New Issue
Block a user