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

Correct linkify #488

Merged
merged 3 commits into from
Jun 14, 2024
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
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 = {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

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
Loading