Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for autolink #2226

Merged
merged 12 commits into from
Dec 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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