Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Replace web editor link behavior (#1319)

* Replace web editor link behavior (close #1293) (close #1292)

* Update link decorator to match rich text link detector

authored by

Paul Frazee and committed by
GitHub
cc283876 2c60a032

+127 -31
+2 -2
bskyweb/templates/base.html
··· 113 113 .ProseMirror .mention { 114 114 color: #0085ff; 115 115 } 116 - .ProseMirror a { 116 + .ProseMirror a, 117 + .ProseMirror .autolink { 117 118 color: #0085ff; 118 - cursor: pointer; 119 119 } 120 120 /* OLLIE: TODO -- this is not accessible */ 121 121 /* Remove focus state on inputs */
-1
package.json
··· 56 56 "@tiptap/extension-document": "^2.0.0-beta.220", 57 57 "@tiptap/extension-hard-break": "^2.0.3", 58 58 "@tiptap/extension-history": "^2.0.3", 59 - "@tiptap/extension-link": "^2.0.0-beta.220", 60 59 "@tiptap/extension-mention": "^2.0.0-beta.220", 61 60 "@tiptap/extension-paragraph": "^2.0.0-beta.220", 62 61 "@tiptap/extension-placeholder": "^2.0.0-beta.220",
+17 -26
src/view/com/composer/text-input/TextInput.web.tsx
··· 1 1 import React from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 - import {RichText} from '@atproto/api' 3 + import {RichText, AppBskyRichtextFacet} from '@atproto/api' 4 4 import EventEmitter from 'eventemitter3' 5 5 import {useEditor, EditorContent, JSONContent} from '@tiptap/react' 6 6 import {Document} from '@tiptap/extension-document' 7 7 import History from '@tiptap/extension-history' 8 8 import Hardbreak from '@tiptap/extension-hard-break' 9 - import {Link} from '@tiptap/extension-link' 10 9 import {Mention} from '@tiptap/extension-mention' 11 10 import {Paragraph} from '@tiptap/extension-paragraph' 12 11 import {Placeholder} from '@tiptap/extension-placeholder' ··· 17 16 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' 18 17 import {isUriImage, blobToDataUri} from 'lib/media/util' 19 18 import {Emoji} from './web/EmojiPicker.web' 19 + import {LinkDecorator} from './web/LinkDecorator' 20 20 21 21 export interface TextInputRef { 22 22 focus: () => void ··· 74 74 { 75 75 extensions: [ 76 76 Document, 77 - Link.configure({ 78 - protocols: ['http', 'https'], 79 - autolink: true, 80 - linkOnPaste: false, 81 - }), 77 + LinkDecorator, 82 78 Mention.configure({ 83 79 HTMLAttributes: { 84 80 class: 'mention', ··· 128 124 newRt.detectFacetsWithoutResolution() 129 125 setRichText(newRt) 130 126 131 - const newSuggestedLinks = new Set(editorJsonToLinks(json)) 132 - if (!isEqual(newSuggestedLinks, suggestedLinks)) { 133 - onSuggestedLinksChanged(newSuggestedLinks) 127 + const set: Set<string> = new Set() 128 + 129 + if (newRt.facets) { 130 + for (const facet of newRt.facets) { 131 + for (const feature of facet.features) { 132 + if (AppBskyRichtextFacet.isLink(feature)) { 133 + set.add(feature.uri) 134 + } 135 + } 136 + } 137 + } 138 + 139 + if (!isEqual(set, suggestedLinks)) { 140 + onSuggestedLinksChanged(set) 134 141 } 135 142 }, 136 143 }, ··· 235 242 type: 'doc', 236 243 content: docContent, 237 244 } 238 - } 239 - 240 - function editorJsonToLinks(json: JSONContent): string[] { 241 - let links: string[] = [] 242 - if (json.content?.length) { 243 - for (const node of json.content) { 244 - links = links.concat(editorJsonToLinks(node)) 245 - } 246 - } 247 - 248 - const link = json.marks?.find(m => m.type === 'link') 249 - if (link?.attrs?.href) { 250 - links.push(link.attrs.href) 251 - } 252 - 253 - return links 254 245 } 255 246 256 247 const styles = StyleSheet.create({
+106
src/view/com/composer/text-input/web/LinkDecorator.ts
··· 1 + /** 2 + * TipTap is a stateful rich-text editor, which is extremely useful 3 + * when you _want_ it to be stateful formatting such as bold and italics. 4 + * 5 + * However we also use "stateless" behaviors, specifically for URLs 6 + * where the text itself drives the formatting. 7 + * 8 + * This plugin uses a regex to detect URIs and then applies 9 + * link decorations (a <span> with the "autolink") class. That avoids 10 + * adding any stateful formatting to TipTap's document model. 11 + * 12 + * We then run the URI detection again when constructing the 13 + * RichText object from TipTap's output and merge their features into 14 + * the facet-set. 15 + */ 16 + 17 + import {Mark} from '@tiptap/core' 18 + import {Plugin, PluginKey} from '@tiptap/pm/state' 19 + import {findChildren} from '@tiptap/core' 20 + import {Node as ProsemirrorNode} from '@tiptap/pm/model' 21 + import {Decoration, DecorationSet} from '@tiptap/pm/view' 22 + import {isValidDomain} from 'lib/strings/url-helpers' 23 + 24 + export const LinkDecorator = Mark.create({ 25 + name: 'link-decorator', 26 + priority: 1000, 27 + keepOnSplit: false, 28 + inclusive() { 29 + return true 30 + }, 31 + addProseMirrorPlugins() { 32 + return [linkDecorator()] 33 + }, 34 + }) 35 + 36 + function getDecorations(doc: ProsemirrorNode) { 37 + const decorations: Decoration[] = [] 38 + 39 + findChildren(doc, node => node.type.name === 'paragraph').forEach( 40 + paragraphNode => { 41 + const textContent = paragraphNode.node.textContent 42 + 43 + // links 44 + iterateUris(textContent, (from, to) => { 45 + decorations.push( 46 + Decoration.inline(paragraphNode.pos + from, paragraphNode.pos + to, { 47 + class: 'autolink', 48 + }), 49 + ) 50 + }) 51 + }, 52 + ) 53 + 54 + return DecorationSet.create(doc, decorations) 55 + } 56 + 57 + function linkDecorator() { 58 + const linkDecoratorPlugin: Plugin = new Plugin({ 59 + key: new PluginKey('link-decorator'), 60 + 61 + state: { 62 + init: (_, {doc}) => getDecorations(doc), 63 + apply: (transaction, decorationSet) => { 64 + if (transaction.docChanged) { 65 + return getDecorations(transaction.doc) 66 + } 67 + return decorationSet.map(transaction.mapping, transaction.doc) 68 + }, 69 + }, 70 + 71 + props: { 72 + decorations(state) { 73 + return linkDecoratorPlugin.getState(state) 74 + }, 75 + }, 76 + }) 77 + return linkDecoratorPlugin 78 + } 79 + 80 + function iterateUris(str: string, cb: (from: number, to: number) => void) { 81 + let match 82 + const re = 83 + /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim 84 + while ((match = re.exec(str))) { 85 + let uri = match[2] 86 + if (!uri.startsWith('http')) { 87 + const domain = match.groups?.domain 88 + if (!domain || !isValidDomain(domain)) { 89 + continue 90 + } 91 + uri = `https://${uri}` 92 + } 93 + let from = str.indexOf(match[2], match.index) 94 + let to = from + match[2].length + 1 95 + // strip ending puncuation 96 + if (/[.,;!?]$/.test(uri)) { 97 + uri = uri.slice(0, -1) 98 + to-- 99 + } 100 + if (/[)]$/.test(uri) && !uri.includes('(')) { 101 + uri = uri.slice(0, -1) 102 + to-- 103 + } 104 + cb(from, to) 105 + } 106 + }
+2 -2
web/index.html
··· 117 117 .ProseMirror .mention { 118 118 color: #0085ff; 119 119 } 120 - .ProseMirror a { 120 + .ProseMirror a, 121 + .ProseMirror .autolink { 121 122 color: #0085ff; 122 - cursor: pointer; 123 123 } 124 124 /* OLLIE: TODO -- this is not accessible */ 125 125 /* Remove focus state on inputs */