fix: refine container interactions
This commit is contained in:
1 parent
34f2a0bd24
commit
dfe2ddf34b
11 files changed
+340
-144
No files matched your search
@@ -335,10 +335,12 @@ Each Container can configure:
|
||||
Open the add-content menu and choose **New Container**.
|
||||
In edit mode, move and resize it like any other item, then drag items into or out of it.
|
||||
Nested content stays within the Container's bounds.
|
||||
While resizing, the solid outline follows the pointer continuously and the dashed grid preview shows the size that will be saved on release.
|
||||
|
||||
### Collapsing and opening apps
|
||||
|
||||
Users can collapse an eligible Container like an accordion in view mode without changing its saved expanded size.
|
||||
The full-width header becomes the compact expansion bar, so the half-row state has no empty pill-shaped spacer.
|
||||
Expanding it restores its bounds and makes room for its contents.
|
||||
If **Open all in new tabs** is enabled, the Container menu can open every app link it contains.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
});
|
||||
const label = options.title.trim() || t("untitled");
|
||||
const contentId = `board-container-${section.id}-content`;
|
||||
const labelLeft = options.collapsible && !isEditMode ? 40 : 8;
|
||||
const labelLeft = isEditMode ? 40 : 8;
|
||||
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
|
||||
|
||||
return (
|
||||
@@ -62,29 +62,29 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{isVisuallyCollapsed && (
|
||||
{options.collapsible && !isEditMode && (
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
top={0}
|
||||
top={isVisuallyCollapsed ? 0 : -28}
|
||||
left={0}
|
||||
maw={`calc(100% - ${options.showOpenAll ? 40 : 0}px)`}
|
||||
size="compact-sm"
|
||||
radius="xl"
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 28}
|
||||
px={12}
|
||||
pe={options.showOpenAll ? 40 : 12}
|
||||
radius={isVisuallyCollapsed ? board.itemRadius : "sm"}
|
||||
variant="default"
|
||||
leftSection={<IconChevronDown size={14} />}
|
||||
justify={options.showLabel ? "flex-start" : "center"}
|
||||
leftSection={options.showLabel ? isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} /> : undefined}
|
||||
onClick={toggle}
|
||||
aria-expanded={false}
|
||||
aria-expanded={!isVisuallyCollapsed}
|
||||
aria-controls={contentId}
|
||||
aria-label={`${t("action.expand")}: ${label}`}
|
||||
data-board-container-collapsed-control
|
||||
styles={{
|
||||
label: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
}}
|
||||
aria-label={`${t(isVisuallyCollapsed ? "action.expand" : "action.collapse")}: ${label}`}
|
||||
data-board-container-collapsed-control={isVisuallyCollapsed ? "true" : undefined}
|
||||
data-board-container-label
|
||||
title={options.showLabel ? label : undefined}
|
||||
>
|
||||
{label}
|
||||
{options.showLabel ? label : isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} />}
|
||||
</Button>
|
||||
)}
|
||||
{isEditMode && (
|
||||
@@ -103,7 +103,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
<IconGripVertical size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isVisuallyCollapsed && options.showLabel && options.title && (
|
||||
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
@@ -128,30 +128,13 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
{options.title}
|
||||
</Badge>
|
||||
)}
|
||||
{options.collapsible && !isEditMode && !isVisuallyCollapsed && (
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
left={4}
|
||||
style={{ zIndex: 10 }}
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
onClick={toggle}
|
||||
aria-expanded={!isVisuallyCollapsed}
|
||||
aria-controls={contentId}
|
||||
aria-label={`${isVisuallyCollapsed ? t("action.expand") : t("action.collapse")}: ${label}`}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{options.showOpenAll && !isEditMode && (
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? 0 : -24}
|
||||
right={4}
|
||||
style={{ zIndex: 10 }}
|
||||
variant="default"
|
||||
top={isVisuallyCollapsed ? "50%" : -26}
|
||||
right={2}
|
||||
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
|
||||
variant={options.collapsible ? "subtle" : "default"}
|
||||
size={24}
|
||||
radius="sm"
|
||||
loading={areAppsLoading}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import { getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta } from "./coordinates";
|
||||
import {
|
||||
getContinuousGridDelta,
|
||||
getContinuousResizePlacement,
|
||||
getPointerProjectedShape,
|
||||
getSnappedGridCoordinates,
|
||||
getSnappedGridDelta,
|
||||
} from "./coordinates";
|
||||
|
||||
describe("dnd grid coordinates", () => {
|
||||
test.each([0.75, 1, 1.25, 2])("maps transformed drag geometry at %sx scale", (scale) => {
|
||||
@@ -64,6 +70,56 @@ describe("dnd grid coordinates", () => {
|
||||
).toEqual({ x: -2, y: 4 });
|
||||
});
|
||||
|
||||
test("keeps the visual resize continuous between snapped grid breakpoints", () => {
|
||||
const visualScale = 0.75;
|
||||
const delta = getContinuousGridDelta({
|
||||
initial: { x: 10, y: 20 },
|
||||
current: {
|
||||
x: 10 + LOGICAL_GRID_PITCH * visualScale * 0.42,
|
||||
y: 20 + LOGICAL_GRID_PITCH * visualScale * 0.68,
|
||||
},
|
||||
visualScale,
|
||||
});
|
||||
|
||||
expect(delta?.x).toBeCloseTo(0.42);
|
||||
expect(delta?.y).toBeCloseTo(0.68);
|
||||
expect(
|
||||
getSnappedGridDelta({
|
||||
initial: { x: 10, y: 20 },
|
||||
current: {
|
||||
x: 10 + LOGICAL_GRID_PITCH * visualScale * 0.42,
|
||||
y: 20 + LOGICAL_GRID_PITCH * visualScale * 0.68,
|
||||
},
|
||||
visualScale,
|
||||
}),
|
||||
).toEqual({ x: 0, y: 1 });
|
||||
});
|
||||
|
||||
test("projects a continuous resize while anchoring the opposite edges and respecting bounds", () => {
|
||||
const placement = { id: "widget", x: 2, y: 3, w: 3, h: 2 };
|
||||
|
||||
expect(
|
||||
getContinuousResizePlacement({
|
||||
placement,
|
||||
direction: "nw",
|
||||
deltaColumns: 0.35,
|
||||
deltaRows: -0.6,
|
||||
columnCount: 8,
|
||||
maxRowCount: 10,
|
||||
}),
|
||||
).toEqual({ id: "widget", x: 2.35, y: 2.4, w: 2.65, h: 2.6 });
|
||||
expect(
|
||||
getContinuousResizePlacement({
|
||||
placement,
|
||||
direction: "se",
|
||||
deltaColumns: 99,
|
||||
deltaRows: 99,
|
||||
columnCount: 8,
|
||||
maxRowCount: 7,
|
||||
}),
|
||||
).toEqual({ id: "widget", x: 2, y: 3, w: 6, h: 4 });
|
||||
});
|
||||
|
||||
test("uses independently measured visual axes for transformed resize geometry", () => {
|
||||
expect(
|
||||
getSnappedGridDelta({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import type { GridResizeDirection } from "./types";
|
||||
|
||||
interface RectangleLike {
|
||||
left: number;
|
||||
@@ -54,11 +56,78 @@ export const getSnappedGridDelta = ({
|
||||
current: { x: number; y: number };
|
||||
initial: { x: number; y: number };
|
||||
visualScale: number | { x: number; y: number };
|
||||
}) => {
|
||||
const delta = getContinuousGridDelta({ current, initial, visualScale });
|
||||
if (!delta) return null;
|
||||
return {
|
||||
x: Math.round(delta.x),
|
||||
y: Math.round(delta.y),
|
||||
};
|
||||
};
|
||||
|
||||
export const getContinuousGridDelta = ({
|
||||
current,
|
||||
initial,
|
||||
visualScale,
|
||||
}: {
|
||||
current: { x: number; y: number };
|
||||
initial: { x: number; y: number };
|
||||
visualScale: number | { x: number; y: number };
|
||||
}) => {
|
||||
const scale = typeof visualScale === "number" ? { x: visualScale, y: visualScale } : visualScale;
|
||||
if (!Number.isFinite(scale.x) || scale.x <= 0 || !Number.isFinite(scale.y) || scale.y <= 0) return null;
|
||||
return {
|
||||
x: Math.round((current.x - initial.x) / (LOGICAL_GRID_PITCH * scale.x)),
|
||||
y: Math.round((current.y - initial.y) / (LOGICAL_GRID_PITCH * scale.y)),
|
||||
x: (current.x - initial.x) / (LOGICAL_GRID_PITCH * scale.x),
|
||||
y: (current.y - initial.y) / (LOGICAL_GRID_PITCH * scale.y),
|
||||
};
|
||||
};
|
||||
|
||||
/** Projects a resize continuously while the committed layout remains snapped. */
|
||||
export const getContinuousResizePlacement = <TPlacement extends GridPlacement>({
|
||||
placement,
|
||||
direction,
|
||||
deltaColumns,
|
||||
deltaRows,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
minWidth = 1,
|
||||
minHeight = 1,
|
||||
}: {
|
||||
placement: TPlacement;
|
||||
direction: GridResizeDirection;
|
||||
deltaColumns: number;
|
||||
deltaRows: number;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
}): TPlacement => {
|
||||
const fixedRight = placement.x + placement.w;
|
||||
const fixedBottom = placement.y + placement.h;
|
||||
let x = placement.x;
|
||||
let y = placement.y;
|
||||
let w = placement.w;
|
||||
let h = placement.h;
|
||||
|
||||
if (direction.includes("w")) {
|
||||
w = clamp(placement.w - deltaColumns, minWidth, fixedRight);
|
||||
x = fixedRight - w;
|
||||
} else if (direction.includes("e")) {
|
||||
w = clamp(placement.w + deltaColumns, minWidth, columnCount - placement.x);
|
||||
}
|
||||
|
||||
if (direction.includes("n")) {
|
||||
h = clamp(placement.h - deltaRows, minHeight, fixedBottom);
|
||||
y = fixedBottom - h;
|
||||
} else if (direction.includes("s")) {
|
||||
h = clamp(
|
||||
placement.h + deltaRows,
|
||||
minHeight,
|
||||
maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - placement.y,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...placement, x, y, w, h };
|
||||
};
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.min(Math.max(value, minimum), maximum);
|
||||
@@ -6,7 +6,13 @@ export {
|
||||
previewGridResize,
|
||||
resolvePinnedGridCollisions,
|
||||
} from "./engine";
|
||||
export { getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta } from "./coordinates";
|
||||
export {
|
||||
getContinuousGridDelta,
|
||||
getContinuousResizePlacement,
|
||||
getPointerProjectedShape,
|
||||
getSnappedGridCoordinates,
|
||||
getSnappedGridDelta,
|
||||
} from "./coordinates";
|
||||
export type { DragProjectionOrigin } from "./coordinates";
|
||||
export { getPreferredNestedExitTargetId } from "./targeting";
|
||||
export type { GridTargetGeometry } from "./targeting";
|
||||
|
||||
@@ -5,16 +5,7 @@ import { createContext, useContext } from "react";
|
||||
|
||||
export type GridEditorRuntimeStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export interface GridResizePreview {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
const GridEditorRuntimeContext = createContext<GridEditorRuntimeStatus>("idle");
|
||||
const GridResizePreviewContext = createContext<GridResizePreview | null>(null);
|
||||
|
||||
export const GridEditorRuntimeProvider = ({
|
||||
children,
|
||||
@@ -24,15 +15,3 @@ export const GridEditorRuntimeProvider = ({
|
||||
);
|
||||
|
||||
export const useGridEditorRuntimeStatus = () => useContext(GridEditorRuntimeContext);
|
||||
|
||||
export const GridResizePreviewProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: PropsWithChildren<{ value: GridResizePreview | null }>) => (
|
||||
<GridResizePreviewContext.Provider value={value}>{children}</GridResizePreviewContext.Provider>
|
||||
);
|
||||
|
||||
export const useGridResizePreview = (entryId: string) => {
|
||||
const preview = useContext(GridResizePreviewContext);
|
||||
return preview?.id === entryId ? preview : null;
|
||||
};
|
||||
@@ -73,8 +73,8 @@
|
||||
zoom: var(--board-grid-overlay-content-zoom, 1);
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-entry,
|
||||
body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
body[data-board-grid-interacting="drag"] .board-grid-entry,
|
||||
body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,24 @@ body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
height 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-placeholder[data-grid-placeholder-mode="resize"] {
|
||||
z-index: 1001;
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent);
|
||||
}
|
||||
|
||||
.board-grid-resize-outline {
|
||||
z-index: 1002;
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
|
||||
border: 2px solid var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.board-grid-resize-outline[data-grid-resize-valid="false"] {
|
||||
background: color-mix(in srgb, var(--mantine-color-red-filled) 8%, transparent);
|
||||
border-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
.board-grid-editor[data-dnd-drop-target="true"] {
|
||||
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
outline-offset: 4px;
|
||||
@@ -110,7 +128,7 @@ body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
|
||||
.board-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
z-index: 20;
|
||||
display: block;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
@@ -246,6 +264,7 @@ body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.board-grid-entry,
|
||||
.board-grid-placeholder,
|
||||
.board-grid-resize-outline,
|
||||
.board-grid-resize-handle::after {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
beginGridTransaction,
|
||||
cancelGridTransaction,
|
||||
commitGridTransaction,
|
||||
getContinuousGridDelta,
|
||||
getContinuousResizePlacement,
|
||||
getPreferredNestedExitTargetId,
|
||||
getPointerProjectedShape,
|
||||
getSnappedGridCoordinates,
|
||||
@@ -45,7 +47,6 @@ import {
|
||||
} from "./dnd";
|
||||
import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, TransactionalGridState } from "./dnd";
|
||||
import { getGridDepth } from "./grid-depth";
|
||||
import { GridResizePreviewProvider } from "./grid-editor-runtime";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
@@ -602,7 +603,10 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
activeId: active.transaction.activeId,
|
||||
sourceGridId: active.transaction.sourceGridId,
|
||||
targetGridId: active.transaction.sourceGridId,
|
||||
targetPlacement: null,
|
||||
targetPlacement:
|
||||
result.transaction.preview.grids
|
||||
.find((grid) => grid.id === active.transaction.sourceGridId)
|
||||
?.placements.find((placement) => placement.id === active.transaction.activeId) ?? null,
|
||||
state: result.transaction.preview,
|
||||
mode: "resize",
|
||||
valid: true,
|
||||
@@ -644,15 +648,6 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
registerGrid,
|
||||
],
|
||||
);
|
||||
const resizePreview = useMemo(() => {
|
||||
if (interaction?.mode !== "resize") return null;
|
||||
return (
|
||||
interaction.state.grids
|
||||
.flatMap((grid) => grid.placements)
|
||||
.find((placement) => placement.id === interaction.activeId) ?? null
|
||||
);
|
||||
}, [interaction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (interaction?.mode !== "resize") return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -666,35 +661,33 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
return (
|
||||
<BoardGridEditorContext.Provider value={value}>
|
||||
<GridResizePreviewProvider value={resizePreview}>
|
||||
<DragDropProvider
|
||||
plugins={configureEditorPlugins}
|
||||
onCollision={handleCollision}
|
||||
onBeforeDragStart={handleBeforeDragStart}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
<DragDropProvider
|
||||
plugins={configureEditorPlugins}
|
||||
onCollision={handleCollision}
|
||||
onBeforeDragStart={handleBeforeDragStart}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay
|
||||
className="board-grid-drag-overlay"
|
||||
dropAnimation={null}
|
||||
style={
|
||||
{
|
||||
zoom: overlayZoom,
|
||||
"--board-grid-overlay-content-zoom": 1 / overlayZoom,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay
|
||||
className="board-grid-drag-overlay"
|
||||
dropAnimation={null}
|
||||
style={
|
||||
{
|
||||
zoom: overlayZoom,
|
||||
"--board-grid-overlay-content-zoom": 1 / overlayZoom,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{(source) => {
|
||||
const data = getGridEntryData(source);
|
||||
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
|
||||
}}
|
||||
</DragOverlay>
|
||||
<GridCollisionSynchronizer interaction={interaction} />
|
||||
</DragDropProvider>
|
||||
</GridResizePreviewProvider>
|
||||
{(source) => {
|
||||
const data = getGridEntryData(source);
|
||||
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
|
||||
}}
|
||||
</DragOverlay>
|
||||
<GridCollisionSynchronizer interaction={interaction} />
|
||||
</DragDropProvider>
|
||||
</BoardGridEditorContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -726,23 +719,21 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
|
||||
const renderPlacements = useMemo(() => {
|
||||
if (!previewGrid || !interaction) return placements;
|
||||
|
||||
const preview = previewGrid.placements.filter(
|
||||
(placement) => interaction.mode === "resize" || placement.id !== interaction.activeId,
|
||||
);
|
||||
if (interaction.mode === "drag" && interaction.sourceGridId === sectionId) {
|
||||
if (interaction.sourceGridId === sectionId) {
|
||||
const original = controlledById.get(interaction.activeId);
|
||||
return original ? [...preview, original] : preview;
|
||||
if (!original) return previewGrid.placements;
|
||||
return previewGrid.placements.some((placement) => placement.id === interaction.activeId)
|
||||
? previewGrid.placements.map((placement) => (placement.id === interaction.activeId ? original : placement))
|
||||
: [...previewGrid.placements, original];
|
||||
}
|
||||
return preview;
|
||||
return previewGrid.placements.filter((placement) => placement.id !== interaction.activeId);
|
||||
}, [controlledById, interaction, placements, previewGrid, sectionId]);
|
||||
const entryById = useMemo(
|
||||
() => new Map([...items, ...innerSections].map((entry) => [entry.id, entry])),
|
||||
[innerSections, items],
|
||||
);
|
||||
const targetPlacement =
|
||||
interaction?.mode === "drag" && interaction.valid && interaction.targetGridId === sectionId
|
||||
? interaction.targetPlacement
|
||||
: null;
|
||||
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
|
||||
const renderedRowCount =
|
||||
section.kind !== "container" && interaction?.mode === "resize"
|
||||
? Math.max(rowCount, getLayoutRowCount(renderPlacements))
|
||||
@@ -817,13 +808,23 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
|
||||
? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`)
|
||||
: entry.options.title.trim() || t("section.container.untitled")
|
||||
: placement.id;
|
||||
return <DndGridEntry key={placement.id} sectionId={sectionId} placement={placement} label={label} />;
|
||||
return (
|
||||
<DndGridEntry
|
||||
key={placement.id}
|
||||
sectionId={sectionId}
|
||||
placement={placement}
|
||||
label={label}
|
||||
columnCount={columnCount}
|
||||
maxRowCount={section.kind === "container" ? rowCount : null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{targetPlacement && (
|
||||
<div
|
||||
className="board-grid-placeholder"
|
||||
style={getLogicalItemStyle(targetPlacement)}
|
||||
data-grid-placeholder-for={interaction?.activeId}
|
||||
data-grid-placeholder-mode={interaction?.mode}
|
||||
data-grid-x={targetPlacement.x}
|
||||
data-grid-y={targetPlacement.y}
|
||||
data-grid-w={targetPlacement.w}
|
||||
@@ -845,12 +846,18 @@ interface DndGridEntryProps {
|
||||
sectionId: string;
|
||||
placement: SectionGridPlacement;
|
||||
label: string;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
}
|
||||
|
||||
const DndGridEntry = ({ sectionId, placement, label }: DndGridEntryProps) => {
|
||||
const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: DndGridEntryProps) => {
|
||||
const { acquireContainer } = useBoardGridPortalHost();
|
||||
const { interaction, beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditor();
|
||||
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
|
||||
const [continuousResize, setContinuousResize] = useState<{
|
||||
placement: SectionGridPlacement;
|
||||
valid: boolean;
|
||||
} | null>(null);
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const sourceData = useMemo<GridEntryData>(
|
||||
@@ -932,6 +939,14 @@ const DndGridEntry = ({ sectionId, placement, label }: DndGridEntryProps) => {
|
||||
|
||||
const isActive = interaction?.activeId === placement.id;
|
||||
const style = getLogicalItemStyle(placement) as CSSProperties;
|
||||
const continuousResizeStyle =
|
||||
continuousResize && isActive
|
||||
? getLogicalItemStyle({
|
||||
...continuousResize.placement,
|
||||
x: continuousResize.placement.x - placement.x,
|
||||
y: continuousResize.placement.y - placement.y,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -950,15 +965,27 @@ const DndGridEntry = ({ sectionId, placement, label }: DndGridEntryProps) => {
|
||||
data-dnd-dropping={isDropping ? "true" : undefined}
|
||||
>
|
||||
<div ref={mountRef} className="board-grid-content-mount" />
|
||||
{continuousResizeStyle && (
|
||||
<div
|
||||
className="board-grid-resize-outline"
|
||||
style={continuousResizeStyle}
|
||||
data-grid-resize-outline
|
||||
data-grid-resize-valid={String(continuousResize?.valid)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<GridResizeHandles
|
||||
sectionId={sectionId}
|
||||
placement={placement}
|
||||
label={label}
|
||||
disabled={interaction !== null || (placement.type === "section" && !isSectionChromeActive)}
|
||||
columnCount={columnCount}
|
||||
maxRowCount={maxRowCount}
|
||||
beginResize={beginResize}
|
||||
previewResize={previewResize}
|
||||
completeResize={completeResize}
|
||||
cancelResize={cancelResize}
|
||||
setContinuousResize={setContinuousResize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -969,10 +996,13 @@ interface GridResizeHandlesProps {
|
||||
placement: SectionGridPlacement;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
beginResize: BoardGridEditorContextValue["beginResize"];
|
||||
previewResize: BoardGridEditorContextValue["previewResize"];
|
||||
completeResize: BoardGridEditorContextValue["completeResize"];
|
||||
cancelResize: BoardGridEditorContextValue["cancelResize"];
|
||||
setContinuousResize: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
|
||||
}
|
||||
|
||||
const RESIZE_DIRECTIONS: readonly GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
||||
@@ -982,10 +1012,13 @@ const GridResizeHandles = ({
|
||||
placement,
|
||||
label,
|
||||
disabled,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
beginResize,
|
||||
previewResize,
|
||||
completeResize,
|
||||
cancelResize,
|
||||
setContinuousResize,
|
||||
}: GridResizeHandlesProps) => {
|
||||
const pointerStartRef = useRef<{
|
||||
id: number;
|
||||
@@ -1033,18 +1066,35 @@ const GridResizeHandles = ({
|
||||
const started = pointerStartRef.current;
|
||||
if (!started || started.id !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
const delta = getSnappedGridDelta({
|
||||
const input = {
|
||||
initial: { x: started.x, y: started.y },
|
||||
current: { x: event.clientX, y: event.clientY },
|
||||
visualScale: started.visualScale,
|
||||
};
|
||||
const snappedDelta = getSnappedGridDelta(input);
|
||||
const continuousDelta = getContinuousGridDelta(input);
|
||||
if (!snappedDelta || !continuousDelta) return;
|
||||
const valid = update(started.direction, snappedDelta.x, snappedDelta.y);
|
||||
setContinuousResize({
|
||||
placement: getContinuousResizePlacement({
|
||||
placement,
|
||||
direction: started.direction,
|
||||
deltaColumns: continuousDelta.x,
|
||||
deltaRows: continuousDelta.y,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
minWidth: placement.minW ?? 1,
|
||||
minHeight: placement.minH ?? 1,
|
||||
}),
|
||||
valid,
|
||||
});
|
||||
if (delta) update(started.direction, delta.x, delta.y);
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: ReactPointerEvent<HTMLSpanElement>, canceled: boolean) => {
|
||||
const started = pointerStartRef.current;
|
||||
if (!started || started.id !== event.pointerId) return;
|
||||
pointerStartRef.current = null;
|
||||
setContinuousResize(null);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId))
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
if (canceled) cancelResize();
|
||||
@@ -1073,6 +1123,7 @@ const GridResizeHandles = ({
|
||||
onLostPointerCapture={(event) => {
|
||||
if (pointerStartRef.current?.id !== event.pointerId) return;
|
||||
pointerStartRef.current = null;
|
||||
setContinuousResize(null);
|
||||
cancelResize();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,6 @@ import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { SectionContentItem } from "../content";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import { useGridResizePreview } from "./grid-editor-runtime";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
interface GridPortalHostContextValue {
|
||||
@@ -138,15 +137,12 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(ownerSectionId);
|
||||
const entryPreview = useGridResizePreview(itemId);
|
||||
const ownerPreview = useGridResizePreview(ownerSectionId);
|
||||
const persistedEntry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
|
||||
const entry = withResizePreview(persistedEntry, entryPreview);
|
||||
const entry = persistedEntry;
|
||||
const rawSection = board.sections.find((section) => section.id === ownerSectionId);
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const persistedSection = toGridSection(rawSection, currentLayoutId);
|
||||
const section =
|
||||
persistedSection?.kind === "container" ? withResizePreview(persistedSection, ownerPreview) : persistedSection;
|
||||
const section = persistedSection;
|
||||
|
||||
if (!entry || !section || !currentLayout) return null;
|
||||
|
||||
@@ -181,22 +177,6 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
|
||||
);
|
||||
};
|
||||
|
||||
const withResizePreview = <
|
||||
TValue extends { xOffset: number; yOffset: number; width: number; height: number } | null | undefined,
|
||||
>(
|
||||
value: TValue,
|
||||
preview: { x: number; y: number; w: number; h: number } | null,
|
||||
): TValue =>
|
||||
value && preview
|
||||
? ({
|
||||
...value,
|
||||
xOffset: preview.x,
|
||||
yOffset: preview.y,
|
||||
width: preview.w,
|
||||
height: preview.h,
|
||||
} as TValue)
|
||||
: value;
|
||||
|
||||
const toGridSection = (
|
||||
section: Section | undefined,
|
||||
layoutId: string,
|
||||
|
||||
@@ -21,3 +21,23 @@
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.containerToggle {
|
||||
z-index: 9;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-text);
|
||||
background-color: var(--background-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.containerToggle :global(.mantine-Button-inner) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.containerToggle :global(.mantine-Button-label) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
+34
-3
@@ -104,6 +104,10 @@ describe("Board grid", () => {
|
||||
await expect(containerSection).toHaveAttribute("data-grid-h", "2");
|
||||
const containerLabel = containerSection.locator("[data-board-container-label]");
|
||||
await expect(containerLabel).toHaveText("Nested box");
|
||||
const expandedContainerBox = await expectBoundingBoxAsync(containerSection);
|
||||
const expandedContainerLabelBox = await expectBoundingBoxAsync(containerLabel);
|
||||
expect(Math.abs(expandedContainerLabelBox.x - expandedContainerBox.x)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(expandedContainerLabelBox.width - expandedContainerBox.width)).toBeLessThanOrEqual(3);
|
||||
await expect(nestedItem).toHaveAttribute("data-grid-x", "0");
|
||||
await expect(belowItem).toHaveAttribute("data-grid-y", "2");
|
||||
const containerLabelBox = await expectBoundingBoxAsync(containerLabel);
|
||||
@@ -193,6 +197,12 @@ describe("Board grid", () => {
|
||||
await expect
|
||||
.poll(async () => (await expectBoundingBoxAsync(containerSection)).height)
|
||||
.toBeCloseTo(expectedCollapsedHeight, 1);
|
||||
const collapsedContainerBox = await expectBoundingBoxAsync(containerSection);
|
||||
const collapsedControlBox = await expectBoundingBoxAsync(collapsedControl);
|
||||
expect(Math.abs(collapsedControlBox.x - collapsedContainerBox.x)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(collapsedControlBox.y - collapsedContainerBox.y)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(collapsedControlBox.width - collapsedContainerBox.width)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(collapsedControlBox.height - collapsedContainerBox.height)).toBeLessThanOrEqual(3);
|
||||
|
||||
await page
|
||||
.getByRole("button", {
|
||||
@@ -637,13 +647,34 @@ describe("Board grid", () => {
|
||||
};
|
||||
await page.mouse.move(eastResizeStart.x, eastResizeStart.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 12 });
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "1");
|
||||
await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale * 0.35, eastResizeStart.y, {
|
||||
steps: 6,
|
||||
});
|
||||
const resizeOutline = containerSection.locator(":scope > [data-grid-resize-outline]");
|
||||
const resizePlaceholder = mainGrid.locator(`[data-grid-placeholder-for="${fixture.containerSectionId}"]`);
|
||||
await expect(resizeOutline).toBeVisible();
|
||||
await expect(resizeOutline).toHaveAttribute("data-grid-resize-valid", "true");
|
||||
await expect(resizePlaceholder).toHaveAttribute("data-grid-placeholder-mode", "resize");
|
||||
await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "2");
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
|
||||
const originalResizeWidth = (await expectBoundingBoxAsync(containerSection)).width;
|
||||
const continuousResizeWidth = (await expectBoundingBoxAsync(resizeOutline)).width;
|
||||
expect(continuousResizeWidth).toBeLessThan(originalResizeWidth);
|
||||
expect(continuousResizeWidth).toBeGreaterThan(logicalCellSize * canvasScale);
|
||||
|
||||
await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 6 });
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
|
||||
await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "1");
|
||||
expect((await expectBoundingBoxAsync(resizeOutline)).width).toBeCloseTo(logicalCellSize * canvasScale, 1);
|
||||
const liveNestedGridBox = await expectBoundingBoxAsync(nestedEditor);
|
||||
const liveContainerBox = await expectBoundingBoxAsync(containerSection);
|
||||
expect(liveNestedGridBox.width).toBeCloseTo(logicalCellSize * canvasScale, 1);
|
||||
expect(liveNestedGridBox.width).toBeCloseTo(originalResizeWidth, 1);
|
||||
expect(liveNestedGridBox.width).toBeLessThanOrEqual(liveContainerBox.width + 1);
|
||||
await page.mouse.up();
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "1");
|
||||
await expect(resizeOutline).toHaveCount(0);
|
||||
await expect(containerSection).toHaveAttribute("data-grid-section-chrome-active", "true");
|
||||
await expect(eastResizeHandle).not.toHaveAttribute("data-grid-resize-disabled");
|
||||
await resizeLocatorByImmediateReleaseAsync(page, eastResizeHandle, logicalCellPitch * canvasScale, 0);
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
|
||||
|
||||
|
||||
Reference in new issue
Block a user