Refactor textarea and chars count meter

It won't re-render on every key press anymore
This commit is contained in:
Lim Chee Aun 2023-01-04 19:03:11 +08:00
parent fea7145ac9
commit 1f12c53ee1
2 changed files with 259 additions and 195 deletions

View file

@ -1,14 +1,17 @@
import './compose.css';
import '@github/text-expander-element';
import { forwardRef } from 'preact/compat';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import stringLength from 'string-length';
import { useSnapshot } from 'valtio';
import supportedLanguages from '../data/status-supported-languages';
import urlRegex from '../data/url-regex';
import emojifyText from '../utils/emojify-text';
import openCompose from '../utils/open-compose';
import states from '../utils/states';
import store from '../utils/store';
import visibilityIconsMap from '../utils/visibility-icons-map';
@ -54,6 +57,16 @@ menu.className = 'text-expander-menu';
const DEFAULT_LANG = 'en';
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags);
const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi;
const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
function countableText(inputText) {
return inputText
.replace(urlRegexObj, urlPlaceholder)
.replace(usernameRegex, '$1@$3');
}
function Compose({
onClose,
replyToStatus,
@ -62,6 +75,7 @@ function Compose({
standalone,
hasOpener,
}) {
console.warn('RENDER COMPOSER');
const [uiState, setUIState] = useState('default');
const accounts = store.local.getJSON('accounts');
@ -223,130 +237,6 @@ function Compose({
}
}, [draftStatus, editStatus, replyToStatus]);
const textExpanderRef = useRef();
const textExpanderTextRef = useRef('');
useEffect(() => {
if (textExpanderRef.current) {
const handleChange = (e) => {
// console.log('text-expander-change', e);
const { key, provide, text } = e.detail;
textExpanderTextRef.current = text;
if (text === '') {
provide(
Promise.resolve({
matched: false,
}),
);
return;
}
if (key === ':') {
// const emojis = customEmojis.current.filter((emoji) =>
// emoji.shortcode.startsWith(text),
// );
const emojis = filterShortcodes(customEmojis.current, text);
let html = '';
emojis.forEach((emoji) => {
const { shortcode, url } = emoji;
html += `
<li role="option" data-value="${encodeHTML(shortcode)}">
<img src="${encodeHTML(
url,
)}" width="16" height="16" alt="" loading="lazy" />
:${encodeHTML(shortcode)}:
</li>`;
});
// console.log({ emojis, html });
menu.innerHTML = html;
provide(
Promise.resolve({
matched: emojis.length > 0,
fragment: menu,
}),
);
return;
}
const type = {
'@': 'accounts',
'#': 'hashtags',
}[key];
provide(
new Promise((resolve) => {
const searchResults = masto.v2.search({
type,
q: text,
limit: 5,
});
searchResults.then((value) => {
if (text !== textExpanderTextRef.current) {
return;
}
console.log({ value, type, v: value[type] });
const results = value[type];
console.log('RESULTS', value, results);
let html = '';
results.forEach((result) => {
const {
name,
avatarStatic,
displayName,
username,
acct,
emojis,
} = result;
const displayNameWithEmoji = emojifyText(displayName, emojis);
// const item = menuItem.cloneNode();
if (acct) {
html += `
<li role="option" data-value="${encodeHTML(acct)}">
<span class="avatar">
<img src="${encodeHTML(
avatarStatic,
)}" width="16" height="16" alt="" loading="lazy" />
</span>
<span>
<b>${displayNameWithEmoji || username}</b>
<br>@${encodeHTML(acct)}
</span>
</li>
`;
} else {
html += `
<li role="option" data-value="${encodeHTML(name)}">
<span>#<b>${encodeHTML(name)}</b></span>
</li>
`;
}
menu.innerHTML = html;
});
console.log('MENU', results, menu);
resolve({
matched: results.length > 0,
fragment: menu,
});
});
}),
);
};
textExpanderRef.current.addEventListener(
'text-expander-change',
handleChange,
);
textExpanderRef.current.addEventListener('text-expander-value', (e) => {
const { key, item } = e.detail;
if (key === ':') {
e.detail.value = `:${item.dataset.value}:`;
} else {
e.detail.value = `${key}${item.dataset.value}`;
}
});
}
}, []);
const formRef = useRef();
const beforeUnloadCopy =
@ -432,19 +322,16 @@ function Compose({
});
}, []);
const [charCount, setCharCount] = useState(
textareaRef.current?.value?.length +
spoilerTextRef.current?.value?.length || 0,
);
const leftChars = maxCharacters - charCount;
const getCharCount = () => {
const { value } = textareaRef.current;
const { value: spoilerText } = spoilerTextRef.current;
return stringLength(countableText(value)) + stringLength(spoilerText);
};
const updateCharCount = () => {
setCharCount(getCharCount());
const count = getCharCount();
states.composerCharacterCount = count;
};
useEffect(updateCharCount, []);
useHotkeys(
'esc',
@ -818,41 +705,22 @@ function Compose({
</select>
</label>{' '}
</div>
<text-expander ref={textExpanderRef} keys="@ # :">
<textarea
ref={textareaRef}
placeholder={
replyToStatus
? 'Post your reply'
: editStatus
? 'Edit your status'
: 'What are you doing?'
}
required={mediaAttachments.length === 0}
autoCapitalize="sentences"
autoComplete="on"
autoCorrect="on"
spellCheck="true"
dir="auto"
rows="6"
cols="50"
name="status"
disabled={uiState === 'loading'}
onInput={(e) => {
const { scrollHeight, offsetHeight, clientHeight, value } =
e.target;
const offset = offsetHeight - clientHeight;
e.target.style.height = value
? scrollHeight + offset + 'px'
: null;
updateCharCount();
}}
style={{
maxHeight: `${maxCharacters / 50}em`,
'--text-weight': (1 + charCount / 140).toFixed(1) || 1,
}}
></textarea>
</text-expander>
<Textarea
ref={textareaRef}
placeholder={
replyToStatus
? 'Post your reply'
: editStatus
? 'Edit your status'
: 'What are you doing?'
}
required={mediaAttachments.length === 0}
disabled={uiState === 'loading'}
onInput={() => {
updateCharCount();
}}
maxCharacters={maxCharacters}
/>
{mediaAttachments.length > 0 && (
<div class="media-attachments">
{mediaAttachments.map((attachment, i) => {
@ -957,26 +825,8 @@ function Compose({
</button>{' '}
<div class="spacer" />
{uiState === 'loading' && <Loader abrupt />}{' '}
{uiState !== 'loading' && charCount > maxCharacters / 2 && (
<>
<meter
class={`donut ${
leftChars <= -10
? 'explode'
: leftChars <= 0
? 'danger'
: leftChars <= 20
? 'warning'
: ''
}`}
value={charCount}
max={maxCharacters}
data-left={leftChars}
style={{
'--percentage': (charCount / maxCharacters) * 100,
}}
/>{' '}
</>
{uiState !== 'loading' && (
<CharCountMeter maxCharacters={maxCharacters} />
)}
<label class="toolbar-button">
<span class="icon-text">
@ -1012,6 +862,229 @@ function Compose({
);
}
const Textarea = forwardRef((props, ref) => {
const [text, setText] = useState(ref.current?.value || '');
const { maxCharacters, ...textareaProps } = props;
const snapStates = useSnapshot(states);
const charCount = snapStates.composerCharacterCount;
const textExpanderRef = useRef();
const textExpanderTextRef = useRef('');
useEffect(() => {
let handleChange, handleValue, handleCommited;
if (textExpanderRef.current) {
handleChange = (e) => {
// console.log('text-expander-change', e);
const { key, provide, text } = e.detail;
textExpanderTextRef.current = text;
if (text === '') {
provide(
Promise.resolve({
matched: false,
}),
);
return;
}
if (key === ':') {
// const emojis = customEmojis.current.filter((emoji) =>
// emoji.shortcode.startsWith(text),
// );
const emojis = filterShortcodes(customEmojis.current, text);
let html = '';
emojis.forEach((emoji) => {
const { shortcode, url } = emoji;
html += `
<li role="option" data-value="${encodeHTML(shortcode)}">
<img src="${encodeHTML(
url,
)}" width="16" height="16" alt="" loading="lazy" />
:${encodeHTML(shortcode)}:
</li>`;
});
// console.log({ emojis, html });
menu.innerHTML = html;
provide(
Promise.resolve({
matched: emojis.length > 0,
fragment: menu,
}),
);
return;
}
const type = {
'@': 'accounts',
'#': 'hashtags',
}[key];
provide(
new Promise((resolve) => {
const searchResults = masto.v2.search({
type,
q: text,
limit: 5,
});
searchResults.then((value) => {
if (text !== textExpanderTextRef.current) {
return;
}
console.log({ value, type, v: value[type] });
const results = value[type];
console.log('RESULTS', value, results);
let html = '';
results.forEach((result) => {
const {
name,
avatarStatic,
displayName,
username,
acct,
emojis,
} = result;
const displayNameWithEmoji = emojifyText(displayName, emojis);
// const item = menuItem.cloneNode();
if (acct) {
html += `
<li role="option" data-value="${encodeHTML(acct)}">
<span class="avatar">
<img src="${encodeHTML(
avatarStatic,
)}" width="16" height="16" alt="" loading="lazy" />
</span>
<span>
<b>${displayNameWithEmoji || username}</b>
<br>@${encodeHTML(acct)}
</span>
</li>
`;
} else {
html += `
<li role="option" data-value="${encodeHTML(name)}">
<span>#<b>${encodeHTML(name)}</b></span>
</li>
`;
}
menu.innerHTML = html;
});
console.log('MENU', results, menu);
resolve({
matched: results.length > 0,
fragment: menu,
});
});
}),
);
};
textExpanderRef.current.addEventListener(
'text-expander-change',
handleChange,
);
handleValue = (e) => {
const { key, item } = e.detail;
if (key === ':') {
e.detail.value = `:${item.dataset.value}:`;
} else {
e.detail.value = `${key}${item.dataset.value}`;
}
};
textExpanderRef.current.addEventListener(
'text-expander-value',
handleValue,
);
handleCommited = (e) => {
const { input } = e.detail;
setText(input.value);
};
textExpanderRef.current.addEventListener(
'text-expander-committed',
handleCommited,
);
}
return () => {
if (textExpanderRef.current) {
textExpanderRef.current.removeEventListener(
'text-expander-change',
handleChange,
);
textExpanderRef.current.removeEventListener(
'text-expander-value',
handleValue,
);
textExpanderRef.current.removeEventListener(
'text-expander-committed',
handleCommited,
);
}
};
}, []);
return (
<text-expander ref={textExpanderRef} keys="@ # :">
<textarea
autoCapitalize="sentences"
autoComplete="on"
autoCorrect="on"
spellCheck="true"
dir="auto"
rows="6"
cols="50"
{...textareaProps}
ref={ref}
name="status"
value={text}
onInput={(e) => {
const { scrollHeight, offsetHeight, clientHeight, value } = e.target;
setText(value);
const offset = offsetHeight - clientHeight;
e.target.style.height = value ? scrollHeight + offset + 'px' : null;
props.onInput?.(e);
}}
style={{
width: '100%',
height: '4em',
maxHeight: `${maxCharacters / 50}em`,
'--text-weight': (1 + charCount / 140).toFixed(1) || 1,
}}
/>
</text-expander>
);
});
function CharCountMeter({ maxCharacters = 500 }) {
const snapStates = useSnapshot(states);
const charCount = snapStates.composerCharacterCount;
const leftChars = maxCharacters - charCount;
if (charCount <= maxCharacters / 2) {
return null;
}
return (
<meter
class={`donut ${
leftChars <= -10
? 'explode'
: leftChars <= 0
? 'danger'
: leftChars <= 20
? 'warning'
: ''
}`}
value={charCount}
max={maxCharacters}
data-left={leftChars}
style={{
'--percentage': (charCount / maxCharacters) * 100,
}}
/>
);
}
function MediaAttachment({
attachment,
disabled,
@ -1220,16 +1293,6 @@ function encodeHTML(str) {
});
}
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags);
const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi;
const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
function countableText(inputText) {
return inputText
.replace(urlRegexObj, urlPlaceholder)
.replace(usernameRegex, '$1@$3');
}
function removeNullUndefined(obj) {
for (let key in obj) {
if (obj[key] === null || obj[key] === undefined) {

View file

@ -18,4 +18,5 @@ export default proxy({
showCompose: false,
showSettings: false,
showAccount: false,
composeCharacterCount: 0,
});