this repo has no description
0
fork

Configure Feed

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

at main 258 lines 8.9 kB view raw
1import { forwardRef } from 'preact/compat'; 2import { useEffect, useRef, useState } from 'preact/hooks'; 3import { useDebouncedCallback, useThrottledCallback } from 'use-debounce'; 4 5import { langDetector } from '../utils/browser-translator'; 6import escapeHTML from '../utils/escape-html'; 7import states from '../utils/states'; 8import urlRegexObj from '../utils/url-regex'; 9 10import TextExpander from './text-expander'; 11 12// https://github.com/mastodon/mastodon/blob/c03bd2a238741a012aa4b98dc4902d6cf948ab63/app/models/account.rb#L69 13const USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i; 14const MENTION_RE = new RegExp( 15 `(^|[^=\\/\\w])(@${USERNAME_RE.source}(?:@[\\p{L}\\w.-]+[\\w]+)?)`, 16 'uig', 17); 18 19// AI-generated, all other regexes are too complicated 20const HASHTAG_RE = new RegExp( 21 `(^|[^=\\/\\w])(#[\\p{L}\\p{N}_]+([\\p{L}\\p{N}_.]+[\\p{L}\\p{N}_]+)?)(?![\\/\\w])`, 22 'iug', 23); 24 25// https://github.com/mastodon/mastodon/blob/23e32a4b3031d1da8b911e0145d61b4dd47c4f96/app/models/custom_emoji.rb#L31 26const SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'; 27const SCAN_RE = new RegExp( 28 `(^|[^=\\/\\w])(:${SHORTCODE_RE_FRAGMENT}:)(?=[^A-Za-z0-9_:]|$)`, 29 'g', 30); 31 32const segmenter = new Intl.Segmenter(); 33 34function highlightText(text, { maxCharacters = Infinity }) { 35 // Exceeded characters limit 36 const { composerCharacterCount } = states; 37 if (composerCharacterCount > maxCharacters) { 38 // Highlight exceeded characters 39 let withinLimitHTML = '', 40 exceedLimitHTML = ''; 41 const htmlSegments = segmenter.segment(text); 42 for (const { segment, index } of htmlSegments) { 43 if (index < maxCharacters) { 44 withinLimitHTML += segment; 45 } else { 46 exceedLimitHTML += segment; 47 } 48 } 49 if (exceedLimitHTML) { 50 exceedLimitHTML = 51 '<mark class="compose-highlight-exceeded">' + 52 escapeHTML(exceedLimitHTML) + 53 '</mark>'; 54 } 55 return escapeHTML(withinLimitHTML) + exceedLimitHTML; 56 } 57 58 return escapeHTML(text) 59 .replace(urlRegexObj, '$2<mark class="compose-highlight-url">$3</mark>') // URLs 60 .replace(MENTION_RE, '$1<mark class="compose-highlight-mention">$2</mark>') // Mentions 61 .replace(HASHTAG_RE, '$1<mark class="compose-highlight-hashtag">$2</mark>') // Hashtags 62 .replace( 63 SCAN_RE, 64 '$1<mark class="compose-highlight-emoji-shortcode">$2</mark>', 65 ); // Emoji shortcodes 66} 67 68function autoResizeTextarea(textarea) { 69 if (!textarea) return; 70 const { value, offsetHeight, scrollHeight, clientHeight } = textarea; 71 if (offsetHeight < window.innerHeight) { 72 // NOTE: This check is needed because the offsetHeight return 50000 (really large number) on first render 73 // No idea why it does that, will re-investigate in far future 74 const offset = offsetHeight - clientHeight; 75 const height = value ? scrollHeight + offset + 'px' : null; 76 textarea.style.height = height; 77 } 78} 79 80const detectLangs = async (text) => { 81 if (langDetector) { 82 const langs = await langDetector.detect(text); 83 if (langs?.length) { 84 return langs.slice(0, 2).map((lang) => lang.detectedLanguage); 85 } 86 } 87 const { detectAll } = await import('tinyld/light'); 88 const langs = detectAll(text); 89 if (langs?.length) { 90 // return max 2 91 return langs.slice(0, 2).map((lang) => lang.lang); 92 } 93 return null; 94}; 95 96const Textarea = forwardRef((props, ref) => { 97 const [text, setText] = useState(ref.current?.value || ''); 98 const { maxCharacters, onTrigger = null, ...textareaProps } = props; 99 100 const textExpanderRef = useRef(); 101 102 useEffect(() => { 103 // Resize observer for textarea 104 const textarea = ref.current; 105 if (!textarea) return; 106 const resizeObserver = new ResizeObserver(() => { 107 // Get height of textarea, set height to textExpander 108 if (textExpanderRef.current) { 109 const { height } = textarea.getBoundingClientRect(); 110 // textExpanderRef.current.style.height = height + 'px'; 111 if (height) { 112 textExpanderRef.current.setStyle({ minHeight: height + 'px' }); 113 } 114 } 115 }); 116 resizeObserver.observe(textarea); 117 }, []); 118 119 const slowHighlightPerf = useRef(0); // increment if slow 120 const composeHighlightRef = useRef(); 121 const throttleHighlightText = useThrottledCallback((text) => { 122 if (!composeHighlightRef.current) return; 123 if (slowHighlightPerf.current > 3) { 124 // After 3 times of lag, disable highlighting 125 composeHighlightRef.current.innerHTML = ''; 126 composeHighlightRef.current = null; // Destroy the whole thing 127 throttleHighlightText?.cancel?.(); 128 return; 129 } 130 let start; 131 let end; 132 if (slowHighlightPerf.current <= 3) start = Date.now(); 133 composeHighlightRef.current.innerHTML = 134 highlightText(text, { 135 maxCharacters, 136 }) + '\n'; 137 if (slowHighlightPerf.current <= 3) end = Date.now(); 138 console.debug('HIGHLIGHT PERF', { start, end, diff: end - start }); 139 if (start && end && end - start > 50) { 140 // if slow, increment 141 slowHighlightPerf.current++; 142 } 143 // Newline to prevent multiple line breaks at the end from being collapsed, no idea why 144 }, 500); 145 146 const debouncedAutoDetectLanguage = useDebouncedCallback(() => { 147 // Make use of the highlightRef to get the DOM 148 // Clone the dom 149 const dom = composeHighlightRef.current?.cloneNode(true); 150 if (!dom) return; 151 // Remove mark 152 dom.querySelectorAll('mark').forEach((mark) => { 153 mark.remove(); 154 }); 155 const text = dom.innerText?.trim(); 156 if (!text) return; 157 (async () => { 158 const langs = await detectLangs(text); 159 if (langs?.length) { 160 onTrigger?.({ 161 name: 'auto-detect-language', 162 languages: langs, 163 }); 164 } 165 })(); 166 }, 2000); 167 168 return ( 169 <TextExpander 170 ref={textExpanderRef} 171 keys="@ # :" 172 class="compose-field-container" 173 onTrigger={onTrigger} 174 > 175 <textarea 176 class="compose-field" 177 autoCapitalize="sentences" 178 autoComplete="on" 179 autoCorrect="on" 180 spellCheck="true" 181 dir="auto" 182 rows="6" 183 cols="50" 184 {...textareaProps} 185 ref={ref} 186 name="status" 187 value={text} 188 onKeyDown={(e) => { 189 // Get line before cursor position after pressing 'Enter' 190 const { key, target } = e; 191 const hasTextExpander = textExpanderRef.current?.activated(); 192 if ( 193 key === 'Enter' && 194 !(e.ctrlKey || e.metaKey || hasTextExpander) && 195 !e.isComposing 196 ) { 197 try { 198 const { value, selectionStart } = target; 199 const textBeforeCursor = value.slice(0, selectionStart); 200 const lastLine = textBeforeCursor.split('\n').slice(-1)[0]; 201 if (lastLine) { 202 // If line starts with "- " or "12. " 203 if (/^\s*(-|\d+\.)\s/.test(lastLine)) { 204 // insert "- " at cursor position 205 const [_, preSpaces, bullet, postSpaces, anything] = 206 lastLine.match(/^(\s*)(-|\d+\.)(\s+)(.+)?/) || []; 207 if (anything) { 208 e.preventDefault(); 209 const [number] = bullet.match(/\d+/) || []; 210 const newBullet = number ? `${+number + 1}.` : '-'; 211 const text = `\n${preSpaces}${newBullet}${postSpaces}`; 212 target.setRangeText(text, selectionStart, selectionStart); 213 const pos = selectionStart + text.length; 214 target.setSelectionRange(pos, pos); 215 } else { 216 // trim the line before the cursor, then insert new line 217 const pos = selectionStart - lastLine.length; 218 target.setRangeText('', pos, selectionStart); 219 } 220 autoResizeTextarea(target); 221 target.dispatchEvent(new Event('input')); 222 } 223 } 224 } catch (e) { 225 // silent fail 226 console.error(e); 227 } 228 } 229 if (composeHighlightRef.current) { 230 composeHighlightRef.current.scrollTop = target.scrollTop; 231 } 232 }} 233 onInput={(e) => { 234 const { target } = e; 235 const text = target.value; 236 setText(text); 237 autoResizeTextarea(target); 238 props.onInput?.(e); 239 throttleHighlightText(text); 240 debouncedAutoDetectLanguage(); 241 }} 242 onScroll={(e) => { 243 if (composeHighlightRef.current) { 244 const { scrollTop } = e.target; 245 composeHighlightRef.current.scrollTop = scrollTop; 246 } 247 }} 248 /> 249 <div 250 ref={composeHighlightRef} 251 class="compose-highlight" 252 aria-hidden="true" 253 /> 254 </TextExpander> 255 ); 256}); 257 258export default Textarea;