From a6e2dcdbb1929b170789da430e745bc9ba6905b5 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Tue, 28 Jul 2026 18:26:07 +0200 Subject: [PATCH] Add `SyncAutolink` extension: automatically update or remove link marks in editors when URLs are edited; integrate into message editors, bios, and comments for consistent link behavior. --- web/components/bio/editable-bio.tsx | 3 +- web/components/comments/comment-input.tsx | 3 +- web/components/contact.tsx | 3 +- web/components/editor/autolink.ts | 157 +++++++++++++++++++++ web/components/profile/connect-actions.tsx | 33 +++-- web/components/widgets/editor.tsx | 50 +------ 6 files changed, 184 insertions(+), 65 deletions(-) create mode 100644 web/components/editor/autolink.ts diff --git a/web/components/bio/editable-bio.tsx b/web/components/bio/editable-bio.tsx index 88646ae2..bdcf0c84 100644 --- a/web/components/bio/editable-bio.tsx +++ b/web/components/bio/editable-bio.tsx @@ -9,9 +9,10 @@ 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 {Col} from 'web/components/layout/col' import {Row} from 'web/components/layout/row' -import {linkifyTrailingUrl, TextEditor, useTextEditor} from 'web/components/widgets/editor' +import {TextEditor, useTextEditor} from 'web/components/widgets/editor' import {NewTabLink} from 'web/components/widgets/new-tab-link' import {ShowMore} from 'web/components/widgets/show-more' import {updateProfile} from 'web/lib/api' diff --git a/web/components/comments/comment-input.tsx b/web/components/comments/comment-input.tsx index ca83d8a7..e58cd26c 100644 --- a/web/components/comments/comment-input.tsx +++ b/web/components/comments/comment-input.tsx @@ -14,9 +14,10 @@ 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 {Row} from '../layout/row' import {Avatar} from '../widgets/avatar' -import {linkifyTrailingUrl, TextEditor, useTextEditor} from '../widgets/editor' +import {TextEditor, useTextEditor} from '../widgets/editor' import {LoadingIndicator} from '../widgets/loading-indicator' export function CommentInput(props: { diff --git a/web/components/contact.tsx b/web/components/contact.tsx index 20730ee9..9e784774 100644 --- a/web/components/contact.tsx +++ b/web/components/contact.tsx @@ -5,9 +5,10 @@ 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 {Col} from 'web/components/layout/col' import {Row} from 'web/components/layout/row' -import {linkifyTrailingUrl, TextEditor, useTextEditor} from 'web/components/widgets/editor' +import {TextEditor, useTextEditor} from 'web/components/widgets/editor' import {Title} from 'web/components/widgets/title' import {useUser} from 'web/hooks/use-user' import {api} from 'web/lib/api' diff --git a/web/components/editor/autolink.ts b/web/components/editor/autolink.ts new file mode 100644 index 00000000..96965226 --- /dev/null +++ b/web/components/editor/autolink.ts @@ -0,0 +1,157 @@ +import { + combineTransactionSteps, + type Editor, + Extension, + getChangedRanges, + getMarkRange, +} from '@tiptap/core' +import type {Mark, MarkType, Node as PMNode} from '@tiptap/pm/model' +import {Plugin, PluginKey} from '@tiptap/pm/state' +import {tokenize} from 'linkifyjs' + +/** Protocol assumed for bare hosts like `example.com`. Matches what `Link` is configured with. */ +export const DEFAULT_PROTOCOL = 'http' + +/** + * The href a piece of text would get if it were autolinked, or undefined if it isn't a link. + * + * Tokenizing the whole string (rather than searching it for links) is what rejects `example.com1`: + * the text only counts as a link if the link is the *entire* string. Same call the autolink plugin + * makes, so anything typed and anything synced here agree on what a URL is. + */ +const hrefOf = (text: string) => { + const tokens = tokenize(text) + if (tokens.length !== 1 || !tokens[0].isLink) return undefined + return tokens[0].toObject(DEFAULT_PROTOCOL).href +} + +/** + * Link a URL sitting at the very end of the doc. + * + * 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. + */ +export const linkifyTrailingUrl = (editor: Editor) => { + const linkType = editor.state.schema.marks.link + if (!linkType) return + + const {doc} = editor.state + let block: {node: PMNode; pos: number} | undefined + doc.descendants((node, pos) => { + if (!node.isTextblock) return true + block = {node, pos} + 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 + + const href = hrefOf(lastWord) + if (!href) 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}))) +} + +/** Every distinct link-marked run of text overlapping [from, to], each expanded to its full extent. */ +const linkRangesIn = (doc: PMNode, from: number, to: number, type: MarkType) => { + const ranges: {from: number; to: number; mark: Mark}[] = [] + doc.nodesBetween(from, to, (node, pos) => { + const mark = node.marks.find((m) => m.type === type) + if (!node.isText || !mark) return + // Expand: the run can start before `from` and end after `to` (editing the middle of a link only + // reports the edited characters as changed). + const range = getMarkRange(doc.resolve(pos), type, mark.attrs) + if (!range || ranges.some((r) => r.from === range.from)) return + ranges.push({...range, mark}) + }) + return ranges +} + +/** + * Keep an autolinked URL's href in sync with its text. + * + * TipTap links the text once, on the keystroke that completes the URL, and then never looks at it + * again — so correcting a typo in an already-linked URL leaves the anchor pointing at the *old* + * address, silently. That's worse than not linking at all: what you click is not what you read. + * + * Neither TipTap v2 nor v3 does this, so the plugin is ours. On every doc change it re-derives the + * href of each edited link and, when the text no longer says what the href says, either updates the + * href or drops the mark (the text stopped being a URL at all). + * + * Links whose text is a *label* rather than a URL — `[click here](…)`, set by hand from the format + * menu — are left alone. They're recognised by the href not matching what the text linkifies to, + * checked against the doc as it was *before* the edit, since after the edit that's exactly the + * mismatch we're here to fix. + */ +export const SyncAutolink = Extension.create({ + name: 'syncAutolink', + + addProseMirrorPlugins() { + const linkType = this.editor.schema.marks.link + if (!linkType) return [] + + return [ + new Plugin({ + key: new PluginKey('syncAutolink'), + appendTransaction: (transactions, oldState, newState) => { + const docChanged = + transactions.some((transaction) => transaction.docChanged) && + !oldState.doc.eq(newState.doc) + // Same escape hatch the autolink plugin honours. + const prevented = transactions.some((transaction) => + transaction.getMeta('preventAutolink'), + ) + if (!docChanged || prevented) return + + const {tr} = newState + const transform = combineTransactionSteps(oldState.doc, [...transactions]) + const handled = new Set() + + getChangedRanges(transform).forEach(({oldRange}) => { + // Widened by one on each side: a pure insertion reports a zero-length range sitting + // *between* two text nodes, which on its own matches neither of them — including the + // case that matters most, typing more characters onto the end of a link. + const from = Math.max(0, oldRange.from - 1) + const to = Math.min(oldState.doc.content.size, oldRange.to + 1) + + linkRangesIn(oldState.doc, from, to, linkType).forEach((old) => { + const oldText = oldState.doc.textBetween(old.from, old.to) + if (old.mark.attrs.href !== hrefOf(oldText)) return // a hand-written label, not a URL + + // The edit may have grown or shrunk the run, so re-read it from the new doc rather + // than trusting the mapped endpoints. + const pos = transform.mapping.map(old.from, 1) + if (pos > newState.doc.content.size) return + const range = getMarkRange(newState.doc.resolve(pos), linkType, old.mark.attrs) + if (!range || handled.has(range.from)) return + handled.add(range.from) + + const text = newState.doc.textBetween(range.from, range.to) + const href = hrefOf(text) + if (href === old.mark.attrs.href) return + + tr.removeMark(range.from, range.to, linkType) + // No href means the text isn't a URL any more (`example.com` → `example`), so the + // mark goes with it. + if (href) tr.addMark(range.from, range.to, linkType.create({...old.mark.attrs, href})) + }) + }) + + return tr.steps.length ? tr : undefined + }, + }), + ] + }, +}) diff --git a/web/components/profile/connect-actions.tsx b/web/components/profile/connect-actions.tsx index b845a6e2..2bb38c82 100644 --- a/web/components/profile/connect-actions.tsx +++ b/web/components/profile/connect-actions.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx' import {INVERTED_RELATIONSHIP_CHOICES, RELATIONSHIP_CHOICES} from 'common/choices' import {Profile} from 'common/profiles/profile' -import {CheckIcon, InfoIcon, SparklesIcon} from 'lucide-react' +import {CheckIcon, SparklesIcon} from 'lucide-react' import {useEffect, useState} from 'react' import toast from 'react-hot-toast' import ReactMarkdown from 'react-markdown' @@ -39,6 +39,8 @@ export function ConnectActions(props: {profile: Profile; user: User}) { - You’re both notified. `, ) + // The toggle that sets this is commented out above; kept so re-enabling it is one edit. + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [showHelp, setShowHelp] = useState(false) const loadPreferences = async () => { @@ -98,7 +100,9 @@ export function ConnectActions(props: {profile: Profile; user: User}) { ) : (

@@ -118,19 +122,19 @@ export function ConnectActions(props: {profile: Profile; user: User}) { className="text-ink-400 font-dm-sans uppercase" style={{fontSize: '10px', letterSpacing: '0.18em'}} > - {t('profile.connect.private_connection_signal', 'Private connection signal')} + {t('profile.connect.private_connection_signal', 'Signal your interest — privately')} {/* Explaining the mechanism is only worth the row when the mechanism is available. */} - {hasSignalControls && ( - - )} + {/*{hasSignalControls && (*/} + {/* setShowHelp(!showHelp)}*/} + {/* aria-expanded={showHelp}*/} + {/* >*/} + {/* */} + {/* {t('profile.connect.how_this_works', 'How this works')}*/} + {/* */} + {/*)}*/} {profile.allow_interest_indicating ? ( @@ -139,7 +143,8 @@ export function ConnectActions(props: {profile: Profile; user: User}) {

{t( 'profile.wont_be_notified_unless_mutual', - 'They won’t be notified unless the interest is mutual.', + "Pick what you'd be open to with {name}. He is never notified unless he picks the same thing — then you both hear about it at once.", + {name: shortenName(user.name)}, )}

)} diff --git a/web/components/widgets/editor.tsx b/web/components/widgets/editor.tsx index 40bb4c0a..5142864e 100644 --- a/web/components/widgets/editor.tsx +++ b/web/components/widgets/editor.tsx @@ -6,7 +6,6 @@ import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' import TableRow from '@tiptap/extension-table-row' import Underline from '@tiptap/extension-underline' -import type {Node as PMNode} from '@tiptap/pm/model' import {TextSelection} from '@tiptap/pm/state' import type {Content, JSONContent} from '@tiptap/react' import {Editor, EditorContent, Extensions, mergeAttributes, useEditor} from '@tiptap/react' @@ -14,7 +13,6 @@ import StarterKit from '@tiptap/starter-kit' import clsx from 'clsx' import {richTextToString} from 'common/util/parse' import Iframe from 'common/util/tiptap-iframe' -import {tokenize} from 'linkifyjs' import {debounce} from 'lodash' import {createElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react' import {CustomLink} from 'web/components/links' @@ -22,6 +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 {EmojiExtension} from '../editor/emoji/emoji-extension' import {FloatingFormatMenu} from '../editor/floating-format-menu' import {BasicImage, DisplayImage} from '../editor/image' @@ -33,9 +32,6 @@ import {BasicVideo, DisplayVideo} from '../editor/video' import {Linkify} from './linkify' import {linkClass} from './site-link' -/** Protocol assumed for bare hosts like `example.com`. Shared with `linkifyTrailingUrl` below. */ -const DEFAULT_PROTOCOL = 'http' - const DisplayLink = Link.extend({ renderHTML({HTMLAttributes}) { HTMLAttributes.target = HTMLAttributes.href.includes('manifold.markets') ? '_self' : '_blank' @@ -51,49 +47,6 @@ const DisplayLink = Link.extend({ }, }) -/** - * Link a URL sitting at the very end of the doc. - * - * 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 tokenize/validate rules as the autolink - * plugin, so the result is identical to what you'd get by typing a trailing space. - */ -export const linkifyTrailingUrl = (editor: Editor) => { - const linkType = editor.state.schema.marks.link - if (!linkType) return - - const {doc} = editor.state - let block: {node: PMNode; pos: number} | undefined - doc.descendants((node, pos) => { - if (!node.isTextblock) return true - block = {node, pos} - 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 - - // Tokenizing the whole word (rather than searching it for links) is what rejects `example.com1`: - // a link is only valid if it is the entire word, optionally wrapped in `()` or `[]`. - const tokens = tokenize(lastWord) - if (tokens.length !== 1 || !tokens[0].isLink) return // e.g. `example.com1` — not a whole-word link - const link = tokens[0].toObject(DEFAULT_PROTOCOL) - - // `text` starts at the block's first content position, one past the block node itself. - const wordStart = block.pos + text.lastIndexOf(lastWord) + 1 - const from = wordStart + link.start - const to = wordStart + link.end - 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: link.href}))) -} - const editorExtensions = (simple = false): Extensions => nodeViewMiddleware([ StarterKit.configure({ @@ -104,6 +57,7 @@ const editorExtensions = (simple = false): Extensions => simple ? DisplayVideo : BasicVideo, EmojiExtension, DisplayLink, + SyncAutolink, DisplayMention, Iframe, Upload,