Bluesky app fork with some witchin' additions 💫

clean up textinput decorator

authored by

12Me21 and committed by tangled.org ebec4a9a a49829fc

+70 -240
+2 -4
src/view/com/composer/text-input/TextInput.web.tsx
··· 37 37 import {type TextInputProps} from './TextInput.types' 38 38 import {type AutocompleteRef, createSuggestion} from './web/Autocomplete' 39 39 import {type Emoji} from './web/EmojiPicker' 40 - import {LinkDecorator} from './web/LinkDecorator' 41 - import {TagDecorator} from './web/TagDecorator' 40 + import {Decorator} from './web/Decorator' 42 41 43 42 export function TextInput({ 44 43 ref, ··· 64 63 const extensions = useMemo( 65 64 () => [ 66 65 Document, 67 - LinkDecorator, 68 - TagDecorator, 66 + Decorator, 69 67 Mention.configure({ 70 68 HTMLAttributes: { 71 69 class: 'mention',
+68
src/view/com/composer/text-input/web/Decorator.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 {type Node as ProsemirrorNode} from '@tiptap/pm/model' 19 + import {Plugin, PluginKey} from '@tiptap/pm/state' 20 + import {Decoration, DecorationSet} from '@tiptap/pm/view' 21 + import {markup_main} from '#/lib/twelve/markup.js' 22 + 23 + function getDecorations(doc: ProsemirrorNode) { 24 + const decorations: Decoration[] = [] 25 + 26 + let text = doc.textContent 27 + for (let span of markup_main(text)) { 28 + decorations.push( 29 + Decoration.inline(span.start, span.end+1, { 30 + class: 'autolink', 31 + }), 32 + ) 33 + } 34 + 35 + return DecorationSet.create(doc, decorations) 36 + } 37 + 38 + const tagDecoratorPlugin: Plugin = new Plugin({ 39 + key: new PluginKey('link-decorator'), 40 + 41 + state: { 42 + init: (_, {doc}) => getDecorations(doc), 43 + apply: (transaction, decorationSet) => { 44 + if (transaction.docChanged) { 45 + return getDecorations(transaction.doc) 46 + } 47 + return decorationSet.map(transaction.mapping, transaction.doc) 48 + }, 49 + }, 50 + 51 + props: { 52 + decorations(state) { 53 + return tagDecoratorPlugin.getState(state) 54 + }, 55 + }, 56 + }) 57 + 58 + export const Decorator = Mark.create({ 59 + name: 'markup-decorator', 60 + priority: 1000, 61 + keepOnSplit: false, 62 + inclusive() { 63 + return true 64 + }, 65 + addProseMirrorPlugins() { 66 + return [tagDecoratorPlugin] 67 + }, 68 + })
-119
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 {URL_REGEX} from '@atproto/api' 18 - import {Mark} from '@tiptap/core' 19 - import {type Node as ProsemirrorNode} from '@tiptap/pm/model' 20 - import {Plugin, PluginKey} from '@tiptap/pm/state' 21 - import {Decoration, DecorationSet} from '@tiptap/pm/view' 22 - 23 - import {isValidDomain} from '#/lib/strings/url-helpers' 24 - 25 - export const LinkDecorator = Mark.create({ 26 - name: 'link-decorator', 27 - priority: 1000, 28 - keepOnSplit: false, 29 - inclusive() { 30 - return true 31 - }, 32 - addProseMirrorPlugins() { 33 - return [linkDecorator()] 34 - }, 35 - }) 36 - 37 - function getDecorations(doc: ProsemirrorNode) { 38 - const decorations: Decoration[] = [] 39 - 40 - doc.descendants((node, pos) => { 41 - if (node.isText && node.text) { 42 - const textContent = node.textContent 43 - 44 - // markdown links [text](url) 45 - const markdownRegex = /\[([^\]]+)\]\s*\(([^)]+)\)/g 46 - let markdownMatch 47 - while ((markdownMatch = markdownRegex.exec(textContent)) !== null) { 48 - const from = markdownMatch.index 49 - const to = from + markdownMatch[0].length 50 - decorations.push( 51 - Decoration.inline(pos + from, pos + to, { 52 - class: 'autolink', 53 - }), 54 - ) 55 - } 56 - 57 - // regular links 58 - iterateUris(textContent, (from, to) => { 59 - decorations.push( 60 - Decoration.inline(pos + from, pos + to, { 61 - class: 'autolink', 62 - }), 63 - ) 64 - }) 65 - } 66 - }) 67 - 68 - return DecorationSet.create(doc, decorations) 69 - } 70 - 71 - function linkDecorator() { 72 - const linkDecoratorPlugin: Plugin = new Plugin({ 73 - key: new PluginKey('link-decorator'), 74 - 75 - state: { 76 - init: (_, {doc}) => getDecorations(doc), 77 - apply: (transaction, decorationSet) => { 78 - if (transaction.docChanged) { 79 - return getDecorations(transaction.doc) 80 - } 81 - return decorationSet.map(transaction.mapping, transaction.doc) 82 - }, 83 - }, 84 - 85 - props: { 86 - decorations(state) { 87 - return linkDecoratorPlugin.getState(state) 88 - }, 89 - }, 90 - }) 91 - return linkDecoratorPlugin 92 - } 93 - 94 - function iterateUris(str: string, cb: (from: number, to: number) => void) { 95 - let match 96 - const re = URL_REGEX 97 - while ((match = re.exec(str))) { 98 - let uri = match[2] 99 - if (!uri.startsWith('http')) { 100 - const domain = match.groups?.domain 101 - if (!domain || !isValidDomain(domain)) { 102 - continue 103 - } 104 - uri = `https://${uri}` 105 - } 106 - let from = str.indexOf(match[2], match.index) 107 - let to = from + match[2].length 108 - // strip ending puncuation 109 - if (/[.,;!?]$/.test(uri)) { 110 - uri = uri.slice(0, -1) 111 - to-- 112 - } 113 - if (/[)]$/.test(uri) && !uri.includes('(')) { 114 - uri = uri.slice(0, -1) 115 - to-- 116 - } 117 - cb(from, to) 118 - } 119 - }
-117
src/view/com/composer/text-input/web/TagDecorator.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 { 18 - CASHTAG_REGEX, 19 - TAG_REGEX, 20 - TRAILING_PUNCTUATION_REGEX, 21 - } from '@atproto/api' 22 - import {Mark} from '@tiptap/core' 23 - import {type Node as ProsemirrorNode} from '@tiptap/pm/model' 24 - import {Plugin, PluginKey} from '@tiptap/pm/state' 25 - import {Decoration, DecorationSet} from '@tiptap/pm/view' 26 - 27 - function getDecorations(doc: ProsemirrorNode) { 28 - const decorations: Decoration[] = [] 29 - 30 - doc.descendants((node, pos) => { 31 - if (node.isText && node.text) { 32 - const regex = TAG_REGEX 33 - const textContent = node.textContent 34 - 35 - // Detect hashtags 36 - let match 37 - while ((match = regex.exec(textContent))) { 38 - const [matchedString, __, tag] = match 39 - 40 - if (!tag || tag.replace(TRAILING_PUNCTUATION_REGEX, '').length > 64) 41 - continue 42 - 43 - const [trailingPunc = ''] = tag.match(TRAILING_PUNCTUATION_REGEX) || [] 44 - const matchedFrom = match.index + matchedString.indexOf(tag) 45 - const matchedTo = matchedFrom + (tag.length - trailingPunc.length) 46 - 47 - /* 48 - * The match is exclusive of `#` so we need to adjust the start of the 49 - * highlight by -1 to include the `#` 50 - */ 51 - const start = pos + matchedFrom - 1 52 - const end = pos + matchedTo 53 - 54 - decorations.push( 55 - Decoration.inline(start, end, { 56 - class: 'autolink', 57 - }), 58 - ) 59 - } 60 - 61 - // Detect cashtags 62 - const cashtagRegex = new RegExp(CASHTAG_REGEX.source, 'gu') 63 - while ((match = cashtagRegex.exec(textContent))) { 64 - const [_fullMatch, leading, ticker] = match 65 - 66 - if (!ticker) continue 67 - 68 - // Calculate positions: leading char + $ + ticker 69 - const matchedFrom = match.index + leading.length 70 - const matchedTo = matchedFrom + 1 + ticker.length // +1 for $ 71 - 72 - const start = pos + matchedFrom 73 - const end = pos + matchedTo 74 - 75 - decorations.push( 76 - Decoration.inline(start, end, { 77 - class: 'autolink', 78 - }), 79 - ) 80 - } 81 - } 82 - }) 83 - 84 - return DecorationSet.create(doc, decorations) 85 - } 86 - 87 - const tagDecoratorPlugin: Plugin = new Plugin({ 88 - key: new PluginKey('link-decorator'), 89 - 90 - state: { 91 - init: (_, {doc}) => getDecorations(doc), 92 - apply: (transaction, decorationSet) => { 93 - if (transaction.docChanged) { 94 - return getDecorations(transaction.doc) 95 - } 96 - return decorationSet.map(transaction.mapping, transaction.doc) 97 - }, 98 - }, 99 - 100 - props: { 101 - decorations(state) { 102 - return tagDecoratorPlugin.getState(state) 103 - }, 104 - }, 105 - }) 106 - 107 - export const TagDecorator = Mark.create({ 108 - name: 'tag-decorator', 109 - priority: 1000, 110 - keepOnSplit: false, 111 - inclusive() { 112 - return true 113 - }, 114 - addProseMirrorPlugins() { 115 - return [tagDecoratorPlugin] 116 - }, 117 - })