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

This commit is contained in:
Nick the Sick 2024-11-11 14:26:08 +01:00
commit f7453a3292
No known key found for this signature in database
GPG Key ID: F575992F156E5BCC
32 changed files with 501 additions and 83 deletions

View File

@ -0,0 +1,5 @@
---
"@tiptap/extension-list-keymap": patch
---
Fix backspace behavior when selection is not collapsed

View File

@ -0,0 +1,5 @@
---
"@tiptap/vue-3": patch
---
Fix editor destruction before transition end if editor is nested

View File

@ -0,0 +1,5 @@
---
"@tiptap/extension-bubble-menu": patch
---
Add `element: HTMLElement` to `shouldShow` options within the BubbleMenu options.

View File

@ -0,0 +1,5 @@
---
"@tiptap/core": patch
---
feat: add `once` to EventEmitters

View File

@ -0,0 +1,5 @@
---
"@tiptap/vue-2": patch
---
Pin vue-ts-types to a working version for vue-2

View 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

View File

@ -0,0 +1,6 @@
---
"@tiptap/core": patch
"@tiptap/extension-hard-break": patch
---
Add Node `linebreakReplacement` support and enable on hard-break nodes

View File

@ -0,0 +1,5 @@
---
"@tiptap/core": patch
---
Improve handling of selections with `updateAttributes`. Should no longer modify parent nodes of the same type.

View 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.

View File

@ -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

View File

@ -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',

View 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>

View File

@ -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')
})

View File

@ -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);

View File

@ -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: `

View File

@ -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
View File

@ -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",

View File

@ -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({

View File

@ -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 = {}
}

View File

@ -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

View File

@ -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
}
})
}
})
}
})
}

View File

@ -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),
),

View File

@ -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,

View File

@ -48,6 +48,8 @@ export const HardBreak = Node.create<HardBreakOptions>({
selectable: false,
linebreakReplacement: true,
parseHTML() {
return [
{ tag: 'br' },

View File

@ -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)) {

View File

@ -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,
}),
)
}

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -59,7 +59,6 @@ export const EditorContent = defineComponent({
editor.createNodeViews()
})
}
})

View File

@ -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()
})