Avatar defaults and colors
This commit is contained in:
parent
a001882d58
commit
12d2b1bf7c
140 changed files with 4212 additions and 1084 deletions
197
ts/components/AvatarTextEditor.tsx
Normal file
197
ts/components/AvatarTextEditor.tsx
Normal file
|
@ -0,0 +1,197 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
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 { AvatarDataType } from '../types/Avatar';
|
||||
import { AvatarModalButtons } from './AvatarModalButtons';
|
||||
import { BetterAvatarBubble } from './BetterAvatarBubble';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer';
|
||||
import { createAvatarData } from '../util/createAvatarData';
|
||||
import {
|
||||
getFittedFontSize,
|
||||
getFontSizes,
|
||||
} from '../util/avatarTextSizeCalculator';
|
||||
|
||||
type DoneHandleType = (
|
||||
avatarBuffer: ArrayBuffer,
|
||||
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 avatarDataToArrayBuffer(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>
|
||||
</>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue