mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-07 17:43:49 +08:00
Merge branch 'develop' into next
This commit is contained in:
commit
14afcadd7b
@ -2,12 +2,13 @@
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.0.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [
|
||||
["@tiptap/*"]
|
||||
],
|
||||
"fixed": [["@tiptap/*"]],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
"ignore": [],
|
||||
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
|
||||
"onlyUpdatePeerDependentsWhenOutOfRange": true
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +0,0 @@
|
||||
---
|
||||
"@tiptap/core": patch
|
||||
---
|
||||
|
||||
Resolve several selection related bug #2690 #5208
|
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.2
|
||||
uses: cypress-io/github-action@v6.7.6
|
||||
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.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
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.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
if: failure()
|
||||
with:
|
||||
name: cypress-videos
|
||||
|
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@ -66,7 +66,7 @@ jobs:
|
||||
- name: Send release notification
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
id: slack
|
||||
uses: slackapi/slack-github-action@v1.26.0
|
||||
uses: slackapi/slack-github-action@v1.27.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
@ -78,7 +78,7 @@ jobs:
|
||||
- name: Send failure notification
|
||||
if: failure()
|
||||
id: slack_failure
|
||||
uses: slackapi/slack-github-action@v1.26.0
|
||||
uses: slackapi/slack-github-action@v1.27.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
|
@ -86,6 +86,12 @@ For help, discussion about best practices, or any other conversation that would
|
||||
<strong>Basewell</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<a href="https://poggio.io">
|
||||
<img src="https://unavatar.io/github/poggiolabs" width="25"><br>
|
||||
<strong>Poggio</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
@ -39,7 +39,7 @@
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"sass": "^1.49.7",
|
||||
"svelte": "^4.0.0",
|
||||
"svelte": "^4.2.19",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^5.4.5",
|
||||
"uuid": "^8.3.2",
|
||||
|
@ -14,7 +14,7 @@ import * as Y from 'yjs'
|
||||
const ydoc = new Y.Doc()
|
||||
const provider = new WebrtcProvider('tiptap-collaboration-cursor-extension', ydoc)
|
||||
|
||||
export default () => {
|
||||
function Component() {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Document,
|
||||
@ -39,3 +39,11 @@ export default () => {
|
||||
|
||||
return <EditorContent editor={editor} />
|
||||
}
|
||||
|
||||
function App() {
|
||||
const useStrictMode = true
|
||||
|
||||
return useStrictMode ? <React.StrictMode><Component /></React.StrictMode> : <Component />
|
||||
}
|
||||
|
||||
export default App
|
||||
|
69
demos/src/Extensions/CollaborationWithMenus/React/index.jsx
Normal file
69
demos/src/Extensions/CollaborationWithMenus/React/index.jsx
Normal file
@ -0,0 +1,69 @@
|
||||
import './styles.scss'
|
||||
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import Collaboration from '@tiptap/extension-collaboration'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Heading from '@tiptap/extension-heading'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import {
|
||||
BubbleMenu, EditorContent, FloatingMenu, useEditor,
|
||||
} from '@tiptap/react'
|
||||
import React from 'react'
|
||||
import { WebrtcProvider } from 'y-webrtc'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const provider = new WebrtcProvider('tiptap-collaboration-extension', ydoc)
|
||||
|
||||
export default () => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Heading,
|
||||
Bold,
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder:
|
||||
'Write something … It’ll be shared with everyone else looking at this example.',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{editor && (
|
||||
<>
|
||||
<BubbleMenu editor={editor}>
|
||||
<div className="bubble-menu">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={editor.isActive('bold') ? 'is-active' : ''}
|
||||
>
|
||||
Bold
|
||||
</button>
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
<FloatingMenu editor={editor}>
|
||||
<div className="floating-menu">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
</div>
|
||||
</FloatingMenu>
|
||||
</>
|
||||
)}
|
||||
<EditorContent editor={editor} />
|
||||
</>
|
||||
)
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
context('/src/Extensions/CollaborationWithMenus/React/', () => {
|
||||
before(() => {
|
||||
cy.visit('/src/Extensions/CollaborationWithMenus/React/')
|
||||
})
|
||||
|
||||
it('should have a working tiptap instance', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
// eslint-disable-next-line
|
||||
expect(editor).to.not.be.null
|
||||
})
|
||||
})
|
||||
|
||||
it('should have menu plugins initiated', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
const bubbleMenuPlugin = editor.view.state.plugins.find(plugin => plugin.spec.key?.key === 'bubbleMenu$')
|
||||
const floatingMenuPlugin = editor.view.state.plugins.find(plugin => plugin.spec.key?.key === 'floatingMenu$')
|
||||
const hasBothMenuPluginsLoaded = !!bubbleMenuPlugin && !!floatingMenuPlugin
|
||||
|
||||
expect(hasBothMenuPluginsLoaded).to.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should have a ydoc', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
/**
|
||||
* @type {import('yjs').Doc}
|
||||
*/
|
||||
const yDoc = editor.extensionManager.extensions.find(a => a.name === 'collaboration').options.document
|
||||
|
||||
// eslint-disable-next-line
|
||||
expect(yDoc).to.not.be.null
|
||||
})
|
||||
})
|
||||
})
|
@ -0,0 +1,72 @@
|
||||
/* Basic editor styles */
|
||||
.tiptap {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Placeholder (at the top) */
|
||||
p.is-editor-empty:first-child::before {
|
||||
color: var(--gray-4);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.ProseMirror-focused p.is-editor-empty:first-child::before {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
/* Bubble menu */
|
||||
.bubble-menu {
|
||||
background-color: var(--white);
|
||||
border: 1px solid var(--gray-1);
|
||||
border-radius: 0.7rem;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
padding: 0.2rem;
|
||||
|
||||
button {
|
||||
background-color: unset;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-3);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--purple);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--purple-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Floating menu */
|
||||
.floating-menu {
|
||||
display: flex;
|
||||
background-color: var(--gray-3);
|
||||
padding: 0.1rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
button {
|
||||
background-color: unset;
|
||||
padding: 0.275rem 0.425rem;
|
||||
border-radius: 0.3rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-3);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--white);
|
||||
color: var(--purple);
|
||||
|
||||
&:hover {
|
||||
color: var(--purple-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
context('/src/Extensions/CollaborationWithMenus/Vue/', () => {
|
||||
before(() => {
|
||||
cy.visit('/src/Extensions/CollaborationWithMenus/Vue/')
|
||||
})
|
||||
|
||||
it('should have a working tiptap instance', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
// eslint-disable-next-line
|
||||
expect(editor).to.not.be.null
|
||||
})
|
||||
})
|
||||
|
||||
it('should have menu plugins initiated', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
const bubbleMenuPlugin = editor.view.state.plugins.find(plugin => plugin.spec.key?.key === 'bubbleMenu$')
|
||||
const floatingMenuPlugin = editor.view.state.plugins.find(plugin => plugin.spec.key?.key === 'floatingMenu$')
|
||||
const hasBothMenuPluginsLoaded = !!bubbleMenuPlugin && !!floatingMenuPlugin
|
||||
|
||||
expect(hasBothMenuPluginsLoaded).to.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should have a ydoc', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
/**
|
||||
* @type {import('yjs').Doc}
|
||||
*/
|
||||
const yDoc = editor.extensionManager.extensions.find(a => a.name === 'collaboration').options.document
|
||||
|
||||
// eslint-disable-next-line
|
||||
expect(yDoc).to.not.be.null
|
||||
})
|
||||
})
|
||||
})
|
157
demos/src/Extensions/CollaborationWithMenus/Vue/index.vue
Normal file
157
demos/src/Extensions/CollaborationWithMenus/Vue/index.vue
Normal file
@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div v-if="editor">
|
||||
<bubble-menu :editor="editor">
|
||||
<div class="bubble-menu">
|
||||
<button
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
:class="{ 'is-active': editor.isActive('bold') }"
|
||||
>
|
||||
Bold
|
||||
</button>
|
||||
</div>
|
||||
</bubble-menu>
|
||||
<floating-menu :editor="editor">
|
||||
<div class="floating-menu">
|
||||
<button
|
||||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
</div>
|
||||
</floating-menu>
|
||||
</div>
|
||||
<editor-content :editor="editor" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import Collaboration from '@tiptap/extension-collaboration'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Heading from '@tiptap/extension-heading'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import {
|
||||
BubbleMenu, Editor, EditorContent, FloatingMenu,
|
||||
} from '@tiptap/vue-3'
|
||||
import { WebrtcProvider } from 'y-webrtc'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EditorContent,
|
||||
BubbleMenu,
|
||||
FloatingMenu,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
editor: null,
|
||||
provider: null,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
this.provider = new WebrtcProvider('tiptap-collaboration-extension', ydoc)
|
||||
|
||||
this.editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Heading,
|
||||
Bold,
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Write something … It’ll be shared with everyone else looking at this example.',
|
||||
}),
|
||||
],
|
||||
})
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
this.editor.destroy()
|
||||
this.provider.destroy()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* Basic editor styles */
|
||||
.tiptap {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Placeholder (at the top) */
|
||||
p.is-editor-empty:first-child::before {
|
||||
color: var(--gray-4);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.ProseMirror-focused p.is-editor-empty:first-child::before {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
/* Bubble menu */
|
||||
.bubble-menu {
|
||||
background-color: var(--white);
|
||||
border: 1px solid var(--gray-1);
|
||||
border-radius: 0.7rem;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
padding: 0.2rem;
|
||||
|
||||
button {
|
||||
background-color: unset;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-3);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--purple);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--purple-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Floating menu */
|
||||
.floating-menu {
|
||||
display: flex;
|
||||
background-color: var(--gray-3);
|
||||
padding: 0.1rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
button {
|
||||
background-color: unset;
|
||||
padding: 0.275rem 0.425rem;
|
||||
border-radius: 0.3rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-3);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--white);
|
||||
color: var(--purple);
|
||||
|
||||
&:hover {
|
||||
color: var(--purple-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -58,7 +58,21 @@ export default () => {
|
||||
>
|
||||
Justify
|
||||
</button>
|
||||
<button onClick={() => editor.chain().focus().unsetTextAlign().run()}>Unset text align</button>
|
||||
<button onClick={() => editor.chain().focus().unsetTextAlign().run()}>
|
||||
Unset text align
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={editor.isActive({ level: 1 }) ? 'is-active' : ''}
|
||||
>
|
||||
Toggle H1
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={editor.isActive({ level: 2 }) ? 'is-active' : ''}
|
||||
>
|
||||
Toggle H2
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EditorContent editor={editor} />
|
||||
|
@ -37,6 +37,21 @@ context('/src/Extensions/TextAlign/React/', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep the text aligned when toggling headings', () => {
|
||||
const alignments = ['center', 'right', 'justify']
|
||||
const headings = [1, 2]
|
||||
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
alignments.forEach(alignment => {
|
||||
headings.forEach(level => {
|
||||
editor.commands.setContent(`<p style="text-align: ${alignment}">Example Text</p>`)
|
||||
editor.commands.toggleHeading({ level })
|
||||
expect(editor.getHTML()).to.eq(`<h${level} style="text-align: ${alignment}">Example Text</h${level}>`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('aligns the text left on the 1st button', () => {
|
||||
cy.get('button:nth-child(1)').click()
|
||||
|
||||
|
@ -37,6 +37,21 @@ context('/src/Extensions/TextAlign/Vue/', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep the text aligned when toggling headings', () => {
|
||||
const alignments = ['center', 'right', 'justify']
|
||||
const headings = [1, 2]
|
||||
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
alignments.forEach(alignment => {
|
||||
headings.forEach(level => {
|
||||
editor.commands.setContent(`<p style="text-align: ${alignment}">Example Text</p>`)
|
||||
editor.commands.toggleHeading({ level })
|
||||
expect(editor.getHTML()).to.eq(`<h${level} style="text-align: ${alignment}">Example Text</h${level}>`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('aligns the text left on the 1st button', () => {
|
||||
cy.get('button:nth-child(1)')
|
||||
.click()
|
||||
|
5744
package-lock.json
generated
5744
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"packageManager": "npm@10.8.1",
|
||||
"packageManager": "npm@10.8.2",
|
||||
"workspaces": [
|
||||
"demos",
|
||||
"shared/*",
|
||||
@ -59,13 +59,13 @@
|
||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^13.3.0",
|
||||
"lint-staged": "^15.2.10",
|
||||
"minimist": "^1.2.8",
|
||||
"ts-loader": "9.3.1",
|
||||
"tsup": "8.2.4",
|
||||
"turbo": "2.0.6",
|
||||
"typescript": "^5.5.3",
|
||||
"webpack": "^5.92.1"
|
||||
"webpack": "^5.94.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@rollup/pluginutils": "^5.1.0"
|
||||
|
@ -24,6 +24,8 @@ import { isActive } from './helpers/isActive.js'
|
||||
import { isNodeEmpty } from './helpers/isNodeEmpty.js'
|
||||
import { resolveFocusPosition } from './helpers/resolveFocusPosition.js'
|
||||
import { NodePos } from './NodePos.js'
|
||||
import { DropPlugin } from './plugins/DropPlugin.js'
|
||||
import { PastePlugin } from './plugins/PastePlugin.js'
|
||||
import { style } from './style.js'
|
||||
import {
|
||||
CanCommands,
|
||||
@ -93,6 +95,8 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
onBlur: () => null,
|
||||
onDestroy: () => null,
|
||||
onContentError: ({ error }) => { throw error },
|
||||
onPaste: () => null,
|
||||
onDrop: () => null,
|
||||
}
|
||||
|
||||
constructor(options: Partial<EditorOptions> = {}) {
|
||||
@ -114,6 +118,14 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
this.on('blur', this.options.onBlur)
|
||||
this.on('destroy', this.options.onDestroy)
|
||||
|
||||
if (this.options.onPaste) {
|
||||
this.registerPlugin(PastePlugin(this.options.onPaste))
|
||||
}
|
||||
|
||||
if (this.options.onDrop) {
|
||||
this.registerPlugin(DropPlugin(this.options.onDrop))
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (this.isDestroyed) {
|
||||
return
|
||||
@ -217,11 +229,12 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
*
|
||||
* @param plugin A ProseMirror plugin
|
||||
* @param handlePlugins Control how to merge the plugin into the existing plugins.
|
||||
* @returns The new editor state
|
||||
*/
|
||||
public registerPlugin(
|
||||
plugin: Plugin,
|
||||
handlePlugins?: (newPlugin: Plugin, plugins: Plugin[]) => Plugin[],
|
||||
): void {
|
||||
): EditorState {
|
||||
const plugins = isFunction(handlePlugins)
|
||||
? handlePlugins(plugin, [...this.state.plugins])
|
||||
: [...this.state.plugins, plugin]
|
||||
@ -229,16 +242,19 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
const state = this.state.reconfigure({ plugins })
|
||||
|
||||
this.view.updateState(state)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a ProseMirror plugin.
|
||||
*
|
||||
* @param nameOrPluginKey The plugins name
|
||||
* @returns The new editor state or undefined if the editor is destroyed
|
||||
*/
|
||||
public unregisterPlugin(nameOrPluginKey: string | PluginKey): void {
|
||||
public unregisterPlugin(nameOrPluginKey: string | PluginKey): EditorState | undefined {
|
||||
if (this.isDestroyed) {
|
||||
return
|
||||
return undefined
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
@ -250,6 +266,8 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
})
|
||||
|
||||
this.view.updateState(state)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
@ -266,7 +284,12 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
FocusEvents,
|
||||
Keymap,
|
||||
Tabindex,
|
||||
] : []
|
||||
].filter(ext => {
|
||||
if (typeof this.options.enableCoreExtensions === 'object') {
|
||||
return this.options.enableCoreExtensions[ext.name as keyof typeof this.options.enableCoreExtensions] !== false
|
||||
}
|
||||
return true
|
||||
}) : []
|
||||
const allExtensions = [...coreExtensions, ...this.options.extensions].filter(extension => {
|
||||
return ['extension', 'node', 'mark'].includes(extension?.type)
|
||||
})
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { keymap } from '@tiptap/pm/keymap'
|
||||
import { Node as ProsemirrorNode, Schema } from '@tiptap/pm/model'
|
||||
import { Schema } from '@tiptap/pm/model'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
import { Decoration, EditorView } from '@tiptap/pm/view'
|
||||
import { NodeViewConstructor } from '@tiptap/pm/view'
|
||||
|
||||
import type { Editor } from './Editor.js'
|
||||
import { getAttributesFromExtensions } from './helpers/getAttributesFromExtensions.js'
|
||||
@ -261,7 +261,7 @@ export class ExtensionManager {
|
||||
* Get all node views from the extensions.
|
||||
* @returns An object with all node views where the key is the node name and the value is the node view function
|
||||
*/
|
||||
get nodeViews() {
|
||||
get nodeViews(): Record<string, NodeViewConstructor> {
|
||||
const { editor } = this
|
||||
const { nodeExtensions } = splitExtensions(this.extensions)
|
||||
|
||||
@ -289,21 +289,26 @@ export class ExtensionManager {
|
||||
return []
|
||||
}
|
||||
|
||||
const nodeview = (
|
||||
node: ProsemirrorNode,
|
||||
view: EditorView,
|
||||
getPos: (() => number) | boolean,
|
||||
decorations: Decoration[],
|
||||
const nodeview: NodeViewConstructor = (
|
||||
node,
|
||||
view,
|
||||
getPos,
|
||||
decorations,
|
||||
innerDecorations,
|
||||
) => {
|
||||
const HTMLAttributes = getRenderedAttributes(node, extensionAttributes)
|
||||
|
||||
return addNodeView()({
|
||||
editor,
|
||||
// pass-through
|
||||
node,
|
||||
getPos,
|
||||
view,
|
||||
getPos: getPos as () => number,
|
||||
decorations,
|
||||
HTMLAttributes,
|
||||
innerDecorations,
|
||||
// tiptap-specific
|
||||
editor,
|
||||
extension,
|
||||
HTMLAttributes,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,7 @@
|
||||
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { NodeSelection } from '@tiptap/pm/state'
|
||||
import { NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
|
||||
|
||||
import { Editor as CoreEditor } from './Editor.js'
|
||||
import { Node } from './Node.js'
|
||||
import { DecorationWithType, NodeViewRendererOptions, NodeViewRendererProps } from './types.js'
|
||||
import { isAndroid } from './utilities/isAndroid.js'
|
||||
import { isiOS } from './utilities/isiOS.js'
|
||||
@ -23,13 +21,19 @@ export class NodeView<
|
||||
|
||||
options: Options
|
||||
|
||||
extension: Node
|
||||
extension: NodeViewRendererProps['extension']
|
||||
|
||||
node: ProseMirrorNode
|
||||
node: NodeViewRendererProps['node']
|
||||
|
||||
decorations: DecorationWithType[]
|
||||
decorations: NodeViewRendererProps['decorations']
|
||||
|
||||
getPos: any
|
||||
innerDecorations: NodeViewRendererProps['innerDecorations']
|
||||
|
||||
view: NodeViewRendererProps['view']
|
||||
|
||||
getPos: NodeViewRendererProps['getPos']
|
||||
|
||||
HTMLAttributes: NodeViewRendererProps['HTMLAttributes']
|
||||
|
||||
isDragging = false
|
||||
|
||||
@ -44,6 +48,9 @@ export class NodeView<
|
||||
this.extension = props.extension
|
||||
this.node = props.node
|
||||
this.decorations = props.decorations as DecorationWithType[]
|
||||
this.innerDecorations = props.innerDecorations
|
||||
this.view = props.view
|
||||
this.HTMLAttributes = props.HTMLAttributes
|
||||
this.getPos = props.getPos
|
||||
this.mount()
|
||||
}
|
||||
@ -93,9 +100,14 @@ export class NodeView<
|
||||
|
||||
event.dataTransfer?.setDragImage(this.dom, x, y)
|
||||
|
||||
const pos = this.getPos()
|
||||
|
||||
if (typeof pos !== 'number') {
|
||||
return
|
||||
}
|
||||
// we need to tell ProseMirror that we want to move the whole node
|
||||
// so we create a NodeSelection
|
||||
const selection = NodeSelection.create(view.state.doc, this.getPos())
|
||||
const selection = NodeSelection.create(view.state.doc, pos)
|
||||
const transaction = view.state.tr.setSelection(selection)
|
||||
|
||||
view.dispatch(transaction)
|
||||
@ -197,6 +209,11 @@ export class NodeView<
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a DOM [mutation](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) or a selection change happens within the view.
|
||||
* @return `false` if the editor should re-read the selection or re-parse the range around the mutation
|
||||
* @return `true` if it can safely be ignored.
|
||||
*/
|
||||
ignoreMutation(mutation: MutationRecord | { type: 'selection'; target: Element }) {
|
||||
if (!this.dom || !this.contentDOM) {
|
||||
return true
|
||||
@ -254,10 +271,17 @@ export class NodeView<
|
||||
return true
|
||||
}
|
||||
|
||||
updateAttributes(attributes: {}) {
|
||||
/**
|
||||
* Update the attributes of the prosemirror node.
|
||||
*/
|
||||
updateAttributes(attributes: Record<string, any>): void {
|
||||
this.editor.commands.command(({ tr }) => {
|
||||
const pos = this.getPos()
|
||||
|
||||
if (typeof pos !== 'number') {
|
||||
return false
|
||||
}
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...this.node.attrs,
|
||||
...attributes,
|
||||
@ -267,8 +291,15 @@ export class NodeView<
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the node.
|
||||
*/
|
||||
deleteNode(): void {
|
||||
const from = this.getPos()
|
||||
|
||||
if (typeof from !== 'number') {
|
||||
return
|
||||
}
|
||||
const to = from + this.node.nodeSize
|
||||
|
||||
this.editor.commands.deleteRange({ from, to })
|
||||
|
@ -28,9 +28,18 @@ export const toggleNode: RawCommands['toggleNode'] = (typeOrName, toggleTypeOrNa
|
||||
const toggleType = getNodeType(toggleTypeOrName, state.schema)
|
||||
const isActive = isNodeActive(state, type, attributes)
|
||||
|
||||
if (isActive) {
|
||||
return commands.setNode(toggleType)
|
||||
let attributesToCopy: Record<string, any> | undefined
|
||||
|
||||
if (state.selection.$anchor.sameParent(state.selection.$head)) {
|
||||
// only copy attributes if the selection is pointing to a node of the same type
|
||||
attributesToCopy = state.selection.$anchor.parent.attrs
|
||||
}
|
||||
|
||||
return commands.setNode(type, attributes)
|
||||
if (isActive) {
|
||||
return commands.setNode(toggleType, attributesToCopy)
|
||||
}
|
||||
|
||||
// If the node is not active, we want to set the new node type with the given attributes
|
||||
// Copying over the attributes from the current node if the selection is pointing to a node of the same type
|
||||
return commands.setNode(type, { ...attributesToCopy, ...attributes })
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { Plugin, PluginKey, Selection } from '@tiptap/pm/state'
|
||||
import { CommandManager } from '../CommandManager.js'
|
||||
import { Extension } from '../Extension.js'
|
||||
import { createChainableState } from '../helpers/createChainableState.js'
|
||||
import { isNodeEmpty } from '../helpers/isNodeEmpty.js'
|
||||
import { isiOS } from '../utilities/isiOS.js'
|
||||
import { isMacOS } from '../utilities/isMacOS.js'
|
||||
|
||||
@ -106,7 +107,9 @@ export const Keymap = Extension.create({
|
||||
const docChanges = transactions.some(transaction => transaction.docChanged)
|
||||
&& !oldState.doc.eq(newState.doc)
|
||||
|
||||
if (!docChanges) {
|
||||
const ignoreTr = transactions.some(transaction => transaction.getMeta('preventClearDocument'))
|
||||
|
||||
if (!docChanges || ignoreTr) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -119,7 +122,7 @@ export const Keymap = Extension.create({
|
||||
return
|
||||
}
|
||||
|
||||
const isEmpty = newState.doc.textBetween(0, newState.doc.content.size, ' ', ' ').length === 0
|
||||
const isEmpty = isNodeEmpty(newState.doc)
|
||||
|
||||
if (!isEmpty) {
|
||||
return
|
||||
|
@ -45,7 +45,13 @@ export function createNodeFromContent(
|
||||
return Fragment.fromArray(content.map(item => schema.nodeFromJSON(item)))
|
||||
}
|
||||
|
||||
return schema.nodeFromJSON(content)
|
||||
const node = schema.nodeFromJSON(content)
|
||||
|
||||
if (options.errorOnInvalidContent) {
|
||||
node.check()
|
||||
}
|
||||
|
||||
return node
|
||||
} catch (error) {
|
||||
if (options.errorOnInvalidContent) {
|
||||
throw new Error('[tiptap error]: Invalid JSON content', { cause: error as Error })
|
||||
|
@ -11,6 +11,8 @@ export * from './NodePos.js'
|
||||
export * from './NodeView.js'
|
||||
export * from './PasteRule.js'
|
||||
export * from './pasteRules/index.js'
|
||||
export * from './plugins/DropPlugin.js'
|
||||
export * from './plugins/PastePlugin.js'
|
||||
export * from './Tracker.js'
|
||||
export * from './types.js'
|
||||
export * from './utilities/index.js'
|
||||
|
14
packages/core/src/plugins/DropPlugin.ts
Normal file
14
packages/core/src/plugins/DropPlugin.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Slice } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
export const DropPlugin = (onDrop: (e: DragEvent, slice: Slice, moved: boolean) => void) => {
|
||||
return new Plugin({
|
||||
key: new PluginKey('tiptapDrop'),
|
||||
|
||||
props: {
|
||||
handleDrop: (_, e, slice, moved) => {
|
||||
onDrop(e, slice, moved)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
14
packages/core/src/plugins/PastePlugin.ts
Normal file
14
packages/core/src/plugins/PastePlugin.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Slice } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
export const PastePlugin = (onPaste: (e: ClipboardEvent, slice: Slice) => void) => {
|
||||
return new Plugin({
|
||||
key: new PluginKey('tiptapPaste'),
|
||||
|
||||
props: {
|
||||
handlePaste: (_view, e, slice) => {
|
||||
onPaste(e, slice)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
@ -3,10 +3,15 @@ import {
|
||||
Node as ProseMirrorNode,
|
||||
NodeType,
|
||||
ParseOptions,
|
||||
Slice,
|
||||
} from '@tiptap/pm/model'
|
||||
import { EditorState, Transaction } from '@tiptap/pm/state'
|
||||
import {
|
||||
Decoration, EditorProps, EditorView, NodeView,
|
||||
Decoration,
|
||||
EditorProps,
|
||||
EditorView,
|
||||
NodeView,
|
||||
NodeViewConstructor,
|
||||
} from '@tiptap/pm/view'
|
||||
|
||||
import { Editor } from './Editor.js'
|
||||
@ -79,7 +84,24 @@ export interface EditorOptions {
|
||||
};
|
||||
enableInputRules: EnableRules;
|
||||
enablePasteRules: EnableRules;
|
||||
enableCoreExtensions: boolean;
|
||||
/**
|
||||
* Determines whether core extensions are enabled.
|
||||
*
|
||||
* If set to `false`, all core extensions will be disabled.
|
||||
* To disable specific core extensions, provide an object where the keys are the extension names and the values are `false`.
|
||||
* Extensions not listed in the object will remain enabled.
|
||||
*
|
||||
* @example
|
||||
* // Disable all core extensions
|
||||
* enabledCoreExtensions: false
|
||||
*
|
||||
* @example
|
||||
* // Disable only the keymap core extension
|
||||
* enabledCoreExtensions: { keymap: false }
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
enableCoreExtensions?: boolean | Partial<Record<'editable' | 'clipboardTextSerializer' | 'commands' | 'focusEvents' | 'keymap' | 'tabindex', false>>;
|
||||
/**
|
||||
* If `true`, the editor will check the content for errors on initialization.
|
||||
* Emitting the `contentError` event if the content is invalid.
|
||||
@ -100,6 +122,8 @@ export interface EditorOptions {
|
||||
onFocus: (props: EditorEvents['focus']) => void;
|
||||
onBlur: (props: EditorEvents['blur']) => void;
|
||||
onDestroy: (props: EditorEvents['destroy']) => void;
|
||||
onPaste: (e: ClipboardEvent, slice: Slice) => void
|
||||
onDrop: (e: DragEvent, slice: Slice, moved: boolean) => void
|
||||
}
|
||||
|
||||
export type HTMLContent = string;
|
||||
@ -184,20 +208,21 @@ export type ValuesOf<T> = T[keyof T];
|
||||
|
||||
export type KeysWithTypeOf<T, Type> = { [P in keyof T]: T[P] extends Type ? P : never }[keyof T];
|
||||
|
||||
export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
|
||||
|
||||
export type DecorationWithType = Decoration & {
|
||||
type: NodeType;
|
||||
};
|
||||
|
||||
export type NodeViewProps = {
|
||||
editor: Editor;
|
||||
node: ProseMirrorNode;
|
||||
decorations: DecorationWithType[];
|
||||
selected: boolean;
|
||||
extension: Node;
|
||||
getPos: () => number;
|
||||
updateAttributes: (attributes: Record<string, any>) => void;
|
||||
deleteNode: () => void;
|
||||
};
|
||||
export type NodeViewProps = Simplify<
|
||||
Omit<NodeViewRendererProps, 'decorations'> & {
|
||||
// TODO this type is not technically correct, but it's the best we can do for now since prosemirror doesn't expose the type of decorations
|
||||
decorations: readonly DecorationWithType[];
|
||||
selected: boolean;
|
||||
updateAttributes: (attributes: Record<string, any>) => void;
|
||||
deleteNode: () => void;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface NodeViewRendererOptions {
|
||||
stopEvent: ((props: { event: Event }) => boolean) | null;
|
||||
@ -208,15 +233,19 @@ export interface NodeViewRendererOptions {
|
||||
}
|
||||
|
||||
export type NodeViewRendererProps = {
|
||||
// pass-through from prosemirror
|
||||
node: Parameters<NodeViewConstructor>[0];
|
||||
view: Parameters<NodeViewConstructor>[1];
|
||||
getPos: () => number; // TODO getPos was incorrectly typed before, change to `Parameters<NodeViewConstructor>[2];` in the next major version
|
||||
decorations: Parameters<NodeViewConstructor>[3];
|
||||
innerDecorations: Parameters<NodeViewConstructor>[4];
|
||||
// tiptap-specific
|
||||
editor: Editor;
|
||||
node: ProseMirrorNode;
|
||||
getPos: (() => number) | boolean;
|
||||
HTMLAttributes: Record<string, any>;
|
||||
decorations: Decoration[];
|
||||
extension: Node;
|
||||
HTMLAttributes: Record<string, any>;
|
||||
};
|
||||
|
||||
export type NodeViewRenderer = (props: NodeViewRendererProps) => NodeView | {};
|
||||
export type NodeViewRenderer = (props: NodeViewRendererProps) => NodeView;
|
||||
|
||||
export type AnyCommands = Record<string, (...args: any[]) => Command>;
|
||||
|
||||
|
@ -23,7 +23,24 @@ export function mergeAttributes(...objects: Record<string, any>[]): Record<strin
|
||||
|
||||
mergedAttributes[key] = [...existingClasses, ...insertClasses].join(' ')
|
||||
} else if (key === 'style') {
|
||||
mergedAttributes[key] = [mergedAttributes[key], value].join('; ')
|
||||
const newStyles: string[] = value ? value.split(';').map((style: string) => style.trim()).filter(Boolean) : []
|
||||
const existingStyles: string[] = mergedAttributes[key] ? mergedAttributes[key].split(';').map((style: string) => style.trim()).filter(Boolean) : []
|
||||
|
||||
const styleMap = new Map<string, string>()
|
||||
|
||||
existingStyles.forEach(style => {
|
||||
const [property, val] = style.split(':').map(part => part.trim())
|
||||
|
||||
styleMap.set(property, val)
|
||||
})
|
||||
|
||||
newStyles.forEach(style => {
|
||||
const [property, val] = style.split(':').map(part => part.trim())
|
||||
|
||||
styleMap.set(property, val)
|
||||
})
|
||||
|
||||
mergedAttributes[key] = Array.from(styleMap.entries()).map(([property, val]) => `${property}: ${val}`).join('; ')
|
||||
} else {
|
||||
mergedAttributes[key] = value
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ function getDecorations({
|
||||
const language = block.node.attrs.language || defaultLanguage
|
||||
const languages = lowlight.listLanguages()
|
||||
|
||||
const nodes = language && (languages.includes(language) || registered(language))
|
||||
const nodes = language && (languages.includes(language) || registered(language) || lowlight.registered?.(language))
|
||||
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
|
||||
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent))
|
||||
|
||||
|
@ -106,11 +106,24 @@ declare module '@tiptap/core' {
|
||||
|
||||
// From DOMPurify
|
||||
// https://github.com/cure53/DOMPurify/blob/main/src/regexp.js
|
||||
const ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex
|
||||
const IS_ALLOWED_URI = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g
|
||||
|
||||
function isAllowedUri(uri: string | undefined) {
|
||||
return !uri || uri.replace(ATTR_WHITESPACE, '').match(IS_ALLOWED_URI)
|
||||
function isAllowedUri(uri: string | undefined, protocols?: LinkOptions['protocols']) {
|
||||
const allowedProtocols: string[] = ['http', 'https', 'ftp', 'ftps', 'mailto', 'tel', 'callto', 'sms', 'cid', 'xmpp']
|
||||
|
||||
if (protocols) {
|
||||
protocols.forEach(protocol => {
|
||||
const nextProtocol = (typeof protocol === 'string' ? protocol : protocol.scheme)
|
||||
|
||||
if (nextProtocol) {
|
||||
allowedProtocols.push(nextProtocol)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
return !uri || uri.replace(ATTR_WHITESPACE, '').match(new RegExp(`^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))`, 'i'))
|
||||
}
|
||||
|
||||
/**
|
||||
@ -187,7 +200,7 @@ export const Link = Mark.create<LinkOptions>({
|
||||
const href = (dom as HTMLElement).getAttribute('href')
|
||||
|
||||
// prevent XSS attacks
|
||||
if (!href || !isAllowedUri(href)) {
|
||||
if (!href || !isAllowedUri(href, this.options.protocols)) {
|
||||
return false
|
||||
}
|
||||
return null
|
||||
@ -197,7 +210,7 @@ export const Link = Mark.create<LinkOptions>({
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
// prevent XSS attacks
|
||||
if (!isAllowedUri(HTMLAttributes.href)) {
|
||||
if (!isAllowedUri(HTMLAttributes.href, this.options.protocols)) {
|
||||
// strip out the href
|
||||
return ['a', mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }), 0]
|
||||
}
|
||||
|
@ -119,7 +119,8 @@ export const Mention = Node.create<MentionOptions>({
|
||||
])
|
||||
.run()
|
||||
|
||||
window.getSelection()?.collapseToEnd()
|
||||
// get reference to `window` object from editor element, to support cross-frame JS usage
|
||||
editor.view.dom.ownerDocument.defaultView?.getSelection()?.collapseToEnd()
|
||||
},
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from)
|
||||
|
@ -85,7 +85,7 @@ export const OrderedList = Node.create<OrderedListOptions>({
|
||||
},
|
||||
},
|
||||
type: {
|
||||
default: null,
|
||||
default: undefined,
|
||||
parseHTML: element => element.getAttribute('type'),
|
||||
},
|
||||
}
|
||||
|
@ -137,6 +137,7 @@ export const TaskItem = Node.create<TaskItemOptions>({
|
||||
|
||||
checkboxWrapper.contentEditable = 'false'
|
||||
checkbox.type = 'checkbox'
|
||||
checkbox.addEventListener('mousedown', event => event.preventDefault())
|
||||
checkbox.addEventListener('change', event => {
|
||||
// if the editor isn’t editable and we don't have a handler for
|
||||
// readonly checks we have to undo the latest change
|
||||
@ -154,6 +155,10 @@ export const TaskItem = Node.create<TaskItemOptions>({
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.command(({ tr }) => {
|
||||
const position = getPos()
|
||||
|
||||
if (typeof position !== 'number') {
|
||||
return false
|
||||
}
|
||||
const currentNode = tr.doc.nodeAt(position)
|
||||
|
||||
tr.setNodeMarkup(position, undefined, {
|
||||
|
@ -36,7 +36,7 @@
|
||||
"@tiptap/pm": "^3.0.0-next.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"zeed-dom": "^0.10.9"
|
||||
"zeed-dom": "^0.15.1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -128,7 +128,7 @@
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.2.1",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
"prosemirror-commands": "^1.5.2",
|
||||
"prosemirror-commands": "^1.6.0",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
@ -136,14 +136,14 @@
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.0",
|
||||
"prosemirror-menu": "^1.2.4",
|
||||
"prosemirror-model": "^1.22.2",
|
||||
"prosemirror-model": "^1.22.3",
|
||||
"prosemirror-schema-basic": "^1.2.3",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.4.0",
|
||||
"prosemirror-trailing-node": "^2.0.9",
|
||||
"prosemirror-transform": "^1.9.0",
|
||||
"prosemirror-view": "^1.33.9"
|
||||
"prosemirror-trailing-node": "^3.0.0",
|
||||
"prosemirror-transform": "^1.10.0",
|
||||
"prosemirror-view": "^1.33.10"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -2,9 +2,9 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
@ -31,6 +31,7 @@
|
||||
"@tiptap/extension-bubble-menu": "^3.0.0-next.1",
|
||||
"@tiptap/extension-floating-menu": "^3.0.0-next.1",
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-deep-equal": "^3",
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -1,55 +1,90 @@
|
||||
import {
|
||||
DecorationWithType,
|
||||
Editor,
|
||||
getRenderedAttributes,
|
||||
NodeView,
|
||||
NodeViewProps,
|
||||
NodeViewRenderer,
|
||||
NodeViewRendererOptions,
|
||||
NodeViewRendererProps,
|
||||
} from '@tiptap/core'
|
||||
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { Decoration, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
|
||||
import React from 'react'
|
||||
import { Node, Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
|
||||
import React, { ComponentType } from 'react'
|
||||
|
||||
import { EditorWithContentComponent } from './Editor.js'
|
||||
import { ReactRenderer } from './ReactRenderer.js'
|
||||
import { ReactNodeViewContext, ReactNodeViewContextProps } from './useReactNodeView.js'
|
||||
|
||||
export interface ReactNodeViewRendererOptions extends NodeViewRendererOptions {
|
||||
/**
|
||||
* This function is called when the node view is updated.
|
||||
* It allows you to compare the old node with the new node and decide if the component should update.
|
||||
*/
|
||||
update:
|
||||
| ((props: {
|
||||
oldNode: ProseMirrorNode
|
||||
oldDecorations: Decoration[]
|
||||
newNode: ProseMirrorNode
|
||||
newDecorations: Decoration[]
|
||||
updateProps: () => void
|
||||
oldNode: ProseMirrorNode;
|
||||
oldDecorations: readonly Decoration[];
|
||||
oldInnerDecorations: DecorationSource;
|
||||
newNode: ProseMirrorNode;
|
||||
newDecorations: readonly Decoration[];
|
||||
innerDecorations: DecorationSource;
|
||||
updateProps: () => void;
|
||||
}) => boolean)
|
||||
| null
|
||||
as?: string
|
||||
className?: string
|
||||
attrs?: Record<string, string>
|
||||
| null;
|
||||
/**
|
||||
* The tag name of the element wrapping the React component.
|
||||
*/
|
||||
as?: string;
|
||||
/**
|
||||
* The class name of the element wrapping the React component.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Attributes that should be applied to the element wrapping the React component.
|
||||
* If this is a function, it will be called each time the node view is updated.
|
||||
* If this is an object, it will be applied once when the node view is mounted.
|
||||
*/
|
||||
attrs?:
|
||||
| Record<string, string>
|
||||
| ((props: {
|
||||
node: ProseMirrorNode;
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}) => Record<string, string>);
|
||||
}
|
||||
|
||||
class ReactNodeView extends NodeView<
|
||||
React.FunctionComponent,
|
||||
Editor,
|
||||
ReactNodeViewRendererOptions
|
||||
> {
|
||||
renderer!: ReactRenderer
|
||||
export class ReactNodeView<
|
||||
Component extends ComponentType<NodeViewProps> = ComponentType<NodeViewProps>,
|
||||
NodeEditor extends Editor = Editor,
|
||||
Options extends ReactNodeViewRendererOptions = ReactNodeViewRendererOptions,
|
||||
> extends NodeView<Component, NodeEditor, Options> {
|
||||
/**
|
||||
* The renderer instance.
|
||||
*/
|
||||
renderer!: ReactRenderer<unknown, NodeViewProps>
|
||||
|
||||
/**
|
||||
* The element that holds the rich-text content of the node.
|
||||
*/
|
||||
contentDOMElement!: HTMLElement | null
|
||||
|
||||
/**
|
||||
* Setup the React component.
|
||||
* Called on initialization.
|
||||
*/
|
||||
mount() {
|
||||
const props: NodeViewProps = {
|
||||
const props = {
|
||||
editor: this.editor,
|
||||
node: this.node,
|
||||
decorations: this.decorations,
|
||||
decorations: this.decorations as DecorationWithType[],
|
||||
innerDecorations: this.innerDecorations,
|
||||
view: this.view,
|
||||
selected: false,
|
||||
extension: this.extension,
|
||||
HTMLAttributes: this.HTMLAttributes,
|
||||
getPos: () => this.getPos(),
|
||||
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
|
||||
deleteNode: () => this.deleteNode(),
|
||||
}
|
||||
} satisfies NodeViewProps
|
||||
|
||||
if (!(this.component as any).displayName) {
|
||||
const capitalizeFirstChar = (string: string): string => {
|
||||
@ -69,13 +104,15 @@ class ReactNodeView extends NodeView<
|
||||
const Component = this.component
|
||||
// For performance reasons, we memoize the provider component
|
||||
// And all of the things it requires are declared outside of the component, so it doesn't need to re-render
|
||||
const ReactNodeViewProvider: React.FunctionComponent = React.memo(componentProps => {
|
||||
return (
|
||||
<ReactNodeViewContext.Provider value={context}>
|
||||
{React.createElement(Component, componentProps)}
|
||||
</ReactNodeViewContext.Provider>
|
||||
)
|
||||
})
|
||||
const ReactNodeViewProvider: React.FunctionComponent<NodeViewProps> = React.memo(
|
||||
componentProps => {
|
||||
return (
|
||||
<ReactNodeViewContext.Provider value={context}>
|
||||
{React.createElement(Component, componentProps)}
|
||||
</ReactNodeViewContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
ReactNodeViewProvider.displayName = 'ReactNodeView'
|
||||
|
||||
@ -88,6 +125,7 @@ class ReactNodeView extends NodeView<
|
||||
}
|
||||
|
||||
if (this.contentDOMElement) {
|
||||
this.contentDOMElement.dataset.nodeViewContentReact = ''
|
||||
// For some reason the whiteSpace prop is not inherited properly in Chrome and Safari
|
||||
// With this fix it seems to work fine
|
||||
// See: https://github.com/ueberdosis/tiptap/issues/1197
|
||||
@ -110,10 +148,15 @@ class ReactNodeView extends NodeView<
|
||||
props,
|
||||
as,
|
||||
className: `node-${this.node.type.name} ${className}`.trim(),
|
||||
attrs: this.options.attrs,
|
||||
})
|
||||
|
||||
this.updateElementAttributes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the DOM element.
|
||||
* This is the element that will be used to display the node view.
|
||||
*/
|
||||
get dom() {
|
||||
if (
|
||||
this.renderer.element.firstElementChild
|
||||
@ -125,6 +168,10 @@ class ReactNodeView extends NodeView<
|
||||
return this.renderer.element as HTMLElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the content DOM element.
|
||||
* This is the element that will be used to display the rich-text content of the node.
|
||||
*/
|
||||
get contentDOM() {
|
||||
if (this.node.isLeaf) {
|
||||
return null
|
||||
@ -133,10 +180,19 @@ class ReactNodeView extends NodeView<
|
||||
return this.contentDOMElement
|
||||
}
|
||||
|
||||
/**
|
||||
* On editor selection update, check if the node is selected.
|
||||
* If it is, call `selectNode`, otherwise call `deselectNode`.
|
||||
*/
|
||||
handleSelectionUpdate() {
|
||||
const { from, to } = this.editor.state.selection
|
||||
const pos = this.getPos()
|
||||
|
||||
if (from <= this.getPos() && to >= this.getPos() + this.node.nodeSize) {
|
||||
if (typeof pos !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
if (from <= pos && to >= pos + this.node.nodeSize) {
|
||||
if (this.renderer.props.selected) {
|
||||
return
|
||||
}
|
||||
@ -151,9 +207,20 @@ class ReactNodeView extends NodeView<
|
||||
}
|
||||
}
|
||||
|
||||
update(node: ProseMirrorNode, decorations: DecorationWithType[]) {
|
||||
const updateProps = (props?: Record<string, any>) => {
|
||||
/**
|
||||
* On update, update the React component.
|
||||
* To prevent unnecessary updates, the `update` option can be used.
|
||||
*/
|
||||
update(
|
||||
node: Node,
|
||||
decorations: readonly Decoration[],
|
||||
innerDecorations: DecorationSource,
|
||||
): boolean {
|
||||
const rerenderComponent = (props?: Record<string, any>) => {
|
||||
this.renderer.updateProps(props)
|
||||
if (typeof this.options.attrs === 'function') {
|
||||
this.updateElementAttributes()
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type !== this.node.type) {
|
||||
@ -163,31 +230,44 @@ class ReactNodeView extends NodeView<
|
||||
if (typeof this.options.update === 'function') {
|
||||
const oldNode = this.node
|
||||
const oldDecorations = this.decorations
|
||||
const oldInnerDecorations = this.innerDecorations
|
||||
|
||||
this.node = node
|
||||
this.decorations = decorations
|
||||
this.innerDecorations = innerDecorations
|
||||
|
||||
return this.options.update({
|
||||
oldNode,
|
||||
oldDecorations,
|
||||
newNode: node,
|
||||
newDecorations: decorations,
|
||||
updateProps: () => updateProps({ node, decorations }),
|
||||
oldInnerDecorations,
|
||||
innerDecorations,
|
||||
updateProps: () => rerenderComponent({ node, decorations, innerDecorations }),
|
||||
})
|
||||
}
|
||||
|
||||
if (node === this.node && this.decorations === decorations) {
|
||||
if (
|
||||
node === this.node
|
||||
&& this.decorations === decorations
|
||||
&& this.innerDecorations === innerDecorations
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.node = node
|
||||
this.decorations = decorations
|
||||
this.innerDecorations = innerDecorations
|
||||
|
||||
updateProps({ node, decorations })
|
||||
rerenderComponent({ node, decorations, innerDecorations })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the node.
|
||||
* Add the `selected` prop and the `ProseMirror-selectednode` class.
|
||||
*/
|
||||
selectNode() {
|
||||
this.renderer.updateProps({
|
||||
selected: true,
|
||||
@ -195,6 +275,10 @@ class ReactNodeView extends NodeView<
|
||||
this.renderer.element.classList.add('ProseMirror-selectednode')
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect the node.
|
||||
* Remove the `selected` prop and the `ProseMirror-selectednode` class.
|
||||
*/
|
||||
deselectNode() {
|
||||
this.renderer.updateProps({
|
||||
selected: false,
|
||||
@ -202,25 +286,52 @@ class ReactNodeView extends NodeView<
|
||||
this.renderer.element.classList.remove('ProseMirror-selectednode')
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the React component instance.
|
||||
*/
|
||||
destroy() {
|
||||
this.renderer.destroy()
|
||||
this.editor.off('selectionUpdate', this.handleSelectionUpdate)
|
||||
this.contentDOMElement = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the attributes of the top-level element that holds the React component.
|
||||
* Applying the attributes defined in the `attrs` option.
|
||||
*/
|
||||
updateElementAttributes() {
|
||||
if (this.options.attrs) {
|
||||
let attrsObj: Record<string, string> = {}
|
||||
|
||||
if (typeof this.options.attrs === 'function') {
|
||||
const extensionAttributes = this.editor.extensionManager.attributes
|
||||
const HTMLAttributes = getRenderedAttributes(this.node, extensionAttributes)
|
||||
|
||||
attrsObj = this.options.attrs({ node: this.node, HTMLAttributes })
|
||||
} else {
|
||||
attrsObj = this.options.attrs
|
||||
}
|
||||
|
||||
this.renderer.updateAttributes(attrsObj)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a React node view renderer.
|
||||
*/
|
||||
export function ReactNodeViewRenderer(
|
||||
component: any,
|
||||
component: ComponentType<NodeViewProps>,
|
||||
options?: Partial<ReactNodeViewRendererOptions>,
|
||||
): NodeViewRenderer {
|
||||
return (props: NodeViewRendererProps) => {
|
||||
return props => {
|
||||
// try to get the parent component
|
||||
// this is important for vue devtools to show the component hierarchy correctly
|
||||
// maybe it’s `undefined` because <editor-content> isn’t rendered yet
|
||||
if (!(props.editor as EditorWithContentComponent).contentComponent) {
|
||||
return {}
|
||||
return {} as unknown as ProseMirrorNodeView
|
||||
}
|
||||
|
||||
return new ReactNodeView(component, props, options) as unknown as ProseMirrorNodeView
|
||||
return new ReactNodeView(component, props, options)
|
||||
}
|
||||
}
|
||||
|
@ -57,14 +57,6 @@ export interface ReactRendererOptions {
|
||||
* @example 'foo bar'
|
||||
*/
|
||||
className?: string,
|
||||
|
||||
/**
|
||||
* The attributes of the element.
|
||||
* @type {Record<string, string>}
|
||||
* @default {}
|
||||
* @example { 'data-foo': 'bar' }
|
||||
*/
|
||||
attrs?: Record<string, string>,
|
||||
}
|
||||
|
||||
type ComponentType<R, P> =
|
||||
@ -83,7 +75,7 @@ type ComponentType<R, P> =
|
||||
* as: 'span',
|
||||
* })
|
||||
*/
|
||||
export class ReactRenderer<R = unknown, P = unknown> {
|
||||
export class ReactRenderer<R = unknown, P extends Record<string, any> = {}> {
|
||||
id: string
|
||||
|
||||
editor: Editor
|
||||
@ -92,23 +84,25 @@ export class ReactRenderer<R = unknown, P = unknown> {
|
||||
|
||||
element: Element
|
||||
|
||||
props: Record<string, any>
|
||||
props: P
|
||||
|
||||
reactElement: React.ReactNode
|
||||
|
||||
ref: R | null = null
|
||||
|
||||
/**
|
||||
* Immediately creates element and renders the provided React component.
|
||||
*/
|
||||
constructor(component: ComponentType<R, P>, {
|
||||
editor,
|
||||
props = {},
|
||||
as = 'div',
|
||||
className = '',
|
||||
attrs,
|
||||
}: ReactRendererOptions) {
|
||||
this.id = Math.floor(Math.random() * 0xFFFFFFFF).toString()
|
||||
this.component = component
|
||||
this.editor = editor as EditorWithContentComponent
|
||||
this.props = props
|
||||
this.props = props as P
|
||||
this.element = document.createElement(as)
|
||||
this.element.classList.add('react-renderer')
|
||||
|
||||
@ -116,12 +110,6 @@ export class ReactRenderer<R = unknown, P = unknown> {
|
||||
this.element.classList.add(...className.split(' '))
|
||||
}
|
||||
|
||||
if (attrs) {
|
||||
Object.keys(attrs).forEach(key => {
|
||||
this.element.setAttribute(key, attrs[key])
|
||||
})
|
||||
}
|
||||
|
||||
if (this.editor.isInitialized) {
|
||||
// On first render, we need to flush the render synchronously
|
||||
// Renders afterwards can be async, but this fixes a cursor positioning issue
|
||||
@ -133,12 +121,16 @@ export class ReactRenderer<R = unknown, P = unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the React component.
|
||||
*/
|
||||
render(): void {
|
||||
const Component = this.component
|
||||
const props = this.props
|
||||
const editor = this.editor as EditorWithContentComponent
|
||||
|
||||
if (isClassComponent(Component) || isForwardRefComponent(Component)) {
|
||||
// @ts-ignore This is a hack to make the ref work
|
||||
props.ref = (ref: R) => {
|
||||
this.ref = ref
|
||||
}
|
||||
@ -149,6 +141,9 @@ export class ReactRenderer<R = unknown, P = unknown> {
|
||||
editor?.contentComponent?.setRenderer(this.id, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-renders the React component with new props.
|
||||
*/
|
||||
updateProps(props: Record<string, any> = {}): void {
|
||||
this.props = {
|
||||
...this.props,
|
||||
@ -158,9 +153,21 @@ export class ReactRenderer<R = unknown, P = unknown> {
|
||||
this.render()
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the React component.
|
||||
*/
|
||||
destroy(): void {
|
||||
const editor = this.editor as EditorWithContentComponent
|
||||
|
||||
editor?.contentComponent?.removeRenderer(this.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the attributes of the element that holds the React component.
|
||||
*/
|
||||
updateAttributes(attributes: Record<string, string>): void {
|
||||
Object.keys(attributes).forEach(key => {
|
||||
this.element.setAttribute(key, attributes[key])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -78,6 +78,7 @@ class EditorInstanceManager {
|
||||
this.options = options
|
||||
this.subscriptions = new Set<() => void>()
|
||||
this.setEditor(this.getInitialEditor())
|
||||
this.scheduleDestroy()
|
||||
|
||||
this.getEditor = this.getEditor.bind(this)
|
||||
this.getServerSnapshot = this.getServerSnapshot.bind(this)
|
||||
@ -147,6 +148,8 @@ class EditorInstanceManager {
|
||||
onTransaction: (...args) => this.options.current.onTransaction?.(...args),
|
||||
onUpdate: (...args) => this.options.current.onUpdate?.(...args),
|
||||
onContentError: (...args) => this.options.current.onContentError?.(...args),
|
||||
onDrop: (...args) => this.options.current.onDrop?.(...args),
|
||||
onPaste: (...args) => this.options.current.onPaste?.(...args),
|
||||
}
|
||||
const editor = new Editor(optionsToApply)
|
||||
|
||||
@ -252,10 +255,10 @@ class EditorInstanceManager {
|
||||
const currentInstanceId = this.instanceId
|
||||
const currentEditor = this.editor
|
||||
|
||||
// Wait a tick to see if the component is still mounted
|
||||
// Wait two ticks 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 still mounted on the following 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)
|
||||
@ -268,7 +271,9 @@ class EditorInstanceManager {
|
||||
this.setEditor(null)
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
// This allows the effect to run again between ticks
|
||||
// which may save us from having to re-create the editor
|
||||
}, 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import deepEqual from 'fast-deep-equal/es6/react'
|
||||
import { useDebugValue, useEffect, useState } from 'react'
|
||||
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
|
||||
|
||||
@ -6,6 +7,7 @@ export type EditorStateSnapshot<TEditor extends Editor | null = Editor | null> =
|
||||
editor: TEditor;
|
||||
transactionNumber: number;
|
||||
};
|
||||
|
||||
export type UseEditorStateOptions<
|
||||
TSelectorResult,
|
||||
TEditor extends Editor | null = Editor | null,
|
||||
@ -20,7 +22,7 @@ export type UseEditorStateOptions<
|
||||
selector: (context: EditorStateSnapshot<TEditor>) => TSelectorResult;
|
||||
/**
|
||||
* A custom equality function to determine if the editor should re-render.
|
||||
* @default `(a, b) => a === b`
|
||||
* @default `deepEqual` from `fast-deep-equal`
|
||||
*/
|
||||
equalityFn?: (a: TSelectorResult, b: TSelectorResult | null) => boolean;
|
||||
};
|
||||
@ -108,30 +110,63 @@ class EditorStateManager<TEditor extends Editor | null = Editor | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook allows you to watch for changes on the editor instance.
|
||||
* It will allow you to select a part of the editor state and re-render the component when it changes.
|
||||
* @example
|
||||
* ```tsx
|
||||
* const editor = useEditor({...options})
|
||||
* const { currentSelection } = useEditorState({
|
||||
* editor,
|
||||
* selector: snapshot => ({ currentSelection: snapshot.editor.state.selection }),
|
||||
* })
|
||||
*/
|
||||
export function useEditorState<TSelectorResult>(
|
||||
options: UseEditorStateOptions<TSelectorResult, Editor>
|
||||
): TSelectorResult;
|
||||
/**
|
||||
* This hook allows you to watch for changes on the editor instance.
|
||||
* It will allow you to select a part of the editor state and re-render the component when it changes.
|
||||
* @example
|
||||
* ```tsx
|
||||
* const editor = useEditor({...options})
|
||||
* const { currentSelection } = useEditorState({
|
||||
* editor,
|
||||
* selector: snapshot => ({ currentSelection: snapshot.editor.state.selection }),
|
||||
* })
|
||||
*/
|
||||
export function useEditorState<TSelectorResult>(
|
||||
options: UseEditorStateOptions<TSelectorResult, Editor | null>
|
||||
): TSelectorResult | null;
|
||||
|
||||
/**
|
||||
* This hook allows you to watch for changes on the editor instance.
|
||||
* It will allow you to select a part of the editor state and re-render the component when it changes.
|
||||
* @example
|
||||
* ```tsx
|
||||
* const editor = useEditor({...options})
|
||||
* const { currentSelection } = useEditorState({
|
||||
* editor,
|
||||
* selector: snapshot => ({ currentSelection: snapshot.editor.state.selection }),
|
||||
* })
|
||||
*/
|
||||
export function useEditorState<TSelectorResult>(
|
||||
options: UseEditorStateOptions<TSelectorResult, Editor> | UseEditorStateOptions<TSelectorResult, Editor | null>,
|
||||
): TSelectorResult | null {
|
||||
const [editorInstance] = useState(() => new EditorStateManager(options.editor))
|
||||
const [editorStateManager] = useState(() => new EditorStateManager(options.editor))
|
||||
|
||||
// Using the `useSyncExternalStore` hook to sync the editor instance with the component state
|
||||
const selectedState = useSyncExternalStoreWithSelector(
|
||||
editorInstance.subscribe,
|
||||
editorInstance.getSnapshot,
|
||||
editorInstance.getServerSnapshot,
|
||||
editorStateManager.subscribe,
|
||||
editorStateManager.getSnapshot,
|
||||
editorStateManager.getServerSnapshot,
|
||||
options.selector as UseEditorStateOptions<TSelectorResult, Editor | null>['selector'],
|
||||
options.equalityFn,
|
||||
options.equalityFn ?? deepEqual,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return editorInstance.watch(options.editor)
|
||||
}, [options.editor, editorInstance])
|
||||
return editorStateManager.watch(options.editor)
|
||||
}, [options.editor, editorStateManager])
|
||||
|
||||
useDebugValue(selectedState)
|
||||
|
||||
|
@ -195,9 +195,9 @@ export function Suggestion<I = any, TSelected = any>({
|
||||
const stopped = prev.active && !next.active
|
||||
const changed = !started && !stopped && prev.query !== next.query
|
||||
|
||||
const handleStart = started
|
||||
const handleStart = started || (moved && changed)
|
||||
const handleChange = changed || moved
|
||||
const handleExit = stopped
|
||||
const handleExit = stopped || (moved && changed)
|
||||
|
||||
// Cancel when suggestion isn't active
|
||||
if (!handleStart && !handleChange && !handleExit) {
|
||||
|
@ -4,10 +4,9 @@ import {
|
||||
NodeViewProps,
|
||||
NodeViewRenderer,
|
||||
NodeViewRendererOptions,
|
||||
NodeViewRendererProps,
|
||||
} from '@tiptap/core'
|
||||
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { Decoration, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
|
||||
import { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
|
||||
import Vue from 'vue'
|
||||
import { VueConstructor } from 'vue/types/umd'
|
||||
import { booleanProp, functionProp, objectProp } from 'vue-ts-types'
|
||||
@ -29,33 +28,38 @@ export const nodeViewProps = {
|
||||
export interface VueNodeViewRendererOptions extends NodeViewRendererOptions {
|
||||
update:
|
||||
| ((props: {
|
||||
oldNode: ProseMirrorNode
|
||||
oldDecorations: Decoration[]
|
||||
newNode: ProseMirrorNode
|
||||
newDecorations: Decoration[]
|
||||
updateProps: () => void
|
||||
oldNode: ProseMirrorNode;
|
||||
oldDecorations: readonly Decoration[];
|
||||
oldInnerDecorations: DecorationSource;
|
||||
newNode: ProseMirrorNode;
|
||||
newDecorations: readonly Decoration[];
|
||||
innerDecorations: DecorationSource;
|
||||
updateProps: () => void;
|
||||
}) => boolean)
|
||||
| null
|
||||
| null;
|
||||
}
|
||||
|
||||
class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRendererOptions> {
|
||||
renderer!: VueRenderer
|
||||
|
||||
decorationClasses!: {
|
||||
value: string
|
||||
value: string;
|
||||
}
|
||||
|
||||
mount() {
|
||||
const props: NodeViewProps = {
|
||||
const props = {
|
||||
editor: this.editor,
|
||||
node: this.node,
|
||||
decorations: this.decorations,
|
||||
decorations: this.decorations as DecorationWithType[],
|
||||
innerDecorations: this.innerDecorations,
|
||||
view: this.view,
|
||||
selected: false,
|
||||
extension: this.extension,
|
||||
HTMLAttributes: this.HTMLAttributes,
|
||||
getPos: () => this.getPos(),
|
||||
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
|
||||
deleteNode: () => this.deleteNode(),
|
||||
}
|
||||
} satisfies NodeViewProps
|
||||
|
||||
const onDragStart = this.onDragStart.bind(this)
|
||||
|
||||
@ -64,7 +68,7 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
|
||||
})
|
||||
|
||||
// @ts-ignore
|
||||
const vue = this.editor.contentComponent?.$options._base ?? Vue // eslint-disable-line
|
||||
const vue = this.editor.contentComponent?.$options._base ?? Vue; // eslint-disable-line
|
||||
|
||||
const Component = vue.extend(this.component).extend({
|
||||
props: Object.keys(props),
|
||||
@ -76,12 +80,19 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
|
||||
},
|
||||
})
|
||||
|
||||
this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this)
|
||||
this.editor.on('selectionUpdate', this.handleSelectionUpdate)
|
||||
|
||||
this.renderer = new VueRenderer(Component, {
|
||||
parent: this.editor.contentComponent,
|
||||
propsData: props,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the DOM element.
|
||||
* This is the element that will be used to display the node view.
|
||||
*/
|
||||
get dom() {
|
||||
if (!this.renderer.element.hasAttribute('data-node-view-wrapper')) {
|
||||
throw Error('Please use the NodeViewWrapper component for your node view.')
|
||||
@ -90,6 +101,10 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
|
||||
return this.renderer.element as HTMLElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the content DOM element.
|
||||
* This is the element that will be used to display the rich-text content of the node.
|
||||
*/
|
||||
get contentDOM() {
|
||||
if (this.node.isLeaf) {
|
||||
return null
|
||||
@ -100,8 +115,43 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
|
||||
return (contentElement || this.dom) as HTMLElement | null
|
||||
}
|
||||
|
||||
update(node: ProseMirrorNode, decorations: DecorationWithType[]) {
|
||||
const updateProps = (props?: Record<string, any>) => {
|
||||
/**
|
||||
* On editor selection update, check if the node is selected.
|
||||
* If it is, call `selectNode`, otherwise call `deselectNode`.
|
||||
*/
|
||||
handleSelectionUpdate() {
|
||||
const { from, to } = this.editor.state.selection
|
||||
const pos = this.getPos()
|
||||
|
||||
if (typeof pos !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
if (from <= pos && to >= pos + this.node.nodeSize) {
|
||||
if (this.renderer.ref.$props.selected) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectNode()
|
||||
} else {
|
||||
if (!this.renderer.ref.$props.selected) {
|
||||
return
|
||||
}
|
||||
|
||||
this.deselectNode()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On update, update the React component.
|
||||
* To prevent unnecessary updates, the `update` option can be used.
|
||||
*/
|
||||
update(
|
||||
node: ProseMirrorNode,
|
||||
decorations: readonly Decoration[],
|
||||
innerDecorations: DecorationSource,
|
||||
): boolean {
|
||||
const rerenderComponent = (props?: Record<string, any>) => {
|
||||
this.decorationClasses.value = this.getDecorationClasses()
|
||||
this.renderer.updateProps(props)
|
||||
}
|
||||
@ -109,16 +159,20 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
|
||||
if (typeof this.options.update === 'function') {
|
||||
const oldNode = this.node
|
||||
const oldDecorations = this.decorations
|
||||
const oldInnerDecorations = this.innerDecorations
|
||||
|
||||
this.node = node
|
||||
this.decorations = decorations
|
||||
this.innerDecorations = innerDecorations
|
||||
|
||||
return this.options.update({
|
||||
oldNode,
|
||||
oldDecorations,
|
||||
newNode: node,
|
||||
newDecorations: decorations,
|
||||
updateProps: () => updateProps({ node, decorations }),
|
||||
oldInnerDecorations,
|
||||
innerDecorations,
|
||||
updateProps: () => rerenderComponent({ node, decorations, innerDecorations }),
|
||||
})
|
||||
}
|
||||
|
||||
@ -126,18 +180,23 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
|
||||
return false
|
||||
}
|
||||
|
||||
if (node === this.node && this.decorations === decorations) {
|
||||
if (node === this.node && this.decorations === decorations && this.innerDecorations === innerDecorations) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.node = node
|
||||
this.decorations = decorations
|
||||
this.innerDecorations = innerDecorations
|
||||
|
||||
updateProps({ node, decorations })
|
||||
rerenderComponent({ node, decorations, innerDecorations })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the node.
|
||||
* Add the `selected` prop and the `ProseMirror-selectednode` class.
|
||||
*/
|
||||
selectNode() {
|
||||
this.renderer.updateProps({
|
||||
selected: true,
|
||||
@ -145,6 +204,10 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
|
||||
this.renderer.element.classList.add('ProseMirror-selectednode')
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect the node.
|
||||
* Remove the `selected` prop and the `ProseMirror-selectednode` class.
|
||||
*/
|
||||
deselectNode() {
|
||||
this.renderer.updateProps({
|
||||
selected: false,
|
||||
@ -164,6 +227,7 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
|
||||
|
||||
destroy() {
|
||||
this.renderer.destroy()
|
||||
this.editor.off('selectionUpdate', this.handleSelectionUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,14 +235,14 @@ export function VueNodeViewRenderer(
|
||||
component: Vue | VueConstructor,
|
||||
options?: Partial<VueNodeViewRendererOptions>,
|
||||
): NodeViewRenderer {
|
||||
return (props: NodeViewRendererProps) => {
|
||||
return props => {
|
||||
// try to get the parent component
|
||||
// this is important for vue devtools to show the component hierarchy correctly
|
||||
// maybe it’s `undefined` because <editor-content> isn’t rendered yet
|
||||
if (!(props.editor as Editor).contentComponent) {
|
||||
return {}
|
||||
return {} as unknown as ProseMirrorNodeView
|
||||
}
|
||||
|
||||
return new VueNodeView(component, props, options) as unknown as ProseMirrorNodeView
|
||||
return new VueNodeView(component, props, options)
|
||||
}
|
||||
}
|
||||
|
@ -73,16 +73,26 @@ export class Editor extends CoreEditor {
|
||||
public registerPlugin(
|
||||
plugin: Plugin,
|
||||
handlePlugins?: (newPlugin: Plugin, plugins: Plugin[]) => Plugin[],
|
||||
): void {
|
||||
super.registerPlugin(plugin, handlePlugins)
|
||||
this.reactiveState.value = this.view.state
|
||||
): EditorState {
|
||||
const nextState = super.registerPlugin(plugin, handlePlugins)
|
||||
|
||||
if (this.reactiveState) {
|
||||
this.reactiveState.value = nextState
|
||||
}
|
||||
|
||||
return nextState
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a ProseMirror plugin.
|
||||
*/
|
||||
public unregisterPlugin(nameOrPluginKey: string | PluginKey): void {
|
||||
super.unregisterPlugin(nameOrPluginKey)
|
||||
this.reactiveState.value = this.view.state
|
||||
public unregisterPlugin(nameOrPluginKey: string | PluginKey): EditorState | undefined {
|
||||
const nextState = super.unregisterPlugin(nameOrPluginKey)
|
||||
|
||||
if (this.reactiveState && nextState) {
|
||||
this.reactiveState.value = nextState
|
||||
}
|
||||
|
||||
return nextState
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,15 @@
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
import {
|
||||
DecorationWithType,
|
||||
NodeView,
|
||||
NodeViewProps,
|
||||
NodeViewRenderer,
|
||||
NodeViewRendererOptions,
|
||||
NodeViewRendererProps,
|
||||
} from '@tiptap/core'
|
||||
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { Decoration, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
|
||||
import { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
|
||||
import {
|
||||
Component,
|
||||
defineComponent,
|
||||
PropType,
|
||||
provide,
|
||||
Ref,
|
||||
ref,
|
||||
Component, defineComponent, PropType, provide, Ref, ref,
|
||||
} from 'vue'
|
||||
|
||||
import { Editor } from './Editor.js'
|
||||
@ -58,13 +53,15 @@ export const nodeViewProps = {
|
||||
export interface VueNodeViewRendererOptions extends NodeViewRendererOptions {
|
||||
update:
|
||||
| ((props: {
|
||||
oldNode: ProseMirrorNode
|
||||
oldDecorations: Decoration[]
|
||||
newNode: ProseMirrorNode
|
||||
newDecorations: Decoration[]
|
||||
updateProps: () => void
|
||||
oldNode: ProseMirrorNode;
|
||||
oldDecorations: readonly Decoration[];
|
||||
oldInnerDecorations: DecorationSource;
|
||||
newNode: ProseMirrorNode;
|
||||
newDecorations: readonly Decoration[];
|
||||
innerDecorations: DecorationSource;
|
||||
updateProps: () => void;
|
||||
}) => boolean)
|
||||
| null
|
||||
| null;
|
||||
}
|
||||
|
||||
class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions> {
|
||||
@ -73,16 +70,19 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
|
||||
decorationClasses!: Ref<string>
|
||||
|
||||
mount() {
|
||||
const props: NodeViewProps = {
|
||||
const props = {
|
||||
editor: this.editor,
|
||||
node: this.node,
|
||||
decorations: this.decorations,
|
||||
decorations: this.decorations as DecorationWithType[],
|
||||
innerDecorations: this.innerDecorations,
|
||||
view: this.view,
|
||||
selected: false,
|
||||
extension: this.extension,
|
||||
HTMLAttributes: this.HTMLAttributes,
|
||||
getPos: () => this.getPos(),
|
||||
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
|
||||
deleteNode: () => this.deleteNode(),
|
||||
}
|
||||
} satisfies NodeViewProps
|
||||
|
||||
const onDragStart = this.onDragStart.bind(this)
|
||||
|
||||
@ -117,12 +117,19 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
|
||||
__file: this.component.__file,
|
||||
})
|
||||
|
||||
this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this)
|
||||
this.editor.on('selectionUpdate', this.handleSelectionUpdate)
|
||||
|
||||
this.renderer = new VueRenderer(extendedComponent, {
|
||||
editor: this.editor,
|
||||
props,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the DOM element.
|
||||
* This is the element that will be used to display the node view.
|
||||
*/
|
||||
get dom() {
|
||||
if (!this.renderer.element || !this.renderer.element.hasAttribute('data-node-view-wrapper')) {
|
||||
throw Error('Please use the NodeViewWrapper component for your node view.')
|
||||
@ -131,6 +138,10 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
|
||||
return this.renderer.element as HTMLElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the content DOM element.
|
||||
* This is the element that will be used to display the rich-text content of the node.
|
||||
*/
|
||||
get contentDOM() {
|
||||
if (this.node.isLeaf) {
|
||||
return null
|
||||
@ -139,8 +150,43 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
|
||||
return this.dom.querySelector('[data-node-view-content]') as HTMLElement | null
|
||||
}
|
||||
|
||||
update(node: ProseMirrorNode, decorations: DecorationWithType[]) {
|
||||
const updateProps = (props?: Record<string, any>) => {
|
||||
/**
|
||||
* On editor selection update, check if the node is selected.
|
||||
* If it is, call `selectNode`, otherwise call `deselectNode`.
|
||||
*/
|
||||
handleSelectionUpdate() {
|
||||
const { from, to } = this.editor.state.selection
|
||||
const pos = this.getPos()
|
||||
|
||||
if (typeof pos !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
if (from <= pos && to >= pos + this.node.nodeSize) {
|
||||
if (this.renderer.props.selected) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectNode()
|
||||
} else {
|
||||
if (!this.renderer.props.selected) {
|
||||
return
|
||||
}
|
||||
|
||||
this.deselectNode()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On update, update the React component.
|
||||
* To prevent unnecessary updates, the `update` option can be used.
|
||||
*/
|
||||
update(
|
||||
node: ProseMirrorNode,
|
||||
decorations: readonly Decoration[],
|
||||
innerDecorations: DecorationSource,
|
||||
): boolean {
|
||||
const rerenderComponent = (props?: Record<string, any>) => {
|
||||
this.decorationClasses.value = this.getDecorationClasses()
|
||||
this.renderer.updateProps(props)
|
||||
}
|
||||
@ -148,16 +194,20 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
|
||||
if (typeof this.options.update === 'function') {
|
||||
const oldNode = this.node
|
||||
const oldDecorations = this.decorations
|
||||
const oldInnerDecorations = this.innerDecorations
|
||||
|
||||
this.node = node
|
||||
this.decorations = decorations
|
||||
this.innerDecorations = innerDecorations
|
||||
|
||||
return this.options.update({
|
||||
oldNode,
|
||||
oldDecorations,
|
||||
newNode: node,
|
||||
newDecorations: decorations,
|
||||
updateProps: () => updateProps({ node, decorations }),
|
||||
oldInnerDecorations,
|
||||
innerDecorations,
|
||||
updateProps: () => rerenderComponent({ node, decorations, innerDecorations }),
|
||||
})
|
||||
}
|
||||
|
||||
@ -165,18 +215,23 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
|
||||
return false
|
||||
}
|
||||
|
||||
if (node === this.node && this.decorations === decorations) {
|
||||
if (node === this.node && this.decorations === decorations && this.innerDecorations === innerDecorations) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.node = node
|
||||
this.decorations = decorations
|
||||
this.innerDecorations = innerDecorations
|
||||
|
||||
updateProps({ node, decorations })
|
||||
rerenderComponent({ node, decorations, innerDecorations })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the node.
|
||||
* Add the `selected` prop and the `ProseMirror-selectednode` class.
|
||||
*/
|
||||
selectNode() {
|
||||
this.renderer.updateProps({
|
||||
selected: true,
|
||||
@ -186,6 +241,10 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect the node.
|
||||
* Remove the `selected` prop and the `ProseMirror-selectednode` class.
|
||||
*/
|
||||
deselectNode() {
|
||||
this.renderer.updateProps({
|
||||
selected: false,
|
||||
@ -207,26 +266,26 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
|
||||
|
||||
destroy() {
|
||||
this.renderer.destroy()
|
||||
this.editor.off('selectionUpdate', this.handleSelectionUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
export function VueNodeViewRenderer(
|
||||
component: Component,
|
||||
component: Component<NodeViewProps>,
|
||||
options?: Partial<VueNodeViewRendererOptions>,
|
||||
): NodeViewRenderer {
|
||||
return (props: NodeViewRendererProps) => {
|
||||
return props => {
|
||||
// try to get the parent component
|
||||
// this is important for vue devtools to show the component hierarchy correctly
|
||||
// maybe it’s `undefined` because <editor-content> isn’t rendered yet
|
||||
if (!(props.editor as Editor).contentComponent) {
|
||||
return {}
|
||||
return {} as unknown as ProseMirrorNodeView
|
||||
}
|
||||
// check for class-component and normalize if neccessary
|
||||
const normalizedComponent = typeof component === 'function' && '__vccOpts' in component
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
? component.__vccOpts as Component
|
||||
? (component.__vccOpts as Component)
|
||||
: component
|
||||
|
||||
return new VueNodeView(normalizedComponent, props, options) as unknown as ProseMirrorNodeView
|
||||
return new VueNodeView(normalizedComponent, props, options)
|
||||
}
|
||||
}
|
||||
|
@ -242,4 +242,25 @@ describe('createNodeFromContent', () => {
|
||||
]), { errorOnInvalidContent: true })
|
||||
}).to.throw('[tiptap error]: Invalid JSON content')
|
||||
})
|
||||
|
||||
it('if `errorOnInvalidContent` is true, will throw an error, when the JSON content does not follow the nesting rules of the schema', () => {
|
||||
const content = {
|
||||
type: 'paragraph',
|
||||
content: [{
|
||||
type: 'paragraph',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
createNodeFromContent(content, getSchemaByResolvedExtensions([
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
]), { errorOnInvalidContent: true })
|
||||
}).to.throw('[tiptap error]: Invalid JSON content')
|
||||
})
|
||||
})
|
||||
|
@ -65,4 +65,20 @@ describe('mergeAttributes', () => {
|
||||
class: 'foo',
|
||||
})
|
||||
})
|
||||
|
||||
it('should overwrite styles', () => {
|
||||
const value = mergeAttributes({ style: 'color: red' }, { style: 'color: green' })
|
||||
|
||||
expect(value).to.deep.eq({
|
||||
style: 'color: green',
|
||||
})
|
||||
})
|
||||
|
||||
it('should merge several styles', () => {
|
||||
const value = mergeAttributes({ style: 'color: red; background-color: red' }, { style: 'color: green; background-color: red; margin-left: 30px' })
|
||||
|
||||
expect(value).to.deep.eq({
|
||||
style: 'color: green; background-color: red; margin-left: 30px',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -250,4 +250,29 @@ describe('extension-link', () => {
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom protocols', () => {
|
||||
it('allows using additional custom protocols', () => {
|
||||
['custom://test.css', 'another-custom://protocol.html', ...validUrls].forEach(url => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
Link.configure({
|
||||
protocols: ['custom', { scheme: 'another-custom' }],
|
||||
}),
|
||||
],
|
||||
content: `<p><a href="${url}">hello world!</a></p>`,
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).to.include(url)
|
||||
expect(JSON.stringify(editor.getJSON())).to.include(url)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user