mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(app): resize the expression picker and snap to emoji columns (#1924)
This commit is contained in:
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)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user