Replace linkifyTrailingUrl with linkifyUrls across components for improved URL handling and consistency.

This commit is contained in:
MartinBraquet
2026-08-01 03:51:23 +02:00
parent 7c25245e6f
commit 292ba01383
7 changed files with 85 additions and 39 deletions

View File

@@ -9,7 +9,7 @@ import {useRouter} from 'next/router'
import {useEffect, useState} from 'react'
import ReactMarkdown from 'react-markdown'
import {Button} from 'web/components/buttons/button'
import {linkifyTrailingUrl} from 'web/components/editor/autolink'
import {linkifyUrls} from 'web/components/editor/autolink'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {TextEditor, useTextEditor} from 'web/components/widgets/editor'
@@ -77,7 +77,7 @@ export function EditableBio(props: {profile: Profile; onSave: () => void; onCanc
const saveBio = async () => {
if (!editor) return
linkifyTrailingUrl(editor)
linkifyUrls(editor)
// console.log(editor.getText().length)
const {error} = await tryCatch(
updateProfile({

View File

@@ -14,7 +14,7 @@ import {useT} from 'web/lib/locale'
import {track} from 'web/lib/service/analytics'
import {safeLocalStorage} from 'web/lib/util/local'
import {linkifyTrailingUrl} from '../editor/autolink'
import {linkifyUrls} from '../editor/autolink'
import {Row} from '../layout/row'
import {Avatar} from '../widgets/avatar'
import {TextEditor, useTextEditor} from '../widgets/editor'
@@ -156,7 +156,7 @@ export function CommentInputTextArea(props: {
// through this, so a message ending in a URL gets that URL linked whichever one you used. TipTap's
// autolink can't do it itself: it only fires once you type a separator *after* the URL.
const submitWithTrailingLink = useEvent((type: CommentType) => {
if (editor) linkifyTrailingUrl(editor)
if (editor) linkifyUrls(editor)
submit?.(type)
})

View File

@@ -5,7 +5,7 @@ import {MAX_DESCRIPTION_LENGTH} from 'common/envs/constants'
import Link from 'next/link'
import toast from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {linkifyTrailingUrl} from 'web/components/editor/autolink'
import {linkifyUrls} from 'web/components/editor/autolink'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {TextEditor, useTextEditor} from 'web/components/widgets/editor'
@@ -55,7 +55,7 @@ export function ContactComponent() {
size="xs"
onClick={async () => {
if (!editor) return
linkifyTrailingUrl(editor)
linkifyUrls(editor)
const data = {
content: editor.getJSON() as JSONContent,
userId: user?.id,

View File

@@ -6,7 +6,7 @@ import {
getMarkRange,
} from '@tiptap/core'
import type {Mark, MarkType, Node as PMNode} from '@tiptap/pm/model'
import {Plugin, PluginKey} from '@tiptap/pm/state'
import {Plugin, PluginKey, type Transaction} from '@tiptap/pm/state'
import {tokenize} from 'linkifyjs'
/** Protocol assumed for bare hosts like `example.com`. Matches what `Link` is configured with. */
@@ -26,42 +26,57 @@ const hrefOf = (text: string) => {
}
/**
* Link a URL sitting at the very end of the doc.
* Add link marks to every bare URL in the doc that doesn't have one yet, writing into `tr`.
*
* TipTap's autolink only fires once a separator is typed *after* a URL (a space, or the enter that
* splits the block), so a message whose last word is a URL is submitted as plain text. Call this
* right before reading the content out of the editor — same rules as the autolink plugin, so the
* result is identical to what you'd get by typing a trailing space.
* Returns whether anything was marked. Shared by the on-send pass and the on-paste plugin so both
* agree, to the character, on what counts as a URL.
*/
export const linkifyTrailingUrl = (editor: Editor) => {
const linkType = editor.state.schema.marks.link
if (!linkType) return
const addLinkMarks = (doc: PMNode, tr: Transaction, linkType: MarkType, codeType?: MarkType) => {
let changed = false
const {doc} = editor.state
let block: {node: PMNode; pos: number} | undefined
doc.descendants((node, pos) => {
if (!node.isTextblock) return true
block = {node, pos}
if (!node.isTextblock) return true // a list item / table cell / blockquote: keep descending
if (node.type.spec.code) return false // a code block is literal text, not prose
// The block's first content position, one past the block node itself. Leaf nodes (hard breaks,
// mentions, emoji) are read as a single space, which is both what the autolink plugin does and
// what keeps string offsets lined up one-to-one with document positions.
const start = pos + 1
const text = doc.textBetween(start, pos + node.nodeSize - 1, undefined, ' ')
tokenize(text).forEach((token) => {
if (!token.isLink) return
const from = start + token.startIndex()
const to = start + token.endIndex()
if (doc.rangeHasMark(from, to, linkType)) return // already linked, by hand or by autolink
if (codeType && doc.rangeHasMark(from, to, codeType)) return
tr.addMark(from, to, linkType.create({href: token.toObject(DEFAULT_PROTOCOL).href}))
changed = true
})
return false // no textblocks nested inside a textblock
})
if (!block) return
// Hard breaks count as spaces, matching how the autolink plugin reads a block.
const text = doc.textBetween(block.pos, block.pos + block.node.nodeSize, undefined, ' ')
const lastWord = text.split(' ').filter(Boolean).pop()
if (!lastWord || !text.endsWith(lastWord)) return // trailing space: autolink already had its turn
return changed
}
const href = hrefOf(lastWord)
if (!href) return
/**
* Link every bare URL in the doc, including protocol-less ones like `compassmeet.com/heartborne`.
*
* TipTap's autolink only fires on the keystroke *after* a URL (a space, or the enter that splits the
* block), so a message whose last word is a URL is submitted as plain text — and text arriving any
* way other than typing (paste, programmatic insert) is never seen by it at all. Call this right
* before reading the content out of the editor; it uses the same rules as the autolink plugin, so
* the result is what you'd have got by typing every URL and following it with a space.
*/
export const linkifyUrls = (editor: Editor) => {
const {schema, tr, doc} = editor.state
const linkType = schema.marks.link
if (!linkType) return
// `text` starts at the block's first content position, one past the block node itself.
const from = block.pos + text.lastIndexOf(lastWord) + 1
const to = from + lastWord.length
const {code} = editor.state.schema.marks
if (doc.rangeHasMark(from, to, linkType)) return
if (code && doc.rangeHasMark(from, to, code)) return
editor.view.dispatch(editor.state.tr.addMark(from, to, linkType.create({href})))
if (addLinkMarks(doc, tr, linkType, schema.marks.code)) {
editor.view.dispatch(tr.setMeta('preventAutolink', true))
}
}
/** Every distinct link-marked run of text overlapping [from, to], each expanded to its full extent. */
@@ -152,6 +167,34 @@ export const SyncAutolink = Extension.create({
return tr.steps.length ? tr : undefined
},
}),
/**
* Linkify pasted URLs.
*
* TipTap's own paste handling only links a URL pasted *over a selection*; a plain paste is left
* to the autolink plugin, which never sees it because it only reacts to typed separators. So
* pasting a list of links yields a list of plain text. Re-scanning the whole doc after a paste
* is cheap at the sizes this editor holds (a message, a bio) and, unlike scanning the pasted
* slice, it also catches a URL completed by the paste landing next to existing text.
*/
new Plugin({
key: new PluginKey('linkifyOnPaste'),
appendTransaction: (transactions, _oldState, newState) => {
const pasted = transactions.some(
(transaction) =>
transaction.docChanged &&
(transaction.getMeta('paste') || transaction.getMeta('uiEvent') === 'drop'),
)
const prevented = transactions.some((transaction) =>
transaction.getMeta('preventAutolink'),
)
if (!pasted || prevented) return
const {tr} = newState
const {code} = newState.schema.marks
return addLinkMarks(newState.doc, tr, linkType, code) ? tr : undefined
},
}),
]
},
})

View File

@@ -10,7 +10,7 @@ import React, {useEffect, useRef, useState} from 'react'
import {BiEnvelope} from 'react-icons/bi'
import {Button, buttonClass} from 'web/components/buttons/button'
import {CommentInputTextArea} from 'web/components/comments/comment-input'
import {linkifyTrailingUrl} from 'web/components/editor/autolink'
import {linkifyUrls} from 'web/components/editor/autolink'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
@@ -146,7 +146,7 @@ export const SendMessageButton = (props: {
})
if (!res) return
linkifyTrailingUrl(editor)
linkifyUrls(editor)
const msgRes = await api('create-private-user-message', {
channelId: res.channelId,
content: editor.getJSON(),

View File

@@ -9,7 +9,7 @@ import Link from 'next/link'
import {useEffect, useMemo, useState} from 'react'
import toast from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {linkifyTrailingUrl} from 'web/components/editor/autolink'
import {linkifyUrls} from 'web/components/editor/autolink'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {EnglishOnlyWarning} from 'web/components/news/english-only-warning'
@@ -167,7 +167,7 @@ export function VoteComponent() {
size="sm"
color="cta"
onClick={async () => {
linkifyTrailingUrl(editor)
linkifyUrls(editor)
const data = {
title: title,
description: editor.getJSON() as JSONContent,

View File

@@ -20,7 +20,7 @@ import {MediaModal} from 'web/components/media-modal'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {safeLocalStorage} from 'web/lib/util/local'
import {DEFAULT_PROTOCOL, SyncAutolink} from '../editor/autolink'
import {DEFAULT_PROTOCOL, linkifyUrls, SyncAutolink} from '../editor/autolink'
import {EMOJI_ENABLED} from '../editor/emoji/emoji-enabled'
import {EmojiExtension} from '../editor/emoji/emoji-extension'
import {FloatingFormatMenu} from '../editor/floating-format-menu'
@@ -207,6 +207,9 @@ export function useTextEditor(props: {
if (available > 0 && text.length > 0) {
const croppedText = text.slice(0, available)
editor.commands.insertContent(croppedText)
// This insert is a command, not a paste transaction, so the on-paste plugin doesn't see
// it. Link its URLs here instead.
linkifyUrls(editor)
}
return true
}