Rewrite the link detection (#3687)
* Rewrite the link detection * Handle parens and colonszio/stable
parent
b3df0b177f
commit
8ec3d8c76e
|
@ -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,
|
|
||||||
mayBePaste,
|
|
||||||
onNewLink,
|
|
||||||
prevAddedLinks: prevAddedLinks.current,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const suggestedUri = suggestLinkCardUri(
|
||||||
for (const uri of prevAddedLinks.current.keys()) {
|
mayBePaste,
|
||||||
if (findIndexInText(uri, newText) === -1) {
|
nextDetectedUris,
|
||||||
prevAddedLinks.current.delete(uri)
|
prevDetectedUris.current,
|
||||||
}
|
pastSuggestedUris.current,
|
||||||
|
)
|
||||||
|
prevDetectedUris.current = nextDetectedUris
|
||||||
|
if (suggestedUri) {
|
||||||
|
onNewLink(suggestedUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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.
|
export function suggestLinkCardUri(
|
||||||
// Link card added -> Remove link card -> Press back space -> Press space -> Link card added -> and so on
|
mayBePaste: boolean,
|
||||||
|
nextDetectedUris: Map<string, LinkFacetMatch>,
|
||||||
// We use the isValidUrl regex below because we don't want to add embeds only if the url is valid, i.e.
|
prevDetectedUris: Map<string, LinkFacetMatch>,
|
||||||
// http://facebook is a valid url, but that doesn't mean we want to embed it. We should only embed if
|
pastSuggestedUris: Set<string>,
|
||||||
// the url is a valid url _and_ domain. new URL() won't work for this check.
|
): string | undefined {
|
||||||
const shouldCheck =
|
const suggestedUris = new Set<string>()
|
||||||
cursorLocationIsOkay && !prevAddedLinks.has(uri) && isValidUrlAndDomain(uri)
|
for (const [uri, nextMatch] of nextDetectedUris) {
|
||||||
|
if (!isValidUrlAndDomain(uri)) {
|
||||||
if (shouldCheck) {
|
continue
|
||||||
onNewLink(uri)
|
}
|
||||||
prevAddedLinks.add(uri)
|
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
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue