mirror of
https://github.com/ueberdosis/tiptap.git
synced 2024-11-27 14:59:27 +08:00
Merge branch 'develop' into next
Some checks failed
build / lint (20) (push) Has been cancelled
build / test (20, map[name:Demos/Examples spec:./demos/src/Examples/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Experiments spec:./demos/src/Experiments/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Extensions spec:./demos/src/Extensions/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideContent spec:./demos/src/GuideContent/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideGettingStarted spec:./demos/src/GuideGettingStarted/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Marks spec:./demos/src/Marks/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Nodes spec:./demos/src/Nodes/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Integration spec:./tests/cypress/integration/**/*.spec.{js,ts}]) (push) Has been cancelled
Publish / Release (20) (push) Has been cancelled
build / build (20) (push) Has been cancelled
Some checks failed
build / lint (20) (push) Has been cancelled
build / test (20, map[name:Demos/Examples spec:./demos/src/Examples/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Experiments spec:./demos/src/Experiments/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Extensions spec:./demos/src/Extensions/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideContent spec:./demos/src/GuideContent/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideGettingStarted spec:./demos/src/GuideGettingStarted/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Marks spec:./demos/src/Marks/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Nodes spec:./demos/src/Nodes/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Integration spec:./tests/cypress/integration/**/*.spec.{js,ts}]) (push) Has been cancelled
Publish / Release (20) (push) Has been cancelled
build / build (20) (push) Has been cancelled
This commit is contained in:
commit
f7453a3292
5
.changeset/chatty-monkeys-hear.md
Normal file
5
.changeset/chatty-monkeys-hear.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/extension-list-keymap": patch
|
||||
---
|
||||
|
||||
Fix backspace behavior when selection is not collapsed
|
5
.changeset/five-flowers-eat.md
Normal file
5
.changeset/five-flowers-eat.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/vue-3": patch
|
||||
---
|
||||
|
||||
Fix editor destruction before transition end if editor is nested
|
5
.changeset/five-mice-turn.md
Normal file
5
.changeset/five-mice-turn.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/extension-bubble-menu": patch
|
||||
---
|
||||
|
||||
Add `element: HTMLElement` to `shouldShow` options within the BubbleMenu options.
|
5
.changeset/lemon-berries-change.md
Normal file
5
.changeset/lemon-berries-change.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/core": patch
|
||||
---
|
||||
|
||||
feat: add `once` to EventEmitters
|
5
.changeset/mean-moose-bow.md
Normal file
5
.changeset/mean-moose-bow.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/vue-2": patch
|
||||
---
|
||||
|
||||
Pin vue-ts-types to a working version for vue-2
|
5
.changeset/polite-buttons-wash.md
Normal file
5
.changeset/polite-buttons-wash.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/react": patch
|
||||
---
|
||||
|
||||
React 19 is now allowed as a peer dep, we did not have to make any changes for React 19
|
6
.changeset/shy-pigs-exercise.md
Normal file
6
.changeset/shy-pigs-exercise.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
"@tiptap/core": patch
|
||||
"@tiptap/extension-hard-break": patch
|
||||
---
|
||||
|
||||
Add Node `linebreakReplacement` support and enable on hard-break nodes
|
5
.changeset/swift-keys-collect.md
Normal file
5
.changeset/swift-keys-collect.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/core": patch
|
||||
---
|
||||
|
||||
Improve handling of selections with `updateAttributes`. Should no longer modify parent nodes of the same type.
|
6
.changeset/witty-olives-protect.md
Normal file
6
.changeset/witty-olives-protect.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
"@tiptap/extension-link": patch
|
||||
"tiptap-demos": patch
|
||||
---
|
||||
|
||||
The link extension's `validate` option now applies to both auto-linking and XSS mitigation. While, the new `shouldAutoLink` option is used to disable auto linking on an otherwise valid url.
|
@ -36,7 +36,7 @@ Before submitting a pull request:
|
||||
- Check the codebase to ensure that your feature doesn't already exist.
|
||||
- Check the pull requests to ensure that another person hasn't already submitted the feature or fix.
|
||||
|
||||
Before commiting:
|
||||
Before committing:
|
||||
|
||||
- Make sure to run the tests and linter before committing your changes.
|
||||
- If you are making changes to one of the packages, make sure to **always** include a [changeset](https://github.com/changesets/changesets) in your PR describing **what changed** with a **description** of the change. Those are responsible for changelog creation
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { VueNodeViewRenderer } from '@tiptap/vue-3'
|
||||
|
||||
import Component from './Component.vue'
|
||||
import Component from './VueComponent.vue'
|
||||
|
||||
export default Node.create({
|
||||
name: 'vueComponent',
|
||||
|
36
demos/src/Examples/Transition/Vue/ParentComponent.vue
Normal file
36
demos/src/Examples/Transition/Vue/ParentComponent.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div>
|
||||
<EditorContent :editor="editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import VueComponent from './Extension.js'
|
||||
import type { TNote } from './types.js'
|
||||
|
||||
const note = ref<TNote>({
|
||||
id: 'note-1',
|
||||
content: `
|
||||
<p>Some random note text</p>
|
||||
<vue-component count="0"></vue-component>
|
||||
`,
|
||||
})
|
||||
|
||||
const editor = useEditor({
|
||||
content: note.value.content,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'textarea',
|
||||
},
|
||||
},
|
||||
extensions: [
|
||||
StarterKit,
|
||||
VueComponent,
|
||||
],
|
||||
})
|
||||
|
||||
</script>
|
@ -3,26 +3,30 @@ context('/src/Examples/Transition/Vue/', () => {
|
||||
cy.visit('/src/Examples/Transition/Vue/')
|
||||
})
|
||||
|
||||
it('should not have an active tiptap instance but a button', () => {
|
||||
it('should have two buttons and no active tiptap instance', () => {
|
||||
cy.get('.tiptap').should('not.exist')
|
||||
|
||||
cy.get('#toggle-editor').should('exist')
|
||||
cy.get('#toggle-direct-editor').should('exist')
|
||||
cy.get('#toggle-nested-editor').should('exist')
|
||||
})
|
||||
|
||||
it('clicking the button should show the editor', () => {
|
||||
cy.get('#toggle-editor').click()
|
||||
it('clicking the buttons should show two editors', () => {
|
||||
cy.get('#toggle-direct-editor').click()
|
||||
cy.get('#toggle-nested-editor').click()
|
||||
|
||||
cy.get('.tiptap').should('exist')
|
||||
cy.get('.tiptap').should('be.visible')
|
||||
})
|
||||
|
||||
it('clicking the button again should hide the editor', () => {
|
||||
cy.get('#toggle-editor').click()
|
||||
it('clicking the buttons again should hide the editors', () => {
|
||||
cy.get('#toggle-direct-editor').click()
|
||||
cy.get('#toggle-nested-editor').click()
|
||||
|
||||
cy.get('.tiptap').should('exist')
|
||||
cy.get('.tiptap').should('be.visible')
|
||||
|
||||
cy.get('#toggle-editor').click()
|
||||
cy.get('#toggle-direct-editor').click()
|
||||
cy.get('#toggle-nested-editor').click()
|
||||
|
||||
cy.get('.tiptap').should('not.exist')
|
||||
})
|
||||
|
@ -1,12 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import VueComponent from './Extension.js'
|
||||
import ParentComponent from './ParentComponent.vue'
|
||||
import type { TNote } from './types.js'
|
||||
|
||||
/** Display editor in the same component */
|
||||
const showDirectEditor = ref(false)
|
||||
|
||||
/** Display editor in a child component */
|
||||
const showNestedEditor = ref(false)
|
||||
|
||||
const note = ref<TNote>({
|
||||
id: 'note-1',
|
||||
content: `
|
||||
@ -28,24 +34,43 @@ const editor = useEditor({
|
||||
],
|
||||
})
|
||||
|
||||
const showEditor = ref(false)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Transition with editor in the same component -->
|
||||
<div>
|
||||
<button
|
||||
id="toggle-direct-editor"
|
||||
type="button"
|
||||
@click="showEditor = !showEditor"
|
||||
style="margin-bottom: 1rem;"
|
||||
id="toggle-editor"
|
||||
@click="showDirectEditor = !showDirectEditor"
|
||||
>
|
||||
{{ showEditor ? 'Hide editor' : 'Show editor' }}
|
||||
{{ showDirectEditor ? 'Hide direct editor' : 'Show direct editor' }}
|
||||
</button>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="showEditor" class="tiptap-wrapper">
|
||||
<editor-content :editor="editor" />
|
||||
<div v-if="showDirectEditor" class="tiptap-wrapper">
|
||||
<EditorContent :editor="editor" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Transition with editor in a child component -->
|
||||
<div>
|
||||
<button
|
||||
id="toggle-nested-editor"
|
||||
type="button"
|
||||
style="margin-bottom: 1rem;"
|
||||
@click="showNestedEditor = !showNestedEditor"
|
||||
>
|
||||
{{ showNestedEditor ? 'Hide nested editor' : 'Show nested editor' }}
|
||||
</button>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="showNestedEditor" class="tiptap-wrapper">
|
||||
<ParentComponent />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
@ -62,6 +87,11 @@ const showEditor = ref(false)
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tiptap-wrapper {
|
||||
background-color: var(--purple-light);
|
||||
border: 2px solid var(--purple);
|
||||
|
@ -20,6 +20,61 @@ export default () => {
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
defaultProtocol: 'https',
|
||||
protocols: ['http', 'https'],
|
||||
isAllowedUri: (url, ctx) => {
|
||||
try {
|
||||
// construct URL
|
||||
const parsedUrl = url.includes(':') ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`)
|
||||
|
||||
// use default validation
|
||||
if (!ctx.defaultValidate(parsedUrl.href)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// disallowed protocols
|
||||
const disallowedProtocols = ['ftp', 'file', 'mailto']
|
||||
const protocol = parsedUrl.protocol.replace(':', '')
|
||||
|
||||
if (disallowedProtocols.includes(protocol)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// only allow protocols specified in ctx.protocols
|
||||
const allowedProtocols = ctx.protocols.map(p => (typeof p === 'string' ? p : p.scheme))
|
||||
|
||||
if (!allowedProtocols.includes(protocol)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// disallowed domains
|
||||
const disallowedDomains = ['example-phishing.com', 'malicious-site.net']
|
||||
const domain = parsedUrl.hostname
|
||||
|
||||
if (disallowedDomains.includes(domain)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// all checks have passed
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
shouldAutoLink: url => {
|
||||
try {
|
||||
// construct URL
|
||||
const parsedUrl = url.includes(':') ? new URL(url) : new URL(`https://${url}`)
|
||||
|
||||
// only auto-link if the domain is not in the disallowed list
|
||||
const disallowedDomains = ['example-no-autolink.com', 'another-no-autolink.com']
|
||||
const domain = parsedUrl.hostname
|
||||
|
||||
return !disallowedDomains.includes(domain)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
}),
|
||||
],
|
||||
content: `
|
||||
|
@ -12,27 +12,27 @@ context('/src/Marks/Link/React/', () => {
|
||||
|
||||
it('should parse a tags correctly', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent('<p><a href="#">Example Text1</a></p>')
|
||||
editor.commands.setContent('<p><a href="https://example.com">Example Text1</a></p>')
|
||||
expect(editor.getHTML()).to.eq(
|
||||
'<p><a target="_blank" rel="noopener noreferrer nofollow" href="#">Example Text1</a></p>',
|
||||
'<p><a target="_blank" rel="noopener noreferrer nofollow" href="https://example.com">Example Text1</a></p>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse a tags with target attribute correctly', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent('<p><a href="#" target="_self">Example Text2</a></p>')
|
||||
editor.commands.setContent('<p><a href="https://example.com" target="_self">Example Text2</a></p>')
|
||||
expect(editor.getHTML()).to.eq(
|
||||
'<p><a target="_self" rel="noopener noreferrer nofollow" href="#">Example Text2</a></p>',
|
||||
'<p><a target="_self" rel="noopener noreferrer nofollow" href="https://example.com">Example Text2</a></p>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse a tags with rel attribute correctly', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent('<p><a href="#" rel="follow">Example Text3</a></p>')
|
||||
editor.commands.setContent('<p><a href="https://example.com" rel="follow">Example Text3</a></p>')
|
||||
expect(editor.getHTML()).to.eq(
|
||||
'<p><a target="_blank" rel="follow" href="#">Example Text3</a></p>',
|
||||
'<p><a target="_blank" rel="follow" href="https://example.com">Example Text3</a></p>',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -54,7 +54,7 @@ context('/src/Marks/Link/React/', () => {
|
||||
|
||||
it('should allow exiting the link once set', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent('<p><a href="#" target="_self">Example Text2</a></p>')
|
||||
editor.commands.setContent('<p><a href="https://example.com" target="_self">Example Text2</a></p>')
|
||||
cy.get('.tiptap').type('{rightArrow}')
|
||||
|
||||
cy.get('button:first').should('not.have.class', 'is-active')
|
||||
@ -129,4 +129,32 @@ context('/src/Marks/Link/React/', () => {
|
||||
.find('a[href="http://example3.com/foobar"]')
|
||||
.should('contain', 'http://example3.com/foobar')
|
||||
})
|
||||
|
||||
it('should not allow links with disallowed protocols', () => {
|
||||
const disallowedProtocols = ['ftp://example.com', 'file:///example.txt', 'mailto:test@example.com']
|
||||
|
||||
disallowedProtocols.forEach(url => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent(`<p><a href="${url}">Example Text</a></p>`)
|
||||
expect(editor.getHTML()).to.not.include(url)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not allow links with disallowed domains', () => {
|
||||
const disallowedDomains = ['https://example-phishing.com', 'https://malicious-site.net']
|
||||
|
||||
disallowedDomains.forEach(url => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent(`<p><a href="${url}">Example Text</a></p>`)
|
||||
expect(editor.getHTML()).to.not.include(url)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not auto-link a URL from a disallowed domain', () => {
|
||||
cy.get('.tiptap').type('https://example-phishing.com ') // disallowed domain
|
||||
cy.get('.tiptap').should('not.have.descendants', 'a')
|
||||
cy.get('.tiptap').should('contain.text', 'https://example-phishing.com')
|
||||
})
|
||||
})
|
||||
|
6
package-lock.json
generated
6
package-lock.json
generated
@ -19121,8 +19121,8 @@
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.0-next.1",
|
||||
"@tiptap/pm": "^3.0.0-next.1",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"packages/starter-kit": {
|
||||
@ -19183,7 +19183,7 @@
|
||||
"dependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.0.0-next.1",
|
||||
"@tiptap/extension-floating-menu": "^3.0.0-next.1",
|
||||
"vue-ts-types": "^1.6.0"
|
||||
"vue-ts-types": "1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "^3.0.0-next.1",
|
||||
|
@ -365,6 +365,11 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
|
||||
this.view = new EditorView(this.options.element, {
|
||||
...this.options.editorProps,
|
||||
attributes: {
|
||||
// add `role="textbox"` to the editor element
|
||||
role: 'textbox',
|
||||
...this.options.editorProps?.attributes,
|
||||
},
|
||||
dispatchTransaction: this.dispatchTransaction.bind(this),
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
@ -372,14 +377,6 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
}),
|
||||
})
|
||||
|
||||
// add `role="textbox"` to the editor element
|
||||
this.view.dom.setAttribute('role', 'textbox')
|
||||
|
||||
// add aria-label to the editor element
|
||||
if (!this.view.dom.getAttribute('aria-label')) {
|
||||
this.view.dom.setAttribute('aria-label', 'Rich-Text Editor')
|
||||
}
|
||||
|
||||
// `editor.view` is not yet available at this time.
|
||||
// Therefore we will add all plugins and node views directly afterwards.
|
||||
const newState = this.state.reconfigure({
|
||||
|
@ -46,6 +46,15 @@ export class EventEmitter<T extends Record<string, any>> {
|
||||
return this
|
||||
}
|
||||
|
||||
public once<EventName extends StringKeyOf<T>>(event: EventName, fn: CallbackFunction<T, EventName>): this {
|
||||
const onceFn = (...args: CallbackType<T, EventName>) => {
|
||||
this.off(event, onceFn)
|
||||
fn.apply(this, args)
|
||||
}
|
||||
|
||||
return this.on(event, onceFn)
|
||||
}
|
||||
|
||||
public removeAllListeners(): void {
|
||||
this.callbacks = {}
|
||||
}
|
||||
|
@ -595,6 +595,25 @@ declare module '@tiptap/core' {
|
||||
editor?: Editor
|
||||
}) => NodeSpec['whitespace'])
|
||||
|
||||
/**
|
||||
* Allows a **single** node to be set as linebreak equivalent (e.g. hardBreak).
|
||||
* When converting between block types that have whitespace set to "pre"
|
||||
* and don't support the linebreak node (e.g. codeBlock) and other block types
|
||||
* that do support the linebreak node (e.g. paragraphs) - this node will be used
|
||||
* as the linebreak instead of stripping the newline.
|
||||
*
|
||||
* See [linebreakReplacement](https://prosemirror.net/docs/ref/#model.NodeSpec.linebreakReplacement).
|
||||
*/
|
||||
linebreakReplacement?:
|
||||
| NodeSpec['linebreakReplacement']
|
||||
| ((this: {
|
||||
name: string
|
||||
options: Options
|
||||
storage: Storage
|
||||
parent: ParentConfig<NodeConfig<Options, Storage>>['linebreakReplacement']
|
||||
editor?: Editor
|
||||
}) => NodeSpec['linebreakReplacement'])
|
||||
|
||||
/**
|
||||
* When enabled, enables both
|
||||
* [`definingAsContext`](https://prosemirror.net/docs/ref/#model.NodeSpec.definingAsContext) and
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { MarkType, NodeType } from '@tiptap/pm/model'
|
||||
import {
|
||||
Mark, MarkType, Node, NodeType,
|
||||
} from '@tiptap/pm/model'
|
||||
import { SelectionRange } from '@tiptap/pm/state'
|
||||
|
||||
import { getMarkType } from '../helpers/getMarkType.js'
|
||||
import { getNodeType } from '../helpers/getNodeType.js'
|
||||
@ -30,6 +33,7 @@ declare module '@tiptap/core' {
|
||||
}
|
||||
|
||||
export const updateAttributes: RawCommands['updateAttributes'] = (typeOrName, attributes = {}) => ({ tr, state, dispatch }) => {
|
||||
|
||||
let nodeType: NodeType | null = null
|
||||
let markType: MarkType | null = null
|
||||
|
||||
@ -51,11 +55,38 @@ export const updateAttributes: RawCommands['updateAttributes'] = (typeOrName, at
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
tr.selection.ranges.forEach(range => {
|
||||
tr.selection.ranges.forEach((range: SelectionRange) => {
|
||||
|
||||
const from = range.$from.pos
|
||||
const to = range.$to.pos
|
||||
|
||||
state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
let lastPos: number | undefined
|
||||
let lastNode: Node | undefined
|
||||
let trimmedFrom: number
|
||||
let trimmedTo: number
|
||||
|
||||
if (tr.selection.empty) {
|
||||
state.doc.nodesBetween(from, to, (node: Node, pos: number) => {
|
||||
|
||||
if (nodeType && nodeType === node.type) {
|
||||
trimmedFrom = Math.max(pos, from)
|
||||
trimmedTo = Math.min(pos + node.nodeSize, to)
|
||||
lastPos = pos
|
||||
lastNode = node
|
||||
}
|
||||
})
|
||||
} else {
|
||||
state.doc.nodesBetween(from, to, (node: Node, pos: number) => {
|
||||
|
||||
if (pos < from && nodeType && nodeType === node.type) {
|
||||
trimmedFrom = Math.max(pos, from)
|
||||
trimmedTo = Math.min(pos + node.nodeSize, to)
|
||||
lastPos = pos
|
||||
lastNode = node
|
||||
}
|
||||
|
||||
if (pos >= from && pos <= to) {
|
||||
|
||||
if (nodeType && nodeType === node.type) {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
@ -64,11 +95,40 @@ export const updateAttributes: RawCommands['updateAttributes'] = (typeOrName, at
|
||||
}
|
||||
|
||||
if (markType && node.marks.length) {
|
||||
node.marks.forEach(mark => {
|
||||
if (markType === mark.type) {
|
||||
const trimmedFrom = Math.max(pos, from)
|
||||
const trimmedTo = Math.min(pos + node.nodeSize, to)
|
||||
node.marks.forEach((mark: Mark) => {
|
||||
|
||||
if (markType === mark.type) {
|
||||
const trimmedFrom2 = Math.max(pos, from)
|
||||
const trimmedTo2 = Math.min(pos + node.nodeSize, to)
|
||||
|
||||
tr.addMark(
|
||||
trimmedFrom2,
|
||||
trimmedTo2,
|
||||
markType.create({
|
||||
...mark.attrs,
|
||||
...attributes,
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (lastNode) {
|
||||
|
||||
if (lastPos !== undefined) {
|
||||
tr.setNodeMarkup(lastPos, undefined, {
|
||||
...lastNode.attrs,
|
||||
...attributes,
|
||||
})
|
||||
}
|
||||
|
||||
if (markType && lastNode.marks.length) {
|
||||
lastNode.marks.forEach((mark: Mark) => {
|
||||
|
||||
if (markType === mark.type) {
|
||||
tr.addMark(
|
||||
trimmedFrom,
|
||||
trimmedTo,
|
||||
@ -80,7 +140,7 @@ export const updateAttributes: RawCommands['updateAttributes'] = (typeOrName, at
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -78,6 +78,7 @@ export function getSchemaByResolvedExtensions(extensions: Extensions, editor?: E
|
||||
),
|
||||
code: callOrReturn(getExtensionField<NodeConfig['code']>(extension, 'code', context)),
|
||||
whitespace: callOrReturn(getExtensionField<NodeConfig['whitespace']>(extension, 'whitespace', context)),
|
||||
linebreakReplacement: callOrReturn(getExtensionField<NodeConfig['linebreakReplacement']>(extension, 'linebreakReplacement', context)),
|
||||
defining: callOrReturn(
|
||||
getExtensionField<NodeConfig['defining']>(extension, 'defining', context),
|
||||
),
|
||||
|
@ -56,6 +56,7 @@ export interface BubbleMenuPluginProps {
|
||||
shouldShow:
|
||||
| ((props: {
|
||||
editor: Editor
|
||||
element: HTMLElement
|
||||
view: EditorView
|
||||
state: EditorState
|
||||
oldState?: EditorState
|
||||
@ -315,12 +316,14 @@ export class BubbleMenuView {
|
||||
const { state } = this.view
|
||||
const { selection } = state
|
||||
|
||||
// support for CellSelections
|
||||
const { ranges } = selection
|
||||
const from = Math.min(...ranges.map(range => range.$from.pos))
|
||||
const to = Math.max(...ranges.map(range => range.$to.pos))
|
||||
|
||||
const shouldShow = this.shouldShow?.({
|
||||
editor: this.editor,
|
||||
element: this.element,
|
||||
view: this.view,
|
||||
state,
|
||||
oldState,
|
||||
|
@ -48,6 +48,8 @@ export const HardBreak = Node.create<HardBreakOptions>({
|
||||
|
||||
selectable: false,
|
||||
|
||||
linebreakReplacement: true,
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{ tag: 'br' },
|
||||
|
@ -35,6 +35,7 @@ type AutolinkOptions = {
|
||||
type: MarkType
|
||||
defaultProtocol: string
|
||||
validate: (url: string) => boolean
|
||||
shouldAutoLink: (url: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@ -144,6 +145,8 @@ export function autolink(options: AutolinkOptions): Plugin {
|
||||
})
|
||||
// validate link
|
||||
.filter(link => options.validate(link.value))
|
||||
// check whether should autolink
|
||||
.filter(link => options.shouldAutoLink(link.value))
|
||||
// Add link mark.
|
||||
.forEach(link => {
|
||||
if (getMarksBetween(link.from, link.to, newState.doc).some(item => item.mark.type === options.type)) {
|
||||
|
@ -38,46 +38,87 @@ export interface LinkOptions {
|
||||
* @default true
|
||||
* @example false
|
||||
*/
|
||||
autolink: boolean
|
||||
autolink: boolean;
|
||||
|
||||
/**
|
||||
* An array of custom protocols to be registered with linkifyjs.
|
||||
* @default []
|
||||
* @example ['ftp', 'git']
|
||||
*/
|
||||
protocols: Array<LinkProtocolOptions | string>
|
||||
protocols: Array<LinkProtocolOptions | string>;
|
||||
|
||||
/**
|
||||
* Default protocol to use when no protocol is specified.
|
||||
* @default 'http'
|
||||
*/
|
||||
defaultProtocol: string
|
||||
defaultProtocol: string;
|
||||
/**
|
||||
* If enabled, links will be opened on click.
|
||||
* @default true
|
||||
* @example false
|
||||
*/
|
||||
openOnClick: boolean | DeprecatedOpenWhenNotEditable
|
||||
openOnClick: boolean | DeprecatedOpenWhenNotEditable;
|
||||
/**
|
||||
* Adds a link to the current selection if the pasted content only contains an url.
|
||||
* @default true
|
||||
* @example false
|
||||
*/
|
||||
linkOnPaste: boolean
|
||||
linkOnPaste: boolean;
|
||||
|
||||
/**
|
||||
* HTML attributes to add to the link element.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>
|
||||
HTMLAttributes: Record<string, any>;
|
||||
|
||||
/**
|
||||
* @deprecated Use the `shouldAutoLink` option instead.
|
||||
* A validation function that modifies link verification for the auto linker.
|
||||
* @param url - The url to be validated.
|
||||
* @returns - True if the url is valid, false otherwise.
|
||||
*/
|
||||
validate: (url: string) => boolean
|
||||
validate: (url: string) => boolean;
|
||||
|
||||
/**
|
||||
* A validation function which is used for configuring link verification for preventing XSS attacks.
|
||||
* Only modify this if you know what you're doing.
|
||||
*
|
||||
* @returns {boolean} `true` if the URL is valid, `false` otherwise.
|
||||
*
|
||||
* @example
|
||||
* isAllowedUri: (url, { defaultValidate, protocols, defaultProtocol }) => {
|
||||
* return url.startsWith('./') || defaultValidate(url)
|
||||
* }
|
||||
*/
|
||||
isAllowedUri: (
|
||||
/**
|
||||
* The URL to be validated.
|
||||
*/
|
||||
url: string,
|
||||
ctx: {
|
||||
/**
|
||||
* The default validation function.
|
||||
*/
|
||||
defaultValidate: (url: string) => boolean;
|
||||
/**
|
||||
* An array of allowed protocols for the URL (e.g., "http", "https"). As defined in the `protocols` option.
|
||||
*/
|
||||
protocols: Array<LinkProtocolOptions | string>;
|
||||
/**
|
||||
* A string that represents the default protocol (e.g., 'http'). As defined in the `defaultProtocol` option.
|
||||
*/
|
||||
defaultProtocol: string;
|
||||
}
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* Determines whether a valid link should be automatically linked in the content.
|
||||
*
|
||||
* @param {string} url - The URL that has already been validated.
|
||||
* @returns {boolean} - True if the link should be auto-linked; false if it should not be auto-linked.
|
||||
*/
|
||||
shouldAutoLink: (url: string) => boolean;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
@ -88,19 +129,29 @@ declare module '@tiptap/core' {
|
||||
* @param attributes The link attributes
|
||||
* @example editor.commands.setLink({ href: 'https://tiptap.dev' })
|
||||
*/
|
||||
setLink: (attributes: { href: string; target?: string | null; rel?: string | null; class?: string | null }) => ReturnType
|
||||
setLink: (attributes: {
|
||||
href: string;
|
||||
target?: string | null;
|
||||
rel?: string | null;
|
||||
class?: string | null;
|
||||
}) => ReturnType;
|
||||
/**
|
||||
* Toggle a link mark
|
||||
* @param attributes The link attributes
|
||||
* @example editor.commands.toggleLink({ href: 'https://tiptap.dev' })
|
||||
*/
|
||||
toggleLink: (attributes: { href: string; target?: string | null; rel?: string | null; class?: string | null }) => ReturnType
|
||||
toggleLink: (attributes: {
|
||||
href: string;
|
||||
target?: string | null;
|
||||
rel?: string | null;
|
||||
class?: string | null;
|
||||
}) => ReturnType;
|
||||
/**
|
||||
* Unset a link mark
|
||||
* @example editor.commands.unsetLink()
|
||||
*/
|
||||
unsetLink: () => ReturnType
|
||||
}
|
||||
unsetLink: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,11 +161,22 @@ declare module '@tiptap/core' {
|
||||
const ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g
|
||||
|
||||
function isAllowedUri(uri: string | undefined, protocols?: LinkOptions['protocols']) {
|
||||
const allowedProtocols: string[] = ['http', 'https', 'ftp', 'ftps', 'mailto', 'tel', 'callto', 'sms', 'cid', 'xmpp']
|
||||
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)
|
||||
const nextProtocol = typeof protocol === 'string' ? protocol : protocol.scheme
|
||||
|
||||
if (nextProtocol) {
|
||||
allowedProtocols.push(nextProtocol)
|
||||
@ -122,8 +184,18 @@ function isAllowedUri(uri: string | undefined, protocols?: LinkOptions['protocol
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
!uri
|
||||
|| uri
|
||||
.replace(ATTR_WHITESPACE, '')
|
||||
.match(
|
||||
new RegExp(
|
||||
// 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'))
|
||||
`^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))`,
|
||||
'i',
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -140,6 +212,13 @@ export const Link = Mark.create<LinkOptions>({
|
||||
exitable: true,
|
||||
|
||||
onCreate() {
|
||||
if (this.options.validate && !this.options.shouldAutoLink) {
|
||||
// Copy the validate function to the shouldAutoLink option
|
||||
this.options.shouldAutoLink = this.options.validate
|
||||
console.warn(
|
||||
'The `validate` option is deprecated. Rename to the `shouldAutoLink` option instead.',
|
||||
)
|
||||
}
|
||||
this.options.protocols.forEach(protocol => {
|
||||
if (typeof protocol === 'string') {
|
||||
registerCustomProtocol(protocol)
|
||||
@ -169,7 +248,9 @@ export const Link = Mark.create<LinkOptions>({
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: null,
|
||||
},
|
||||
isAllowedUri: (url, ctx) => !!isAllowedUri(url, ctx.protocols),
|
||||
validate: url => !!url,
|
||||
shouldAutoLink: url => !!url,
|
||||
}
|
||||
},
|
||||
|
||||
@ -194,25 +275,44 @@ export const Link = Mark.create<LinkOptions>({
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
tag: 'a[href]',
|
||||
getAttrs: dom => {
|
||||
const href = (dom as HTMLElement).getAttribute('href')
|
||||
|
||||
// prevent XSS attacks
|
||||
if (!href || !isAllowedUri(href, this.options.protocols)) {
|
||||
if (
|
||||
!href
|
||||
|| !this.options.isAllowedUri(href, {
|
||||
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
|
||||
protocols: this.options.protocols,
|
||||
defaultProtocol: this.options.defaultProtocol,
|
||||
})
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return null
|
||||
},
|
||||
}]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
// prevent XSS attacks
|
||||
if (!isAllowedUri(HTMLAttributes.href, this.options.protocols)) {
|
||||
if (
|
||||
!this.options.isAllowedUri(HTMLAttributes.href, {
|
||||
defaultValidate: href => !!isAllowedUri(href, this.options.protocols),
|
||||
protocols: this.options.protocols,
|
||||
defaultProtocol: this.options.defaultProtocol,
|
||||
})
|
||||
) {
|
||||
// strip out the href
|
||||
return ['a', mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }), 0]
|
||||
return [
|
||||
'a',
|
||||
mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }),
|
||||
0,
|
||||
]
|
||||
}
|
||||
|
||||
return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||
@ -250,17 +350,24 @@ export const Link = Mark.create<LinkOptions>({
|
||||
const foundLinks: PasteRuleMatch[] = []
|
||||
|
||||
if (text) {
|
||||
const { validate } = this.options
|
||||
const links = find(text).filter(item => item.isLink && validate(item.value))
|
||||
const { protocols, defaultProtocol } = this.options
|
||||
const links = find(text).filter(
|
||||
item => item.isLink
|
||||
&& this.options.isAllowedUri(item.value, {
|
||||
defaultValidate: href => !!isAllowedUri(href, protocols),
|
||||
protocols,
|
||||
defaultProtocol,
|
||||
}),
|
||||
)
|
||||
|
||||
if (links.length) {
|
||||
links.forEach(link => (foundLinks.push({
|
||||
links.forEach(link => foundLinks.push({
|
||||
text: link.value,
|
||||
data: {
|
||||
href: link.href,
|
||||
},
|
||||
index: link.start,
|
||||
})))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@ -278,13 +385,19 @@ export const Link = Mark.create<LinkOptions>({
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const plugins: Plugin[] = []
|
||||
const { protocols, defaultProtocol } = this.options
|
||||
|
||||
if (this.options.autolink) {
|
||||
plugins.push(
|
||||
autolink({
|
||||
type: this.type,
|
||||
defaultProtocol: this.options.defaultProtocol,
|
||||
validate: this.options.validate,
|
||||
validate: url => this.options.isAllowedUri(url, {
|
||||
defaultValidate: href => !!isAllowedUri(href, protocols),
|
||||
protocols,
|
||||
defaultProtocol,
|
||||
}),
|
||||
shouldAutoLink: this.options.shouldAutoLink,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
@ -12,6 +12,12 @@ export const handleBackspace = (editor: Editor, name: string, parentListTypes: s
|
||||
return true
|
||||
}
|
||||
|
||||
// if the selection is not collapsed
|
||||
// we can rely on the default backspace behavior
|
||||
if (editor.state.selection.from !== editor.state.selection.to) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if the current item is NOT inside a list item &
|
||||
// the previous item is a list (orderedList or bulletList)
|
||||
// move the cursor into the list and delete the current item
|
||||
|
@ -45,8 +45,8 @@
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.0-next.1",
|
||||
"@tiptap/pm": "^3.0.0-next.1",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -30,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.0.0-next.1",
|
||||
"@tiptap/extension-floating-menu": "^3.0.0-next.1",
|
||||
"vue-ts-types": "^1.6.0"
|
||||
"vue-ts-types": "1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "^3.0.0-next.1",
|
||||
|
@ -59,7 +59,6 @@ export const EditorContent = defineComponent({
|
||||
|
||||
editor.createNodeViews()
|
||||
})
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -11,6 +11,12 @@ export const useEditor = (options: Partial<EditorOptions> = {}) => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// Cloning root node (and its children) to avoid content being lost by destroy
|
||||
const nodes = editor.value?.options.element
|
||||
const newEl = nodes?.cloneNode(true) as HTMLElement
|
||||
|
||||
nodes?.parentNode?.replaceChild(newEl, nodes)
|
||||
|
||||
editor.value?.destroy()
|
||||
})
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user