diff --git a/app/comfy.test.ts b/app/comfy.test.ts index fbea511..29a8e34 100644 --- a/app/comfy.test.ts +++ b/app/comfy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { buildAnimaWorkflow, buildSdxlWorkflow, buildZImageTurboWorkflow, buildZImageWorkflow, selectGeneratedOutputImage } from "./comfy"; +import { buildAnimaWorkflow, buildSdxlWorkflow, buildZImageTurboWorkflow, buildZImageWorkflow, handleComfyApi, selectGeneratedOutputImage } from "./comfy"; describe("Comfy adapter", () => { test("selects SaveImage output instead of uploaded input or mask images", () => { @@ -85,6 +85,44 @@ describe("Comfy adapter", () => { expect(workflow["2"]?.inputs.clip_name).toBe("miaomiaoHarem_anima13_txt.safetensors"); expect(workflow["3"]?.inputs.vae_name).toBe("qwen_image_vae.safetensors"); }); + + test("lists branded non-Z diffusion models under Anima", async () => { + const originalFetch = globalThis.fetch; + const mockFetch: typeof fetch = Object.assign(async () => new Response(JSON.stringify({ + CheckpointLoaderSimple: { input: { required: { ckpt_name: [["sd_xl_base_1.0.safetensors"]] } } }, + KSampler: { input: { required: { sampler_name: [["euler"]], scheduler: [["normal"]] } } }, + UNETLoader: { + input: { + required: { + unet_name: [[ + "anima-base-v1.0.safetensors", + "miaomiaoHarem_anima13.safetensors", + "novaAnimeAM_v30.safetensors", + "z_image_bf16.safetensors", + "z_image_turbo_bf16.safetensors", + ]], + }, + }, + }, + CLIPLoader: { input: { required: { clip_name: [["qwen_3_06b_base.safetensors"]] } } }, + VAELoader: { input: { required: { vae_name: [["qwen_image_vae.safetensors"]] } } }, + }), { headers: { "content-type": "application/json" } }), { preconnect: originalFetch.preconnect }); + globalThis.fetch = mockFetch; + + try { + const response = await handleComfyApi(new Request("http://image-studio.test/api/comfy/models")); + const body = await response.json() as { architectures: { value: string; models: string[] }[] }; + const anima = body.architectures.find((architecture) => architecture.value === "anima"); + + expect(response.status).toBe(200); + expect(anima?.models).toContain("novaAnimeAM_v30.safetensors"); + expect(anima?.models).toContain("miaomiaoHarem_anima13.safetensors"); + expect(anima?.models).not.toContain("z_image_bf16.safetensors"); + expect(anima?.models).not.toContain("z_image_turbo_bf16.safetensors"); + } finally { + globalThis.fetch = originalFetch; + } + }); }); function inpaintRequest(inpaint: { maskedContent: "neutral" | "original"; growMaskBy?: number }) { diff --git a/app/comfy.ts b/app/comfy.ts index ce21560..952a0c1 100644 --- a/app/comfy.ts +++ b/app/comfy.ts @@ -111,7 +111,7 @@ async function listGenerationOptions() { value: "anima", label: "Anima", defaultModel: defaultModels.anima, - models: modelsForArchitecture(diffusionModels, defaultModels.anima, [/anima/i]), + models: modelsForArchitecture(diffusionModels, defaultModels.anima, [], { exclude: [/z[_-]?image/i] }), supportedModes: ["text-to-image"], }, ], @@ -227,11 +227,12 @@ export function buildSdxlWorkflow(request: ComfyGenerateRequest): Workflow { const scheduler = request.scheduler ?? "normal"; const positive = request.prompt; const negative = request.negativePrompt ?? ""; + const samplerInputs: Record = { seed, steps, cfg, sampler_name: sampler, scheduler, denoise, model: ["1", 0], positive: ["2", 0], negative: ["3", 0], latent_image: ["5", 0] }; const workflow: Workflow = { "1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: request.model } }, "2": { class_type: "CLIPTextEncode", inputs: { text: positive, clip: ["1", 1] } }, "3": { class_type: "CLIPTextEncode", inputs: { text: negative, clip: ["1", 1] } }, - "6": { class_type: "KSampler", inputs: { seed, steps, cfg, sampler_name: sampler, scheduler, denoise, model: ["1", 0], positive: ["2", 0], negative: ["3", 0], latent_image: ["5", 0] } }, + "6": { class_type: "KSampler", inputs: samplerInputs }, "7": { class_type: "VAEDecode", inputs: { samples: ["6", 0], vae: ["1", 2] } }, "8": { class_type: "SaveImage", inputs: { filename_prefix: `image-studio-${request.mode}`, images: ["7", 0] } }, }; @@ -250,7 +251,7 @@ export function buildSdxlWorkflow(request: ComfyGenerateRequest): Workflow { workflow["5"] = { class_type: "VAEEncode", inputs: { pixels: ["4", 0], vae: ["1", 2] } }; workflow["12"] = { class_type: "GrowMask", inputs: { mask: ["11", 0], expand: resolveGrowMaskBy(request), tapered_corners: true } }; workflow["13"] = { class_type: "SetLatentNoiseMask", inputs: { samples: ["5", 0], mask: ["12", 0] } }; - workflow["6"].inputs.latent_image = ["13", 0]; + samplerInputs.latent_image = ["13", 0]; } else { workflow["5"] = { class_type: "VAEEncodeForInpaint", inputs: { pixels: ["4", 0], vae: ["1", 2], mask: ["11", 0], grow_mask_by: resolveGrowMaskBy(request) } }; } @@ -410,8 +411,13 @@ function architectureLabel(architecture: GenerateArchitecture): string { } } -function modelsForArchitecture(models: string[], defaultModel: string, matchers: RegExp[]) { - return unique([defaultModel, ...models.filter((model) => model === defaultModel || matchers.some((matcher) => matcher.test(model)))]); +function modelsForArchitecture(models: string[], defaultModel: string, matchers: RegExp[], options: { exclude?: RegExp[] } = {}) { + return unique([defaultModel, ...models.filter((model) => { + if (model === defaultModel) return true; + if (options.exclude?.some((matcher) => matcher.test(model))) return false; + if (matchers.length === 0) return true; + return matchers.some((matcher) => matcher.test(model)); + })]); } function unique(values: T[]) { diff --git a/commands/document.ts b/commands/document.ts index 3cfe462..ca7bdba 100644 --- a/commands/document.ts +++ b/commands/document.ts @@ -524,13 +524,14 @@ export const documentApplyLayerMaskOperationCommand: Command - asset.id === maskLocation.layer.assetId + asset.id === maskAssetId ? { ...asset, source: payload.source, @@ -541,7 +542,7 @@ export const documentApplyLayerMaskOperationCommand: Command Select context → describe an operation → generate alternatives → compare → accept or combine → refine locally + +That loop should work for full-image generation, image-to-image, inpainting, outpainting, background replacement, object cleanup, and future model-backed operations. The document remains authoritative; generated results remain provisional until accepted. + +## What is already strong + +### 1. The state architecture has a clear backbone + +Persistent document state lives in `core/`, transient editor state lives in `editor/`, changes flow through deterministic commands, input resolves intent, and the renderer consumes state without owning it. This is a sound basis for undo, serialization, collaboration, macros, and future agent-driven operations. + +### 2. Generated outputs are provisional candidates + +`GenerationState` separates candidates from the document. Candidates retain settings, seed, source inputs, masks, placement, crop information, and inpaint preparation metadata. This is exactly the right conceptual direction for comparing uncertain model outputs before committing them. + +### 3. Accepted generated assets retain provenance + +Generated assets record their mode, prompt, negative prompt, seed, model configuration, acceptance path, and inpaint context. This is strategically important. The UI does not expose it yet, but the underlying data could support “reuse settings,” “make variations,” auditability, and workflow history. + +### 4. Masks and nested groups are real document concepts + +Groups are tree nodes rather than flat UI labels. Masks are backed by raster assets and participate in commands and rendering. This avoids the common prototype trap where layers look hierarchical but behave as a flat list. + +### 5. Automated behavioral coverage is meaningful + +The repository has 130 passing tests across commands, history, transforms, selection, nested layer operations, masks, input, rendering decisions, generation candidates, inpaint preparation, and ComfyUI workflow construction. + +## Priority findings + +Severity meanings: + +- **P0** — blocks treating the project as safely maintainable. +- **P1** — breaks or obscures a primary product workflow. +- **P2** — significant friction, inconsistency, or scaling risk. +- **P3** — polish and cleanup that should follow the structural redesign. + +### P0 — strict TypeScript validation fails — resolved 2026-07-09 + +`bunx tsc --noEmit` fails despite tests, ESLint, and the production build passing. Current failures include unsafe layer narrowing, possibly undefined mask access, generic select-menu handlers, canvas typed-array incompatibilities, a resize hook call, input test typing, and the two currently modified Comfy files. + +This creates a false-green validation story: production assets can build while the architecture's type contracts are already drifting. Strict type checking should become a required baseline before the redesign begins. + +Resolution: the affected layer narrowing, optional mask access, generic select handling, typed-array inference, resize-hook initialization, input-test dispatch typing, and current Comfy adapter typing were corrected without weakening the domain unions. The new `typecheck` and aggregate `check` scripts make the baseline repeatable. + +### P1 — there is no durable project lifecycle + +The app can import images and export an artboard PNG, but there is no project save, project open, autosave, recovery, or recent-document flow. `ImageDocument` is already serialization-friendly, but imported assets use object URLs and there is no persistence adapter. + +For a layer-based editor, this is not an optional feature. Without it, the product cannot fulfill “turn an AI image into an editable project” beyond a single browser session. + +Redesign implication: the workspace needs document identity and save state in its persistent chrome. The architecture needs an asset persistence strategy before project import/export is presented as complete. + +### P1 — generation mode is hidden while infrastructure settings are promoted + +The primary user intent—text-to-image, image-to-image, inpaint, or outpaint—is inside an “Advanced” disclosure. Meanwhile backend architecture, model, seed, and sometimes text encoder and VAE are presented as essentials. + +This hierarchy is backwards for the target audience. Users should choose an operation first; compatible models and defaults should follow. Model infrastructure belongs in a secondary model/settings layer unless a power user explicitly expands it. + +### P1 — generation is modeled as four competing UI concepts + +Generation appears as: + +- A tool in the left rail. +- A duplicate sparkle action in the top-right toolbar. +- A right-side settings sheet. +- A bottom action island containing execution, status, candidate navigation, comparison, regeneration, acceptance, mask editing, and dismissal. + +These surfaces do not form a legible sequence. “Generate” is closer to a workflow or operation workspace than a pointer mode. Treating it as a normal canvas tool also makes panel visibility and keyboard behavior artificially coupled to `activeTool`. + +Redesign implication: generation should open an operation workspace with a clear input stage and a result stage. The active canvas interaction inside that workspace can still be select, pan, paint-mask, or transform. + +### P1 — accepting one candidate destroys the entire candidate session + +Both “accept as layer” and “replace pixels” call `clearCommittedGenerationPreview`, which clears all candidates, not only the accepted candidate. This conflicts with the core use case of combining multiple model outputs. A user who generates several alternatives and accepts one loses the remaining comparison set. + +Redesign implication: accepting a candidate should mark or remove only that candidate by default. The result tray should support keeping, pinning, multi-selecting, and clearing the session explicitly. + +### P1 — up to twelve candidates are stored but only six are selectable + +Generation state retains twelve candidates, while `CandidatePicker` renders only `candidates.slice(0, 6)`. Candidates seven through twelve have no visible selection path. This is a concrete interaction bug, not merely a styling concern. + +### P1 — “variant” is only a renamed ordinary layer + +“Accept variant” creates a standard top-level layer and records `variant-layer` provenance. There is no document-level variant set, linked source, stack semantics, exclusive visibility, or comparison group. The label promises more structure than the product provides. + +Redesign implication: either call this “Add as another layer” or introduce a real variant/result-set concept. A result tray can provide variant semantics without forcing them into the document tree prematurely. + +### P1 — generation preconditions are not represented clearly + +The Generate button only requires a non-empty prompt. Image-to-image can proceed without a selected source image. Inpaint configuration is available without explaining or enforcing the required source layer and mask. Outpaint and mode-specific settings coexist regardless of current mode. + +The workflow should state its required context before execution and offer direct repair actions such as “Select a layer,” “Create mask,” or “Use active artboard.” Disabled buttons alone are insufficient. + +### P1 — non-inpaint generated placement ignores the active context + +Non-inpaint candidates default to document position `(0, 0)` at native scale. The default artboard spans negative and positive coordinates, so a 1024×1024 result can appear partly outside the active artboard and does not use the viewport center or selected artboard bounds. + +Placement should be explicit: fit active artboard, use requested frame, place at viewport center, or preserve source-layer bounds depending on operation. + +### P1 — asynchronous generation state is owned by a transient React control + +Busy state, elapsed time, and errors live inside `GenerateActionControls`. Switching away from Generate unmounts that surface while the request continues. The user loses status and error visibility, and remounting removes the local busy guard even if a request is still running. + +Generation jobs are application state, not ephemeral component state. They need stable IDs, lifecycle status, cancellation where supported, error details, and persistence across panel changes. + +### P2 — the workspace lacks stable information architecture + +The current shell is a canvas surrounded by floating islands: + +- Top-right file, generate, export, and layers icons. +- Left vertical tool capsule. +- Right mutually exclusive sheet. +- Bottom contextual control capsule. +- Bottom-left shortcut encyclopedia. + +There is no persistent document header, inspector, operation status area, result tray, or clear distinction between modes, properties, document structure, and global actions. The layout works as a feature demo but will not scale with more editing operations. + +### P2 — the visual system is not actually centralized + +The global stylesheet includes generic shadcn-style light/dark tokens, while the product shell mostly uses raw Tailwind color literals, opacity values, bespoke radii, and repeated class-building functions. The main background uses decorative gradients and most surfaces rely on backdrop blur without a consistent opaque surface color. + +Consequences: + +- Contrast depends on whatever lies behind a control. +- Nearly every component invents its own surface and selected state. +- Rounded pills are used for containers, rows, inputs, menus, buttons, badges, and destructive actions, weakening hierarchy. +- A complete redesign cannot be implemented reliably through token replacement alone. + +Redesign implication: create semantic tokens and primitives after the new workspace hierarchy is approved, not before. Tokens should represent canvas, chrome, panel, raised surface, field, hover, selection, focus, warning, generation status, and mask-edit context. + +### P2 — tool semantics mix modes, operations, and effects + +The rail places Select, Generate, Brush, Eraser, Chroma Key, Magic Wand, and Pan at the same level. These are not peers: + +- Select and Pan are navigation/interaction modes. +- Brush and Eraser are paint modes whose meaning changes during mask editing. +- Magic Wand is a selection/mask operation. +- Chroma Key is an effect-to-mask workflow. +- Generate is an AI operation workspace. + +The redesign should classify tools by behavior, then decide whether they belong in the global rail, context bar, properties panel, or operation launcher. + +### P2 — layers carry too many actions but too little visual identity + +The Layers sheet includes artboards, tree nesting, visibility, locking, export, grouping, ordering, deletion, mask creation, mask coverage analysis, reveal/hide mode entry, raster mask operations, and rename behavior. Rows lack image or mask thumbnails and do not expose opacity, blend behavior, provenance, or core transform properties. + +The result is a dense management panel that still cannot answer the basic question “which visual element is this?” quickly. + +Redesign implication: use a durable document tree with thumbnails and compact row actions, then move selected-object properties and mask controls into a contextual inspector. Mask editing should become a clear editor mode, not an expanded sub-card full of unrelated actions. + +### P2 — panel state ownership is inconsistent + +Generate visibility is derived from authoritative `activeTool`, while Layers visibility is local React state. `App.tsx` then manually enforces mutual exclusion across buttons, shortcuts, effects, and command-palette callbacks. + +This works today but does not scale to more panels, inspectors, result trays, modal operation states, or workspace layouts. Meaningful workspace state should have one model and one transition path. + +### P2 — view code owns application workflows and side effects + +React/view modules directly orchestrate image decoding, object URLs, network requests, generation preparation, candidate acceptance setup, raster processing, download behavior, and Comfy model discovery. Important examples are `useImageImport.tsx`, `GenerateControls.tsx`, `GenerateActionControls.tsx`, `runGenerate.ts`, and the mask/chroma-key helpers. + +These functions are testable only unevenly and blur the intended boundary that React should display state and capture intent. The redesign is an opportunity to introduce explicit application services/jobs without weakening the command-only mutation rule. + +### P2 — imported object URLs have no durable ownership + +Image import creates object URLs and revokes them only when no artboard exists. Successful imports keep the URL indefinitely and would not survive project serialization or browser restart. + +Asset sources need a lifecycle: persisted blob/handle, data migration, load/release hooks, and garbage collection when unreferenced. + +### P2 — primary document actions are either invisible or duplicated + +Undo and redo exist only as shortcuts/commands. Export exists in both the global top bar and every artboard row. Generate exists in both the rail and top bar. Fit/reset/zoom actions are split between transient bottom controls and the command palette. The command palette also exposes debug commands in the normal product surface. + +The redesign should establish a predictable location for document actions and reserve the command palette for acceleration rather than compensating for missing UI. + +### P2 — accessibility is inconsistent + +The four icon-only top-bar buttons have no `aria-label` or visible label. Other icon buttons are labeled more carefully, but abbreviations such as “Contig,” “Sub,” “Tol,” “Hard,” and “Clean” assume specialist knowledge. Tooltips depend mainly on native `title` attributes. Focus styles and hit targets need live verification. + +### P2 — large modules have become change hotspots + +Several files combine multiple responsibilities: + +- `commands/document.ts` — 923 lines. +- `renderer/image-textures.ts` — 754 lines. +- `view/CommandPalette.tsx` — 617 lines. +- `view/LayersSheet.tsx` — 505 lines. +- `commands/tool.ts` — 378 lines. +- `view/bottom-controls/GenerateControls.tsx` — 338 lines. +- `commands/generation.ts` — 315 lines. +- `view/bottom-controls/ChromaKeyControls.tsx` — 313 lines. + +Line count alone is not a defect, but these files are already coordinating distinct concepts. The redesign should split by product responsibility rather than by arbitrary component size. + +### P3 — prototype identity remains in project metadata and chrome + +The package is still named `bun-react-template`, the document defaults to “Untitled” without displaying that identity, the app has no visible product title, and debug palette items ship beside user actions. These details reinforce the prototype feel. + +### P3 — the shortcut panel dominates the workspace + +The shortcut reference defaults open, occupies a 30rem-wide floating panel, and competes with the canvas. Shortcuts should be discoverable through tooltips, menus, a compact help entry, and an on-demand reference—not persistent primary chrome. + +## Proposed information architecture + +This is a structural proposal, not a visual design. + +### Persistent application frame + +**Top bar** + +- Product/document identity. +- Save state and project actions. +- Undo/redo. +- Export/share. +- Background job status. +- Command palette and help. + +**Left tool rail** + +- Select/transform. +- Pan/hand as a temporary or secondary navigation mode. +- Paint. +- Mask/select region. +- AI operation launcher. +- Tool groups can expand, but only true interaction modes remain persistently active. + +**Left or right document tree** + +- Artboards, groups, layers, and masks. +- Thumbnails and clear hierarchy. +- Compact visibility/lock state. +- Creation, grouping, ordering, and deletion. +- No model configuration or mask-processing controls. + +**Context inspector** + +- Selected object properties. +- Transform, opacity, mask relationship, provenance, and operation-specific parameters. +- Clear empty and multi-selection states. + +**Canvas** + +- The document and direct manipulation overlays. +- Temporary generation previews and compare affordances. +- Explicit mask-edit and operation states. + +**Result tray** + +- All current candidates, not an arbitrary subset. +- Selection, multi-select, pin, compare, dismiss, and accept. +- Candidate metadata on demand. +- Accept as layer, replace region, create masked refinement layer, or keep as reference. +- Remains visible while the user inspects document context. + +### AI operation workspace + +The user should begin with an operation, not a backend: + +1. Generate new image. +2. Transform selected image. +3. Replace or repair region. +4. Extend canvas. +5. Remove/replace background. +6. Create variations. + +Each operation declares: + +- Required document context. +- Prompt and references. +- Output frame and placement. +- Quality/profile preset. +- Optional advanced model controls. +- Expected result behavior. + +The product can then select a compatible architecture and expose technical overrides only when requested. + +## Visual redesign principles + +The new visual system should be created from scratch after wireframes validate the information architecture. + +1. **Canvas first, not chrome first.** UI surfaces should frame the work rather than float decoratively over it. +2. **Hierarchy through structure, not universal pills.** Use shape, spacing, typography, surface level, and borders intentionally. +3. **Stable panels for stable concepts.** Document tree and inspector should not appear and disappear like temporary tooltips. +4. **Context is explicit.** Mask edit, generation preview, job progress, and destructive replacement must be visibly distinct states. +5. **Progressive disclosure follows expertise.** Operation and intent first; sampler, scheduler, encoder, and VAE later. +6. **Generated results look provisional.** Candidates need a clear visual status distinct from accepted document layers. +7. **Every icon-only control has a name.** Labels, tooltips, focus states, and shortcuts are part of the component contract. +8. **Density is deliberate.** The layer tree can be compact; prompts and comparison views need space; destructive choices need clarity. + +## Architecture recommendations + +### Preserve + +- Core document and asset model direction. +- Command-only state mutation. +- Editor/document state separation. +- Input resolution layer. +- Read-only renderer architecture. +- Generation candidate and provenance concepts. +- Nested group and mask invariants. + +### Refine before UI migration + +- Restore strict TypeScript cleanliness. +- Model workspace/panel state consistently. +- Introduce generation job state separate from candidates. +- Change candidate commitment to preserve unrelated candidates. +- Define placement rules per operation. +- Define project and asset persistence. +- Separate application services from React presentation. +- Add operation precondition selectors with user-facing reasons. + +### Avoid during redesign + +- Rewriting the renderer solely to support new styling. +- Encoding new visual layout concepts in the core document model. +- Adding more one-off local panel booleans. +- Treating every AI workflow as another permanent tool-rail icon. +- Building a large component library before the workspace wireframe is approved. + +## Recommended implementation sequence + +### Phase 0 — restore engineering baseline + +- Make `bunx tsc --noEmit` pass. +- Add it to the standard validation script/CI. +- Preserve and finish the current Comfy changes separately. +- Add focused tests for candidate preservation and generation preconditions. + +### Phase 1 — product model corrections + +- Introduce durable generation job state. +- Preserve candidate sessions after acceptance. +- Make all candidates reachable. +- Rename or model variants honestly. +- Define operation-specific placement and prerequisites. +- Define project persistence and asset lifecycle. + +### Phase 2 — low-fidelity redesign + +- Produce workspace wireframes for empty document, layered composition, mask edit, generation setup, generation running, candidate comparison, and candidate acceptance. +- Validate control ownership before choosing colors or component styling. +- Run the pending live visual/interaction audit against the old UI for comparison. + +### Phase 3 — visual system + +- Choose the approved visual direction. +- Create semantic tokens. +- Build the application frame, buttons, fields, tree rows, inspector sections, menus, tooltips, dialogs, status elements, and result cards. +- Document accessibility and interaction states as part of each primitive. + +### Phase 4 — shell migration + +- Implement the new app frame around the existing canvas. +- Migrate document actions, tool modes, document tree, and inspector. +- Keep old workflow surfaces behind temporary boundaries until replaced. + +### Phase 5 — AI workflow migration + +- Implement the operation launcher and context checks. +- Move generation settings into intent-first flows. +- Implement durable jobs and the result tray. +- Surface provenance and “reuse settings.” +- Retire the old Generate sheet and bottom candidate action island. + +### Phase 6 — persistence and production hardening + +- Save/open/recovery. +- Asset storage and cleanup. +- Keyboard and screen-reader pass. +- Pointer, touch, and pen pass. +- Performance profiling with large images, masks, deep trees, and candidate sets. + +## Verification snapshot + +Run on 2026-07-09: + +- `bun test`: **pass**, 130 tests. +- `bun run lint`: **pass**. +- `bun run build`: **pass**. +- `bunx tsc --noEmit`: **fail**, multiple strict type errors. +- Local HTTP server: **200 OK** on port 3017 when run outside sandbox isolation. +- Screenshot/browser interaction pass: **not run**, browser automation bridge unavailable. + +## Immediate next deliverable + +Create low-fidelity wireframes for seven canonical states: + +1. Empty/new project. +2. Imported image with layers visible. +3. Selected layer with transform properties. +4. Mask editing. +5. AI operation setup. +6. Generation running with persistent job status. +7. Candidate comparison and multi-result acceptance. + +The wireframes should intentionally ignore the existing floating-island styling. They should test information architecture and control ownership before visual direction is explored. diff --git a/input/layers-panel.ts b/input/layers-panel.ts index ced6380..35c61f0 100644 --- a/input/layers-panel.ts +++ b/input/layers-panel.ts @@ -44,13 +44,14 @@ export function resolveLayerDrop(options: { if (isDescendantLayer(sourceInfo.layer, options.target.layer.id)) return undefined; const verticalRatio = Math.max(0, Math.min(1, options.verticalRatio)); - const dropIntoGroup = options.target.layer.type === "group" && verticalRatio >= 0.33 && verticalRatio <= 0.66; + const targetLayer = options.target.layer; + const dropIntoGroup = targetLayer.type === "group" && verticalRatio >= 0.33 && verticalRatio <= 0.66; if (dropIntoGroup) { return { layerId: options.sourceLayerId, toArtboardId: options.target.artboardId, - toParentGroupId: options.target.layer.id, - toIndex: options.target.layer.children.length, + toParentGroupId: targetLayer.id, + toIndex: targetLayer.children.length, }; } diff --git a/input/viewport-pan-store.test.ts b/input/viewport-pan-store.test.ts index 2a77f62..57916c8 100644 --- a/input/viewport-pan-store.test.ts +++ b/input/viewport-pan-store.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { Dispatch } from "@commands/dispatcher"; import { commandIds } from "@commands/ids"; +import type { CommandPayloads } from "@commands/payloads"; import type { PointerInputEvent } from "./pointer"; import { createViewportPanInputController } from "./viewport-pan"; @@ -45,25 +46,36 @@ describe("viewport pan store integration", () => { }); }); -type InputToolId = "select" | "brush" | "eraser" | "pan"; +type InputToolId = CommandPayloads[typeof commandIds.toolSetActive]["tool"]; +type InputInteractionMode = + | { type: "tool"; tool: InputToolId } + | { type: "temporary-pan"; previousTool: InputToolId }; + +type InputStoreState = { + viewport: { center: { x: number; y: number }; zoom: number }; + tools: { activeTool: InputToolId; interactionMode: InputInteractionMode }; +}; type InputStore = ReturnType; function createInputStore() { - const store = { - state: { - viewport: { center: { x: 0, y: 0 }, zoom: 1 }, - tools: { - activeTool: "select" as InputToolId, - interactionMode: { type: "tool" as const, tool: "select" as InputToolId }, - }, + const state: InputStoreState = { + viewport: { center: { x: 0, y: 0 }, zoom: 1 }, + tools: { + activeTool: "select", + interactionMode: { type: "tool", tool: "select" }, }, + }; + const store = { + state, dispatch: ((commandId, payload) => { switch (commandId) { - case commandIds.toolSetActive: - store.state.tools.activeTool = payload.tool; - store.state.tools.interactionMode = { type: "tool", tool: payload.tool }; + case commandIds.toolSetActive: { + const next = payload as CommandPayloads[typeof commandIds.toolSetActive]; + store.state.tools.activeTool = next.tool; + store.state.tools.interactionMode = { type: "tool", tool: next.tool }; break; + } case commandIds.toolEnterTemporaryPan: store.state.tools.interactionMode = { type: "temporary-pan", previousTool: store.state.tools.activeTool }; break; @@ -75,12 +87,14 @@ function createInputStore() { } break; } - case commandIds.viewportPan: + case commandIds.viewportPan: { + const next = payload as CommandPayloads[typeof commandIds.viewportPan]; store.state.viewport.center = { - x: store.state.viewport.center.x + payload.delta.x, - y: store.state.viewport.center.y + payload.delta.y, + x: store.state.viewport.center.x + next.delta.x, + y: store.state.viewport.center.y + next.delta.y, }; break; + } } return ignoredState; }) as Dispatch, diff --git a/package.json b/package.json index 43385d8..3253e10 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "start": "NODE_ENV=production bun index.ts", "build": "bun run build.ts", "lint": "eslint .", - "test": "bun test" + "test": "bun test", + "typecheck": "bunx tsc --noEmit", + "check": "bun run typecheck && bun run test && bun run lint && bun run build" }, "dependencies": { "@phosphor-icons/react": "^2.1.10", diff --git a/view/bottom-controls/SelectMenu.tsx b/view/bottom-controls/SelectMenu.tsx index 97e1487..fc4087d 100644 --- a/view/bottom-controls/SelectMenu.tsx +++ b/view/bottom-controls/SelectMenu.tsx @@ -1,5 +1,5 @@ import { Check, CaretDown } from "@phosphor-icons/react"; -import { forwardRef, useEffect, useRef, useState, type CSSProperties, type ForwardedRef } from "react"; +import { useEffect, useRef, useState, type CSSProperties, type RefObject } from "react"; import { createPortal } from "react-dom"; export type BottomControlSelectOption = { @@ -85,7 +85,7 @@ export function BottomControlSelectMenu({ value, options, {open && placement === "inline" ? ( ({ value, options, ) : open ? ( createPortal( ({ value, options, } type SelectOptionsProps = { + menuRef: RefObject; options: readonly BottomControlSelectOption[]; value: TValue; className: string; @@ -122,12 +123,9 @@ type SelectOptionsProps = { setOpen: (open: boolean) => void; }; -const SelectOptions = forwardRef(function SelectOptions( - { options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps, - ref: ForwardedRef, -) { +function SelectOptions({ menuRef, options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps) { return ( -
+
{options.map((option) => { const selected = option.value === value; return ( @@ -149,4 +147,4 @@ const SelectOptions = forwardRef(function SelectOptions( })}
); -}); +} diff --git a/view/canvas/magic-wand.ts b/view/canvas/magic-wand.ts index 7a854a4..439967c 100644 --- a/view/canvas/magic-wand.ts +++ b/view/canvas/magic-wand.ts @@ -106,7 +106,7 @@ function matches(data: ImageData, pixel: number, key: number[], tolerance: numbe } function postProcessSelection(selected: Uint8Array, width: number, height: number, settings: EditorState["tools"]["magicWand"]) { - let next = selectionToMaskValues(selected); + let next: Uint8ClampedArray = selectionToMaskValues(selected); const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle))); const choke = Math.round(Math.max(-20, Math.min(20, settings.choke))); const feather = Math.round(Math.max(0, Math.min(20, settings.feather))); diff --git a/view/canvas/useCanvasResize.ts b/view/canvas/useCanvasResize.ts index a93d91f..1eb4db6 100644 --- a/view/canvas/useCanvasResize.ts +++ b/view/canvas/useCanvasResize.ts @@ -3,7 +3,7 @@ import type { Dispatch } from "@commands/dispatcher"; import { commandIds } from "@commands/ids"; export function useCanvasResize(canvasRef: RefObject, dispatch: Dispatch) { - const lastSize = useRef<{ w: number; h: number }>(); + const lastSize = useRef<{ w: number; h: number } | undefined>(undefined); useEffect(() => { const canvas = canvasRef.current;