Skip to content

Commit

Permalink
feat: Add support for autolink (#2226)
Browse files Browse the repository at this point in the history
* 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 <philippkuehn@MacBook-Pro-von-Philipp.local>
  • Loading branch information
philippkuehn and Philipp Kühn authored Dec 3, 2021
1 parent 40a9404 commit 3d68981
Show file tree
Hide file tree
Showing 15 changed files with 366 additions and 82 deletions.
4 changes: 2 additions & 2 deletions demos/src/Marks/Link/React/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -13,10 +13,10 @@ export default () => {
Document,
Paragraph,
Text,
Code,
Link.configure({
openOnClick: false,
}),
Code,
],
content: `
<p>
Expand Down
4 changes: 2 additions & 2 deletions demos/src/Marks/Link/Vue/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -35,10 +35,10 @@ export default {
Document,
Paragraph,
Text,
Code,
Link.configure({
openOnClick: false,
}),
Code,
],
content: `
<p>
Expand Down
20 changes: 15 additions & 5 deletions docs/api/marks/link.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
```

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

Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/helpers/combineTransactionSteps.ts
Original file line number Diff line number Diff line change
@@ -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
}
82 changes: 82 additions & 0 deletions packages/core/src/helpers/getChangedRanges.ts
Original file line number Diff line number Diff line change
@@ -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)
}
39 changes: 30 additions & 9 deletions packages/core/src/helpers/getMarksBetween.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/inputRules/markInputRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/pasteRules/markPasteRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/utilities/removeDuplicates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Removes duplicated values within an array.
* Supports numbers, strings and objects.
*/
export default function removeDuplicates<T>(array: T[], by = JSON.stringify): T[] {
const seen: Record<any, any> = {}

return array.filter(item => {
const key = by(item)

return Object.prototype.hasOwnProperty.call(seen, key)
? false
: (seen[key] = true)
})
}
1 change: 1 addition & 0 deletions packages/extension-link/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"dependencies": {
"linkifyjs": "^3.0.4",
"prosemirror-model": "^1.15.0",
"prosemirror-state": "^1.3.4"
},
"repository": {
Expand Down
92 changes: 92 additions & 0 deletions packages/extension-link/src/helpers/autolink.ts
Original file line number Diff line number Diff line change
@@ -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
},
})
}
Loading

0 comments on commit 3d68981

Please sign in to comment.