fix(editor): fill canvas viewport, fix black rulers, add resizable panel (#258)

The image editor canvas only used part of the viewport, and the right sidebar was a fixed width that could clip its controls on shorter screens.

- Canvas: the canvas container used `flex-1`, but its parent wrapper in editor-page.tsx was not a flex container, so it collapsed to the Konva Stage's content height (~600px), leaving a large inert region below. Make the wrapper a flex container so the canvas fills the available area.

- Rulers: ruler background/ticks were set via `ctx.fillStyle = "var(--color-card)"`, which canvas 2D cannot parse, so the default black fill remained and painted the rulers as solid black bars. Resolve the theme tokens to concrete colors from computed style at draw time (theme-aware).

- Right panel: add a left-edge drag handle to resize the panel (240-480px, persisted to localStorage) and `min-h-0` so the tab content scrolls internally instead of pushing the color controls off-screen.

Adds editor-layout.spec.ts (canvas-fill + resize) and a ruler-not-black regression test. All 7 targeted editor e2e tests pass.
This commit is contained in:
SnapOtter
2026-06-17 14:21:35 +08:00
parent 609cd6b609
commit 063a2e47e2
5 changed files with 171 additions and 15 deletions
@@ -7,9 +7,21 @@ import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
const RULER_SIZE = 20; // px
const TICK_COLOR = "var(--color-muted-foreground)";
const BG_COLOR = "var(--color-card)";
const TEXT_COLOR = "var(--color-muted-foreground)";
// Canvas 2D `fillStyle`/`strokeStyle` do NOT understand CSS custom properties:
// assigning `var(--color-card)` is invalid and silently leaves the previous
// style in place (black by default), which painted the rulers as solid black
// bars (issue #258). Resolve the theme tokens to concrete colors from the
// element's computed style at draw time so the rulers stay correct in both
// light and dark themes.
function resolveRulerColors(el: HTMLElement): { bg: string; tick: string } {
const styles = getComputedStyle(el);
const read = (prop: string, fallback: string) => styles.getPropertyValue(prop).trim() || fallback;
return {
bg: read("--color-card", "#ffffff"),
tick: read("--color-muted-foreground", "#6b6560"),
};
}
// ---------------------------------------------------------------------------
// Helper: pick tick spacing based on zoom level
@@ -43,6 +55,7 @@ export function HorizontalRuler() {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { bg, tick } = resolveRulerColors(canvas);
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = RULER_SIZE;
@@ -51,7 +64,7 @@ export function HorizontalRuler() {
ctx.scale(dpr, dpr);
// Background
ctx.fillStyle = BG_COLOR;
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
const { major, minor } = getTickInterval(zoom);
@@ -59,8 +72,8 @@ export function HorizontalRuler() {
const endPx = (w - panOffset.x) / zoom;
const startTick = Math.floor(startPx / minor) * minor;
ctx.strokeStyle = TICK_COLOR;
ctx.fillStyle = TEXT_COLOR;
ctx.strokeStyle = tick;
ctx.fillStyle = tick;
ctx.font = "9px sans-serif";
ctx.textBaseline = "top";
@@ -83,7 +96,7 @@ export function HorizontalRuler() {
}
// Bottom border
ctx.strokeStyle = TICK_COLOR;
ctx.strokeStyle = tick;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, h - 0.5);
@@ -155,6 +168,7 @@ export function VerticalRuler() {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { bg, tick } = resolveRulerColors(canvas);
const dpr = window.devicePixelRatio || 1;
const w = RULER_SIZE;
const h = canvas.clientHeight;
@@ -162,7 +176,7 @@ export function VerticalRuler() {
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = BG_COLOR;
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
const { major, minor } = getTickInterval(zoom);
@@ -170,8 +184,8 @@ export function VerticalRuler() {
const endPx = (h - panOffset.y) / zoom;
const startTick = Math.floor(startPx / minor) * minor;
ctx.strokeStyle = TICK_COLOR;
ctx.fillStyle = TEXT_COLOR;
ctx.strokeStyle = tick;
ctx.fillStyle = tick;
ctx.font = "9px sans-serif";
for (let t = startTick; t <= endPx + minor; t += minor) {
@@ -198,7 +212,7 @@ export function VerticalRuler() {
}
// Right border
ctx.strokeStyle = TICK_COLOR;
ctx.strokeStyle = tick;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(w - 0.5, 0);
@@ -1,5 +1,6 @@
// apps/web/src/components/editor/editor-right-panel.tsx
import { ChevronRight } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
@@ -15,6 +16,17 @@ const TABS = [
{ id: "history" as const, label: "History" },
];
// The panel is user-resizable so it never has to consume a fixed, excessive
// share of the viewport (issue #258). Width persists across reloads.
const MIN_PANEL_WIDTH = 240;
const MAX_PANEL_WIDTH = 480;
const DEFAULT_PANEL_WIDTH = 280;
const PANEL_WIDTH_STORAGE_KEY = "snapotter-editor-right-panel-width";
function clampPanelWidth(w: number): number {
return Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, w));
}
export function EditorRightPanel() {
const { t } = useTranslation();
const visible = useEditorStore((s) => s.rightPanelVisible);
@@ -23,6 +35,38 @@ export function EditorRightPanel() {
const togglePanel = useEditorStore((s) => s.toggleRightPanel);
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
const [width, setWidth] = useState<number>(() => {
if (typeof window === "undefined") return DEFAULT_PANEL_WIDTH;
const stored = Number(window.localStorage.getItem(PANEL_WIDTH_STORAGE_KEY));
return stored > 0 ? clampPanelWidth(stored) : DEFAULT_PANEL_WIDTH;
});
useEffect(() => {
window.localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(width));
}, [width]);
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = width;
// Panel is anchored to the right edge, so dragging left widens it.
const onMove = (me: MouseEvent) =>
setWidth(clampPanelWidth(startWidth + (startX - me.clientX)));
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[width],
);
if (!visible) {
return (
<button
@@ -37,7 +81,19 @@ export function EditorRightPanel() {
}
return (
<div className="flex flex-col w-[280px] bg-card border-l border-border">
<div
className="relative flex flex-col bg-card border-l border-border shrink-0"
style={{ width }}
>
{/* Drag handle on the left edge to resize the panel. Mouse-only, matching
the editor's other drag handles (rulers, navigator viewport). */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-only resize handle */}
<div
onMouseDown={handleResizeStart}
className="absolute -left-0.5 top-0 bottom-0 z-20 w-1 cursor-col-resize hover:bg-primary/40 transition-colors"
data-testid="right-panel-resize-handle"
/>
{/* Navigator always visible when image loaded */}
{sourceImageUrl && <NavigatorPanel />}
@@ -71,8 +127,9 @@ export function EditorRightPanel() {
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{/* Tab content. `min-h-0` lets this flex child shrink so it scrolls
internally instead of pushing the color panel below the viewport. */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2">
{activeTab === "layers" && <LayersPanel />}
{activeTab === "adjustments" && <AdjustmentsPanel />}
{activeTab === "history" && <HistoryPanel />}
+4 -1
View File
@@ -177,7 +177,10 @@ export function EditorPage() {
<EditorToolbar />
{/* Vertical ruler along the left edge */}
{rulersVisible && <VerticalRuler />}
<div className="relative flex-1 overflow-hidden bg-muted/30">
{/* `flex` is required so the canvas child's `flex-1` fills this area;
without it the canvas collapses to the Stage's content height,
leaving a dead region below (issue #258). */}
<div className="relative flex flex-1 overflow-hidden bg-muted/30">
<EditorCanvas
onCanvasResize={() => setShowCanvasResize(true)}
onImageResize={() => setShowImageResize(true)}
+57
View File
@@ -0,0 +1,57 @@
import { createNewDocument, expect, test } from "./helpers";
// Regression coverage for issue #258: the editor canvas collapsed to the
// Stage's content height (leaving a large dead region), and the right panel
// was a fixed, non-resizable width.
test.describe("Editor layout (issue #258)", () => {
test.beforeEach(async ({ editorPage: page }) => {
await createNewDocument(page);
});
test("canvas viewport fills the full available editor area", async ({ editorPage: page }) => {
const result = await page.evaluate(() => {
const container = document.querySelector('[data-testid="editor-canvas"]');
const parent = container?.parentElement;
if (!container || !parent) return null;
const c = container.getBoundingClientRect();
const p = parent.getBoundingClientRect();
return {
containerH: Math.round(c.height),
parentH: Math.round(p.height),
containerW: Math.round(c.width),
parentW: Math.round(p.width),
};
});
expect(result).not.toBeNull();
// The canvas container must fill its parent in both dimensions; before the
// fix it collapsed to ~600px tall, leaving an inert region below.
expect(result?.containerH).toBe(result?.parentH);
expect(result?.containerW).toBe(result?.parentW);
expect(result?.containerH ?? 0).toBeGreaterThan(300);
});
test("right panel can be resized with the drag handle", async ({ editorPage: page }) => {
const handle = page.locator('[data-testid="right-panel-resize-handle"]');
await expect(handle).toBeVisible();
const panel = handle.locator(".."); // the panel is the handle's parent
const before = (await panel.boundingBox())?.width ?? 0;
expect(before).toBeGreaterThan(0);
const box = await handle.boundingBox();
if (!box) throw new Error("resize handle has no bounding box");
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
// Drag left: the panel is anchored to the right edge, so it widens.
await page.mouse.move(cx, cy);
await page.mouse.down();
await page.mouse.move(cx - 100, cy, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(300);
const after = (await panel.boundingBox())?.width ?? 0;
expect(after).toBeGreaterThan(before);
});
});
@@ -70,4 +70,29 @@ test.describe("Editor Rulers and Guides", () => {
// Ruler should stretch to fill the available height
expect(box?.height).toBeGreaterThan(100);
});
test("rulers render a themed background, not solid black (issue #258)", async ({
editorPage: page,
}) => {
// Canvas 2D `fillStyle` cannot read CSS `var(--...)` colors; the regression
// left the default black fill in place and painted the rulers as solid
// black bars. Verify both rulers paint a light (card) background instead.
await page.keyboard.press("Control+r");
await page.waitForTimeout(500);
for (const selector of ["canvas.cursor-col-resize", "canvas.cursor-row-resize"]) {
const ruler = page.locator(selector);
await expect(ruler).toBeVisible();
const pixel = await ruler.evaluate((cv: HTMLCanvasElement) => {
const ctx = cv.getContext("2d", { willReadFrequently: true });
const d = ctx?.getImageData(Math.floor(cv.width / 2), Math.floor(cv.height / 2), 1, 1).data;
return d ? [d[0], d[1], d[2], d[3]] : null;
});
expect(pixel).not.toBeNull();
// Not the default-black fill the bug produced...
expect(pixel).not.toEqual([0, 0, 0, 255]);
// ...and unmistakably a light background (white card sums to 765).
expect((pixel?.[0] ?? 0) + (pixel?.[1] ?? 0) + (pixel?.[2] ?? 0)).toBeGreaterThan(300);
}
});
});