197 lines
5.1 KiB
TypeScript
197 lines
5.1 KiB
TypeScript
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import type { ChangeEvent, ClipboardEvent } from 'react';
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { noop } from 'lodash';
|
|
|
|
import * as grapheme from '../util/grapheme';
|
|
import { AvatarColorPicker } from './AvatarColorPicker';
|
|
import { AvatarColors } from '../types/Colors';
|
|
import type { AvatarDataType } from '../types/Avatar';
|
|
import { AvatarModalButtons } from './AvatarModalButtons';
|
|
import { BetterAvatarBubble } from './BetterAvatarBubble';
|
|
import type { LocalizerType } from '../types/Util';
|
|
import { avatarDataToBytes } from '../util/avatarDataToBytes';
|
|
import { createAvatarData } from '../util/createAvatarData';
|
|
import {
|
|
getFittedFontSize,
|
|
getFontSizes,
|
|
} from '../util/avatarTextSizeCalculator';
|
|
|
|
type DoneHandleType = (
|
|
avatarBuffer: Uint8Array,
|
|
avatarData: AvatarDataType
|
|
) => unknown;
|
|
|
|
export type PropsType = {
|
|
avatarData?: AvatarDataType;
|
|
i18n: LocalizerType;
|
|
onCancel: () => unknown;
|
|
onDone: DoneHandleType;
|
|
};
|
|
|
|
const BUBBLE_SIZE = 120;
|
|
const MAX_LENGTH = 3;
|
|
|
|
export const AvatarTextEditor = ({
|
|
avatarData,
|
|
i18n,
|
|
onCancel,
|
|
onDone,
|
|
}: PropsType): JSX.Element => {
|
|
const initialText = useMemo(() => avatarData?.text || '', [avatarData]);
|
|
const initialColor = useMemo(
|
|
() => avatarData?.color || AvatarColors[0],
|
|
[avatarData]
|
|
);
|
|
|
|
const [inputText, setInputText] = useState(initialText);
|
|
const [fontSize, setFontSize] = useState(getFontSizes(BUBBLE_SIZE).text);
|
|
const [selectedColor, setSelectedColor] = useState(initialColor);
|
|
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const focusInput = useCallback(() => {
|
|
const inputEl = inputRef?.current;
|
|
if (inputEl) {
|
|
inputEl.focus();
|
|
}
|
|
}, []);
|
|
|
|
const handleChange = useCallback(
|
|
(ev: ChangeEvent<HTMLInputElement>) => {
|
|
const { value } = ev.target;
|
|
if (grapheme.count(value) <= MAX_LENGTH) {
|
|
setInputText(ev.target.value);
|
|
}
|
|
},
|
|
[setInputText]
|
|
);
|
|
|
|
const handlePaste = useCallback(
|
|
(ev: ClipboardEvent<HTMLInputElement>) => {
|
|
const inputEl = ev.currentTarget;
|
|
|
|
const selectionStart = inputEl.selectionStart || 0;
|
|
const selectionEnd = inputEl.selectionEnd || inputEl.selectionStart || 0;
|
|
const textBeforeSelection = inputText.slice(0, selectionStart);
|
|
const textAfterSelection = inputText.slice(selectionEnd);
|
|
|
|
const pastedText = ev.clipboardData.getData('Text');
|
|
|
|
const newGraphemeCount =
|
|
grapheme.count(textBeforeSelection) +
|
|
grapheme.count(pastedText) +
|
|
grapheme.count(textAfterSelection);
|
|
|
|
if (newGraphemeCount > MAX_LENGTH) {
|
|
ev.preventDefault();
|
|
}
|
|
},
|
|
[inputText]
|
|
);
|
|
|
|
const onDoneRef = useRef<DoneHandleType>(onDone);
|
|
|
|
// Make sure we keep onDoneRef up to date
|
|
useEffect(() => {
|
|
onDoneRef.current = onDone;
|
|
}, [onDone]);
|
|
|
|
const handleDone = useCallback(async () => {
|
|
const newAvatarData = createAvatarData({
|
|
color: selectedColor,
|
|
text: inputText,
|
|
});
|
|
|
|
const buffer = await avatarDataToBytes(newAvatarData);
|
|
|
|
onDoneRef.current(buffer, newAvatarData);
|
|
}, [inputText, selectedColor]);
|
|
|
|
// In case the component unmounts before we're able to create the avatar data
|
|
// we set the done handler to a no-op.
|
|
useEffect(() => {
|
|
return () => {
|
|
onDoneRef.current = noop;
|
|
};
|
|
}, []);
|
|
|
|
const measureElRef = useRef<null | HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
const measureEl = measureElRef.current;
|
|
if (!measureEl) {
|
|
return;
|
|
}
|
|
|
|
const nextFontSize = getFittedFontSize(
|
|
BUBBLE_SIZE,
|
|
inputText,
|
|
candidateFontSize => {
|
|
measureEl.style.fontSize = `${candidateFontSize}px`;
|
|
const { width, height } = measureEl.getBoundingClientRect();
|
|
return { height, width };
|
|
}
|
|
);
|
|
|
|
setFontSize(nextFontSize);
|
|
}, [inputText]);
|
|
|
|
useEffect(() => {
|
|
focusInput();
|
|
}, [focusInput]);
|
|
|
|
const hasChanges =
|
|
initialText !== inputText || selectedColor !== initialColor;
|
|
|
|
return (
|
|
<>
|
|
<div className="AvatarEditor__preview">
|
|
<BetterAvatarBubble
|
|
color={selectedColor}
|
|
i18n={i18n}
|
|
onSelect={focusInput}
|
|
style={{
|
|
height: BUBBLE_SIZE,
|
|
width: BUBBLE_SIZE,
|
|
}}
|
|
>
|
|
<input
|
|
className="AvatarTextEditor__input"
|
|
onChange={handleChange}
|
|
onPaste={handlePaste}
|
|
ref={inputRef}
|
|
style={{ fontSize }}
|
|
type="text"
|
|
value={inputText}
|
|
/>
|
|
</BetterAvatarBubble>
|
|
</div>
|
|
<hr className="AvatarEditor__divider" />
|
|
<AvatarColorPicker
|
|
i18n={i18n}
|
|
onColorSelected={color => {
|
|
setSelectedColor(color);
|
|
focusInput();
|
|
}}
|
|
selectedColor={selectedColor}
|
|
/>
|
|
<AvatarModalButtons
|
|
hasChanges={hasChanges}
|
|
i18n={i18n}
|
|
onCancel={onCancel}
|
|
onSave={handleDone}
|
|
/>
|
|
<div className="AvatarTextEditor__measure" ref={measureElRef}>
|
|
{inputText}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|