this repo has no description
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;