Merge branch 'feat/auto-mobile-boards' into feat/homarr-assistant
This commit is contained in:
36 files changed
+1421
-642
No files matched your search
@@ -12,7 +12,7 @@ tags:
|
||||
|
||||
Icons in Homarr are automatically requested from multiple sources:
|
||||
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
|
||||
- [selfh.st icons](https://github.com/selfhst/icons)
|
||||
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
||||
- [Tabler icons](https://tabler-icons.io/)
|
||||
@@ -21,7 +21,7 @@ Icons in Homarr are automatically requested from multiple sources:
|
||||
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
|
||||
|
||||
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
|
||||
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
|
||||
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
|
||||
|
||||
### Finding Missing Icons
|
||||
|
||||
@@ -31,7 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
|
||||
|
||||
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
|
||||
|
||||
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
|
||||
|
||||
### Using external icons
|
||||
|
||||
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
|
||||
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
|
||||
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
|
||||
|
||||
### Uploading a local image
|
||||
|
||||
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
|
||||
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
|
||||
### Icon URL
|
||||
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ Action buttons can only be executed by administrators.
|
||||
|
||||
Each named request declares its minimum board permission. This permission does not depend on the HTTP method.
|
||||
|
||||
Anonymous visitors can load queries from a Custom API widget on a public board. Definitions placed only on private
|
||||
boards remain unavailable to them.
|
||||
|
||||
Queries and actions can use GET, POST, PUT, or PATCH. DELETE is valid only for actions so it can never run
|
||||
automatically while a board loads. The `kind` value controls the remaining runtime behavior.
|
||||
|
||||
|
||||
@@ -170,7 +170,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
|
||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
|
||||
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
suggestedSearch={mode === "create" ? form.values.name : undefined}
|
||||
{...form.getInputProps("iconUrl")}
|
||||
/>
|
||||
</EditorSection>
|
||||
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
|
||||
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
|
||||
|
||||
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
{...form.getInputProps("iconUrl")}
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const observeGridDragHandle = (
|
||||
container: HTMLElement,
|
||||
selector: string,
|
||||
bind: (element: HTMLElement | null) => void,
|
||||
) => {
|
||||
let observer: MutationObserver | null = null;
|
||||
const bindFirstHandle = () => {
|
||||
const handle = container.querySelector<HTMLElement>(selector);
|
||||
if (!handle) return false;
|
||||
bind(handle);
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!bindFirstHandle()) {
|
||||
observer = new MutationObserver(bindFirstHandle);
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
bind(null);
|
||||
};
|
||||
};
|
||||
@@ -75,8 +75,8 @@
|
||||
zoom: var(--board-grid-overlay-content-zoom, 1);
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting="drag"] .board-grid-entry,
|
||||
body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
body[data-board-grid-interacting] .board-grid-entry,
|
||||
body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren, RefObject } from "react";
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
@@ -36,12 +36,7 @@ import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDro
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import {
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
LOGICAL_GRID_PITCH,
|
||||
} from "~/components/board/layout";
|
||||
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import {
|
||||
@@ -61,9 +56,11 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa
|
||||
import type { DndGridEntryProps } from "./grid-entry-memo";
|
||||
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
|
||||
import { getGridDepth } from "./grid-depth";
|
||||
import { observeGridDragHandle } from "./grid-drag-handle";
|
||||
import { createGridInteractionStore } from "./grid-interaction-store";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
|
||||
import type { GridInteraction } from "./grid-preview-layer";
|
||||
import { GridPreviewLayer } from "./grid-preview-layer";
|
||||
import { createGridResizeOutlineController } from "./grid-resize-outline";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
@@ -103,17 +100,6 @@ interface RegisteredGrid {
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
interface GridInteraction {
|
||||
activeId: string;
|
||||
sourceGridId: string;
|
||||
targetGridId: string | null;
|
||||
targetPlacement: SectionGridPlacement | null;
|
||||
state: TransactionalGridState<SectionGridPlacement>;
|
||||
mode: "drag" | "resize";
|
||||
valid: boolean;
|
||||
previewRevision: number;
|
||||
}
|
||||
|
||||
interface ActiveTransaction {
|
||||
transaction: GridTransaction<SectionGridPlacement>;
|
||||
label: string;
|
||||
@@ -173,13 +159,6 @@ const useBoardGridEditorInteraction = () => {
|
||||
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
||||
};
|
||||
|
||||
const useBoardGridEditorGridInteraction = (sectionId: string) => {
|
||||
const store = useBoardGridEditorInteractionStore();
|
||||
const subscribe = useCallback((listener: () => void) => store.subscribeGrid(sectionId, listener), [sectionId, store]);
|
||||
const getSnapshot = useCallback(() => store.getGridSnapshot(sectionId), [sectionId, store]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
/** One provider coordinates every root and nested grid on the board. */
|
||||
export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
@@ -716,10 +695,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
|
||||
}}
|
||||
</DragOverlay>
|
||||
<GridInteractionEffects
|
||||
cancelActiveInteraction={cancelActiveInteraction}
|
||||
syncRenderedInteraction={syncRenderedInteraction}
|
||||
/>
|
||||
<GridInteractionEffects syncRenderedInteraction={syncRenderedInteraction} />
|
||||
</DragDropProvider>
|
||||
</BoardGridEditorInteractionContext.Provider>
|
||||
</BoardGridEditorActionsContext.Provider>
|
||||
@@ -727,27 +703,14 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
};
|
||||
|
||||
const GridInteractionEffects = ({
|
||||
cancelActiveInteraction,
|
||||
syncRenderedInteraction,
|
||||
}: {
|
||||
cancelActiveInteraction: () => void;
|
||||
syncRenderedInteraction: (interaction: GridInteraction | null) => void;
|
||||
}) => {
|
||||
const interaction = useBoardGridEditorInteraction();
|
||||
|
||||
useLayoutEffect(() => syncRenderedInteraction(interaction), [interaction, syncRenderedInteraction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (interaction?.mode !== "resize") return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
cancelActiveInteraction();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
}, [cancelActiveInteraction, interaction?.mode]);
|
||||
|
||||
return <GridCollisionSynchronizer previewRevision={interaction?.previewRevision ?? null} />;
|
||||
};
|
||||
|
||||
@@ -775,8 +738,9 @@ export default function GridEditor({
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections } = useSectionContext();
|
||||
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
|
||||
const interactionStore = useBoardGridEditorInteractionStore();
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const entryElementsRef = useRef(new Map<string, HTMLElement>());
|
||||
const [entryElements] = useState(() => new Map<string, HTMLElement>());
|
||||
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
|
||||
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
|
||||
const depth = getDepth(sectionId);
|
||||
@@ -811,10 +775,13 @@ export default function GridEditor({
|
||||
},
|
||||
[droppableRef],
|
||||
);
|
||||
const registerEntryElement = useCallback((id: string, element: HTMLElement | null) => {
|
||||
if (element) entryElementsRef.current.set(id, element);
|
||||
else entryElementsRef.current.delete(id);
|
||||
}, []);
|
||||
const registerEntryElement = useCallback(
|
||||
(id: string, element: HTMLElement | null) => {
|
||||
if (element) entryElements.set(id, element);
|
||||
else entryElements.delete(id);
|
||||
},
|
||||
[entryElements],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = gridRef.current;
|
||||
@@ -864,7 +831,8 @@ export default function GridEditor({
|
||||
maxRowCount={maxRowCount}
|
||||
placements={placements}
|
||||
gridRef={gridRef}
|
||||
entryElementsRef={entryElementsRef}
|
||||
entryElements={entryElements}
|
||||
interactionStore={interactionStore}
|
||||
/>
|
||||
<span
|
||||
data-grid-runtime="homarr-dnd-kit-v1"
|
||||
@@ -876,103 +844,6 @@ export default function GridEditor({
|
||||
);
|
||||
}
|
||||
|
||||
interface GridPreviewLayerProps {
|
||||
sectionId: string;
|
||||
rowCount: number;
|
||||
maxRowCount: number | null;
|
||||
placements: readonly SectionGridPlacement[];
|
||||
gridRef: RefObject<HTMLDivElement | null>;
|
||||
entryElementsRef: RefObject<Map<string, HTMLElement>>;
|
||||
}
|
||||
|
||||
const GridPreviewLayer = ({
|
||||
sectionId,
|
||||
rowCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
gridRef,
|
||||
entryElementsRef,
|
||||
}: GridPreviewLayerProps) => {
|
||||
const interaction = useBoardGridEditorGridInteraction(sectionId);
|
||||
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
|
||||
const previewStateRef = useRef(createGridPreviewDomState());
|
||||
const placementsRef = useRef(placements);
|
||||
placementsRef.current = placements;
|
||||
const targetPlacement =
|
||||
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
|
||||
const growsDuringDrag =
|
||||
interaction?.mode === "drag" &&
|
||||
(interaction.targetGridId === sectionId ||
|
||||
(interaction.targetGridId === null && interaction.sourceGridId === sectionId));
|
||||
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? placements);
|
||||
const renderedRowCount =
|
||||
maxRowCount === null && interaction ? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0) : rowCount;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
previewStateRef.current = syncGridPreviewGeometry({
|
||||
elements: entryElementsRef.current,
|
||||
placements,
|
||||
previewPlacements: previewGrid?.placements ?? null,
|
||||
activeId: interaction?.activeId ?? null,
|
||||
mode: interaction?.mode ?? null,
|
||||
previous: previewStateRef.current,
|
||||
});
|
||||
|
||||
const grid = gridRef.current;
|
||||
if (!grid) return;
|
||||
const isIntentTarget = interaction?.mode === "drag" && interaction.targetGridId === sectionId;
|
||||
setOptionalAttribute(grid, "data-dnd-drop-target", isIntentTarget ? "true" : "false");
|
||||
setOptionalAttribute(grid, "data-dnd-drop-valid", isIntentTarget ? String(targetPlacement !== null) : null);
|
||||
setOptionalAttribute(
|
||||
grid,
|
||||
"data-dnd-preview-revision",
|
||||
interaction?.targetGridId === sectionId ? String(interaction.previewRevision) : null,
|
||||
);
|
||||
grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`;
|
||||
|
||||
const viewport = grid.parentElement;
|
||||
if (viewport?.hasAttribute("data-section-id")) {
|
||||
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
|
||||
}
|
||||
}, [
|
||||
entryElementsRef,
|
||||
gridRef,
|
||||
interaction,
|
||||
placements,
|
||||
previewGrid?.placements,
|
||||
renderedRowCount,
|
||||
sectionId,
|
||||
targetPlacement,
|
||||
]);
|
||||
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
clearGridPreviewGeometry(entryElementsRef.current, placementsRef.current, previewStateRef.current);
|
||||
const grid = gridRef.current;
|
||||
if (!grid) return;
|
||||
grid.removeAttribute("data-dnd-drop-target");
|
||||
grid.removeAttribute("data-dnd-drop-valid");
|
||||
grid.removeAttribute("data-dnd-preview-revision");
|
||||
grid.parentElement?.style.removeProperty("--board-grid-drag-height");
|
||||
},
|
||||
[entryElementsRef, gridRef],
|
||||
);
|
||||
|
||||
return 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}
|
||||
data-grid-h={targetPlacement.h}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const DndGridEntryComponent = ({
|
||||
sectionId,
|
||||
placement,
|
||||
@@ -1023,7 +894,7 @@ const DndGridEntryComponent = ({
|
||||
const {
|
||||
ref: draggableRef,
|
||||
handleRef,
|
||||
isDragging,
|
||||
isDragSource,
|
||||
isDropping,
|
||||
} = useDraggable<GridEntryData>({
|
||||
id: placement.id,
|
||||
@@ -1051,15 +922,7 @@ const DndGridEntryComponent = ({
|
||||
|
||||
const handleSelector =
|
||||
placement.type === "section" ? "[data-grid-container-drag-handle]" : "[data-editor-grid-entry]";
|
||||
const bindHandle = () => handleRef(container.querySelector<HTMLElement>(handleSelector));
|
||||
bindHandle();
|
||||
const observer = new MutationObserver(bindHandle);
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
handleRef(null);
|
||||
};
|
||||
return observeGridDragHandle(container, handleSelector, handleRef);
|
||||
}, [acquireContainer, handleRef, placement.id, placement.type]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -1097,7 +960,7 @@ const DndGridEntryComponent = ({
|
||||
data-grid-w={placement.w}
|
||||
data-grid-h={placement.h}
|
||||
data-grid-section-chrome-active={placement.type === "section" ? String(isSectionChromeActive) : undefined}
|
||||
data-dnd-drag-source={isDragging ? "true" : undefined}
|
||||
data-dnd-drag-source={isDragSource ? "true" : undefined}
|
||||
data-dnd-dropping={isDropping ? "true" : undefined}
|
||||
>
|
||||
<div ref={mountRef} className="board-grid-content-mount" />
|
||||
@@ -1112,7 +975,7 @@ const DndGridEntryComponent = ({
|
||||
previewResize={previewResize}
|
||||
completeResize={completeResize}
|
||||
cancelResize={cancelResize}
|
||||
setContinuousResize={resizeOutlineController.schedule}
|
||||
scheduleResizeOutline={resizeOutlineController.schedule}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1131,7 +994,7 @@ interface GridResizeHandlesProps {
|
||||
previewResize: BoardGridEditorActionsContextValue["previewResize"];
|
||||
completeResize: BoardGridEditorActionsContextValue["completeResize"];
|
||||
cancelResize: BoardGridEditorActionsContextValue["cancelResize"];
|
||||
setContinuousResize: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
|
||||
scheduleResizeOutline: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
|
||||
}
|
||||
|
||||
const RESIZE_DIRECTIONS: readonly GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
||||
@@ -1147,16 +1010,26 @@ const GridResizeHandles = ({
|
||||
previewResize,
|
||||
completeResize,
|
||||
cancelResize,
|
||||
setContinuousResize,
|
||||
scheduleResizeOutline,
|
||||
}: GridResizeHandlesProps) => {
|
||||
const pointerStartRef = useRef<{
|
||||
id: number;
|
||||
handle: HTMLSpanElement;
|
||||
x: number;
|
||||
y: number;
|
||||
direction: GridResizeDirection;
|
||||
visualScale: { x: number; y: number };
|
||||
placement: SectionGridPlacement;
|
||||
} | null>(null);
|
||||
const escapeListenerRef = useRef<((event: KeyboardEvent) => void) | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const listener = escapeListenerRef.current;
|
||||
if (listener) window.removeEventListener("keydown", listener, { capture: true });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const start = (direction: GridResizeDirection) =>
|
||||
beginResize({ activeId: placement.id, sourceGridId: sectionId, label }) && direction;
|
||||
@@ -1170,6 +1043,44 @@ const GridResizeHandles = ({
|
||||
minHeight: placement.minH ?? 1,
|
||||
});
|
||||
|
||||
const removeEscapeListener = () => {
|
||||
const listener = escapeListenerRef.current;
|
||||
if (!listener) return;
|
||||
window.removeEventListener("keydown", listener, { capture: true });
|
||||
escapeListenerRef.current = null;
|
||||
};
|
||||
|
||||
const finishResize = (pointerId: number, current: { x: number; y: number } | null, canceled: boolean) => {
|
||||
const started = pointerStartRef.current;
|
||||
if (!started || started.id !== pointerId) return;
|
||||
|
||||
pointerStartRef.current = null;
|
||||
scheduleResizeOutline(null);
|
||||
removeEscapeListener();
|
||||
if (started.handle.hasPointerCapture(pointerId)) started.handle.releasePointerCapture(pointerId);
|
||||
|
||||
if (canceled || !current) {
|
||||
cancelResize();
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = getSnappedGridDelta({
|
||||
initial: { x: started.x, y: started.y },
|
||||
current,
|
||||
visualScale: started.visualScale,
|
||||
});
|
||||
if (delta) update(started.direction, delta.x, delta.y);
|
||||
completeResize();
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
const started = pointerStartRef.current;
|
||||
if (event.key !== "Escape" || !started) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
finishResize(started.id, null, true);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLSpanElement>, direction: GridResizeDirection) => {
|
||||
const visualScale = getEntryVisualScale(event.currentTarget.parentElement, placement);
|
||||
if (
|
||||
@@ -1191,12 +1102,15 @@ const GridResizeHandles = ({
|
||||
}
|
||||
pointerStartRef.current = {
|
||||
id: event.pointerId,
|
||||
handle: event.currentTarget,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
direction,
|
||||
visualScale,
|
||||
placement: { ...placement },
|
||||
};
|
||||
escapeListenerRef.current = handleEscape;
|
||||
window.addEventListener("keydown", handleEscape, { capture: true });
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLSpanElement>) => {
|
||||
@@ -1212,7 +1126,7 @@ const GridResizeHandles = ({
|
||||
const continuousDelta = getContinuousGridDelta(input);
|
||||
if (!snappedDelta || !continuousDelta) return;
|
||||
const valid = update(started.direction, snappedDelta.x, snappedDelta.y);
|
||||
setContinuousResize({
|
||||
scheduleResizeOutline({
|
||||
placement: getContinuousResizePlacement({
|
||||
placement: started.placement,
|
||||
direction: started.direction,
|
||||
@@ -1228,22 +1142,7 @@ const GridResizeHandles = ({
|
||||
};
|
||||
|
||||
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();
|
||||
else {
|
||||
const delta = getSnappedGridDelta({
|
||||
initial: { x: started.x, y: started.y },
|
||||
current: { x: event.clientX, y: event.clientY },
|
||||
visualScale: started.visualScale,
|
||||
});
|
||||
if (delta) update(started.direction, delta.x, delta.y);
|
||||
completeResize();
|
||||
}
|
||||
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
|
||||
};
|
||||
|
||||
return RESIZE_DIRECTIONS.map((direction) => (
|
||||
@@ -1257,12 +1156,7 @@ const GridResizeHandles = ({
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={(event) => handlePointerEnd(event, false)}
|
||||
onPointerCancel={(event) => handlePointerEnd(event, true)}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (pointerStartRef.current?.id !== event.pointerId) return;
|
||||
pointerStartRef.current = null;
|
||||
setContinuousResize(null);
|
||||
cancelResize();
|
||||
}}
|
||||
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
@@ -1325,11 +1219,6 @@ const getAncestorCssZoom = (element: Element): number => {
|
||||
const arePointerCoordinatesEqual = (first: { x: number; y: number } | null, second: { x: number; y: number } | null) =>
|
||||
first !== null && second !== null && Math.abs(first.x - second.x) <= 0.5 && Math.abs(first.y - second.y) <= 0.5;
|
||||
|
||||
const setOptionalAttribute = (element: HTMLElement, name: string, value: string | null) => {
|
||||
if (value === null) element.removeAttribute(name);
|
||||
else element.setAttribute(name, value);
|
||||
};
|
||||
|
||||
const getPointerTargetGrid = (
|
||||
grids: Iterable<RegisteredGrid>,
|
||||
source: AbstractDraggable,
|
||||
|
||||
@@ -6,10 +6,10 @@ export interface GridPreviewDomState {
|
||||
changedIds: Set<string>;
|
||||
}
|
||||
|
||||
interface SyncGridPreviewGeometryInput<TPlacement extends GridPlacement> {
|
||||
interface SyncGridPreviewGeometryInput {
|
||||
elements: ReadonlyMap<string, HTMLElement>;
|
||||
placements: readonly TPlacement[];
|
||||
previewPlacements: readonly TPlacement[] | null;
|
||||
placements: readonly GridPlacement[];
|
||||
previewPlacements: readonly GridPlacement[] | null;
|
||||
activeId: string | null;
|
||||
mode: "drag" | "resize" | null;
|
||||
previous: GridPreviewDomState;
|
||||
@@ -20,14 +20,14 @@ export const createGridPreviewDomState = (): GridPreviewDomState => ({
|
||||
changedIds: new Set(),
|
||||
});
|
||||
|
||||
export const syncGridPreviewGeometry = <TPlacement extends GridPlacement>({
|
||||
export const syncGridPreviewGeometry = ({
|
||||
elements,
|
||||
placements,
|
||||
previewPlacements,
|
||||
activeId,
|
||||
mode,
|
||||
previous,
|
||||
}: SyncGridPreviewGeometryInput<TPlacement>): GridPreviewDomState => {
|
||||
}: SyncGridPreviewGeometryInput): GridPreviewDomState => {
|
||||
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
|
||||
const changedIds = new Set<string>();
|
||||
|
||||
@@ -58,9 +58,9 @@ export const syncGridPreviewGeometry = <TPlacement extends GridPlacement>({
|
||||
return { activeId, changedIds };
|
||||
};
|
||||
|
||||
export const clearGridPreviewGeometry = <TPlacement extends GridPlacement>(
|
||||
export const clearGridPreviewGeometry = (
|
||||
elements: ReadonlyMap<string, HTMLElement>,
|
||||
placements: readonly TPlacement[],
|
||||
placements: readonly GridPlacement[],
|
||||
state: GridPreviewDomState,
|
||||
) => {
|
||||
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
|
||||
|
||||
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout";
|
||||
import type { TransactionalGridState } from "./dnd";
|
||||
import type { GridInteractionStore } from "./grid-interaction-store";
|
||||
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
|
||||
export interface GridInteraction {
|
||||
activeId: string;
|
||||
sourceGridId: string;
|
||||
targetGridId: string | null;
|
||||
targetPlacement: SectionGridPlacement | null;
|
||||
state: TransactionalGridState<SectionGridPlacement>;
|
||||
mode: "drag" | "resize";
|
||||
valid: boolean;
|
||||
previewRevision: number;
|
||||
}
|
||||
|
||||
interface GridPreviewLayerProps {
|
||||
sectionId: string;
|
||||
rowCount: number;
|
||||
maxRowCount: number | null;
|
||||
placements: readonly SectionGridPlacement[];
|
||||
gridRef: RefObject<HTMLDivElement | null>;
|
||||
entryElements: ReadonlyMap<string, HTMLElement>;
|
||||
interactionStore: GridInteractionStore<GridInteraction>;
|
||||
}
|
||||
|
||||
export const GridPreviewLayer = ({
|
||||
sectionId,
|
||||
rowCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
gridRef,
|
||||
entryElements,
|
||||
interactionStore,
|
||||
}: GridPreviewLayerProps) => {
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => interactionStore.subscribeGrid(sectionId, listener),
|
||||
[interactionStore, sectionId],
|
||||
);
|
||||
const getSnapshot = useCallback(() => interactionStore.getGridSnapshot(sectionId), [interactionStore, sectionId]);
|
||||
const interaction = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
|
||||
const previewStateRef = useRef(createGridPreviewDomState());
|
||||
const placementsRef = useRef(placements);
|
||||
|
||||
const isDragging = interaction?.mode === "drag";
|
||||
const isTargetGrid = isDragging && interaction.targetGridId === sectionId;
|
||||
let targetPlacement: SectionGridPlacement | null = null;
|
||||
if (interaction?.valid && interaction.targetGridId === sectionId) {
|
||||
targetPlacement = interaction.targetPlacement;
|
||||
}
|
||||
|
||||
const growsDuringDrag =
|
||||
isDragging && (isTargetGrid || (interaction.targetGridId === null && interaction.sourceGridId === sectionId));
|
||||
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? placements);
|
||||
let renderedRowCount = rowCount;
|
||||
if (maxRowCount === null && interaction) {
|
||||
renderedRowCount = Math.max(rowCount, previewRowCount);
|
||||
if (growsDuringDrag) renderedRowCount += 1;
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
placementsRef.current = placements;
|
||||
previewStateRef.current = syncGridPreviewGeometry({
|
||||
elements: entryElements,
|
||||
placements,
|
||||
previewPlacements: previewGrid?.placements ?? null,
|
||||
activeId: interaction?.activeId ?? null,
|
||||
mode: interaction?.mode ?? null,
|
||||
previous: previewStateRef.current,
|
||||
});
|
||||
|
||||
const grid = gridRef.current;
|
||||
if (!grid) return;
|
||||
grid.dataset.dndDropTarget = String(isTargetGrid);
|
||||
if (isTargetGrid) grid.dataset.dndDropValid = String(targetPlacement !== null);
|
||||
else delete grid.dataset.dndDropValid;
|
||||
|
||||
if (interaction?.targetGridId === sectionId) {
|
||||
grid.dataset.dndPreviewRevision = String(interaction.previewRevision);
|
||||
} else {
|
||||
delete grid.dataset.dndPreviewRevision;
|
||||
}
|
||||
grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`;
|
||||
|
||||
const viewport = grid.parentElement;
|
||||
if (viewport?.hasAttribute("data-section-id")) {
|
||||
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
|
||||
}
|
||||
}, [
|
||||
entryElements,
|
||||
gridRef,
|
||||
interaction,
|
||||
isTargetGrid,
|
||||
placements,
|
||||
previewGrid?.placements,
|
||||
renderedRowCount,
|
||||
sectionId,
|
||||
targetPlacement,
|
||||
]);
|
||||
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
clearGridPreviewGeometry(entryElements, placementsRef.current, previewStateRef.current);
|
||||
const grid = gridRef.current;
|
||||
if (!grid) return;
|
||||
grid.removeAttribute("data-dnd-drop-target");
|
||||
grid.removeAttribute("data-dnd-drop-valid");
|
||||
grid.removeAttribute("data-dnd-preview-revision");
|
||||
grid.parentElement?.style.removeProperty("--board-grid-drag-height");
|
||||
},
|
||||
[entryElements, gridRef],
|
||||
);
|
||||
|
||||
if (!targetPlacement) return null;
|
||||
|
||||
return (
|
||||
<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}
|
||||
data-grid-h={targetPlacement.h}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,17 @@
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
|
||||
interface ResizeOutlinePreview<TPlacement extends GridPlacement> {
|
||||
placement: TPlacement;
|
||||
interface ResizeOutlinePreview {
|
||||
placement: GridPlacement;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export const createGridResizeOutlineController = <TPlacement extends GridPlacement>(
|
||||
export const createGridResizeOutlineController = (
|
||||
getShell: () => HTMLElement | null,
|
||||
getControlledPlacement: () => TPlacement,
|
||||
getControlledPlacement: () => GridPlacement,
|
||||
) => {
|
||||
let frame = 0;
|
||||
let pending: ResizeOutlinePreview<TPlacement> | null = null;
|
||||
let pending: ResizeOutlinePreview | null = null;
|
||||
|
||||
const remove = () => {
|
||||
getShell()?.querySelector(":scope > [data-grid-resize-outline]")?.remove();
|
||||
@@ -37,7 +37,7 @@ export const createGridResizeOutlineController = <TPlacement extends GridPlaceme
|
||||
};
|
||||
|
||||
return {
|
||||
schedule(preview: ResizeOutlinePreview<TPlacement> | null) {
|
||||
schedule(preview: ResizeOutlinePreview | null) {
|
||||
pending = preview;
|
||||
if (!preview) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { observeGridDragHandle } from "../grid-drag-handle";
|
||||
|
||||
describe("grid drag handle", () => {
|
||||
test("disconnects the observer after binding the first handle", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const bind = vi.fn();
|
||||
|
||||
const cleanup = observeGridDragHandle(container, "[data-drag-handle]", bind);
|
||||
expect(bind).not.toHaveBeenCalled();
|
||||
|
||||
container.append(document.createElement("span"));
|
||||
await Promise.resolve();
|
||||
expect(bind).not.toHaveBeenCalled();
|
||||
|
||||
const handle = document.createElement("button");
|
||||
handle.dataset.dragHandle = "";
|
||||
container.append(handle);
|
||||
await vi.waitFor(() => expect(bind).toHaveBeenCalledOnce());
|
||||
expect(bind).toHaveBeenLastCalledWith(handle);
|
||||
|
||||
handle.append(document.createElement("span"));
|
||||
container.prepend(document.createElement("button"));
|
||||
await Promise.resolve();
|
||||
expect(bind).toHaveBeenCalledOnce();
|
||||
|
||||
cleanup();
|
||||
expect(bind).toHaveBeenLastCalledWith(null);
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,9 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import type { DndGridEntryProps } from "../grid-entry-memo";
|
||||
import { areDndGridEntryPropsEqual } from "../grid-entry-memo";
|
||||
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
|
||||
import { createGridInteractionStore } from "../grid-interaction-store";
|
||||
import type { GridInteraction } from "../grid-preview-layer";
|
||||
import { GridPreviewLayer } from "../grid-preview-layer";
|
||||
|
||||
describe("grid entry memoization", () => {
|
||||
let container: HTMLDivElement;
|
||||
@@ -76,8 +78,13 @@ describe("grid entry memoization", () => {
|
||||
expect(renders).toBe(101);
|
||||
});
|
||||
|
||||
test("keeps 100 entry and widget renders frozen while preview geometry changes", () => {
|
||||
test("keeps 100 entry and widget renders frozen across interaction store previews", () => {
|
||||
const elements = new Map<string, HTMLElement>();
|
||||
const interactionStore = createGridInteractionStore<GridInteraction>();
|
||||
const gridRef = { current: null as HTMLDivElement | null };
|
||||
const setGridRef = (element: HTMLDivElement | null) => {
|
||||
gridRef.current = element;
|
||||
};
|
||||
const registerElement = (id: string, element: HTMLElement | null) => {
|
||||
if (element) elements.set(id, element);
|
||||
else elements.delete(id);
|
||||
@@ -87,21 +94,50 @@ describe("grid entry memoization", () => {
|
||||
registerElement,
|
||||
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
|
||||
}));
|
||||
renderBoard(entries);
|
||||
const renderPreviewBoard = (nextEntries: DndGridEntryProps[]) => {
|
||||
act(() =>
|
||||
root.render(
|
||||
createElement(
|
||||
"div",
|
||||
{ "data-section-id": "root" },
|
||||
createElement(
|
||||
"div",
|
||||
{ ref: setGridRef },
|
||||
nextEntries.map((entry) => createElement(Probe, { ...entry, key: entry.placement.id })),
|
||||
createElement(GridPreviewLayer, {
|
||||
sectionId: "root",
|
||||
rowCount: 10,
|
||||
maxRowCount: null,
|
||||
placements: nextEntries.map((entry) => entry.placement),
|
||||
gridRef,
|
||||
entryElements: elements,
|
||||
interactionStore,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
renderPreviewBoard(entries);
|
||||
expect(renders).toBe(100);
|
||||
expect(widgetRenders).toBe(100);
|
||||
|
||||
let previewState = createGridPreviewDomState();
|
||||
for (let revision = 1; revision <= 20; revision += 1) {
|
||||
previewState = syncGridPreviewGeometry({
|
||||
elements,
|
||||
placements: entries.map((entry) => entry.placement),
|
||||
previewPlacements: entries.map((entry, index) =>
|
||||
index === 42 ? { ...entry.placement, y: entry.placement.y + revision } : entry.placement,
|
||||
),
|
||||
activeId: "item-0",
|
||||
mode: "drag",
|
||||
previous: previewState,
|
||||
const previewPlacements = entries.map((entry, index) =>
|
||||
index === 42 ? { ...entry.placement, y: entry.placement.y + revision } : entry.placement,
|
||||
);
|
||||
act(() => {
|
||||
interactionStore.publish({
|
||||
activeId: "item-0",
|
||||
sourceGridId: "root",
|
||||
targetGridId: "root",
|
||||
targetPlacement: previewPlacements[0] ?? null,
|
||||
state: { grids: [{ id: "root", columnCount: 12, maxRowCount: null, placements: previewPlacements }] },
|
||||
mode: "drag",
|
||||
valid: true,
|
||||
previewRevision: revision,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,13 +145,16 @@ describe("grid entry memoization", () => {
|
||||
expect(widgetRenders).toBe(100);
|
||||
expect(elements.get("item-42")?.getAttribute("data-grid-preview")).toBe("true");
|
||||
|
||||
renderBoard(
|
||||
act(() => interactionStore.publish(null));
|
||||
renderPreviewBoard(
|
||||
entries.map((entry, index) =>
|
||||
index === 42 ? { ...entry, placement: { ...entry.placement, y: entry.placement.y + 20 } } : entry,
|
||||
),
|
||||
);
|
||||
expect(renders).toBe(101);
|
||||
expect(widgetRenders).toBe(101);
|
||||
expect(gridRef.current?.dataset.dndDropTarget).toBe("false");
|
||||
expect(gridRef.current?.hasAttribute("data-dnd-preview-revision")).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
||||
Reference in new issue
Block a user