mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-08-02 19:31:19 -04:00
Replace linkifyTrailingUrl with linkifyUrls across components for improved URL handling and consistency.
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user