From 5915c62a9a4579eba03a9bc01ffdbf8eee30d3e0 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 11 Jul 2026 14:55:32 +0200 Subject: [PATCH] refactor: Update bottom controls for improved styling and functionality - Adjusted button styles across various components for consistency and better UX. - Enhanced layout of action controls to utilize whitespace more effectively. - Updated slider styles for a more modern appearance and improved usability. - Refined input fields and labels for better accessibility and readability. - Introduced new app surface styles for a cohesive design across the application. - Added tests for canvas cursor behavior to ensure correct cursor display during operations. --- commands/workspace.test.ts | 16 ++- commands/workspace.ts | 4 +- editor/state.ts | 4 + renderer/image-textures.ts | 18 ++- renderer/layers.ts | 4 +- renderer/renderer.ts | 14 ++- renderer/text-asset.ts | 28 +++++ view/App.tsx | 32 +++-- view/BottomControlsIsland.tsx | 8 +- view/CanvasViewport.tsx | 13 +- view/CommandPalette.tsx | 22 ++-- view/GenerateSheet.tsx | 2 +- view/GenerationJobStatus.tsx | 2 +- view/LayersSheet.tsx | 119 +++++++++++------- view/ShortcutsDisplay.tsx | 77 ------------ view/ToolOverlay.tsx | 16 +-- view/bottom-controls/BrushControls.tsx | 12 +- view/bottom-controls/ChromaKeyControls.tsx | 26 ++-- view/bottom-controls/ColorPicker.tsx | 6 +- .../GenerateActionControls.tsx | 34 ++--- view/bottom-controls/GenerateControls.tsx | 53 ++++---- view/bottom-controls/MagicWandControls.tsx | 11 +- view/bottom-controls/SelectMenu.tsx | 8 +- view/bottom-controls/Slider.tsx | 1 + view/bottom-controls/TransformControls.tsx | 6 +- view/bottom-controls/styles.ts | 12 +- view/canvas/cursor.test.ts | 12 ++ view/canvas/cursor.ts | 3 +- view/canvas/useCanvasInput.ts | 9 +- view/index.css | 54 ++++---- view/layers/MaskControls.tsx | 6 +- 31 files changed, 345 insertions(+), 287 deletions(-) delete mode 100644 view/ShortcutsDisplay.tsx create mode 100644 view/canvas/cursor.test.ts diff --git a/commands/workspace.test.ts b/commands/workspace.test.ts index cd7c3a1..6a02440 100644 --- a/commands/workspace.test.ts +++ b/commands/workspace.test.ts @@ -4,10 +4,20 @@ import { workspaceSetPanelCommand } from "./workspace"; describe("workspace commands", () => { test.each(["generate", "chromaKey"] as const)("opens the %s operation without changing the persistent tool", (panel) => { - const state = createInitialAppState("Test"); + const initial = createInitialAppState("Test"); + const state = { + ...initial, + editor: { + ...initial.editor, + tools: { ...initial.editor.tools, activeTool: "brush" as const, interactionMode: { type: "tool" as const, tool: "brush" as const } }, + brushPreview: { position: { x: 10, y: 20 } }, + brushStrokePreview: { layerId: "layer", assetId: "asset", source: "preview" }, + }, + }; const next = workspaceSetPanelCommand.execute({ state }, { panel }); expect(next.editor.workspace.panel).toBe(panel); - expect(next.editor.tools.activeTool).toBe("select"); - expect(next.editor.tools.interactionMode).toEqual({ type: "tool", tool: "select" }); + expect(next.editor.tools.activeTool).toBe("brush"); + expect(next.editor.brushPreview).toBeUndefined(); + expect(next.editor.brushStrokePreview).toBeUndefined(); }); }); diff --git a/commands/workspace.ts b/commands/workspace.ts index f52c426..c280e06 100644 --- a/commands/workspace.ts +++ b/commands/workspace.ts @@ -10,12 +10,14 @@ export const workspaceSetPanelCommand: Command = { history: { mode: "ignore" }, execute({ state }, payload) { if (!workspacePanels.has(payload.panel)) return state; - if (state.editor.workspace.panel === payload.panel) return state; + if (state.editor.workspace.panel === payload.panel && !state.editor.brushPreview && !state.editor.brushStrokePreview) return state; return { ...state, editor: { ...state.editor, workspace: { panel: payload.panel }, + brushPreview: undefined, + brushStrokePreview: undefined, }, }; }, diff --git a/editor/state.ts b/editor/state.ts index a4bf685..83c4e96 100644 --- a/editor/state.ts +++ b/editor/state.ts @@ -129,6 +129,10 @@ export type CommandPaletteState = { export type WorkspacePanel = "none" | "generate" | "chromaKey" | "layers"; +export function isOperationWorkspacePanel(panel: WorkspacePanel): boolean { + return panel === "generate" || panel === "chromaKey"; +} + export type WorkspaceState = { panel: WorkspacePanel; }; diff --git a/renderer/image-textures.ts b/renderer/image-textures.ts index 8ca9f39..9876797 100644 --- a/renderer/image-textures.ts +++ b/renderer/image-textures.ts @@ -8,7 +8,7 @@ import { textureCoordinatesForCrop, textureCoordinatesForRect } from "./texture- export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay"; export type ImageTextureRenderer = { - syncAssets(assets: readonly Asset[]): void; + syncAssets(assets: readonly Pick[]): void; render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean; renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean; renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number, sourceRect?: Rect): boolean; @@ -346,7 +346,8 @@ function loadTextureState( const source = asset.source; image.onload = () => { if (isDisposed() || entry.source !== source) return; - const texture = createTexture(context.gl, image); + const textureSource = asset.mimeType === "image/svg+xml" ? rasterizeSvgImage(image, asset) : image; + const texture = createTexture(context.gl, textureSource); if (entry.previousTexture) context.gl.deleteTexture(entry.previousTexture); entry.previousTexture = undefined; entry.current = { status: "ready", texture }; @@ -361,6 +362,17 @@ function loadTextureState( return { status: "loading", image }; } +function rasterizeSvgImage(image: HTMLImageElement, asset: Asset): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.ceil(asset.intrinsicSize.w)); + canvas.height = Math.max(1, Math.ceil(asset.intrinsicSize.h)); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Failed to create SVG rasterization context"); + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + return canvas; +} + function renderableTexture(entry: TextureEntry): WebGLTexture | undefined { if (entry.current.status === "ready") return entry.current.texture; return entry.previousTexture; @@ -392,7 +404,7 @@ function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefi return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; } -function createTexture(gl: WebGL2RenderingContext, image: HTMLImageElement) { +function createTexture(gl: WebGL2RenderingContext, image: TexImageSource) { const texture = gl.createTexture(); if (!texture) throw new Error("Failed to create image texture"); diff --git a/renderer/layers.ts b/renderer/layers.ts index 57ecf58..8e13175 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -10,7 +10,7 @@ import type { ImageTextureRenderer } from "./image-textures"; import { documentRectToScreenRect } from "./screen-rect"; import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types"; import type { AdjustmentPass } from "./adjustment-pass"; -import { textLayerRenderAsset } from "./text-asset"; +import { rasterizedTextLayerRenderAsset } from "./text-asset"; const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1]; const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1]; @@ -77,7 +77,7 @@ function renderTextLayer(context: WebGlRendererContext, documentIndex: DocumentR const bounds = resolveIndexedLayerBounds(documentIndex, layer); if (!bounds) return; const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport); - imageTextureRenderer.render(textLayerRenderAsset(layer), rect, clipRect, opacity, layer.transform.rotation); + imageTextureRenderer.render(rasterizedTextLayerRenderAsset(layer), rect, clipRect, opacity, layer.transform.rotation); } function renderLeafLayer( diff --git a/renderer/renderer.ts b/renderer/renderer.ts index 0ff9f91..c49c5a2 100644 --- a/renderer/renderer.ts +++ b/renderer/renderer.ts @@ -1,5 +1,6 @@ import type { ImageDocument } from "@core/document"; import type { EditorState } from "@editor/state"; +import { createDocumentReadIndex } from "@editor/document-indexes"; import { renderArtboard } from "./artboard"; import { createBrushPreviewRenderer } from "./brush-preview"; import { createCheckerboardRenderer } from "./checkerboard"; @@ -64,7 +65,11 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke for (const artboard of frame.document.artboards) { if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport, checkerboardRenderer); } - imageTextureRenderer.syncAssets([...frame.document.assets, ...generationCandidatePreviewAssets(frame.editor)]); + imageTextureRenderer.syncAssets([ + ...frame.document.assets, + ...generationCandidatePreviewAssets(frame.editor), + ...textTextureHandles(frame.document), + ]); renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer, adjustmentPass); if (!frame.editor.maskEdit) { @@ -86,6 +91,13 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke return renderer; } +function textTextureHandles(document: ImageDocument): Array<{ id: string }> { + const index = createDocumentReadIndex(document); + return [...index.layerById.values()] + .filter((layer) => layer.type === "text") + .map((layer) => ({ id: `text-render:${layer.id}` })); +} + function createOptionalBrushPreviewRenderer(context: WebGlRendererContext) { try { return createBrushPreviewRenderer(context); diff --git a/renderer/text-asset.ts b/renderer/text-asset.ts index fce35fa..fb529d9 100644 --- a/renderer/text-asset.ts +++ b/renderer/text-asset.ts @@ -14,6 +14,34 @@ export function textLayerRenderAsset(layer: TextLayer): Asset { return { id: `text-render:${layer.id}`, name: layer.name, mimeType: "image/svg+xml", source: `data:image/svg+xml,${encodeURIComponent(svg)}`, intrinsicSize: size }; } +/** Rasterizes authoritative text for the live WebGL renderer without relying on browser SVG text decoding. */ +export function rasterizedTextLayerRenderAsset(layer: TextLayer): Asset { + const size = measureTextLayer(layer); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.ceil(size.w)); + canvas.height = Math.max(1, Math.ceil(size.h)); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Failed to create text rasterization context"); + + context.clearRect(0, 0, canvas.width, canvas.height); + context.fillStyle = layer.style.color; + context.font = `${layer.style.fontStyle} ${layer.style.fontWeight} ${layer.style.fontSize}px ${JSON.stringify(layer.style.fontFamily)}, sans-serif`; + context.textAlign = layer.style.alignment; + context.textBaseline = "alphabetic"; + const x = layer.style.alignment === "left" ? 0 : layer.style.alignment === "center" ? size.w / 2 : size.w; + for (const [index, line] of layer.content.split("\n").entries()) { + context.fillText(line || " ", x, (index + 0.82) * layer.style.fontSize * layer.style.lineHeight); + } + + return { + id: `text-render:${layer.id}`, + name: layer.name, + mimeType: "image/png", + source: canvas.toDataURL("image/png"), + intrinsicSize: size, + }; +} + function escapeXml(value: string) { return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!); } diff --git a/view/App.tsx b/view/App.tsx index 0add7f6..96337f9 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -9,7 +9,6 @@ import { CommandPalette } from "./CommandPalette"; import { GenerateSheet } from "./GenerateSheet"; import { GenerationJobStatus } from "./GenerationJobStatus"; import { LayersSheet } from "./LayersSheet"; -import { ShortcutsDisplay } from "./ShortcutsDisplay"; import { ToolOverlay } from "./ToolOverlay"; import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets"; import type { AppState } from "@editor/state"; @@ -142,7 +141,7 @@ export function App({ app }: AppProps) { const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit }); return ( -
+
{imageImport.input} {project.input} -
-
-

Image Studio

-

{document.name}

+
+
+

Image Studio

+

{document.name}

@@ -212,7 +211,7 @@ export function App({ app }: AppProps) { dispatch={app.store.dispatch} documentActions={app.actions.document} /> -
+
-
- -
); } function topBarButtonClass(active = false) { - const base = "inline-flex size-12 items-center justify-center rounded-full text-sm font-medium transition disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30"; - return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`; + const base = "inline-flex size-8 items-center justify-center rounded-lg text-sm font-medium transition disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50"; + return active ? `${base} bg-sky-300 text-slate-950` : `${base} text-white/60 hover:bg-white/[0.07] hover:text-white`; } type AppShellState = { diff --git a/view/BottomControlsIsland.tsx b/view/BottomControlsIsland.tsx index 80cd601..6dc3257 100644 --- a/view/BottomControlsIsland.tsx +++ b/view/BottomControlsIsland.tsx @@ -50,18 +50,18 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a return (
{operation === "generate" ? ( + ) : operation === "chromaKey" ? ( + ) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? ( ) : activeTool === "brush" || activeTool === "eraser" ? ( - ) : operation === "chromaKey" ? ( - ) : activeTool === "magicWand" ? ( ) : transformBounds && transformTarget ? ( @@ -78,7 +78,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a function BrushHint({ tool, hint }: { tool: "brush" | "eraser"; hint: string }) { return (
- {tool} + {tool} {hint}
); diff --git a/view/CanvasViewport.tsx b/view/CanvasViewport.tsx index 1f29a9a..45e487d 100644 --- a/view/CanvasViewport.tsx +++ b/view/CanvasViewport.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef } from "react"; import type { ImageDocument } from "@core/document"; -import type { AppState, MaskEditState } from "@editor/state"; +import { isOperationWorkspacePanel, type AppState, type MaskEditState, type WorkspacePanel } from "@editor/state"; import type { AppStore } from "@editor/store"; import type { InteractionMode } from "@editor/tools"; import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; @@ -40,7 +40,13 @@ export function CanvasViewport({ useCanvasInput(canvasRef, store, inputOptions); const brushHint = brushUnavailableHint(cursorState.document, cursorState.editor); const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor)); - const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, cursorState.isPanning, hasBrushPreview, !brushHint); + const cursorClass = canvasCursorClass( + cursorState.editor.tools.interactionMode, + cursorState.isPanning, + hasBrushPreview, + !brushHint, + isOperationWorkspacePanel(cursorState.panel), + ); return ( -
-
- +
+
+ - + K
@@ -219,15 +219,15 @@ function filterItems(items: PaletteItem[], query: string) { } function paletteItemClass(active: boolean, disabled: boolean) { - const base = "flex min-h-14 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition focus:outline-none"; + const base = "flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left transition focus:outline-none"; if (disabled) return `${base} cursor-not-allowed text-white/30 opacity-45`; return active ? `${base} bg-white text-black` : `${base} text-white/75 hover:bg-white/[0.08] hover:text-white`; } function paletteIconClass(active: boolean) { return active - ? "grid size-10 shrink-0 place-items-center rounded-xl bg-black/10 text-black/60" - : "grid size-10 shrink-0 place-items-center rounded-xl bg-white/[0.06] text-white/60"; + ? "grid size-8 shrink-0 place-items-center rounded-md bg-black/10 text-black/60" + : "grid size-8 shrink-0 place-items-center rounded-md bg-white/[0.05] text-white/55"; } function paletteSubtitleClass(active: boolean) { @@ -236,6 +236,6 @@ function paletteSubtitleClass(active: boolean) { function paletteSectionClass(active: boolean) { return active - ? "shrink-0 rounded-full bg-black/10 px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-black/45" - : "shrink-0 rounded-full bg-white/[0.06] px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-white/35"; + ? "shrink-0 rounded bg-black/10 px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-[0.12em] text-black/45" + : "shrink-0 rounded bg-white/[0.05] px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-[0.12em] text-white/30"; } diff --git a/view/GenerateSheet.tsx b/view/GenerateSheet.tsx index 47488b9..0a69e9f 100644 --- a/view/GenerateSheet.tsx +++ b/view/GenerateSheet.tsx @@ -16,7 +16,7 @@ export function GenerateSheet({ settings, resources, open, dispatch }: GenerateS id="generate-sheet" aria-hidden={!open} aria-label="Generate settings" - className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${ + className={`app-surface pointer-events-auto absolute bottom-3 right-3 top-[4.5rem] z-20 flex w-[22rem] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-xl px-3 text-sm text-white transition-all duration-200 ${ open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0" }`} > diff --git a/view/GenerationJobStatus.tsx b/view/GenerationJobStatus.tsx index 26cc2b4..5fc1476 100644 --- a/view/GenerationJobStatus.tsx +++ b/view/GenerationJobStatus.tsx @@ -20,7 +20,7 @@ export function GenerationJobStatus({ generation, compact = false }: { generatio const label = job.status === "running" ? `${job.label} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : job.status === "cancelled" ? `${job.label} cancelled` : `${job.label} complete`; const tone = job.status === "failed" ? "bg-red-500/15 text-red-100" : job.status === "running" ? "bg-white/10 text-white/70" : job.status === "cancelled" ? "bg-amber-500/15 text-amber-100" : "bg-emerald-500/15 text-emerald-100"; - return {label}; + return {label}; } function formatElapsed(seconds: number) { diff --git a/view/LayersSheet.tsx b/view/LayersSheet.tsx index 9ce4a17..c9fbaa1 100644 --- a/view/LayersSheet.tsx +++ b/view/LayersSheet.tsx @@ -17,6 +17,9 @@ import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayer import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls"; import { LayerThumbnail } from "./layers/LayerThumbnail"; import { createLayerThumbnailIndex, type LayerThumbnailModel } from "./layers/thumbnailModel"; +import { BottomControlColorPicker } from "./bottom-controls/ColorPicker"; +import { BottomControlSlider } from "./bottom-controls/Slider"; +import { BottomControlSelectMenu, type BottomControlSelectOption } from "./bottom-controls/SelectMenu"; export type LayersSheetProps = { document: ImageDocument; @@ -36,7 +39,7 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch, doc id="layers-sheet" aria-hidden={!open} aria-label="Layers" - className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${ + className={`app-surface pointer-events-auto absolute bottom-3 right-3 top-[4.5rem] z-20 flex w-[22rem] flex-col overflow-hidden rounded-xl px-3 text-xs text-white transition-all duration-200 ${ open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0" }`} > @@ -81,14 +84,14 @@ function LayersSheetBody({ return ( <> -
+
-
+
@@ -124,9 +127,9 @@ function LayersSheetBody({ const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0; return ( -
+
{ if (draggedLayerId.current) event.preventDefault(); }} @@ -174,11 +177,11 @@ function LayersSheetBody({ > - {displayLayerCount} + {displayLayerCount}
-
+
{displayLayerCount === 0 ? ( -
No layers yet
+
No layers yet
) : ( artboard.layers.map((layer) => ( ({ ...layer.style }); - useEffect(() => { setContent(layer.content); setStyle({ ...layer.style }); }, [layer.id, layer.content, layer.style]); - const commit = () => dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style }); - return
-