mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-11-25 10:45:41 +03:00
Refactor textarea and chars count meter
It won't re-render on every key press anymore
This commit is contained in:
parent
fea7145ac9
commit
1f12c53ee1
2 changed files with 259 additions and 195 deletions
|
@ -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) {
|
||||
|
|
|
@ -18,4 +18,5 @@ export default proxy({
|
|||
showCompose: false,
|
||||
showSettings: false,
|
||||
showAccount: false,
|
||||
composeCharacterCount: 0,
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue