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

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:46:30 +02:00
1 parent eb4f41e80c
commit fbd653d8e0
8 files changed
+259 -99

No files matched your search

@@ -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)}
/>
))}
</>
);
}