mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
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.
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
157
web/components/editor/autolink.ts
Normal file
157
web/components/editor/autolink.ts
Normal file
@@ -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<number>()
|
||||
|
||||
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
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -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<boolean>(false)
|
||||
|
||||
const loadPreferences = async () => {
|
||||
@@ -98,7 +100,9 @@ export function ConnectActions(props: {profile: Profile; user: User}) {
|
||||
<SendMessageButton
|
||||
toUser={user}
|
||||
profile={profile}
|
||||
text={t('messaging.send_thoughtful_message', 'Send them a thoughtful message')}
|
||||
text={t('profile.connect.message_name', 'Message {name}', {
|
||||
name: shortenName(user.name),
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-ink-500 text-sm">
|
||||
@@ -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')}
|
||||
</h3>
|
||||
{/* Explaining the mechanism is only worth the row when the mechanism is available. */}
|
||||
{hasSignalControls && (
|
||||
<button
|
||||
className="text-ink-500 hover:text-primary-600 flex flex-none items-center gap-1 text-xs transition-colors"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
aria-expanded={showHelp}
|
||||
>
|
||||
<InfoIcon className="h-3.5 w-3.5" />
|
||||
{t('profile.connect.how_this_works', 'How this works')}
|
||||
</button>
|
||||
)}
|
||||
{/*{hasSignalControls && (*/}
|
||||
{/* <button*/}
|
||||
{/* className="text-ink-500 hover:text-primary-600 flex flex-none items-center gap-1 text-xs transition-colors"*/}
|
||||
{/* onClick={() => setShowHelp(!showHelp)}*/}
|
||||
{/* aria-expanded={showHelp}*/}
|
||||
{/* >*/}
|
||||
{/* <InfoIcon className="h-3.5 w-3.5" />*/}
|
||||
{/* {t('profile.connect.how_this_works', 'How this works')}*/}
|
||||
{/* </button>*/}
|
||||
{/*)}*/}
|
||||
</Row>
|
||||
|
||||
{profile.allow_interest_indicating ? (
|
||||
@@ -139,7 +143,8 @@ export function ConnectActions(props: {profile: Profile; user: User}) {
|
||||
<p className="text-ink-500 mt-2 text-sm">
|
||||
{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)},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user