From 3d68981b47d087fff40549d2143eb952fc9e0a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20K=C3=BChn?= Date: Fri, 3 Dec 2021 08:53:58 +0100 Subject: [PATCH] feat: Add support for autolink (#2226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * WIP * add autolink implementation * refactoring * set keepOnSplit to false * refactoring * improve changed ranges detection * move some helpers into core Co-authored-by: Philipp Kühn --- demos/src/Marks/Link/React/index.jsx | 4 +- demos/src/Marks/Link/Vue/index.vue | 4 +- docs/api/marks/link.md | 20 +++- .../src/helpers/combineTransactionSteps.ts | 18 ++++ packages/core/src/helpers/getChangedRanges.ts | 82 ++++++++++++++++ packages/core/src/helpers/getMarksBetween.ts | 39 ++++++-- packages/core/src/index.ts | 2 + packages/core/src/inputRules/markInputRule.ts | 2 +- packages/core/src/pasteRules/markPasteRule.ts | 2 +- .../core/src/utilities/removeDuplicates.ts | 15 +++ packages/extension-link/package.json | 1 + .../extension-link/src/helpers/autolink.ts | 92 ++++++++++++++++++ .../src/helpers/clickHandler.ts | 27 ++++++ .../src/helpers/pasteHandler.ts | 44 +++++++++ packages/extension-link/src/link.ts | 96 +++++++------------ 15 files changed, 366 insertions(+), 82 deletions(-) create mode 100644 packages/core/src/helpers/combineTransactionSteps.ts create mode 100644 packages/core/src/helpers/getChangedRanges.ts create mode 100644 packages/core/src/utilities/removeDuplicates.ts create mode 100644 packages/extension-link/src/helpers/autolink.ts create mode 100644 packages/extension-link/src/helpers/clickHandler.ts create mode 100644 packages/extension-link/src/helpers/pasteHandler.ts diff --git a/demos/src/Marks/Link/React/index.jsx b/demos/src/Marks/Link/React/index.jsx index 04f989ad..f1ac6a8e 100644 --- a/demos/src/Marks/Link/React/index.jsx +++ b/demos/src/Marks/Link/React/index.jsx @@ -3,8 +3,8 @@ import { useEditor, EditorContent } from '@tiptap/react' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' -import Link from '@tiptap/extension-link' import Code from '@tiptap/extension-code' +import Link from '@tiptap/extension-link' import './styles.scss' export default () => { @@ -13,10 +13,10 @@ export default () => { Document, Paragraph, Text, + Code, Link.configure({ openOnClick: false, }), - Code, ], content: `

diff --git a/demos/src/Marks/Link/Vue/index.vue b/demos/src/Marks/Link/Vue/index.vue index 2cb88d1c..6aa0579e 100644 --- a/demos/src/Marks/Link/Vue/index.vue +++ b/demos/src/Marks/Link/Vue/index.vue @@ -15,8 +15,8 @@ import { Editor, EditorContent } from '@tiptap/vue-3' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' -import Link from '@tiptap/extension-link' import Code from '@tiptap/extension-code' +import Link from '@tiptap/extension-link' export default { components: { @@ -35,10 +35,10 @@ export default { Document, Paragraph, Text, + Code, Link.configure({ openOnClick: false, }), - Code, ], content: `

diff --git a/docs/api/marks/link.md b/docs/api/marks/link.md index 56bf7df7..34641145 100644 --- a/docs/api/marks/link.md +++ b/docs/api/marks/link.md @@ -20,14 +20,14 @@ npm install @tiptap/extension-link ## Settings -### HTMLAttributes -Custom HTML attributes that should be added to the rendered HTML tag. +### autolink +If enabled, it adds links as you type. + +Default: `true` ```js Link.configure({ - HTMLAttributes: { - class: 'my-custom-class', - }, + autolink: false, }) ``` @@ -53,6 +53,16 @@ Link.configure({ }) ``` +### HTMLAttributes +Custom HTML attributes that should be added to the rendered HTML tag. + +```js +Link.configure({ + HTMLAttributes: { + class: 'my-custom-class', + }, +}) +``` ## Commands diff --git a/packages/core/src/helpers/combineTransactionSteps.ts b/packages/core/src/helpers/combineTransactionSteps.ts new file mode 100644 index 00000000..a7a4b9cb --- /dev/null +++ b/packages/core/src/helpers/combineTransactionSteps.ts @@ -0,0 +1,18 @@ +import { Node as ProseMirrorNode } from 'prosemirror-model' +import { Transaction } from 'prosemirror-state' +import { Transform } from 'prosemirror-transform' + +/** + * Returns a new `Transform` based on all steps of the passed transactions. + */ +export default function combineTransactionSteps(oldDoc: ProseMirrorNode, transactions: Transaction[]): Transform { + const transform = new Transform(oldDoc) + + transactions.forEach(transaction => { + transaction.steps.forEach(step => { + transform.step(step) + }) + }) + + return transform +} diff --git a/packages/core/src/helpers/getChangedRanges.ts b/packages/core/src/helpers/getChangedRanges.ts new file mode 100644 index 00000000..b39419a2 --- /dev/null +++ b/packages/core/src/helpers/getChangedRanges.ts @@ -0,0 +1,82 @@ +import { Transform, Step } from 'prosemirror-transform' +import { Range } from '../types' +import removeDuplicates from '../utilities/removeDuplicates' + +export type ChangedRange = { + oldRange: Range, + newRange: Range, +} + +/** + * Removes duplicated ranges and ranges that are + * fully captured by other ranges. + */ +function simplifyChangedRanges(changes: ChangedRange[]): ChangedRange[] { + const uniqueChanges = removeDuplicates(changes) + + return uniqueChanges.length === 1 + ? uniqueChanges + : uniqueChanges.filter((change, index) => { + const rest = uniqueChanges.filter((_, i) => i !== index) + + return !rest.some(otherChange => { + return change.oldRange.from >= otherChange.oldRange.from + && change.oldRange.to <= otherChange.oldRange.to + && change.newRange.from >= otherChange.newRange.from + && change.newRange.to <= otherChange.newRange.to + }) + }) +} + +/** + * Returns a list of changed ranges + * based on the first and last state of all steps. + */ +export default function getChangedRanges(transform: Transform): ChangedRange[] { + const { mapping, steps } = transform + const changes: ChangedRange[] = [] + + mapping.maps.forEach((stepMap, index) => { + const ranges: Range[] = [] + + // This accounts for step changes where no range was actually altered + // e.g. when setting a mark, node attribute, etc. + // @ts-ignore + if (!stepMap.ranges.length) { + const { from, to } = steps[index] as Step & { + from?: number, + to?: number, + } + + if (from === undefined || to === undefined) { + return + } + + ranges.push({ from, to }) + } else { + stepMap.forEach((from, to) => { + ranges.push({ from, to }) + }) + } + + ranges.forEach(({ from, to }) => { + const newStart = mapping.slice(index).map(from, -1) + const newEnd = mapping.slice(index).map(to) + const oldStart = mapping.invert().map(newStart, -1) + const oldEnd = mapping.invert().map(newEnd) + + changes.push({ + oldRange: { + from: oldStart, + to: oldEnd, + }, + newRange: { + from: newStart, + to: newEnd, + }, + }) + }) + }) + + return simplifyChangedRanges(changes) +} diff --git a/packages/core/src/helpers/getMarksBetween.ts b/packages/core/src/helpers/getMarksBetween.ts index 75373fcf..6e62bddd 100644 --- a/packages/core/src/helpers/getMarksBetween.ts +++ b/packages/core/src/helpers/getMarksBetween.ts @@ -1,16 +1,37 @@ -import { EditorState } from 'prosemirror-state' +import { Node as ProseMirrorNode } from 'prosemirror-model' import { MarkRange } from '../types' +import getMarkRange from './getMarkRange' -export default function getMarksBetween(from: number, to: number, state: EditorState): MarkRange[] { +export default function getMarksBetween(from: number, to: number, doc: ProseMirrorNode): MarkRange[] { const marks: MarkRange[] = [] - state.doc.nodesBetween(from, to, (node, pos) => { - marks.push(...node.marks.map(mark => ({ - from: pos, - to: pos + node.nodeSize, - mark, - }))) - }) + // get all inclusive marks on empty selection + if (from === to) { + doc + .resolve(from) + .marks() + .forEach(mark => { + const $pos = doc.resolve(from - 1) + const range = getMarkRange($pos, mark.type) + + if (!range) { + return + } + + marks.push({ + mark, + ...range, + }) + }) + } else { + doc.nodesBetween(from, to, (node, pos) => { + marks.push(...node.marks.map(mark => ({ + from: pos, + to: pos + node.nodeSize, + mark, + }))) + }) + } return marks } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 38059236..65c7b252 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,7 @@ export { default as textPasteRule } from './pasteRules/textPasteRule' export { default as callOrReturn } from './utilities/callOrReturn' export { default as mergeAttributes } from './utilities/mergeAttributes' +export { default as combineTransactionSteps } from './helpers/combineTransactionSteps' export { default as defaultBlockAt } from './helpers/defaultBlockAt' export { default as getExtensionField } from './helpers/getExtensionField' export { default as findChildren } from './helpers/findChildren' @@ -32,6 +33,7 @@ export { default as findParentNodeClosestToPos } from './helpers/findParentNodeC export { default as generateHTML } from './helpers/generateHTML' export { default as generateJSON } from './helpers/generateJSON' export { default as generateText } from './helpers/generateText' +export { default as getChangedRanges } from './helpers/getChangedRanges' export { default as getSchema } from './helpers/getSchema' export { default as getHTMLFromFragment } from './helpers/getHTMLFromFragment' export { default as getDebugJSON } from './helpers/getDebugJSON' diff --git a/packages/core/src/inputRules/markInputRule.ts b/packages/core/src/inputRules/markInputRule.ts index a55c4e15..42c8d77b 100644 --- a/packages/core/src/inputRules/markInputRule.ts +++ b/packages/core/src/inputRules/markInputRule.ts @@ -37,7 +37,7 @@ export default function markInputRule(config: { const textStart = range.from + fullMatch.indexOf(captureGroup) const textEnd = textStart + captureGroup.length - const excludedMarks = getMarksBetween(range.from, range.to, state) + const excludedMarks = getMarksBetween(range.from, range.to, state.doc) .filter(item => { // @ts-ignore const excluded = item.mark.type.excluded as MarkType[] diff --git a/packages/core/src/pasteRules/markPasteRule.ts b/packages/core/src/pasteRules/markPasteRule.ts index 12f9cbf6..b0dd30cf 100644 --- a/packages/core/src/pasteRules/markPasteRule.ts +++ b/packages/core/src/pasteRules/markPasteRule.ts @@ -37,7 +37,7 @@ export default function markPasteRule(config: { const textStart = range.from + fullMatch.indexOf(captureGroup) const textEnd = textStart + captureGroup.length - const excludedMarks = getMarksBetween(range.from, range.to, state) + const excludedMarks = getMarksBetween(range.from, range.to, state.doc) .filter(item => { // @ts-ignore const excluded = item.mark.type.excluded as MarkType[] diff --git a/packages/core/src/utilities/removeDuplicates.ts b/packages/core/src/utilities/removeDuplicates.ts new file mode 100644 index 00000000..73930ea7 --- /dev/null +++ b/packages/core/src/utilities/removeDuplicates.ts @@ -0,0 +1,15 @@ +/** + * Removes duplicated values within an array. + * Supports numbers, strings and objects. + */ +export default function removeDuplicates(array: T[], by = JSON.stringify): T[] { + const seen: Record = {} + + return array.filter(item => { + const key = by(item) + + return Object.prototype.hasOwnProperty.call(seen, key) + ? false + : (seen[key] = true) + }) +} diff --git a/packages/extension-link/package.json b/packages/extension-link/package.json index 522bb7ba..9967033b 100644 --- a/packages/extension-link/package.json +++ b/packages/extension-link/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "linkifyjs": "^3.0.4", + "prosemirror-model": "^1.15.0", "prosemirror-state": "^1.3.4" }, "repository": { diff --git a/packages/extension-link/src/helpers/autolink.ts b/packages/extension-link/src/helpers/autolink.ts new file mode 100644 index 00000000..1474df30 --- /dev/null +++ b/packages/extension-link/src/helpers/autolink.ts @@ -0,0 +1,92 @@ +import { + getMarksBetween, + findChildrenInRange, + combineTransactionSteps, + getChangedRanges, +} from '@tiptap/core' +import { Plugin, PluginKey } from 'prosemirror-state' +import { MarkType } from 'prosemirror-model' +import { find, test } from 'linkifyjs' + +type AutolinkOptions = { + type: MarkType, +} + +export default function autolink(options: AutolinkOptions): Plugin { + return new Plugin({ + key: new PluginKey('autolink'), + appendTransaction: (transactions, oldState, newState) => { + const docChanges = transactions.some(transaction => transaction.docChanged) + && !oldState.doc.eq(newState.doc) + + if (!docChanges) { + return + } + + const { tr } = newState + const transform = combineTransactionSteps(oldState.doc, transactions) + const { mapping } = transform + const changes = getChangedRanges(transform) + + changes.forEach(({ oldRange, newRange }) => { + // at first we check if we have to remove links + getMarksBetween(oldRange.from, oldRange.to, oldState.doc) + .filter(item => item.mark.type === options.type) + .forEach(oldMark => { + const newFrom = mapping.map(oldMark.from) + const newTo = mapping.map(oldMark.to) + const newMarks = getMarksBetween(newFrom, newTo, newState.doc) + .filter(item => item.mark.type === options.type) + + if (!newMarks.length) { + return + } + + const newMark = newMarks[0] + const oldLinkText = oldState.doc.textBetween(oldMark.from, oldMark.to) + const newLinkText = newState.doc.textBetween(newMark.from, newMark.to) + const wasLink = test(oldLinkText) + const isLink = test(newLinkText) + + // remove only the link, if it was a link before too + // because we don’t want to remove links that were set manually + if (wasLink && !isLink) { + tr.removeMark(newMark.from, newMark.to, options.type) + } + }) + + // now let’s see if we can add new links + findChildrenInRange(newState.doc, newRange, node => node.isTextblock) + .forEach(textBlock => { + find(textBlock.node.textContent) + .filter(link => link.isLink) + // calculate link position + .map(link => ({ + ...link, + from: textBlock.pos + link.start + 1, + to: textBlock.pos + link.end + 1, + })) + // check if link is within the changed range + .filter(link => { + const fromIsInRange = newRange.from >= link.from && newRange.from <= link.to + const toIsInRange = newRange.to >= link.from && newRange.to <= link.to + + return fromIsInRange || toIsInRange + }) + // add link mark + .forEach(link => { + tr.addMark(link.from, link.to, options.type.create({ + href: link.href, + })) + }) + }) + }) + + if (!tr.steps.length) { + return + } + + return tr + }, + }) +} diff --git a/packages/extension-link/src/helpers/clickHandler.ts b/packages/extension-link/src/helpers/clickHandler.ts new file mode 100644 index 00000000..5991f493 --- /dev/null +++ b/packages/extension-link/src/helpers/clickHandler.ts @@ -0,0 +1,27 @@ +import { getAttributes } from '@tiptap/core' +import { Plugin, PluginKey } from 'prosemirror-state' +import { MarkType } from 'prosemirror-model' + +type ClickHandlerOptions = { + type: MarkType, +} + +export default function clickHandler(options: ClickHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey('handleClickLink'), + props: { + handleClick: (view, pos, event) => { + const attrs = getAttributes(view.state, options.type.name) + const link = (event.target as HTMLElement)?.closest('a') + + if (link && attrs.href) { + window.open(attrs.href, attrs.target) + + return true + } + + return false + }, + }, + }) +} diff --git a/packages/extension-link/src/helpers/pasteHandler.ts b/packages/extension-link/src/helpers/pasteHandler.ts new file mode 100644 index 00000000..6f0c9f44 --- /dev/null +++ b/packages/extension-link/src/helpers/pasteHandler.ts @@ -0,0 +1,44 @@ +import { Editor } from '@tiptap/core' +import { Plugin, PluginKey } from 'prosemirror-state' +import { MarkType } from 'prosemirror-model' +import { find } from 'linkifyjs' + +type PasteHandlerOptions = { + editor: Editor, + type: MarkType, +} + +export default function pasteHandler(options: PasteHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey('handlePasteLink'), + props: { + handlePaste: (view, event, slice) => { + const { state } = view + const { selection } = state + const { empty } = selection + + if (empty) { + return false + } + + let textContent = '' + + slice.content.forEach(node => { + textContent += node.textContent + }) + + const link = find(textContent).find(item => item.isLink && item.value === textContent) + + if (!textContent || !link) { + return false + } + + options.editor.commands.setMark(options.type, { + href: link.href, + }) + + return true + }, + }, + }) +} diff --git a/packages/extension-link/src/link.ts b/packages/extension-link/src/link.ts index 9caa9943..59fac262 100644 --- a/packages/extension-link/src/link.ts +++ b/packages/extension-link/src/link.ts @@ -1,12 +1,14 @@ -import { - Mark, - markPasteRule, - mergeAttributes, -} from '@tiptap/core' -import { Plugin, PluginKey } from 'prosemirror-state' +import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core' import { find } from 'linkifyjs' +import autolink from './helpers/autolink' +import clickHandler from './helpers/clickHandler' +import pasteHandler from './helpers/pasteHandler' export interface LinkOptions { + /** + * If enabled, it adds links as you type. + */ + autolink: boolean, /** * If enabled, links will be opened on click. */ @@ -45,12 +47,17 @@ export const Link = Mark.create({ priority: 1000, - inclusive: false, + keepOnSplit: false, + + inclusive() { + return this.options.autolink + }, addOptions() { return { openOnClick: true, linkOnPaste: true, + autolink: true, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer nofollow', @@ -76,7 +83,11 @@ export const Link = Mark.create({ }, renderHTML({ HTMLAttributes }) { - return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + return [ + 'a', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ] }, addCommands() { @@ -84,9 +95,11 @@ export const Link = Mark.create({ setLink: attributes => ({ commands }) => { return commands.setMark(this.name, attributes) }, + toggleLink: attributes => ({ commands }) => { return commands.toggleMark(this.name, attributes, { extendEmptyMarkRange: true }) }, + unsetLink: () => ({ commands }) => { return commands.unsetMark(this.name, { extendEmptyMarkRange: true }) }, @@ -114,64 +127,23 @@ export const Link = Mark.create({ addProseMirrorPlugins() { const plugins = [] + if (this.options.autolink) { + plugins.push(autolink({ + type: this.type, + })) + } + if (this.options.openOnClick) { - plugins.push( - new Plugin({ - key: new PluginKey('handleClickLink'), - props: { - handleClick: (view, pos, event) => { - const attrs = this.editor.getAttributes(this.name) - const link = (event.target as HTMLElement)?.closest('a') - - if (link && attrs.href) { - window.open(attrs.href, attrs.target) - - return true - } - - return false - }, - }, - }), - ) + plugins.push(clickHandler({ + type: this.type, + })) } if (this.options.linkOnPaste) { - plugins.push( - new Plugin({ - key: new PluginKey('handlePasteLink'), - props: { - handlePaste: (view, event, slice) => { - const { state } = view - const { selection } = state - const { empty } = selection - - if (empty) { - return false - } - - let textContent = '' - - slice.content.forEach(node => { - textContent += node.textContent - }) - - const link = find(textContent) - .find(item => item.isLink && item.value === textContent) - - if (!textContent || !link) { - return false - } - - this.editor.commands.setMark(this.type, { - href: link.href, - }) - - return true - }, - }, - }), - ) + plugins.push(pasteHandler({ + editor: this.editor, + type: this.type, + })) } return plugins