Grow the editor's canvas to reach widgets below its edge

A widget that fell off the panel, after the canvas was made smaller, was
drawn under the clip and could only be re-packed with Reflow. Editing, the
canvas now extends to the lowest widget and scrolls, with the panel's own
edge dashed and the area past it hatched, so the widget can be dragged
back. The scale stays the panel's, and view mode is unchanged.
This commit is contained in:
2026-10-15 16:05:23 +02:00
parent 984da1706c
commit 14e651d5eb
4 changed files with 74 additions and 5 deletions
+4
View File
@@ -16,6 +16,10 @@ Edits stay in a draft; paired screens show the published version. On phones,
tiles stack in one column and dragging is disabled, but settings remain editable.
In edit mode, ⌘Z / Ctrl-Z undoes an edit and ⌘⇧Z / Ctrl-Shift-Z redoes it.
A widget can end up below the canvas, for example after the canvas was made
smaller. In edit mode the canvas then grows to reach it, under a dashed line
where the panel ends. Scroll down and drag it back, or use **Reflow**.
Controls can publish real values while you edit, so choose their targets with
care. Your new Switch may already be flipping something!
@@ -561,6 +561,11 @@ export function DashboardEditor({
// now falls past its bottom edge drawn under the clip. A round face has no
// grid to fall off.
const clipped = round ? [] : layout.filter((item) => item.y + item.h > rows)
// Editing, the canvas grows to reach them, so they can be dragged back.
const reach =
edit && clipped.length
? Math.max(...clipped.map((item) => item.y + item.h))
: undefined
const shown = Math.max(
0,
widgets.findIndex((widget) => widget.id === (selected ?? face)),
@@ -669,7 +674,7 @@ export function DashboardEditor({
) : null}
</div>
) : (
<CanvasSurface dashboard={draft} dots={edit} rail={rail}>
<CanvasSurface dashboard={draft} dots={edit} rail={rail} reach={reach}>
{(scale) =>
!edit ? (
<DashboardView dashboard={draft} rail={hasRail} />
@@ -176,6 +176,7 @@ export function CanvasSurface({
dots,
rail,
touch,
reach,
children,
}: {
dashboard: Dashboard
@@ -185,6 +186,13 @@ export function CanvasSurface({
rail?: React.ReactNode
/** Whether the panel this canvas hangs on is touched rather than pointed at. */
touch?: boolean
/**
* How many grid rows the arrangement reaches down to, while it is being
* arranged. A canvas they fall off grows to reach them, with its own edge
* still drawn, so they can be scrolled to and moved back. The scale stays
* the panel's.
*/
reach?: number
children: (scale: number) => React.ReactNode
}) {
const ref = useRef<HTMLDivElement>(null)
@@ -208,6 +216,17 @@ export function CanvasSurface({
const root = useCanvasRoot(dashboard, touch)
const round = isRound(dashboard)
const area = areaOf(dashboard, Boolean(rail))
// How far past the bottom edge the rows reached go, margin included.
const beyond = reach
? Math.max(
0,
area.top +
reach * rowPitchOf(dashboard, Boolean(rail)) -
GRID_GAP +
GRID_INSET -
height,
)
: 0
// The arrangement, held across the renders that are not about it. This
// component reads the dashboard's own settings, so a bound one wakes it on
// every message the flow sends — and calling a render prop again rebuilds
@@ -219,7 +238,17 @@ export function CanvasSurface({
)
return (
<div ref={ref} className="relative size-full overflow-hidden">
<div
ref={ref}
className={cn(
"relative size-full",
// The gutter is kept, so a scrollbar coming and going cannot change
// the width the scale is measured from.
beyond
? "overflow-x-hidden overflow-y-auto [scrollbar-gutter:stable]"
: "overflow-hidden",
)}
>
{/* Measured first: a guessed scale would place the whole panel once and
then move it. */}
{scale > 0 ? (
@@ -238,7 +267,7 @@ export function CanvasSurface({
{
...root.style,
width,
height,
height: height + beyond,
// Round, nothing outside the circle is drawn.
clipPath: round ? "circle(50%)" : undefined,
left: (box.width - width * scale) / 2,
@@ -255,14 +284,30 @@ export function CanvasSurface({
{/* Full bleed, and under everything: a background covers the
panel, margin included. */}
<CanvasGround dashboard={dashboard} />
{rail}
{beyond ? (
<div
aria-hidden
data-testid="canvas-beyond"
className="canvas-beyond pointer-events-none absolute inset-x-0"
style={{ top: height, height: beyond }}
/>
) : null}
{/* Centred on the panel, not on a canvas grown past it. */}
{rail ? (
<div
className="pointer-events-none absolute inset-x-0 top-0"
style={{ height }}
>
{rail}
</div>
) : null}
<div
className="absolute"
style={{
left: area.left,
top: area.top,
width: area.width,
height: area.height,
height: area.height + beyond,
}}
>
{/* Over the ground rather than under it: the dots are what is
@@ -19,6 +19,21 @@
background-position: -1px -1px;
}
/*
* Below the panel's bottom edge, while the editor has grown the canvas to
* reach widgets that fell off it: hatched, under a dashed line where the panel
* ends, so what it will not show reads as outside it.
*/
.canvas-beyond {
border-top: 3px dashed
color-mix(in srgb, var(--muted-foreground) 70%, transparent);
background: repeating-linear-gradient(
-45deg,
color-mix(in srgb, var(--muted-foreground) 14%, transparent) 0 3px,
transparent 3px 16px
);
}
/*
* View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall
* panel can be matched to its own width. It does not reflow: the grid lives on