diff --git a/web/components/bio/editable-bio.tsx b/web/components/bio/editable-bio.tsx index c278e5c8..a3287687 100644 --- a/web/components/bio/editable-bio.tsx +++ b/web/components/bio/editable-bio.tsx @@ -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({ diff --git a/web/components/comments/comment-input.tsx b/web/components/comments/comment-input.tsx index e58cd26c..9ce48056 100644 --- a/web/components/comments/comment-input.tsx +++ b/web/components/comments/comment-input.tsx @@ -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) }) diff --git a/web/components/contact.tsx b/web/components/contact.tsx index 9e784774..25430b62 100644 --- a/web/components/contact.tsx +++ b/web/components/contact.tsx @@ -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, diff --git a/web/components/editor/autolink.ts b/web/components/editor/autolink.ts index 96965226..0b239f12 100644 --- a/web/components/editor/autolink.ts +++ b/web/components/editor/autolink.ts @@ -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 + }, + }), ] }, }) diff --git a/web/components/messaging/send-message-button.tsx b/web/components/messaging/send-message-button.tsx index 2cc81867..1c85ff15 100644 --- a/web/components/messaging/send-message-button.tsx +++ b/web/components/messaging/send-message-button.tsx @@ -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(), diff --git a/web/components/votes/vote-info.tsx b/web/components/votes/vote-info.tsx index 4f95d61d..3e1acee7 100644 --- a/web/components/votes/vote-info.tsx +++ b/web/components/votes/vote-info.tsx @@ -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, diff --git a/web/components/widgets/editor.tsx b/web/components/widgets/editor.tsx index ebfa3397..85a4fab9 100644 --- a/web/components/widgets/editor.tsx +++ b/web/components/widgets/editor.tsx @@ -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 }