mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-05 18:15:24 +00:00
feat: shared tile grid for plugin pages and many-tile dashboard cards (SDK 1.0.26)
This commit is contained in:
1 parent
1c4fa52de3
commit
9613b787a7
22 files changed
+1997
-7
No files matched your search
@@ -7,9 +7,15 @@
|
||||
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
|
||||
- API keys can be limited to reading or running actions, when an admin makes them
|
||||
- A click on a host can open its host page, when a plugin offers one
|
||||
- Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings
|
||||
- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline
|
||||
|
||||
### Changed
|
||||
|
||||
- Plugin cards placed on a tile grid take the dashboard look, with shared lines and no gaps
|
||||
- Line charts that cover a few minutes show seconds in their times
|
||||
- Plugins a new release adds to the image start on only when they are recommended. The rest are installed and left off for an admin to turn on
|
||||
|
||||
- Host status, import and export, and credential tools now start on for new installs
|
||||
- Sync now starts on in a new desktop app, so it can link to a server
|
||||
- The first run plugin picker leaves plugins you did not choose off instead of removing them
|
||||
@@ -21,6 +27,7 @@
|
||||
- Password fields with the show button are the same height as the other fields next to them
|
||||
- The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on
|
||||
- An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot
|
||||
- Canceling a download to the desktop app right as it starts no longer leaves a partial file behind
|
||||
|
||||
## 26.11.0
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.26
|
||||
|
||||
Plugin API 1.15.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `/ui` exports `TileGrid`, `TileFrame` and `TilePicker`, the 12 column tile grid the dashboard, the host page and Host Metrics share, plus `appendTile`, `flowTiles`, `removeTile`, `sanitizeTiles` and the `TileSlot` type
|
||||
- `registerDashboardCard` takes `entries`, so one card can be placed many times with its own `config`, and `defaultSize` for the grid
|
||||
- Dashboard cards get `config`, `setConfig` and `editMode`
|
||||
|
||||
## 1.0.25
|
||||
|
||||
Plugin API 1.14.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.25 | Plugin API 1.14.0
|
||||
SDK 1.0.26 | Plugin API 1.15.0
|
||||
|
||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||
@@ -768,6 +768,12 @@ exports with props is `ui.api.txt` in the Termix repo
|
||||
`PanePrompt` for small prompts.
|
||||
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
|
||||
forms end with `FormFooter`, empty lists show `EmptyState`.
|
||||
- Tile pages use `TileGrid` with `TileFrame` around each tile and `TilePicker` beside it while editing. Slots are
|
||||
`{ id, x, y, w, h }` on 12 columns (`h` null for the natural height); save them where your plugin keeps
|
||||
preferences and run saved ones through `sanitizeTiles`. Add with `appendTile`. Needs Termix 26.11.1.
|
||||
- A dashboard card with `entries` can be placed many times: each entry has a title, a group and a `config` the
|
||||
card gets back as `config` (save changes with `setConfig`, keep it small). Operations Center uses this for
|
||||
status and action tiles.
|
||||
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same
|
||||
value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per
|
||||
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.25",
|
||||
"version": "1.0.26",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -799,6 +799,26 @@ export interface KeybindingDefaultContribution {
|
||||
export interface DashboardCardProps {
|
||||
isVisible: boolean;
|
||||
shell: ShellApi;
|
||||
/** This tile's settings, for cards with `entries`. Empty otherwise. Core 26.11.1+. */
|
||||
config?: Record<string, unknown>;
|
||||
/** Saves this tile's settings. Keep it small, it is stored in one line. */
|
||||
setConfig?: (config: Record<string, unknown>) => void;
|
||||
/** True while the user is arranging the dashboard. */
|
||||
editMode?: boolean;
|
||||
}
|
||||
|
||||
/** One tile a card with `entries` offers in the dashboard's add list. */
|
||||
export interface DashboardCardEntry {
|
||||
/** Stable within the card, used to mark entries already placed. */
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Group heading in the add list. Defaults to the card title. */
|
||||
group?: string;
|
||||
kind?: "card" | "chart" | "signal" | "action" | "resource" | "session";
|
||||
/** Passed back as `config`. */
|
||||
config: Record<string, unknown>;
|
||||
defaultSize?: { w: number; h?: number | null };
|
||||
}
|
||||
|
||||
export interface DashboardCardContribution {
|
||||
@@ -813,6 +833,13 @@ export interface DashboardCardContribution {
|
||||
* edge, for a strip of numbers. Defaults to "framed".
|
||||
*/
|
||||
frame?: "framed" | "bare";
|
||||
/** Size on the 12 column dashboard grid when added. Core 26.11.1+. */
|
||||
defaultSize?: { w: number; h?: number | null };
|
||||
/**
|
||||
* Lets the card be placed many times, once per entry, each with its own
|
||||
* `config`. Titles come back already translated. Core 26.11.1+.
|
||||
*/
|
||||
entries?: (language: string) => Promise<DashboardCardEntry[]>;
|
||||
component: ComponentType<DashboardCardProps>;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export {
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.14.0";
|
||||
export const PLUGIN_API_VERSION = "1.15.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
|
||||
@@ -291,6 +291,7 @@ SurfaceKind (type): SurfaceKind
|
||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||
TILE_COLUMNS (value): 12
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Tab (type): Tab
|
||||
TabShellCallbacks (type): TabShellCallbacks
|
||||
@@ -305,6 +306,13 @@ TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, d
|
||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||
TextareaProps (type): TextareaProps
|
||||
ThemeId (type): string
|
||||
TileFrame (value): ({ title, icon, actions, bare, onTitleClick, className, bodyClassName, children, }: { title?: ReactNode; icon?: ReactNode; actions?: ReactNode; bare?: boolean; onTitleClick?: () => void; className?: string; bodyClassName?: string; children: ReactNode; }) => Element
|
||||
TileGrid (value): ({ slots, editMode, renderTile, tileLabel, onChange, onSettings, gap, stackBelow, minColumns, minHeight, empty, className, }: TileGridProps) => Element
|
||||
TileGridProps (type): TileGridProps
|
||||
TileKind (type): TileKind
|
||||
TilePicker (value): ({ entries, onAdd, onClose, className, }: { entries: TilePickerEntry[]; onAdd: (entry: TilePickerEntry) => void; onClose?: () => void; className?: string; }) => Element
|
||||
TilePickerEntry (type): TilePickerEntry
|
||||
TileSlot (type): TileSlot
|
||||
TlsStatus (type): TlsStatus
|
||||
ToolsTab (type): ToolsTab
|
||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||
@@ -341,6 +349,7 @@ adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
|
||||
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
|
||||
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
|
||||
appendTile (value): (slots: TileSlot[], tile: { id: string; w?: number; h?: number; }) => TileSlot[]
|
||||
applyAccentColor (value): (colorValue: string) => void
|
||||
applyFontSize (value): (id: FontSizeId) => void
|
||||
applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise<void>
|
||||
@@ -384,6 +393,7 @@ endSplitDrag (value): (commit: boolean) => void
|
||||
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
|
||||
findCoreHostDefault (value): (key: string) => CoreHostDefault
|
||||
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
|
||||
flowTiles (value): (tiles: { id: string; w: number; h?: number; }[], rowHeight?: number) => TileSlot[]
|
||||
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
|
||||
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
|
||||
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
|
||||
@@ -514,6 +524,7 @@ releaseUrlFrom (value): (info: VersionInfo) => string
|
||||
rem (value): (px: number) => string
|
||||
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||
removeTile (value): (slots: TileSlot[], id: string) => TileSlot[]
|
||||
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
@@ -543,6 +554,7 @@ runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallba
|
||||
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
||||
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
|
||||
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
|
||||
sanitizeTiles (value): (value: unknown, min?: number) => TileSlot[]
|
||||
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
|
||||
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
|
||||
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
|
||||
|
||||
@@ -19,6 +19,18 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
|
||||
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||
|
||||
const ALLOWED = [
|
||||
"TileFrame",
|
||||
"TileGrid",
|
||||
"TilePicker",
|
||||
"appendTile",
|
||||
"flowTiles",
|
||||
"removeTile",
|
||||
"sanitizeTiles",
|
||||
"TILE_COLUMNS",
|
||||
"TileGridProps",
|
||||
"TileKind",
|
||||
"TilePickerEntry",
|
||||
"TileSlot",
|
||||
"useHostSections",
|
||||
"operationsApi",
|
||||
"listHostSections",
|
||||
|
||||
@@ -26,6 +26,14 @@ const TICK_COUNT = 5;
|
||||
|
||||
function formatTimestamp(ts: string, rangeMs: number): string {
|
||||
const d = parseServerTime(ts);
|
||||
// A live chart covers a minute or two, so minutes alone would repeat.
|
||||
if (rangeMs <= 10 * 60 * 1000) {
|
||||
return d.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
if (rangeMs <= 25 * 60 * 60 * 1000) {
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useInSeamlessTile } from "@/components/tile-grid/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Default cap (px) for a scrollable card body before it scrolls. */
|
||||
@@ -29,11 +30,36 @@ export function MetricCard({
|
||||
/** Max body height in px for scrollable cards. */
|
||||
scrollMax?: number;
|
||||
}) {
|
||||
// In an edge to edge tile grid the grid draws the lines, so the card takes
|
||||
// the dashboard tile look: no border or fill, the small title row.
|
||||
const tiled = useInSeamlessTile();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col overflow-hidden border border-border bg-card">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="flex-1 truncate text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden",
|
||||
!tiled && "border border-border bg-card",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2 border-b border-border",
|
||||
tiled ? "px-3 py-1.5" : "px-4 py-2.5",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
tiled && "flex [&_svg]:size-3",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 truncate font-semibold uppercase tracking-widest text-muted-foreground",
|
||||
tiled ? "text-[10px]" : "text-xs",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{action && <div className="ml-auto shrink-0">{action}</div>}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The look every tile shares with the dashboard: a small uppercase title row
|
||||
* over a body that scrolls when the tile has a fixed height. The grid draws
|
||||
* the lines between tiles. `bare` drops the title row.
|
||||
*/
|
||||
export function TileFrame({
|
||||
title,
|
||||
icon,
|
||||
actions,
|
||||
bare = false,
|
||||
onTitleClick,
|
||||
className,
|
||||
bodyClassName,
|
||||
children,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
bare?: boolean;
|
||||
onTitleClick?: () => void;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-h-0 min-w-0 flex-col overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!bare && (title || icon || actions) && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
{icon && (
|
||||
<span className="flex shrink-0 text-muted-foreground [&_svg]:size-3">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{onTitleClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTitleClick}
|
||||
className="min-w-0 truncate text-left text-[10px] font-semibold uppercase tracking-widest text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
) : (
|
||||
<span className="min-w-0 truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{actions && (
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{actions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-auto thin-scrollbar",
|
||||
bodyClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GripVertical, Settings2, X } from "lucide-react";
|
||||
import { remScale } from "@/lib/rem";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAX_TILE_HEIGHT,
|
||||
MIN_TILE_COLUMNS,
|
||||
MIN_TILE_HEIGHT,
|
||||
TILE_COLUMNS,
|
||||
clampTileColumns,
|
||||
settleTiles,
|
||||
tileBottom,
|
||||
tileBoxes,
|
||||
tilesFromBoxes,
|
||||
type TileBox,
|
||||
type TileSlot,
|
||||
} from "./layout";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { gridLines } from "./layout";
|
||||
|
||||
type Edge = "left" | "right" | "bottom" | "corner";
|
||||
interface Proposal {
|
||||
x?: number;
|
||||
y?: number;
|
||||
w?: number;
|
||||
h?: number | null;
|
||||
}
|
||||
interface Interaction {
|
||||
id: string;
|
||||
kind: "move" | "resize";
|
||||
base: TileSlot[];
|
||||
proposal: Proposal;
|
||||
frame: { left: number; top: number; width: number; height?: number };
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 4;
|
||||
const SNAP = 8;
|
||||
const SCROLL_EDGE = 48;
|
||||
|
||||
const styles = `
|
||||
.tg-grid { position: relative; }
|
||||
.tg-tile { position: absolute; left: 0; top: 0; min-width: 0; display: flex; flex-direction: column; }
|
||||
.tg-grid[data-animate="true"] .tg-tile:not([data-active]) { transition: transform 160ms ease, width 160ms ease, height 160ms ease; }
|
||||
.tg-tile[data-active] { z-index: 40; }
|
||||
.tg-tile[data-active="move"] { opacity: 0.85; cursor: grabbing; }
|
||||
.tg-grid[data-edit-mode="true"] .tg-tile { user-select: none; }
|
||||
.tg-grid[data-edit-mode="true"] .tg-tile:not([data-active]) { cursor: grab; }
|
||||
[data-tg-drop-preview] {
|
||||
position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; box-sizing: border-box;
|
||||
background: color-mix(in srgb, var(--accent-brand, #60a5fa) 12%, transparent);
|
||||
border: 2px dashed color-mix(in srgb, var(--accent-brand, #60a5fa) 70%, transparent);
|
||||
}
|
||||
.tg-handle { position: absolute; z-index: 30; display: flex; touch-action: none; align-items: center; justify-content: center; }
|
||||
.tg-handle > span { background: var(--border); }
|
||||
.tg-handle:hover > span { background: var(--accent-brand, #60a5fa); }
|
||||
.tg-edge-left, .tg-edge-right { top: 2rem; bottom: 1rem; width: 0.75rem; cursor: ew-resize; }
|
||||
.tg-edge-left { left: 0; }
|
||||
.tg-edge-right { right: 0; }
|
||||
.tg-edge-bottom { bottom: 0; left: 1rem; right: 1rem; height: 0.75rem; cursor: ns-resize; }
|
||||
.tg-edge-corner { bottom: 0; right: 0; width: 1rem; height: 1rem; cursor: nwse-resize; }
|
||||
.tg-edge-corner::after { content: ""; width: 0.5rem; height: 0.5rem; border-right: 2px solid var(--border); border-bottom: 2px solid var(--border); }
|
||||
.tg-handle:focus-visible { outline: 2px solid var(--accent-brand, #60a5fa); }
|
||||
.tg-card[data-fixed-height="true"] { height: 100%; }
|
||||
.tg-card[data-fixed-height="true"] > .tg-content { overflow: hidden; }
|
||||
.tg-card[data-fixed-height="true"] > .tg-content > * { height: 100%; }
|
||||
`;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.max(min, Math.min(max, value));
|
||||
|
||||
function propose(base: TileSlot[], id: string, proposal: Proposal) {
|
||||
return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot));
|
||||
}
|
||||
|
||||
function scrollParent(element: HTMLElement | null) {
|
||||
for (let node = element?.parentElement; node; node = node.parentElement) {
|
||||
const { overflowY } = getComputedStyle(node);
|
||||
if (overflowY === "auto" || overflowY === "scroll") return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const NO_DRAG =
|
||||
"button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle";
|
||||
|
||||
export interface TileGridProps {
|
||||
slots: TileSlot[];
|
||||
editMode: boolean;
|
||||
renderTile: (id: string) => ReactNode;
|
||||
/** Name of a tile, for the edit controls. */
|
||||
tileLabel: (id: string) => string;
|
||||
onChange: (slots: TileSlot[]) => void;
|
||||
/** Shows a settings button on each tile while editing. */
|
||||
onSettings?: (id: string) => void;
|
||||
/** Space between tiles in px. 0 draws tiles edge to edge with shared borders, like the dashboard. */
|
||||
gap?: number;
|
||||
/** Below this width tiles stack in one column and editing is off. */
|
||||
stackBelow?: number;
|
||||
minColumns?: number;
|
||||
minHeight?: number;
|
||||
/** Shown while editing an empty grid. */
|
||||
empty?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A 12 column grid of tiles the user can drag and resize. Tiles keep their
|
||||
* column and settle upward, so a layout never has holes it did not ask for.
|
||||
*/
|
||||
export function TileGrid({
|
||||
slots,
|
||||
editMode,
|
||||
renderTile,
|
||||
tileLabel,
|
||||
onChange,
|
||||
onSettings,
|
||||
gap = 0,
|
||||
stackBelow = 640,
|
||||
minColumns = MIN_TILE_COLUMNS,
|
||||
minHeight = MIN_TILE_HEIGHT,
|
||||
empty,
|
||||
className,
|
||||
}: TileGridProps) {
|
||||
const { t } = useTranslation();
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const gridEl = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
// Whether the grid reaches the bottom of the view it scrolls in. Then its
|
||||
// last row meets the bottom of the screen and needs no line under it.
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
const overflowsRef = useRef(overflows);
|
||||
overflowsRef.current = overflows;
|
||||
const [heights, setHeights] = useState<Record<string, number>>({});
|
||||
const [active, setActive] = useState<Interaction | null>(null);
|
||||
const cancel = useRef<(() => void) | null>(null);
|
||||
const heightsRef = useRef(heights);
|
||||
heightsRef.current = heights;
|
||||
|
||||
const scale = remScale();
|
||||
const seamless = gap <= 0;
|
||||
// Edge to edge tiles draw no lines of their own. The grid works out every
|
||||
// line between them and draws each once, so a line is never doubled at any
|
||||
// display scaling and a taller neighbour still gets its edge.
|
||||
const inset = seamless ? 0 : gap * scale;
|
||||
const settleGap = seamless ? 0 : gap;
|
||||
const pitch = (width + inset) / TILE_COLUMNS;
|
||||
const stacked = width > 0 && width < stackBelow;
|
||||
const editing = editMode && !stacked;
|
||||
|
||||
const settled = useMemo(
|
||||
() => settleTiles(tileBoxes(slots, heights), settleGap),
|
||||
[slots, heights, settleGap],
|
||||
);
|
||||
const preview = useMemo(
|
||||
() =>
|
||||
active
|
||||
? settleTiles(
|
||||
tileBoxes(
|
||||
propose(active.base, active.id, active.proposal),
|
||||
heights,
|
||||
),
|
||||
settleGap,
|
||||
)
|
||||
: settled,
|
||||
[active, settled, heights, settleGap],
|
||||
);
|
||||
const boxById = useMemo(
|
||||
() => new Map(preview.map((box) => [box.id, box])),
|
||||
[preview],
|
||||
);
|
||||
// Same elements across pointer moves, so tiles only re-render on new data.
|
||||
const content = useMemo(
|
||||
() =>
|
||||
new Map<string, ReactNode>(
|
||||
slots.map((slot) => [slot.id, renderTile(slot.id)]),
|
||||
),
|
||||
[slots, renderTile],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = root.current;
|
||||
if (!element) return;
|
||||
const update = () =>
|
||||
setWidth(element.getBoundingClientRect().width || element.clientWidth);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = root.current;
|
||||
const scroller = seamless ? scrollParent(element) : null;
|
||||
if (!element || !scroller) {
|
||||
setOverflows(false);
|
||||
return;
|
||||
}
|
||||
// Where the grid's last row ends inside the scrolled content, without the
|
||||
// kept bottom line's px, so adding or dropping that line never flips it.
|
||||
const update = () => {
|
||||
const end =
|
||||
element.getBoundingClientRect().bottom -
|
||||
scroller.getBoundingClientRect().top +
|
||||
scroller.scrollTop -
|
||||
(overflowsRef.current ? 0 : 1);
|
||||
setOverflows(end >= scroller.clientHeight - 0.5);
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(scroller);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [seamless, settled, width]);
|
||||
|
||||
// An outside change (undo, reset, host switch) ends any drag in progress.
|
||||
useEffect(() => () => cancel.current?.(), [slots, editing]);
|
||||
|
||||
const reportHeight = useCallback(
|
||||
(id: string, height: number) =>
|
||||
setHeights((previous) =>
|
||||
Math.abs((previous[id] ?? -1) - height) < 0.5
|
||||
? previous
|
||||
: { ...previous, [id]: height },
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const commit = (base: TileSlot[], id: string | null, proposal: Proposal) => {
|
||||
const next = id ? propose(base, id, proposal) : base;
|
||||
const boxes = settleTiles(tileBoxes(next, heightsRef.current), settleGap);
|
||||
onChange(tilesFromBoxes(next, boxes));
|
||||
};
|
||||
const settledBase = (): TileSlot[] =>
|
||||
slots.map((slot) => ({
|
||||
...slot,
|
||||
y: settled.find((box) => box.id === slot.id)?.y ?? slot.y,
|
||||
}));
|
||||
|
||||
const begin = (
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
id: string,
|
||||
edge?: Edge,
|
||||
) => {
|
||||
if (event.button !== 0 || !editing || cancel.current) return;
|
||||
const element = gridEl.current;
|
||||
const box = settled.find((b) => b.id === id);
|
||||
const slot = slots.find((s) => s.id === id);
|
||||
if (!element || !box || !slot) return;
|
||||
if (!edge) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest(NO_DRAG) && !target.closest("[data-tg-grip]")) return;
|
||||
const native = event.nativeEvent;
|
||||
const scrolls =
|
||||
target.scrollHeight > target.clientHeight ||
|
||||
target.scrollWidth > target.clientWidth;
|
||||
if (
|
||||
scrolls &&
|
||||
(native.offsetX > target.clientWidth ||
|
||||
native.offsetY > target.clientHeight)
|
||||
)
|
||||
return;
|
||||
} else {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
const kind = edge ? "resize" : "move";
|
||||
const base = settledBase();
|
||||
const origin = element.getBoundingClientRect();
|
||||
const startLeft = box.x * pitch;
|
||||
const startTop = box.y * scale;
|
||||
const startWidth = box.w * pitch - inset;
|
||||
const startHeight = box.h * scale;
|
||||
const fixed = slot.h != null;
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const grabX = startX - origin.left - startLeft;
|
||||
const grabY = startY - origin.top - startTop;
|
||||
const scroller = scrollParent(element);
|
||||
const others = settled.filter((b) => b.id !== id);
|
||||
let pointer = { x: startX, y: startY };
|
||||
let started = kind === "resize";
|
||||
let current: Interaction | null = null;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const compute = (): Interaction => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (kind === "move") {
|
||||
const left = pointer.x - rect.left - grabX;
|
||||
const top = pointer.y - rect.top - grabY;
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
base,
|
||||
proposal: {
|
||||
x: clamp(Math.round(left / pitch), 0, TILE_COLUMNS - box.w),
|
||||
y: Math.max(0, top / scale),
|
||||
},
|
||||
frame: {
|
||||
left,
|
||||
top,
|
||||
width: startWidth,
|
||||
height: fixed ? startHeight : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
const dx = pointer.x - startX;
|
||||
const dy = pointer.y - rect.top - (startY - origin.top);
|
||||
const proposal: Proposal = {};
|
||||
const frame: Interaction["frame"] = {
|
||||
left: startLeft,
|
||||
top: startTop,
|
||||
width: startWidth,
|
||||
height: fixed ? startHeight : undefined,
|
||||
};
|
||||
const minWidth = minColumns * pitch - inset;
|
||||
if (edge === "right" || edge === "corner") {
|
||||
const raw = clamp(
|
||||
startWidth + dx,
|
||||
minWidth,
|
||||
(TILE_COLUMNS - box.x) * pitch - inset,
|
||||
);
|
||||
proposal.w = Math.min(
|
||||
clampTileColumns((raw + inset) / pitch, minColumns),
|
||||
TILE_COLUMNS - box.x,
|
||||
);
|
||||
frame.width = raw;
|
||||
}
|
||||
if (edge === "left") {
|
||||
const right = box.x + box.w;
|
||||
const raw = clamp(startWidth - dx, minWidth, right * pitch - inset);
|
||||
proposal.w = Math.min(
|
||||
clampTileColumns((raw + inset) / pitch, minColumns),
|
||||
right,
|
||||
);
|
||||
proposal.x = right - proposal.w;
|
||||
frame.width = raw;
|
||||
frame.left = startLeft + startWidth - raw;
|
||||
}
|
||||
if (edge === "bottom" || edge === "corner") {
|
||||
let height =
|
||||
clamp(startHeight + dy, minHeight * scale, MAX_TILE_HEIGHT * scale) /
|
||||
scale;
|
||||
// Line up with the bottom of a nearby tile.
|
||||
for (const other of others) {
|
||||
const bottom = other.y + other.h - box.y;
|
||||
if (bottom >= minHeight && Math.abs(bottom - height) <= SNAP) {
|
||||
height = bottom;
|
||||
break;
|
||||
}
|
||||
}
|
||||
proposal.h = Math.round(height);
|
||||
frame.height = proposal.h * scale;
|
||||
}
|
||||
return { id, kind, base, proposal, frame };
|
||||
};
|
||||
const update = () => {
|
||||
current = compute();
|
||||
setActive(current);
|
||||
};
|
||||
const autoScroll = () => {
|
||||
if (!scroller) return;
|
||||
const rect = scroller.getBoundingClientRect();
|
||||
const step =
|
||||
pointer.y < rect.top + SCROLL_EDGE
|
||||
? -12
|
||||
: pointer.y > rect.bottom - SCROLL_EDGE
|
||||
? 12
|
||||
: 0;
|
||||
if (!step) return;
|
||||
const before = scroller.scrollTop;
|
||||
scroller.scrollTop += step;
|
||||
if (scroller.scrollTop !== before) update();
|
||||
};
|
||||
const move = (e: PointerEvent) => {
|
||||
if (e.pointerId !== event.pointerId) return;
|
||||
pointer = { x: e.clientX, y: e.clientY };
|
||||
if (
|
||||
!started &&
|
||||
Math.hypot(pointer.x - startX, pointer.y - startY) < DRAG_THRESHOLD
|
||||
)
|
||||
return;
|
||||
if (!started) {
|
||||
started = true;
|
||||
window.getSelection?.()?.removeAllRanges();
|
||||
if (scroller) timer = setInterval(autoScroll, 16);
|
||||
}
|
||||
update();
|
||||
};
|
||||
const finish = (save: boolean) => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", cancelled);
|
||||
window.removeEventListener("keydown", key);
|
||||
window.removeEventListener("blur", blur);
|
||||
clearInterval(timer);
|
||||
cancel.current = null;
|
||||
setActive(null);
|
||||
if (save && started && current) commit(base, id, current.proposal);
|
||||
};
|
||||
const up = (e: PointerEvent) => {
|
||||
if (e.pointerId === event.pointerId) finish(true);
|
||||
};
|
||||
const cancelled = (e: PointerEvent) => {
|
||||
if (e.pointerId === event.pointerId) finish(false);
|
||||
};
|
||||
const key = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
finish(false);
|
||||
};
|
||||
const blur = () => finish(false);
|
||||
cancel.current = () => finish(false);
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", cancelled);
|
||||
window.addEventListener("keydown", key);
|
||||
window.addEventListener("blur", blur);
|
||||
if (started) update();
|
||||
};
|
||||
|
||||
const moveByKey = (event: ReactKeyboardEvent, id: string) => {
|
||||
const box = settled.find((b) => b.id === id);
|
||||
if (!box || !event.key.startsWith("Arrow")) return;
|
||||
const near = settled.filter(
|
||||
(b) => b.id !== id && b.x < box.x + box.w && box.x < b.x + b.w,
|
||||
);
|
||||
let proposal: Proposal | null = null;
|
||||
if (event.key === "ArrowLeft" && box.x > 0) proposal = { x: box.x - 1 };
|
||||
if (event.key === "ArrowRight" && box.x + box.w < TILE_COLUMNS)
|
||||
proposal = { x: box.x + 1 };
|
||||
if (event.key === "ArrowUp") {
|
||||
const above = near
|
||||
.filter((b) => b.y < box.y)
|
||||
.sort((a, b) => b.y - a.y)[0];
|
||||
if (above) proposal = { y: Math.max(0, above.y - 1) };
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
const below = near
|
||||
.filter((b) => b.y > box.y)
|
||||
.sort((a, b) => a.y - b.y)[0];
|
||||
if (below) proposal = { y: below.y + 1 };
|
||||
}
|
||||
event.preventDefault();
|
||||
if (proposal) commit(settledBase(), id, proposal);
|
||||
};
|
||||
|
||||
const resizeByKey = (
|
||||
event: ReactKeyboardEvent,
|
||||
id: string,
|
||||
axis: "width" | "height",
|
||||
) => {
|
||||
const box = settled.find((b) => b.id === id);
|
||||
if (!box) return;
|
||||
const keys =
|
||||
axis === "width" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
|
||||
if (![...keys, "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const sign = event.key === keys[1] ? 1 : -1;
|
||||
if (axis === "width") {
|
||||
const max = TILE_COLUMNS - box.x;
|
||||
const w =
|
||||
event.key === "Home"
|
||||
? minColumns
|
||||
: event.key === "End"
|
||||
? max
|
||||
: clamp(box.w + sign, minColumns, max);
|
||||
if (w !== box.w) commit(settledBase(), id, { w });
|
||||
} else {
|
||||
const current = Math.round(box.h);
|
||||
const h =
|
||||
event.key === "Home"
|
||||
? minHeight
|
||||
: event.key === "End"
|
||||
? MAX_TILE_HEIGHT
|
||||
: clamp(
|
||||
current + sign * (event.shiftKey ? 50 : 10),
|
||||
minHeight,
|
||||
MAX_TILE_HEIGHT,
|
||||
);
|
||||
if (h !== current) commit(settledBase(), id, { h });
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (id: string) =>
|
||||
commit(
|
||||
settledBase().filter((slot) => slot.id !== id),
|
||||
null,
|
||||
{},
|
||||
);
|
||||
|
||||
if (stacked) {
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={seamless}>
|
||||
<div
|
||||
ref={root}
|
||||
className={cn("flex flex-col", !seamless && "gap-3", className)}
|
||||
>
|
||||
{[...settled]
|
||||
.sort((a, b) => a.y - b.y || a.x - b.x)
|
||||
.map((box) => (
|
||||
<div
|
||||
key={box.id}
|
||||
className={cn(
|
||||
"w-full min-w-0",
|
||||
seamless && "border-b border-border",
|
||||
)}
|
||||
>
|
||||
{content.get(box.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const activeBox = active ? boxById.get(active.id) : undefined;
|
||||
// Edge to edge tiles sit on whole px so the lines between them are crisp.
|
||||
const snap = (value: number) => (seamless ? Math.round(value) : value);
|
||||
const leftOf = (box: TileBox) => snap(box.x * pitch);
|
||||
const widthOf = (box: TileBox) =>
|
||||
seamless
|
||||
? snap((box.x + box.w) * pitch) - snap(box.x * pitch)
|
||||
: box.w * pitch - inset;
|
||||
const bottom = tileBottom(preview, settleGap) * scale;
|
||||
// A kept bottom line sits one px under the last row.
|
||||
const height = Math.max(
|
||||
0,
|
||||
seamless ? snap(bottom) + (overflows ? 0 : 1) : bottom - inset,
|
||||
);
|
||||
const lines = seamless
|
||||
? gridLines(
|
||||
preview.map((box) => ({
|
||||
l: leftOf(box),
|
||||
t: snap(box.y * scale),
|
||||
r: leftOf(box) + widthOf(box),
|
||||
b: snap((box.y + box.h) * scale),
|
||||
})),
|
||||
snap(width),
|
||||
overflows,
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={seamless}>
|
||||
<div ref={root} className={className}>
|
||||
<div
|
||||
ref={gridEl}
|
||||
className="tg-grid"
|
||||
data-edit-mode={editing}
|
||||
data-animate={editing}
|
||||
style={{ height }}
|
||||
>
|
||||
<style>{styles}</style>
|
||||
{lines.map((line) => (
|
||||
<div
|
||||
key={`${line.x},${line.y},${line.w},${line.h}`}
|
||||
data-tg-line
|
||||
aria-hidden="true"
|
||||
// A real border, so it is as thin as every other line at any
|
||||
// display scaling; a 1 px fill rounds up to two screen px.
|
||||
className={cn(
|
||||
"pointer-events-none absolute z-[25] border-border",
|
||||
line.w === 1 ? "border-l" : "border-t",
|
||||
)}
|
||||
style={{
|
||||
left: line.x,
|
||||
top: line.y,
|
||||
width: line.w === 1 ? 0 : line.w,
|
||||
height: line.h === 1 ? 0 : line.h,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{activeBox && (
|
||||
<div
|
||||
data-tg-drop-preview={activeBox.id}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
transform: `translate(${leftOf(activeBox)}px, ${snap(activeBox.y * scale)}px)`,
|
||||
width: widthOf(activeBox),
|
||||
height: activeBox.h * scale,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{slots.map((slot) => {
|
||||
const box = boxById.get(slot.id)!;
|
||||
const frame = active?.id === slot.id ? active.frame : null;
|
||||
const tileHeight = frame
|
||||
? frame.height
|
||||
: seamless
|
||||
? snap((box.y + box.h) * scale) - snap(box.y * scale)
|
||||
: slot.h != null
|
||||
? slot.h * scale
|
||||
: undefined;
|
||||
return (
|
||||
<Tile
|
||||
key={slot.id}
|
||||
id={slot.id}
|
||||
box={box}
|
||||
label={tileLabel(slot.id)}
|
||||
editMode={editing}
|
||||
active={active?.id === slot.id ? active.kind : undefined}
|
||||
left={frame ? frame.left : leftOf(box)}
|
||||
top={frame ? frame.top : snap(box.y * scale)}
|
||||
width={frame ? frame.width : widthOf(box)}
|
||||
height={tileHeight}
|
||||
measure={slot.h == null}
|
||||
scale={scale}
|
||||
seamless={seamless}
|
||||
minColumns={minColumns}
|
||||
minHeight={minHeight}
|
||||
onHeight={reportHeight}
|
||||
onPointerDown={(event, edge) => begin(event, slot.id, edge)}
|
||||
onMoveKey={(event) => moveByKey(event, slot.id)}
|
||||
onResizeKey={(event, axis) => resizeByKey(event, slot.id, axis)}
|
||||
onSettings={onSettings ? () => onSettings(slot.id) : undefined}
|
||||
onRemove={() => remove(slot.id)}
|
||||
>
|
||||
{content.get(slot.id)}
|
||||
</Tile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{editing && slots.length === 0 && (
|
||||
<div className="flex items-center justify-center border border-dashed border-border py-10 text-xs text-muted-foreground">
|
||||
{empty ?? t("tileGrid.empty")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({
|
||||
id,
|
||||
box,
|
||||
label,
|
||||
editMode,
|
||||
active,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
measure,
|
||||
scale,
|
||||
seamless,
|
||||
minColumns,
|
||||
minHeight,
|
||||
onHeight,
|
||||
onPointerDown,
|
||||
onMoveKey,
|
||||
onResizeKey,
|
||||
onSettings,
|
||||
onRemove,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
box: TileBox;
|
||||
label: string;
|
||||
editMode: boolean;
|
||||
active?: Interaction["kind"];
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height?: number;
|
||||
/** The tile has no height of its own and reports its content's. */
|
||||
measure: boolean;
|
||||
scale: number;
|
||||
seamless: boolean;
|
||||
minColumns: number;
|
||||
minHeight: number;
|
||||
onHeight: (id: string, height: number) => void;
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLElement>, edge?: Edge) => void;
|
||||
onMoveKey: (event: ReactKeyboardEvent) => void;
|
||||
onResizeKey: (event: ReactKeyboardEvent, axis: "width" | "height") => void;
|
||||
onSettings?: () => void;
|
||||
onRemove: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const card = useRef<HTMLDivElement>(null);
|
||||
const body = useRef<HTMLDivElement>(null);
|
||||
const fixed = !measure;
|
||||
|
||||
// Tiles with no height of their own report their content's so the grid can
|
||||
// place them. Edge to edge, that is rounded up to a whole px.
|
||||
useLayoutEffect(() => {
|
||||
const element = seamless ? body.current : card.current;
|
||||
if (!element || fixed) return;
|
||||
const report = () => {
|
||||
const own = element.getBoundingClientRect().height;
|
||||
onHeight(id, (seamless ? Math.ceil(own) : own) / scale);
|
||||
};
|
||||
report();
|
||||
const observer = new ResizeObserver(report);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [id, fixed, scale, seamless, onHeight]);
|
||||
|
||||
const button =
|
||||
"flex size-6 items-center justify-center border border-border bg-card text-muted-foreground hover:text-foreground";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tg-tile={id}
|
||||
data-active={active}
|
||||
className="tg-tile"
|
||||
style={{
|
||||
transform: `translate(${left}px, ${top}px)`,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
onPointerDown={editMode ? (event) => onPointerDown(event) : undefined}
|
||||
>
|
||||
<div
|
||||
ref={card}
|
||||
className={cn(
|
||||
"tg-card relative flex min-h-0 min-w-0 flex-col",
|
||||
seamless && "min-h-full",
|
||||
)}
|
||||
data-tg-card={id}
|
||||
data-fixed-height={fixed}
|
||||
>
|
||||
<div
|
||||
ref={body}
|
||||
className={cn(
|
||||
"tg-content min-h-0",
|
||||
seamless && measure ? "flex-none" : "flex-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{editMode && (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-0 z-10 border-2 border-dashed border-accent-brand/30" />
|
||||
<div className="absolute top-1 right-1 z-20 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(button, "cursor-grab active:cursor-grabbing")}
|
||||
title={t("tileGrid.move")}
|
||||
aria-label={t("tileGrid.moveTile", { tile: label })}
|
||||
onKeyDown={onMoveKey}
|
||||
data-tg-grip
|
||||
>
|
||||
<GripVertical className="size-3" />
|
||||
</button>
|
||||
{onSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
title={t("tileGrid.settings", { tile: label })}
|
||||
aria-label={t("tileGrid.settings", { tile: label })}
|
||||
onClick={onSettings}
|
||||
>
|
||||
<Settings2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className={cn(
|
||||
button,
|
||||
"hover:border-destructive/40 hover:text-destructive",
|
||||
)}
|
||||
title={t("tileGrid.remove", { tile: label })}
|
||||
aria-label={t("tileGrid.remove", { tile: label })}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="tg-handle tg-edge-left"
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => onPointerDown(event, "left")}
|
||||
/>
|
||||
{(["width", "height"] as const).map((axis) => (
|
||||
<div
|
||||
key={axis}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation={axis === "width" ? "vertical" : "horizontal"}
|
||||
aria-label={`${label}: ${t(axis === "width" ? "tileGrid.width" : "tileGrid.height")}`}
|
||||
aria-valuemin={axis === "width" ? minColumns : minHeight}
|
||||
aria-valuemax={
|
||||
axis === "width" ? TILE_COLUMNS : MAX_TILE_HEIGHT
|
||||
}
|
||||
aria-valuenow={axis === "width" ? box.w : Math.round(box.h)}
|
||||
title={t(
|
||||
axis === "width" ? "tileGrid.width" : "tileGrid.height",
|
||||
)}
|
||||
className={`tg-handle ${axis === "width" ? "tg-edge-right" : "tg-edge-bottom"}`}
|
||||
onPointerDown={(event) =>
|
||||
onPointerDown(event, axis === "width" ? "right" : "bottom")
|
||||
}
|
||||
onKeyDown={(event) => onResizeKey(event, axis)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
axis === "width"
|
||||
? "h-12 w-0.5 rounded-full"
|
||||
: "h-0.5 w-12 rounded-full"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="tg-handle tg-edge-corner"
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => onPointerDown(event, "corner")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TileKind =
|
||||
"card" | "signal" | "action" | "session" | "resource" | "chart";
|
||||
|
||||
export interface TilePickerEntry {
|
||||
/** Tile id the grid gets when this entry is added. */
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Group heading, usually the plugin name. */
|
||||
group: string;
|
||||
kind: TileKind;
|
||||
icon?: ReactNode;
|
||||
/** Already on the grid; shown checked and not added twice. */
|
||||
placed?: boolean;
|
||||
}
|
||||
|
||||
const KINDS: TileKind[] = [
|
||||
"card",
|
||||
"chart",
|
||||
"signal",
|
||||
"action",
|
||||
"resource",
|
||||
"session",
|
||||
];
|
||||
const KIND_LABELS: Record<TileKind | "all", string> = {
|
||||
all: "tileGrid.kind.all",
|
||||
card: "tileGrid.kind.card",
|
||||
chart: "tileGrid.kind.chart",
|
||||
signal: "tileGrid.kind.signal",
|
||||
action: "tileGrid.kind.action",
|
||||
resource: "tileGrid.kind.resource",
|
||||
session: "tileGrid.kind.session",
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything that can go on a grid, in a narrow column beside it while
|
||||
* editing. Search, filter by kind and fold groups, so long lists from many
|
||||
* plugins stay usable.
|
||||
*/
|
||||
export function TilePicker({
|
||||
entries,
|
||||
onAdd,
|
||||
onClose,
|
||||
className,
|
||||
}: {
|
||||
entries: TilePickerEntry[];
|
||||
onAdd: (entry: TilePickerEntry) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [kind, setKind] = useState<TileKind | null>(null);
|
||||
const [folded, setFolded] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const kinds = useMemo(
|
||||
() => KINDS.filter((k) => entries.some((entry) => entry.kind === k)),
|
||||
[entries],
|
||||
);
|
||||
const groups = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const shown = entries.filter(
|
||||
(entry) =>
|
||||
(!kind || entry.kind === kind) &&
|
||||
(!needle ||
|
||||
entry.title.toLowerCase().includes(needle) ||
|
||||
entry.group.toLowerCase().includes(needle) ||
|
||||
entry.description?.toLowerCase().includes(needle)),
|
||||
);
|
||||
const map = new Map<string, TilePickerEntry[]>();
|
||||
for (const entry of shown) {
|
||||
const list = map.get(entry.group) ?? [];
|
||||
list.push(entry);
|
||||
map.set(entry.group, list);
|
||||
}
|
||||
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [entries, query, kind]);
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
const toggle = (group: string) =>
|
||||
setFolded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(group)) next.delete(group);
|
||||
else next.add(group);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-72 shrink-0 flex-col border-l border-border bg-surface/40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 border-b border-border px-3">
|
||||
<Plus className="size-3 text-accent-brand" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("tileGrid.addTiles")}
|
||||
</span>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground"
|
||||
title={t("tileGrid.closePicker")}
|
||||
aria-label={t("tileGrid.closePicker")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2 border-b border-border p-2">
|
||||
<label className="flex h-7 items-center gap-1.5 border border-border bg-background px-2">
|
||||
<Search className="size-3 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("tileGrid.search")}
|
||||
aria-label={t("tileGrid.search")}
|
||||
className="min-w-0 flex-1 bg-transparent text-xs outline-none"
|
||||
/>
|
||||
</label>
|
||||
{kinds.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[null, ...kinds].map((k) => (
|
||||
<button
|
||||
key={k ?? "all"}
|
||||
type="button"
|
||||
onClick={() => setKind(k)}
|
||||
className={cn(
|
||||
"h-5 border px-1.5 text-[10px] transition-colors",
|
||||
kind === k
|
||||
? "border-accent-brand text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(KIND_LABELS[k ?? "all"])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
|
||||
{groups.length === 0 && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{t("tileGrid.noMatches")}
|
||||
</p>
|
||||
)}
|
||||
{groups.map(([group, list]) => {
|
||||
const open = searching || !folded.has(group);
|
||||
return (
|
||||
<div key={group} className="border-b border-border last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(group)}
|
||||
aria-expanded={open}
|
||||
className="flex h-7 w-full items-center gap-1.5 px-2 text-left hover:bg-hover"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{group}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground/60">
|
||||
{list.length}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="pb-1">
|
||||
{list.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={entry.placed}
|
||||
onClick={() => onAdd(entry)}
|
||||
title={entry.description}
|
||||
className="flex w-full items-center gap-2 px-3 py-1 text-left text-xs transition-colors hover:bg-hover disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent"
|
||||
>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-3">
|
||||
{entry.icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{entry.title}</span>
|
||||
{entry.description && (
|
||||
<span className="block truncate text-[10px] text-muted-foreground">
|
||||
{entry.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{entry.placed ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<Plus className="size-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/** True inside an edge to edge TileGrid, where the grid draws the lines. */
|
||||
export const SeamlessTileContext = createContext(false);
|
||||
|
||||
export function useInSeamlessTile(): boolean {
|
||||
return useContext(SeamlessTileContext);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { TileGrid, type TileGridProps } from "./TileGrid";
|
||||
export { TileFrame } from "./TileFrame";
|
||||
export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker";
|
||||
export * from "./layout";
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* A tile on a 12 column grid. x and w are columns, y is unscaled px from the
|
||||
* top (the grid settles it), h is a fixed height in unscaled px or null for
|
||||
* the tile's natural height.
|
||||
*/
|
||||
export interface TileSlot {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h?: number | null;
|
||||
}
|
||||
|
||||
/** A tile with a resolved height, as the grid draws it. */
|
||||
export interface TileBox {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export const TILE_COLUMNS = 12;
|
||||
export const MIN_TILE_COLUMNS = 2;
|
||||
export const MIN_TILE_HEIGHT = 48;
|
||||
export const MAX_TILE_HEIGHT = 10000;
|
||||
/** y given to a new tile so it settles under everything else. */
|
||||
export const BOTTOM = 1_000_000;
|
||||
|
||||
export const clampTileColumns = (w: number, min = MIN_TILE_COLUMNS) =>
|
||||
Math.max(min, Math.min(TILE_COLUMNS, Math.round(w) || min));
|
||||
|
||||
const overlapsX = (a: TileBox, b: TileBox) =>
|
||||
a.x < b.x + b.w && b.x < a.x + a.w;
|
||||
|
||||
/**
|
||||
* Settles tiles top to bottom in (y, x) order. Each tile sits right under the
|
||||
* lowest tile placed before it in the columns it covers. Tiles never move
|
||||
* sideways and never jump above a tile that came before them.
|
||||
*/
|
||||
export function settleTiles(boxes: TileBox[], gap = 0): TileBox[] {
|
||||
const order = [...boxes].sort(
|
||||
(a, b) => a.y - b.y || a.x - b.x || (a.id < b.id ? -1 : 1),
|
||||
);
|
||||
const placed: TileBox[] = [];
|
||||
for (const box of order) {
|
||||
const y = placed.reduce(
|
||||
(top, p) => (overlapsX(p, box) ? Math.max(top, p.y + p.h + gap) : top),
|
||||
0,
|
||||
);
|
||||
placed.push({ ...box, y });
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
||||
export function tileBoxes(
|
||||
slots: TileSlot[],
|
||||
heights: Record<string, number>,
|
||||
fallback = 160,
|
||||
): TileBox[] {
|
||||
return slots.map((slot) => ({
|
||||
id: slot.id,
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
w: slot.w,
|
||||
h: slot.h ?? heights[slot.id] ?? fallback,
|
||||
}));
|
||||
}
|
||||
|
||||
export function tileBottom(boxes: TileBox[], gap = 0) {
|
||||
return boxes.reduce((max, box) => Math.max(max, box.y + box.h + gap), 0);
|
||||
}
|
||||
|
||||
/** Drops broken entries and duplicates and keeps every tile inside the grid. */
|
||||
export function sanitizeTiles(
|
||||
value: unknown,
|
||||
min = MIN_TILE_COLUMNS,
|
||||
): TileSlot[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: TileSlot[] = [];
|
||||
for (const raw of value) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const slot = raw as Record<string, unknown>;
|
||||
const id = typeof slot.id === "string" ? slot.id : "";
|
||||
if (!id || id.length > 300 || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const w = clampTileColumns(Number(slot.w), min);
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(TILE_COLUMNS - w, Math.round(Number(slot.x)) || 0),
|
||||
);
|
||||
const y = Math.max(0, Number(slot.y) || 0);
|
||||
const h =
|
||||
slot.h == null || !Number.isFinite(Number(slot.h))
|
||||
? null
|
||||
: Math.max(
|
||||
MIN_TILE_HEIGHT,
|
||||
Math.min(MAX_TILE_HEIGHT, Math.round(Number(slot.h))),
|
||||
);
|
||||
out.push({ id, x, y, w, h });
|
||||
}
|
||||
return out.slice(0, 200);
|
||||
}
|
||||
|
||||
/** Adds a tile at the bottom, at the left. */
|
||||
export function appendTile(
|
||||
slots: TileSlot[],
|
||||
tile: { id: string; w?: number; h?: number | null },
|
||||
): TileSlot[] {
|
||||
if (slots.some((slot) => slot.id === tile.id)) return slots;
|
||||
return [
|
||||
...slots,
|
||||
{
|
||||
id: tile.id,
|
||||
x: 0,
|
||||
y: BOTTOM,
|
||||
w: clampTileColumns(tile.w ?? 4),
|
||||
h: tile.h ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function removeTile(slots: TileSlot[], id: string): TileSlot[] {
|
||||
return slots.filter((slot) => slot.id !== id);
|
||||
}
|
||||
|
||||
/** Writes settled positions back, in reading order. */
|
||||
export function tilesFromBoxes(
|
||||
slots: TileSlot[],
|
||||
boxes: TileBox[],
|
||||
): TileSlot[] {
|
||||
const byId = new Map(boxes.map((box) => [box.id, box]));
|
||||
return slots
|
||||
.map((slot) => {
|
||||
const box = byId.get(slot.id);
|
||||
return box ? { ...slot, x: box.x, y: Math.round(box.y), w: box.w } : slot;
|
||||
})
|
||||
.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays tiles out left to right in rows, for layouts that only know an order
|
||||
* and a width (old dashboards, generated defaults).
|
||||
*/
|
||||
export function flowTiles(
|
||||
tiles: { id: string; w: number; h?: number | null }[],
|
||||
rowHeight = 160,
|
||||
): TileSlot[] {
|
||||
const out: TileSlot[] = [];
|
||||
let x = 0;
|
||||
let row = 0;
|
||||
for (const tile of tiles) {
|
||||
const w = clampTileColumns(tile.w);
|
||||
if (x + w > TILE_COLUMNS) {
|
||||
x = 0;
|
||||
row++;
|
||||
}
|
||||
out.push({ id: tile.id, x, y: row * rowHeight, w, h: tile.h ?? null });
|
||||
x += w;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A tile's box in whole screen px. */
|
||||
export interface PixelRect {
|
||||
l: number;
|
||||
t: number;
|
||||
r: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
/** A 1 px line the grid draws, as a box. */
|
||||
export interface GridLine {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
function mergeSpans(spans: [number, number][]): [number, number][] {
|
||||
const sorted = [...spans].sort((a, b) => a[0] - b[0]);
|
||||
const out: [number, number][] = [];
|
||||
for (const [start, end] of sorted) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && start <= last[1]) last[1] = Math.max(last[1], end);
|
||||
else out.push([start, end]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines between edge to edge tiles, each drawn once. A line sits on the
|
||||
* first px column or row past a tile, so it covers the edge of whatever is
|
||||
* next to it. The outer left, top and right edges are left out. With
|
||||
* `openBottom` (the grid runs to the bottom of the screen) so is the line
|
||||
* along the very bottom; a column that ends higher keeps its bottom line.
|
||||
* Lines reach one px further so corners join.
|
||||
*/
|
||||
export function gridLines(
|
||||
rects: PixelRect[],
|
||||
width: number,
|
||||
openBottom = false,
|
||||
): GridLine[] {
|
||||
const lowest = Math.max(0, ...rects.map((rect) => rect.b));
|
||||
const end = openBottom ? lowest : Infinity;
|
||||
const vertical = new Map<number, [number, number][]>();
|
||||
const horizontal = new Map<number, [number, number][]>();
|
||||
const add = (
|
||||
map: Map<number, [number, number][]>,
|
||||
at: number,
|
||||
span: [number, number],
|
||||
) => {
|
||||
if (span[1] <= span[0]) return;
|
||||
map.set(at, [...(map.get(at) ?? []), span]);
|
||||
};
|
||||
for (const rect of rects) {
|
||||
if (rect.r <= rect.l || rect.b <= rect.t) continue;
|
||||
const down: [number, number] = [rect.t, Math.min(end, rect.b + 1)];
|
||||
if (rect.l > 0) add(vertical, rect.l, down);
|
||||
if (rect.r < width) add(vertical, rect.r, down);
|
||||
const across: [number, number] = [rect.l, Math.min(width, rect.r + 1)];
|
||||
if (rect.t > 0) add(horizontal, rect.t, across);
|
||||
if (rect.b < end) add(horizontal, rect.b, across);
|
||||
}
|
||||
const lines: GridLine[] = [];
|
||||
for (const [x, spans] of vertical)
|
||||
for (const [start, end] of mergeSpans(spans))
|
||||
lines.push({ x, y: start, w: 1, h: end - start });
|
||||
for (const [y, spans] of horizontal)
|
||||
for (const [start, end] of mergeSpans(spans))
|
||||
lines.push({ x: start, y, w: end - start, h: 1 });
|
||||
return lines;
|
||||
}
|
||||
@@ -6,6 +6,24 @@ export interface DashboardCardRenderProps {
|
||||
isVisible: boolean;
|
||||
/** The shell, e.g. to open the full-page version of the card. */
|
||||
shell: TabShellCallbacks;
|
||||
/** This copy's settings, for cards with entries. Empty otherwise. */
|
||||
config?: Record<string, unknown>;
|
||||
setConfig?: (config: Record<string, unknown>) => void;
|
||||
/** True while the user is arranging the dashboard. */
|
||||
editMode?: boolean;
|
||||
}
|
||||
|
||||
/** One thing the add list offers from a card that can be placed many times. */
|
||||
export interface DashboardCardEntry {
|
||||
/** Stable within the card, used to mark entries already placed. */
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Group heading in the add list. Defaults to the card title. */
|
||||
group?: string;
|
||||
kind?: "card" | "chart" | "signal" | "action" | "resource" | "session";
|
||||
config: Record<string, unknown>;
|
||||
defaultSize?: { w: number; h?: number | null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +41,10 @@ export interface RegisteredDashboardCard {
|
||||
defaultPanel?: "main" | "side";
|
||||
/** "framed" gets a titled header from the dashboard; "bare" draws its own. */
|
||||
frame?: "framed" | "bare";
|
||||
/** Size on the 12 column dashboard grid when added. */
|
||||
defaultSize?: { w: number; h?: number | null };
|
||||
/** Lists what this card can show; each entry becomes its own tile. */
|
||||
entries?: (language: string) => Promise<DashboardCardEntry[]>;
|
||||
component: ComponentType<DashboardCardRenderProps>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1694,5 +1694,27 @@
|
||||
"admin": "Admin",
|
||||
"user": "User"
|
||||
}
|
||||
},
|
||||
"tileGrid": {
|
||||
"empty": "No tiles yet. Add some from the list.",
|
||||
"move": "Drag to move",
|
||||
"moveTile": "Move {{tile}}",
|
||||
"settings": "{{tile}} settings",
|
||||
"remove": "Remove {{tile}}",
|
||||
"width": "Width",
|
||||
"height": "Height",
|
||||
"addTiles": "Add tiles",
|
||||
"closePicker": "Close",
|
||||
"search": "Search tiles",
|
||||
"noMatches": "Nothing matches.",
|
||||
"kind": {
|
||||
"all": "All",
|
||||
"card": "Cards",
|
||||
"chart": "Charts",
|
||||
"signal": "Status",
|
||||
"action": "Actions",
|
||||
"resource": "Lists",
|
||||
"session": "Sessions"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -534,6 +534,8 @@ export function createPluginApp(
|
||||
defaultHeight: card.defaultHeight,
|
||||
defaultPanel: card.defaultPanel,
|
||||
frame: card.frame,
|
||||
defaultSize: card.defaultSize,
|
||||
entries: card.entries,
|
||||
component: scoped(
|
||||
card.component as unknown as ComponentType<DashboardCardRenderProps>,
|
||||
),
|
||||
|
||||
@@ -127,6 +127,20 @@ export {
|
||||
type GridLayout,
|
||||
type GridSlot,
|
||||
} from "@/components/card-grid/types";
|
||||
export {
|
||||
TileFrame,
|
||||
TileGrid,
|
||||
TilePicker,
|
||||
appendTile,
|
||||
flowTiles,
|
||||
removeTile,
|
||||
sanitizeTiles,
|
||||
TILE_COLUMNS,
|
||||
type TileGridProps,
|
||||
type TileKind,
|
||||
type TilePickerEntry,
|
||||
type TileSlot,
|
||||
} from "@/components/tile-grid";
|
||||
export { ConnectionScreen } from "@/components/connection/ConnectionScreen";
|
||||
export { type ConnectionStatus } from "@/components/connection/connection-status";
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { TileGrid } from "../../../components/tile-grid/TileGrid";
|
||||
import { MetricCard } from "../../../components/metric-card";
|
||||
import type { TileSlot } from "../../../components/tile-grid/layout";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}));
|
||||
|
||||
// 1212px wide grid: one column is (1212 + 12) / 12 = 102px.
|
||||
let gridWidth = 1212;
|
||||
const PITCH = 102;
|
||||
const original = HTMLElement.prototype.getBoundingClientRect;
|
||||
|
||||
beforeAll(() => {
|
||||
document.documentElement.style.fontSize = "14px";
|
||||
vi.stubGlobal(
|
||||
"PointerEvent",
|
||||
class extends MouseEvent {
|
||||
pointerId: number;
|
||||
constructor(type: string, init: PointerEventInit = {}) {
|
||||
super(type, init);
|
||||
this.pointerId = init.pointerId ?? 1;
|
||||
}
|
||||
},
|
||||
);
|
||||
globalThis.ResizeObserver ??= class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
|
||||
if (this.dataset.tgCard) return { height: 200, width: 0 } as DOMRect;
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: gridWidth,
|
||||
height: 0,
|
||||
right: gridWidth,
|
||||
bottom: 0,
|
||||
} as DOMRect;
|
||||
};
|
||||
});
|
||||
afterAll(() => {
|
||||
HTMLElement.prototype.getBoundingClientRect = original;
|
||||
document.documentElement.style.removeProperty("font-size");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
beforeEach(() => {
|
||||
gridWidth = 1212;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const three: TileSlot[] = [
|
||||
{ id: "cpu", x: 0, y: 0, w: 4, h: null },
|
||||
{ id: "memory", x: 4, y: 0, w: 4, h: null },
|
||||
{ id: "disk", x: 8, y: 0, w: 4, h: null },
|
||||
];
|
||||
|
||||
function mount(layout = three, editMode = true) {
|
||||
const changed = vi.fn();
|
||||
function Harness() {
|
||||
const [value, setValue] = useState(layout);
|
||||
return (
|
||||
<TileGrid
|
||||
slots={value}
|
||||
gap={12}
|
||||
minHeight={80}
|
||||
editMode={editMode}
|
||||
tileLabel={(id) => id}
|
||||
onChange={(next) => {
|
||||
changed(next);
|
||||
setValue(next);
|
||||
}}
|
||||
renderTile={(id) => <div data-testid={`body-${id}`}>{id}</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return { ...render(<Harness />), changed };
|
||||
}
|
||||
|
||||
const tile = (id: string) =>
|
||||
document.querySelector<HTMLElement>(`[data-tg-tile="${id}"]`)!;
|
||||
const position = (element: HTMLElement) => {
|
||||
const match = /translate\((-?[\d.]+)px, (-?[\d.]+)px\)/.exec(
|
||||
element.style.transform,
|
||||
)!;
|
||||
return { x: Number(match[1]), y: Number(match[2]) };
|
||||
};
|
||||
const pointer = (
|
||||
target: Element | Window,
|
||||
type: "pointerDown" | "pointerMove" | "pointerUp",
|
||||
x: number,
|
||||
y: number,
|
||||
) =>
|
||||
fireEvent[type](target, { clientX: x, clientY: y, button: 0, pointerId: 1 });
|
||||
const slot = (slots: TileSlot[], id: string) => slots.find((s) => s.id === id)!;
|
||||
|
||||
describe("TileGrid", () => {
|
||||
it("places tiles on the 12 column grid", () => {
|
||||
mount();
|
||||
expect(position(tile("cpu"))).toEqual({ x: 0, y: 0 });
|
||||
expect(position(tile("memory"))).toEqual({ x: 4 * PITCH, y: 0 });
|
||||
expect(position(tile("disk"))).toEqual({ x: 8 * PITCH, y: 0 });
|
||||
expect(tile("cpu").style.width).toBe(`${4 * PITCH - 12}px`);
|
||||
});
|
||||
|
||||
it("drops a dragged card exactly where the preview showed it", () => {
|
||||
const { changed } = mount();
|
||||
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
|
||||
pointer(window, "pointerMove", 10 + 4 * PITCH, 300);
|
||||
const ghost = document.querySelector<HTMLElement>(
|
||||
"[data-tg-drop-preview]",
|
||||
)!;
|
||||
const previewed = position(ghost);
|
||||
expect(previewed).toEqual({ x: 4 * PITCH, y: 212 });
|
||||
expect(position(tile("memory"))).toEqual({ x: 4 * PITCH, y: 0 });
|
||||
expect(position(tile("disk"))).toEqual({ x: 8 * PITCH, y: 0 });
|
||||
pointer(window, "pointerUp", 10 + 4 * PITCH, 300);
|
||||
const next = changed.mock.calls[0][0] as TileSlot[];
|
||||
expect(slot(next, "cpu")).toMatchObject({ x: 4, y: 212 });
|
||||
expect(position(tile("cpu"))).toEqual(previewed);
|
||||
expect(slot(next, "memory")).toMatchObject({ x: 4, y: 0 });
|
||||
expect(slot(next, "disk")).toMatchObject({ x: 8, y: 0 });
|
||||
});
|
||||
|
||||
it("moves a card into another column without touching the rest", () => {
|
||||
const layout: TileSlot[] = [
|
||||
{ id: "cpu", x: 0, y: 0, w: 4, h: null },
|
||||
{ id: "memory", x: 4, y: 0, w: 4, h: null },
|
||||
];
|
||||
const { changed } = mount(layout);
|
||||
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
|
||||
pointer(window, "pointerMove", 10 + 8 * PITCH, 10);
|
||||
pointer(window, "pointerUp", 10 + 8 * PITCH, 10);
|
||||
const next = changed.mock.calls[0][0] as TileSlot[];
|
||||
expect(slot(next, "cpu")).toMatchObject({ x: 8, y: 0 });
|
||||
expect(slot(next, "memory")).toMatchObject({ x: 4, y: 0 });
|
||||
});
|
||||
|
||||
it("does nothing on a click without movement", () => {
|
||||
const { changed } = mount();
|
||||
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
|
||||
pointer(window, "pointerMove", 12, 11);
|
||||
pointer(window, "pointerUp", 12, 11);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only pushes the overlapping card when a card gets wider", () => {
|
||||
const { changed } = mount();
|
||||
const edge = tile("cpu").querySelector(".tg-edge-right")!;
|
||||
pointer(edge, "pointerDown", 400, 100);
|
||||
pointer(window, "pointerMove", 400 + 2 * PITCH, 100);
|
||||
expect(tile("cpu").dataset.active).toBe("resize");
|
||||
pointer(window, "pointerUp", 400 + 2 * PITCH, 100);
|
||||
const next = changed.mock.calls[0][0] as TileSlot[];
|
||||
expect(slot(next, "cpu")).toMatchObject({ x: 0, y: 0, w: 6 });
|
||||
expect(slot(next, "memory")).toMatchObject({ x: 4, y: 212 });
|
||||
expect(slot(next, "disk")).toMatchObject({ x: 8, y: 0, w: 4 });
|
||||
});
|
||||
|
||||
it("resizes from the left edge keeping the right side in place", () => {
|
||||
const { changed } = mount();
|
||||
const edge = tile("disk").querySelector(".tg-edge-left")!;
|
||||
pointer(edge, "pointerDown", 8 * PITCH, 100);
|
||||
pointer(window, "pointerMove", 8 * PITCH + 2 * PITCH, 100);
|
||||
pointer(window, "pointerUp", 8 * PITCH + 2 * PITCH, 100);
|
||||
const next = changed.mock.calls[0][0] as TileSlot[];
|
||||
expect(slot(next, "disk")).toMatchObject({ x: 10, w: 2 });
|
||||
});
|
||||
|
||||
it("pins the height and snaps to a neighbour's bottom", () => {
|
||||
const layout: TileSlot[] = [
|
||||
{ id: "cpu", x: 0, y: 0, w: 4, h: null },
|
||||
{ id: "memory", x: 4, y: 0, w: 4, h: 300 },
|
||||
];
|
||||
const { changed } = mount(layout);
|
||||
const edge = tile("memory").querySelector(".tg-edge-bottom")!;
|
||||
pointer(edge, "pointerDown", 500, 300);
|
||||
pointer(window, "pointerMove", 500, 196);
|
||||
pointer(window, "pointerUp", 500, 196);
|
||||
const next = changed.mock.calls[0][0] as TileSlot[];
|
||||
expect(slot(next, "memory").h).toBe(200);
|
||||
});
|
||||
|
||||
it("cancels on Escape without saving", () => {
|
||||
const { changed } = mount();
|
||||
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
|
||||
pointer(window, "pointerMove", 10, 300);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
pointer(window, "pointerUp", 10, 300);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("[data-tg-drop-preview]")).toBeNull();
|
||||
expect(position(tile("cpu"))).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("resizes and moves with the keyboard", () => {
|
||||
const { changed } = mount();
|
||||
const width = tile("disk").querySelector('[aria-orientation="vertical"]')!;
|
||||
fireEvent.keyDown(width, { key: "ArrowLeft" });
|
||||
expect(slot(changed.mock.calls[0][0], "disk").w).toBe(3);
|
||||
const grip = tile("cpu").querySelector("[data-tg-grip]")!;
|
||||
fireEvent.keyDown(grip, { key: "ArrowRight" });
|
||||
expect(slot(changed.mock.calls[1][0], "cpu").x).toBe(1);
|
||||
expect(slot(changed.mock.calls[1][0], "memory")).toMatchObject({ y: 212 });
|
||||
});
|
||||
|
||||
it("removes tiles", () => {
|
||||
const { changed } = mount();
|
||||
fireEvent.click(screen.getAllByLabelText("tileGrid.remove")[0]);
|
||||
expect(changed.mock.calls[0][0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("draws each line between edge to edge tiles once", () => {
|
||||
render(
|
||||
<TileGrid
|
||||
slots={[
|
||||
{ id: "cpu", x: 0, y: 0, w: 4, h: 100 },
|
||||
{ id: "memory", x: 4, y: 0, w: 4, h: 300 },
|
||||
{ id: "disk", x: 0, y: 100, w: 4, h: 50 },
|
||||
]}
|
||||
editMode={false}
|
||||
tileLabel={(id) => id}
|
||||
onChange={() => {}}
|
||||
renderTile={(id) => <div>{id}</div>}
|
||||
/>,
|
||||
);
|
||||
// Whole px tiles that touch, with no lines of their own.
|
||||
expect(position(tile("memory")).x).toBe(404);
|
||||
expect(parseFloat(tile("cpu").style.width)).toBe(404);
|
||||
expect(parseFloat(tile("cpu").style.height)).toBe(100);
|
||||
expect(tile("cpu").querySelector(".tg-card")!.className).not.toMatch(
|
||||
/(^| )border/,
|
||||
);
|
||||
const vertical = [
|
||||
...document.querySelectorAll<HTMLElement>("[data-tg-line].border-l"),
|
||||
].map((el) => [
|
||||
parseFloat(el.style.left),
|
||||
parseFloat(el.style.top),
|
||||
parseFloat(el.style.height),
|
||||
]);
|
||||
// One line down the middle, as tall as the taller side, and one on the
|
||||
// open right edge of the middle tile. Drawn as borders so they are as
|
||||
// thin as other lines at any display scaling.
|
||||
expect(vertical).toEqual([
|
||||
[404, 0, 301],
|
||||
[808, 0, 301],
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives metric cards the tile look only in an edge to edge grid", () => {
|
||||
const card = (gap?: number) =>
|
||||
render(
|
||||
<TileGrid
|
||||
slots={[{ id: "cpu", x: 0, y: 0, w: 4, h: null }]}
|
||||
gap={gap}
|
||||
editMode={false}
|
||||
tileLabel={(id) => id}
|
||||
onChange={() => {}}
|
||||
renderTile={() => (
|
||||
<MetricCard title="CPU" icon={null}>
|
||||
x
|
||||
</MetricCard>
|
||||
)}
|
||||
/>,
|
||||
).container.querySelector(".tg-content > div")!.className;
|
||||
expect(card()).not.toContain("bg-card");
|
||||
cleanup();
|
||||
expect(card(12)).toContain("border border-border bg-card");
|
||||
});
|
||||
|
||||
it("stacks cards with no edit controls on narrow screens", () => {
|
||||
gridWidth = 500;
|
||||
mount();
|
||||
expect(document.querySelector("[data-tg-tile]")).toBeNull();
|
||||
expect(screen.getByTestId("body-disk")).toBeInTheDocument();
|
||||
expect(screen.queryAllByRole("separator")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows no edit controls outside edit mode", () => {
|
||||
mount(three, false);
|
||||
expect(screen.queryAllByRole("separator")).toHaveLength(0);
|
||||
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
|
||||
expect(document.querySelector("[data-active]")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BOTTOM,
|
||||
appendTile,
|
||||
flowTiles,
|
||||
gridLines,
|
||||
removeTile,
|
||||
sanitizeTiles,
|
||||
settleTiles,
|
||||
tileBottom,
|
||||
tileBoxes,
|
||||
tilesFromBoxes,
|
||||
} from "../../../components/tile-grid/layout";
|
||||
|
||||
describe("tile grid layout", () => {
|
||||
it("settles tiles under the ones above them in the same columns", () => {
|
||||
const boxes = settleTiles([
|
||||
{ id: "a", x: 0, y: 0, w: 6, h: 100 },
|
||||
{ id: "b", x: 6, y: 0, w: 6, h: 50 },
|
||||
{ id: "c", x: 6, y: 500, w: 6, h: 50 },
|
||||
{ id: "d", x: 0, y: 900, w: 12, h: 20 },
|
||||
]);
|
||||
const y = Object.fromEntries(boxes.map((box) => [box.id, box.y]));
|
||||
expect(y).toEqual({ a: 0, b: 0, c: 50, d: 100 });
|
||||
expect(tileBottom(boxes)).toBe(120);
|
||||
});
|
||||
|
||||
it("overlaps by the gap when it is negative, for shared borders", () => {
|
||||
const boxes = settleTiles(
|
||||
[
|
||||
{ id: "a", x: 0, y: 0, w: 12, h: 100 },
|
||||
{ id: "b", x: 0, y: 1, w: 12, h: 100 },
|
||||
],
|
||||
-1,
|
||||
);
|
||||
expect(boxes[1].y).toBe(99);
|
||||
});
|
||||
|
||||
it("uses measured heights for tiles without a fixed one", () => {
|
||||
const boxes = tileBoxes(
|
||||
[
|
||||
{ id: "a", x: 0, y: 0, w: 4, h: null },
|
||||
{ id: "b", x: 0, y: 0, w: 4, h: 30 },
|
||||
{ id: "c", x: 0, y: 0, w: 4 },
|
||||
],
|
||||
{ a: 77 },
|
||||
10,
|
||||
);
|
||||
expect(boxes.map((box) => box.h)).toEqual([77, 30, 10]);
|
||||
});
|
||||
|
||||
it("appends at the bottom once and removes by id", () => {
|
||||
const start = [{ id: "a", x: 0, y: 0, w: 4, h: null }];
|
||||
const added = appendTile(start, { id: "b", w: 30 });
|
||||
expect(added[1]).toEqual({ id: "b", x: 0, y: BOTTOM, w: 12, h: null });
|
||||
expect(appendTile(added, { id: "b" })).toBe(added);
|
||||
expect(removeTile(added, "a").map((slot) => slot.id)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("writes settled positions back in reading order", () => {
|
||||
const slots = [
|
||||
{ id: "b", x: 6, y: 300, w: 6, h: 10 },
|
||||
{ id: "a", x: 0, y: 0, w: 6, h: 10 },
|
||||
];
|
||||
const out = tilesFromBoxes(slots, settleTiles(tileBoxes(slots, {})));
|
||||
expect(out.map((slot) => [slot.id, slot.y])).toEqual([
|
||||
["a", 0],
|
||||
["b", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("cleans saved layouts", () => {
|
||||
expect(sanitizeTiles("nope")).toEqual([]);
|
||||
expect(
|
||||
sanitizeTiles([
|
||||
{ id: "a", x: 11, y: -5, w: 6, h: 3 },
|
||||
{ id: "a", x: 0, y: 0, w: 4 },
|
||||
{ x: 0 },
|
||||
null,
|
||||
{ id: "b", x: "x", y: 10, w: 1, h: "big" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ id: "a", x: 6, y: 0, w: 6, h: 48 },
|
||||
{ id: "b", x: 0, y: 10, w: 2, h: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flows tiles into rows by width", () => {
|
||||
const out = flowTiles([
|
||||
{ id: "a", w: 8 },
|
||||
{ id: "b", w: 4 },
|
||||
{ id: "c", w: 6 },
|
||||
]);
|
||||
expect(out.map((slot) => [slot.id, slot.x, slot.y])).toEqual([
|
||||
["a", 0, 0],
|
||||
["b", 8, 0],
|
||||
["c", 0, 160],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gridLines", () => {
|
||||
it("draws a shared edge once, as long as the longer side", () => {
|
||||
const lines = gridLines(
|
||||
[
|
||||
{ l: 0, t: 0, r: 100, b: 50 },
|
||||
{ l: 100, t: 0, r: 200, b: 120 },
|
||||
],
|
||||
200,
|
||||
);
|
||||
expect(lines.filter((line) => line.w === 1)).toEqual([
|
||||
{ x: 100, y: 0, w: 1, h: 121 },
|
||||
]);
|
||||
expect(lines.filter((line) => line.h === 1)).toEqual([
|
||||
{ x: 0, y: 50, w: 101, h: 1 },
|
||||
{ x: 100, y: 120, w: 100, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves out the outer left, top and right edges and keeps bottoms", () => {
|
||||
expect(gridLines([{ l: 0, t: 0, r: 200, b: 80 }], 200)).toEqual([
|
||||
{ x: 0, y: 80, w: 200, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops the very bottom line when the grid runs to the bottom of the screen", () => {
|
||||
const lines = gridLines(
|
||||
[
|
||||
{ l: 0, t: 0, r: 100, b: 50 },
|
||||
{ l: 100, t: 0, r: 200, b: 120 },
|
||||
],
|
||||
200,
|
||||
true,
|
||||
);
|
||||
// The shorter column still gets its bottom line.
|
||||
expect(lines.filter((line) => line.h === 1)).toEqual([
|
||||
{ x: 0, y: 50, w: 101, h: 1 },
|
||||
]);
|
||||
expect(lines.filter((line) => line.w === 1)).toEqual([
|
||||
{ x: 100, y: 0, w: 1, h: 120 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("joins rows into one line across tiles", () => {
|
||||
const lines = gridLines(
|
||||
[
|
||||
{ l: 0, t: 0, r: 100, b: 50 },
|
||||
{ l: 100, t: 0, r: 200, b: 50 },
|
||||
{ l: 0, t: 50, r: 200, b: 90 },
|
||||
],
|
||||
200,
|
||||
);
|
||||
expect(lines.filter((line) => line.h === 1 && line.y === 50)).toEqual([
|
||||
{ x: 0, y: 50, w: 200, h: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user