feat(app): resize the expression picker and snap to emoji columns (#1924)

This commit is contained in:
Hampus
2026-08-24 12:46:30 +02:00
committed by GitHub
parent eb4f41e80c
commit fbd653d8e0
8 changed files with 259 additions and 99 deletions
@@ -52,6 +52,7 @@ interface FloatingState {
isReady: boolean;
offsetX: number;
offsetY: number;
placement: Placement;
}
interface UseAntiShiftFloatingOptions {
@@ -280,7 +281,7 @@ export function useAntiShiftFloating(
const floatingRef = useRef<HTMLElement>(null);
const [state, setState] = useState<FloatingState>(() => {
const {x, y} = target ? getInitialGuess(target, placement, offsetMainAxis, offsetCrossAxis) : {x: -9999, y: -9999};
return {x, y, isReady: false, offsetX: 0, offsetY: 0};
return {x, y, isReady: false, offsetX: 0, offsetY: 0, placement};
});
const cleanupRef = useRef<(() => void) | null>(null);
const isCalculatingRef = useRef(false);
@@ -345,7 +346,11 @@ export function useAntiShiftFloating(
}),
);
}
const {x, y} = await computePosition(target, floating, {
const {
x,
y,
placement: resolvedPlacement,
} = await computePosition(target, floating, {
placement,
middleware,
});
@@ -387,11 +392,18 @@ export function useAntiShiftFloating(
prev.y === safeBasePosition.y &&
prev.isReady &&
prev.offsetX === nextOffset.x &&
prev.offsetY === nextOffset.y
prev.offsetY === nextOffset.y &&
prev.placement === resolvedPlacement
) {
return prev;
}
return {...safeBasePosition, isReady: true, offsetX: nextOffset.x, offsetY: nextOffset.y};
return {
...safeBasePosition,
isReady: true,
offsetX: nextOffset.x,
offsetY: nextOffset.y,
placement: resolvedPlacement,
};
});
} catch (error) {
logger.error('Error positioning floating element', error);
@@ -87,47 +87,6 @@
background-color: var(--background-primary);
}
.resizeCorner {
position: absolute;
width: 1.25rem;
height: 1.25rem;
z-index: 5;
border: none;
background: transparent;
padding: 0;
pointer-events: auto;
touch-action: none;
}
.resizeCorner:focus-visible {
outline: 0.125rem solid var(--focus-primary);
outline-offset: -0.125rem;
}
.resizeCornerTopLeft {
top: 0;
left: 0;
cursor: nwse-resize;
}
.resizeCornerTopRight {
top: 0;
right: 0;
cursor: nesw-resize;
}
.resizeCornerBottomLeft {
bottom: 0;
left: 0;
cursor: nesw-resize;
}
.resizeCornerBottomRight {
right: 0;
bottom: 0;
cursor: nwse-resize;
}
:global(html.expression-picker-resizing),
:global(html.expression-picker-resizing) * {
cursor: var(--expression-picker-resize-cursor, nwse-resize) !important;
@@ -2,6 +2,10 @@
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {EmojiPicker} from '@app/features/channel/components/EmojiPicker';
import {
EMOJI_GRID_SCROLLER_PADDING_WIDTH,
EMOJI_GRID_TRACK_WIDTH,
} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants';
import {GifPicker} from '@app/features/channel/components/pickers/gif/GifPicker';
import {MemesPicker} from '@app/features/channel/components/pickers/memes/MemesPicker';
import {StickersPicker} from '@app/features/channel/components/StickersPicker';
@@ -20,10 +24,13 @@ import {
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import type {ResizeEdge} from '@app/features/ui/floating_pane/FloatingPaneMath';
import {useResizablePane} from '@app/features/ui/hooks/useResizablePane';
import {
type ResizablePaneHandleLabels,
ResizablePaneHandles,
} from '@app/features/ui/resizable_pane/ResizablePaneHandles';
import {getNextTabIndex, getTabNavigationDirection} from '@app/features/ui/tabs/TabKeyboardNavigation';
import type {I18n, MessageDescriptor} from '@lingui/core';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
@@ -31,6 +38,22 @@ import {observer} from 'mobx-react-lite';
import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import ReactDOM from 'react-dom';
const RESIZE_EXPRESSION_PICKER_TOP_DESCRIPTOR = msg({
message: 'Resize expression picker from the top edge',
comment: 'Accessible label for the top resize handle on the expression picker popout.',
});
const RESIZE_EXPRESSION_PICKER_BOTTOM_DESCRIPTOR = msg({
message: 'Resize expression picker from the bottom edge',
comment: 'Accessible label for the bottom resize handle on the expression picker popout.',
});
const RESIZE_EXPRESSION_PICKER_LEFT_DESCRIPTOR = msg({
message: 'Resize expression picker from the left edge',
comment: 'Accessible label for the left resize handle on the expression picker popout.',
});
const RESIZE_EXPRESSION_PICKER_RIGHT_DESCRIPTOR = msg({
message: 'Resize expression picker from the right edge',
comment: 'Accessible label for the right resize handle on the expression picker popout.',
});
const RESIZE_EXPRESSION_PICKER_TOP_LEFT_DESCRIPTOR = msg({
message: 'Resize expression picker from top left',
comment: 'Accessible label for the top-left resize handle on the expression picker popout.',
@@ -48,18 +71,20 @@ const RESIZE_EXPRESSION_PICKER_BOTTOM_RIGHT_DESCRIPTOR = msg({
comment: 'Accessible label for the bottom-right resize handle on the expression picker popout.',
});
const EXPRESSION_PICKER_DEFAULT_SIZE = {width: 578, height: 690};
const EXPRESSION_PICKER_MIN_SIZE = {width: 360, height: 360};
export const EXPRESSION_PICKER_CATEGORY_RAIL_WIDTH = 46;
export const EXPRESSION_PICKER_BORDER_WIDTH = 1;
export const EXPRESSION_PICKER_GRID_CHROME_WIDTH =
EXPRESSION_PICKER_CATEGORY_RAIL_WIDTH + EXPRESSION_PICKER_BORDER_WIDTH * 2 + EMOJI_GRID_SCROLLER_PADDING_WIDTH * 2;
export const EXPRESSION_PICKER_WIDTH_SNAP = {
step: EMOJI_GRID_TRACK_WIDTH,
offset: EXPRESSION_PICKER_GRID_CHROME_WIDTH,
};
export const EXPRESSION_PICKER_DEFAULT_SIZE = {width: 592, height: 690};
export const EXPRESSION_PICKER_MIN_SIZE = {width: 360, height: 360};
const EXPRESSION_PICKER_VIEWPORT_PADDING = 16;
const EXPRESSION_PICKER_RESIZING_CLASS = 'expression-picker-resizing';
const EXPRESSION_PICKER_RESIZE_CURSOR_PROPERTY = '--expression-picker-resize-cursor';
interface ResizeHandleSpec {
edge: ResizeEdge;
className: string;
label: MessageDescriptor;
}
interface ExpressionPickerHeaderContextType {
headerPortalElement: HTMLDivElement | null;
}
@@ -148,28 +173,16 @@ interface ExpressionPickerPopoutProps {
onTabChange?: (tab: ExpressionPickerTabType) => void;
}
const RESIZE_HANDLES: ReadonlyArray<ResizeHandleSpec> = [
{
edge: 'top-left',
className: styles.resizeCornerTopLeft,
label: RESIZE_EXPRESSION_PICKER_TOP_LEFT_DESCRIPTOR,
},
{
edge: 'top-right',
className: styles.resizeCornerTopRight,
label: RESIZE_EXPRESSION_PICKER_TOP_RIGHT_DESCRIPTOR,
},
{
edge: 'bottom-left',
className: styles.resizeCornerBottomLeft,
label: RESIZE_EXPRESSION_PICKER_BOTTOM_LEFT_DESCRIPTOR,
},
{
edge: 'bottom-right',
className: styles.resizeCornerBottomRight,
label: RESIZE_EXPRESSION_PICKER_BOTTOM_RIGHT_DESCRIPTOR,
},
];
const RESIZE_HANDLE_LABELS: ResizablePaneHandleLabels = {
top: RESIZE_EXPRESSION_PICKER_TOP_DESCRIPTOR,
bottom: RESIZE_EXPRESSION_PICKER_BOTTOM_DESCRIPTOR,
left: RESIZE_EXPRESSION_PICKER_LEFT_DESCRIPTOR,
right: RESIZE_EXPRESSION_PICKER_RIGHT_DESCRIPTOR,
'top-left': RESIZE_EXPRESSION_PICKER_TOP_LEFT_DESCRIPTOR,
'top-right': RESIZE_EXPRESSION_PICKER_TOP_RIGHT_DESCRIPTOR,
'bottom-left': RESIZE_EXPRESSION_PICKER_BOTTOM_LEFT_DESCRIPTOR,
'bottom-right': RESIZE_EXPRESSION_PICKER_BOTTOM_RIGHT_DESCRIPTOR,
};
export const ExpressionPickerPopout = observer(
({
@@ -220,6 +233,7 @@ export const ExpressionPickerPopout = observer(
storageKey: 'fluxer:ui:expression-picker-size',
defaultSize: EXPRESSION_PICKER_DEFAULT_SIZE,
minSize: EXPRESSION_PICKER_MIN_SIZE,
widthSnap: EXPRESSION_PICKER_WIDTH_SNAP,
viewportPadding: EXPRESSION_PICKER_VIEWPORT_PADDING,
resizingClassName: EXPRESSION_PICKER_RESIZING_CLASS,
cursorProperty: EXPRESSION_PICKER_RESIZE_CURSOR_PROPERTY,
@@ -325,17 +339,11 @@ export const ExpressionPickerPopout = observer(
<div className={styles.content} data-flx="expressions.expression-picker-popout.content">
{selectedCategory.renderComponent({channelId, onSelect: handleEmojiSelect, onClose})}
</div>
{RESIZE_HANDLES.map(({edge, className, label}) => (
<button
key={edge}
type="button"
aria-label={i18n._(label)}
aria-keyshortcuts="ArrowLeft ArrowRight ArrowUp ArrowDown Home End Enter Backspace Delete"
className={clsx(styles.resizeCorner, className)}
data-flx="expressions.expression-picker-popout.resize-corner.button"
{...getHandleProps(edge)}
/>
))}
<ResizablePaneHandles
getHandleProps={getHandleProps}
labels={RESIZE_HANDLE_LABELS}
data-flx="expressions.expression-picker-popout.resizable-pane-handles"
/>
</div>
</ExpressionPickerHeaderContext.Provider>
);
@@ -17,10 +17,16 @@ export interface ResizablePaneSize {
height: number;
}
export interface ResizablePaneWidthSnap {
readonly step: number;
readonly offset: number;
}
export interface UseResizablePaneOptions {
readonly storageKey: string;
readonly defaultSize: ResizablePaneSize;
readonly minSize: ResizablePaneSize;
readonly widthSnap?: ResizablePaneWidthSnap | null;
readonly viewportPadding: number;
readonly resizingClassName: string;
readonly cursorProperty: string;
@@ -80,11 +86,22 @@ const RESIZABLE_PANE_SIZE_JSON_MAX_LENGTH =
const TITLEBAR_SELECTOR = '[data-native-titlebar]';
const logger = new Logger('useResizablePane');
function clampDimension(value: number, configuredMin: number, availableMax: number): number {
export function clampResizablePaneDimension(
value: number,
configuredMin: number,
availableMax: number,
snap?: ResizablePaneWidthSnap | null,
): number {
const max = Math.max(1, Math.floor(availableMax));
const min = Math.min(configuredMin, max);
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(Math.round(value), max));
const requested = Number.isFinite(value) ? Math.round(value) : min;
const bounded = Math.max(min, Math.min(requested, max));
if (snap == null || !(snap.step > 0)) return bounded;
const fitted = snap.offset + Math.floor((bounded - snap.offset) / snap.step) * snap.step;
if (fitted >= min) return fitted;
const raised = fitted + snap.step;
if (raised <= max) return raised;
return fitted > 0 ? fitted : bounded;
}
function getUsableAppZoomFactor(ownerDocument: Document): number {
@@ -281,24 +298,25 @@ export function useResizablePane(
edge: ResizeEdge | null,
anchorRect: ResizeAnchorRect | null,
): ResizablePaneSize => {
const {minSize, viewportPadding} = optionsRef.current;
const {minSize, viewportPadding, widthSnap} = optionsRef.current;
const unbounded = Number.POSITIVE_INFINITY;
if (ownerDocument == null) {
return {
width: Math.max(minSize.width, Math.round(size.width)),
height: Math.max(minSize.height, Math.round(size.height)),
width: clampResizablePaneDimension(size.width, minSize.width, unbounded, widthSnap),
height: clampResizablePaneDimension(size.height, minSize.height, unbounded),
};
}
const zoom = getUsableAppZoomFactor(ownerDocument);
const maximumSize = getResizeMaximumSize(ownerDocument, viewportPadding, zoom, edge, anchorRect);
if (maximumSize == null) {
return {
width: Math.max(minSize.width, Math.round(size.width)),
height: Math.max(minSize.height, Math.round(size.height)),
width: clampResizablePaneDimension(size.width, minSize.width, unbounded, widthSnap),
height: clampResizablePaneDimension(size.height, minSize.height, unbounded),
};
}
return {
width: clampDimension(size.width, minSize.width, maximumSize.width),
height: clampDimension(size.height, minSize.height, maximumSize.height),
width: clampResizablePaneDimension(size.width, minSize.width, maximumSize.width, widthSnap),
height: clampResizablePaneDimension(size.height, minSize.height, maximumSize.height),
};
},
[],
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PopoutPosition} from '@app/features/ui/popover';
import {createContext, useContext} from 'react';
export interface PopoutResizePositionOffset {
@@ -14,6 +15,7 @@ export interface PopoutResizePositionSession {
export interface PopoutResizePositionController {
begin: () => PopoutResizePositionSession;
placement?: PopoutPosition;
}
export const PopoutResizePositionContext = createContext<PopoutResizePositionController | null>(null);
@@ -25,3 +27,9 @@ export function usePopoutResizePositionController(): PopoutResizePositionControl
}
return controller;
}
export function usePopoutResizePlacement(): PopoutPosition | null {
const controller = useContext(PopoutResizePositionContext);
if (controller == null || controller.placement == null) return null;
return controller.placement;
}
@@ -198,7 +198,10 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
constrainHeight,
enableSmartBoundary: true,
});
const resizePositionController = useMemo(() => ({begin: beginManualPositioning}), [beginManualPositioning]);
const resizePositionController = useMemo(
() => ({begin: beginManualPositioning, placement: state.placement}),
[beginManualPositioning, state.placement],
);
const {refs: focusRefs, context: focusContext} = useFloating({open: true});
useLayoutEffect(() => {
focusRefs.setReference(target);
@@ -0,0 +1,81 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.handle {
position: absolute;
z-index: 5;
border: none;
background: transparent;
padding: 0;
pointer-events: auto;
touch-action: none;
}
.handle:focus-visible {
outline: 0.125rem solid var(--focus-primary);
outline-offset: -0.125rem;
}
.edgeTop {
top: 0;
left: 0;
right: 0;
height: 0.5rem;
cursor: n-resize;
}
.edgeBottom {
bottom: 0;
left: 0;
right: 0;
height: 0.5rem;
cursor: s-resize;
}
.edgeLeft {
top: 0;
left: 0;
bottom: 0;
width: 0.5rem;
cursor: w-resize;
}
.edgeRight {
top: 0;
right: 0;
bottom: 0;
width: 0.5rem;
cursor: e-resize;
}
.cornerTopLeft,
.cornerTopRight,
.cornerBottomLeft,
.cornerBottomRight {
width: 1.25rem;
height: 1.25rem;
z-index: 6;
}
.cornerTopLeft {
top: 0;
left: 0;
cursor: nwse-resize;
}
.cornerTopRight {
top: 0;
right: 0;
cursor: nesw-resize;
}
.cornerBottomLeft {
bottom: 0;
left: 0;
cursor: nesw-resize;
}
.cornerBottomRight {
right: 0;
bottom: 0;
cursor: nwse-resize;
}
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ALL_RESIZE_EDGES, type ResizeEdge} from '@app/features/ui/floating_pane/FloatingPaneMath';
import type {ResizablePaneHandleProps} from '@app/features/ui/hooks/useResizablePane';
import type {PopoutPosition} from '@app/features/ui/popover';
import {usePopoutResizePlacement} from '@app/features/ui/popover/PopoutResizePositionContext';
import styles from '@app/features/ui/resizable_pane/ResizablePaneHandles.module.css';
import type {MessageDescriptor} from '@lingui/core';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
const EDGES_AWAY_FROM_ANCHOR: Readonly<Record<PopoutPosition, ReadonlyArray<ResizeEdge>>> = {
top: ['top'],
bottom: ['bottom'],
left: ['left'],
right: ['right'],
'top-start': ['top', 'right', 'top-right'],
'top-end': ['top', 'left', 'top-left'],
'bottom-start': ['bottom', 'right', 'bottom-right'],
'bottom-end': ['bottom', 'left', 'bottom-left'],
'left-start': ['left', 'bottom', 'bottom-left'],
'left-end': ['left', 'top', 'top-left'],
'right-start': ['right', 'bottom', 'bottom-right'],
'right-end': ['right', 'top', 'top-right'],
};
export function getResizablePaneHandleEdges(placement: PopoutPosition | null): ReadonlyArray<ResizeEdge> {
if (placement == null) return ALL_RESIZE_EDGES;
return EDGES_AWAY_FROM_ANCHOR[placement];
}
const RESIZABLE_PANE_HANDLE_KEY_SHORTCUTS = 'ArrowLeft ArrowRight ArrowUp ArrowDown Home End Enter Backspace Delete';
export type ResizablePaneHandleLabels = Readonly<Record<ResizeEdge, MessageDescriptor>>;
const EDGE_CLASS_NAMES: Readonly<Record<ResizeEdge, string>> = {
top: styles.edgeTop,
bottom: styles.edgeBottom,
left: styles.edgeLeft,
right: styles.edgeRight,
'top-left': styles.cornerTopLeft,
'top-right': styles.cornerTopRight,
'bottom-left': styles.cornerBottomLeft,
'bottom-right': styles.cornerBottomRight,
};
export interface ResizablePaneHandlesProps {
getHandleProps: (edge: ResizeEdge) => ResizablePaneHandleProps;
labels: ResizablePaneHandleLabels;
}
export function ResizablePaneHandles({getHandleProps, labels}: ResizablePaneHandlesProps) {
const {i18n} = useLingui();
const placement = usePopoutResizePlacement();
return (
<>
{getResizablePaneHandleEdges(placement).map((edge) => (
<button
key={edge}
type="button"
aria-label={i18n._(labels[edge])}
aria-keyshortcuts={RESIZABLE_PANE_HANDLE_KEY_SHORTCUTS}
className={clsx(styles.handle, EDGE_CLASS_NAMES[edge])}
data-resizable-pane-edge={edge}
data-flx="ui.resizable-pane.resizable-pane-handles.handle.button"
{...getHandleProps(edge)}
/>
))}
</>
);
}