diff --git a/.changeset/chatty-monkeys-hear.md b/.changeset/chatty-monkeys-hear.md
new file mode 100644
index 000000000..8f8d01daf
--- /dev/null
+++ b/.changeset/chatty-monkeys-hear.md
@@ -0,0 +1,5 @@
+---
+"@tiptap/extension-list-keymap": patch
+---
+
+Fix backspace behavior when selection is not collapsed
diff --git a/.changeset/five-flowers-eat.md b/.changeset/five-flowers-eat.md
new file mode 100644
index 000000000..a44e7c58d
--- /dev/null
+++ b/.changeset/five-flowers-eat.md
@@ -0,0 +1,5 @@
+---
+"@tiptap/vue-3": patch
+---
+
+Fix editor destruction before transition end if editor is nested
diff --git a/.changeset/five-mice-turn.md b/.changeset/five-mice-turn.md
new file mode 100644
index 000000000..a7f0aa1aa
--- /dev/null
+++ b/.changeset/five-mice-turn.md
@@ -0,0 +1,5 @@
+---
+"@tiptap/extension-bubble-menu": patch
+---
+
+Add `element: HTMLElement` to `shouldShow` options within the BubbleMenu options.
diff --git a/.changeset/lemon-berries-change.md b/.changeset/lemon-berries-change.md
new file mode 100644
index 000000000..2748fe225
--- /dev/null
+++ b/.changeset/lemon-berries-change.md
@@ -0,0 +1,5 @@
+---
+"@tiptap/core": patch
+---
+
+feat: add `once` to EventEmitters
diff --git a/.changeset/mean-moose-bow.md b/.changeset/mean-moose-bow.md
new file mode 100644
index 000000000..6598c1e0e
--- /dev/null
+++ b/.changeset/mean-moose-bow.md
@@ -0,0 +1,5 @@
+---
+"@tiptap/vue-2": patch
+---
+
+Pin vue-ts-types to a working version for vue-2
diff --git a/.changeset/polite-buttons-wash.md b/.changeset/polite-buttons-wash.md
new file mode 100644
index 000000000..69aa677ea
--- /dev/null
+++ b/.changeset/polite-buttons-wash.md
@@ -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
diff --git a/.changeset/shy-pigs-exercise.md b/.changeset/shy-pigs-exercise.md
new file mode 100644
index 000000000..16e4d26ff
--- /dev/null
+++ b/.changeset/shy-pigs-exercise.md
@@ -0,0 +1,6 @@
+---
+"@tiptap/core": patch
+"@tiptap/extension-hard-break": patch
+---
+
+Add Node `linebreakReplacement` support and enable on hard-break nodes
diff --git a/.changeset/swift-keys-collect.md b/.changeset/swift-keys-collect.md
new file mode 100644
index 000000000..d9a6ebf45
--- /dev/null
+++ b/.changeset/swift-keys-collect.md
@@ -0,0 +1,5 @@
+---
+"@tiptap/core": patch
+---
+
+Improve handling of selections with `updateAttributes`. Should no longer modify parent nodes of the same type.
diff --git a/.changeset/witty-olives-protect.md b/.changeset/witty-olives-protect.md
new file mode 100644
index 000000000..0893a2fde
--- /dev/null
+++ b/.changeset/witty-olives-protect.md
@@ -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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5a7ab416a..e59b86f40 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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
diff --git a/demos/src/Examples/Transition/Vue/Extension.js b/demos/src/Examples/Transition/Vue/Extension.js
index 9dede7bb0..b83694ad7 100644
--- a/demos/src/Examples/Transition/Vue/Extension.js
+++ b/demos/src/Examples/Transition/Vue/Extension.js
@@ -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',
diff --git a/demos/src/Examples/Transition/Vue/ParentComponent.vue b/demos/src/Examples/Transition/Vue/ParentComponent.vue
new file mode 100644
index 000000000..7cebbc9a9
--- /dev/null
+++ b/demos/src/Examples/Transition/Vue/ParentComponent.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Transition/Vue/Component.vue b/demos/src/Examples/Transition/Vue/VueComponent.vue
similarity index 100%
rename from demos/src/Examples/Transition/Vue/Component.vue
rename to demos/src/Examples/Transition/Vue/VueComponent.vue
diff --git a/demos/src/Examples/Transition/Vue/index.spec.js b/demos/src/Examples/Transition/Vue/index.spec.js
index 5bceb87e1..39100317d 100644
--- a/demos/src/Examples/Transition/Vue/index.spec.js
+++ b/demos/src/Examples/Transition/Vue/index.spec.js
@@ -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')
})
diff --git a/demos/src/Examples/Transition/Vue/index.vue b/demos/src/Examples/Transition/Vue/index.vue
index 46e65dc0c..6bd914949 100644
--- a/demos/src/Examples/Transition/Vue/index.vue
+++ b/demos/src/Examples/Transition/Vue/index.vue
@@ -1,12 +1,18 @@
+
- {{ showEditor ? 'Hide editor' : 'Show editor' }}
+ {{ showDirectEditor ? 'Hide direct editor' : 'Show direct editor' }}
-
+
+
+
+
+
+
+ {{ showNestedEditor ? 'Hide nested editor' : 'Show nested editor' }}
+
+
+
+
@@ -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);
diff --git a/demos/src/Marks/Link/React/index.jsx b/demos/src/Marks/Link/React/index.jsx
index 452bb5b4b..4af6bcff6 100644
--- a/demos/src/Marks/Link/React/index.jsx
+++ b/demos/src/Marks/Link/React/index.jsx
@@ -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: `
diff --git a/demos/src/Marks/Link/React/index.spec.js b/demos/src/Marks/Link/React/index.spec.js
index 61fd1b281..ea5bf9057 100644
--- a/demos/src/Marks/Link/React/index.spec.js
+++ b/demos/src/Marks/Link/React/index.spec.js
@@ -12,27 +12,27 @@ context('/src/Marks/Link/React/', () => {
it('should parse a tags correctly', () => {
cy.get('.tiptap').then(([{ editor }]) => {
- editor.commands.setContent('Example Text1
')
+ editor.commands.setContent('Example Text1
')
expect(editor.getHTML()).to.eq(
- 'Example Text1
',
+ 'Example Text1
',
)
})
})
it('should parse a tags with target attribute correctly', () => {
cy.get('.tiptap').then(([{ editor }]) => {
- editor.commands.setContent('Example Text2
')
+ editor.commands.setContent('Example Text2
')
expect(editor.getHTML()).to.eq(
- 'Example Text2
',
+ 'Example Text2
',
)
})
})
it('should parse a tags with rel attribute correctly', () => {
cy.get('.tiptap').then(([{ editor }]) => {
- editor.commands.setContent('Example Text3
')
+ editor.commands.setContent('Example Text3
')
expect(editor.getHTML()).to.eq(
- 'Example Text3
',
+ 'Example Text3
',
)
})
})
@@ -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('Example Text2
')
+ editor.commands.setContent('Example Text2
')
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(`Example Text
`)
+ 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(`Example Text
`)
+ 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')
+ })
})
diff --git a/package-lock.json b/package-lock.json
index 68d4219e0..7b7f1ffd1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts
index 1cfe67112..c865eeb84 100644
--- a/packages/core/src/Editor.ts
+++ b/packages/core/src/Editor.ts
@@ -365,6 +365,11 @@ export class Editor extends EventEmitter {
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 {
}),
})
- // 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({
diff --git a/packages/core/src/EventEmitter.ts b/packages/core/src/EventEmitter.ts
index 0aa0fe880..104ecf633 100644
--- a/packages/core/src/EventEmitter.ts
+++ b/packages/core/src/EventEmitter.ts
@@ -46,6 +46,15 @@ export class EventEmitter> {
return this
}
+ public once>(event: EventName, fn: CallbackFunction): this {
+ const onceFn = (...args: CallbackType) => {
+ this.off(event, onceFn)
+ fn.apply(this, args)
+ }
+
+ return this.on(event, onceFn)
+ }
+
public removeAllListeners(): void {
this.callbacks = {}
}
diff --git a/packages/core/src/Node.ts b/packages/core/src/Node.ts
index 19d01ca81..567e0eef7 100644
--- a/packages/core/src/Node.ts
+++ b/packages/core/src/Node.ts
@@ -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>['linebreakReplacement']
+ editor?: Editor
+ }) => NodeSpec['linebreakReplacement'])
+
/**
* When enabled, enables both
* [`definingAsContext`](https://prosemirror.net/docs/ref/#model.NodeSpec.definingAsContext) and
diff --git a/packages/core/src/commands/updateAttributes.ts b/packages/core/src/commands/updateAttributes.ts
index d6993b6d6..f01fb1a75 100644
--- a/packages/core/src/commands/updateAttributes.ts
+++ b/packages/core/src/commands/updateAttributes.ts
@@ -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,24 +55,80 @@ 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) => {
- if (nodeType && nodeType === node.type) {
- tr.setNodeMarkup(pos, undefined, {
- ...node.attrs,
+ 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,
+ ...attributes,
+ })
+ }
+
+ if (markType && node.marks.length) {
+ 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 && 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)
+ 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
}
})
}
- })
+ }
})
}
diff --git a/packages/core/src/helpers/getSchemaByResolvedExtensions.ts b/packages/core/src/helpers/getSchemaByResolvedExtensions.ts
index e426d6b9e..b28eeb559 100644
--- a/packages/core/src/helpers/getSchemaByResolvedExtensions.ts
+++ b/packages/core/src/helpers/getSchemaByResolvedExtensions.ts
@@ -78,6 +78,7 @@ export function getSchemaByResolvedExtensions(extensions: Extensions, editor?: E
),
code: callOrReturn(getExtensionField(extension, 'code', context)),
whitespace: callOrReturn(getExtensionField(extension, 'whitespace', context)),
+ linebreakReplacement: callOrReturn(getExtensionField(extension, 'linebreakReplacement', context)),
defining: callOrReturn(
getExtensionField(extension, 'defining', context),
),
diff --git a/packages/extension-bubble-menu/src/bubble-menu-plugin.ts b/packages/extension-bubble-menu/src/bubble-menu-plugin.ts
index 5d1df132f..cede48697 100644
--- a/packages/extension-bubble-menu/src/bubble-menu-plugin.ts
+++ b/packages/extension-bubble-menu/src/bubble-menu-plugin.ts
@@ -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,
diff --git a/packages/extension-hard-break/src/hard-break.ts b/packages/extension-hard-break/src/hard-break.ts
index 6461faf5b..ed5b9a68b 100644
--- a/packages/extension-hard-break/src/hard-break.ts
+++ b/packages/extension-hard-break/src/hard-break.ts
@@ -48,6 +48,8 @@ export const HardBreak = Node.create({
selectable: false,
+ linebreakReplacement: true,
+
parseHTML() {
return [
{ tag: 'br' },
diff --git a/packages/extension-link/src/helpers/autolink.ts b/packages/extension-link/src/helpers/autolink.ts
index c15efa9a1..7b57007a5 100644
--- a/packages/extension-link/src/helpers/autolink.ts
+++ b/packages/extension-link/src/helpers/autolink.ts
@@ -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)) {
diff --git a/packages/extension-link/src/link.ts b/packages/extension-link/src/link.ts
index f78dc1db0..b9b882995 100644
--- a/packages/extension-link/src/link.ts
+++ b/packages/extension-link/src/link.ts
@@ -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
+ protocols: Array;
/**
* 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
+ HTMLAttributes: Record;
/**
+ * @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;
+ /**
+ * 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
})
}
- // 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'))
+ return (
+ !uri
+ || uri
+ .replace(ATTR_WHITESPACE, '')
+ .match(
+ new RegExp(
+ // eslint-disable-next-line no-useless-escape
+ `^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))`,
+ 'i',
+ ),
+ )
+ )
}
/**
@@ -140,6 +212,13 @@ export const Link = Mark.create({
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({
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({
},
parseHTML() {
- return [{
- tag: 'a[href]',
- getAttrs: dom => {
- const href = (dom as HTMLElement).getAttribute('href')
+ return [
+ {
+ tag: 'a[href]',
+ getAttrs: dom => {
+ const href = (dom as HTMLElement).getAttribute('href')
- // prevent XSS attacks
- if (!href || !isAllowedUri(href, this.options.protocols)) {
- return false
- }
- return null
+ // prevent XSS attacks
+ 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({
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({
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,
}),
)
}
diff --git a/packages/extension-list-keymap/src/listHelpers/handleBackspace.ts b/packages/extension-list-keymap/src/listHelpers/handleBackspace.ts
index e1fc012ce..506347f31 100644
--- a/packages/extension-list-keymap/src/listHelpers/handleBackspace.ts
+++ b/packages/extension-list-keymap/src/listHelpers/handleBackspace.ts
@@ -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
diff --git a/packages/react/package.json b/packages/react/package.json
index 76cfeb2e6..87e7b7ee5 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -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",
diff --git a/packages/vue-2/package.json b/packages/vue-2/package.json
index a37a06cb4..004b061b1 100644
--- a/packages/vue-2/package.json
+++ b/packages/vue-2/package.json
@@ -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",
diff --git a/packages/vue-3/src/EditorContent.ts b/packages/vue-3/src/EditorContent.ts
index e4cbaa308..46cfad81e 100644
--- a/packages/vue-3/src/EditorContent.ts
+++ b/packages/vue-3/src/EditorContent.ts
@@ -59,7 +59,6 @@ export const EditorContent = defineComponent({
editor.createNodeViews()
})
-
}
})
diff --git a/packages/vue-3/src/useEditor.ts b/packages/vue-3/src/useEditor.ts
index e75d7f41c..907215327 100644
--- a/packages/vue-3/src/useEditor.ts
+++ b/packages/vue-3/src/useEditor.ts
@@ -11,6 +11,12 @@ export const useEditor = (options: Partial = {}) => {
})
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()
})