diff --git a/apps/web/src/components/editor/common/rulers.tsx b/apps/web/src/components/editor/common/rulers.tsx index cd4fc4df..9fd12e86 100644 --- a/apps/web/src/components/editor/common/rulers.tsx +++ b/apps/web/src/components/editor/common/rulers.tsx @@ -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); diff --git a/apps/web/src/components/editor/editor-right-panel.tsx b/apps/web/src/components/editor/editor-right-panel.tsx index 641db5e9..dbf81f41 100644 --- a/apps/web/src/components/editor/editor-right-panel.tsx +++ b/apps/web/src/components/editor/editor-right-panel.tsx @@ -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(() => { + 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 ( - {/* Tab content */} -
+ {/* Tab content. `min-h-0` lets this flex child shrink so it scrolls + internally instead of pushing the color panel below the viewport. */} +
{activeTab === "layers" && } {activeTab === "adjustments" && } {activeTab === "history" && } diff --git a/apps/web/src/pages/editor-page.tsx b/apps/web/src/pages/editor-page.tsx index 70d9f516..812860b7 100644 --- a/apps/web/src/pages/editor-page.tsx +++ b/apps/web/src/pages/editor-page.tsx @@ -177,7 +177,10 @@ export function EditorPage() { {/* Vertical ruler along the left edge */} {rulersVisible && } -
+ {/* `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). */} +
setShowCanvasResize(true)} onImageResize={() => setShowImageResize(true)} diff --git a/tests/e2e-editor/editor-layout.spec.ts b/tests/e2e-editor/editor-layout.spec.ts new file mode 100644 index 00000000..fa336103 --- /dev/null +++ b/tests/e2e-editor/editor-layout.spec.ts @@ -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); + }); +}); diff --git a/tests/e2e-editor/editor-rulers-guides.spec.ts b/tests/e2e-editor/editor-rulers-guides.spec.ts index b9ea3eba..5541ff24 100644 --- a/tests/e2e-editor/editor-rulers-guides.spec.ts +++ b/tests/e2e-editor/editor-rulers-guides.spec.ts @@ -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); + } + }); });