Composer: Support animated emojis in input (#2143)
This commit is contained in:
parent
980e1e062d
commit
a7ebcaf664
@ -13,7 +13,6 @@ import buildClassName from '../../util/buildClassName';
|
|||||||
import safePlay from '../../util/safePlay';
|
import safePlay from '../../util/safePlay';
|
||||||
import { selectIsAlwaysHighPriorityEmoji, selectIsDefaultEmojiStatusPack } from '../../global/selectors';
|
import { selectIsAlwaysHighPriorityEmoji, selectIsDefaultEmojiStatusPack } from '../../global/selectors';
|
||||||
|
|
||||||
import useEnsureCustomEmoji from '../../hooks/useEnsureCustomEmoji';
|
|
||||||
import useCustomEmoji from './hooks/useCustomEmoji';
|
import useCustomEmoji from './hooks/useCustomEmoji';
|
||||||
|
|
||||||
import StickerView from './StickerView';
|
import StickerView from './StickerView';
|
||||||
@ -72,7 +71,6 @@ const CustomEmoji: FC<OwnProps> = ({
|
|||||||
|
|
||||||
// An alternative to `withGlobal` to avoid adding numerous global containers
|
// An alternative to `withGlobal` to avoid adding numerous global containers
|
||||||
const customEmoji = useCustomEmoji(documentId);
|
const customEmoji = useCustomEmoji(documentId);
|
||||||
useEnsureCustomEmoji(documentId);
|
|
||||||
|
|
||||||
const loopCountRef = useRef(0);
|
const loopCountRef = useRef(0);
|
||||||
const [shouldLoop, setShouldLoop] = useState(true);
|
const [shouldLoop, setShouldLoop] = useState(true);
|
||||||
|
|||||||
@ -1,27 +1,17 @@
|
|||||||
import { useCallback, useEffect, useState } from '../../../lib/teact/teact';
|
import { useCallback, useEffect, useState } from '../../../lib/teact/teact';
|
||||||
import { addCallback } from '../../../lib/teact/teactn';
|
|
||||||
import { getGlobal } from '../../../global';
|
import { getGlobal } from '../../../global';
|
||||||
|
|
||||||
import type { GlobalState } from '../../../global/types';
|
|
||||||
import type { ApiSticker } from '../../../api/types';
|
import type { ApiSticker } from '../../../api/types';
|
||||||
|
|
||||||
const handlers = new Set<AnyToVoidFunction>();
|
import { addCustomEmojiCallback, removeCustomEmojiCallback } from '../../../util/customEmojiManager';
|
||||||
|
|
||||||
let prevGlobal: GlobalState | undefined;
|
import useEnsureCustomEmoji from '../../../hooks/useEnsureCustomEmoji';
|
||||||
|
|
||||||
addCallback((global: GlobalState) => {
|
|
||||||
if (global.customEmojis.byId !== prevGlobal?.customEmojis.byId) {
|
|
||||||
for (const handler of handlers) {
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prevGlobal = global;
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function useCustomEmoji(documentId: string) {
|
export default function useCustomEmoji(documentId: string) {
|
||||||
const [customEmoji, setCustomEmoji] = useState<ApiSticker | undefined>(getGlobal().customEmojis.byId[documentId]);
|
const [customEmoji, setCustomEmoji] = useState<ApiSticker | undefined>(getGlobal().customEmojis.byId[documentId]);
|
||||||
|
|
||||||
|
useEnsureCustomEmoji(documentId);
|
||||||
|
|
||||||
const handleGlobalChange = useCallback(() => {
|
const handleGlobalChange = useCallback(() => {
|
||||||
setCustomEmoji(getGlobal().customEmojis.byId[documentId]);
|
setCustomEmoji(getGlobal().customEmojis.byId[documentId]);
|
||||||
}, [documentId]);
|
}, [documentId]);
|
||||||
@ -31,10 +21,10 @@ export default function useCustomEmoji(documentId: string) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (customEmoji) return undefined;
|
if (customEmoji) return undefined;
|
||||||
|
|
||||||
handlers.add(handleGlobalChange);
|
addCustomEmojiCallback(handleGlobalChange, documentId);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
handlers.delete(handleGlobalChange);
|
removeCustomEmojiCallback(handleGlobalChange);
|
||||||
};
|
};
|
||||||
}, [customEmoji, documentId, handleGlobalChange]);
|
}, [customEmoji, documentId, handleGlobalChange]);
|
||||||
|
|
||||||
|
|||||||
@ -58,6 +58,7 @@ import useNativeCopySelectedMessages from '../../hooks/useNativeCopySelectedMess
|
|||||||
import useMedia from '../../hooks/useMedia';
|
import useMedia from '../../hooks/useMedia';
|
||||||
import useLayoutEffectWithPrevDeps from '../../hooks/useLayoutEffectWithPrevDeps';
|
import useLayoutEffectWithPrevDeps from '../../hooks/useLayoutEffectWithPrevDeps';
|
||||||
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
|
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
|
||||||
|
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
|
|
||||||
import Loading from '../ui/Loading';
|
import Loading from '../ui/Loading';
|
||||||
import MessageListContent from './MessageListContent';
|
import MessageListContent from './MessageListContent';
|
||||||
@ -297,26 +298,10 @@ const MessageList: FC<OwnProps & StateProps> = ({
|
|||||||
}, [updateStickyDates, hasTools, type, setScrollOffset, chatId, threadId]);
|
}, [updateStickyDates, hasTools, type, setScrollOffset, chatId, threadId]);
|
||||||
|
|
||||||
// Container resize observer (caused by Composer reply/webpage panels)
|
// Container resize observer (caused by Composer reply/webpage panels)
|
||||||
useEffect(() => {
|
const handleResize = useCallback((entry: ResizeObserverEntry) => {
|
||||||
if (!('ResizeObserver' in window) || process.env.APP_ENV === 'perf') {
|
setContainerHeight(entry.contentRect.height);
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(([entry]) => {
|
|
||||||
// During animation
|
|
||||||
if (!(entry.target as HTMLDivElement).offsetParent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setContainerHeight(entry.contentRect.height);
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(containerRef.current!);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
useResizeObserver(containerRef, handleResize);
|
||||||
|
|
||||||
// Memorize height for scroll animation
|
// Memorize height for scroll animation
|
||||||
const { height: windowHeight } = useWindowSize();
|
const { height: windowHeight } = useWindowSize();
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
.AttachMenu {
|
.AttachMenu {
|
||||||
align-self: flex-end;
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
&--button {
|
&--button {
|
||||||
&:focus {
|
&:focus {
|
||||||
|
|||||||
@ -77,6 +77,7 @@
|
|||||||
|
|
||||||
.drop-target {
|
.drop-target {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&::before,
|
&::before,
|
||||||
&::after {
|
&::after {
|
||||||
|
|||||||
@ -249,12 +249,21 @@
|
|||||||
.message-input-wrapper {
|
.message-input-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
|
.input-scroller {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
> .Spinner {
|
> .Spinner {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
--spinner-size: 1.5rem;
|
--spinner-size: 1.5rem;
|
||||||
margin-right: -0.5rem;
|
margin-right: -0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-bot-spinner {
|
||||||
|
margin-right: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
> .AttachMenu > .Button,
|
> .AttachMenu > .Button,
|
||||||
> .Button {
|
> .Button {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@ -402,6 +411,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-scroller {
|
||||||
|
min-height: 3.5rem;
|
||||||
|
max-height: 26rem;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.overflown {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
min-height: 2.875rem;
|
||||||
|
max-height: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .input-scroller-content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
|
||||||
|
transition: height 100ms ease;
|
||||||
|
|
||||||
|
body.animation-level-0 & {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin: 0 1px -5px;
|
||||||
|
vertical-align: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-emoji {
|
||||||
|
margin: 0;
|
||||||
|
vertical-align: text-top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#message-input-text,
|
#message-input-text,
|
||||||
#caption-input-text {
|
#caption-input-text {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -410,16 +458,18 @@
|
|||||||
padding: calc((3.25rem - var(--composer-text-size, 1rem) * 1.375) / 2 - var(--border-width, 0) * 2)
|
padding: calc((3.25rem - var(--composer-text-size, 1rem) * 1.375) / 2 - var(--border-width, 0) * 2)
|
||||||
calc(0.9rem - var(--border-width));
|
calc(0.9rem - var(--border-width));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
height: auto;
|
||||||
line-height: 1.375;
|
line-height: 1.375;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
unicode-bidi: plaintext;
|
unicode-bidi: plaintext;
|
||||||
text-align: initial;
|
text-align: initial;
|
||||||
font-size: var(--composer-text-size, 1rem);
|
font-size: var(--composer-text-size, 1rem);
|
||||||
|
|
||||||
&.overflown {
|
background: transparent !important;
|
||||||
overflow-y: auto;
|
border: none !important;
|
||||||
overflow-x: hidden;
|
border-radius: 0 !important;
|
||||||
}
|
box-shadow: none !important;
|
||||||
|
caret-color: var(--color-text);
|
||||||
|
|
||||||
&.touched,
|
&.touched,
|
||||||
&:focus {
|
&:focus {
|
||||||
@ -496,6 +546,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: -10;
|
z-index: -10;
|
||||||
@ -509,30 +560,13 @@
|
|||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
background: transparent !important;
|
|
||||||
border: none !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
caret-color: var(--color-text);
|
|
||||||
min-height: 3.5rem;
|
|
||||||
max-height: 26rem;
|
|
||||||
line-height: 1.3125;
|
line-height: 1.3125;
|
||||||
padding: calc((3.5rem - var(--composer-text-size, 1rem) * 1.3125) / 2) 0;
|
padding: calc((3.5rem - var(--composer-text-size, 1rem) * 1.3125) / 2) 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
height: 2.875rem;
|
|
||||||
min-height: 2.875rem;
|
|
||||||
max-height: 16rem;
|
|
||||||
padding: calc((2.875rem - var(--composer-text-size, 1rem) * 1.3125) / 2) 0;
|
padding: calc((2.875rem - var(--composer-text-size, 1rem) * 1.3125) / 2) 0;
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
|
|
||||||
transition: height 100ms ease;
|
|
||||||
|
|
||||||
body.animation-level-0 & {
|
|
||||||
transition: none !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -562,25 +596,22 @@
|
|||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji {
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
margin: 0 1px -5px;
|
|
||||||
vertical-align: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-emoji {
|
|
||||||
vertical-align: text-top;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#caption-input-text {
|
#caption-input-text {
|
||||||
.form-control {
|
border: 1px solid var(--color-borders-input);
|
||||||
height: 3.25rem;
|
border-radius: var(--border-radius-default);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
.input-scroller {
|
||||||
min-height: 3.25rem;
|
min-height: 3.25rem;
|
||||||
max-height: 15rem;
|
max-height: 15rem;
|
||||||
|
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
|
||||||
|
&:has(.form-control:focus) {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-text {
|
.placeholder-text {
|
||||||
|
|||||||
@ -1261,7 +1261,7 @@ const Composer: FC<OwnProps & StateProps> = ({
|
|||||||
onSuppressedFocus={closeSymbolMenu}
|
onSuppressedFocus={closeSymbolMenu}
|
||||||
/>
|
/>
|
||||||
{isInlineBotLoading && Boolean(inlineBotId) && (
|
{isInlineBotLoading && Boolean(inlineBotId) && (
|
||||||
<Spinner color="gray" />
|
<Spinner color="gray" className="inline-bot-spinner" />
|
||||||
)}
|
)}
|
||||||
{withScheduledButton && (
|
{withScheduledButton && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -17,13 +17,15 @@ import {
|
|||||||
} from '../../../util/environment';
|
} from '../../../util/environment';
|
||||||
import captureKeyboardListeners from '../../../util/captureKeyboardListeners';
|
import captureKeyboardListeners from '../../../util/captureKeyboardListeners';
|
||||||
import { getIsDirectTextInputDisabled } from '../../../util/directInputManager';
|
import { getIsDirectTextInputDisabled } from '../../../util/directInputManager';
|
||||||
|
import parseEmojiOnlyString from '../../../util/parseEmojiOnlyString';
|
||||||
|
import { isSelectionInsideInput } from './helpers/selection';
|
||||||
|
import renderText from '../../common/helpers/renderText';
|
||||||
|
|
||||||
import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps';
|
import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps';
|
||||||
import useFlag from '../../../hooks/useFlag';
|
import useFlag from '../../../hooks/useFlag';
|
||||||
import { isHeavyAnimating } from '../../../hooks/useHeavyAnimationCheck';
|
import { isHeavyAnimating } from '../../../hooks/useHeavyAnimationCheck';
|
||||||
import useLang from '../../../hooks/useLang';
|
import useLang from '../../../hooks/useLang';
|
||||||
import parseEmojiOnlyString from '../../../util/parseEmojiOnlyString';
|
import useInputCustomEmojis from './hooks/useInputCustomEmojis';
|
||||||
import { isSelectionInsideInput } from './helpers/selection';
|
|
||||||
import renderText from '../../common/helpers/renderText';
|
|
||||||
|
|
||||||
import TextFormatter from './TextFormatter';
|
import TextFormatter from './TextFormatter';
|
||||||
|
|
||||||
@ -32,6 +34,8 @@ const CONTEXT_MENU_CLOSE_DELAY_MS = 100;
|
|||||||
const FOCUS_DELAY_MS = 350;
|
const FOCUS_DELAY_MS = 350;
|
||||||
const TRANSITION_DURATION_FACTOR = 50;
|
const TRANSITION_DURATION_FACTOR = 50;
|
||||||
|
|
||||||
|
const SCROLLER_CLASS = 'input-scroller';
|
||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
id: string;
|
id: string;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
@ -58,6 +62,7 @@ type StateProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MAX_INPUT_HEIGHT = IS_SINGLE_COLUMN_LAYOUT ? 256 : 416;
|
const MAX_INPUT_HEIGHT = IS_SINGLE_COLUMN_LAYOUT ? 256 : 416;
|
||||||
|
const MAX_ATTACHMENT_MODAL_INPUT_HEIGHT = 240;
|
||||||
const TAB_INDEX_PRIORITY_TIMEOUT = 2000;
|
const TAB_INDEX_PRIORITY_TIMEOUT = 2000;
|
||||||
// Heuristics allowing the user to make a triple click
|
// Heuristics allowing the user to make a triple click
|
||||||
const SELECTION_RECALCULATE_DELAY_MS = 260;
|
const SELECTION_RECALCULATE_DELAY_MS = 260;
|
||||||
@ -112,6 +117,14 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
|||||||
const selectionTimeoutRef = useRef<number>(null);
|
const selectionTimeoutRef = useRef<number>(null);
|
||||||
// eslint-disable-next-line no-null/no-null
|
// eslint-disable-next-line no-null/no-null
|
||||||
const cloneRef = useRef<HTMLDivElement>(null);
|
const cloneRef = useRef<HTMLDivElement>(null);
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const scrollerCloneRef = useRef<HTMLDivElement>(null);
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const sharedCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const sharedCanvasHqRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const absoluteContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const lang = useLang();
|
const lang = useLang();
|
||||||
const isContextMenuOpenRef = useRef(false);
|
const isContextMenuOpenRef = useRef(false);
|
||||||
@ -120,10 +133,40 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
|||||||
const [selectedRange, setSelectedRange] = useState<Range>();
|
const [selectedRange, setSelectedRange] = useState<Range>();
|
||||||
const [isTextFormatterDisabled, setIsTextFormatterDisabled] = useState<boolean>(false);
|
const [isTextFormatterDisabled, setIsTextFormatterDisabled] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useInputCustomEmojis(html, inputRef, sharedCanvasRef, sharedCanvasHqRef, absoluteContainerRef);
|
||||||
|
|
||||||
|
const updateInputHeight = useCallback((willSend = false) => {
|
||||||
|
const scroller = inputRef.current!.closest<HTMLDivElement>(`.${SCROLLER_CLASS}`)!;
|
||||||
|
const clone = scrollerCloneRef.current!;
|
||||||
|
const currentHeight = Number(scroller.style.height.replace('px', ''));
|
||||||
|
const maxHeight = isAttachmentModalInput ? MAX_ATTACHMENT_MODAL_INPUT_HEIGHT : MAX_INPUT_HEIGHT;
|
||||||
|
const newHeight = Math.min(clone.scrollHeight, maxHeight);
|
||||||
|
if (newHeight === currentHeight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitionDuration = Math.round(
|
||||||
|
TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const exec = () => {
|
||||||
|
scroller.style.height = `${newHeight}px`;
|
||||||
|
scroller.style.transitionDuration = `${transitionDuration}ms`;
|
||||||
|
scroller.classList.toggle('overflown', clone.scrollHeight > maxHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (willSend) {
|
||||||
|
// Sync with sending animation
|
||||||
|
requestAnimationFrame(exec);
|
||||||
|
} else {
|
||||||
|
exec();
|
||||||
|
}
|
||||||
|
}, [isAttachmentModalInput]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAttachmentModalInput) return;
|
if (!isAttachmentModalInput) return;
|
||||||
updateInputHeight(false);
|
updateInputHeight(false);
|
||||||
}, [isAttachmentModalInput]);
|
}, [isAttachmentModalInput, updateInputHeight]);
|
||||||
|
|
||||||
useLayoutEffectWithPrevDeps(([prevHtml]) => {
|
useLayoutEffectWithPrevDeps(([prevHtml]) => {
|
||||||
if (html !== inputRef.current!.innerHTML) {
|
if (html !== inputRef.current!.innerHTML) {
|
||||||
@ -331,33 +374,6 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateInputHeight(willSend = false) {
|
|
||||||
const input = inputRef.current!;
|
|
||||||
const clone = cloneRef.current!;
|
|
||||||
const currentHeight = Number(input.style.height.replace('px', ''));
|
|
||||||
const newHeight = Math.min(clone.scrollHeight, MAX_INPUT_HEIGHT);
|
|
||||||
if (newHeight === currentHeight) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transitionDuration = Math.round(
|
|
||||||
TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const exec = () => {
|
|
||||||
input.style.height = `${newHeight}px`;
|
|
||||||
input.style.transitionDuration = `${transitionDuration}ms`;
|
|
||||||
input.classList.toggle('overflown', clone.scrollHeight > MAX_INPUT_HEIGHT);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (willSend) {
|
|
||||||
// Sync with sending animation
|
|
||||||
requestAnimationFrame(exec);
|
|
||||||
} else {
|
|
||||||
exec();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (IS_TOUCH_ENV) {
|
if (IS_TOUCH_ENV) {
|
||||||
return;
|
return;
|
||||||
@ -450,37 +466,47 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
|||||||
}, [shouldSuppressFocus]);
|
}, [shouldSuppressFocus]);
|
||||||
|
|
||||||
const className = buildClassName(
|
const className = buildClassName(
|
||||||
'form-control custom-scroll',
|
'form-control',
|
||||||
html.length > 0 && 'touched',
|
html.length > 0 && 'touched',
|
||||||
shouldSuppressFocus && 'focus-disabled',
|
shouldSuppressFocus && 'focus-disabled',
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id={id} onClick={shouldSuppressFocus ? onSuppressedFocus : undefined} dir={lang.isRtl ? 'rtl' : undefined}>
|
<div id={id} onClick={shouldSuppressFocus ? onSuppressedFocus : undefined} dir={lang.isRtl ? 'rtl' : undefined}>
|
||||||
<div
|
<div className={buildClassName('custom-scroll', SCROLLER_CLASS)}>
|
||||||
ref={inputRef}
|
<div className="input-scroller-content">
|
||||||
id={editableInputId || EDITABLE_INPUT_ID}
|
<div
|
||||||
className={className}
|
ref={inputRef}
|
||||||
contentEditable
|
id={editableInputId || EDITABLE_INPUT_ID}
|
||||||
role="textbox"
|
className={className}
|
||||||
dir="auto"
|
contentEditable
|
||||||
tabIndex={0}
|
role="textbox"
|
||||||
onClick={focusInput}
|
dir="auto"
|
||||||
onChange={handleChange}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
onClick={focusInput}
|
||||||
onMouseDown={handleMouseDown}
|
onChange={handleChange}
|
||||||
onContextMenu={IS_ANDROID ? handleAndroidContextMenu : undefined}
|
onKeyDown={handleKeyDown}
|
||||||
onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined}
|
onMouseDown={handleMouseDown}
|
||||||
aria-label={placeholder}
|
onContextMenu={IS_ANDROID ? handleAndroidContextMenu : undefined}
|
||||||
/>
|
onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined}
|
||||||
|
aria-label={placeholder}
|
||||||
|
/>
|
||||||
|
{!forcedPlaceholder && <span className="placeholder-text" dir="auto">{placeholder}</span>}
|
||||||
|
<canvas ref={sharedCanvasRef} className="shared-canvas" />
|
||||||
|
<canvas ref={sharedCanvasHqRef} className="shared-canvas" />
|
||||||
|
<div ref={absoluteContainerRef} className="absolute-video-container" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={scrollerCloneRef} className={buildClassName('custom-scroll', SCROLLER_CLASS, 'clone')}>
|
||||||
|
<div className="input-scroller-content">
|
||||||
|
<div ref={cloneRef} className={buildClassName(className, 'clone')} dir="auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{captionLimit && (
|
{captionLimit && (
|
||||||
<div className="max-length-indicator" dir="auto">
|
<div className="max-length-indicator" dir="auto">
|
||||||
{captionLimit}
|
{captionLimit}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={cloneRef} className={buildClassName(className, 'clone')} dir="auto" />
|
|
||||||
{!forcedPlaceholder && <span className="placeholder-text" dir="auto">{placeholder}</span>}
|
|
||||||
<TextFormatter
|
<TextFormatter
|
||||||
isOpen={isTextFormatterOpen}
|
isOpen={isTextFormatterOpen}
|
||||||
anchorPosition={textFormatterAnchorPosition}
|
anchorPosition={textFormatterAnchorPosition}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import { selectIsAlwaysHighPriorityEmoji } from '../../../global/selectors';
|
|||||||
import { IS_WEBM_SUPPORTED } from '../../../util/environment';
|
import { IS_WEBM_SUPPORTED } from '../../../util/environment';
|
||||||
import { getFirstLetters } from '../../../util/textFormat';
|
import { getFirstLetters } from '../../../util/textFormat';
|
||||||
import buildClassName from '../../../util/buildClassName';
|
import buildClassName from '../../../util/buildClassName';
|
||||||
|
import { getStickerPreviewHash } from '../../../global/helpers';
|
||||||
|
|
||||||
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
|
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
|
||||||
import useMedia from '../../../hooks/useMedia';
|
import useMedia from '../../../hooks/useMedia';
|
||||||
import useMediaTransition from '../../../hooks/useMediaTransition';
|
import useMediaTransition from '../../../hooks/useMediaTransition';
|
||||||
@ -42,8 +44,13 @@ const StickerSetCover: FC<OwnProps> = ({
|
|||||||
|
|
||||||
const isIntersecting = useIsIntersecting(containerRef, observeIntersection);
|
const isIntersecting = useIsIntersecting(containerRef, observeIntersection);
|
||||||
|
|
||||||
const mediaData = useMedia((hasThumbnail || isLottie) && `stickerSet${stickerSet.id}`, !isIntersecting);
|
const shouldFallbackToStatic = stickerSet.stickers && isVideo && !IS_WEBM_SUPPORTED;
|
||||||
const isReady = mediaData && (!isVideo || IS_WEBM_SUPPORTED);
|
const staticHash = shouldFallbackToStatic && getStickerPreviewHash(stickerSet.stickers![0].id);
|
||||||
|
const staticMediaData = useMedia(staticHash, !isIntersecting);
|
||||||
|
|
||||||
|
const mediaHash = ((hasThumbnail && !shouldFallbackToStatic) || isLottie) && `stickerSet${stickerSet.id}`;
|
||||||
|
const mediaData = useMedia(mediaHash, !isIntersecting);
|
||||||
|
const isReady = mediaData || staticMediaData;
|
||||||
const transitionClassNames = useMediaTransition(isReady);
|
const transitionClassNames = useMediaTransition(isReady);
|
||||||
|
|
||||||
const bounds = useBoundsInSharedCanvas(containerRef, sharedCanvasRef);
|
const bounds = useBoundsInSharedCanvas(containerRef, sharedCanvasRef);
|
||||||
@ -61,7 +68,7 @@ const StickerSetCover: FC<OwnProps> = ({
|
|||||||
sharedCanvas={sharedCanvasRef?.current || undefined}
|
sharedCanvas={sharedCanvasRef?.current || undefined}
|
||||||
sharedCanvasCoords={bounds.coords}
|
sharedCanvasCoords={bounds.coords}
|
||||||
/>
|
/>
|
||||||
) : isVideo ? (
|
) : (isVideo && !shouldFallbackToStatic) ? (
|
||||||
<OptimizedVideo
|
<OptimizedVideo
|
||||||
className={buildClassName(styles.video, transitionClassNames)}
|
className={buildClassName(styles.video, transitionClassNames)}
|
||||||
src={mediaData}
|
src={mediaData}
|
||||||
@ -71,7 +78,7 @@ const StickerSetCover: FC<OwnProps> = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={mediaData}
|
src={mediaData || staticMediaData}
|
||||||
className={transitionClassNames}
|
className={transitionClassNames}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -82,7 +82,7 @@ const SymbolMenuFooter: FC<OwnProps> = ({
|
|||||||
{renderTabButton(SymbolMenuTabs.Stickers)}
|
{renderTabButton(SymbolMenuTabs.Stickers)}
|
||||||
{renderTabButton(SymbolMenuTabs.GIFs)}
|
{renderTabButton(SymbolMenuTabs.GIFs)}
|
||||||
|
|
||||||
{activeTab === SymbolMenuTabs.Emoji && (
|
{(activeTab === SymbolMenuTabs.Emoji || activeTab === SymbolMenuTabs.CustomEmoji) && (
|
||||||
<Button
|
<Button
|
||||||
className="symbol-delete-button"
|
className="symbol-delete-button"
|
||||||
onClick={onRemoveSymbol}
|
onClick={onRemoveSymbol}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
.TextFormatter {
|
.TextFormatter {
|
||||||
transform: translate(-50%, -3.25rem);
|
transform: translate(-50%, -3.25rem);
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
&,
|
&,
|
||||||
&-link-control {
|
&-link-control {
|
||||||
|
|||||||
@ -1,37 +1,38 @@
|
|||||||
import type { ApiMessageEntityCustomEmoji, ApiSticker } from '../../../../api/types';
|
import type { ApiMessageEntityCustomEmoji, ApiSticker } from '../../../../api/types';
|
||||||
import { ApiMessageEntityTypes } from '../../../../api/types';
|
import { ApiMessageEntityTypes } from '../../../../api/types';
|
||||||
|
|
||||||
|
import { getGlobal } from '../../../../global';
|
||||||
import { EMOJI_SIZES } from '../../../../config';
|
import { EMOJI_SIZES } from '../../../../config';
|
||||||
import { REM } from '../../../common/helpers/mediaDimensions';
|
import { REM } from '../../../common/helpers/mediaDimensions';
|
||||||
import { getCustomEmojiPreviewMediaData } from '../../../../util/customEmojiManager';
|
import { getInputCustomEmojiParams } from '../../../../util/customEmojiManager';
|
||||||
|
|
||||||
import placeholderSrc from '../../../../assets/square.svg';
|
|
||||||
|
|
||||||
export const INPUT_CUSTOM_EMOJI_SELECTOR = 'img[data-document-id]';
|
export const INPUT_CUSTOM_EMOJI_SELECTOR = 'img[data-document-id]';
|
||||||
|
|
||||||
export function buildCustomEmojiHtml(emoji: ApiSticker) {
|
export function buildCustomEmojiHtml(emoji: ApiSticker) {
|
||||||
const mediaData = getCustomEmojiPreviewMediaData(emoji.id);
|
const [isPlaceholder, src, uniqueId] = getInputCustomEmojiParams(emoji);
|
||||||
|
|
||||||
return `<img
|
return `<img
|
||||||
class="custom-emoji emoji emoji-small${!mediaData ? ' placeholder' : ''}"
|
class="custom-emoji emoji emoji-small ${isPlaceholder ? 'placeholder' : ''}"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
alt="${emoji.emoji}"
|
alt="${emoji.emoji}"
|
||||||
data-document-id="${emoji.id}"
|
data-document-id="${emoji.id}"
|
||||||
|
${uniqueId ? `data-unique-id="${uniqueId}"` : ''}
|
||||||
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
|
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
|
||||||
src="${mediaData || placeholderSrc}"
|
src="${src}"
|
||||||
/>`;
|
/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessageEntityCustomEmoji) {
|
export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessageEntityCustomEmoji) {
|
||||||
const mediaData = getCustomEmojiPreviewMediaData(entity.documentId);
|
const customEmoji = getGlobal().customEmojis.byId[entity.documentId];
|
||||||
|
const [isPlaceholder, src, uniqueId] = getInputCustomEmojiParams(customEmoji);
|
||||||
return `<img
|
return `<img
|
||||||
class="custom-emoji emoji emoji-small${!mediaData ? ' placeholder' : ''}"
|
class="custom-emoji emoji emoji-small ${isPlaceholder ? 'placeholder' : ''}"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
alt="${rawText}"
|
alt="${rawText}"
|
||||||
data-document-id="${entity.documentId}"
|
data-document-id="${entity.documentId}"
|
||||||
|
${uniqueId ? `data-unique-id="${uniqueId}"` : ''}
|
||||||
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
|
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
|
||||||
src="${mediaData || placeholderSrc}"
|
src="${src}"
|
||||||
/>`;
|
/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,8 +35,8 @@ const useEditing = (
|
|||||||
if (prevEditedMessage?.id === editedMessage.id) {
|
if (prevEditedMessage?.id === editedMessage.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const text = !prevEditedMessage && editingDraft?.text.length ? editingDraft : editedMessage.content.text;
|
||||||
const html = getTextWithEntitiesAsHtml(editingDraft?.text.length ? editingDraft : editedMessage.content.text);
|
const html = getTextWithEntitiesAsHtml(text);
|
||||||
setHtml(html);
|
setHtml(html);
|
||||||
// `fastRaf` would execute syncronously in this case
|
// `fastRaf` would execute syncronously in this case
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|||||||
198
src/components/middle/composer/hooks/useInputCustomEmojis.ts
Normal file
198
src/components/middle/composer/hooks/useInputCustomEmojis.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import {
|
||||||
|
useCallback, useEffect, useRef,
|
||||||
|
} from '../../../../lib/teact/teact';
|
||||||
|
import RLottie from '../../../../lib/rlottie/RLottie';
|
||||||
|
|
||||||
|
import type { ApiSticker } from '../../../../api/types';
|
||||||
|
|
||||||
|
import { getGlobal } from '../../../../global';
|
||||||
|
import { selectIsAlwaysHighPriorityEmoji } from '../../../../global/selectors';
|
||||||
|
import generateIdFor from '../../../../util/generateIdFor';
|
||||||
|
import {
|
||||||
|
addCustomEmojiInputRenderCallback,
|
||||||
|
getCustomEmojiMediaDataForInput,
|
||||||
|
removeCustomEmojiInputRenderCallback,
|
||||||
|
} from '../../../../util/customEmojiManager';
|
||||||
|
import { round } from '../../../../util/math';
|
||||||
|
import { fastRaf } from '../../../../util/schedulers';
|
||||||
|
import AbsoluteVideo from '../../../../util/AbsoluteVideo';
|
||||||
|
|
||||||
|
import { useResizeObserver } from '../../../../hooks/useResizeObserver';
|
||||||
|
import useBackgroundMode from '../../../../hooks/useBackgroundMode';
|
||||||
|
|
||||||
|
const ID_STORE = {};
|
||||||
|
const SIZE = 20;
|
||||||
|
|
||||||
|
type Player = {
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
destroy: () => void;
|
||||||
|
updatePosition: (x: number, y: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useInputCustomEmojis(
|
||||||
|
html: string,
|
||||||
|
inputRef: React.RefObject<HTMLDivElement>,
|
||||||
|
sharedCanvasRef: React.RefObject<HTMLCanvasElement>,
|
||||||
|
sharedCanvasHqRef: React.RefObject<HTMLCanvasElement>,
|
||||||
|
absoluteContainerRef: React.RefObject<HTMLElement>,
|
||||||
|
) {
|
||||||
|
const mapRef = useRef<Map<string, Player>>(new Map());
|
||||||
|
|
||||||
|
const removeContainers = useCallback((ids: string[]) => {
|
||||||
|
ids.forEach((id) => {
|
||||||
|
const player = mapRef.current.get(id);
|
||||||
|
if (player) {
|
||||||
|
player.destroy();
|
||||||
|
mapRef.current.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const synchronizeElements = useCallback(() => {
|
||||||
|
if (!inputRef.current || !sharedCanvasRef.current || !sharedCanvasHqRef.current) return;
|
||||||
|
const global = getGlobal();
|
||||||
|
const removedContainers = new Set(mapRef.current.keys());
|
||||||
|
const customEmojies = Array.from(inputRef.current.querySelectorAll<HTMLElement>('.custom-emoji'));
|
||||||
|
|
||||||
|
customEmojies.forEach((element) => {
|
||||||
|
const id = element.dataset.uniqueId!;
|
||||||
|
const documentId = element.dataset.documentId!;
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removedContainers.delete(id);
|
||||||
|
|
||||||
|
const mediaUrl = getCustomEmojiMediaDataForInput(documentId);
|
||||||
|
if (!mediaUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasBounds = sharedCanvasRef.current!.getBoundingClientRect();
|
||||||
|
const elementBounds = element.getBoundingClientRect();
|
||||||
|
const x = round((elementBounds.left - canvasBounds.left) / canvasBounds.width, 4);
|
||||||
|
const y = round((elementBounds.top - canvasBounds.top) / canvasBounds.height, 4);
|
||||||
|
|
||||||
|
if (mapRef.current.has(id)) {
|
||||||
|
const player = mapRef.current.get(id)!;
|
||||||
|
player.updatePosition(x, y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customEmoji = global.customEmojis.byId[documentId];
|
||||||
|
if (!customEmoji) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isHq = customEmoji?.stickerSetInfo && selectIsAlwaysHighPriorityEmoji(global, customEmoji.stickerSetInfo);
|
||||||
|
|
||||||
|
const animation = createPlayer({
|
||||||
|
customEmoji,
|
||||||
|
sharedCanvasRef,
|
||||||
|
sharedCanvasHqRef,
|
||||||
|
absoluteContainerRef,
|
||||||
|
uniqueId: id,
|
||||||
|
mediaUrl,
|
||||||
|
isHq,
|
||||||
|
position: { x, y },
|
||||||
|
});
|
||||||
|
animation.play();
|
||||||
|
|
||||||
|
mapRef.current.set(id, animation);
|
||||||
|
});
|
||||||
|
|
||||||
|
removeContainers(Array.from(removedContainers));
|
||||||
|
}, [absoluteContainerRef, inputRef, removeContainers, sharedCanvasHqRef, sharedCanvasRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
addCustomEmojiInputRenderCallback(synchronizeElements);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeCustomEmojiInputRenderCallback(synchronizeElements);
|
||||||
|
};
|
||||||
|
}, [synchronizeElements]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!html || !inputRef.current || !sharedCanvasRef.current) {
|
||||||
|
removeContainers(Array.from(mapRef.current.keys()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronizeElements();
|
||||||
|
}, [html, inputRef, removeContainers, sharedCanvasRef, synchronizeElements]);
|
||||||
|
|
||||||
|
useResizeObserver(sharedCanvasRef, synchronizeElements, true);
|
||||||
|
|
||||||
|
const freezeAnimation = useCallback(() => {
|
||||||
|
mapRef.current.forEach((player) => {
|
||||||
|
player.pause();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unfreezeAnimation = useCallback(() => {
|
||||||
|
mapRef.current.forEach((player) => {
|
||||||
|
player.play();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unfreezeAnimationOnRaf = useCallback(() => {
|
||||||
|
fastRaf(unfreezeAnimation);
|
||||||
|
}, [unfreezeAnimation]);
|
||||||
|
|
||||||
|
// Pausing frame may not happen in background,
|
||||||
|
// so we need to make sure it happens right after focusing,
|
||||||
|
// then we can play again.
|
||||||
|
useBackgroundMode(freezeAnimation, unfreezeAnimationOnRaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayer({
|
||||||
|
customEmoji,
|
||||||
|
sharedCanvasRef,
|
||||||
|
sharedCanvasHqRef,
|
||||||
|
absoluteContainerRef,
|
||||||
|
uniqueId,
|
||||||
|
mediaUrl,
|
||||||
|
position,
|
||||||
|
isHq,
|
||||||
|
} : {
|
||||||
|
customEmoji: ApiSticker;
|
||||||
|
sharedCanvasRef: React.RefObject<HTMLCanvasElement>;
|
||||||
|
sharedCanvasHqRef: React.RefObject<HTMLCanvasElement>;
|
||||||
|
absoluteContainerRef: React.RefObject<HTMLElement>;
|
||||||
|
uniqueId: string;
|
||||||
|
mediaUrl: string;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
isHq?: boolean;
|
||||||
|
}): Player {
|
||||||
|
if (customEmoji.isLottie) {
|
||||||
|
const lottie = RLottie.init(
|
||||||
|
uniqueId,
|
||||||
|
isHq ? sharedCanvasHqRef.current! : sharedCanvasRef.current!,
|
||||||
|
undefined,
|
||||||
|
generateIdFor(ID_STORE, true),
|
||||||
|
mediaUrl,
|
||||||
|
{
|
||||||
|
size: SIZE,
|
||||||
|
coords: position,
|
||||||
|
isLowPriority: !isHq,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
play: () => lottie.play(),
|
||||||
|
pause: () => lottie.pause(),
|
||||||
|
destroy: () => lottie.removeContainer(uniqueId),
|
||||||
|
updatePosition: (x: number, y: number) => lottie.setSharedCanvasCoords(uniqueId, { x, y }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customEmoji.isVideo) {
|
||||||
|
const absoluteVideo = new AbsoluteVideo(mediaUrl, absoluteContainerRef.current!, { size: SIZE, position });
|
||||||
|
return {
|
||||||
|
play: () => absoluteVideo.play(),
|
||||||
|
pause: () => absoluteVideo.pause(),
|
||||||
|
destroy: () => absoluteVideo.destroy(),
|
||||||
|
updatePosition: (x: number, y: number) => absoluteVideo.updatePosition({ x, y }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported custom emoji type');
|
||||||
|
}
|
||||||
@ -8,12 +8,17 @@ import './Spinner.scss';
|
|||||||
const Spinner: FC<{
|
const Spinner: FC<{
|
||||||
color?: 'blue' | 'white' | 'black' | 'green' | 'gray' | 'yellow';
|
color?: 'blue' | 'white' | 'black' | 'green' | 'gray' | 'yellow';
|
||||||
backgroundColor?: 'light' | 'dark';
|
backgroundColor?: 'light' | 'dark';
|
||||||
|
className?: string;
|
||||||
}> = ({
|
}> = ({
|
||||||
color = 'blue',
|
color = 'blue',
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={buildClassName('Spinner', color, backgroundColor && 'with-background', `bg-${backgroundColor}`)}>
|
<div className={buildClassName(
|
||||||
|
'Spinner', className, color, backgroundColor && 'with-background', `bg-${backgroundColor}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div />
|
<div />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
useCallback, useEffect, useLayoutEffect, useMemo, useState,
|
useCallback, useLayoutEffect, useMemo, useState,
|
||||||
} from '../lib/teact/teact';
|
} from '../lib/teact/teact';
|
||||||
|
|
||||||
import { throttle } from '../util/schedulers';
|
|
||||||
import { round } from '../util/math';
|
import { round } from '../util/math';
|
||||||
|
|
||||||
|
import { useResizeObserver } from './useResizeObserver';
|
||||||
|
|
||||||
export default function useBoundsInSharedCanvas(
|
export default function useBoundsInSharedCanvas(
|
||||||
containerRef: React.RefObject<HTMLDivElement>,
|
containerRef: React.RefObject<HTMLDivElement>,
|
||||||
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>,
|
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>,
|
||||||
@ -32,26 +33,7 @@ export default function useBoundsInSharedCanvas(
|
|||||||
|
|
||||||
useLayoutEffect(recalculate, [recalculate]);
|
useLayoutEffect(recalculate, [recalculate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useResizeObserver(sharedCanvasRef, recalculate, true);
|
||||||
if (!('ResizeObserver' in window) || !sharedCanvasRef?.current) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(throttle(([entry]) => {
|
|
||||||
// During animation
|
|
||||||
if (!(entry.target as HTMLCanvasElement).offsetParent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
recalculate();
|
|
||||||
}, 300, false));
|
|
||||||
|
|
||||||
observer.observe(sharedCanvasRef.current);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [recalculate, sharedCanvasRef]);
|
|
||||||
|
|
||||||
const coords = useMemo(() => (x !== undefined && y !== undefined ? { x, y } : undefined), [x, y]);
|
const coords = useMemo(() => (x !== undefined && y !== undefined ? { x, y } : undefined), [x, y]);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { getActions, getGlobal } from '../global';
|
import { getActions, getGlobal } from '../global';
|
||||||
|
import { addCustomEmojiInputRenderCallback } from '../util/customEmojiManager';
|
||||||
|
|
||||||
import { throttle } from '../util/schedulers';
|
import { throttle } from '../util/schedulers';
|
||||||
|
|
||||||
@ -24,11 +25,13 @@ const updateLastRendered = throttle(() => {
|
|||||||
RENDER_HISTORY.clear();
|
RENDER_HISTORY.clear();
|
||||||
}, THROTTLE, false);
|
}, THROTTLE, false);
|
||||||
|
|
||||||
export function notifyCustomEmojiRender(emojiId: string) {
|
function notifyCustomEmojiRender(emojiId: string) {
|
||||||
RENDER_HISTORY.add(emojiId);
|
RENDER_HISTORY.add(emojiId);
|
||||||
updateLastRendered();
|
updateLastRendered();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addCustomEmojiInputRenderCallback(notifyCustomEmojiRender);
|
||||||
|
|
||||||
export default function useEnsureCustomEmoji(id: string) {
|
export default function useEnsureCustomEmoji(id: string) {
|
||||||
const lastSyncTime = useLastSyncTime();
|
const lastSyncTime = useLastSyncTime();
|
||||||
notifyCustomEmojiRender(id);
|
notifyCustomEmojiRender(id);
|
||||||
|
|||||||
32
src/hooks/useResizeObserver.ts
Normal file
32
src/hooks/useResizeObserver.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect } from '../lib/teact/teact';
|
||||||
|
import { throttle } from '../util/schedulers';
|
||||||
|
|
||||||
|
const THROTTLE = 300;
|
||||||
|
|
||||||
|
export function useResizeObserver(
|
||||||
|
ref: React.RefObject<HTMLElement> | undefined,
|
||||||
|
onResize: (entry: ResizeObserverEntry) => void,
|
||||||
|
withThrottle = false,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!('ResizeObserver' in window) || !ref?.current) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback: ResizeObserverCallback = ([entry]) => {
|
||||||
|
// During animation
|
||||||
|
if (!(entry.target as HTMLElement).offsetParent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onResize(entry);
|
||||||
|
};
|
||||||
|
const observer = new ResizeObserver(withThrottle ? throttle(callback, THROTTLE, false) : callback);
|
||||||
|
|
||||||
|
observer.observe(ref.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [onResize, ref, withThrottle]);
|
||||||
|
}
|
||||||
@ -206,7 +206,7 @@ class RLottie {
|
|||||||
setSharedCanvasCoords(containerId: string, newCoords: Params['coords']) {
|
setSharedCanvasCoords(containerId: string, newCoords: Params['coords']) {
|
||||||
const containerInfo = this.containers.get(containerId)!;
|
const containerInfo = this.containers.get(containerId)!;
|
||||||
const {
|
const {
|
||||||
canvas, ctx, isPaused, coords,
|
canvas, ctx,
|
||||||
} = containerInfo;
|
} = containerInfo;
|
||||||
|
|
||||||
if (!canvas.dataset.isJustCleaned || canvas.dataset.isJustCleaned === 'false') {
|
if (!canvas.dataset.isJustCleaned || canvas.dataset.isJustCleaned === 'false') {
|
||||||
@ -224,12 +224,10 @@ class RLottie {
|
|||||||
y: Math.round((newCoords?.y || 0) * canvas.height),
|
y: Math.round((newCoords?.y || 0) * canvas.height),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isPaused || !this.isPlaying()) {
|
const frame = this.getFrame(this.prevFrameIndex) || this.getFrame(Math.round(this.approxFrameIndex));
|
||||||
const frame = this.getFrame(this.prevFrameIndex) || this.getFrame(Math.round(this.approxFrameIndex));
|
|
||||||
|
|
||||||
if (frame && frame !== WAITING) {
|
if (frame && frame !== WAITING) {
|
||||||
ctx.drawImage(frame, coords!.x, coords!.y);
|
ctx.drawImage(frame, containerInfo.coords.x, containerInfo.coords.y);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -290,7 +290,7 @@ div[role="button"] {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shared-canvas {
|
.shared-canvas, .absolute-video-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
71
src/util/AbsoluteVideo.ts
Normal file
71
src/util/AbsoluteVideo.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import safePlay from './safePlay';
|
||||||
|
|
||||||
|
type AbsoluteVideoOptions = {
|
||||||
|
position: { x: number; y: number };
|
||||||
|
noLoop?: boolean;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class AbsoluteVideo {
|
||||||
|
private video?: HTMLVideoElement;
|
||||||
|
|
||||||
|
private isPlaying = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
videoUrl: string,
|
||||||
|
private container: HTMLElement,
|
||||||
|
private options: AbsoluteVideoOptions,
|
||||||
|
) {
|
||||||
|
this.video = document.createElement('video');
|
||||||
|
this.video.src = videoUrl;
|
||||||
|
this.video.disablePictureInPicture = true;
|
||||||
|
this.video.muted = true;
|
||||||
|
this.video.style.position = 'absolute';
|
||||||
|
this.video.load();
|
||||||
|
|
||||||
|
if (!this.options.noLoop) {
|
||||||
|
this.video.loop = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.appendChild(this.video);
|
||||||
|
this.recalculatePositionAndSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public play() {
|
||||||
|
if (this.isPlaying || !this.video) return;
|
||||||
|
this.recalculatePositionAndSize();
|
||||||
|
if (this.video.paused) {
|
||||||
|
safePlay(this.video);
|
||||||
|
}
|
||||||
|
this.isPlaying = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public pause() {
|
||||||
|
if (!this.isPlaying || !this.video) return;
|
||||||
|
if (!this.video.paused) {
|
||||||
|
this.video.pause();
|
||||||
|
}
|
||||||
|
this.isPlaying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
this.pause();
|
||||||
|
this.video?.remove();
|
||||||
|
this.video = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public updatePosition(position: AbsoluteVideoOptions['position']) {
|
||||||
|
this.options.position = position;
|
||||||
|
this.recalculatePositionAndSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private recalculatePositionAndSize() {
|
||||||
|
if (!this.video) return;
|
||||||
|
const { size, position: { x, y } } = this.options;
|
||||||
|
const { width, height } = this.container.getBoundingClientRect();
|
||||||
|
this.video.style.left = `${Math.round(x * width)}px`;
|
||||||
|
this.video.style.top = `${Math.round(y * height)}px`;
|
||||||
|
this.video.style.width = `${size}px`;
|
||||||
|
this.video.style.height = `${size}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,37 +1,124 @@
|
|||||||
|
import { addCallback } from '../lib/teact/teactn';
|
||||||
|
import { getGlobal } from '../global';
|
||||||
|
|
||||||
import { ApiMediaFormat } from '../api/types';
|
import { ApiMediaFormat } from '../api/types';
|
||||||
|
import type { ApiSticker } from '../api/types';
|
||||||
|
import type { GlobalState } from '../global/types';
|
||||||
|
|
||||||
import { getStickerPreviewHash } from '../global/helpers';
|
import { getStickerPreviewHash } from '../global/helpers';
|
||||||
import { notifyCustomEmojiRender } from '../hooks/useEnsureCustomEmoji';
|
|
||||||
import * as mediaLoader from './mediaLoader';
|
import * as mediaLoader from './mediaLoader';
|
||||||
import { throttle } from './schedulers';
|
import { throttle } from './schedulers';
|
||||||
|
import generateIdFor from './generateIdFor';
|
||||||
|
import { IS_WEBM_SUPPORTED } from './environment';
|
||||||
|
|
||||||
|
import placeholderSrc from '../assets/square.svg';
|
||||||
|
import blankSrc from '../assets/blank.png';
|
||||||
|
|
||||||
|
type CustomEmojiLoadCallback = (customEmojis: GlobalState['customEmojis']) => void;
|
||||||
|
type CustomEmojiInputRenderCallback = (emojiId: string) => void;
|
||||||
|
|
||||||
|
const ID_STORE = {};
|
||||||
const DOM_PROCESS_THROTTLE = 500;
|
const DOM_PROCESS_THROTTLE = 500;
|
||||||
|
|
||||||
|
const INPUT_WAITING_CUSTOM_EMOJI_IDS: Set<string> = new Set();
|
||||||
|
|
||||||
|
const handlers = new Map<CustomEmojiLoadCallback, string>();
|
||||||
|
const renderHandlers = new Set<CustomEmojiInputRenderCallback>();
|
||||||
|
|
||||||
|
let prevGlobal: GlobalState | undefined;
|
||||||
|
|
||||||
|
addCallback((global: GlobalState) => {
|
||||||
|
if (global.customEmojis.byId !== prevGlobal?.customEmojis.byId) {
|
||||||
|
for (const entry of handlers) {
|
||||||
|
const [handler, id] = entry;
|
||||||
|
if (global.customEmojis.byId[id]) {
|
||||||
|
handler(global.customEmojis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkInputCustomEmojiLoad(global.customEmojis);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevGlobal = global;
|
||||||
|
});
|
||||||
|
|
||||||
|
export function addCustomEmojiCallback(handler: CustomEmojiLoadCallback, emojiId: string) {
|
||||||
|
handlers.set(handler, emojiId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCustomEmojiCallback(handler: CustomEmojiLoadCallback) {
|
||||||
|
handlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCustomEmojiInputRenderCallback(handler: AnyToVoidFunction) {
|
||||||
|
renderHandlers.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCustomEmojiInputRenderCallback(handler: AnyToVoidFunction) {
|
||||||
|
renderHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
const callInputRenderHandlers = throttle((emojiId: string) => {
|
||||||
|
renderHandlers.forEach((handler) => handler(emojiId));
|
||||||
|
}, DOM_PROCESS_THROTTLE);
|
||||||
|
|
||||||
function processDomForCustomEmoji() {
|
function processDomForCustomEmoji() {
|
||||||
const emojis = document.querySelectorAll<HTMLImageElement>('.custom-emoji.placeholder');
|
const emojis = document.querySelectorAll<HTMLImageElement>('.custom-emoji.placeholder');
|
||||||
emojis.forEach((emoji) => {
|
emojis.forEach((emoji) => {
|
||||||
const emojiId = emoji.dataset.documentId!;
|
const customEmoji = getGlobal().customEmojis.byId[emoji.dataset.documentId!];
|
||||||
const mediaHash = getStickerPreviewHash(emojiId);
|
if (!customEmoji) {
|
||||||
const mediaData = mediaLoader.getFromMemory(mediaHash);
|
INPUT_WAITING_CUSTOM_EMOJI_IDS.add(emoji.dataset.documentId!);
|
||||||
if (mediaData) {
|
return;
|
||||||
emoji.src = mediaData;
|
}
|
||||||
emoji.classList.remove('placeholder');
|
const [isPlaceholder, src, uniqueId] = getInputCustomEmojiParams(customEmoji);
|
||||||
|
|
||||||
notifyCustomEmojiRender(emojiId);
|
if (!isPlaceholder) {
|
||||||
|
emoji.src = src;
|
||||||
|
emoji.classList.remove('placeholder');
|
||||||
|
if (uniqueId) emoji.dataset.uniqueId = uniqueId;
|
||||||
|
|
||||||
|
callInputRenderHandlers(customEmoji.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const processMessageInputForCustomEmoji = throttle(processDomForCustomEmoji, DOM_PROCESS_THROTTLE);
|
export const processMessageInputForCustomEmoji = throttle(processDomForCustomEmoji, DOM_PROCESS_THROTTLE);
|
||||||
|
|
||||||
export function getCustomEmojiPreviewMediaData(emojiId: string) {
|
function checkInputCustomEmojiLoad(customEmojis: GlobalState['customEmojis']) {
|
||||||
const mediaHash = getStickerPreviewHash(emojiId);
|
const loaded = Array.from(INPUT_WAITING_CUSTOM_EMOJI_IDS).filter((id) => Boolean(customEmojis.byId[id]));
|
||||||
|
if (loaded.length) {
|
||||||
|
loaded.forEach((id) => INPUT_WAITING_CUSTOM_EMOJI_IDS.delete(id));
|
||||||
|
processMessageInputForCustomEmoji();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomEmojiMediaDataForInput(emojiId: string, isPreview?: boolean) {
|
||||||
|
const mediaHash = isPreview ? getStickerPreviewHash(emojiId) : `sticker${emojiId}`;
|
||||||
const data = mediaLoader.getFromMemory(mediaHash);
|
const data = mediaLoader.getFromMemory(mediaHash);
|
||||||
if (data) {
|
if (data) {
|
||||||
notifyCustomEmojiRender(emojiId);
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaLoader.fetch(mediaHash, ApiMediaFormat.BlobUrl).then(() => processMessageInputForCustomEmoji());
|
fetchAndProcess(mediaHash);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchAndProcess(mediaHash: string) {
|
||||||
|
return mediaLoader.fetch(mediaHash, ApiMediaFormat.BlobUrl).then(() => {
|
||||||
|
processMessageInputForCustomEmoji();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInputCustomEmojiParams(customEmoji?: ApiSticker) {
|
||||||
|
if (!customEmoji) return [true, placeholderSrc, undefined];
|
||||||
|
const shouldUseStaticFallback = !IS_WEBM_SUPPORTED && customEmoji.isVideo;
|
||||||
|
const isUsingSharedCanvas = customEmoji.isLottie || (customEmoji.isVideo && !shouldUseStaticFallback);
|
||||||
|
if (isUsingSharedCanvas) {
|
||||||
|
fetchAndProcess(`sticker${customEmoji.id}`);
|
||||||
|
return [false, blankSrc, generateIdFor(ID_STORE, true)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaData = getCustomEmojiMediaDataForInput(customEmoji.id, shouldUseStaticFallback);
|
||||||
|
|
||||||
|
return [!mediaData, mediaData || placeholderSrc, undefined];
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user