Rewrite the link detection (#3687)

* Rewrite the link detection

* Handle parens and colons
zio/stable
dan 2024-04-24 17:30:44 +01:00 committed by GitHub
parent b3df0b177f
commit 8ec3d8c76e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 111 additions and 88 deletions

View File

@ -28,8 +28,8 @@ import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {isIOS} from 'platform/detection' import {isIOS} from 'platform/detection'
import { import {
addLinkCardIfNecessary, LinkFacetMatch,
findIndexInText, suggestLinkCardUri,
} from 'view/com/composer/text-input/text-input-util' } from 'view/com/composer/text-input/text-input-util'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {Autocomplete} from './mobile/Autocomplete' import {Autocomplete} from './mobile/Autocomplete'
@ -73,7 +73,6 @@ export const TextInput = forwardRef(function TextInputImpl(
const theme = useTheme() const theme = useTheme()
const [autocompletePrefix, setAutocompletePrefix] = useState('') const [autocompletePrefix, setAutocompletePrefix] = useState('')
const prevLength = React.useRef(richtext.length) const prevLength = React.useRef(richtext.length)
const prevAddedLinks = useRef(new Set<string>())
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
focus: () => textInput.current?.focus(), focus: () => textInput.current?.focus(),
@ -83,6 +82,8 @@ export const TextInput = forwardRef(function TextInputImpl(
getCursorPosition: () => undefined, // Not implemented on native getCursorPosition: () => undefined, // Not implemented on native
})) }))
const pastSuggestedUris = useRef(new Set<string>())
const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
const onChangeText = useCallback( const onChangeText = useCallback(
(newText: string) => { (newText: string) => {
/* /*
@ -112,6 +113,7 @@ export const TextInput = forwardRef(function TextInputImpl(
setAutocompletePrefix('') setAutocompletePrefix('')
} }
const nextDetectedUris = new Map<string, LinkFacetMatch>()
if (newRt.facets) { if (newRt.facets) {
for (const facet of newRt.facets) { for (const facet of newRt.facets) {
for (const feature of facet.features) { for (const feature of facet.features) {
@ -130,32 +132,26 @@ export const TextInput = forwardRef(function TextInputImpl(
onPhotoPasted(res.path) onPhotoPasted(res.path)
} }
} else { } else {
const cursorLocation = textInputSelection.current.end nextDetectedUris.set(feature.uri, {facet, rt: newRt})
}
addLinkCardIfNecessary({ }
uri: feature.uri, }
newText, }
cursorLocation, }
const suggestedUri = suggestLinkCardUri(
mayBePaste, mayBePaste,
onNewLink, nextDetectedUris,
prevAddedLinks: prevAddedLinks.current, prevDetectedUris.current,
}) pastSuggestedUris.current,
)
prevDetectedUris.current = nextDetectedUris
if (suggestedUri) {
onNewLink(suggestedUri)
} }
}
}
}
}
for (const uri of prevAddedLinks.current.keys()) {
if (findIndexInText(uri, newText) === -1) {
prevAddedLinks.current.delete(uri)
}
}
prevLength.current = newText.length prevLength.current = newText.length
}, 1) }, 1)
}, },
[setRichText, autocompletePrefix, onPhotoPasted, prevAddedLinks, onNewLink], [setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
) )
const onPaste = useCallback( const onPaste = useCallback(

View File

@ -19,8 +19,8 @@ import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {blobToDataUri, isUriImage} from 'lib/media/util' import {blobToDataUri, isUriImage} from 'lib/media/util'
import { import {
addLinkCardIfNecessary, LinkFacetMatch,
findIndexInText, suggestLinkCardUri,
} from 'view/com/composer/text-input/text-input-util' } from 'view/com/composer/text-input/text-input-util'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {Text} from '../../util/text/Text' import {Text} from '../../util/text/Text'
@ -61,8 +61,6 @@ export const TextInput = React.forwardRef(function TextInputImpl(
ref, ref,
) { ) {
const autocomplete = useActorAutocompleteFn() const autocomplete = useActorAutocompleteFn()
const prevAddedLinks = useRef(new Set<string>())
const pal = usePalette('default') const pal = usePalette('default')
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
@ -143,6 +141,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
} }
}, [setIsDropping]) }, [setIsDropping])
const pastSuggestedUris = useRef(new Set<string>())
const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
const editor = useEditor( const editor = useEditor(
{ {
extensions, extensions,
@ -185,38 +185,32 @@ export const TextInput = React.forwardRef(function TextInputImpl(
onUpdate({editor: editorProp}) { onUpdate({editor: editorProp}) {
const json = editorProp.getJSON() const json = editorProp.getJSON()
const newText = editorJsonToText(json) const newText = editorJsonToText(json)
const mayBePaste = window.event?.type === 'paste' const isPaste = window.event?.type === 'paste'
const newRt = new RichText({text: newText}) const newRt = new RichText({text: newText})
newRt.detectFacetsWithoutResolution() newRt.detectFacetsWithoutResolution()
setRichText(newRt) setRichText(newRt)
const nextDetectedUris = new Map<string, LinkFacetMatch>()
if (newRt.facets) { if (newRt.facets) {
for (const facet of newRt.facets) { for (const facet of newRt.facets) {
for (const feature of facet.features) { for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) { if (AppBskyRichtextFacet.isLink(feature)) {
// The TipTap editor shows the position as being one character ahead, as if the start index is 1. nextDetectedUris.set(feature.uri, {facet, rt: newRt})
// Subtracting 1 from the pos gives us the same behavior as the native impl.
let cursorLocation = editor?.state.selection.$anchor.pos ?? 1
cursorLocation -= 1
addLinkCardIfNecessary({
uri: feature.uri,
newText,
cursorLocation,
mayBePaste,
onNewLink,
prevAddedLinks: prevAddedLinks.current,
})
} }
} }
} }
} }
for (const uri of prevAddedLinks.current.keys()) { const suggestedUri = suggestLinkCardUri(
if (findIndexInText(uri, newText) === -1) { isPaste,
prevAddedLinks.current.delete(uri) nextDetectedUris,
} prevDetectedUris.current,
pastSuggestedUris.current,
)
prevDetectedUris.current = nextDetectedUris
if (suggestedUri) {
onNewLink(suggestedUri)
} }
}, },
}, },

View File

@ -1,41 +1,85 @@
export function addLinkCardIfNecessary({ import {AppBskyRichtextFacet, RichText} from '@atproto/api'
uri,
newText,
cursorLocation,
mayBePaste,
onNewLink,
prevAddedLinks,
}: {
uri: string
newText: string
cursorLocation: number
mayBePaste: boolean
onNewLink: (uri: string) => void
prevAddedLinks: Set<string>
}) {
// It would be cool if we could just use facet.index.byteEnd, but you know... *upside down smiley*
const lastCharacterPosition = findIndexInText(uri, newText) + uri.length
// If the text being added is not from a paste, then we should only check if the cursor is one export type LinkFacetMatch = {
// position ahead of the last character. However, if it is a paste we need to check both if it's rt: RichText
// the same position _or_ one position ahead. That is because iOS will add a space after a paste if facet: AppBskyRichtextFacet.Main
// pasting into the middle of a sentence!
const cursorLocationIsOkay =
cursorLocation === lastCharacterPosition + 1 || mayBePaste
// Checking previouslyAddedLinks keeps a card from getting added over and over i.e.
// Link card added -> Remove link card -> Press back space -> Press space -> Link card added -> and so on
// We use the isValidUrl regex below because we don't want to add embeds only if the url is valid, i.e.
// http://facebook is a valid url, but that doesn't mean we want to embed it. We should only embed if
// the url is a valid url _and_ domain. new URL() won't work for this check.
const shouldCheck =
cursorLocationIsOkay && !prevAddedLinks.has(uri) && isValidUrlAndDomain(uri)
if (shouldCheck) {
onNewLink(uri)
prevAddedLinks.add(uri)
} }
export function suggestLinkCardUri(
mayBePaste: boolean,
nextDetectedUris: Map<string, LinkFacetMatch>,
prevDetectedUris: Map<string, LinkFacetMatch>,
pastSuggestedUris: Set<string>,
): string | undefined {
const suggestedUris = new Set<string>()
for (const [uri, nextMatch] of nextDetectedUris) {
if (!isValidUrlAndDomain(uri)) {
continue
}
if (pastSuggestedUris.has(uri)) {
// Don't suggest already added or already dismissed link cards.
continue
}
if (mayBePaste) {
// Immediately add the pasted link without waiting to type more.
suggestedUris.add(uri)
continue
}
const prevMatch = prevDetectedUris.get(uri)
if (!prevMatch) {
// If the same exact link wasn't already detected during the last keystroke,
// it means you're probably still typing it. Disregard until it stabilizes.
continue
}
const prevTextAfterUri = prevMatch.rt.unicodeText.slice(
prevMatch.facet.index.byteEnd,
)
const nextTextAfterUri = nextMatch.rt.unicodeText.slice(
nextMatch.facet.index.byteEnd,
)
if (prevTextAfterUri === nextTextAfterUri) {
// The text you're editing is before the link, e.g.
// "abc google.com" -> "abcd google.com".
// This is a good time to add the link.
suggestedUris.add(uri)
continue
}
if (/^\s/m.test(nextTextAfterUri)) {
// The link is followed by a space, e.g.
// "google.com" -> "google.com " or
// "google.com." -> "google.com ".
// This is a clear indicator we can linkify it.
suggestedUris.add(uri)
continue
}
if (
/^[)]?[.,:;!?)](\s|$)/m.test(prevTextAfterUri) &&
/^[)]?[.,:;!?)]\s/m.test(nextTextAfterUri)
) {
// The link was *already* being followed by punctuation,
// and now it's followed both by punctuation and a space.
// This means you're typing after punctuation, e.g.
// "google.com." -> "google.com. " or
// "google.com.foo" -> "google.com. foo".
// This means you're not typing the link anymore, so we can linkify it.
suggestedUris.add(uri)
continue
}
}
for (const uri of pastSuggestedUris) {
if (!nextDetectedUris.has(uri)) {
// If a link is no longer detected, it's eligible for suggestions next time.
pastSuggestedUris.delete(uri)
}
}
let suggestedUri: string | undefined
if (suggestedUris.size > 0) {
suggestedUri = Array.from(suggestedUris)[0]
pastSuggestedUris.add(suggestedUri)
}
return suggestedUri
} }
// https://stackoverflow.com/questions/8667070/javascript-regular-expression-to-validate-url // https://stackoverflow.com/questions/8667070/javascript-regular-expression-to-validate-url
@ -46,14 +90,3 @@ function isValidUrlAndDomain(value: string) {
value, value,
) )
} }
export function findIndexInText(term: string, text: string) {
// This should find patterns like:
// HELLO SENTENCE http://google.com/ HELLO
// HELLO SENTENCE http://google.com HELLO
// http://google.com/ HELLO.
// http://google.com/.
const pattern = new RegExp(`\\b(${term})(?![/w])`, 'i')
const match = pattern.exec(text)
return match ? match.index : -1
}