-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add support for autolink (#2226)
* 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
1 parent
40a9404
commit 3d68981
Showing
15 changed files
with
366 additions
and
82 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}, | ||
}) | ||
} |
Oops, something went wrong.