Skip to content

Commit

Permalink
Merge pull request #488 from thematters/feat/link
Browse files Browse the repository at this point in the history
Correct linkify
  • Loading branch information
robertu7 authored Jun 14, 2024
2 parents 360b57b + 04a780f commit a8b5efd
Show file tree
Hide file tree
Showing 6 changed files with 253 additions and 102 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@matters/matters-editor",
"version": "0.2.5-alpha.4",
"version": "0.2.5-alpha.5",
"description": "Editor for matters.news",
"author": "https://github.com/thematters",
"homepage": "https://github.com/thematters/matters-editor",
Expand Down
145 changes: 85 additions & 60 deletions src/editors/extensions/link/helpers/autolink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,78 +3,79 @@ import {
findChildrenInRange,
getChangedRanges,
getMarksBetween,
type NodeWithPos,
NodeWithPos,
} from '@tiptap/core'
import { type MarkType } from '@tiptap/pm/model'
import { MarkType } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { find, test } from 'linkifyjs'
import { MultiToken, tokenize } from 'linkifyjs'

/**
* Check if the provided tokens form a valid link structure, which can either be a single link token
* or a link token surrounded by parentheses or square brackets.
*
* This ensures that only complete and valid text is hyperlinked, preventing cases where a valid
* top-level domain (TLD) is immediately followed by an invalid character, like a number. For
* example, with the `find` method from Linkify, entering `example.com1` would result in
* `example.com` being linked and the trailing `1` left as plain text. By using the `tokenize`
* method, we can perform more comprehensive validation on the input text.
*/
function isValidLinkStructure(
tokens: Array<ReturnType<MultiToken['toObject']>>,
) {
if (tokens.length === 1) {
return tokens[0].isLink
}

if (tokens.length === 3 && tokens[1].isLink) {
return ['()', '[]'].includes(tokens[0].value + tokens[2].value)
}

return false
}

interface AutolinkOptions {
type AutolinkOptions = {
type: MarkType
validate?: (url: string) => boolean
defaultProtocol: string
validate: (url: string) => boolean
}

/**
* This plugin allows you to automatically add links to your editor.
* @param options The plugin options
* @returns The plugin instance
*/
export function autolink(options: AutolinkOptions): Plugin {
return new Plugin({
key: new PluginKey('autolink'),
appendTransaction: (transactions, oldState, newState) => {
/**
* Does the transaction change the document?
*/
const docChanges =
transactions.some((transaction) => transaction.docChanged) &&
!oldState.doc.eq(newState.doc)

/**
* Prevent autolink if the transaction is not a document change or if the transaction has the meta `preventAutolink`.
*/
const preventAutolink = transactions.some((transaction) =>
transaction.getMeta('preventAutolink'),
)

/**
* Prevent autolink if the transaction is not a document change
* or if the transaction has the meta `preventAutolink`.
*/
if (!docChanges || preventAutolink) {
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 === 0) {
return
}

const newMark = newMarks[0]
const oldLinkText = oldState.doc.textBetween(
oldMark.from,
oldMark.to,
undefined,
' ',
)
const newLinkText = newState.doc.textBetween(
newMark.from,
newMark.to,
undefined,
' ',
)
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
changes.forEach(({ newRange }) => {
// Now let’s see if we can add new links.
const nodesInChangedRanges = findChildrenInRange(
newState.doc,
newRange,
Expand All @@ -85,7 +86,7 @@ export function autolink(options: AutolinkOptions): Plugin {
let textBeforeWhitespace: string | undefined

if (nodesInChangedRanges.length > 1) {
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter)
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter).
textBlock = nodesInChangedRanges[0]
textBeforeWhitespace = newState.doc.textBetween(
textBlock.pos,
Expand All @@ -94,8 +95,8 @@ export function autolink(options: AutolinkOptions): Plugin {
' ',
)
} else if (
nodesInChangedRanges.length > 0 &&
// We want to make sure to include the block seperator argument to treat hard breaks like spaces
nodesInChangedRanges.length &&
// We want to make sure to include the block seperator argument to treat hard breaks like spaces.
newState.doc
.textBetween(newRange.from, newRange.to, ' ', ' ')
.endsWith(' ')
Expand Down Expand Up @@ -128,22 +129,46 @@ export function autolink(options: AutolinkOptions): Plugin {
return false
}

find(lastWordBeforeSpace)
const linksBeforeSpace = tokenize(lastWordBeforeSpace).map((t) =>
t.toObject(options.defaultProtocol),
)

if (!isValidLinkStructure(linksBeforeSpace)) {
return false
}

linksBeforeSpace
.filter((link) => link.isLink)
.filter((link) => {
if (options.validate) {
return options.validate(link.value)
}
return true
})
// calculate link position
// Calculate link position.
.map((link) => ({
...link,
from: lastWordAndBlockOffset + link.start + 1,
to: lastWordAndBlockOffset + link.end + 1,
}))
// add link mark
// ignore link inside code mark
.filter((link) => {
if (!newState.schema.marks.code) {
return true
}

return !newState.doc.rangeHasMark(
link.from,
link.to,
newState.schema.marks.code,
)
})
// validate link
.filter((link) => options.validate(link.value))
// Add link mark.
.forEach((link) => {
if (
getMarksBetween(link.from, link.to, newState.doc).some(
(item) => item.mark.type === options.type,
)
) {
return
}

tr.addMark(
link.from,
link.to,
Expand All @@ -155,7 +180,7 @@ export function autolink(options: AutolinkOptions): Plugin {
}
})

if (tr.steps.length === 0) {
if (!tr.steps.length) {
return
}

Expand Down
24 changes: 18 additions & 6 deletions src/editors/extensions/link/helpers/clickHandler.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { getAttributes } from '@tiptap/core'
import { type MarkType } from '@tiptap/pm/model'
import { MarkType } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'

interface ClickHandlerOptions {
type ClickHandlerOptions = {
type: MarkType
}

Expand All @@ -11,15 +11,27 @@ export function clickHandler(options: ClickHandlerOptions): Plugin {
key: new PluginKey('handleClickLink'),
props: {
handleClick: (view, pos, event) => {
if (event.button !== 1) {
if (event.button !== 0) {
return false
}

let a = event.target as HTMLElement
const els = []

while (a.nodeName !== 'DIV') {
els.push(a)
a = a.parentNode as HTMLElement
}

if (!els.find((value) => value.nodeName === 'A')) {
return false
}

const attrs = getAttributes(view.state, options.type.name)
const link = (event.target as HTMLElement)?.closest('a')
const link = event.target as HTMLLinkElement

const href = link?.href ?? (attrs.href as string)
const target = link?.target ?? (attrs.target as string)
const href = link?.href ?? attrs.href
const target = link?.target ?? attrs.target

if (link && href) {
window.open(href, target)
Expand Down
13 changes: 7 additions & 6 deletions src/editors/extensions/link/helpers/pasteHandler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { type Editor } from '@tiptap/core'
import { type MarkType } from '@tiptap/pm/model'
import { Editor } from '@tiptap/core'
import { MarkType } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { find } from 'linkifyjs'

interface PasteHandlerOptions {
type PasteHandlerOptions = {
editor: Editor
defaultProtocol: string
type: MarkType
}

Expand All @@ -27,9 +28,9 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin {
textContent += node.textContent
})

const link = find(textContent).find(
(item) => item.isLink && item.value === textContent,
)
const link = find(textContent, {
defaultProtocol: options.defaultProtocol,
}).find((item) => item.isLink && item.value === textContent)

if (!textContent || !link) {
return false
Expand Down
Loading

0 comments on commit a8b5efd

Please sign in to comment.