From 7cc614f6dcf331a5bf71eac8d51acdd366877c5e Mon Sep 17 00:00:00 2001
From: Emdadul Islam
Date: Mon, 15 Jun 2026 08:22:01 +0600
Subject: [PATCH 1/2] Refactor codebase into testable lib modules and thin UI
layers.
Extract shared business logic, split large panels and canvas hooks, and add Framekit docs without changing editor or export behavior.
Co-authored-by: Cursor
---
CONTRIBUTING.md | 126 +++
LICENSE | 21 +
README.md | 95 ++-
.../canvas/CanvasTextEditOverlay.tsx | 71 ++
src/components/canvas/CanvasWorkspace.tsx | 647 +++------------
src/components/canvas/ScreenContextMenu.tsx | 75 +-
src/components/landing/ProjectsSection.tsx | 12 +-
.../landing/StarterTemplatesRow.tsx | 27 +-
.../landing/TemplatePickerModal.tsx | 37 +-
src/components/panels/AssetsPanel.tsx | 9 +-
src/components/panels/BackgroundControls.tsx | 524 +-----------
src/components/panels/BrandKitPanel.tsx | 46 +-
src/components/panels/LayerContextMenu.tsx | 115 +--
src/components/panels/LayersPanel.tsx | 40 +-
src/components/panels/PropertiesPanel.tsx | 749 ++----------------
src/components/panels/TemplatesPanel.tsx | 81 +-
.../background/BackgroundPresetsSection.tsx | 36 +
.../panels/background/BackgroundPreview.tsx | 20 +
.../panels/background/BackgroundTypeTabs.tsx | 43 +
.../background/GradientBackgroundSection.tsx | 66 ++
.../background/ImageBackgroundSection.tsx | 115 +++
.../background/MeshBackgroundSection.tsx | 26 +
.../background/PatternBackgroundSection.tsx | 55 ++
.../background/SolidBackgroundSection.tsx | 17 +
src/components/panels/background/shared.tsx | 153 ++++
.../properties/DevicePropertiesSection.tsx | 184 +++++
.../properties/ElementEffectsSection.tsx | 55 ++
.../properties/ElementTransformSection.tsx | 65 ++
.../properties/ImagePropertiesSection.tsx | 161 ++++
.../properties/MultiSelectionProperties.tsx | 63 ++
.../properties/ScreenOnlyProperties.tsx | 23 +
.../properties/ShapePropertiesSection.tsx | 107 +++
.../properties/TextPropertiesSection.tsx | 17 +
src/components/panels/properties/shared.tsx | 22 +
.../ProjectThumbnail.tsx | 26 +-
src/components/templates/TemplateCard.tsx | 100 +++
.../templates/TemplatePreviewFrame.tsx | 24 +
.../templates/TemplateThumbnail.tsx | 10 +-
src/components/toolbar/EditorToolbar.tsx | 18 +-
src/components/toolbar/ExportDialog.tsx | 578 ++++----------
.../toolbar/export/ExportAdvancedTab.tsx | 249 ++++++
.../toolbar/export/ExportQuickTab.tsx | 78 ++
src/components/ui/FloatingMenu.tsx | 82 ++
src/components/ui/SegmentedControl.test.tsx | 23 +
src/components/ui/SegmentedControl.tsx | 66 ++
src/hooks/useCanvasDrop.ts | 89 +++
src/hooks/useCanvasSelection.ts | 193 +++++
src/hooks/useCanvasTextEdit.ts | 64 ++
src/hooks/useCanvasViewport.ts | 206 +++++
src/hooks/useExportFormReducer.ts | 19 +
src/hooks/useExportPreview.ts | 129 +++
src/hooks/useFloatingMenu.ts | 43 +
src/hooks/useHistoryNavigation.ts | 23 +
src/hooks/useKeyboardShortcuts.ts | 13 +-
src/hooks/usePersistAssetUpload.ts | 19 +
src/hooks/useProjectsCatalog.test.ts | 29 +
src/hooks/useProjectsCatalog.ts | 155 ++++
src/hooks/useScreenContextMenu.ts | 100 +++
src/hooks/useSelectedElements.ts | 14 +
src/hooks/useSelectionCapabilities.ts | 27 +
src/lib/assets/device-presets.ts | 8 +
src/lib/assets/persist-project-asset.test.ts | 41 +
src/lib/assets/persist-project-asset.ts | 15 +
src/lib/canvas/backgrounds.ts | 38 +-
src/lib/canvas/coordinates.test.ts | 73 ++
src/lib/canvas/coordinates.ts | 48 ++
src/lib/canvas/create-canvas-gradient.test.ts | 72 ++
src/lib/canvas/create-canvas-gradient.ts | 56 ++
src/lib/constants/brand.ts | 22 +
.../{constants.ts => constants/defaults.ts} | 36 +-
src/lib/constants/export.ts | 11 +
src/lib/constants/index.ts | 7 +
src/lib/constants/workspace.ts | 2 +
src/lib/elements/element-meta.test.ts | 23 +
src/lib/elements/element-meta.ts | 63 ++
src/lib/elements/selection-fields.test.ts | 16 +
src/lib/elements/selection-fields.ts | 6 +
src/lib/export/export-form-state.test.ts | 54 ++
src/lib/export/export-form-state.ts | 110 +++
src/lib/export/export-plan.ts | 6 +-
src/lib/export/prepare-export-screen.test.ts | 21 +
src/lib/export/prepare-export-screen.ts | 18 +
src/lib/export/renderer.ts | 42 +-
src/lib/export/types.ts | 1 +
src/lib/export/zip.ts | 28 +-
src/lib/landing/types.ts | 1 +
.../selection/resolve-selected-elements.ts | 17 +
.../apply-template-to-screen.test.ts | 43 +
src/lib/templates/apply-template-to-screen.ts | 38 +
src/lib/templates/filter-templates.test.ts | 25 +
src/lib/templates/filter-templates.ts | 27 +
src/lib/templates/index.ts | 1 +
src/routes/index.tsx | 143 +---
src/stores/project-store.ts | 405 ++--------
src/stores/project/create-device-element.ts | 30 +
src/stores/project/element-alignment.test.ts | 53 ++
src/stores/project/element-alignment.ts | 104 +++
src/stores/project/element-mutations.ts | 81 ++
.../project/element-update-coalesce.test.ts | 46 ++
src/stores/project/element-update-coalesce.ts | 39 +
src/stores/project/screen-mutations.test.ts | 19 +
src/stores/project/screen-mutations.ts | 157 ++++
src/stores/project/template-screen.ts | 18 +
src/test/fixtures/minimal-project.ts | 9 +
src/test/fixtures/minimal-screen.ts | 14 +
105 files changed, 5221 insertions(+), 3146 deletions(-)
create mode 100644 CONTRIBUTING.md
create mode 100644 LICENSE
create mode 100644 src/components/canvas/CanvasTextEditOverlay.tsx
create mode 100644 src/components/panels/background/BackgroundPresetsSection.tsx
create mode 100644 src/components/panels/background/BackgroundPreview.tsx
create mode 100644 src/components/panels/background/BackgroundTypeTabs.tsx
create mode 100644 src/components/panels/background/GradientBackgroundSection.tsx
create mode 100644 src/components/panels/background/ImageBackgroundSection.tsx
create mode 100644 src/components/panels/background/MeshBackgroundSection.tsx
create mode 100644 src/components/panels/background/PatternBackgroundSection.tsx
create mode 100644 src/components/panels/background/SolidBackgroundSection.tsx
create mode 100644 src/components/panels/background/shared.tsx
create mode 100644 src/components/panels/properties/DevicePropertiesSection.tsx
create mode 100644 src/components/panels/properties/ElementEffectsSection.tsx
create mode 100644 src/components/panels/properties/ElementTransformSection.tsx
create mode 100644 src/components/panels/properties/ImagePropertiesSection.tsx
create mode 100644 src/components/panels/properties/MultiSelectionProperties.tsx
create mode 100644 src/components/panels/properties/ScreenOnlyProperties.tsx
create mode 100644 src/components/panels/properties/ShapePropertiesSection.tsx
create mode 100644 src/components/panels/properties/TextPropertiesSection.tsx
create mode 100644 src/components/panels/properties/shared.tsx
rename src/components/{dashboard => templates}/ProjectThumbnail.tsx (69%)
create mode 100644 src/components/templates/TemplateCard.tsx
create mode 100644 src/components/templates/TemplatePreviewFrame.tsx
create mode 100644 src/components/toolbar/export/ExportAdvancedTab.tsx
create mode 100644 src/components/toolbar/export/ExportQuickTab.tsx
create mode 100644 src/components/ui/FloatingMenu.tsx
create mode 100644 src/components/ui/SegmentedControl.test.tsx
create mode 100644 src/components/ui/SegmentedControl.tsx
create mode 100644 src/hooks/useCanvasDrop.ts
create mode 100644 src/hooks/useCanvasSelection.ts
create mode 100644 src/hooks/useCanvasTextEdit.ts
create mode 100644 src/hooks/useCanvasViewport.ts
create mode 100644 src/hooks/useExportFormReducer.ts
create mode 100644 src/hooks/useExportPreview.ts
create mode 100644 src/hooks/useFloatingMenu.ts
create mode 100644 src/hooks/useHistoryNavigation.ts
create mode 100644 src/hooks/usePersistAssetUpload.ts
create mode 100644 src/hooks/useProjectsCatalog.test.ts
create mode 100644 src/hooks/useProjectsCatalog.ts
create mode 100644 src/hooks/useScreenContextMenu.ts
create mode 100644 src/hooks/useSelectedElements.ts
create mode 100644 src/hooks/useSelectionCapabilities.ts
create mode 100644 src/lib/assets/device-presets.ts
create mode 100644 src/lib/assets/persist-project-asset.test.ts
create mode 100644 src/lib/assets/persist-project-asset.ts
create mode 100644 src/lib/canvas/coordinates.test.ts
create mode 100644 src/lib/canvas/coordinates.ts
create mode 100644 src/lib/canvas/create-canvas-gradient.test.ts
create mode 100644 src/lib/canvas/create-canvas-gradient.ts
create mode 100644 src/lib/constants/brand.ts
rename src/lib/{constants.ts => constants/defaults.ts} (68%)
create mode 100644 src/lib/constants/export.ts
create mode 100644 src/lib/constants/index.ts
create mode 100644 src/lib/constants/workspace.ts
create mode 100644 src/lib/elements/element-meta.test.ts
create mode 100644 src/lib/elements/element-meta.ts
create mode 100644 src/lib/elements/selection-fields.test.ts
create mode 100644 src/lib/elements/selection-fields.ts
create mode 100644 src/lib/export/export-form-state.test.ts
create mode 100644 src/lib/export/export-form-state.ts
create mode 100644 src/lib/export/prepare-export-screen.test.ts
create mode 100644 src/lib/export/prepare-export-screen.ts
create mode 100644 src/lib/export/types.ts
create mode 100644 src/lib/landing/types.ts
create mode 100644 src/lib/selection/resolve-selected-elements.ts
create mode 100644 src/lib/templates/apply-template-to-screen.test.ts
create mode 100644 src/lib/templates/apply-template-to-screen.ts
create mode 100644 src/lib/templates/filter-templates.test.ts
create mode 100644 src/lib/templates/filter-templates.ts
create mode 100644 src/stores/project/create-device-element.ts
create mode 100644 src/stores/project/element-alignment.test.ts
create mode 100644 src/stores/project/element-alignment.ts
create mode 100644 src/stores/project/element-mutations.ts
create mode 100644 src/stores/project/element-update-coalesce.test.ts
create mode 100644 src/stores/project/element-update-coalesce.ts
create mode 100644 src/stores/project/screen-mutations.test.ts
create mode 100644 src/stores/project/screen-mutations.ts
create mode 100644 src/stores/project/template-screen.ts
create mode 100644 src/test/fixtures/minimal-project.ts
create mode 100644 src/test/fixtures/minimal-screen.ts
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..fc8293e
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,126 @@
+# Contributing to Framekit
+
+Thank you for helping improve Framekit. This guide covers local setup, project conventions, and what we expect in pull requests.
+
+## Ways to contribute
+
+- **Report bugs** — [open an issue](https://github.com/mdadul/FrameKit/issues) with steps to reproduce, expected vs. actual behavior, and browser/OS
+- **Suggest features** — describe the problem you are solving and how it fits store-screenshot workflows
+- **Submit code** — bug fixes, tests, docs, and focused refactors are all welcome
+- **Improve docs** — README, inline comments for non-obvious logic, and onboarding clarity
+
+## Development setup
+
+1. Fork and clone the repository:
+
+ ```bash
+ git clone https://github.com//FrameKit.git
+ cd FrameKit
+ ```
+
+2. Install dependencies (Bun recommended):
+
+ ```bash
+ bun install
+ ```
+
+3. Start the dev server:
+
+ ```bash
+ bun run dev
+ ```
+
+4. Before opening a PR, run:
+
+ ```bash
+ bun run test
+ bun run build
+ bun run lint
+ ```
+
+All three should pass. `build` runs TypeScript project references (`tsc -b`) in addition to the Vite bundle.
+
+## Architecture
+
+Framekit follows a simple layering model:
+
+| Layer | Location | Responsibility |
+|-------|----------|----------------|
+| **Lib** | `src/lib/` | Pure functions: export planning, template apply, canvas math, asset persistence. No React, no Zustand. |
+| **Stores** | `src/stores/` | Domain state and mutations. `project-store` delegates to `stores/project/*` modules. |
+| **Hooks** | `src/hooks/` | Thin React adapters (selection, export form, canvas viewport). |
+| **Components** | `src/components/` | UI only — panels, canvas, toolbar, landing. |
+
+**Rules of thumb:**
+
+- Put reusable business rules in `src/lib/` with unit tests.
+- Keep Zustand actions as thin wrappers: clone project → call lib/module → persist history.
+- Prefer extending existing helpers over duplicating logic (especially export, templates, and asset upload).
+- Use `@/` path aliases (`@/lib/...`, `@/components/...`).
+
+## Code style
+
+- **TypeScript** — strict types; avoid `any` unless unavoidable at a library boundary.
+- **Naming** — intention-revealing names; functions do one thing; constants in `src/lib/constants/`.
+- **Components** — match surrounding patterns (Tailwind utility classes, Radix primitives, existing panel layout).
+- **Comments** — only where behavior is non-obvious; let code and tests document contracts.
+- **Scope** — keep PRs focused. A bug fix should not include unrelated refactors.
+
+## Testing
+
+Tests use [Vitest](https://vitest.dev/) with jsdom. Shared fixtures live in `src/test/fixtures/`.
+
+- Add or update tests when changing `src/lib/` behavior.
+- Characterization tests are preferred for refactors: lock current behavior before moving code.
+- Run `bun run test` locally; CI expects a green suite.
+
+Example:
+
+```bash
+bun run test # all tests
+bun run test src/lib/export/zip.ts # path filter (Vitest)
+```
+
+## Pull request checklist
+
+- [ ] `bun run test` passes
+- [ ] `bun run build` passes
+- [ ] `bun run lint` passes (or no new lint violations in touched files)
+- [ ] Behavior change is intentional and described in the PR
+- [ ] Refactors preserve existing UI, store actions, export output, and shortcuts unless the PR explicitly changes them
+- [ ] No secrets, API keys, or personal data committed
+
+### Commit messages
+
+Use clear, imperative subjects. Prefer **why** over **what** when it helps reviewers:
+
+```
+fix export ZIP paths for Android tablet presets
+
+Extract prepare-export-screen helper so ExportDialog and zip share resize logic
+```
+
+### PR description
+
+Include:
+
+1. **Summary** — what changed and why (1–3 bullets)
+2. **Test plan** — commands run and any manual smoke steps (editor open, export preview, template apply, etc.)
+
+## Manual smoke testing
+
+For UI or export changes, quickly verify:
+
+1. Landing page loads; create or open a project
+2. Add text, image, shape, and device elements on the canvas
+3. Apply a template; undo/redo
+4. Export preview and download a PNG or ZIP
+5. Import/export a `.ssgproj` file
+
+## Project file format
+
+`.ssgproj` files are JSON project bundles. Changes to the schema or import logic should remain backward compatible when possible, and should be called out in the PR if not.
+
+## Questions
+
+Open an issue if you are unsure whether an idea fits. For substantial features, filing an issue first helps align on scope before you invest in a large PR.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..dc377c6
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Emdadul Islam
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index f9f19a4..12d300f 100644
--- a/README.md
+++ b/README.md
@@ -1,66 +1,103 @@
-# Screenshot Studio OSS
+# Framekit
-A browser-based App Store and Google Play screenshot generator. Design once, export all required store sizes, and keep everything local.
+**Store-ready screenshots** — a browser-based App Store and Google Play screenshot generator. Design once, export every required store size, and keep everything on your machine.
+
+[GitHub](https://github.com/mdadul/FrameKit) · [Contributing](CONTRIBUTING.md)
+
+## Why Framekit
+
+- **Local-first** — projects autosave to IndexedDB in your browser; no account or cloud upload required
+- **Store export built in** — Apple and Google Play size presets, bulk ZIP export with `ios/` and `android/` folders
+- **Multi-platform workflow** — design on iOS frames, copy screens to Android with one action
+- **Open source** — MIT licensed; fork it, self-host it, or contribute back
## Features
- Canvas editor with text, images, shapes, and device mockups
- 8 device frames (iPhone, iPad, Pixel, Galaxy)
-- Layers, assets, templates, and brand kit
+- Layers, assets, starter templates, and per-project brand kit
- Multi-screen projects (up to 10 screens)
-- Undo/redo (100 states)
+- Undo/redo (100 history states)
- IndexedDB autosave and `.ssgproj` import/export
-- PNG/JPEG export at 1x/2x/3x
+- PNG/JPEG export at 1×, 2×, and 3×
- Bulk ZIP export for Apple and Android store presets
- Light/dark theme and workspace preferences
-- Project rename, save status, and confirmation dialogs
-- Smart export with ios/ / android/ ZIP folders
- Screen overview mode and dashboard thumbnails
-- Per-project brand kit and keyboard shortcuts guide
-- First-run onboarding tour
+- Keyboard shortcuts and first-run onboarding tour
+
+## Tech stack
-## Tech Stack
+| Layer | Tools |
+|-------|-------|
+| UI | React 19, TypeScript, Tailwind CSS v4, Radix UI |
+| Routing | TanStack Router |
+| Canvas | Konva / React-Konva |
+| State | Zustand + Immer |
+| Storage | Dexie (IndexedDB) |
+| Export | Canvas2D renderer, JSZip, Comlink workers |
+| Build | Vite 8, Vitest, ESLint |
-- React 19 + TypeScript + Vite
-- TanStack Router
-- Zustand + Immer
-- Konva / React-Konva
-- Dexie (IndexedDB)
-- Tailwind CSS v4
-- dnd-kit, Radix UI, JSZip
+## Getting started
-## Getting Started
+**Prerequisites:** [Bun](https://bun.sh) (recommended) or Node.js 20+
```bash
+git clone https://github.com/mdadul/FrameKit.git
+cd FrameKit
bun install
bun run dev
```
-Open `http://localhost:5173`.
+Open [http://localhost:5173](http://localhost:5173).
-## Scripts
+With npm:
```bash
-bun run dev # Start dev server
-bun run build # Production build
-bun run preview # Preview production build
-bun run test # Run unit tests
-bun run lint # Lint project
+npm install
+npm run dev
```
+## Scripts
+
+| Command | Description |
+|---------|-------------|
+| `bun run dev` | Start the Vite dev server |
+| `bun run build` | Typecheck and production build → `dist/` |
+| `bun run preview` | Preview the production build locally |
+| `bun run test` | Run unit tests (Vitest) |
+| `bun run lint` | Lint with ESLint |
+
+## Project layout
+
+```
+src/
+├── components/ # React UI (canvas, panels, toolbar, landing)
+├── hooks/ # Thin React hooks (selection, export, canvas)
+├── lib/ # Pure business logic (export, templates, canvas math)
+├── routes/ # TanStack Router pages
+├── stores/ # Zustand stores (project, editor, history)
+└── test/ # Shared test fixtures and setup
+```
+
+Pure logic lives in `src/lib/`; stores orchestrate mutations; components stay thin. See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions.
+
## Deployment
-The app is a static SPA. Build with `bun run build` and deploy the `dist/` folder to:
+Framekit is a static SPA. Build with `bun run build` and deploy the `dist/` folder to any static host:
- GitHub Pages
- Cloudflare Pages
- Netlify
- Vercel
-## Project Format
+## Project format
+
+Projects can be exported as `.ssgproj` JSON files. A file contains screens, project settings, and embedded asset data so you can back up or share work outside the browser.
+
+## Contributing
-Projects can be exported as `.ssgproj` JSON files containing screens, settings, and embedded assets.
+Bug reports, feature ideas, and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) for setup, architecture notes, and the PR checklist.
## License
-MIT
+[MIT](LICENSE)
diff --git a/src/components/canvas/CanvasTextEditOverlay.tsx b/src/components/canvas/CanvasTextEditOverlay.tsx
new file mode 100644
index 0000000..fbba2ab
--- /dev/null
+++ b/src/components/canvas/CanvasTextEditOverlay.tsx
@@ -0,0 +1,71 @@
+import { forwardRef, type KeyboardEvent } from 'react'
+import type { TextElement } from '@/lib/types'
+
+export interface CanvasTextEditOverlayProps {
+ element: TextElement
+ screenOffset: { x: number; y: number }
+ containerRect: DOMRect
+ panX: number
+ panY: number
+ workspaceZoom: number
+ value: string
+ onChange: (value: string) => void
+ onCommit: () => void
+ onCancel: () => void
+}
+
+export const CanvasTextEditOverlay = forwardRef(
+ function CanvasTextEditOverlay(
+ {
+ element,
+ screenOffset,
+ containerRect,
+ panX,
+ panY,
+ workspaceZoom,
+ value,
+ onChange,
+ onCommit,
+ onCancel,
+ },
+ ref,
+ ) {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault()
+ onCommit()
+ } else if (event.key === 'Escape') {
+ event.preventDefault()
+ onCancel()
+ }
+ }
+
+ return (
+
{templates.map((template) => (
-
onSelect(template)}
- className="group overflow-hidden rounded-2xl border border-white/20 bg-card/70 text-left shadow-lg ring-1 ring-black/5 backdrop-blur transition hover:scale-[1.02] hover:border-primary/30 hover:shadow-xl dark:border-white/10"
- >
-
-
-
{template.name}
-
- {LAYOUT_LABELS[template.layout]}
-
-
- Use template
-
-
-
-
+ />
))}
diff --git a/src/components/landing/TemplatePickerModal.tsx b/src/components/landing/TemplatePickerModal.tsx
index 88bc5e9..c64cee3 100644
--- a/src/components/landing/TemplatePickerModal.tsx
+++ b/src/components/landing/TemplatePickerModal.tsx
@@ -1,8 +1,8 @@
import { useMemo, useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { Search, Upload, X } from 'lucide-react'
-import { TEMPLATES, CATEGORY_LABELS, LAYOUT_LABELS, SHOWCASE_TEMPLATE_IDS } from '@/lib/templates'
-import { TemplateThumbnail } from '@/components/templates/TemplateThumbnail'
+import { TEMPLATES, SHOWCASE_TEMPLATE_IDS, filterTemplates } from '@/lib/templates'
+import { TemplateCard } from '@/components/templates/TemplateCard'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import type { TemplateDefinition } from '@/lib/types'
@@ -24,17 +24,10 @@ export function TemplatePickerModal({
}: TemplatePickerModalProps) {
const [query, setQuery] = useState('')
- const filtered = useMemo(() => {
- const normalized = query.trim().toLowerCase()
- if (!normalized) return TEMPLATES
- return TEMPLATES.filter(
- (t) =>
- t.name.toLowerCase().includes(normalized) ||
- t.category.toLowerCase().includes(normalized) ||
- CATEGORY_LABELS[t.category].toLowerCase().includes(normalized) ||
- LAYOUT_LABELS[t.layout].toLowerCase().includes(normalized),
- )
- }, [query])
+ const filtered = useMemo(
+ () => filterTemplates(TEMPLATES, { query }),
+ [query],
+ )
const showcase = filtered.filter((t) => SHOWCASE_TEMPLATE_IDS.includes(t.id as (typeof SHOWCASE_TEMPLATE_IDS)[number]))
const rest = filtered.filter((t) => !SHOWCASE_TEMPLATE_IDS.includes(t.id as (typeof SHOWCASE_TEMPLATE_IDS)[number]))
@@ -127,22 +120,12 @@ function TemplateGrid({
return (
{templates.map((template) => (
-
onSelect(template)}
- className="group overflow-hidden rounded-xl border border-border bg-card text-left transition hover:border-primary/50 hover:shadow-md"
- >
-
-
-
-
-
{template.name}
-
- {LAYOUT_LABELS[template.layout]}
-
-
-
+ />
))}
)
diff --git a/src/components/panels/AssetsPanel.tsx b/src/components/panels/AssetsPanel.tsx
index a72ef09..50579a0 100644
--- a/src/components/panels/AssetsPanel.tsx
+++ b/src/components/panels/AssetsPanel.tsx
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Check, ImagePlus, Images, Plus, Search, Trash2, Upload } from 'lucide-react'
import { useVirtualizer } from '@tanstack/react-virtual'
-import { createAssetFromFile, createAssetObjectUrl } from '@/lib/assets/image-pipeline'
-import { deleteAsset, getProjectAssets, saveAsset } from '@/lib/db'
+import { createAssetObjectUrl } from '@/lib/assets/image-pipeline'
+import { persistProjectAsset } from '@/lib/assets/persist-project-asset'
+import { deleteAsset, getProjectAssets } from '@/lib/db'
import { useProjectStore } from '@/stores/project-store'
import { useEditorStore } from '@/stores/editor-store'
import { cn } from '@/lib/utils'
@@ -187,10 +188,8 @@ export function AssetsPanel() {
if (!project) return
for (const file of files) {
if (!file.type.startsWith('image/')) continue
- const asset = await createAssetFromFile(file, project.id)
- await saveAsset(asset)
+ const asset = await persistProjectAsset(file, project.id, registerAssetUrl)
const url = createAssetObjectUrl(asset)
- registerAssetUrl(asset.id, url)
if (uploadMode === 'canvas') {
addImageFromAsset(asset.id, url)
}
diff --git a/src/components/panels/BackgroundControls.tsx b/src/components/panels/BackgroundControls.tsx
index 0d98a99..fc48ed8 100644
--- a/src/components/panels/BackgroundControls.tsx
+++ b/src/components/panels/BackgroundControls.tsx
@@ -1,216 +1,24 @@
-import { useMemo, useRef, useState, type ReactNode } from 'react'
-import {
- Blend,
- Grid3x3,
- Image as ImageIcon,
- Palette,
- Sparkles,
- Trash2,
- type LucideIcon,
-} from 'lucide-react'
+import { useMemo } from 'react'
import { useProjectStore } from '@/stores/project-store'
-import { createAssetFromFile, createAssetObjectUrl } from '@/lib/assets/image-pipeline'
-import { saveAsset } from '@/lib/db'
-import {
- ALL_BACKGROUND_PRESETS,
- GRADIENT_PRESETS,
- MESH_PRESETS,
- PATTERN_KINDS,
- buildBackgroundCanvas,
- type BackgroundPreset,
-} from '@/lib/canvas/backgrounds'
+import { GRADIENT_PRESETS, MESH_PRESETS } from '@/lib/canvas/backgrounds'
import { BRAND_PRIMARY } from '@/lib/constants'
-import type { BackgroundConfig, BackgroundType, ImageFit } from '@/lib/types'
-import { ColorPickerField } from '@/components/ui/ColorPickerField'
-import { SliderField } from '@/components/ui/SliderField'
-import { Button } from '@/components/ui/Button'
-import { cn } from '@/lib/utils'
-
-const PRESET_PREVIEW_LIMIT = 8
-
-const TYPE_TABS: Array<{ id: BackgroundType; label: string; icon: LucideIcon }> = [
- { id: 'solid', label: 'Solid', icon: Palette },
- { id: 'linear-gradient', label: 'Gradient', icon: Blend },
- { id: 'mesh', label: 'Mesh', icon: Sparkles },
- { id: 'pattern', label: 'Pattern', icon: Grid3x3 },
- { id: 'image', label: 'Image', icon: ImageIcon },
-]
-
-const IMAGE_FITS: ImageFit[] = ['cover', 'contain', 'fill']
-
-const DEFAULT_OVERLAY_HEX = '#000000'
-const DEFAULT_OVERLAY_ALPHA = 0.35
-
-function presetThumb(background: BackgroundConfig): string {
- const canvas = buildBackgroundCanvas(background, 64, 64)
- return canvas.toDataURL()
-}
-
-function hexToRgb(hex: string): { r: number; g: number; b: number } {
- const normalized = hex.replace('#', '')
- const full =
- normalized.length === 3
- ? normalized
- .split('')
- .map((char) => char + char)
- .join('')
- : normalized.padEnd(6, '0').slice(0, 6)
- const int = Number.parseInt(full, 16)
- return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255 }
-}
-
-function rgbToHex(r: number, g: number, b: number): string {
- const toHex = (value: number) =>
- Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, '0')
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`
-}
-
-function parseOverlay(value: string | undefined): { hex: string; alpha: number } {
- if (!value) return { hex: DEFAULT_OVERLAY_HEX, alpha: DEFAULT_OVERLAY_ALPHA }
- const rgba = value.match(/rgba?\(([^)]+)\)/i)
- if (rgba) {
- const parts = rgba[1].split(',').map((part) => Number.parseFloat(part.trim()))
- const [r = 0, g = 0, b = 0, a = 1] = parts
- return { hex: rgbToHex(r, g, b), alpha: Number.isFinite(a) ? a : 1 }
- }
- if (value.startsWith('#')) return { hex: value, alpha: 1 }
- return { hex: DEFAULT_OVERLAY_HEX, alpha: DEFAULT_OVERLAY_ALPHA }
-}
-
-function composeOverlay(hex: string, alpha: number): string {
- const { r, g, b } = hexToRgb(hex)
- return `rgba(${r}, ${g}, ${b}, ${Number(alpha.toFixed(2))})`
-}
-
-function PropertyBlock({ title, children }: { title?: string; children: ReactNode }) {
- return (
-
- {title ? (
-
{title}
- ) : null}
- {children}
-
- )
-}
-
-function SegmentedControl({
- options,
- value,
- onChange,
- ariaLabel,
-}: {
- options: Array<{ value: T; label: string }>
- value: T
- onChange: (value: T) => void
- ariaLabel?: string
-}) {
- return (
-
- {options.map((option) => {
- const active = value === option.value
- return (
- onChange(option.value)}
- className={cn(
- 'flex-1 rounded-[5px] py-1.5 text-[11px] font-medium transition',
- active
- ? 'bg-card text-foreground shadow-sm'
- : 'text-muted-foreground hover:text-foreground',
- )}
- >
- {option.label}
-
- )
- })}
-
- )
-}
-
-function getVisiblePresetGroups(background: BackgroundConfig, isGradient: boolean) {
- if (background.type === 'solid' || background.type === 'image') return []
- if (isGradient) {
- return ALL_BACKGROUND_PRESETS.filter(
- (group) => group.title === 'Gradients' || group.title === 'Glass',
- )
- }
- if (background.type === 'mesh') {
- return ALL_BACKGROUND_PRESETS.filter((group) => group.title === 'Mesh')
- }
- if (background.type === 'pattern') {
- return ALL_BACKGROUND_PRESETS.filter((group) => group.title === 'Patterns')
- }
- return []
-}
-
-function PresetGroup({
- title,
- presets,
- onSelect,
-}: {
- title: string
- presets: BackgroundPreset[]
- onSelect: (background: BackgroundConfig) => void
-}) {
- const [expanded, setExpanded] = useState(false)
- const hasMore = presets.length > PRESET_PREVIEW_LIMIT
- const visible = expanded ? presets : presets.slice(0, PRESET_PREVIEW_LIMIT)
-
- return (
-
-
-
{title}
- {hasMore && (
-
setExpanded((value) => !value)}
- className="text-[10px] font-medium text-foreground/70 hover:text-foreground"
- >
- {expanded ? 'Less' : `All ${presets.length}`}
-
- )}
-
-
- {visible.map((preset) => (
- onSelect({ ...preset.background })}
- className="group relative aspect-square overflow-hidden rounded-md border border-border/70 transition hover:border-foreground/25 hover:ring-1 hover:ring-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
- style={{
- backgroundImage: `url(${presetThumb(preset.background)})`,
- backgroundSize: 'cover',
- }}
- >
-
- {preset.label}
-
-
- ))}
-
-
- )
-}
+import type { BackgroundConfig, BackgroundType } from '@/lib/types'
+import { BackgroundPreview } from '@/components/panels/background/BackgroundPreview'
+import { BackgroundPresetsSection } from '@/components/panels/background/BackgroundPresetsSection'
+import { BackgroundTypeTabs } from '@/components/panels/background/BackgroundTypeTabs'
+import { GradientBackgroundSection } from '@/components/panels/background/GradientBackgroundSection'
+import { ImageBackgroundSection } from '@/components/panels/background/ImageBackgroundSection'
+import { MeshBackgroundSection } from '@/components/panels/background/MeshBackgroundSection'
+import { PatternBackgroundSection } from '@/components/panels/background/PatternBackgroundSection'
+import { SolidBackgroundSection } from '@/components/panels/background/SolidBackgroundSection'
+import { TYPE_TABS, presetThumb } from '@/components/panels/background/shared'
interface BackgroundControlsProps {
background: BackgroundConfig
}
export function BackgroundControls({ background }: BackgroundControlsProps) {
- const project = useProjectStore((state) => state.project)
- const registerAssetUrl = useProjectStore((state) => state.registerAssetUrl)
- const assetUrls = useProjectStore((state) => state.assetUrls)
const setBackground = useProjectStore((state) => state.setActiveScreenBackground)
- const fileInputRef = useRef(null)
const patch = (next: Partial) =>
setBackground({ ...background, ...next })
@@ -242,304 +50,38 @@ export function BackgroundControls({ background }: BackgroundControlsProps) {
}
}
- const handleUpload = async (file: File) => {
- if (!project) return
- const asset = await createAssetFromFile(file, project.id, 'background')
- await saveAsset(asset)
- registerAssetUrl(asset.id, createAssetObjectUrl(asset))
- patch({ type: 'image', imageAssetId: asset.id, imageFit: background.imageFit ?? 'cover' })
- }
-
const isGradient =
background.type === 'linear-gradient' || background.type === 'radial-gradient'
- const imageUrl =
- background.type === 'image' && background.imageAssetId
- ? assetUrls[background.imageAssetId]
- : undefined
-
- const visiblePresetGroups = useMemo(
- () => getVisiblePresetGroups(background, isGradient),
- [background, isGradient],
- )
const activeTypeLabel =
TYPE_TABS.find(
- (tab) =>
- tab.id === background.type || (tab.id === 'linear-gradient' && isGradient),
+ (tab) => tab.id === background.type || (tab.id === 'linear-gradient' && isGradient),
)?.label ?? 'Background'
const previewUrl = useMemo(() => presetThumb(background), [background])
- const overlay = parseOverlay(background.overlayColor)
- const overlayEnabled = Boolean(background.overlayColor)
+ const sectionProps = { background, patch, setBackground }
return (
-
-
-
- {activeTypeLabel}
-
-
-
-
- {TYPE_TABS.map((tab) => {
- const Icon = tab.icon
- const active =
- tab.id === background.type ||
- (tab.id === 'linear-gradient' && isGradient)
- return (
- selectType(tab.id)}
- className={cn(
- 'flex flex-col items-center justify-center gap-0.5 rounded-[5px] py-1.5 transition',
- active
- ? 'bg-card text-foreground shadow-sm'
- : 'text-muted-foreground hover:bg-card/60 hover:text-foreground',
- )}
- >
-
- {tab.label}
-
- )
- })}
-
-
- {background.type === 'solid' && (
-
- setBackground({ type: 'solid', color })}
- />
-
- )}
-
- {isGradient && background.gradient && (
-
- patch({ type })}
- />
- {background.type === 'linear-gradient' && (
-
- patch({ gradient: { ...background.gradient!, angle } })
- }
- />
- )}
-
- {
- const stops = [...background.gradient!.stops]
- stops[0] = { offset: 0, color }
- patch({ gradient: { ...background.gradient!, stops } })
- }}
- />
- {
- const stops = [...background.gradient!.stops]
- stops[stops.length - 1] = { offset: 1, color }
- patch({ gradient: { ...background.gradient!, stops } })
- }}
- />
-
-
- )}
-
- {background.type === 'mesh' && (
-
-
- {(background.meshColors ?? []).map((color, index) => (
- {
- const meshColors = [...(background.meshColors ?? [])]
- meshColors[index] = next
- patch({ meshColors })
- }}
- />
- ))}
-
-
- )}
-
- {background.type === 'pattern' && (
-
-
- {PATTERN_KINDS.map((kind) => (
- patch({ patternKind: kind })}
- className={cn(
- 'rounded-md border px-2 py-1 text-[10px] font-medium capitalize transition',
- background.patternKind === kind
- ? 'border-foreground/20 bg-foreground/5 text-foreground'
- : 'border-transparent bg-muted/50 text-muted-foreground hover:text-foreground',
- )}
- >
- {kind}
-
- ))}
-
-
- patch({ color })}
- />
- patch({ patternColor })}
- />
-
- patch({ patternScale })}
- />
-
- )}
-
- {background.type === 'image' && (
-
- {
- const file = event.target.files?.[0]
- if (file) void handleUpload(file)
- event.target.value = ''
- }}
- />
- {imageUrl ? (
-
- ) : (
-
- No image selected
-
- )}
-
- fileInputRef.current?.click()}>
- Upload
-
- {background.imageAssetId && (
- patch({ imageAssetId: undefined })}
- >
- Remove
-
- )}
-
- ({
- value: fit,
- label: fit.charAt(0).toUpperCase() + fit.slice(1),
- }))}
- onChange={(imageFit) => patch({ imageFit })}
- />
-
-
-
-
- patch({
- overlayColor: event.target.checked
- ? composeOverlay(overlay.hex, overlay.alpha)
- : undefined,
- })
- }
- />
- Color overlay
-
- {overlayEnabled && (
- <>
- patch({ overlayColor: composeOverlay(hex, overlay.alpha) })}
- />
-
- patch({ overlayColor: composeOverlay(overlay.hex, value / 100) })
- }
- />
- >
- )}
-
-
- )}
-
- {visiblePresetGroups.length > 0 && (
-
-
- {visiblePresetGroups.map((group) => (
-
- ))}
-
-
- )}
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/src/components/panels/BrandKitPanel.tsx b/src/components/panels/BrandKitPanel.tsx
index c49174d..b1d6297 100644
--- a/src/components/panels/BrandKitPanel.tsx
+++ b/src/components/panels/BrandKitPanel.tsx
@@ -1,14 +1,14 @@
-import { useMemo, useState } from 'react'
+import { useState } from 'react'
import { Plus, RotateCcw, Sparkles, Type, X } from 'lucide-react'
+import { useSelectionCapabilities } from '@/hooks/useSelectionCapabilities'
import { DEFAULT_BRAND_KIT, BRAND_PRIMARY, GOOGLE_FONTS } from '@/lib/constants'
-import { useEditorStore } from '@/stores/editor-store'
-import { useProjectStore } from '@/stores/project-store'
import { useBrandKit, useBrandKitActions } from '@/hooks/useBrandKit'
+import { useProjectStore } from '@/stores/project-store'
+import { useEditorStore } from '@/stores/editor-store'
import { confirm } from '@/stores/confirm-store'
import { ColorPickerField } from '@/components/ui/ColorPickerField'
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/utils'
-import type { Element } from '@/lib/types'
function ControlGroup({
title,
@@ -38,42 +38,6 @@ function SelectionHint({ children }: { children: React.ReactNode }) {
)
}
-function useSelectionContext() {
- const project = useProjectStore((state) => state.project)
- const activeScreenId = useEditorStore((state) => state.activeScreenId)
- const selectedElementIds = useEditorStore((state) => state.selectedElementIds)
-
- return useMemo(() => {
- const screen = project?.screens.find((item) => item.id === activeScreenId)
- if (!screen || selectedElementIds.length === 0) {
- return {
- hasSelection: false,
- hasText: false,
- hasShape: false,
- canApplyColor: false,
- canApplyFont: false,
- selectedElements: [] as Element[],
- }
- }
-
- const selectedElements = selectedElementIds
- .map((id) => screen.elements.find((item) => item.id === id))
- .filter((item): item is Element => item != null)
-
- const hasText = selectedElements.some((item) => item.type === 'text')
- const hasShape = selectedElements.some((item) => item.type === 'shape')
-
- return {
- hasSelection: selectedElements.length > 0,
- hasText,
- hasShape,
- canApplyColor: hasText || hasShape,
- canApplyFont: hasText,
- selectedElements,
- }
- }, [project, activeScreenId, selectedElementIds])
-}
-
export function BrandKitPanel() {
const brandKit = useBrandKit()
const { updateBrandKit, resetToGlobal, hasOverride } = useBrandKitActions()
@@ -81,7 +45,7 @@ export function BrandKitPanel() {
const applyBrandToAllText = useProjectStore((state) => state.applyBrandToAllText)
const getActiveScreen = useProjectStore((state) => state.getActiveScreen)
const selectedElementIds = useEditorStore((state) => state.selectedElementIds)
- const selection = useSelectionContext()
+ const selection = useSelectionCapabilities()
const [draftColor, setDraftColor] = useState(BRAND_PRIMARY)
const [draftFont, setDraftFont] = useState('Inter')
diff --git a/src/components/panels/LayerContextMenu.tsx b/src/components/panels/LayerContextMenu.tsx
index 625fb8f..b2a95d1 100644
--- a/src/components/panels/LayerContextMenu.tsx
+++ b/src/components/panels/LayerContextMenu.tsx
@@ -1,4 +1,3 @@
-import { useEffect, useRef } from 'react'
import {
ArrowDown,
ArrowUp,
@@ -9,6 +8,11 @@ import {
Trash2,
Unlock,
} from 'lucide-react'
+import {
+ ContextMenuItem,
+ ContextMenuSeparator,
+ FloatingMenu,
+} from '@/components/ui/FloatingMenu'
export interface LayerContextMenuTarget {
elementIds: string[]
@@ -42,131 +46,76 @@ export function LayerContextMenu({
onToggleLocked,
onClose,
}: LayerContextMenuProps) {
- const menuRef = useRef(null)
const count = target.elementIds.length
const label = count === 1 ? 'layer' : `${count} layers`
- useEffect(() => {
- const handlePointerDown = (event: MouseEvent) => {
- if (menuRef.current?.contains(event.target as Node)) return
- onClose()
- }
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') onClose()
- }
- window.addEventListener('mousedown', handlePointerDown)
- window.addEventListener('keydown', handleKeyDown)
- window.addEventListener('scroll', onClose, true)
- return () => {
- window.removeEventListener('mousedown', handlePointerDown)
- window.removeEventListener('keydown', handleKeyDown)
- window.removeEventListener('scroll', onClose, true)
- }
- }, [onClose])
-
- useEffect(() => {
- const menu = menuRef.current
- if (!menu) return
- const rect = menu.getBoundingClientRect()
- const padding = 8
- let left = x
- let top = y
- if (left + rect.width > window.innerWidth - padding) {
- left = window.innerWidth - rect.width - padding
- }
- if (top + rect.height > window.innerHeight - padding) {
- top = window.innerHeight - rect.height - padding
- }
- menu.style.left = `${Math.max(padding, left)}px`
- menu.style.top = `${Math.max(padding, top)}px`
- }, [x, y])
-
return (
- event.stopPropagation()}
+ marker="layer-context-menu"
>
-
}
onClick={() => {
onDuplicate()
onClose()
}}
>
-
Duplicate {label}
-
-
+ }
onClick={() => {
onBringForward()
onClose()
}}
>
-
Bring forward
-
-
+ }
onClick={() => {
onSendBackward()
onClose()
}}
>
-
Send backward
-
-
-
+
+ : }
onClick={() => {
onToggleVisible()
onClose()
}}
>
- {target.allVisible ? : }
{target.allVisible ? 'Hide' : 'Show'} {label}
-
-
+ : }
onClick={() => {
onToggleLocked()
onClose()
}}
>
- {target.allLocked ? : }
{target.allLocked ? 'Unlock' : 'Lock'} {label}
-
-
-
+
+ }
+ destructive
disabled={!target.canDelete}
- className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-destructive transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
if (!target.canDelete) return
onDelete()
onClose()
}}
>
-
Delete {label}
-
-
+
+
)
}
diff --git a/src/components/panels/LayersPanel.tsx b/src/components/panels/LayersPanel.tsx
index ca15357..55f1d2a 100644
--- a/src/components/panels/LayersPanel.tsx
+++ b/src/components/panels/LayersPanel.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useState, type ComponentType, type MouseEvent } from 'react'
+import { useCallback, useState, type MouseEvent } from 'react'
import {
DndContext,
closestCenter,
@@ -16,55 +16,25 @@ import {
import { CSS } from '@dnd-kit/utilities'
import {
ChevronRight,
- Circle,
Copy,
Eye,
EyeOff,
Folder,
GripVertical,
- Image as ImageIcon,
Layers,
Lock,
- Minus,
- Smartphone,
- Square,
Trash2,
- Triangle,
- Type,
} from 'lucide-react'
+import { getLayerIcon } from '@/lib/elements/element-meta'
import { LayerContextMenu } from '@/components/panels/LayerContextMenu'
import { useProjectStore } from '@/stores/project-store'
import { useEditorStore } from '@/stores/editor-store'
import { cn } from '@/lib/utils'
-import type { Element, ShapeKind } from '@/lib/types'
+import type { Element } from '@/lib/types'
function LayerTypeIcon({ element }: { element: Element }) {
- const iconClass = 'shrink-0 text-muted-foreground'
-
- if (element.type === 'text') {
- return
- }
- if (element.type === 'device') {
- return
- }
- if (element.type === 'image') {
- return
- }
- if (element.type === 'group') {
- return
- }
- if (element.type === 'shape') {
- const shapeIcons: Record> = {
- rectangle: Square,
- circle: Circle,
- triangle: Triangle,
- line: Minus,
- }
- const Icon = shapeIcons[element.shapeKind] ?? Square
- return
- }
-
- return
+ const Icon = getLayerIcon(element)
+ return
}
function selectLayer(
diff --git a/src/components/panels/PropertiesPanel.tsx b/src/components/panels/PropertiesPanel.tsx
index bdbbf74..d7207e9 100644
--- a/src/components/panels/PropertiesPanel.tsx
+++ b/src/components/panels/PropertiesPanel.tsx
@@ -1,75 +1,33 @@
-import type { ReactNode } from 'react'
+import { X } from 'lucide-react'
+import { ELEMENT_TYPE_META } from '@/lib/elements/element-meta'
+import { useSelectedElements } from '@/hooks/useSelectedElements'
+import { usePersistAssetUpload } from '@/hooks/usePersistAssetUpload'
import {
- Box,
- FlipHorizontal,
- FlipVertical,
- Image as ImageIcon,
- ImagePlus,
- Layers,
- Move,
- Palette,
- Smartphone,
- Sparkles,
- Sun,
- Type,
- X,
-} from 'lucide-react'
-import { DEVICES, getDevice } from '@/lib/assets/devices'
-import { createAssetFromFile, createAssetObjectUrl } from '@/lib/assets/image-pipeline'
-import { saveAsset } from '@/lib/db'
-import { BRAND_PRIMARY } from '@/lib/constants'
+ isDeviceElement,
+ isImageElement,
+ isShapeElement,
+ isTextElement,
+} from '@/lib/elements/element-meta'
import { useProjectStore } from '@/stores/project-store'
import { useEditorStore } from '@/stores/editor-store'
-import { PanelSection } from '@/components/ui/PanelSection'
-import { BackgroundControls } from '@/components/panels/BackgroundControls'
-import { TextControls } from '@/components/panels/TextControls'
-import { Input } from '@/components/ui/Input'
-import { FieldGroup } from '@/components/ui/FieldGroup'
-import { MixedNumberField, NumberField } from '@/components/ui/NumberField'
-import { SliderField } from '@/components/ui/SliderField'
-import { ColorPickerField } from '@/components/ui/ColorPickerField'
-import { Button } from '@/components/ui/Button'
-import { cn } from '@/lib/utils'
-import type {
- DeviceElement,
- Element,
- FillConfig,
- GradientFill,
- ImageElement,
- ImageFit,
- ShapeElement,
- TextElement,
-} from '@/lib/types'
-
-const TILT_PRESETS: Array<{ label: string; tiltX: number; tiltY: number }> = [
- { label: 'Flat', tiltX: 0, tiltY: 0 },
- { label: 'Left', tiltX: 0, tiltY: 22 },
- { label: 'Right', tiltX: 0, tiltY: -22 },
- { label: 'Back', tiltX: 16, tiltY: 0 },
- { label: 'Hero L', tiltX: 10, tiltY: 18 },
- { label: 'Hero R', tiltX: 10, tiltY: -18 },
-]
-
-const ELEMENT_LABELS: Record = {
- text: { label: 'Text', icon: Type },
- shape: { label: 'Shape', icon: Palette },
- image: { label: 'Image', icon: ImageIcon },
- device: { label: 'Device', icon: Smartphone },
- group: { label: 'Group', icon: Layers },
-}
+import { ElementTransformSection } from '@/components/panels/properties/ElementTransformSection'
+import { MultiSelectionProperties } from '@/components/panels/properties/MultiSelectionProperties'
+import { ScreenOnlyProperties } from '@/components/panels/properties/ScreenOnlyProperties'
+import { TextPropertiesSection } from '@/components/panels/properties/TextPropertiesSection'
+import { ShapePropertiesSection } from '@/components/panels/properties/ShapePropertiesSection'
+import { ImagePropertiesSection } from '@/components/panels/properties/ImagePropertiesSection'
+import { DevicePropertiesSection } from '@/components/panels/properties/DevicePropertiesSection'
+import { ElementEffectsSection } from '@/components/panels/properties/ElementEffectsSection'
+import type { Element } from '@/lib/types'
function usePropertiesContext() {
const screen = useProjectStore((state) => state.getActiveScreen())
- const selectedElementIds = useEditorStore((state) => state.selectedElementIds)
+ const selectedElements = useSelectedElements()
if (!screen) {
return { subtitle: '', contextLabel: null as string | null }
}
- const selectedElements = screen.elements.filter((element) =>
- selectedElementIds.includes(element.id),
- )
-
if (selectedElements.length === 0) {
return { subtitle: screen.name, contextLabel: 'Screen' }
}
@@ -81,7 +39,7 @@ function usePropertiesContext() {
}
}
- const meta = ELEMENT_LABELS[selectedElements[0].type]
+ const meta = ELEMENT_TYPE_META[selectedElements[0].type]
return {
subtitle: selectedElements[0].name || meta.label,
contextLabel: meta.label,
@@ -102,7 +60,7 @@ export function PropertiesPanelHeader({ onClose }: { onClose: () => void }) {
{contextLabel}
) : null}
- {subtitle}
+ {subtitle}
) : null}
@@ -119,92 +77,20 @@ export function PropertiesPanelHeader({ onClose }: { onClose: () => void }) {
)
}
-function sharedNumber(values: number[]): number | null {
- if (values.length === 0) return null
- const [first] = values
- return values.every((value) => value === first) ? first : null
-}
-
-function Hint({ children }: { children: ReactNode }) {
- return {children}
-}
-
-function SegmentedControl({
- label,
- value,
- options,
- onChange,
-}: {
- label: string
- value: T
- options: Array<{ value: T; label: string }>
- onChange: (value: T) => void
-}) {
- return (
-
-
{label}
-
- {options.map((option) => {
- const active = option.value === value
- return (
- onChange(option.value)}
- className={cn(
- 'rounded-[5px] py-1.5 text-[11px] font-medium capitalize transition',
- active
- ? 'bg-card text-foreground shadow-sm'
- : 'text-muted-foreground hover:text-foreground',
- )}
- >
- {option.label}
-
- )
- })}
-
-
- )
-}
-
-const OBJECT_FIT_OPTIONS: Array<{ value: ImageFit; label: string }> = [
- { value: 'cover', label: 'Cover' },
- { value: 'contain', label: 'Contain' },
- { value: 'fill', label: 'Fill' },
-]
-
-const DEFAULT_GRADIENT: GradientFill = {
- type: 'linear',
- angle: 90,
- stops: [
- { offset: 0, color: BRAND_PRIMARY },
- { offset: 1, color: '#0f172a' },
- ],
-}
-
export function PropertiesPanel() {
const screen = useProjectStore((state) => state.getActiveScreen())
- const project = useProjectStore((state) => state.project)
const updateElement = useProjectStore((state) => state.updateElement)
- const registerAssetUrl = useProjectStore((state) => state.registerAssetUrl)
+ const uploadAsset = usePersistAssetUpload()
+ const selectedElements = useSelectedElements()
const selectedElementIds = useEditorStore((state) => state.selectedElementIds)
const assignDeviceScreenshot = async (elementId: string, file: File) => {
- if (!project) return
- const asset = await createAssetFromFile(file, project.id, 'screenshot')
- await saveAsset(asset)
- registerAssetUrl(asset.id, createAssetObjectUrl(asset))
+ const asset = await uploadAsset(file, 'screenshot')
updateElement(elementId, { screenshotAssetId: asset.id })
}
if (!screen) return null
- const selectedElements = screen.elements.filter((element) =>
- selectedElementIds.includes(element.id),
- )
const element = selectedElements.length === 1 ? selectedElements[0] : null
const updateSelected = (patch: Partial) => {
@@ -214,585 +100,46 @@ export function PropertiesPanel() {
}
if (!element && selectedElements.length > 1) {
- const sharedOpacity = sharedNumber(selectedElements.map((item) => item.opacity))
- const sharedX = sharedNumber(selectedElements.map((item) => Math.round(item.x)))
- const sharedY = sharedNumber(selectedElements.map((item) => Math.round(item.y)))
- const sharedWidth = sharedNumber(selectedElements.map((item) => Math.round(item.width)))
- const sharedHeight = sharedNumber(selectedElements.map((item) => Math.round(item.height)))
return (
-
-
-
- updateSelected({ x })} />
- updateSelected({ y })} />
- updateSelected({ width })}
- />
- updateSelected({ height })}
- />
-
- updateSelected({ opacity: value / 100 })}
- />
-
- Changes apply to all {selectedElements.length} selected elements. Alignment and
- distribution live in the toolbar.
-
-
-
-
-
-
-
+
)
}
if (!element) {
- return (
-
-
-
-
-
-
- Select a layer on the canvas to edit position, typography, and effects.
-
-
-
- )
+ return
}
+ const updateElementPatch = (patch: Partial) => updateElement(element.id, patch)
+
return (
-
-
- updateElement(element.id, { name: event.target.value })}
- />
-
-
- updateElement(element.id, { x })}
- />
- updateElement(element.id, { y })}
- />
- updateElement(element.id, { width })}
- />
- updateElement(element.id, { height })}
- />
-
- updateElement(element.id, { rotation })}
- />
- updateElement(element.id, { opacity: value / 100 })}
- />
-
+
- {element.type === 'text' && (
-
- updateElement(element.id, patch)}
- />
-
+ {isTextElement(element) && (
+
)}
- {element.type === 'shape' &&
- (() => {
- const shape = element as ShapeElement
- const isGradient = shape.fill.type === 'gradient'
- const gradient = shape.fill.gradient ?? DEFAULT_GRADIENT
- const setFill = (fill: FillConfig) => updateElement(shape.id, { fill })
- const patchGradient = (next: Partial
) =>
- setFill({ type: 'gradient', gradient: { ...gradient, ...next } })
- const setStop = (index: number, color: string) => {
- const stops = gradient.stops.map((stop) => ({ ...stop }))
- stops[index] = { offset: index === 0 ? 0 : 1, color }
- patchGradient({ stops })
- }
- const lastStop = gradient.stops[gradient.stops.length - 1]
- return (
-
-
- type === 'gradient'
- ? setFill({
- type: 'gradient',
- gradient: shape.fill.gradient ?? { ...DEFAULT_GRADIENT },
- })
- : setFill({ type: 'solid', color: shape.fill.color ?? BRAND_PRIMARY })
- }
- />
-
- {isGradient ? (
- <>
- patchGradient({ type })}
- />
- {gradient.type === 'linear' && (
- patchGradient({ angle })}
- />
- )}
- setStop(0, color)}
- />
- setStop(gradient.stops.length - 1, color)}
- />
- >
- ) : (
- setFill({ type: 'solid', color })}
- />
- )}
-
- updateElement(shape.id, { stroke })}
- />
- updateElement(shape.id, { strokeWidth })}
- />
- updateElement(shape.id, { cornerRadius })}
- />
-
- )
- })()}
-
- {element.type === 'image' &&
- (() => {
- const image = element as ImageElement
- return (
- <>
-
- updateElement(image.id, { objectFit })}
- />
-
-
Flip
-
- updateElement(image.id, { flipX: !image.flipX })}
- >
- Horizontal
-
- updateElement(image.id, { flipY: !image.flipY })}
- >
- Vertical
-
-
-
- updateElement(image.id, { cornerRadius })}
- />
- updateElement(image.id, { borderWidth })}
- />
- updateElement(image.id, { borderColor })}
- />
-
-
-
- updateElement(image.id, { brightness })}
- />
- updateElement(image.id, { contrast })}
- />
- updateElement(image.id, { saturation })}
- />
- updateElement(image.id, { blur })}
- />
- {(image.brightness || image.contrast || image.saturation || image.blur) && (
-
- updateElement(image.id, {
- brightness: 0,
- contrast: 0,
- saturation: 0,
- blur: 0,
- })
- }
- >
- Reset adjustments
-
- )}
-
-
-
- Fit the image to its frame, or fine-tune the visible region (0–100%).
-
- updateElement(image.id, {
- cropX: 0,
- cropY: 0,
- cropWidth: 1,
- cropHeight: 1,
- })
- }
- >
- Fit frame
-
- updateElement(image.id, { cropX: value / 100 })}
- />
- updateElement(image.id, { cropY: value / 100 })}
- />
- updateElement(image.id, { cropWidth: value / 100 })}
- />
- updateElement(image.id, { cropHeight: value / 100 })}
- />
-
- >
- )
- })()}
-
- {element.type === 'device' && (
- <>
-
-
-
- {(element as DeviceElement).screenshotAssetId
- ? 'Replace screenshot'
- : 'Fill screenshot'}
- {
- const file = event.target.files?.[0]
- if (file) void assignDeviceScreenshot(element.id, file)
- event.target.value = ''
- }}
- />
-
- {(element as DeviceElement).screenshotAssetId && (
- updateElement(element.id, { screenshotAssetId: undefined })}
- >
- Remove screenshot
-
- )}
-
- Upload an app screenshot to fill the device screen, or use the Assets panel and
- click “Use” on a saved asset.
-
-
-
-
-
- Model
- {
- const deviceId = event.target.value
- const device = getDevice(deviceId)
- updateElement(element.id, {
- deviceId,
- colorVariant: device?.colorVariants[0]?.id,
- })
- }}
- >
- {DEVICES.map((device) => (
-
- {device.name}
-
- ))}
-
-
-
- Color variant
-
- updateElement(element.id, { colorVariant: event.target.value })
- }
- >
- {(getDevice((element as DeviceElement).deviceId)?.colorVariants ?? []).map(
- (variant) => (
-
- {variant.name}
-
- ),
- )}
-
-
-
-
- updateElement(element.id, { showFrame: event.target.checked })
- }
- />
- Show frame
-
-
- Screenshot fit
-
- updateElement(element.id, {
- screenshotFit: event.target.value as DeviceElement['screenshotFit'],
- })
- }
- >
- Cover
- Contain
-
-
-
+ {isShapeElement(element) && (
+
+ )}
-
-
-
Presets
-
- {TILT_PRESETS.map((preset) => (
-
- updateElement(element.id, {
- tiltX: preset.tiltX,
- tiltY: preset.tiltY,
- })
- }
- >
- {preset.label}
-
- ))}
-
-
- updateElement(element.id, { tiltX })}
- />
- updateElement(element.id, { tiltY })}
- />
- updateElement(element.id, { perspective })}
- />
- updateElement(element.id, { rotation })}
- />
-
+ {isImageElement(element) && (
+
+ )}
-
- updateElement(element.id, { shadowIntensity: value / 100 })}
- />
- updateElement(element.id, { shadowSpread })}
- />
-
- >
+ {isDeviceElement(element) && (
+ void assignDeviceScreenshot(element.id, file)}
+ />
)}
-
-
-
- updateElement(element.id, {
- shadow: {
- ...(element.shadow ?? {
- offsetX: 0,
- offsetY: 4,
- blur: 12,
- color: 'rgba(0,0,0,0.25)',
- enabled: false,
- }),
- enabled: event.target.checked,
- },
- })
- }
- />
- Enable shadow
-
- {element.shadow?.enabled && (
- <>
-
- updateElement(element.id, { shadow: { ...element.shadow!, blur } })
- }
- />
-
- updateElement(element.id, { shadow: { ...element.shadow!, color } })
- }
- />
- >
- )}
-
+
)
}
diff --git a/src/components/panels/TemplatesPanel.tsx b/src/components/panels/TemplatesPanel.tsx
index eca06c2..12aaf65 100644
--- a/src/components/panels/TemplatesPanel.tsx
+++ b/src/components/panels/TemplatesPanel.tsx
@@ -1,18 +1,18 @@
import { useMemo, useState } from 'react'
import { LayoutTemplate, Search, Sparkles } from 'lucide-react'
+import { filterTemplates } from '@/lib/templates/filter-templates'
import {
TEMPLATES,
- LAYOUT_LABELS,
CATEGORY_LABELS,
TEMPLATE_FILTER_TAGS,
} from '@/lib/templates'
-import { createId } from '@/lib/utils'
+import { templateElementsToScreenElements } from '@/lib/templates/preview'
import { useProjectStore } from '@/stores/project-store'
import { useEditorStore } from '@/stores/editor-store'
import { Input } from '@/components/ui/Input'
import { TemplateApplyDialog } from '@/components/templates/TemplateApplyDialog'
-import { TemplateThumbnail } from '@/components/templates/TemplateThumbnail'
-import type { Element, TemplateApplyMode, TemplateDefinition } from '@/lib/types'
+import { TemplateCard } from '@/components/templates/TemplateCard'
+import type { TemplateApplyMode, TemplateDefinition } from '@/lib/types'
type FilterTag = (typeof TEMPLATE_FILTER_TAGS)[number] | 'all'
@@ -25,20 +25,14 @@ export function TemplatesPanel() {
const [activeTag, setActiveTag] = useState('all')
const [pendingTemplate, setPendingTemplate] = useState(null)
- const filteredTemplates = useMemo(() => {
- const normalized = query.trim().toLowerCase()
- return TEMPLATES.filter((template) => {
- if (activeTag !== 'all' && !template.tags?.includes(activeTag)) return false
- if (!normalized) return true
- return (
- template.name.toLowerCase().includes(normalized) ||
- template.category.toLowerCase().includes(normalized) ||
- CATEGORY_LABELS[template.category].toLowerCase().includes(normalized) ||
- LAYOUT_LABELS[template.layout].toLowerCase().includes(normalized) ||
- template.tags?.some((tag) => tag.toLowerCase().includes(normalized))
- )
- })
- }, [query, activeTag])
+ const filteredTemplates = useMemo(
+ () =>
+ filterTemplates(TEMPLATES, {
+ query,
+ tag: activeTag === 'all' ? undefined : activeTag,
+ }),
+ [query, activeTag],
+ )
const groups = useMemo(() => {
return [...new Set(filteredTemplates.map((t) => t.category))]
@@ -55,13 +49,7 @@ export function TemplatesPanel() {
const screenId = activeScreenId ?? project?.screens[0]?.id
if (!template || !screenId) return
- const elements = template.elements.map((element, index) => ({
- ...structuredClone(element),
- id: createId(),
- name: element.name ?? `Element ${index + 1}`,
- zIndex: index,
- })) as Element[]
-
+ const elements = templateElementsToScreenElements(template.elements)
const background = structuredClone(template.background)
if (scope === 'all') {
@@ -125,47 +113,12 @@ export function TemplatesPanel() {
{group.templates.map((template) => (
-
setPendingTemplate(template)}
- title={`Preview ${template.name}`}
- className="group flex w-full flex-col overflow-hidden rounded-xl border border-border bg-card text-left shadow-sm transition hover:border-primary/50 hover:shadow-md focus-visible:border-primary focus-visible:outline-none"
- >
-
-
-
-
-
-
- Use template
-
-
-
-
-
- {template.name}
-
-
- {LAYOUT_LABELS[template.layout]}
-
- {template.tags && template.tags.length > 0 && (
-
- {template.tags.map((tag) => (
-
- {tag}
-
- ))}
-
- )}
-
-
+ />
))}
diff --git a/src/components/panels/background/BackgroundPresetsSection.tsx b/src/components/panels/background/BackgroundPresetsSection.tsx
new file mode 100644
index 0000000..6a3a4de
--- /dev/null
+++ b/src/components/panels/background/BackgroundPresetsSection.tsx
@@ -0,0 +1,36 @@
+import {
+ PropertyBlock,
+ PresetGroup,
+ getVisiblePresetGroups,
+} from '@/components/panels/background/shared'
+import type { BackgroundConfig } from '@/lib/types'
+
+interface BackgroundPresetsSectionProps {
+ background: BackgroundConfig
+ isGradient: boolean
+ onSelect: (background: BackgroundConfig) => void
+}
+
+export function BackgroundPresetsSection({
+ background,
+ isGradient,
+ onSelect,
+}: BackgroundPresetsSectionProps) {
+ const visiblePresetGroups = getVisiblePresetGroups(background, isGradient)
+ if (visiblePresetGroups.length === 0) return null
+
+ return (
+
+
+ {visiblePresetGroups.map((group) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/panels/background/BackgroundPreview.tsx b/src/components/panels/background/BackgroundPreview.tsx
new file mode 100644
index 0000000..58b7787
--- /dev/null
+++ b/src/components/panels/background/BackgroundPreview.tsx
@@ -0,0 +1,20 @@
+interface BackgroundPreviewProps {
+ previewUrl: string
+ activeTypeLabel: string
+}
+
+export function BackgroundPreview({ previewUrl, activeTypeLabel }: BackgroundPreviewProps) {
+ return (
+
+
+
+ {activeTypeLabel}
+
+
+ )
+}
diff --git a/src/components/panels/background/BackgroundTypeTabs.tsx b/src/components/panels/background/BackgroundTypeTabs.tsx
new file mode 100644
index 0000000..3f83424
--- /dev/null
+++ b/src/components/panels/background/BackgroundTypeTabs.tsx
@@ -0,0 +1,43 @@
+import { TYPE_TABS } from '@/components/panels/background/shared'
+import type { BackgroundConfig, BackgroundType } from '@/lib/types'
+import { cn } from '@/lib/utils'
+
+interface BackgroundTypeTabsProps {
+ background: BackgroundConfig
+ isGradient: boolean
+ onSelectType: (type: BackgroundType) => void
+}
+
+export function BackgroundTypeTabs({
+ background,
+ isGradient,
+ onSelectType,
+}: BackgroundTypeTabsProps) {
+ return (
+
+ {TYPE_TABS.map((tab) => {
+ const Icon = tab.icon
+ const active =
+ tab.id === background.type || (tab.id === 'linear-gradient' && isGradient)
+ return (
+ onSelectType(tab.id)}
+ className={cn(
+ 'flex flex-col items-center justify-center gap-0.5 rounded-[5px] py-1.5 transition',
+ active
+ ? 'bg-card text-foreground shadow-sm'
+ : 'text-muted-foreground hover:bg-card/60 hover:text-foreground',
+ )}
+ >
+
+ {tab.label}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/panels/background/GradientBackgroundSection.tsx b/src/components/panels/background/GradientBackgroundSection.tsx
new file mode 100644
index 0000000..da5ca9c
--- /dev/null
+++ b/src/components/panels/background/GradientBackgroundSection.tsx
@@ -0,0 +1,66 @@
+import { PropertyBlock, type BackgroundSectionProps } from '@/components/panels/background/shared'
+import { BRAND_PRIMARY } from '@/lib/constants'
+import { ColorPickerField } from '@/components/ui/ColorPickerField'
+import { SegmentedControl } from '@/components/ui/SegmentedControl'
+import { SliderField } from '@/components/ui/SliderField'
+
+interface GradientBackgroundSectionProps extends BackgroundSectionProps {
+ isGradient: boolean
+}
+
+export function GradientBackgroundSection({
+ background,
+ patch,
+ isGradient,
+}: GradientBackgroundSectionProps) {
+ if (!isGradient || !background.gradient) return null
+
+ return (
+
+ patch({ type })}
+ />
+ {background.type === 'linear-gradient' && (
+ patch({ gradient: { ...background.gradient!, angle } })}
+ />
+ )}
+
+ {
+ const stops = [...background.gradient!.stops]
+ stops[0] = { offset: 0, color }
+ patch({ gradient: { ...background.gradient!, stops } })
+ }}
+ />
+ {
+ const stops = [...background.gradient!.stops]
+ stops[stops.length - 1] = { offset: 1, color }
+ patch({ gradient: { ...background.gradient!, stops } })
+ }}
+ />
+
+
+ )
+}
diff --git a/src/components/panels/background/ImageBackgroundSection.tsx b/src/components/panels/background/ImageBackgroundSection.tsx
new file mode 100644
index 0000000..8646aa3
--- /dev/null
+++ b/src/components/panels/background/ImageBackgroundSection.tsx
@@ -0,0 +1,115 @@
+import { useRef } from 'react'
+import { Image as ImageIcon, Trash2 } from 'lucide-react'
+import { usePersistAssetUpload } from '@/hooks/usePersistAssetUpload'
+import { useProjectStore } from '@/stores/project-store'
+import {
+ PropertyBlock,
+ composeOverlay,
+ parseOverlay,
+ type BackgroundSectionProps,
+} from '@/components/panels/background/shared'
+import type { ImageFit } from '@/lib/types'
+import { Button } from '@/components/ui/Button'
+import { ColorPickerField } from '@/components/ui/ColorPickerField'
+import { SegmentedControl } from '@/components/ui/SegmentedControl'
+import { SliderField } from '@/components/ui/SliderField'
+
+const IMAGE_FITS: ImageFit[] = ['cover', 'contain', 'fill']
+
+export function ImageBackgroundSection({ background, patch }: BackgroundSectionProps) {
+ const assetUrls = useProjectStore((state) => state.assetUrls)
+ const uploadAsset = usePersistAssetUpload()
+ const fileInputRef = useRef(null)
+
+ if (background.type !== 'image') return null
+
+ const imageUrl = background.imageAssetId ? assetUrls[background.imageAssetId] : undefined
+ const overlay = parseOverlay(background.overlayColor)
+ const overlayEnabled = Boolean(background.overlayColor)
+
+ const handleUpload = async (file: File) => {
+ const asset = await uploadAsset(file, 'background')
+ patch({ type: 'image', imageAssetId: asset.id, imageFit: background.imageFit ?? 'cover' })
+ }
+
+ return (
+
+ {
+ const file = event.target.files?.[0]
+ if (file) void handleUpload(file)
+ event.target.value = ''
+ }}
+ />
+ {imageUrl ? (
+
+ ) : (
+
+ No image selected
+
+ )}
+
+ fileInputRef.current?.click()}>
+ Upload
+
+ {background.imageAssetId && (
+ patch({ imageAssetId: undefined })}>
+ Remove
+
+ )}
+
+ ({
+ value: fit,
+ label: fit.charAt(0).toUpperCase() + fit.slice(1),
+ }))}
+ onChange={(imageFit) => patch({ imageFit })}
+ />
+
+
+
+
+ patch({
+ overlayColor: event.target.checked
+ ? composeOverlay(overlay.hex, overlay.alpha)
+ : undefined,
+ })
+ }
+ />
+ Color overlay
+
+ {overlayEnabled && (
+ <>
+ patch({ overlayColor: composeOverlay(hex, overlay.alpha) })}
+ />
+
+ patch({ overlayColor: composeOverlay(overlay.hex, value / 100) })
+ }
+ />
+ >
+ )}
+
+
+ )
+}
diff --git a/src/components/panels/background/MeshBackgroundSection.tsx b/src/components/panels/background/MeshBackgroundSection.tsx
new file mode 100644
index 0000000..ab0710f
--- /dev/null
+++ b/src/components/panels/background/MeshBackgroundSection.tsx
@@ -0,0 +1,26 @@
+import { PropertyBlock, type BackgroundSectionProps } from '@/components/panels/background/shared'
+import { ColorPickerField } from '@/components/ui/ColorPickerField'
+
+export function MeshBackgroundSection({ background, patch }: BackgroundSectionProps) {
+ if (background.type !== 'mesh') return null
+
+ return (
+
+
+ {(background.meshColors ?? []).map((color, index) => (
+ {
+ const meshColors = [...(background.meshColors ?? [])]
+ meshColors[index] = next
+ patch({ meshColors })
+ }}
+ />
+ ))}
+
+
+ )
+}
diff --git a/src/components/panels/background/PatternBackgroundSection.tsx b/src/components/panels/background/PatternBackgroundSection.tsx
new file mode 100644
index 0000000..922d8f1
--- /dev/null
+++ b/src/components/panels/background/PatternBackgroundSection.tsx
@@ -0,0 +1,55 @@
+import { PropertyBlock, type BackgroundSectionProps } from '@/components/panels/background/shared'
+import { BRAND_PRIMARY } from '@/lib/constants'
+import { PATTERN_KINDS } from '@/lib/canvas/backgrounds'
+import { ColorPickerField } from '@/components/ui/ColorPickerField'
+import { SliderField } from '@/components/ui/SliderField'
+import { cn } from '@/lib/utils'
+
+export function PatternBackgroundSection({ background, patch }: BackgroundSectionProps) {
+ if (background.type !== 'pattern') return null
+
+ return (
+
+
+ {PATTERN_KINDS.map((kind) => (
+ patch({ patternKind: kind })}
+ className={cn(
+ 'rounded-md border px-2 py-1 text-[10px] font-medium capitalize transition',
+ background.patternKind === kind
+ ? 'border-foreground/20 bg-foreground/5 text-foreground'
+ : 'border-transparent bg-muted/50 text-muted-foreground hover:text-foreground',
+ )}
+ >
+ {kind}
+
+ ))}
+
+
+ patch({ color })}
+ />
+ patch({ patternColor })}
+ />
+
+ patch({ patternScale })}
+ />
+
+ )
+}
diff --git a/src/components/panels/background/SolidBackgroundSection.tsx b/src/components/panels/background/SolidBackgroundSection.tsx
new file mode 100644
index 0000000..621c929
--- /dev/null
+++ b/src/components/panels/background/SolidBackgroundSection.tsx
@@ -0,0 +1,17 @@
+import { PropertyBlock, type BackgroundSectionProps } from '@/components/panels/background/shared'
+import { ColorPickerField } from '@/components/ui/ColorPickerField'
+
+export function SolidBackgroundSection({ background, setBackground }: BackgroundSectionProps) {
+ if (background.type !== 'solid') return null
+
+ return (
+
+ setBackground({ type: 'solid', color })}
+ />
+
+ )
+}
diff --git a/src/components/panels/background/shared.tsx b/src/components/panels/background/shared.tsx
new file mode 100644
index 0000000..63f2cf9
--- /dev/null
+++ b/src/components/panels/background/shared.tsx
@@ -0,0 +1,153 @@
+import { useState, type ReactNode } from 'react'
+import {
+ Blend,
+ Grid3x3,
+ Image as ImageIcon,
+ Palette,
+ Sparkles,
+ type LucideIcon,
+} from 'lucide-react'
+import {
+ ALL_BACKGROUND_PRESETS,
+ buildBackgroundCanvas,
+ type BackgroundPreset,
+} from '@/lib/canvas/backgrounds'
+import type { BackgroundConfig, BackgroundType } from '@/lib/types'
+
+export const PRESET_PREVIEW_LIMIT = 8
+
+export const TYPE_TABS: Array<{ id: BackgroundType; label: string; icon: LucideIcon }> = [
+ { id: 'solid', label: 'Solid', icon: Palette },
+ { id: 'linear-gradient', label: 'Gradient', icon: Blend },
+ { id: 'mesh', label: 'Mesh', icon: Sparkles },
+ { id: 'pattern', label: 'Pattern', icon: Grid3x3 },
+ { id: 'image', label: 'Image', icon: ImageIcon },
+]
+
+const DEFAULT_OVERLAY_HEX = '#000000'
+const DEFAULT_OVERLAY_ALPHA = 0.35
+
+export interface BackgroundSectionProps {
+ background: BackgroundConfig
+ patch: (next: Partial) => void
+ setBackground: (background: BackgroundConfig) => void
+}
+
+export function presetThumb(background: BackgroundConfig): string {
+ const canvas = buildBackgroundCanvas(background, 64, 64)
+ return canvas.toDataURL()
+}
+
+function hexToRgb(hex: string): { r: number; g: number; b: number } {
+ const normalized = hex.replace('#', '')
+ const full =
+ normalized.length === 3
+ ? normalized
+ .split('')
+ .map((char) => char + char)
+ .join('')
+ : normalized.padEnd(6, '0').slice(0, 6)
+ const int = Number.parseInt(full, 16)
+ return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255 }
+}
+
+function rgbToHex(r: number, g: number, b: number): string {
+ const toHex = (value: number) =>
+ Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, '0')
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`
+}
+
+export function parseOverlay(value: string | undefined): { hex: string; alpha: number } {
+ if (!value) return { hex: DEFAULT_OVERLAY_HEX, alpha: DEFAULT_OVERLAY_ALPHA }
+ const rgba = value.match(/rgba?\(([^)]+)\)/i)
+ if (rgba) {
+ const parts = rgba[1].split(',').map((part) => Number.parseFloat(part.trim()))
+ const [r = 0, g = 0, b = 0, a = 1] = parts
+ return { hex: rgbToHex(r, g, b), alpha: Number.isFinite(a) ? a : 1 }
+ }
+ if (value.startsWith('#')) return { hex: value, alpha: 1 }
+ return { hex: DEFAULT_OVERLAY_HEX, alpha: DEFAULT_OVERLAY_ALPHA }
+}
+
+export function composeOverlay(hex: string, alpha: number): string {
+ const { r, g, b } = hexToRgb(hex)
+ return `rgba(${r}, ${g}, ${b}, ${Number(alpha.toFixed(2))})`
+}
+
+export function PropertyBlock({ title, children }: { title?: string; children: ReactNode }) {
+ return (
+
+ {title ? (
+
{title}
+ ) : null}
+ {children}
+
+ )
+}
+
+export function getVisiblePresetGroups(background: BackgroundConfig, isGradient: boolean) {
+ if (background.type === 'solid' || background.type === 'image') return []
+ if (isGradient) {
+ return ALL_BACKGROUND_PRESETS.filter(
+ (group) => group.title === 'Gradients' || group.title === 'Glass',
+ )
+ }
+ if (background.type === 'mesh') {
+ return ALL_BACKGROUND_PRESETS.filter((group) => group.title === 'Mesh')
+ }
+ if (background.type === 'pattern') {
+ return ALL_BACKGROUND_PRESETS.filter((group) => group.title === 'Patterns')
+ }
+ return []
+}
+
+export function PresetGroup({
+ title,
+ presets,
+ onSelect,
+}: {
+ title: string
+ presets: BackgroundPreset[]
+ onSelect: (background: BackgroundConfig) => void
+}) {
+ const [expanded, setExpanded] = useState(false)
+ const hasMore = presets.length > PRESET_PREVIEW_LIMIT
+ const visible = expanded ? presets : presets.slice(0, PRESET_PREVIEW_LIMIT)
+
+ return (
+
+
+
{title}
+ {hasMore && (
+
setExpanded((value) => !value)}
+ className="text-[10px] font-medium text-foreground/70 hover:text-foreground"
+ >
+ {expanded ? 'Less' : `All ${presets.length}`}
+
+ )}
+
+
+ {visible.map((preset) => (
+ onSelect({ ...preset.background })}
+ className="group relative aspect-square overflow-hidden rounded-md border border-border/70 transition hover:border-foreground/25 hover:ring-1 hover:ring-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ style={{
+ backgroundImage: `url(${presetThumb(preset.background)})`,
+ backgroundSize: 'cover',
+ }}
+ >
+
+ {preset.label}
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/panels/properties/DevicePropertiesSection.tsx b/src/components/panels/properties/DevicePropertiesSection.tsx
new file mode 100644
index 0000000..0410ff1
--- /dev/null
+++ b/src/components/panels/properties/DevicePropertiesSection.tsx
@@ -0,0 +1,184 @@
+import { Box, ImagePlus, Smartphone, Sun } from 'lucide-react'
+import { DEVICES, getDevice } from '@/lib/assets/devices'
+import { TILT_PRESETS } from '@/lib/assets/device-presets'
+import { PanelSection } from '@/components/ui/PanelSection'
+import { SliderField } from '@/components/ui/SliderField'
+import { Button } from '@/components/ui/Button'
+import { Hint } from '@/components/panels/properties/shared'
+import type { DeviceElement } from '@/lib/types'
+
+interface DevicePropertiesSectionProps {
+ element: DeviceElement
+ onUpdate: (patch: Partial) => void
+ onUploadScreenshot: (file: File) => void
+}
+
+export function DevicePropertiesSection({
+ element,
+ onUpdate,
+ onUploadScreenshot,
+}: DevicePropertiesSectionProps) {
+ return (
+ <>
+
+
+
+ {element.screenshotAssetId ? 'Replace screenshot' : 'Fill screenshot'}
+ {
+ const file = event.target.files?.[0]
+ if (file) onUploadScreenshot(file)
+ event.target.value = ''
+ }}
+ />
+
+ {element.screenshotAssetId && (
+ onUpdate({ screenshotAssetId: undefined })}
+ >
+ Remove screenshot
+
+ )}
+
+ Upload an app screenshot to fill the device screen, or use the Assets panel and click
+ “Use” on a saved asset.
+
+
+
+
+
+ Model
+ {
+ const deviceId = event.target.value
+ const device = getDevice(deviceId)
+ onUpdate({
+ deviceId,
+ colorVariant: device?.colorVariants[0]?.id,
+ })
+ }}
+ >
+ {DEVICES.map((device) => (
+
+ {device.name}
+
+ ))}
+
+
+
+ Color variant
+ onUpdate({ colorVariant: event.target.value })}
+ >
+ {(getDevice(element.deviceId)?.colorVariants ?? []).map((variant) => (
+
+ {variant.name}
+
+ ))}
+
+
+
+ onUpdate({ showFrame: event.target.checked })}
+ />
+ Show frame
+
+
+ Screenshot fit
+
+ onUpdate({
+ screenshotFit: event.target.value as DeviceElement['screenshotFit'],
+ })
+ }
+ >
+ Cover
+ Contain
+
+
+
+
+
+
+
Presets
+
+ {TILT_PRESETS.map((preset) => (
+
+ onUpdate({
+ tiltX: preset.tiltX,
+ tiltY: preset.tiltY,
+ })
+ }
+ >
+ {preset.label}
+
+ ))}
+
+
+ onUpdate({ tiltX })}
+ />
+ onUpdate({ tiltY })}
+ />
+ onUpdate({ perspective })}
+ />
+ onUpdate({ rotation })}
+ />
+
+
+
+ onUpdate({ shadowIntensity: value / 100 })}
+ />
+ onUpdate({ shadowSpread })}
+ />
+
+ >
+ )
+}
diff --git a/src/components/panels/properties/ElementEffectsSection.tsx b/src/components/panels/properties/ElementEffectsSection.tsx
new file mode 100644
index 0000000..c54c798
--- /dev/null
+++ b/src/components/panels/properties/ElementEffectsSection.tsx
@@ -0,0 +1,55 @@
+import { Sparkles } from 'lucide-react'
+import { PanelSection } from '@/components/ui/PanelSection'
+import { SliderField } from '@/components/ui/SliderField'
+import { ColorPickerField } from '@/components/ui/ColorPickerField'
+import type { Element } from '@/lib/types'
+
+interface ElementEffectsSectionProps {
+ element: Element
+ onUpdate: (patch: Partial) => void
+}
+
+export function ElementEffectsSection({ element, onUpdate }: ElementEffectsSectionProps) {
+ return (
+
+
+
+ onUpdate({
+ shadow: {
+ ...(element.shadow ?? {
+ offsetX: 0,
+ offsetY: 4,
+ blur: 12,
+ color: 'rgba(0,0,0,0.25)',
+ enabled: false,
+ }),
+ enabled: event.target.checked,
+ },
+ })
+ }
+ />
+ Enable shadow
+
+ {element.shadow?.enabled && (
+ <>
+ onUpdate({ shadow: { ...element.shadow!, blur } })}
+ />
+ onUpdate({ shadow: { ...element.shadow!, color } })}
+ />
+ >
+ )}
+
+ )
+}
diff --git a/src/components/panels/properties/ElementTransformSection.tsx b/src/components/panels/properties/ElementTransformSection.tsx
new file mode 100644
index 0000000..b87aae8
--- /dev/null
+++ b/src/components/panels/properties/ElementTransformSection.tsx
@@ -0,0 +1,65 @@
+import { Move } from 'lucide-react'
+import { PanelSection } from '@/components/ui/PanelSection'
+import { Input } from '@/components/ui/Input'
+import { FieldGroup } from '@/components/ui/FieldGroup'
+import { NumberField } from '@/components/ui/NumberField'
+import { SliderField } from '@/components/ui/SliderField'
+import type { Element } from '@/lib/types'
+
+interface ElementTransformSectionProps {
+ element: Element
+ onUpdate: (patch: Partial) => void
+}
+
+export function ElementTransformSection({ element, onUpdate }: ElementTransformSectionProps) {
+ return (
+
+
+ onUpdate({ name: event.target.value })}
+ />
+
+
+ onUpdate({ x })}
+ />
+ onUpdate({ y })}
+ />
+ onUpdate({ width })}
+ />
+ onUpdate({ height })}
+ />
+
+ onUpdate({ rotation })}
+ />
+ onUpdate({ opacity: value / 100 })}
+ />
+
+ )
+}
diff --git a/src/components/panels/properties/ImagePropertiesSection.tsx b/src/components/panels/properties/ImagePropertiesSection.tsx
new file mode 100644
index 0000000..108226c
--- /dev/null
+++ b/src/components/panels/properties/ImagePropertiesSection.tsx
@@ -0,0 +1,161 @@
+import { Box, FlipHorizontal, FlipVertical, Image as ImageIcon, Sun } from 'lucide-react'
+import { PanelSection } from '@/components/ui/PanelSection'
+import { SegmentedControl } from '@/components/ui/SegmentedControl'
+import { SliderField } from '@/components/ui/SliderField'
+import { ColorPickerField } from '@/components/ui/ColorPickerField'
+import { Button } from '@/components/ui/Button'
+import { Hint, OBJECT_FIT_OPTIONS } from '@/components/panels/properties/shared'
+import type { ImageElement } from '@/lib/types'
+
+interface ImagePropertiesSectionProps {
+ element: ImageElement
+ onUpdate: (patch: Partial) => void
+}
+
+export function ImagePropertiesSection({ element, onUpdate }: ImagePropertiesSectionProps) {
+ return (
+ <>
+
+ onUpdate({ objectFit })}
+ />
+
+
Flip
+
+ onUpdate({ flipX: !element.flipX })}
+ >
+ Horizontal
+
+ onUpdate({ flipY: !element.flipY })}
+ >
+ Vertical
+
+
+
+ onUpdate({ cornerRadius })}
+ />
+ onUpdate({ borderWidth })}
+ />
+ onUpdate({ borderColor })}
+ />
+
+
+
+ onUpdate({ brightness })}
+ />
+ onUpdate({ contrast })}
+ />
+ onUpdate({ saturation })}
+ />
+ onUpdate({ blur })}
+ />
+ {(element.brightness || element.contrast || element.saturation || element.blur) && (
+
+ onUpdate({
+ brightness: 0,
+ contrast: 0,
+ saturation: 0,
+ blur: 0,
+ })
+ }
+ >
+ Reset adjustments
+
+ )}
+
+
+
+ Fit the image to its frame, or fine-tune the visible region (0–100%).
+
+ onUpdate({
+ cropX: 0,
+ cropY: 0,
+ cropWidth: 1,
+ cropHeight: 1,
+ })
+ }
+ >
+ Fit frame
+
+ onUpdate({ cropX: value / 100 })}
+ />
+ onUpdate({ cropY: value / 100 })}
+ />
+ onUpdate({ cropWidth: value / 100 })}
+ />
+ onUpdate({ cropHeight: value / 100 })}
+ />
+
+ >
+ )
+}
diff --git a/src/components/panels/properties/MultiSelectionProperties.tsx b/src/components/panels/properties/MultiSelectionProperties.tsx
new file mode 100644
index 0000000..545e750
--- /dev/null
+++ b/src/components/panels/properties/MultiSelectionProperties.tsx
@@ -0,0 +1,63 @@
+import { Move, Palette } from 'lucide-react'
+import { sharedNumber } from '@/lib/elements/selection-fields'
+import { PanelSection } from '@/components/ui/PanelSection'
+import { BackgroundControls } from '@/components/panels/BackgroundControls'
+import { MixedNumberField } from '@/components/ui/NumberField'
+import { SliderField } from '@/components/ui/SliderField'
+import { Hint } from '@/components/panels/properties/shared'
+import type { BackgroundConfig, Element } from '@/lib/types'
+
+interface MultiSelectionPropertiesProps {
+ selectedElements: Element[]
+ screenBackground: BackgroundConfig
+ onUpdateSelected: (patch: Partial) => void
+}
+
+export function MultiSelectionProperties({
+ selectedElements,
+ screenBackground,
+ onUpdateSelected,
+}: MultiSelectionPropertiesProps) {
+ const sharedOpacity = sharedNumber(selectedElements.map((item) => item.opacity))
+ const sharedX = sharedNumber(selectedElements.map((item) => Math.round(item.x)))
+ const sharedY = sharedNumber(selectedElements.map((item) => Math.round(item.y)))
+ const sharedWidth = sharedNumber(selectedElements.map((item) => Math.round(item.width)))
+ const sharedHeight = sharedNumber(selectedElements.map((item) => Math.round(item.height)))
+
+ return (
+
+
+
+ onUpdateSelected({ x })} />
+ onUpdateSelected({ y })} />
+ onUpdateSelected({ width })}
+ />
+ onUpdateSelected({ height })}
+ />
+
+ onUpdateSelected({ opacity: value / 100 })}
+ />
+
+ Changes apply to all {selectedElements.length} selected elements. Alignment and
+ distribution live in the toolbar.
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/panels/properties/ScreenOnlyProperties.tsx b/src/components/panels/properties/ScreenOnlyProperties.tsx
new file mode 100644
index 0000000..a7bc1a5
--- /dev/null
+++ b/src/components/panels/properties/ScreenOnlyProperties.tsx
@@ -0,0 +1,23 @@
+import { Palette } from 'lucide-react'
+import { PanelSection } from '@/components/ui/PanelSection'
+import { BackgroundControls } from '@/components/panels/BackgroundControls'
+import type { BackgroundConfig } from '@/lib/types'
+
+interface ScreenOnlyPropertiesProps {
+ background: BackgroundConfig
+}
+
+export function ScreenOnlyProperties({ background }: ScreenOnlyPropertiesProps) {
+ return (
+
+
+
+
+
+
+ Select a layer on the canvas to edit position, typography, and effects.
+
+
+
+ )
+}
diff --git a/src/components/panels/properties/ShapePropertiesSection.tsx b/src/components/panels/properties/ShapePropertiesSection.tsx
new file mode 100644
index 0000000..c2b5294
--- /dev/null
+++ b/src/components/panels/properties/ShapePropertiesSection.tsx
@@ -0,0 +1,107 @@
+import { Palette } from 'lucide-react'
+import { BRAND_PRIMARY } from '@/lib/constants'
+import { PanelSection } from '@/components/ui/PanelSection'
+import { SegmentedControl } from '@/components/ui/SegmentedControl'
+import { SliderField } from '@/components/ui/SliderField'
+import { ColorPickerField } from '@/components/ui/ColorPickerField'
+import { DEFAULT_GRADIENT } from '@/components/panels/properties/shared'
+import type { FillConfig, GradientFill, ShapeElement } from '@/lib/types'
+
+interface ShapePropertiesSectionProps {
+ element: ShapeElement
+ onUpdate: (patch: Partial) => void
+}
+
+export function ShapePropertiesSection({ element, onUpdate }: ShapePropertiesSectionProps) {
+ const isGradient = element.fill.type === 'gradient'
+ const gradient = element.fill.gradient ?? DEFAULT_GRADIENT
+ const setFill = (fill: FillConfig) => onUpdate({ fill })
+ const patchGradient = (next: Partial) =>
+ setFill({ type: 'gradient', gradient: { ...gradient, ...next } })
+ const setStop = (index: number, color: string) => {
+ const stops = gradient.stops.map((stop) => ({ ...stop }))
+ stops[index] = { offset: index === 0 ? 0 : 1, color }
+ patchGradient({ stops })
+ }
+ const lastStop = gradient.stops[gradient.stops.length - 1]
+
+ return (
+
+
+ type === 'gradient'
+ ? setFill({
+ type: 'gradient',
+ gradient: element.fill.gradient ?? { ...DEFAULT_GRADIENT },
+ })
+ : setFill({ type: 'solid', color: element.fill.color ?? BRAND_PRIMARY })
+ }
+ />
+
+ {isGradient ? (
+ <>
+ patchGradient({ type })}
+ />
+ {gradient.type === 'linear' && (
+ patchGradient({ angle })}
+ />
+ )}
+ setStop(0, color)}
+ />
+ setStop(gradient.stops.length - 1, color)}
+ />
+ >
+ ) : (
+ setFill({ type: 'solid', color })}
+ />
+ )}
+
+ onUpdate({ stroke })}
+ />
+ onUpdate({ strokeWidth })}
+ />
+ onUpdate({ cornerRadius })}
+ />
+
+ )
+}
diff --git a/src/components/panels/properties/TextPropertiesSection.tsx b/src/components/panels/properties/TextPropertiesSection.tsx
new file mode 100644
index 0000000..6b2111d
--- /dev/null
+++ b/src/components/panels/properties/TextPropertiesSection.tsx
@@ -0,0 +1,17 @@
+import { Type } from 'lucide-react'
+import { PanelSection } from '@/components/ui/PanelSection'
+import { TextControls } from '@/components/panels/TextControls'
+import type { TextElement } from '@/lib/types'
+
+interface TextPropertiesSectionProps {
+ element: TextElement
+ onChange: (patch: Partial) => void
+}
+
+export function TextPropertiesSection({ element, onChange }: TextPropertiesSectionProps) {
+ return (
+
+
+
+ )
+}
diff --git a/src/components/panels/properties/shared.tsx b/src/components/panels/properties/shared.tsx
new file mode 100644
index 0000000..a9658a6
--- /dev/null
+++ b/src/components/panels/properties/shared.tsx
@@ -0,0 +1,22 @@
+import type { ReactNode } from 'react'
+import { BRAND_PRIMARY } from '@/lib/constants'
+import type { GradientFill, ImageFit } from '@/lib/types'
+
+export function Hint({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+export const OBJECT_FIT_OPTIONS: Array<{ value: ImageFit; label: string }> = [
+ { value: 'cover', label: 'Cover' },
+ { value: 'contain', label: 'Contain' },
+ { value: 'fill', label: 'Fill' },
+]
+
+export const DEFAULT_GRADIENT: GradientFill = {
+ type: 'linear',
+ angle: 90,
+ stops: [
+ { offset: 0, color: BRAND_PRIMARY },
+ { offset: 1, color: '#0f172a' },
+ ],
+}
diff --git a/src/components/dashboard/ProjectThumbnail.tsx b/src/components/templates/ProjectThumbnail.tsx
similarity index 69%
rename from src/components/dashboard/ProjectThumbnail.tsx
rename to src/components/templates/ProjectThumbnail.tsx
index b82fe08..12b9825 100644
--- a/src/components/dashboard/ProjectThumbnail.tsx
+++ b/src/components/templates/ProjectThumbnail.tsx
@@ -1,25 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
+import { screenContentSignature } from '@/lib/canvas/perf/content-signature'
import { renderScreenToDataUrl } from '@/lib/export/renderer'
+import { TemplatePreviewFrame } from '@/components/templates/TemplatePreviewFrame'
import type { Screen } from '@/lib/types'
const thumbnailCache = new Map()
-function getContentSignature(
- screen: Screen,
- assetResolver: (assetId?: string) => string | undefined,
-): string {
- const assetIds: string[] = []
- if (screen.background.imageAssetId) assetIds.push(screen.background.imageAssetId)
- for (const element of screen.elements) {
- if (element.type === 'image' && element.assetId) assetIds.push(element.assetId)
- if (element.type === 'device' && element.screenshotAssetId) {
- assetIds.push(element.screenshotAssetId)
- }
- }
- const assetSignature = assetIds.map((id) => `${id}=${assetResolver(id) ?? ''}`).join('|')
- return `${JSON.stringify(screen)}::${assetSignature}`
-}
-
interface ProjectThumbnailProps {
screen: Screen
assetResolver: (assetId?: string) => string | undefined
@@ -28,7 +14,7 @@ interface ProjectThumbnailProps {
export function ProjectThumbnail({ screen, assetResolver, className }: ProjectThumbnailProps) {
const signature = useMemo(
- () => getContentSignature(screen, assetResolver),
+ () => screenContentSignature(screen, assetResolver),
[screen, assetResolver],
)
const cacheKey = `${screen.id}::${signature}`
@@ -53,9 +39,9 @@ export function ProjectThumbnail({ screen, assetResolver, className }: ProjectTh
}, [cacheKey, screen, assetResolver])
return (
-
{dataUrl ? (
)}
-
+
)
}
diff --git a/src/components/templates/TemplateCard.tsx b/src/components/templates/TemplateCard.tsx
new file mode 100644
index 0000000..e5e21e0
--- /dev/null
+++ b/src/components/templates/TemplateCard.tsx
@@ -0,0 +1,100 @@
+import { ArrowRight } from 'lucide-react'
+import { TemplateThumbnail } from '@/components/templates/TemplateThumbnail'
+import { LAYOUT_LABELS } from '@/lib/templates'
+import type { TemplateDefinition } from '@/lib/types'
+import { cn } from '@/lib/utils'
+
+export type TemplateCardVariant = 'landing' | 'grid' | 'panel'
+
+interface TemplateCardProps {
+ template: TemplateDefinition
+ variant: TemplateCardVariant
+ onClick: () => void
+}
+
+const THUMBNAIL_CLASS = 'aspect-[9/19.5] w-full'
+
+export function TemplateCard({ template, variant, onClick }: TemplateCardProps) {
+ if (variant === 'landing') {
+ return (
+
+
+
+
{template.name}
+
+ {LAYOUT_LABELS[template.layout]}
+
+
+ Use template
+
+
+
+
+ )
+ }
+
+ if (variant === 'grid') {
+ return (
+
+
+
+
+
+
{template.name}
+
+ {LAYOUT_LABELS[template.layout]}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ Use template
+
+
+
+
+
{template.name}
+
+ {LAYOUT_LABELS[template.layout]}
+
+ {template.tags && template.tags.length > 0 && (
+
+ {template.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/templates/TemplatePreviewFrame.tsx b/src/components/templates/TemplatePreviewFrame.tsx
new file mode 100644
index 0000000..1243e15
--- /dev/null
+++ b/src/components/templates/TemplatePreviewFrame.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react'
+import { cn } from '@/lib/utils'
+
+interface TemplatePreviewFrameProps {
+ aspectRatio: string
+ className?: string
+ children: ReactNode
+}
+
+/** Shared aspect-ratio wrapper for template and project thumbnails. */
+export function TemplatePreviewFrame({
+ aspectRatio,
+ className,
+ children,
+}: TemplatePreviewFrameProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/templates/TemplateThumbnail.tsx b/src/components/templates/TemplateThumbnail.tsx
index 9cfe400..9d25f48 100644
--- a/src/components/templates/TemplateThumbnail.tsx
+++ b/src/components/templates/TemplateThumbnail.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
+import { TemplatePreviewFrame } from '@/components/templates/TemplatePreviewFrame'
import { renderTemplatePreview } from '@/lib/templates/preview'
import type { TemplateDefinition } from '@/lib/types'
@@ -32,10 +33,8 @@ export function TemplateThumbnail({ template, className }: TemplateThumbnailProp
}, [template, previewUrl])
return (
-
+
+
{previewUrl ? (
)}
-
+
+
)
}
diff --git a/src/components/toolbar/EditorToolbar.tsx b/src/components/toolbar/EditorToolbar.tsx
index 8e099dd..f5d6ab6 100644
--- a/src/components/toolbar/EditorToolbar.tsx
+++ b/src/components/toolbar/EditorToolbar.tsx
@@ -30,9 +30,9 @@ import {
Undo2,
} from 'lucide-react'
import { DEVICES } from '@/lib/assets/devices'
+import { useHistoryNavigation } from '@/hooks/useHistoryNavigation'
import { APP_NAME } from '@/lib/constants'
import { useProjectStore } from '@/stores/project-store'
-import { useHistoryStore } from '@/stores/history-store'
import { useEditorStore } from '@/stores/editor-store'
import { ExportDialog } from '@/components/toolbar/ExportDialog'
import { SettingsDialog } from '@/components/settings/SettingsDialog'
@@ -107,11 +107,7 @@ export function EditorToolbar() {
const distributeElements = useProjectStore((state) => state.distributeElements)
const bringForward = useProjectStore((state) => state.bringForward)
const sendBackward = useProjectStore((state) => state.sendBackward)
- const restoreFromHistory = useProjectStore((state) => state.restoreFromHistory)
- const canUndo = useHistoryStore((state) => state.canUndo)
- const canRedo = useHistoryStore((state) => state.canRedo)
- const undo = useHistoryStore((state) => state.undo)
- const redo = useHistoryStore((state) => state.redo)
+ const { canUndo, canRedo, undo, redo } = useHistoryNavigation()
const selectedElementIds = useEditorStore((state) => state.selectedElementIds)
const viewMode = useEditorStore((state) => state.viewMode)
const setViewMode = useEditorStore((state) => state.setViewMode)
@@ -167,19 +163,13 @@ export function EditorToolbar() {
icon={Undo2}
label="Undo"
disabled={!canUndo}
- onClick={() => {
- const restored = undo()
- if (restored) restoreFromHistory(restored)
- }}
+ onClick={undo}
/>
{
- const restored = redo()
- if (restored) restoreFromHistory(restored)
- }}
+ onClick={redo}
/>
setSettingsOpen(true)} />
void
@@ -34,50 +34,26 @@ export function ExportDialog({ open, onOpenChange }: ExportDialogProps) {
const updateExport = useSettingsStore((state) => state.updateExport)
const konvaStageBridge = useEditorStore((state) => state.konvaStageBridge)
- const [tab, setTab] = useState('quick')
- const [format, setFormat] = useState(exportPrefs.defaultFormat)
- const [scale, setScale] = useState<1 | 2 | 3>(exportPrefs.defaultScale)
- const [transparentBackground, setTransparentBackground] = useState(
- exportPrefs.transparentBackground,
- )
- const [jpegQuality, setJpegQuality] = useState(exportPrefs.jpegQuality)
- const [resizeStrategy, setResizeStrategy] = useState(
- exportPrefs.resizeStrategy ?? 'fit',
- )
- const [currentTarget, setCurrentTarget] = useState('design')
- const [selectedPresets, setSelectedPresets] = useState(
- exportPrefs.lastUsedPresets.length > 0
- ? exportPrefs.lastUsedPresets
- : getSmartExportPresetIds(),
- )
- const [selectedScreenIds, setSelectedScreenIds] = useState([])
- const [organizeByPlatform, setOrganizeByPlatform] = useState(true)
- const [platformAware, setPlatformAware] = useState(true)
- const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null)
- const [busy, setBusy] = useState(false)
- const [previewUrl, setPreviewUrl] = useState(null)
- const [previewLoading, setPreviewLoading] = useState(false)
- const [gridPreviews, setGridPreviews] = useState>(
- [],
- )
+ const defaultPresets = useMemo(() => getSmartExportPresetIds(), [])
+ const [form, dispatch] = useExportFormReducer(exportPrefs, defaultPresets)
useEffect(() => {
if (open && project) {
- setSelectedScreenIds(project.screens.map((s) => s.id))
+ dispatch({ type: 'init_screens', screenIds: project.screens.map((s) => s.id) })
}
- }, [open, project])
+ }, [open, project, dispatch])
const assetResolver = (assetId?: string) => (assetId ? assetUrls[assetId] : undefined)
const targetPreset = useMemo(
- () => STORE_PRESETS.find((preset) => preset.id === currentTarget),
- [currentTarget],
+ () => STORE_PRESETS.find((preset) => preset.id === form.currentTarget),
+ [form.currentTarget],
)
const exportScreens = useMemo(() => {
if (!project) return []
- return project.screens.filter((s) => selectedScreenIds.includes(s.id))
- }, [project, selectedScreenIds])
+ return project.screens.filter((s) => form.selectedScreenIds.includes(s.id))
+ }, [project, form.selectedScreenIds])
const quickPlan = useMemo(
() => buildExportPlan(exportScreens, getSmartExportPresetIds(), { platformAware: true }),
@@ -86,8 +62,8 @@ export function ExportDialog({ open, onOpenChange }: ExportDialogProps) {
const quickSummary = useMemo(() => summarizeExportPlan(quickPlan), [quickPlan])
const advancedPlan = useMemo(
- () => buildExportPlan(exportScreens, selectedPresets, { platformAware }),
- [exportScreens, selectedPresets, platformAware],
+ () => buildExportPlan(exportScreens, form.selectedPresets, { platformAware: form.platformAware }),
+ [exportScreens, form.selectedPresets, form.platformAware],
)
const advancedSummary = useMemo(() => summarizeExportPlan(advancedPlan), [advancedPlan])
@@ -96,12 +72,12 @@ export function ExportDialog({ open, onOpenChange }: ExportDialogProps) {
if (targetPreset) {
return { width: targetPreset.width, height: targetPreset.height }
}
- return { width: screen.width * scale, height: screen.height * scale }
- }, [screen, targetPreset, scale])
+ return { width: screen.width * form.scale, height: screen.height * form.scale }
+ }, [screen, targetPreset, form.scale])
const filenamePreview = useMemo(() => {
if (!project || !screen) return ''
- const extension = format === 'jpeg' ? 'jpg' : 'png'
+ const extension = form.format === 'jpeg' ? 'jpg' : 'png'
if (targetPreset) {
const name = applyFileNamePattern(exportPrefs.fileNamePattern, {
project: project.name,
@@ -111,107 +87,30 @@ export function ExportDialog({ open, onOpenChange }: ExportDialogProps) {
return `${name}.${extension}`
}
return `${screen.name}.${extension}`
- }, [project, screen, targetPreset, format, exportPrefs.fileNamePattern])
-
- useEffect(() => {
- if (!open || !screen) return
- let cancelled = false
- const timer = setTimeout(async () => {
- setPreviewLoading(true)
- try {
- const source = targetPreset
- ? (() => {
- const resized = autoResizeScreen(screen, targetPreset.width, targetPreset.height, {
- strategy: resizeStrategy,
- })
- return {
- ...screen,
- width: resized.width,
- height: resized.height,
- background: resized.background,
- elements: resized.elements,
- }
- })()
- : screen
- const url = await renderScreenToDataUrl({
- screen: source,
- assetResolver,
- scale: targetPreset ? 1 : scale,
- format,
- jpegQuality,
- transparentBackground,
- })
- if (!cancelled) setPreviewUrl(url)
- } catch {
- if (!cancelled) setPreviewUrl(null)
- } finally {
- if (!cancelled) setPreviewLoading(false)
- }
- }, 200)
- return () => {
- cancelled = true
- clearTimeout(timer)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, screen, targetPreset, format, jpegQuality, transparentBackground, scale, assetUrls, resizeStrategy])
-
- useEffect(() => {
- if (!open || tab !== 'quick' || exportScreens.length === 0) {
- setGridPreviews([])
- return
- }
-
- let cancelled = false
- const timer = setTimeout(async () => {
- const previews: Array<{ id: string; label: string; url: string }> = []
- const items = quickPlan.slice(0, 6)
-
- for (const { screen: itemScreen, preset } of items) {
- try {
- const resized = autoResizeScreen(itemScreen, preset.width, preset.height, {
- strategy: resizeStrategy,
- })
- const url = await renderScreenToDataUrl({
- screen: {
- ...itemScreen,
- width: resized.width,
- height: resized.height,
- background: resized.background,
- elements: resized.elements,
- },
- assetResolver,
- scale: 1,
- format: 'png',
- jpegQuality,
- transparentBackground: false,
- })
- previews.push({
- id: `${itemScreen.id}-${preset.id}`,
- label: `${itemScreen.name} · ${preset.name}`,
- url,
- })
- } catch {
- // skip failed preview
- }
- }
-
- if (!cancelled) setGridPreviews(previews)
- }, 300)
-
- return () => {
- cancelled = true
- clearTimeout(timer)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, tab, exportScreens, quickPlan, assetUrls, resizeStrategy, jpegQuality])
+ }, [project, screen, targetPreset, form.format, exportPrefs.fileNamePattern])
+
+ const { previewUrl, previewLoading, gridPreviews } = useExportPreview({
+ open,
+ tab: form.tab,
+ screen: screen ?? undefined,
+ targetPreset,
+ format: form.format,
+ scale: form.scale,
+ jpegQuality: form.jpegQuality,
+ transparentBackground: form.transparentBackground,
+ resizeStrategy: form.resizeStrategy,
+ assetResolver,
+ exportScreens,
+ quickPlan,
+ })
const persistPrefs = () =>
updateExport({
- defaultFormat: format,
- defaultScale: scale,
- transparentBackground,
- jpegQuality,
- resizeStrategy,
+ defaultFormat: form.format,
+ defaultScale: form.scale,
+ transparentBackground: form.transparentBackground,
+ jpegQuality: form.jpegQuality,
+ resizeStrategy: form.resizeStrategy,
})
const markExportComplete = () => {
@@ -221,76 +120,72 @@ export function ExportDialog({ open, onOpenChange }: ExportDialogProps) {
const exportCurrent = async () => {
if (!project || !screen) return
- setBusy(true)
+ dispatch({ type: 'set_busy', busy: true })
try {
const blob = await exportSingleScreen(screen, {
project,
- format,
- scale,
- jpegQuality,
- transparentBackground,
+ format: form.format,
+ scale: form.scale,
+ jpegQuality: form.jpegQuality,
+ transparentBackground: form.transparentBackground,
fileNamePattern: exportPrefs.fileNamePattern,
assetResolver,
preset: targetPreset,
- resizeStrategy,
+ resizeStrategy: form.resizeStrategy,
konvaStageBridge,
})
- const extension = format === 'jpeg' ? 'jpg' : 'png'
+ const extension = form.format === 'jpeg' ? 'jpg' : 'png'
downloadBlob(blob, filenamePreview || `${screen.name}.${extension}`)
await persistPrefs()
markExportComplete()
toast('Screenshot exported', 'success')
} finally {
- setBusy(false)
+ dispatch({ type: 'set_busy', busy: false })
}
}
- const exportZip = async (presetIds: string[], usePlatformAware = platformAware) => {
+ const exportZip = async (presetIds: string[], usePlatformAware = form.platformAware) => {
if (!project || exportScreens.length === 0) return
- setBusy(true)
+ dispatch({ type: 'set_busy', busy: true })
const presetList = STORE_PRESETS.filter((preset) => presetIds.includes(preset.id))
const plan = buildExportPlan(exportScreens, presetIds, { platformAware: usePlatformAware })
- setProgress({ completed: 0, total: plan.length })
+ dispatch({ type: 'set_progress', progress: { completed: 0, total: plan.length } })
try {
const blob = await exportScreensAsZip({
project,
screens: exportScreens,
presets: presetList,
- format,
- scale,
- jpegQuality,
- transparentBackground,
+ format: form.format,
+ scale: form.scale,
+ jpegQuality: form.jpegQuality,
+ transparentBackground: form.transparentBackground,
fileNamePattern: exportPrefs.fileNamePattern,
assetResolver,
- organizeByPlatform,
+ organizeByPlatform: form.organizeByPlatform,
platformAware: usePlatformAware,
- resizeStrategy,
- onProgress: (completed, total) => setProgress({ completed, total }),
+ resizeStrategy: form.resizeStrategy,
+ onProgress: (completed, total) =>
+ dispatch({ type: 'set_progress', progress: { completed, total } }),
})
downloadBlob(blob, `${project.name}-export.zip`)
await updateExport({
lastUsedPresets: presetIds,
- defaultFormat: format,
- defaultScale: scale,
- transparentBackground,
- jpegQuality,
- resizeStrategy,
+ defaultFormat: form.format,
+ defaultScale: form.scale,
+ transparentBackground: form.transparentBackground,
+ jpegQuality: form.jpegQuality,
+ resizeStrategy: form.resizeStrategy,
})
markExportComplete()
toast('ZIP export complete', 'success')
} finally {
- setBusy(false)
- setProgress(null)
+ dispatch({ type: 'set_busy', busy: false })
+ dispatch({ type: 'set_progress', progress: null })
}
}
const quickExport = () => exportZip(getSmartExportPresetIds(), true)
- const advancedExport = () => exportZip(selectedPresets, platformAware)
-
- const toggleAllScreens = (checked: boolean) => {
- if (!project) return
- setSelectedScreenIds(checked ? project.screens.map((s) => s.id) : [])
- }
+ const advancedExport = () => exportZip(form.selectedPresets, form.platformAware)
return (
@@ -307,10 +202,10 @@ export function ExportDialog({ open, onOpenChange }: ExportDialogProps) {
setTab(value)}
+ onClick={() => dispatch({ type: 'set_tab', tab: value })}
className={cn(
'flex-1 rounded-md px-3 py-1.5 text-sm font-medium capitalize transition',
- tab === value
+ form.tab === value
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
@@ -320,278 +215,77 @@ export function ExportDialog({ open, onOpenChange }: ExportDialogProps) {
))}
- {tab === 'quick' ? (
-
-
-
Store-ready ZIP
-
- Exports {exportScreens.length} screen{exportScreens.length === 1 ? '' : 's'} with
- platform-matched presets — iOS screens to Apple sizes, Android screens to Android
- sizes.
-
-
- {quickSummary.presets.map((preset) => (
-
- {preset.name}: {preset.width}×{preset.height}px
-
- ))}
-
-
- {quickSummary.totalFiles} {' '}
- files
- {quickSummary.appleFiles > 0 && (
-
- {' '}
- · {quickSummary.appleFiles} iOS
-
- )}
- {quickSummary.androidFiles > 0 && (
-
- {' '}
- · {quickSummary.androidFiles} Android
-
- )}
-
-
-
- {gridPreviews.length > 0 && (
-
-
Preview
-
- {gridPreviews.map((item) => (
-
-
-
- {item.label}
-
-
- ))}
-
-
- )}
-
-
- setOrganizeByPlatform(event.target.checked)}
- />
- Organize ZIP into ios/ and android/ folders
-
-
+ {form.tab === 'quick' ? (
+
+ dispatch({ type: 'set_organize_by_platform', value })
+ }
+ />
) : (
-
-
-
-
-
Output preview
-
- Current screen target
- setCurrentTarget(event.target.value)}
- >
- Design size ({scale}x)
- {STORE_PRESETS.map((preset) => (
-
- {preset.name}
-
- ))}
-
-
-
-
- {previewLoading ? (
-
Rendering…
- ) : previewUrl ? (
-
- ) : (
-
No preview
- )}
-
-
- {currentOutput && (
-
- Output:{' '}
-
- {currentOutput.width}×{currentOutput.height}px
-
-
- )}
-
- {advancedSummary.totalFiles > 0 && (
-
- ZIP: {advancedSummary.totalFiles} file
- {advancedSummary.totalFiles === 1 ? '' : 's'}
-
- )}
-
-
+ dispatch({ type: 'set_format', format })}
+ onScaleChange={(scale) => dispatch({ type: 'set_scale', scale })}
+ onJpegQualityChange={(value) => dispatch({ type: 'set_jpeg_quality', value })}
+ onTransparentBackgroundChange={(value) =>
+ dispatch({ type: 'set_transparent_background', value })
+ }
+ onResizeStrategyChange={(value) =>
+ dispatch({ type: 'set_resize_strategy', value })
+ }
+ onCurrentTargetChange={(value) => dispatch({ type: 'set_current_target', value })}
+ onToggleAllScreens={(checked) =>
+ dispatch({
+ type: 'toggle_all_screens',
+ checked,
+ allIds: project?.screens.map((s) => s.id) ?? [],
+ })
+ }
+ onToggleScreen={(screenId, checked) =>
+ dispatch({ type: 'toggle_screen', screenId, checked })
+ }
+ onTogglePreset={(presetId, checked) =>
+ dispatch({ type: 'toggle_preset', presetId, checked })
+ }
+ onOrganizeByPlatformChange={(value) =>
+ dispatch({ type: 'set_organize_by_platform', value })
+ }
+ onPlatformAwareChange={(value) =>
+ dispatch({ type: 'set_platform_aware', value })
+ }
+ />
)}
- {progress && (
+ {form.progress && (
- Exporting {progress.completed} / {progress.total}
+ Exporting {form.progress.completed} / {form.progress.total}
@@ -601,21 +295,25 @@ export function ExportDialog({ open, onOpenChange }: ExportDialogProps) {
onOpenChange(false)}>
Cancel
- {tab === 'quick' ? (
+ {form.tab === 'quick' ? (
void quickExport()}
>
Export store ZIP
) : (
<>
- void exportCurrent()}>
+ void exportCurrent()}
+ >
Export current
void advancedExport()}
>
diff --git a/src/components/toolbar/export/ExportAdvancedTab.tsx b/src/components/toolbar/export/ExportAdvancedTab.tsx
new file mode 100644
index 0000000..51f24b9
--- /dev/null
+++ b/src/components/toolbar/export/ExportAdvancedTab.tsx
@@ -0,0 +1,249 @@
+import { STORE_PRESETS } from '@/lib/presets/store-sizes'
+import type { ExportPlanSummary } from '@/lib/export/export-plan'
+import type { ExportFormat, Project, ResizeStrategy, Screen } from '@/lib/types'
+import type { StorePreset } from '@/lib/types'
+
+interface ExportAdvancedTabProps {
+ project: Project | null
+ screen: Screen | undefined
+ format: ExportFormat
+ scale: 1 | 2 | 3
+ jpegQuality: number
+ transparentBackground: boolean
+ resizeStrategy: ResizeStrategy
+ currentTarget: string
+ selectedScreenIds: string[]
+ selectedPresets: string[]
+ organizeByPlatform: boolean
+ platformAware: boolean
+ previewUrl: string | null
+ previewLoading: boolean
+ currentOutput: { width: number; height: number } | null
+ advancedSummary: ExportPlanSummary
+ onFormatChange: (format: ExportFormat) => void
+ onScaleChange: (scale: 1 | 2 | 3) => void
+ onJpegQualityChange: (value: number) => void
+ onTransparentBackgroundChange: (value: boolean) => void
+ onResizeStrategyChange: (value: ResizeStrategy) => void
+ onCurrentTargetChange: (value: string) => void
+ onToggleAllScreens: (checked: boolean) => void
+ onToggleScreen: (screenId: string, checked: boolean) => void
+ onTogglePreset: (presetId: string, checked: boolean) => void
+ onOrganizeByPlatformChange: (value: boolean) => void
+ onPlatformAwareChange: (value: boolean) => void
+}
+
+export function ExportAdvancedTab({
+ project,
+ screen,
+ format,
+ scale,
+ jpegQuality,
+ transparentBackground,
+ resizeStrategy,
+ currentTarget,
+ selectedScreenIds,
+ selectedPresets,
+ organizeByPlatform,
+ platformAware,
+ previewUrl,
+ previewLoading,
+ currentOutput,
+ advancedSummary,
+ onFormatChange,
+ onScaleChange,
+ onJpegQualityChange,
+ onTransparentBackgroundChange,
+ onResizeStrategyChange,
+ onCurrentTargetChange,
+ onToggleAllScreens,
+ onToggleScreen,
+ onTogglePreset,
+ onOrganizeByPlatformChange,
+ onPlatformAwareChange,
+}: ExportAdvancedTabProps) {
+ return (
+
+
+
+
+
Output preview
+
+ Current screen target
+ onCurrentTargetChange(event.target.value)}
+ >
+ Design size ({scale}x)
+ {STORE_PRESETS.map((preset: StorePreset) => (
+
+ {preset.name}
+
+ ))}
+
+
+
+
+ {previewLoading ? (
+
Rendering…
+ ) : previewUrl ? (
+
+ ) : (
+
No preview
+ )}
+
+
+ {currentOutput && (
+
+ Output:{' '}
+
+ {currentOutput.width}×{currentOutput.height}px
+
+
+ )}
+
+ {advancedSummary.totalFiles > 0 && (
+
+ ZIP: {advancedSummary.totalFiles} file
+ {advancedSummary.totalFiles === 1 ? '' : 's'}
+
+ )}
+
+ {!screen && (
+
Select a screen to preview export output.
+ )}
+
+
+ )
+}
diff --git a/src/components/toolbar/export/ExportQuickTab.tsx b/src/components/toolbar/export/ExportQuickTab.tsx
new file mode 100644
index 0000000..519c683
--- /dev/null
+++ b/src/components/toolbar/export/ExportQuickTab.tsx
@@ -0,0 +1,78 @@
+import type { ExportPlanSummary } from '@/lib/export/export-plan'
+import type { ExportGridPreview } from '@/hooks/useExportPreview'
+
+interface ExportQuickTabProps {
+ exportScreenCount: number
+ quickSummary: ExportPlanSummary
+ gridPreviews: ExportGridPreview[]
+ organizeByPlatform: boolean
+ onOrganizeByPlatformChange: (value: boolean) => void
+}
+
+export function ExportQuickTab({
+ exportScreenCount,
+ quickSummary,
+ gridPreviews,
+ organizeByPlatform,
+ onOrganizeByPlatformChange,
+}: ExportQuickTabProps) {
+ return (
+
+
+
Store-ready ZIP
+
+ Exports {exportScreenCount} screen{exportScreenCount === 1 ? '' : 's'} with platform-matched
+ presets — iOS screens to Apple sizes, Android screens to Android sizes.
+
+
+ {quickSummary.presets.map((preset) => (
+
+ {preset.name}: {preset.width}×{preset.height}px
+
+ ))}
+
+
+ {quickSummary.totalFiles} files
+ {quickSummary.appleFiles > 0 && (
+ · {quickSummary.appleFiles} iOS
+ )}
+ {quickSummary.androidFiles > 0 && (
+ · {quickSummary.androidFiles} Android
+ )}
+
+
+
+ {gridPreviews.length > 0 && (
+
+
Preview
+
+ {gridPreviews.map((item) => (
+
+
+
+ {item.label}
+
+
+ ))}
+
+
+ )}
+
+
+ onOrganizeByPlatformChange(event.target.checked)}
+ />
+ Organize ZIP into ios/ and android/ folders
+
+
+ )
+}
diff --git a/src/components/ui/FloatingMenu.tsx b/src/components/ui/FloatingMenu.tsx
new file mode 100644
index 0000000..53b19eb
--- /dev/null
+++ b/src/components/ui/FloatingMenu.tsx
@@ -0,0 +1,82 @@
+import type { ReactNode } from 'react'
+import { useFloatingMenu } from '@/hooks/useFloatingMenu'
+import { cn } from '@/lib/utils'
+
+export type FloatingMenuMarker = 'screen-context-menu' | 'layer-context-menu'
+
+interface FloatingMenuProps {
+ x: number
+ y: number
+ onClose: () => void
+ 'aria-label': string
+ marker?: FloatingMenuMarker
+ children: ReactNode
+}
+
+export function FloatingMenu({
+ x,
+ y,
+ onClose,
+ 'aria-label': ariaLabel,
+ marker,
+ children,
+}: FloatingMenuProps) {
+ const menuRef = useFloatingMenu(x, y, onClose)
+ const markerProps =
+ marker === 'screen-context-menu'
+ ? { 'data-screen-context-menu': true }
+ : marker === 'layer-context-menu'
+ ? { 'data-layer-context-menu': true }
+ : {}
+
+ return (
+ event.stopPropagation()}
+ >
+ {children}
+
+ )
+}
+
+interface ContextMenuItemProps {
+ icon?: ReactNode
+ children: ReactNode
+ disabled?: boolean
+ destructive?: boolean
+ onClick: () => void
+}
+
+export function ContextMenuItem({
+ icon,
+ children,
+ disabled,
+ destructive,
+ onClick,
+}: ContextMenuItemProps) {
+ return (
+
+ {icon}
+ {children}
+
+ )
+}
+
+export function ContextMenuSeparator() {
+ return
+}
diff --git a/src/components/ui/SegmentedControl.test.tsx b/src/components/ui/SegmentedControl.test.tsx
new file mode 100644
index 0000000..95d8057
--- /dev/null
+++ b/src/components/ui/SegmentedControl.test.tsx
@@ -0,0 +1,23 @@
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { SegmentedControl } from '@/components/ui/SegmentedControl'
+
+describe('SegmentedControl', () => {
+ it('calls onChange when an option is selected', () => {
+ const onChange = vi.fn()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('tab', { name: 'B' }))
+ expect(onChange).toHaveBeenCalledWith('b')
+ })
+})
diff --git a/src/components/ui/SegmentedControl.tsx b/src/components/ui/SegmentedControl.tsx
new file mode 100644
index 0000000..a2e73ea
--- /dev/null
+++ b/src/components/ui/SegmentedControl.tsx
@@ -0,0 +1,66 @@
+import { cn } from '@/lib/utils'
+
+export interface SegmentedControlOption {
+ value: T
+ label: string
+}
+
+interface SegmentedControlProps {
+ options: Array>
+ value: T
+ onChange: (value: T) => void
+ label?: string
+ ariaLabel?: string
+ className?: string
+}
+
+export function SegmentedControl({
+ options,
+ value,
+ onChange,
+ label,
+ ariaLabel,
+ className,
+}: SegmentedControlProps) {
+ const control = (
+
+ {options.map((option) => {
+ const active = value === option.value
+ return (
+ onChange(option.value)}
+ className={cn(
+ 'rounded-[5px] py-1.5 text-[11px] font-medium capitalize transition',
+ active
+ ? 'bg-card text-foreground shadow-sm'
+ : 'text-muted-foreground hover:text-foreground',
+ )}
+ >
+ {option.label}
+
+ )
+ })}
+
+ )
+
+ if (!label) return control
+
+ return (
+
+ {label}
+ {control}
+
+ )
+}
diff --git a/src/hooks/useCanvasDrop.ts b/src/hooks/useCanvasDrop.ts
new file mode 100644
index 0000000..c56cc34
--- /dev/null
+++ b/src/hooks/useCanvasDrop.ts
@@ -0,0 +1,89 @@
+import { useCallback } from 'react'
+import { findScreenAtPoint } from '@/lib/canvas/workspace-layout'
+import { workspaceToScreenLocal } from '@/lib/canvas/coordinates'
+import { createImageElement } from '@/lib/factories'
+import { usePersistAssetUpload } from '@/hooks/usePersistAssetUpload'
+import { useEditorStore } from '@/stores/editor-store'
+import { useProjectStore } from '@/stores/project-store'
+import type { Screen } from '@/lib/types'
+
+interface UseCanvasDropOptions {
+ screens: Screen[]
+ clientToWorkspace: (clientX: number, clientY: number) => { x: number; y: number } | null
+}
+
+export function useCanvasDrop({ screens, clientToWorkspace }: UseCanvasDropOptions) {
+ const screenLayout = useEditorStore((state) => state.screenLayout)
+ const setActiveScreenId = useEditorStore((state) => state.setActiveScreenId)
+ const uploadAsset = usePersistAssetUpload()
+ const project = useProjectStore((state) => state.project)
+ const updateElement = useProjectStore((state) => state.updateElement)
+ const addElement = useProjectStore((state) => state.addElement)
+
+ const handleDrop = useCallback(
+ async (event: React.DragEvent) => {
+ event.preventDefault()
+ const point = clientToWorkspace(event.clientX, event.clientY)
+ if (!point || !project) return
+ const targetScreen = findScreenAtPoint(point, screens, screenLayout)
+ if (!targetScreen) return
+
+ setActiveScreenId(targetScreen.id)
+
+ const files = Array.from(event.dataTransfer.files).filter((file) =>
+ file.type.startsWith('image/'),
+ )
+ if (files.length === 0) return
+
+ const localPoint = workspaceToScreenLocal(point, screenLayout, targetScreen.id)
+
+ const targetDevice = [...targetScreen.elements]
+ .filter(
+ (item) =>
+ item.type === 'device' &&
+ localPoint.x >= item.x &&
+ localPoint.x <= item.x + item.width &&
+ localPoint.y >= item.y &&
+ localPoint.y <= item.y + item.height,
+ )
+ .sort((a, b) => b.zIndex - a.zIndex)[0]
+
+ for (const file of files) {
+ const asset = await uploadAsset(file, 'screenshot')
+
+ if (targetDevice) {
+ updateElement(targetDevice.id, { screenshotAssetId: asset.id })
+ break
+ }
+
+ addElement(
+ createImageElement({
+ assetId: asset.id,
+ name: file.name,
+ x: localPoint.x - 200,
+ y: localPoint.y - 200,
+ }),
+ )
+ }
+ },
+ [
+ addElement,
+ clientToWorkspace,
+ project,
+ screenLayout,
+ screens,
+ setActiveScreenId,
+ updateElement,
+ uploadAsset,
+ ],
+ )
+
+ const handleDragOver = useCallback((event: React.DragEvent) => {
+ if (Array.from(event.dataTransfer.types).includes('Files')) {
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'copy'
+ }
+ }, [])
+
+ return { handleDrop, handleDragOver }
+}
diff --git a/src/hooks/useCanvasSelection.ts b/src/hooks/useCanvasSelection.ts
new file mode 100644
index 0000000..8f95969
--- /dev/null
+++ b/src/hooks/useCanvasSelection.ts
@@ -0,0 +1,193 @@
+import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react'
+import type Konva from 'konva'
+import { getDevice } from '@/lib/assets/devices'
+import { rectsIntersect } from '@/lib/canvas/helpers'
+import { useEditorStore } from '@/stores/editor-store'
+import type { InteractionOverlayHandle } from '@/components/canvas/InteractionOverlay'
+import type { Screen } from '@/lib/types'
+
+interface UseCanvasSelectionOptions {
+ screens: Screen[]
+ stageRef: RefObject
+ transformerRef: RefObject
+ overlayRef: RefObject
+ scheduleDraw: () => void
+}
+
+export function useCanvasSelection({
+ screens,
+ stageRef,
+ transformerRef,
+ overlayRef,
+ scheduleDraw,
+}: UseCanvasSelectionOptions) {
+ const marqueeStartRef = useRef<{
+ screenId: string
+ x: number
+ y: number
+ additive: boolean
+ } | null>(null)
+
+ const activeScreenId = useEditorStore((state) => state.activeScreenId)
+ const selectedElementIds = useEditorStore((state) => state.selectedElementIds)
+ const setSelectedElementIds = useEditorStore((state) => state.setSelectedElementIds)
+ const setActiveScreenId = useEditorStore((state) => state.setActiveScreenId)
+ const clearSelection = useEditorStore((state) => state.clearSelection)
+ const workspaceZoom = useEditorStore((state) => state.workspaceZoom)
+ const isPanning = useEditorStore((state) => state.isPanning)
+ const isSpacePressed = useEditorStore((state) => state.isSpacePressed)
+
+ const activeScreen = screens.find((screen) => screen.id === activeScreenId)
+
+ const setActiveScreen = useCallback(
+ (screenId: string, options?: { clearSelection?: boolean }) => {
+ const { activeScreenId: current } = useEditorStore.getState()
+ if (current !== screenId) {
+ setActiveScreenId(screenId)
+ }
+ if (options?.clearSelection) clearSelection()
+ },
+ [setActiveScreenId, clearSelection],
+ )
+
+ const handleSelect = useCallback(
+ (screenId: string, id: string, additive: boolean) => {
+ setActiveScreenId(screenId)
+ if (additive) {
+ const current = useEditorStore.getState().selectedElementIds
+ if (current.includes(id)) {
+ setSelectedElementIds(current.filter((item) => item !== id))
+ } else {
+ setSelectedElementIds([...current, id])
+ }
+ return
+ }
+ setSelectedElementIds([id])
+ },
+ [setActiveScreenId, setSelectedElementIds],
+ )
+
+ useEffect(() => {
+ const transformer = transformerRef.current
+ const stage = stageRef.current
+ if (!transformer || !stage || !activeScreenId) return
+ const screen = screens.find((item) => item.id === activeScreenId)
+ if (!screen) return
+ const lockedIds = new Set(screen.elements.filter((item) => item.locked).map((item) => item.id))
+ const screenElementIds = new Set(screen.elements.map((item) => item.id))
+ const nodes = selectedElementIds
+ .filter((id) => screenElementIds.has(id))
+ .filter((id) => !lockedIds.has(id))
+ .map((id) => stage.findOne(`#${id}`))
+ .filter((node): node is Konva.Node => Boolean(node))
+ transformer.nodes(nodes)
+ transformer.forceUpdate()
+ scheduleDraw()
+ }, [selectedElementIds, screens, activeScreenId, stageRef, transformerRef, scheduleDraw])
+
+ useEffect(() => {
+ const transformer = transformerRef.current
+ if (!transformer) return
+ transformer.forceUpdate()
+ scheduleDraw()
+ }, [workspaceZoom, transformerRef, scheduleDraw])
+
+ const selectedDeviceAspect = useMemo(() => {
+ if (!activeScreen || selectedElementIds.length !== 1) return null
+ const selected = activeScreen.elements.find((item) => item.id === selectedElementIds[0])
+ if (!selected || selected.type !== 'device' || selected.locked) return null
+ const device = getDevice(selected.deviceId)
+ if (!device || !device.frameHeight) return null
+ return device.frameWidth / device.frameHeight
+ }, [activeScreen, selectedElementIds])
+
+ const handleArtboardMouseDown = useCallback(
+ (screenId: string, additive: boolean) => {
+ if (isSpacePressed || isPanning) return
+ if (!additive) {
+ setActiveScreen(screenId, { clearSelection: true })
+ } else {
+ setActiveScreenId(screenId)
+ }
+ const stage = stageRef.current
+ const group = stage?.findOne(`#screen-${screenId}`)
+ const pos = group?.getRelativePointerPosition()
+ if (!pos) return
+ marqueeStartRef.current = {
+ screenId,
+ x: pos.x,
+ y: pos.y,
+ additive,
+ }
+ overlayRef.current?.setMarquee({ x: pos.x, y: pos.y, width: 0, height: 0 })
+ },
+ [isSpacePressed, isPanning, setActiveScreen, setActiveScreenId, stageRef, overlayRef],
+ )
+
+ const handleStageMouseMove = useCallback(() => {
+ const start = marqueeStartRef.current
+ if (!start) return
+ const stage = stageRef.current
+ const group = stage?.findOne(`#screen-${start.screenId}`)
+ const pos = group?.getRelativePointerPosition()
+ if (!pos) return
+ overlayRef.current?.setMarquee({
+ x: Math.min(start.x, pos.x),
+ y: Math.min(start.y, pos.y),
+ width: Math.abs(pos.x - start.x),
+ height: Math.abs(pos.y - start.y),
+ })
+ }, [stageRef, overlayRef])
+
+ const handleStageMouseUp = useCallback(() => {
+ const start = marqueeStartRef.current
+ if (!start) return
+ const rect = overlayRef.current?.getMarquee()
+ marqueeStartRef.current = null
+ overlayRef.current?.clear()
+
+ const screen = screens.find((item) => item.id === start.screenId)
+ if (!screen || !rect) return
+
+ if (rect.width < 3 && rect.height < 3) {
+ if (!start.additive) {
+ clearSelection()
+ }
+ return
+ }
+
+ const hits = screen.elements
+ .filter((item) => item.visible && rectsIntersect(item, rect))
+ .map((item) => item.id)
+
+ if (start.additive) {
+ const current = useEditorStore.getState().selectedElementIds
+ setSelectedElementIds(Array.from(new Set([...current, ...hits])))
+ } else {
+ setSelectedElementIds(hits)
+ }
+ }, [screens, clearSelection, setSelectedElementIds, overlayRef])
+
+ const handleStageMouseDown = useCallback(
+ (event: Konva.KonvaEventObject) => {
+ if (event.target !== event.target.getStage()) return
+ if (isSpacePressed || isPanning) return
+ clearSelection()
+ },
+ [isSpacePressed, isPanning, clearSelection],
+ )
+
+ return {
+ activeScreen,
+ activeScreenId,
+ selectedElementIds,
+ selectedDeviceAspect,
+ setActiveScreen,
+ setActiveScreenId,
+ handleSelect,
+ handleArtboardMouseDown,
+ handleStageMouseMove,
+ handleStageMouseUp,
+ handleStageMouseDown,
+ }
+}
diff --git a/src/hooks/useCanvasTextEdit.ts b/src/hooks/useCanvasTextEdit.ts
new file mode 100644
index 0000000..0504a81
--- /dev/null
+++ b/src/hooks/useCanvasTextEdit.ts
@@ -0,0 +1,64 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import type { Element, Screen, TextElement } from '@/lib/types'
+
+interface UseCanvasTextEditOptions {
+ screens: Screen[]
+ activeScreen: Screen | undefined
+ setActiveScreenId: (screenId: string) => void
+ updateElement: (id: string, patch: Partial) => void
+}
+
+export function useCanvasTextEdit({
+ screens,
+ activeScreen,
+ setActiveScreenId,
+ updateElement,
+}: UseCanvasTextEditOptions) {
+ const [editingTextId, setEditingTextId] = useState(null)
+ const [editValue, setEditValue] = useState('')
+ const editTextareaRef = useRef(null)
+
+ const beginTextEdit = useCallback(
+ (screenId: string, id: string) => {
+ setActiveScreenId(screenId)
+ const screen = screens.find((item) => item.id === screenId)
+ const element = screen?.elements.find((item) => item.id === id)
+ if (!element || element.type !== 'text') return
+ setEditValue(element.text)
+ setEditingTextId(id)
+ },
+ [screens, setActiveScreenId],
+ )
+
+ const commitTextEdit = useCallback(() => {
+ if (!editingTextId) return
+ updateElement(editingTextId, { text: editValue })
+ setEditingTextId(null)
+ }, [editValue, editingTextId, updateElement])
+
+ const cancelTextEdit = useCallback(() => {
+ setEditingTextId(null)
+ }, [])
+
+ useEffect(() => {
+ if (!editingTextId) return
+ editTextareaRef.current?.focus()
+ editTextareaRef.current?.select()
+ }, [editingTextId])
+
+ const editingElement =
+ editingTextId && activeScreen
+ ? (activeScreen.elements.find((item) => item.id === editingTextId) as TextElement | undefined)
+ : undefined
+
+ return {
+ editingTextId,
+ editingElement,
+ editValue,
+ setEditValue,
+ editTextareaRef,
+ beginTextEdit,
+ commitTextEdit,
+ cancelTextEdit,
+ }
+}
diff --git a/src/hooks/useCanvasViewport.ts b/src/hooks/useCanvasViewport.ts
new file mode 100644
index 0000000..16d3340
--- /dev/null
+++ b/src/hooks/useCanvasViewport.ts
@@ -0,0 +1,206 @@
+import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
+import { MIN_ZOOM, MAX_ZOOM } from '@/lib/constants'
+import { getWorkspaceBounds } from '@/lib/canvas/workspace-layout'
+import { getWorkspaceViewport } from '@/lib/canvas/perf/viewport'
+import { clamp } from '@/lib/utils'
+import { useEditorStore } from '@/stores/editor-store'
+import type { Screen } from '@/lib/types'
+
+interface UseCanvasViewportOptions {
+ containerRef: RefObject
+ screens: Screen[]
+}
+
+export function useCanvasViewport({ containerRef, screens }: UseCanvasViewportOptions) {
+ const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null)
+ const didInitialFitRef = useRef(false)
+
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
+ const [containerRect, setContainerRect] = useState(null)
+
+ const activeScreenId = useEditorStore((state) => state.activeScreenId)
+ const workspaceZoom = useEditorStore((state) => state.workspaceZoom)
+ const setWorkspaceZoom = useEditorStore((state) => state.setWorkspaceZoom)
+ const panX = useEditorStore((state) => state.panX)
+ const panY = useEditorStore((state) => state.panY)
+ const setPan = useEditorStore((state) => state.setPan)
+ const screenLayout = useEditorStore((state) => state.screenLayout)
+ const isPanning = useEditorStore((state) => state.isPanning)
+ const isSpacePressed = useEditorStore((state) => state.isSpacePressed)
+ const setIsPanning = useEditorStore((state) => state.setIsPanning)
+ const fitRequest = useEditorStore((state) => state.fitRequest)
+
+ const viewport = useMemo(
+ () =>
+ getWorkspaceViewport(
+ containerSize.width,
+ containerSize.height,
+ panX,
+ panY,
+ workspaceZoom,
+ ),
+ [containerSize.width, containerSize.height, panX, panY, workspaceZoom],
+ )
+
+ useEffect(() => {
+ const element = containerRef.current
+ if (!element) return
+ const observer = new ResizeObserver(([entry]) => {
+ setContainerSize({
+ width: entry.contentRect.width,
+ height: entry.contentRect.height,
+ })
+ setContainerRect(element.getBoundingClientRect())
+ })
+ observer.observe(element)
+ return () => observer.disconnect()
+ }, [containerRef])
+
+ useEffect(() => {
+ const updateRect = () => {
+ if (containerRef.current) setContainerRect(containerRef.current.getBoundingClientRect())
+ }
+ updateRect()
+ window.addEventListener('scroll', updateRect, true)
+ return () => window.removeEventListener('scroll', updateRect, true)
+ }, [containerRef, panX, panY, workspaceZoom, containerSize])
+
+ const fitAll = useCallback(() => {
+ if (containerSize.width < 2 || containerSize.height < 2) return
+ const bounds = getWorkspaceBounds(screens, screenLayout, true)
+ const padding = 64
+ const scale = Math.min(
+ (containerSize.width - padding) / bounds.width,
+ (containerSize.height - padding) / bounds.height,
+ )
+ const z = clamp(scale, MIN_ZOOM, 1)
+ setWorkspaceZoom(z)
+ setPan(
+ (containerSize.width - bounds.width * z) / 2 - bounds.minX * z,
+ (containerSize.height - bounds.height * z) / 2 - bounds.minY * z,
+ )
+ }, [containerSize, screens, screenLayout, setPan, setWorkspaceZoom])
+
+ const fitActive = useCallback(() => {
+ if (!activeScreenId || containerSize.width < 2) return
+ const screen = screens.find((item) => item.id === activeScreenId)
+ const pos = screenLayout[activeScreenId]
+ if (!screen || !pos) return
+ const padding = 80
+ const scale = Math.min(
+ (containerSize.width - padding) / screen.width,
+ (containerSize.height - padding) / screen.height,
+ )
+ const z = clamp(scale, MIN_ZOOM, MAX_ZOOM)
+ setWorkspaceZoom(z)
+ setPan(
+ (containerSize.width - screen.width * z) / 2 - pos.x * z,
+ (containerSize.height - screen.height * z) / 2 - pos.y * z,
+ )
+ }, [activeScreenId, containerSize, screens, screenLayout, setPan, setWorkspaceZoom])
+
+ useEffect(() => {
+ if (containerSize.width < 2 || didInitialFitRef.current) return
+ fitAll()
+ didInitialFitRef.current = true
+ }, [containerSize.width, containerSize.height, fitAll])
+
+ useEffect(() => {
+ if (!fitRequest) return
+ if (fitRequest === 'all') fitAll()
+ else if (fitRequest === 'active') fitActive()
+ useEditorStore.setState({ fitRequest: null })
+ }, [fitRequest, fitAll, fitActive])
+
+ const zoomAtPoint = useCallback(
+ (clientX: number, clientY: number, nextZoom: number) => {
+ const rect = containerRef.current?.getBoundingClientRect()
+ if (!rect) return
+ const px = clientX - rect.left
+ const py = clientY - rect.top
+ const z = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM)
+ const sx = (px - panX) / workspaceZoom
+ const sy = (py - panY) / workspaceZoom
+ setPan(px - sx * z, py - sy * z)
+ setWorkspaceZoom(z)
+ },
+ [containerRef, panX, panY, workspaceZoom, setPan, setWorkspaceZoom],
+ )
+
+ const zoomBy = useCallback(
+ (factor: number, clientX?: number, clientY?: number) => {
+ const rect = containerRef.current?.getBoundingClientRect()
+ if (!rect) return
+ zoomAtPoint(
+ clientX ?? rect.left + containerSize.width / 2,
+ clientY ?? rect.top + containerSize.height / 2,
+ workspaceZoom * factor,
+ )
+ },
+ [containerRef, containerSize, workspaceZoom, zoomAtPoint],
+ )
+
+ const clientToWorkspace = useCallback(
+ (clientX: number, clientY: number) => {
+ const rect = containerRef.current?.getBoundingClientRect()
+ if (!rect) return null
+ return {
+ x: (clientX - rect.left - panX) / workspaceZoom,
+ y: (clientY - rect.top - panY) / workspaceZoom,
+ }
+ },
+ [containerRef, panX, panY, workspaceZoom],
+ )
+
+ const handleWheel = useCallback(
+ (event: React.WheelEvent) => {
+ if (event.deltaY === 0) return
+ event.preventDefault()
+ zoomBy(event.deltaY > 0 ? 0.92 : 1.08, event.clientX, event.clientY)
+ },
+ [zoomBy],
+ )
+
+ const handlePanMouseDown = useCallback(
+ (event: React.MouseEvent) => {
+ if (!isSpacePressed && event.button !== 1) return
+ event.preventDefault()
+ setIsPanning(true)
+ panStartRef.current = { x: event.clientX, y: event.clientY, panX, panY }
+ },
+ [isSpacePressed, panX, panY, setIsPanning],
+ )
+
+ const handlePanMouseMove = useCallback(
+ (event: React.MouseEvent) => {
+ const start = panStartRef.current
+ if (!start) return
+ setPan(start.panX + (event.clientX - start.x), start.panY + (event.clientY - start.y))
+ },
+ [setPan],
+ )
+
+ const endPan = useCallback(() => {
+ panStartRef.current = null
+ setIsPanning(false)
+ }, [setIsPanning])
+
+ return {
+ containerSize,
+ containerRect,
+ viewport,
+ workspaceZoom,
+ panX,
+ panY,
+ isPanning,
+ isSpacePressed,
+ fitAll,
+ fitActive,
+ zoomBy,
+ clientToWorkspace,
+ handleWheel,
+ handlePanMouseDown,
+ handlePanMouseMove,
+ endPan,
+ }
+}
diff --git a/src/hooks/useExportFormReducer.ts b/src/hooks/useExportFormReducer.ts
new file mode 100644
index 0000000..9ac89fe
--- /dev/null
+++ b/src/hooks/useExportFormReducer.ts
@@ -0,0 +1,19 @@
+import { useReducer, type Dispatch } from 'react'
+import type { ExportPreferences } from '@/lib/types'
+import {
+ createExportFormState,
+ exportFormReducer,
+ type ExportFormAction,
+ type ExportFormState,
+} from '@/lib/export/export-form-state'
+
+export function useExportFormReducer(
+ prefs: ExportPreferences,
+ defaultPresets: string[],
+): [ExportFormState, Dispatch] {
+ return useReducer(
+ exportFormReducer,
+ { prefs, defaultPresets },
+ ({ prefs, defaultPresets }) => createExportFormState(prefs, defaultPresets),
+ )
+}
diff --git a/src/hooks/useExportPreview.ts b/src/hooks/useExportPreview.ts
new file mode 100644
index 0000000..7b2fe62
--- /dev/null
+++ b/src/hooks/useExportPreview.ts
@@ -0,0 +1,129 @@
+import { useEffect, useState } from 'react'
+import { prepareExportScreen } from '@/lib/export/prepare-export-screen'
+import { renderScreenToDataUrl } from '@/lib/export/renderer'
+import type { ExportDialogTab } from '@/lib/export/types'
+import type { ExportFormat, ResizeStrategy, Screen, StorePreset } from '@/lib/types'
+import type { ExportPlanItem } from '@/lib/export/export-plan'
+
+export interface ExportGridPreview {
+ id: string
+ label: string
+ url: string
+}
+
+interface UseExportPreviewOptions {
+ open: boolean
+ tab: ExportDialogTab
+ screen: Screen | undefined
+ targetPreset: StorePreset | undefined
+ format: ExportFormat
+ scale: 1 | 2 | 3
+ jpegQuality: number
+ transparentBackground: boolean
+ resizeStrategy: ResizeStrategy
+ assetResolver: (assetId?: string) => string | undefined
+ exportScreens: Screen[]
+ quickPlan: ExportPlanItem[]
+}
+
+export function useExportPreview({
+ open,
+ tab,
+ screen,
+ targetPreset,
+ format,
+ scale,
+ jpegQuality,
+ transparentBackground,
+ resizeStrategy,
+ assetResolver,
+ exportScreens,
+ quickPlan,
+}: UseExportPreviewOptions) {
+ const [previewUrl, setPreviewUrl] = useState(null)
+ const [previewLoading, setPreviewLoading] = useState(false)
+ const [gridPreviews, setGridPreviews] = useState([])
+
+ useEffect(() => {
+ if (!open || !screen) return
+ let cancelled = false
+ const timer = setTimeout(async () => {
+ setPreviewLoading(true)
+ try {
+ const source = targetPreset
+ ? prepareExportScreen(screen, targetPreset, resizeStrategy)
+ : screen
+ const url = await renderScreenToDataUrl({
+ screen: source,
+ assetResolver,
+ scale: targetPreset ? 1 : scale,
+ format,
+ jpegQuality,
+ transparentBackground,
+ })
+ if (!cancelled) setPreviewUrl(url)
+ } catch {
+ if (!cancelled) setPreviewUrl(null)
+ } finally {
+ if (!cancelled) setPreviewLoading(false)
+ }
+ }, 200)
+ return () => {
+ cancelled = true
+ clearTimeout(timer)
+ }
+ }, [
+ open,
+ screen,
+ targetPreset,
+ format,
+ jpegQuality,
+ transparentBackground,
+ scale,
+ resizeStrategy,
+ assetResolver,
+ ])
+
+ useEffect(() => {
+ if (!open || tab !== 'quick' || exportScreens.length === 0) {
+ setGridPreviews([])
+ return
+ }
+
+ let cancelled = false
+ const timer = setTimeout(async () => {
+ const previews: ExportGridPreview[] = []
+ const items = quickPlan.slice(0, 6)
+
+ for (const { screen: itemScreen, preset } of items) {
+ try {
+ const prepared = prepareExportScreen(itemScreen, preset, resizeStrategy)
+ const url = await renderScreenToDataUrl({
+ screen: prepared,
+ assetResolver,
+ scale: 1,
+ format: 'png',
+ jpegQuality,
+ transparentBackground: false,
+ })
+ previews.push({
+ id: `${itemScreen.id}-${preset.id}`,
+ label: `${itemScreen.name} · ${preset.name}`,
+ url,
+ })
+ } catch {
+ // skip failed preview
+ }
+ }
+
+ if (!cancelled) setGridPreviews(previews)
+ }, 300)
+
+ return () => {
+ cancelled = true
+ clearTimeout(timer)
+ }
+ }, [open, tab, exportScreens, quickPlan, assetResolver, resizeStrategy, jpegQuality])
+
+ return { previewUrl, previewLoading, gridPreviews }
+}
diff --git a/src/hooks/useFloatingMenu.ts b/src/hooks/useFloatingMenu.ts
new file mode 100644
index 0000000..4bd6bd5
--- /dev/null
+++ b/src/hooks/useFloatingMenu.ts
@@ -0,0 +1,43 @@
+import { useEffect, useRef } from 'react'
+
+const VIEWPORT_PADDING = 8
+
+export function useFloatingMenu(x: number, y: number, onClose: () => void) {
+ const menuRef = useRef(null)
+
+ useEffect(() => {
+ const handlePointerDown = (event: MouseEvent) => {
+ if (menuRef.current?.contains(event.target as Node)) return
+ onClose()
+ }
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') onClose()
+ }
+ window.addEventListener('mousedown', handlePointerDown)
+ window.addEventListener('keydown', handleKeyDown)
+ window.addEventListener('scroll', onClose, true)
+ return () => {
+ window.removeEventListener('mousedown', handlePointerDown)
+ window.removeEventListener('keydown', handleKeyDown)
+ window.removeEventListener('scroll', onClose, true)
+ }
+ }, [onClose])
+
+ useEffect(() => {
+ const menu = menuRef.current
+ if (!menu) return
+ const rect = menu.getBoundingClientRect()
+ let left = x
+ let top = y
+ if (left + rect.width > window.innerWidth - VIEWPORT_PADDING) {
+ left = window.innerWidth - rect.width - VIEWPORT_PADDING
+ }
+ if (top + rect.height > window.innerHeight - VIEWPORT_PADDING) {
+ top = window.innerHeight - rect.height - VIEWPORT_PADDING
+ }
+ menu.style.left = `${Math.max(VIEWPORT_PADDING, left)}px`
+ menu.style.top = `${Math.max(VIEWPORT_PADDING, top)}px`
+ }, [x, y])
+
+ return menuRef
+}
diff --git a/src/hooks/useHistoryNavigation.ts b/src/hooks/useHistoryNavigation.ts
new file mode 100644
index 0000000..042e498
--- /dev/null
+++ b/src/hooks/useHistoryNavigation.ts
@@ -0,0 +1,23 @@
+import { useCallback } from 'react'
+import { useHistoryStore } from '@/stores/history-store'
+import { useProjectStore } from '@/stores/project-store'
+
+export function useHistoryNavigation() {
+ const canUndo = useHistoryStore((state) => state.canUndo)
+ const canRedo = useHistoryStore((state) => state.canRedo)
+ const undo = useHistoryStore((state) => state.undo)
+ const redo = useHistoryStore((state) => state.redo)
+ const restoreFromHistory = useProjectStore((state) => state.restoreFromHistory)
+
+ const handleUndo = useCallback(() => {
+ const project = undo()
+ if (project) restoreFromHistory(project)
+ }, [undo, restoreFromHistory])
+
+ const handleRedo = useCallback(() => {
+ const project = redo()
+ if (project) restoreFromHistory(project)
+ }, [redo, restoreFromHistory])
+
+ return { canUndo, canRedo, undo: handleUndo, redo: handleRedo }
+}
diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts
index 5d101bb..1e690ea 100644
--- a/src/hooks/useKeyboardShortcuts.ts
+++ b/src/hooks/useKeyboardShortcuts.ts
@@ -1,6 +1,6 @@
import { useEffect } from 'react'
+import { useHistoryNavigation } from '@/hooks/useHistoryNavigation'
import { useProjectStore } from '@/stores/project-store'
-import { useHistoryStore } from '@/stores/history-store'
import { useEditorStore } from '@/stores/editor-store'
import { useSettingsStore } from '@/stores/settings-store'
import { duplicateElement } from '@/lib/factories'
@@ -15,9 +15,7 @@ export function useKeyboardShortcuts() {
const updateElement = useProjectStore((state) => state.updateElement)
const bringForward = useProjectStore((state) => state.bringForward)
const sendBackward = useProjectStore((state) => state.sendBackward)
- const undo = useHistoryStore((state) => state.undo)
- const redo = useHistoryStore((state) => state.redo)
- const restoreFromHistory = useProjectStore((state) => state.restoreFromHistory)
+ const { undo, redo } = useHistoryNavigation()
const selectedElementIds = useEditorStore((state) => state.selectedElementIds)
const setSelectedElementIds = useEditorStore((state) => state.setSelectedElementIds)
const clipboard = useEditorStore((state) => state.clipboard)
@@ -39,15 +37,13 @@ export function useKeyboardShortcuts() {
if (meta && event.key.toLowerCase() === 'z' && !event.shiftKey) {
event.preventDefault()
- const project = undo()
- if (project) restoreFromHistory(project)
+ undo()
return
}
if (meta && (event.key.toLowerCase() === 'y' || (event.shiftKey && event.key.toLowerCase() === 'z'))) {
event.preventDefault()
- const project = redo()
- if (project) restoreFromHistory(project)
+ redo()
return
}
@@ -226,7 +222,6 @@ export function useKeyboardShortcuts() {
groupElements,
nudgeStep,
redo,
- restoreFromHistory,
selectedElementIds,
sendBackward,
setClipboard,
diff --git a/src/hooks/usePersistAssetUpload.ts b/src/hooks/usePersistAssetUpload.ts
new file mode 100644
index 0000000..8a3585f
--- /dev/null
+++ b/src/hooks/usePersistAssetUpload.ts
@@ -0,0 +1,19 @@
+import { useCallback } from 'react'
+import { persistProjectAsset } from '@/lib/assets/persist-project-asset'
+import { useProjectStore } from '@/stores/project-store'
+import type { AssetRecord } from '@/lib/types'
+
+export function usePersistAssetUpload() {
+ const project = useProjectStore((state) => state.project)
+ const registerAssetUrl = useProjectStore((state) => state.registerAssetUrl)
+
+ return useCallback(
+ async (file: File, type: AssetRecord['type'] = 'screenshot') => {
+ if (!project) {
+ throw new Error('No active project')
+ }
+ return persistProjectAsset(file, project.id, registerAssetUrl, type)
+ },
+ [project, registerAssetUrl],
+ )
+}
diff --git a/src/hooks/useProjectsCatalog.test.ts b/src/hooks/useProjectsCatalog.test.ts
new file mode 100644
index 0000000..9c6c635
--- /dev/null
+++ b/src/hooks/useProjectsCatalog.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it, vi } from 'vitest'
+import { loadProjectAssetUrls } from '@/hooks/useProjectsCatalog'
+import type { Project } from '@/lib/types'
+
+vi.mock('@/lib/db', () => ({
+ getProjectAssets: vi.fn(async (projectId: string) => [
+ { id: `${projectId}-asset`, projectId, type: 'screenshot', name: 'shot.png', mimeType: 'image/png', size: 1, createdAt: '' },
+ ]),
+}))
+
+vi.mock('@/lib/assets/image-pipeline', () => ({
+ createAssetObjectUrl: vi.fn((asset: { id: string }) => `blob:${asset.id}`),
+}))
+
+describe('loadProjectAssetUrls', () => {
+ it('builds per-project asset url maps', async () => {
+ const projects = [
+ { id: 'p1', name: 'One' } as Project,
+ { id: 'p2', name: 'Two' } as Project,
+ ]
+
+ const urls = await loadProjectAssetUrls(projects)
+
+ expect(urls).toEqual({
+ p1: { 'p1-asset': 'blob:p1-asset' },
+ p2: { 'p2-asset': 'blob:p2-asset' },
+ })
+ })
+})
diff --git a/src/hooks/useProjectsCatalog.ts b/src/hooks/useProjectsCatalog.ts
new file mode 100644
index 0000000..199e65c
--- /dev/null
+++ b/src/hooks/useProjectsCatalog.ts
@@ -0,0 +1,155 @@
+import { useCallback, useEffect, useState } from 'react'
+import { useNavigate } from '@tanstack/react-router'
+import { createAndLoadProject, useProjectStore } from '@/stores/project-store'
+import {
+ deleteProject,
+ getAllProjects,
+ getProjectAssets,
+ saveProject,
+} from '@/lib/db'
+import { createAssetObjectUrl } from '@/lib/assets/image-pipeline'
+import { cloneProject } from '@/lib/factories'
+import { exportProjectFile, importProjectFile } from '@/lib/project-io'
+import { applyTemplateToProject } from '@/lib/templates/preview'
+import { downloadBlob } from '@/lib/utils'
+import { confirm } from '@/stores/confirm-store'
+import { toast } from '@/stores/toast-store'
+import type { Project, TemplateDefinition } from '@/lib/types'
+
+export type ProjectAssetUrlMap = Record>
+
+export async function loadProjectAssetUrls(projects: Project[]): Promise {
+ const urls: ProjectAssetUrlMap = {}
+ for (const project of projects) {
+ const assets = await getProjectAssets(project.id)
+ urls[project.id] = {}
+ for (const asset of assets) {
+ urls[project.id][asset.id] = createAssetObjectUrl(asset)
+ }
+ }
+ return urls
+}
+
+export function useProjectsCatalog() {
+ const navigate = useNavigate()
+ const [projects, setProjects] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [assetUrls, setAssetUrls] = useState({})
+
+ const refresh = useCallback(async () => {
+ const items = await getAllProjects()
+ setProjects(items)
+ setAssetUrls(await loadProjectAssetUrls(items))
+ setLoading(false)
+ }, [])
+
+ useEffect(() => {
+ void refresh()
+ }, [refresh])
+
+ const goToEditor = useCallback(
+ (projectId: string) => {
+ void navigate({ to: '/editor/$projectId', params: { projectId } })
+ },
+ [navigate],
+ )
+
+ const createBlankProject = useCallback(async () => {
+ const project = createAndLoadProject('New Project')
+ await saveProject(project)
+ await refresh()
+ goToEditor(project.id)
+ }, [refresh, goToEditor])
+
+ const createFromTemplate = useCallback(
+ async (template: TemplateDefinition) => {
+ const baseName = template.name.split('—')[0]?.trim() || 'New Project'
+ const project = createAndLoadProject(baseName)
+ const withTemplate = applyTemplateToProject(project, template)
+ useProjectStore.getState().loadProject(withTemplate)
+ await saveProject(withTemplate)
+ await refresh()
+ goToEditor(withTemplate.id)
+ },
+ [refresh, goToEditor],
+ )
+
+ const duplicate = useCallback(
+ async (project: Project) => {
+ const copy = cloneProject(project)
+ await saveProject(copy)
+ await refresh()
+ const open = await confirm({
+ title: 'Open duplicated project?',
+ description: `"${copy.name}" was created. Open it now?`,
+ confirmLabel: 'Open copy',
+ cancelLabel: 'Stay here',
+ })
+ if (open) goToEditor(copy.id)
+ },
+ [refresh, goToEditor],
+ )
+
+ const remove = useCallback(
+ async (project: Project) => {
+ const confirmed = await confirm({
+ title: 'Delete project?',
+ description: `"${project.name}" will be permanently deleted. Export it first if you need a backup.`,
+ confirmLabel: 'Delete',
+ destructive: true,
+ })
+ if (!confirmed) return
+ await deleteProject(project.id)
+ await refresh()
+ toast('Project deleted', 'success')
+ },
+ [refresh],
+ )
+
+ const renameProject = useCallback(
+ async (project: Project, name: string) => {
+ const updated = { ...project, name, updatedAt: new Date().toISOString() }
+ await saveProject(updated)
+ await refresh()
+ },
+ [refresh],
+ )
+
+ const importProject = useCallback(
+ async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+ try {
+ const project = await importProjectFile(file)
+ await saveProject(project)
+ await refresh()
+ toast('Project imported', 'success')
+ } catch {
+ toast('Failed to import project', 'error')
+ }
+ event.target.value = ''
+ },
+ [refresh],
+ )
+
+ const exportProject = useCallback(async (project: Project) => {
+ const blob = await exportProjectFile(project)
+ downloadBlob(blob, `${project.name}.ssgproj`)
+ toast('Project exported', 'success')
+ }, [])
+
+ return {
+ projects,
+ loading,
+ assetUrls,
+ refresh,
+ openProject: goToEditor,
+ createBlankProject,
+ createFromTemplate,
+ duplicate,
+ remove,
+ renameProject,
+ importProject,
+ exportProject,
+ }
+}
diff --git a/src/hooks/useScreenContextMenu.ts b/src/hooks/useScreenContextMenu.ts
new file mode 100644
index 0000000..2f64745
--- /dev/null
+++ b/src/hooks/useScreenContextMenu.ts
@@ -0,0 +1,100 @@
+import { useCallback, useState } from 'react'
+import { findScreenAtPoint } from '@/lib/canvas/workspace-layout'
+import { MAX_SCREENS } from '@/lib/constants'
+import { confirm } from '@/stores/confirm-store'
+import type { Project, Screen } from '@/lib/types'
+
+interface ScreenLayoutEntry {
+ x: number
+ y: number
+}
+
+interface UseScreenContextMenuOptions {
+ screens: Screen[]
+ screenLayout: Record
+ project: Project | null
+ clientToWorkspace: (clientX: number, clientY: number) => { x: number; y: number } | null
+ setActiveScreen: (screenId: string, options?: { clearSelection?: boolean }) => void
+ duplicateScreen: (screenId: string) => void
+ deleteScreen: (screenId: string) => void
+}
+
+export function useScreenContextMenu({
+ screens,
+ screenLayout,
+ project,
+ clientToWorkspace,
+ setActiveScreen,
+ duplicateScreen,
+ deleteScreen,
+}: UseScreenContextMenuOptions) {
+ const [screenContextMenu, setScreenContextMenu] = useState<{
+ screenId: string
+ x: number
+ y: number
+ } | null>(null)
+
+ const atScreenLimit = (project?.screens.length ?? 0) >= MAX_SCREENS
+
+ const dismissScreenContextMenu = useCallback(() => {
+ setScreenContextMenu(null)
+ }, [])
+
+ const handleScreenContextMenu = useCallback(
+ (event: React.MouseEvent | MouseEvent) => {
+ event.preventDefault()
+ const point = clientToWorkspace(event.clientX, event.clientY)
+ if (!point) return
+ const screen = findScreenAtPoint(point, screens, screenLayout)
+ if (!screen) {
+ setScreenContextMenu(null)
+ return
+ }
+ setActiveScreen(screen.id, { clearSelection: true })
+ setScreenContextMenu({
+ screenId: screen.id,
+ x: event.clientX,
+ y: event.clientY,
+ })
+ },
+ [clientToWorkspace, screenLayout, screens, setActiveScreen],
+ )
+
+ const handleDuplicateScreen = useCallback(
+ (screenId: string) => {
+ if (atScreenLimit) return
+ duplicateScreen(screenId)
+ },
+ [atScreenLimit, duplicateScreen],
+ )
+
+ const handleDeleteScreen = useCallback(
+ async (screenId: string) => {
+ setScreenContextMenu(null)
+ const screen = screens.find((item) => item.id === screenId)
+ if (!screen || screens.length <= 1) return
+ const confirmed = await confirm({
+ title: 'Delete screen?',
+ description: `"${screen.name}" and all its elements will be removed.`,
+ confirmLabel: 'Delete',
+ destructive: true,
+ })
+ if (confirmed) deleteScreen(screenId)
+ },
+ [deleteScreen, screens],
+ )
+
+ const contextMenuScreen = screenContextMenu
+ ? screens.find((screen) => screen.id === screenContextMenu.screenId)
+ : undefined
+
+ return {
+ screenContextMenu,
+ contextMenuScreen,
+ atScreenLimit,
+ handleScreenContextMenu,
+ dismissScreenContextMenu,
+ handleDuplicateScreen,
+ handleDeleteScreen,
+ }
+}
diff --git a/src/hooks/useSelectedElements.ts b/src/hooks/useSelectedElements.ts
new file mode 100644
index 0000000..d26be54
--- /dev/null
+++ b/src/hooks/useSelectedElements.ts
@@ -0,0 +1,14 @@
+import { useMemo } from 'react'
+import { resolveSelectedElements } from '@/lib/selection/resolve-selected-elements'
+import { useProjectStore } from '@/stores/project-store'
+import { useEditorStore } from '@/stores/editor-store'
+
+export function useSelectedElements() {
+ const screen = useProjectStore((state) => state.getActiveScreen())
+ const selectedElementIds = useEditorStore((state) => state.selectedElementIds)
+
+ return useMemo(() => {
+ if (!screen) return []
+ return resolveSelectedElements(screen, selectedElementIds)
+ }, [screen, selectedElementIds])
+}
diff --git a/src/hooks/useSelectionCapabilities.ts b/src/hooks/useSelectionCapabilities.ts
new file mode 100644
index 0000000..078b7dd
--- /dev/null
+++ b/src/hooks/useSelectionCapabilities.ts
@@ -0,0 +1,27 @@
+import { useMemo } from 'react'
+import { resolveSelectedElementsById } from '@/lib/selection/resolve-selected-elements'
+import { useProjectStore } from '@/stores/project-store'
+import { useEditorStore } from '@/stores/editor-store'
+import type { Element } from '@/lib/types'
+
+export function useSelectionCapabilities() {
+ const project = useProjectStore((state) => state.project)
+ const activeScreenId = useEditorStore((state) => state.activeScreenId)
+ const selectedElementIds = useEditorStore((state) => state.selectedElementIds)
+
+ return useMemo(() => {
+ const screen = project?.screens.find((item) => item.id === activeScreenId)
+ const selectedElements = resolveSelectedElementsById(screen, selectedElementIds)
+ const hasText = selectedElements.some((item) => item.type === 'text')
+ const hasShape = selectedElements.some((item) => item.type === 'shape')
+
+ return {
+ hasSelection: selectedElements.length > 0,
+ hasText,
+ hasShape,
+ canApplyColor: hasText || hasShape,
+ canApplyFont: hasText,
+ selectedElements: selectedElements as Element[],
+ }
+ }, [project, activeScreenId, selectedElementIds])
+}
diff --git a/src/lib/assets/device-presets.ts b/src/lib/assets/device-presets.ts
new file mode 100644
index 0000000..e627d83
--- /dev/null
+++ b/src/lib/assets/device-presets.ts
@@ -0,0 +1,8 @@
+export const TILT_PRESETS: Array<{ label: string; tiltX: number; tiltY: number }> = [
+ { label: 'Flat', tiltX: 0, tiltY: 0 },
+ { label: 'Left', tiltX: 0, tiltY: 22 },
+ { label: 'Right', tiltX: 0, tiltY: -22 },
+ { label: 'Back', tiltX: 16, tiltY: 0 },
+ { label: 'Hero L', tiltX: 10, tiltY: 18 },
+ { label: 'Hero R', tiltX: 10, tiltY: -18 },
+]
diff --git a/src/lib/assets/persist-project-asset.test.ts b/src/lib/assets/persist-project-asset.test.ts
new file mode 100644
index 0000000..a384369
--- /dev/null
+++ b/src/lib/assets/persist-project-asset.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { persistProjectAsset } from '@/lib/assets/persist-project-asset'
+
+vi.mock('@/lib/assets/image-pipeline', () => ({
+ createAssetFromFile: vi.fn(async (file: File, projectId: string, type: string) => ({
+ id: 'asset-1',
+ projectId,
+ name: file.name,
+ type,
+ blob: file,
+ mimeType: file.type,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ })),
+ createAssetObjectUrl: vi.fn(() => 'blob:mock-url'),
+}))
+
+vi.mock('@/lib/db', () => ({
+ saveAsset: vi.fn(async () => undefined),
+}))
+
+import { createAssetFromFile, createAssetObjectUrl } from '@/lib/assets/image-pipeline'
+import { saveAsset } from '@/lib/db'
+
+describe('persistProjectAsset', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('persists asset and registers object url', async () => {
+ const file = new File(['pixels'], 'shot.png', { type: 'image/png' })
+ const registerAssetUrl = vi.fn()
+
+ const asset = await persistProjectAsset(file, 'project-1', registerAssetUrl, 'screenshot')
+
+ expect(createAssetFromFile).toHaveBeenCalledWith(file, 'project-1', 'screenshot')
+ expect(saveAsset).toHaveBeenCalledWith(asset)
+ expect(createAssetObjectUrl).toHaveBeenCalledWith(asset)
+ expect(registerAssetUrl).toHaveBeenCalledWith('asset-1', 'blob:mock-url')
+ expect(asset.id).toBe('asset-1')
+ })
+})
diff --git a/src/lib/assets/persist-project-asset.ts b/src/lib/assets/persist-project-asset.ts
new file mode 100644
index 0000000..9a34008
--- /dev/null
+++ b/src/lib/assets/persist-project-asset.ts
@@ -0,0 +1,15 @@
+import { createAssetFromFile, createAssetObjectUrl } from '@/lib/assets/image-pipeline'
+import { saveAsset } from '@/lib/db'
+import type { AssetRecord } from '@/lib/types'
+
+export async function persistProjectAsset(
+ file: File,
+ projectId: string,
+ registerAssetUrl: (id: string, url: string) => void,
+ type: AssetRecord['type'] = 'screenshot',
+): Promise {
+ const asset = await createAssetFromFile(file, projectId, type)
+ await saveAsset(asset)
+ registerAssetUrl(asset.id, createAssetObjectUrl(asset))
+ return asset
+}
diff --git a/src/lib/canvas/backgrounds.ts b/src/lib/canvas/backgrounds.ts
index 78c99f5..4ec378c 100644
--- a/src/lib/canvas/backgrounds.ts
+++ b/src/lib/canvas/backgrounds.ts
@@ -1,5 +1,6 @@
import type { BackgroundConfig, MeshBlob, PatternKind } from '@/lib/types'
import { BRAND_PRIMARY } from '@/lib/constants'
+import { fillRectWithCanvasGradient } from '@/lib/canvas/create-canvas-gradient'
export interface BackgroundPreset {
id: string
@@ -329,33 +330,16 @@ export function buildBackgroundCanvas(
(background.type === 'linear-gradient' || background.type === 'radial-gradient') &&
background.gradient
) {
- let gradient: CanvasGradient
- if (background.type === 'radial-gradient') {
- gradient = ctx.createRadialGradient(
- width / 2,
- height / 2,
- 0,
- width / 2,
- height / 2,
- Math.max(width, height) / 2,
- )
- } else {
- const angle = ((background.gradient.angle ?? 180) * Math.PI) / 180
- const cx = width / 2
- const cy = height / 2
- const len = Math.max(width, height)
- gradient = ctx.createLinearGradient(
- cx - (Math.cos(angle) * len) / 2,
- cy - (Math.sin(angle) * len) / 2,
- cx + (Math.cos(angle) * len) / 2,
- cy + (Math.sin(angle) * len) / 2,
- )
- }
- for (const stop of background.gradient.stops) {
- gradient.addColorStop(stop.offset, stop.color)
- }
- ctx.fillStyle = gradient
- ctx.fillRect(0, 0, width, height)
+ fillRectWithCanvasGradient(
+ ctx,
+ {
+ type: background.type === 'radial-gradient' ? 'radial' : 'linear',
+ angle: background.gradient.angle,
+ stops: background.gradient.stops,
+ },
+ width,
+ height,
+ )
return canvas
}
diff --git a/src/lib/canvas/coordinates.test.ts b/src/lib/canvas/coordinates.test.ts
new file mode 100644
index 0000000..22c5933
--- /dev/null
+++ b/src/lib/canvas/coordinates.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest'
+import {
+ getAbsoluteNodePosition,
+ getGroupOrigin,
+ setAbsoluteNodePosition,
+ workspaceToScreenLocal,
+} from '@/lib/canvas/coordinates'
+import { minimalScreen } from '@/test/fixtures/minimal-screen'
+import type { Element } from '@/lib/types'
+
+function mockNode(x: number, y: number) {
+ let nodeX = x
+ let nodeY = y
+ const node = {
+ x: (value?: number) => {
+ if (value !== undefined) {
+ nodeX = value
+ return node
+ }
+ return nodeX
+ },
+ y: (value?: number) => {
+ if (value !== undefined) {
+ nodeY = value
+ return node
+ }
+ return nodeY
+ },
+ }
+ return node as unknown as import('konva').default.Node
+}
+
+describe('coordinates', () => {
+ it('returns group origin from member positions', () => {
+ const screen = {
+ ...minimalScreen(),
+ elements: [
+ { id: 'a', groupId: 'g1', x: 40, y: 60 } as Element,
+ { id: 'b', groupId: 'g1', x: 10, y: 30 } as Element,
+ ],
+ }
+ expect(getGroupOrigin(screen, 'g1')).toEqual({ x: 10, y: 30 })
+ })
+
+ it('maps grouped node position to absolute coordinates', () => {
+ const screen = {
+ ...minimalScreen(),
+ elements: [{ id: 'a', groupId: 'g1', x: 10, y: 20 } as Element],
+ }
+ const node = mockNode(5, 8)
+ expect(getAbsoluteNodePosition(screen.elements[0], screen, node)).toEqual({ x: 15, y: 28 })
+ })
+
+ it('writes absolute coordinates back to a grouped node', () => {
+ const screen = {
+ ...minimalScreen(),
+ elements: [{ id: 'a', groupId: 'g1', x: 10, y: 20 } as Element],
+ }
+ const node = mockNode(0, 0)
+ setAbsoluteNodePosition(screen.elements[0], screen, node, 30, 40)
+ expect(node.x()).toBe(20)
+ expect(node.y()).toBe(20)
+ })
+
+ it('converts workspace coordinates to screen-local space', () => {
+ const local = workspaceToScreenLocal(
+ { x: 150, y: 220 },
+ { 'screen-1': { x: 100, y: 200 } },
+ 'screen-1',
+ )
+ expect(local).toEqual({ x: 50, y: 20 })
+ })
+})
diff --git a/src/lib/canvas/coordinates.ts b/src/lib/canvas/coordinates.ts
new file mode 100644
index 0000000..26453e8
--- /dev/null
+++ b/src/lib/canvas/coordinates.ts
@@ -0,0 +1,48 @@
+import type Konva from 'konva'
+import type { Element, Screen } from '@/lib/types'
+
+export function getGroupOrigin(screen: Screen, groupId: string) {
+ const members = screen.elements.filter((item) => item.groupId === groupId)
+ return {
+ x: Math.min(...members.map((item) => item.x)),
+ y: Math.min(...members.map((item) => item.y)),
+ }
+}
+
+export function getAbsoluteNodePosition(element: Element, screen: Screen, node: Konva.Node) {
+ if (!element.groupId) {
+ return { x: node.x(), y: node.y() }
+ }
+ const origin = getGroupOrigin(screen, element.groupId)
+ return { x: origin.x + node.x(), y: origin.y + node.y() }
+}
+
+export function setAbsoluteNodePosition(
+ element: Element,
+ screen: Screen,
+ node: Konva.Node,
+ absoluteX: number,
+ absoluteY: number,
+) {
+ if (!element.groupId) {
+ node.x(absoluteX)
+ node.y(absoluteY)
+ return
+ }
+ const origin = getGroupOrigin(screen, element.groupId)
+ node.x(absoluteX - origin.x)
+ node.y(absoluteY - origin.y)
+}
+
+/** Screen-local point from a workspace coordinate. */
+export function workspaceToScreenLocal(
+ point: { x: number; y: number },
+ screenLayout: Record,
+ screenId: string,
+) {
+ const offset = screenLayout[screenId]
+ return {
+ x: point.x - (offset?.x ?? 0),
+ y: point.y - (offset?.y ?? 0),
+ }
+}
diff --git a/src/lib/canvas/create-canvas-gradient.test.ts b/src/lib/canvas/create-canvas-gradient.test.ts
new file mode 100644
index 0000000..6be52b2
--- /dev/null
+++ b/src/lib/canvas/create-canvas-gradient.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it, vi } from 'vitest'
+import { createCanvasGradient } from '@/lib/canvas/create-canvas-gradient'
+
+function createMockContext() {
+ const gradient = {
+ stops: [] as Array<{ offset: number; color: string }>,
+ addColorStop(offset: number, color: string) {
+ this.stops.push({ offset, color })
+ },
+ }
+
+ return {
+ createLinearGradient: vi.fn(() => gradient),
+ createRadialGradient: vi.fn(() => gradient),
+ gradient,
+ } as unknown as CanvasRenderingContext2D & {
+ gradient: { stops: Array<{ offset: number; color: string }> }
+ }
+}
+
+describe('createCanvasGradient', () => {
+ it('creates a linear gradient with color stops', () => {
+ const context = createMockContext()
+
+ const gradient = createCanvasGradient(
+ context,
+ {
+ type: 'linear',
+ angle: 180,
+ stops: [
+ { offset: 0, color: '#000000' },
+ { offset: 1, color: '#ffffff' },
+ ],
+ },
+ 100,
+ 100,
+ )
+
+ expect(gradient).toBeTruthy()
+ expect(context.createLinearGradient).toHaveBeenCalled()
+ expect(context.gradient.stops).toEqual([
+ { offset: 0, color: '#000000' },
+ { offset: 1, color: '#ffffff' },
+ ])
+ })
+
+ it('creates a radial gradient', () => {
+ const context = createMockContext()
+
+ const gradient = createCanvasGradient(
+ context,
+ {
+ type: 'radial',
+ stops: [{ offset: 0, color: '#ff0000' }],
+ },
+ 200,
+ 200,
+ )
+
+ expect(gradient).toBeTruthy()
+ expect(context.createRadialGradient).toHaveBeenCalled()
+ expect(context.gradient.stops).toEqual([{ offset: 0, color: '#ff0000' }])
+ })
+
+ it('returns null when there are no stops', () => {
+ const context = createMockContext()
+
+ expect(
+ createCanvasGradient(context, { type: 'linear', stops: [] }, 100, 100),
+ ).toBeNull()
+ })
+})
diff --git a/src/lib/canvas/create-canvas-gradient.ts b/src/lib/canvas/create-canvas-gradient.ts
new file mode 100644
index 0000000..5b688c4
--- /dev/null
+++ b/src/lib/canvas/create-canvas-gradient.ts
@@ -0,0 +1,56 @@
+import type { GradientStop } from '@/lib/types'
+
+export interface CanvasGradientSpec {
+ type: 'linear' | 'radial'
+ angle?: number
+ stops: GradientStop[]
+}
+
+export function createCanvasGradient(
+ context: CanvasRenderingContext2D,
+ spec: CanvasGradientSpec,
+ width: number,
+ height: number,
+): CanvasGradient | null {
+ if (spec.stops.length === 0) return null
+
+ let gradient: CanvasGradient
+ if (spec.type === 'radial') {
+ gradient = context.createRadialGradient(
+ width / 2,
+ height / 2,
+ 0,
+ width / 2,
+ height / 2,
+ Math.max(width, height) / 2,
+ )
+ } else {
+ const angle = ((spec.angle ?? 180) * Math.PI) / 180
+ const cx = width / 2
+ const cy = height / 2
+ const len = Math.max(width, height)
+ gradient = context.createLinearGradient(
+ cx - (Math.cos(angle) * len) / 2,
+ cy - (Math.sin(angle) * len) / 2,
+ cx + (Math.cos(angle) * len) / 2,
+ cy + (Math.sin(angle) * len) / 2,
+ )
+ }
+
+ for (const stop of spec.stops) {
+ gradient.addColorStop(stop.offset, stop.color)
+ }
+ return gradient
+}
+
+export function fillRectWithCanvasGradient(
+ context: CanvasRenderingContext2D,
+ spec: CanvasGradientSpec,
+ width: number,
+ height: number,
+): void {
+ const gradient = createCanvasGradient(context, spec, width, height)
+ if (!gradient) return
+ context.fillStyle = gradient
+ context.fillRect(0, 0, width, height)
+}
diff --git a/src/lib/constants/brand.ts b/src/lib/constants/brand.ts
new file mode 100644
index 0000000..598dacd
--- /dev/null
+++ b/src/lib/constants/brand.ts
@@ -0,0 +1,22 @@
+export const BRAND_PRIMARY = '#0D9488'
+export const BRAND_PRIMARY_LIGHT = '#2DD4BF'
+
+export const APP_NAME = 'Framekit'
+export const APP_TAGLINE = 'Store-ready screenshots'
+export const APP_DESCRIPTION =
+ 'Design App Store and Google Play screenshots in your browser. Device mockups, templates, multi-screen projects, and one-click store export — local-first, no account.'
+
+export const GITHUB_REPO_URL = 'https://github.com/mdadul/FrameKit'
+
+export const GOOGLE_FONTS = [
+ 'Inter',
+ 'Roboto',
+ 'Poppins',
+ 'Montserrat',
+ 'Open Sans',
+ 'Lato',
+ 'Oswald',
+ 'Raleway',
+ 'Nunito',
+ 'Playfair Display',
+]
diff --git a/src/lib/constants.ts b/src/lib/constants/defaults.ts
similarity index 68%
rename from src/lib/constants.ts
rename to src/lib/constants/defaults.ts
index 1c4e042..02d5d85 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants/defaults.ts
@@ -1,20 +1,12 @@
import type {
BackgroundConfig,
BrandKit,
- ExportPreferences,
ProjectSettings,
ShadowConfig,
UserPreferences,
WorkspacePreferences,
} from '@/lib/types'
-
-export const BRAND_PRIMARY = '#0D9488'
-export const BRAND_PRIMARY_LIGHT = '#2DD4BF'
-
-export const APP_NAME = 'Framekit'
-export const APP_TAGLINE = 'Store-ready screenshots'
-export const APP_DESCRIPTION =
- 'Design App Store and Google Play screenshots in your browser. Device mockups, templates, multi-screen projects, and one-click store export — local-first, no account.'
+import { DEFAULT_EXPORT_PREFS } from '@/lib/constants/export'
export const DEFAULT_DESIGN_WIDTH = 1290
export const DEFAULT_DESIGN_HEIGHT = 2796
@@ -34,6 +26,7 @@ export function scaledDeviceFrame(scale: number) {
height: Math.round(DEFAULT_DEVICE_HEIGHT * scale),
}
}
+
export const MAX_SCREENS = 10
export const MAX_HISTORY = 100
export const MAX_ELEMENTS_SOFT = 50
@@ -73,16 +66,6 @@ export const DEFAULT_WORKSPACE: WorkspacePreferences = {
highDpiCanvas: true,
}
-export const DEFAULT_EXPORT_PREFS: ExportPreferences = {
- defaultFormat: 'png',
- defaultScale: 1,
- jpegQuality: 0.92,
- transparentBackground: false,
- fileNamePattern: '{project}-{screen}-{preset}',
- lastUsedPresets: [],
- resizeStrategy: 'fit',
-}
-
export const DEFAULT_USER_PREFERENCES: UserPreferences = {
id: 'default',
theme: 'system',
@@ -90,18 +73,3 @@ export const DEFAULT_USER_PREFERENCES: UserPreferences = {
export: DEFAULT_EXPORT_PREFS,
brandKit: DEFAULT_BRAND_KIT,
}
-
-export const GOOGLE_FONTS = [
- 'Inter',
- 'Roboto',
- 'Poppins',
- 'Montserrat',
- 'Open Sans',
- 'Lato',
- 'Oswald',
- 'Raleway',
- 'Nunito',
- 'Playfair Display',
-]
-
-export const GITHUB_REPO_URL = 'https://github.com/emdadulislam/app-screen-generator'
diff --git a/src/lib/constants/export.ts b/src/lib/constants/export.ts
new file mode 100644
index 0000000..8704067
--- /dev/null
+++ b/src/lib/constants/export.ts
@@ -0,0 +1,11 @@
+import type { ExportPreferences } from '@/lib/types'
+
+export const DEFAULT_EXPORT_PREFS: ExportPreferences = {
+ defaultFormat: 'png',
+ defaultScale: 1,
+ jpegQuality: 0.92,
+ transparentBackground: false,
+ fileNamePattern: '{project}-{screen}-{preset}',
+ lastUsedPresets: [],
+ resizeStrategy: 'fit',
+}
diff --git a/src/lib/constants/index.ts b/src/lib/constants/index.ts
new file mode 100644
index 0000000..777eeb1
--- /dev/null
+++ b/src/lib/constants/index.ts
@@ -0,0 +1,7 @@
+export * from '@/lib/constants/brand'
+export * from '@/lib/constants/defaults'
+export * from '@/lib/constants/export'
+export {
+ MIN_WORKSPACE_ZOOM as MIN_ZOOM,
+ MAX_WORKSPACE_ZOOM as MAX_ZOOM,
+} from '@/lib/constants/workspace'
diff --git a/src/lib/constants/workspace.ts b/src/lib/constants/workspace.ts
new file mode 100644
index 0000000..c817937
--- /dev/null
+++ b/src/lib/constants/workspace.ts
@@ -0,0 +1,2 @@
+export const MIN_WORKSPACE_ZOOM = 0.08
+export const MAX_WORKSPACE_ZOOM = 2.5
diff --git a/src/lib/elements/element-meta.test.ts b/src/lib/elements/element-meta.test.ts
new file mode 100644
index 0000000..8bd1fff
--- /dev/null
+++ b/src/lib/elements/element-meta.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { ELEMENT_TYPE_META, getElementTypeLabel, isTextElement } from '@/lib/elements/element-meta'
+import { createTextElement, createShapeElement } from '@/lib/factories'
+
+describe('element-meta', () => {
+ it('defines metadata for every element type', () => {
+ const types = ['text', 'shape', 'image', 'device', 'group'] as const
+ for (const type of types) {
+ expect(ELEMENT_TYPE_META[type].label.length).toBeGreaterThan(0)
+ expect(ELEMENT_TYPE_META[type].icon).toBeTruthy()
+ }
+ })
+
+ it('narrows text elements', () => {
+ const text = createTextElement()
+ expect(isTextElement(text)).toBe(true)
+ expect(isTextElement(createShapeElement())).toBe(false)
+ })
+
+ it('returns human-readable labels', () => {
+ expect(getElementTypeLabel(createTextElement())).toBe('Text')
+ })
+})
diff --git a/src/lib/elements/element-meta.ts b/src/lib/elements/element-meta.ts
new file mode 100644
index 0000000..ed5a7c1
--- /dev/null
+++ b/src/lib/elements/element-meta.ts
@@ -0,0 +1,63 @@
+import {
+ Circle,
+ Folder,
+ Image as ImageIcon,
+ Layers,
+ Minus,
+ Smartphone,
+ Square,
+ Triangle,
+ Type,
+} from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import type { Element, ShapeElement, ShapeKind, TextElement, ImageElement, DeviceElement } from '@/lib/types'
+
+export const ELEMENT_TYPE_META: Record<
+ Element['type'],
+ { label: string; icon: LucideIcon }
+> = {
+ text: { label: 'Text', icon: Type },
+ shape: { label: 'Shape', icon: Square },
+ image: { label: 'Image', icon: ImageIcon },
+ device: { label: 'Device', icon: Smartphone },
+ group: { label: 'Group', icon: Layers },
+}
+
+const SHAPE_KIND_ICONS: Record = {
+ rectangle: Square,
+ circle: Circle,
+ triangle: Triangle,
+ line: Minus,
+}
+
+export function isTextElement(element: Element): element is TextElement {
+ return element.type === 'text'
+}
+
+export function isImageElement(element: Element): element is ImageElement {
+ return element.type === 'image'
+}
+
+export function isDeviceElement(element: Element): element is DeviceElement {
+ return element.type === 'device'
+}
+
+export function isShapeElement(element: Element): element is ShapeElement {
+ return element.type === 'shape'
+}
+
+export function getElementTypeLabel(element: Element): string {
+ return ELEMENT_TYPE_META[element.type].label
+}
+
+export function getElementTypeIcon(element: Element): LucideIcon {
+ if (element.type === 'shape') {
+ return SHAPE_KIND_ICONS[element.shapeKind] ?? Square
+ }
+ return ELEMENT_TYPE_META[element.type].icon
+}
+
+export function getLayerIcon(element: Element): LucideIcon {
+ if (element.type === 'group') return Folder
+ return getElementTypeIcon(element)
+}
diff --git a/src/lib/elements/selection-fields.test.ts b/src/lib/elements/selection-fields.test.ts
new file mode 100644
index 0000000..e3fa2fa
--- /dev/null
+++ b/src/lib/elements/selection-fields.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+import { sharedNumber } from '@/lib/elements/selection-fields'
+
+describe('sharedNumber', () => {
+ it('returns null for an empty list', () => {
+ expect(sharedNumber([])).toBeNull()
+ })
+
+ it('returns the value when all match', () => {
+ expect(sharedNumber([10, 10, 10])).toBe(10)
+ })
+
+ it('returns null when values differ', () => {
+ expect(sharedNumber([10, 20])).toBeNull()
+ })
+})
diff --git a/src/lib/elements/selection-fields.ts b/src/lib/elements/selection-fields.ts
new file mode 100644
index 0000000..6f631e3
--- /dev/null
+++ b/src/lib/elements/selection-fields.ts
@@ -0,0 +1,6 @@
+/** Returns the shared value when all numbers match, otherwise null (mixed selection). */
+export function sharedNumber(values: number[]): number | null {
+ if (values.length === 0) return null
+ const [first] = values
+ return values.every((value) => value === first) ? first : null
+}
diff --git a/src/lib/export/export-form-state.test.ts b/src/lib/export/export-form-state.test.ts
new file mode 100644
index 0000000..d34ce9b
--- /dev/null
+++ b/src/lib/export/export-form-state.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+import {
+ createExportFormState,
+ exportFormReducer,
+ type ExportFormState,
+} from '@/lib/export/export-form-state'
+import { DEFAULT_EXPORT_PREFS } from '@/lib/constants/export'
+
+const baseState = createExportFormState(DEFAULT_EXPORT_PREFS, ['preset-a', 'preset-b'])
+
+describe('exportFormReducer', () => {
+ it('toggles a screen id on and off', () => {
+ const added = exportFormReducer(baseState, {
+ type: 'toggle_screen',
+ screenId: 'screen-1',
+ checked: true,
+ })
+ expect(added.selectedScreenIds).toEqual(['screen-1'])
+
+ const removed = exportFormReducer(added, {
+ type: 'toggle_screen',
+ screenId: 'screen-1',
+ checked: false,
+ })
+ expect(removed.selectedScreenIds).toEqual([])
+ })
+
+ it('selects all screens when toggle_all_screens is checked', () => {
+ const next = exportFormReducer(baseState, {
+ type: 'toggle_all_screens',
+ checked: true,
+ allIds: ['a', 'b'],
+ })
+ expect(next.selectedScreenIds).toEqual(['a', 'b'])
+ })
+
+ it('initializes selected screens from init_screens', () => {
+ const next = exportFormReducer(baseState, {
+ type: 'init_screens',
+ screenIds: ['x', 'y'],
+ })
+ expect(next.selectedScreenIds).toEqual(['x', 'y'])
+ })
+
+ it('tracks export progress and busy state', () => {
+ let state: ExportFormState = exportFormReducer(baseState, { type: 'set_busy', busy: true })
+ state = exportFormReducer(state, {
+ type: 'set_progress',
+ progress: { completed: 2, total: 5 },
+ })
+ expect(state.busy).toBe(true)
+ expect(state.progress).toEqual({ completed: 2, total: 5 })
+ })
+})
diff --git a/src/lib/export/export-form-state.ts b/src/lib/export/export-form-state.ts
new file mode 100644
index 0000000..a6e940f
--- /dev/null
+++ b/src/lib/export/export-form-state.ts
@@ -0,0 +1,110 @@
+import type { ExportDialogTab } from '@/lib/export/types'
+import type { ExportFormat, ExportPreferences, ResizeStrategy } from '@/lib/types'
+
+export interface ExportFormState {
+ tab: ExportDialogTab
+ format: ExportFormat
+ scale: 1 | 2 | 3
+ transparentBackground: boolean
+ jpegQuality: number
+ resizeStrategy: ResizeStrategy
+ currentTarget: string
+ selectedPresets: string[]
+ selectedScreenIds: string[]
+ organizeByPlatform: boolean
+ platformAware: boolean
+ progress: { completed: number; total: number } | null
+ busy: boolean
+}
+
+export type ExportFormAction =
+ | { type: 'set_tab'; tab: ExportDialogTab }
+ | { type: 'set_format'; format: ExportFormat }
+ | { type: 'set_scale'; scale: 1 | 2 | 3 }
+ | { type: 'set_transparent_background'; value: boolean }
+ | { type: 'set_jpeg_quality'; value: number }
+ | { type: 'set_resize_strategy'; value: ResizeStrategy }
+ | { type: 'set_current_target'; value: string }
+ | { type: 'toggle_preset'; presetId: string; checked: boolean }
+ | { type: 'toggle_screen'; screenId: string; checked: boolean }
+ | { type: 'toggle_all_screens'; checked: boolean; allIds: string[] }
+ | { type: 'set_organize_by_platform'; value: boolean }
+ | { type: 'set_platform_aware'; value: boolean }
+ | { type: 'set_progress'; progress: ExportFormState['progress'] }
+ | { type: 'set_busy'; busy: boolean }
+ | { type: 'init_screens'; screenIds: string[] }
+
+export function createExportFormState(
+ prefs: ExportPreferences,
+ defaultPresets: string[],
+): ExportFormState {
+ return {
+ tab: 'quick',
+ format: prefs.defaultFormat,
+ scale: prefs.defaultScale,
+ transparentBackground: prefs.transparentBackground,
+ jpegQuality: prefs.jpegQuality,
+ resizeStrategy: prefs.resizeStrategy ?? 'fit',
+ currentTarget: 'design',
+ selectedPresets:
+ prefs.lastUsedPresets.length > 0 ? prefs.lastUsedPresets : defaultPresets,
+ selectedScreenIds: [],
+ organizeByPlatform: true,
+ platformAware: true,
+ progress: null,
+ busy: false,
+ }
+}
+
+export function exportFormReducer(
+ state: ExportFormState,
+ action: ExportFormAction,
+): ExportFormState {
+ switch (action.type) {
+ case 'set_tab':
+ return { ...state, tab: action.tab }
+ case 'set_format':
+ return { ...state, format: action.format }
+ case 'set_scale':
+ return { ...state, scale: action.scale }
+ case 'set_transparent_background':
+ return { ...state, transparentBackground: action.value }
+ case 'set_jpeg_quality':
+ return { ...state, jpegQuality: action.value }
+ case 'set_resize_strategy':
+ return { ...state, resizeStrategy: action.value }
+ case 'set_current_target':
+ return { ...state, currentTarget: action.value }
+ case 'toggle_preset':
+ return {
+ ...state,
+ selectedPresets: action.checked
+ ? [...state.selectedPresets, action.presetId]
+ : state.selectedPresets.filter((id) => id !== action.presetId),
+ }
+ case 'toggle_screen':
+ return {
+ ...state,
+ selectedScreenIds: action.checked
+ ? [...state.selectedScreenIds, action.screenId]
+ : state.selectedScreenIds.filter((id) => id !== action.screenId),
+ }
+ case 'toggle_all_screens':
+ return {
+ ...state,
+ selectedScreenIds: action.checked ? action.allIds : [],
+ }
+ case 'set_organize_by_platform':
+ return { ...state, organizeByPlatform: action.value }
+ case 'set_platform_aware':
+ return { ...state, platformAware: action.value }
+ case 'set_progress':
+ return { ...state, progress: action.progress }
+ case 'set_busy':
+ return { ...state, busy: action.busy }
+ case 'init_screens':
+ return { ...state, selectedScreenIds: action.screenIds }
+ default:
+ return state
+ }
+}
diff --git a/src/lib/export/export-plan.ts b/src/lib/export/export-plan.ts
index e9b1d68..9282e92 100644
--- a/src/lib/export/export-plan.ts
+++ b/src/lib/export/export-plan.ts
@@ -45,12 +45,14 @@ export function getSmartExportPresetIds(): string[] {
return [...SMART_EXPORT_PRESETS]
}
-export function summarizeExportPlan(items: ExportPlanItem[]): {
+export interface ExportPlanSummary {
totalFiles: number
appleFiles: number
androidFiles: number
presets: StorePreset[]
-} {
+}
+
+export function summarizeExportPlan(items: ExportPlanItem[]): ExportPlanSummary {
const presetMap = new Map()
let appleFiles = 0
let androidFiles = 0
diff --git a/src/lib/export/prepare-export-screen.test.ts b/src/lib/export/prepare-export-screen.test.ts
new file mode 100644
index 0000000..302d8e3
--- /dev/null
+++ b/src/lib/export/prepare-export-screen.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import { prepareExportScreen } from '@/lib/export/prepare-export-screen'
+import { minimalScreen } from '@/test/fixtures/minimal-screen'
+
+describe('prepareExportScreen', () => {
+ it('resizes screen dimensions for export preset', () => {
+ const screen = minimalScreen()
+ const prepared = prepareExportScreen(screen, { width: 1080, height: 1920 }, 'fit')
+
+ expect(prepared.width).toBe(1080)
+ expect(prepared.height).toBe(1920)
+ expect(prepared.elements).toHaveLength(screen.elements.length)
+ expect(prepared.id).toBe(screen.id)
+ })
+
+ it('preserves element count after resize', () => {
+ const screen = minimalScreen()
+ const prepared = prepareExportScreen(screen, { width: 500, height: 500 }, 'fill')
+ expect(prepared.elements).toHaveLength(1)
+ })
+})
diff --git a/src/lib/export/prepare-export-screen.ts b/src/lib/export/prepare-export-screen.ts
new file mode 100644
index 0000000..3af0e2d
--- /dev/null
+++ b/src/lib/export/prepare-export-screen.ts
@@ -0,0 +1,18 @@
+import { autoResizeScreen } from '@/lib/resize/auto-resize'
+import type { ResizeStrategy } from '@/lib/resize/auto-resize'
+import type { Screen, StorePreset } from '@/lib/types'
+
+export function prepareExportScreen(
+ screen: Screen,
+ preset: Pick,
+ strategy: ResizeStrategy,
+): Screen {
+ const resized = autoResizeScreen(screen, preset.width, preset.height, { strategy })
+ return {
+ ...screen,
+ width: resized.width,
+ height: resized.height,
+ background: resized.background,
+ elements: resized.elements,
+ }
+}
diff --git a/src/lib/export/renderer.ts b/src/lib/export/renderer.ts
index 5a95d41..5365994 100644
--- a/src/lib/export/renderer.ts
+++ b/src/lib/export/renderer.ts
@@ -7,8 +7,7 @@ import type {
Screen,
TextElement,
} from '@/lib/types'
-import { autoResizeScreen } from '@/lib/resize/auto-resize'
-import { getBackgroundGradientProps, getElementShadowProps, getGradientProps } from '@/lib/canvas/helpers'
+import { createCanvasGradient as buildCanvasGradient } from '@/lib/canvas/create-canvas-gradient'
import { renderDeviceComposite } from '@/lib/canvas/device-render'
import { buildBackgroundCanvas } from '@/lib/canvas/backgrounds'
import { BRAND_PRIMARY } from '@/lib/constants'
@@ -89,33 +88,16 @@ function createCanvasGradient(
height: number,
): CanvasGradient | null {
if (fill.type !== 'gradient' || !fill.gradient) return null
- const definition = fill.gradient
- let gradient: CanvasGradient
- if (definition.type === 'radial') {
- gradient = context.createRadialGradient(
- width / 2,
- height / 2,
- 0,
- width / 2,
- height / 2,
- Math.max(width, height) / 2,
- )
- } else {
- const angle = ((definition.angle ?? 180) * Math.PI) / 180
- const cx = width / 2
- const cy = height / 2
- const len = Math.max(width, height)
- gradient = context.createLinearGradient(
- cx - (Math.cos(angle) * len) / 2,
- cy - (Math.sin(angle) * len) / 2,
- cx + (Math.cos(angle) * len) / 2,
- cy + (Math.sin(angle) * len) / 2,
- )
- }
- for (const stop of definition.stops) {
- gradient.addColorStop(stop.offset, stop.color)
- }
- return gradient
+ return buildCanvasGradient(
+ context,
+ {
+ type: fill.gradient.type,
+ angle: fill.gradient.angle,
+ stops: fill.gradient.stops,
+ },
+ width,
+ height,
+ )
}
function drawTextDecoration(
@@ -478,5 +460,3 @@ export async function renderScreenToBlob(
const response = await fetch(dataUrl)
return response.blob()
}
-
-export { autoResizeScreen, getBackgroundGradientProps, getElementShadowProps, getGradientProps }
diff --git a/src/lib/export/types.ts b/src/lib/export/types.ts
new file mode 100644
index 0000000..f08ad1e
--- /dev/null
+++ b/src/lib/export/types.ts
@@ -0,0 +1 @@
+export type ExportDialogTab = 'quick' | 'advanced'
diff --git a/src/lib/export/zip.ts b/src/lib/export/zip.ts
index f428860..b627a21 100644
--- a/src/lib/export/zip.ts
+++ b/src/lib/export/zip.ts
@@ -1,7 +1,7 @@
import JSZip from 'jszip'
-import { renderScreenToBlob } from '@/lib/export/renderer'
import { buildExportPlan } from '@/lib/export/export-plan'
-import { autoResizeScreen } from '@/lib/resize/auto-resize'
+import { prepareExportScreen } from '@/lib/export/prepare-export-screen'
+import { renderScreenToBlob } from '@/lib/export/renderer'
import type { KonvaStageBridge } from '@/stores/editor-store'
import type { ResizeStrategy } from '@/lib/resize/auto-resize'
import { applyFileNamePattern } from '@/lib/utils'
@@ -40,17 +40,9 @@ export async function exportScreensAsZip(options: BulkExportOptions): Promise idSet.has(element.id))
+}
+
+export function resolveSelectedElementsById(
+ screen: Screen | undefined,
+ selectedIds: string[],
+): Element[] {
+ if (!screen) return []
+ return selectedIds
+ .map((id) => screen.elements.find((element) => element.id === id))
+ .filter((element): element is Element => element != null)
+}
diff --git a/src/lib/templates/apply-template-to-screen.test.ts b/src/lib/templates/apply-template-to-screen.test.ts
new file mode 100644
index 0000000..2ba2520
--- /dev/null
+++ b/src/lib/templates/apply-template-to-screen.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+import { createTextElement } from '@/lib/factories'
+import { applyTemplateToScreen } from '@/lib/templates/apply-template-to-screen'
+import { minimalBackground } from '@/test/fixtures/minimal-screen'
+import { minimalScreen } from '@/test/fixtures/minimal-screen'
+
+describe('applyTemplateToScreen', () => {
+ const templateElements = [
+ createTextElement({ name: 'Template Text', text: 'From template', zIndex: 0 }),
+ ]
+ const background = minimalBackground()
+
+ it('replace mode replaces background and elements', () => {
+ const screen = minimalScreen()
+ const result = applyTemplateToScreen(screen, templateElements, background, 'replace')
+
+ expect(result.background).toEqual(background)
+ expect(result.elements).toHaveLength(1)
+ expect(result.elements[0]?.type).toBe('text')
+ if (result.elements[0]?.type === 'text') {
+ expect(result.elements[0].text).toBe('From template')
+ }
+ expect(result.elements[0]?.id).not.toBe('existing-text')
+ })
+
+ it('background mode only updates background', () => {
+ const screen = minimalScreen()
+ const result = applyTemplateToScreen(screen, templateElements, background, 'background')
+
+ expect(result.background).toEqual(background)
+ expect(result.elements).toHaveLength(1)
+ expect(result.elements[0]?.id).toBe('existing-text')
+ })
+
+ it('add-elements mode appends with higher z-index', () => {
+ const screen = minimalScreen()
+ const result = applyTemplateToScreen(screen, templateElements, background, 'add-elements')
+
+ expect(result.elements).toHaveLength(2)
+ expect(result.elements[0]?.id).toBe('existing-text')
+ expect(result.elements[1]?.zIndex).toBeGreaterThan(result.elements[0]?.zIndex ?? 0)
+ })
+})
diff --git a/src/lib/templates/apply-template-to-screen.ts b/src/lib/templates/apply-template-to-screen.ts
new file mode 100644
index 0000000..7fc31da
--- /dev/null
+++ b/src/lib/templates/apply-template-to-screen.ts
@@ -0,0 +1,38 @@
+import { createId } from '@/lib/utils'
+import type { BackgroundConfig, Element, Screen, TemplateApplyMode } from '@/lib/types'
+
+export function applyTemplateToScreen(
+ screen: Screen,
+ elements: Element[],
+ background: BackgroundConfig,
+ mode: TemplateApplyMode = 'replace',
+): Screen {
+ const next = structuredClone(screen)
+
+ if (mode === 'background') {
+ next.background = structuredClone(background)
+ return next
+ }
+
+ const templateElements = elements.map((element, index) => ({
+ ...structuredClone(element),
+ id: createId(),
+ zIndex: index,
+ }))
+
+ if (mode === 'add-elements') {
+ const maxZ = next.elements.reduce((max, element) => Math.max(max, element.zIndex), -1)
+ next.elements = [
+ ...next.elements,
+ ...templateElements.map((element, index) => ({
+ ...element,
+ zIndex: maxZ + 1 + index,
+ })),
+ ]
+ return next
+ }
+
+ next.background = structuredClone(background)
+ next.elements = templateElements
+ return next
+}
diff --git a/src/lib/templates/filter-templates.test.ts b/src/lib/templates/filter-templates.test.ts
new file mode 100644
index 0000000..2740c7c
--- /dev/null
+++ b/src/lib/templates/filter-templates.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest'
+import { TEMPLATES } from '@/lib/templates/catalog'
+import { filterTemplates } from '@/lib/templates/filter-templates'
+
+describe('filterTemplates', () => {
+ it('returns all templates when query and tag are empty', () => {
+ expect(filterTemplates(TEMPLATES, {})).toHaveLength(TEMPLATES.length)
+ })
+
+ it('filters by tag', () => {
+ const showcase = filterTemplates(TEMPLATES, { tag: 'showcase' })
+ expect(showcase.length).toBeGreaterThan(0)
+ expect(showcase.every((template) => template.tags?.includes('showcase'))).toBe(true)
+ })
+
+ it('filters by name query', () => {
+ const first = TEMPLATES[0]
+ const results = filterTemplates(TEMPLATES, { query: first.name.slice(0, 4) })
+ expect(results.some((template) => template.id === first.id)).toBe(true)
+ })
+
+ it('returns empty when nothing matches', () => {
+ expect(filterTemplates(TEMPLATES, { query: 'zzzznonexistent-template' })).toHaveLength(0)
+ })
+})
diff --git a/src/lib/templates/filter-templates.ts b/src/lib/templates/filter-templates.ts
new file mode 100644
index 0000000..6755bff
--- /dev/null
+++ b/src/lib/templates/filter-templates.ts
@@ -0,0 +1,27 @@
+import { CATEGORY_LABELS, LAYOUT_LABELS } from '@/lib/templates/types'
+import type { TemplateDefinition } from '@/lib/templates/types'
+
+export interface FilterTemplatesOptions {
+ query?: string
+ tag?: string
+}
+
+export function filterTemplates(
+ templates: TemplateDefinition[],
+ options: FilterTemplatesOptions,
+): TemplateDefinition[] {
+ const normalized = options.query?.trim().toLowerCase() ?? ''
+ const tag = options.tag
+
+ return templates.filter((template) => {
+ if (tag && tag !== 'all' && !template.tags?.includes(tag)) return false
+ if (!normalized) return true
+ return (
+ template.name.toLowerCase().includes(normalized) ||
+ template.category.toLowerCase().includes(normalized) ||
+ CATEGORY_LABELS[template.category].toLowerCase().includes(normalized) ||
+ LAYOUT_LABELS[template.layout].toLowerCase().includes(normalized) ||
+ template.tags?.some((item) => item.toLowerCase().includes(normalized))
+ )
+ })
+}
diff --git a/src/lib/templates/index.ts b/src/lib/templates/index.ts
index 56436bd..2cea263 100644
--- a/src/lib/templates/index.ts
+++ b/src/lib/templates/index.ts
@@ -9,6 +9,7 @@ export {
type TemplateCategory,
} from '@/lib/templates/types'
export { renderTemplatePreview, templateToScreen, applyTemplateToProject, SHOWCASE_TEMPLATE_IDS } from '@/lib/templates/preview'
+export { filterTemplates } from '@/lib/templates/filter-templates'
import { TEMPLATES } from '@/lib/templates/catalog'
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 330fa6f..00cdb06 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -1,142 +1,27 @@
-import { useEffect, useState } from 'react'
-import { createFileRoute, useNavigate } from '@tanstack/react-router'
-import { createAndLoadProject, useProjectStore } from '@/stores/project-store'
-import {
- deleteProject,
- getAllProjects,
- getProjectAssets,
- saveProject,
-} from '@/lib/db'
-import { createAssetObjectUrl } from '@/lib/assets/image-pipeline'
-import { cloneProject } from '@/lib/factories'
-import { exportProjectFile, importProjectFile } from '@/lib/project-io'
-import { applyTemplateToProject } from '@/lib/templates/preview'
-import { downloadBlob } from '@/lib/utils'
-import { confirm } from '@/stores/confirm-store'
-import { toast } from '@/stores/toast-store'
+import { createFileRoute } from '@tanstack/react-router'
+import { useProjectsCatalog } from '@/hooks/useProjectsCatalog'
import { LandingPage } from '@/components/landing/LandingPage'
-import type { Project, TemplateDefinition } from '@/lib/types'
export const Route = createFileRoute('/')({
component: HomePage,
})
function HomePage() {
- const navigate = useNavigate()
- const [projects, setProjects] = useState([])
- const [loading, setLoading] = useState(true)
- const [assetUrls, setAssetUrls] = useState>>({})
-
- const refresh = async () => {
- const items = await getAllProjects()
- setProjects(items)
-
- const urls: Record> = {}
- for (const project of items) {
- const assets = await getProjectAssets(project.id)
- urls[project.id] = {}
- for (const asset of assets) {
- urls[project.id][asset.id] = createAssetObjectUrl(asset)
- }
- }
- setAssetUrls(urls)
- setLoading(false)
- }
-
- useEffect(() => {
- void refresh()
- }, [])
-
- const goToEditor = (projectId: string) => {
- void navigate({ to: '/editor/$projectId', params: { projectId } })
- }
-
- const createBlankProject = async () => {
- const project = createAndLoadProject('New Project')
- await saveProject(project)
- await refresh()
- goToEditor(project.id)
- }
-
- const createFromTemplate = async (template: TemplateDefinition) => {
- const baseName = template.name.split('—')[0]?.trim() || 'New Project'
- const project = createAndLoadProject(baseName)
- const withTemplate = applyTemplateToProject(project, template)
- useProjectStore.getState().loadProject(withTemplate)
- await saveProject(withTemplate)
- await refresh()
- goToEditor(withTemplate.id)
- }
-
- const openProject = (projectId: string) => {
- goToEditor(projectId)
- }
-
- const duplicate = async (project: Project) => {
- const copy = cloneProject(project)
- await saveProject(copy)
- await refresh()
- const open = await confirm({
- title: 'Open duplicated project?',
- description: `"${copy.name}" was created. Open it now?`,
- confirmLabel: 'Open copy',
- cancelLabel: 'Stay here',
- })
- if (open) openProject(copy.id)
- }
-
- const remove = async (project: Project) => {
- const confirmed = await confirm({
- title: 'Delete project?',
- description: `"${project.name}" will be permanently deleted. Export it first if you need a backup.`,
- confirmLabel: 'Delete',
- destructive: true,
- })
- if (!confirmed) return
- await deleteProject(project.id)
- await refresh()
- toast('Project deleted', 'success')
- }
-
- const renameProject = async (project: Project, name: string) => {
- const updated = { ...project, name, updatedAt: new Date().toISOString() }
- await saveProject(updated)
- await refresh()
- }
-
- const importProject = async (event: React.ChangeEvent) => {
- const file = event.target.files?.[0]
- if (!file) return
- try {
- const project = await importProjectFile(file)
- await saveProject(project)
- await refresh()
- toast('Project imported', 'success')
- } catch {
- toast('Failed to import project', 'error')
- }
- event.target.value = ''
- }
-
- const exportProject = async (project: Project) => {
- const blob = await exportProjectFile(project)
- downloadBlob(blob, `${project.name}.ssgproj`)
- toast('Project exported', 'success')
- }
+ const catalog = useProjectsCatalog()
return (
void createBlankProject()}
- onCreateFromTemplate={(template) => void createFromTemplate(template)}
- onImport={(event) => void importProject(event)}
- onOpenProject={openProject}
- onDuplicate={(project) => void duplicate(project)}
- onRemove={(project) => void remove(project)}
- onRenameProject={(project, name) => void renameProject(project, name)}
- onExportProject={(project) => void exportProject(project)}
+ projects={catalog.projects}
+ loading={catalog.loading}
+ assetUrls={catalog.assetUrls}
+ onCreateBlankProject={() => void catalog.createBlankProject()}
+ onCreateFromTemplate={(template) => void catalog.createFromTemplate(template)}
+ onImport={(event) => void catalog.importProject(event)}
+ onOpenProject={catalog.openProject}
+ onDuplicate={(project) => void catalog.duplicate(project)}
+ onRemove={(project) => void catalog.remove(project)}
+ onRenameProject={(project, name) => void catalog.renameProject(project, name)}
+ onExportProject={(project) => void catalog.exportProject(project)}
/>
)
}
diff --git a/src/stores/project-store.ts b/src/stores/project-store.ts
index d8eda5d..5e09573 100644
--- a/src/stores/project-store.ts
+++ b/src/stores/project-store.ts
@@ -1,39 +1,53 @@
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import {
- DEFAULT_DEVICE_HEIGHT,
- DEFAULT_DEVICE_SHADOW_INTENSITY,
- DEFAULT_DEVICE_SHADOW_SPREAD,
- DEFAULT_DEVICE_WIDTH,
- DEFAULT_DEVICE_X,
- DEFAULT_DEVICE_Y,
- MAX_SCREENS,
-} from '@/lib/constants'
-import {
- cloneScreenDesign,
createImageElement,
createProject,
- createScreenFromPrevious,
createShapeElement,
createTextElement,
- duplicateElement,
- reindexElements,
- sortElementsByZIndex,
} from '@/lib/factories'
-import { saveProject } from '@/lib/db'
import {
- cloneScreenForAndroid,
- getScreenPlatform,
- isAppleScreen,
- sortScreensByPlatform,
-} from '@/lib/platform-copy'
-import { createId } from '@/lib/utils'
+ resetElementCoalesce,
+ resolveElementUpdateHistory,
+} from '@/stores/project/element-update-coalesce'
+import {
+ alignElementsOnScreen,
+ alignElementsToArtboard,
+ distributeElementsOnScreen,
+} from '@/stores/project/element-alignment'
+import {
+ addElementToScreen,
+ bringForwardElement,
+ deleteElementsFromScreen,
+ duplicateElementsOnScreen,
+ findScreenById,
+ groupElementsOnScreen,
+ reorderElementsOnScreen,
+ sendBackwardElement,
+ ungroupElementsOnScreen,
+ updateElementOnScreen,
+} from '@/stores/project/element-mutations'
+import { createDefaultDeviceElement } from '@/stores/project/create-device-element'
+import {
+ addScreenToProject,
+ applyTemplateToAllScreensInProject,
+ applyTemplateToScreenInProject,
+ copyAllScreensToAndroidInProject,
+ copyScreenToAndroidInProject,
+ deleteScreenFromProject,
+ duplicateScreenInProject,
+ renameScreenInProject,
+ reorderScreensInProject,
+ resolveNextActiveScreenId,
+ setScreenBackground,
+ syncLinkedAndroidScreenInProject,
+} from '@/stores/project/screen-mutations'
+import { saveProject } from '@/lib/db'
import { useHistoryStore } from '@/stores/history-store'
import { useEditorStore } from '@/stores/editor-store'
import type {
BackgroundConfig,
BrandKit,
- DeviceElement,
Element,
Project,
Screen,
@@ -101,73 +115,7 @@ function withHistory(project: Project, recordHistory = true) {
useHistoryStore.getState().setPresent(project, recordHistory)
}
-function applyTemplateToScreenState(
- screen: Screen,
- elements: Element[],
- background: BackgroundConfig,
- mode: TemplateApplyMode = 'replace',
-) {
- if (mode === 'background') {
- screen.background = structuredClone(background)
- return
- }
-
- const templateElements = elements.map((element, index) => ({
- ...structuredClone(element),
- id: createId(),
- zIndex: index,
- }))
-
- if (mode === 'add-elements') {
- const maxZ = screen.elements.reduce((max, element) => Math.max(max, element.zIndex), -1)
- screen.elements = [
- ...screen.elements,
- ...templateElements.map((element, index) => ({
- ...element,
- zIndex: maxZ + 1 + index,
- })),
- ]
- return
- }
-
- screen.background = structuredClone(background)
- screen.elements = templateElements
-}
-
-function getAndroidDeviceId(screen: Screen): string | undefined {
- const device = screen.elements.find((element) => element.type === 'device')
- return device?.type === 'device' ? device.deviceId : undefined
-}
-
-/**
- * History coalescing for continuous interactions (slider/color drags, canvas
- * moves). Repeated `updateElement` calls that target the same element + same
- * patched fields within a short window collapse into a SINGLE undo step:
- * the first call records a history baseline, subsequent calls only replace the
- * present without pushing more past entries. The window auto-closes after
- * inactivity; `endElementCoalesce()` lets callers close it deterministically
- * (e.g. on slider commit or color popover close).
- */
-const COALESCE_WINDOW_MS = 500
-let coalesceKey: string | null = null
-let coalesceTimer: ReturnType | null = null
-
-function resetCoalesce() {
- coalesceKey = null
- if (coalesceTimer) {
- clearTimeout(coalesceTimer)
- coalesceTimer = null
- }
-}
-
-function scheduleCoalesceClear() {
- if (coalesceTimer) clearTimeout(coalesceTimer)
- coalesceTimer = setTimeout(resetCoalesce, COALESCE_WINDOW_MS)
-}
-
-export function endElementCoalesce() {
- resetCoalesce()
-}
+export { endElementCoalesce } from '@/stores/project/element-update-coalesce'
export const useProjectStore = create()(
immer((set, get) => ({
@@ -177,7 +125,7 @@ export const useProjectStore = create()(
saveStatus: 'saved',
loadProject: (project) => {
- resetCoalesce()
+ resetElementCoalesce()
useHistoryStore.getState().reset(project)
set({ project, dirty: false, saveStatus: 'saved' })
},
@@ -186,7 +134,7 @@ export const useProjectStore = create()(
// Apply an undo/redo result WITHOUT resetting the history stacks, so that
// multi-step undo and redo keep working. The history store has already
// advanced its present pointer; we only mirror it into project state.
- resetCoalesce()
+ resetElementCoalesce()
set({ project, dirty: true, saveStatus: 'unsaved' })
},
@@ -249,9 +197,7 @@ export const useProjectStore = create()(
applyTemplateToAllScreens: (elements, background, mode = 'replace') => {
get().updateProject((project) => {
- for (const screen of project.screens) {
- applyTemplateToScreenState(screen, elements, background, mode)
- }
+ applyTemplateToAllScreensInProject(project, elements, background, mode)
})
},
@@ -263,39 +209,20 @@ export const useProjectStore = create()(
addScreen: () => {
get().updateProject((project) => {
- if (project.screens.length >= MAX_SCREENS) return
- const previous = project.screens.at(-1)
- project.screens.push(
- createScreenFromPrevious(previous, `Screen ${project.screens.length + 1}`),
- )
+ addScreenToProject(project)
})
},
duplicateScreen: (screenId) => {
get().updateProject((project) => {
- if (project.screens.length >= MAX_SCREENS) return
- const source = project.screens.find((screen) => screen.id === screenId)
- if (!source) return
- project.screens.push(cloneScreenDesign(source, `${source.name} copy`))
+ duplicateScreenInProject(project, screenId)
})
},
copyScreenToAndroid: (screenId, targetDeviceId) => {
let createdId: string | null = null
get().updateProject((project) => {
- const source = project.screens.find((screen) => screen.id === screenId)
- if (!source || !isAppleScreen(source)) return
-
- project.screens = project.screens.filter(
- (screen) =>
- !(getScreenPlatform(screen) === 'android' && screen.sourceScreenId === screenId),
- )
-
- if (project.screens.length >= MAX_SCREENS) return
-
- const copy = cloneScreenForAndroid(source, targetDeviceId)
- createdId = copy.id
- project.screens = sortScreensByPlatform([...project.screens, copy])
+ createdId = copyScreenToAndroidInProject(project, screenId, targetDeviceId)
})
return createdId
},
@@ -303,47 +230,15 @@ export const useProjectStore = create()(
syncLinkedAndroidScreen: (appleScreenId) => {
let synced = false
get().updateProject((project) => {
- const source = project.screens.find((screen) => screen.id === appleScreenId)
- if (!source || !isAppleScreen(source)) return
-
- const linked = project.screens.find(
- (screen) =>
- getScreenPlatform(screen) === 'android' && screen.sourceScreenId === appleScreenId,
- )
- if (!linked) return
-
- const targetDeviceId = getAndroidDeviceId(linked) ?? getAndroidDeviceId(source)
- if (!targetDeviceId) return
-
- const refreshed = cloneScreenForAndroid(source, targetDeviceId)
- linked.background = refreshed.background
- linked.elements = refreshed.elements
- linked.width = refreshed.width
- linked.height = refreshed.height
- synced = true
+ synced = syncLinkedAndroidScreenInProject(project, appleScreenId)
})
return synced
},
copyAllScreensToAndroid: (targetDeviceId) => {
- const createdIds: string[] = []
+ let createdIds: string[] = []
get().updateProject((project) => {
- const appleScreens = project.screens.filter(isAppleScreen)
- if (appleScreens.length === 0) return
-
- const appleIds = new Set(appleScreens.map((screen) => screen.id))
- project.screens = project.screens.filter(
- (screen) =>
- getScreenPlatform(screen) !== 'android' ||
- !screen.sourceScreenId ||
- !appleIds.has(screen.sourceScreenId),
- )
-
- const slotsAvailable = MAX_SCREENS - project.screens.length
- const toCopy = appleScreens.slice(0, slotsAvailable)
- const copies = toCopy.map((screen) => cloneScreenForAndroid(screen, targetDeviceId))
- createdIds.push(...copies.map((screen) => screen.id))
- project.screens = sortScreensByPlatform([...project.screens, ...copies])
+ createdIds = copyAllScreensToAndroidInProject(project, targetDeviceId)
})
return createdIds
},
@@ -351,15 +246,13 @@ export const useProjectStore = create()(
deleteScreen: (screenId) => {
let nextActiveId: string | null = null
get().updateProject((project) => {
- if (project.screens.length <= 1) return
- const index = project.screens.findIndex((screen) => screen.id === screenId)
- if (index === -1) return
- const remaining = project.screens.filter((screen) => screen.id !== screenId)
- const activeId = useEditorStore.getState().activeScreenId
- if (!activeId || activeId === screenId || !remaining.some((screen) => screen.id === activeId)) {
- nextActiveId = remaining[Math.min(index, remaining.length - 1)]?.id ?? remaining[0]?.id ?? null
- }
- project.screens = remaining
+ const result = deleteScreenFromProject(project, screenId)
+ if (!result) return
+ nextActiveId = resolveNextActiveScreenId(
+ result.deletedIndex,
+ result.remaining,
+ useEditorStore.getState().activeScreenId,
+ )
})
if (nextActiveId) {
useEditorStore.getState().focusScreen(nextActiveId, false)
@@ -370,17 +263,13 @@ export const useProjectStore = create()(
reorderScreens: (screenIds) => {
get().updateProject((project) => {
- const map = new Map(project.screens.map((screen) => [screen.id, screen]))
- project.screens = screenIds
- .map((id) => map.get(id))
- .filter((screen): screen is Screen => Boolean(screen))
+ reorderScreensInProject(project, screenIds)
})
},
renameScreen: (screenId, name) => {
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === screenId)
- if (screen) screen.name = name
+ renameScreenInProject(project, screenId, name)
})
},
@@ -388,8 +277,8 @@ export const useProjectStore = create()(
const activeScreen = get().getActiveScreen()
if (!activeScreen) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
- if (screen) screen.background = background
+ const screen = findScreenById(project, activeScreen.id)
+ if (screen) setScreenBackground(screen, background)
})
},
@@ -397,32 +286,20 @@ export const useProjectStore = create()(
const activeScreen = get().getActiveScreen()
if (!activeScreen) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- const zIndex = screen.elements.length
- screen.elements.push({ ...element, zIndex })
+ addElementToScreen(screen, element)
})
},
updateElement: (id, patch) => {
const activeScreen = get().getActiveScreen()
if (!activeScreen) return
- // Coalesce rapid updates to the same element + same fields (slider/color
- // drags, canvas moves) into one undo step: the first update records a
- // baseline, later ones in the window only replace the present.
- const key = `${id}:${Object.keys(patch).sort().join(',')}`
- const recordHistory = key !== coalesceKey
- coalesceKey = key
- scheduleCoalesceClear()
+ const recordHistory = resolveElementUpdateHistory(id, patch)
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- const index = screen.elements.findIndex((element) => element.id === id)
- if (index === -1) return
- screen.elements[index] = {
- ...screen.elements[index],
- ...patch,
- } as Element
+ updateElementOnScreen(screen, id, patch)
}, recordHistory)
},
@@ -430,11 +307,9 @@ export const useProjectStore = create()(
const activeScreen = get().getActiveScreen()
if (!activeScreen) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- screen.elements = reindexElements(
- sortElementsByZIndex(screen.elements).filter((element) => !ids.includes(element.id)),
- )
+ deleteElementsFromScreen(screen, ids)
})
},
@@ -442,12 +317,9 @@ export const useProjectStore = create()(
const activeScreen = get().getActiveScreen()
if (!activeScreen) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- const copies = screen.elements
- .filter((element) => ids.includes(element.id))
- .map((element) => duplicateElement(element))
- screen.elements = reindexElements([...sortElementsByZIndex(screen.elements), ...copies])
+ duplicateElementsOnScreen(screen, ids)
})
},
@@ -455,17 +327,9 @@ export const useProjectStore = create()(
const activeScreen = get().getActiveScreen()
if (!activeScreen) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- const map = new Map(screen.elements.map((element) => [element.id, element]))
- const orderedIds = new Set(elementIds)
- const ordered = elementIds
- .map((id) => map.get(id))
- .filter((element): element is Element => Boolean(element))
- const remaining = sortElementsByZIndex(
- screen.elements.filter((element) => !orderedIds.has(element.id)),
- )
- screen.elements = reindexElements([...ordered, ...remaining])
+ reorderElementsOnScreen(screen, elementIds)
})
},
@@ -473,13 +337,9 @@ export const useProjectStore = create()(
const activeScreen = get().getActiveScreen()
if (!activeScreen) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- const sorted = sortElementsByZIndex(screen.elements)
- const index = sorted.findIndex((element) => element.id === id)
- if (index < 0 || index === sorted.length - 1) return
- ;[sorted[index], sorted[index + 1]] = [sorted[index + 1], sorted[index]]
- screen.elements = reindexElements(sorted)
+ bringForwardElement(screen, id)
})
},
@@ -487,13 +347,9 @@ export const useProjectStore = create()(
const activeScreen = get().getActiveScreen()
if (!activeScreen) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- const sorted = sortElementsByZIndex(screen.elements)
- const index = sorted.findIndex((element) => element.id === id)
- if (index <= 0) return
- ;[sorted[index], sorted[index - 1]] = [sorted[index - 1], sorted[index]]
- screen.elements = reindexElements(sorted)
+ sendBackwardElement(screen, id)
})
},
@@ -502,12 +358,9 @@ export const useProjectStore = create()(
const activeScreen = get().getActiveScreen()
if (!activeScreen) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- const groupId = createId()
- screen.elements = screen.elements.map((element) =>
- ids.includes(element.id) ? { ...element, groupId } : element,
- )
+ groupElementsOnScreen(screen, ids)
})
},
@@ -515,19 +368,15 @@ export const useProjectStore = create()(
const activeScreen = get().getActiveScreen()
if (!activeScreen) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- screen.elements = screen.elements.map((element) =>
- element.groupId === groupId ? { ...element, groupId: undefined } : element,
- )
+ ungroupElementsOnScreen(screen, groupId)
})
},
applyTemplateToScreen: (screenId, elements, background, mode = 'replace') => {
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === screenId)
- if (!screen) return
- applyTemplateToScreenState(screen, elements, background, mode)
+ applyTemplateToScreenInProject(project, screenId, elements, background, mode)
})
},
@@ -550,67 +399,16 @@ export const useProjectStore = create()(
},
addDevice: (deviceId) => {
- const device: DeviceElement = {
- id: createId(),
- type: 'device',
- name: 'Device',
- deviceId,
- x: DEFAULT_DEVICE_X,
- y: DEFAULT_DEVICE_Y,
- width: DEFAULT_DEVICE_WIDTH,
- height: DEFAULT_DEVICE_HEIGHT,
- rotation: 0,
- opacity: 1,
- locked: false,
- visible: true,
- zIndex: 3,
- shadowIntensity: DEFAULT_DEVICE_SHADOW_INTENSITY,
- shadowSpread: DEFAULT_DEVICE_SHADOW_SPREAD,
- }
- get().addElement(device)
+ get().addElement(createDefaultDeviceElement(deviceId))
},
alignElements: (ids, alignment) => {
const activeScreen = get().getActiveScreen()
if (!activeScreen || ids.length === 0) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- const selected = screen.elements.filter((element) => ids.includes(element.id))
- if (selected.length === 0) return
-
- const bounds = {
- left: Math.min(...selected.map((element) => element.x)),
- right: Math.max(...selected.map((element) => element.x + element.width)),
- top: Math.min(...selected.map((element) => element.y)),
- bottom: Math.max(...selected.map((element) => element.y + element.height)),
- }
-
- screen.elements = screen.elements.map((element) => {
- if (!ids.includes(element.id)) return element
- const next = { ...element }
- switch (alignment) {
- case 'left':
- next.x = bounds.left
- break
- case 'center':
- next.x = bounds.left + (bounds.right - bounds.left - element.width) / 2
- break
- case 'right':
- next.x = bounds.right - element.width
- break
- case 'top':
- next.y = bounds.top
- break
- case 'middle':
- next.y = bounds.top + (bounds.bottom - bounds.top - element.height) / 2
- break
- case 'bottom':
- next.y = bounds.bottom - element.height
- break
- }
- return next
- })
+ alignElementsOnScreen(screen, ids, alignment)
})
},
@@ -618,14 +416,9 @@ export const useProjectStore = create()(
const activeScreen = get().getActiveScreen()
if (!activeScreen || ids.length === 0) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- screen.elements = screen.elements.map((element) => {
- if (!ids.includes(element.id)) return element
- return axis === 'horizontal'
- ? { ...element, x: (screen.width - element.width) / 2 }
- : { ...element, y: (screen.height - element.height) / 2 }
- })
+ alignElementsToArtboard(screen, ids, axis)
})
},
@@ -633,39 +426,9 @@ export const useProjectStore = create()(
const activeScreen = get().getActiveScreen()
if (!activeScreen || ids.length < 3) return
get().updateProject((project) => {
- const screen = project.screens.find((item) => item.id === activeScreen.id)
+ const screen = findScreenById(project, activeScreen.id)
if (!screen) return
- const selected = screen.elements
- .filter((element) => ids.includes(element.id))
- .sort((a, b) => (axis === 'horizontal' ? a.x - b.x : a.y - b.y))
- if (selected.length < 3) return
-
- const first = selected[0]
- const last = selected[selected.length - 1]
- const totalSpace =
- axis === 'horizontal'
- ? last.x + last.width - first.x
- : last.y + last.height - first.y
- const itemsSize = selected.reduce(
- (sum, element) => sum + (axis === 'horizontal' ? element.width : element.height),
- 0,
- )
- const gap = (totalSpace - itemsSize) / (selected.length - 1)
-
- let cursor = axis === 'horizontal' ? first.x : first.y
- const positions = new Map()
- selected.forEach((element) => {
- positions.set(element.id, cursor)
- cursor += (axis === 'horizontal' ? element.width : element.height) + gap
- })
-
- screen.elements = screen.elements.map((element) => {
- const position = positions.get(element.id)
- if (position === undefined) return element
- return axis === 'horizontal'
- ? { ...element, x: position }
- : { ...element, y: position }
- })
+ distributeElementsOnScreen(screen, ids, axis)
})
},
})),
diff --git a/src/stores/project/create-device-element.ts b/src/stores/project/create-device-element.ts
new file mode 100644
index 0000000..6a5d9cf
--- /dev/null
+++ b/src/stores/project/create-device-element.ts
@@ -0,0 +1,30 @@
+import {
+ DEFAULT_DEVICE_HEIGHT,
+ DEFAULT_DEVICE_SHADOW_INTENSITY,
+ DEFAULT_DEVICE_SHADOW_SPREAD,
+ DEFAULT_DEVICE_WIDTH,
+ DEFAULT_DEVICE_X,
+ DEFAULT_DEVICE_Y,
+} from '@/lib/constants'
+import { createId } from '@/lib/utils'
+import type { DeviceElement } from '@/lib/types'
+
+export function createDefaultDeviceElement(deviceId: string): DeviceElement {
+ return {
+ id: createId(),
+ type: 'device',
+ name: 'Device',
+ deviceId,
+ x: DEFAULT_DEVICE_X,
+ y: DEFAULT_DEVICE_Y,
+ width: DEFAULT_DEVICE_WIDTH,
+ height: DEFAULT_DEVICE_HEIGHT,
+ rotation: 0,
+ opacity: 1,
+ locked: false,
+ visible: true,
+ zIndex: 3,
+ shadowIntensity: DEFAULT_DEVICE_SHADOW_INTENSITY,
+ shadowSpread: DEFAULT_DEVICE_SHADOW_SPREAD,
+ }
+}
diff --git a/src/stores/project/element-alignment.test.ts b/src/stores/project/element-alignment.test.ts
new file mode 100644
index 0000000..c2554e0
--- /dev/null
+++ b/src/stores/project/element-alignment.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import { alignElementsOnScreen, distributeElementsOnScreen } from '@/stores/project/element-alignment'
+import { minimalScreen } from '@/test/fixtures/minimal-screen'
+import type { Element } from '@/lib/types'
+
+function box(id: string, x: number, y: number, width = 100, height = 100): Element {
+ return {
+ id,
+ type: 'shape',
+ name: id,
+ shapeKind: 'rectangle',
+ x,
+ y,
+ width,
+ height,
+ rotation: 0,
+ opacity: 1,
+ visible: true,
+ locked: false,
+ zIndex: 0,
+ fill: { type: 'solid', color: '#000' },
+ stroke: '#000',
+ strokeWidth: 0,
+ cornerRadius: 0,
+ dash: [],
+ }
+}
+
+describe('element-alignment', () => {
+ it('aligns selected elements to the left edge of their bounds', () => {
+ const screen = {
+ ...minimalScreen(),
+ elements: [box('a', 10, 0), box('b', 50, 0), box('c', 200, 0)],
+ }
+
+ alignElementsOnScreen(screen, ['a', 'b'], 'left')
+
+ expect(screen.elements.find((item) => item.id === 'a')?.x).toBe(10)
+ expect(screen.elements.find((item) => item.id === 'b')?.x).toBe(10)
+ expect(screen.elements.find((item) => item.id === 'c')?.x).toBe(200)
+ })
+
+ it('distributes three elements with equal gaps horizontally', () => {
+ const screen = {
+ ...minimalScreen(),
+ elements: [box('a', 0, 0, 100), box('b', 200, 0, 100), box('c', 500, 0, 100)],
+ }
+
+ distributeElementsOnScreen(screen, ['a', 'b', 'c'], 'horizontal')
+
+ expect(screen.elements.map((item) => item.x)).toEqual([0, 250, 500])
+ })
+})
diff --git a/src/stores/project/element-alignment.ts b/src/stores/project/element-alignment.ts
new file mode 100644
index 0000000..512b2c9
--- /dev/null
+++ b/src/stores/project/element-alignment.ts
@@ -0,0 +1,104 @@
+import type { Element, Screen } from '@/lib/types'
+
+export type ElementAlignment =
+ | 'left'
+ | 'center'
+ | 'right'
+ | 'top'
+ | 'middle'
+ | 'bottom'
+
+function selectionBounds(elements: Element[]) {
+ return {
+ left: Math.min(...elements.map((element) => element.x)),
+ right: Math.max(...elements.map((element) => element.x + element.width)),
+ top: Math.min(...elements.map((element) => element.y)),
+ bottom: Math.max(...elements.map((element) => element.y + element.height)),
+ }
+}
+
+export function alignElementsOnScreen(
+ screen: Screen,
+ ids: string[],
+ alignment: ElementAlignment,
+): void {
+ const selected = screen.elements.filter((element) => ids.includes(element.id))
+ if (selected.length === 0) return
+
+ const bounds = selectionBounds(selected)
+
+ screen.elements = screen.elements.map((element) => {
+ if (!ids.includes(element.id)) return element
+ const next = { ...element }
+ switch (alignment) {
+ case 'left':
+ next.x = bounds.left
+ break
+ case 'center':
+ next.x = bounds.left + (bounds.right - bounds.left - element.width) / 2
+ break
+ case 'right':
+ next.x = bounds.right - element.width
+ break
+ case 'top':
+ next.y = bounds.top
+ break
+ case 'middle':
+ next.y = bounds.top + (bounds.bottom - bounds.top - element.height) / 2
+ break
+ case 'bottom':
+ next.y = bounds.bottom - element.height
+ break
+ }
+ return next
+ })
+}
+
+export function alignElementsToArtboard(
+ screen: Screen,
+ ids: string[],
+ axis: 'horizontal' | 'vertical',
+): void {
+ screen.elements = screen.elements.map((element) => {
+ if (!ids.includes(element.id)) return element
+ return axis === 'horizontal'
+ ? { ...element, x: (screen.width - element.width) / 2 }
+ : { ...element, y: (screen.height - element.height) / 2 }
+ })
+}
+
+export function distributeElementsOnScreen(
+ screen: Screen,
+ ids: string[],
+ axis: 'horizontal' | 'vertical',
+): void {
+ const selected = screen.elements
+ .filter((element) => ids.includes(element.id))
+ .sort((a, b) => (axis === 'horizontal' ? a.x - b.x : a.y - b.y))
+ if (selected.length < 3) return
+
+ const first = selected[0]
+ const last = selected[selected.length - 1]
+ const totalSpace =
+ axis === 'horizontal'
+ ? last.x + last.width - first.x
+ : last.y + last.height - first.y
+ const itemsSize = selected.reduce(
+ (sum, element) => sum + (axis === 'horizontal' ? element.width : element.height),
+ 0,
+ )
+ const gap = (totalSpace - itemsSize) / (selected.length - 1)
+
+ let cursor = axis === 'horizontal' ? first.x : first.y
+ const positions = new Map()
+ selected.forEach((element) => {
+ positions.set(element.id, cursor)
+ cursor += (axis === 'horizontal' ? element.width : element.height) + gap
+ })
+
+ screen.elements = screen.elements.map((element) => {
+ const position = positions.get(element.id)
+ if (position === undefined) return element
+ return axis === 'horizontal' ? { ...element, x: position } : { ...element, y: position }
+ })
+}
diff --git a/src/stores/project/element-mutations.ts b/src/stores/project/element-mutations.ts
new file mode 100644
index 0000000..d125ae9
--- /dev/null
+++ b/src/stores/project/element-mutations.ts
@@ -0,0 +1,81 @@
+import {
+ duplicateElement,
+ reindexElements,
+ sortElementsByZIndex,
+} from '@/lib/factories'
+import { createId } from '@/lib/utils'
+import type { Element, Screen } from '@/lib/types'
+
+export function findScreenById(project: { screens: Screen[] }, screenId: string): Screen | undefined {
+ return project.screens.find((item) => item.id === screenId)
+}
+
+export function addElementToScreen(screen: Screen, element: Element): void {
+ const zIndex = screen.elements.length
+ screen.elements.push({ ...element, zIndex })
+}
+
+export function updateElementOnScreen(screen: Screen, id: string, patch: Partial): boolean {
+ const index = screen.elements.findIndex((element) => element.id === id)
+ if (index === -1) return false
+ screen.elements[index] = {
+ ...screen.elements[index],
+ ...patch,
+ } as Element
+ return true
+}
+
+export function deleteElementsFromScreen(screen: Screen, ids: string[]): void {
+ screen.elements = reindexElements(
+ sortElementsByZIndex(screen.elements).filter((element) => !ids.includes(element.id)),
+ )
+}
+
+export function duplicateElementsOnScreen(screen: Screen, ids: string[]): void {
+ const copies = screen.elements
+ .filter((element) => ids.includes(element.id))
+ .map((element) => duplicateElement(element))
+ screen.elements = reindexElements([...sortElementsByZIndex(screen.elements), ...copies])
+}
+
+export function reorderElementsOnScreen(screen: Screen, elementIds: string[]): void {
+ const map = new Map(screen.elements.map((element) => [element.id, element]))
+ const orderedIds = new Set(elementIds)
+ const ordered = elementIds
+ .map((id) => map.get(id))
+ .filter((element): element is Element => Boolean(element))
+ const remaining = sortElementsByZIndex(
+ screen.elements.filter((element) => !orderedIds.has(element.id)),
+ )
+ screen.elements = reindexElements([...ordered, ...remaining])
+}
+
+export function bringForwardElement(screen: Screen, id: string): void {
+ const sorted = sortElementsByZIndex(screen.elements)
+ const index = sorted.findIndex((element) => element.id === id)
+ if (index < 0 || index === sorted.length - 1) return
+ ;[sorted[index], sorted[index + 1]] = [sorted[index + 1], sorted[index]]
+ screen.elements = reindexElements(sorted)
+}
+
+export function sendBackwardElement(screen: Screen, id: string): void {
+ const sorted = sortElementsByZIndex(screen.elements)
+ const index = sorted.findIndex((element) => element.id === id)
+ if (index <= 0) return
+ ;[sorted[index], sorted[index - 1]] = [sorted[index - 1], sorted[index]]
+ screen.elements = reindexElements(sorted)
+}
+
+export function groupElementsOnScreen(screen: Screen, ids: string[]): void {
+ if (ids.length < 2) return
+ const groupId = createId()
+ screen.elements = screen.elements.map((element) =>
+ ids.includes(element.id) ? { ...element, groupId } : element,
+ )
+}
+
+export function ungroupElementsOnScreen(screen: Screen, groupId: string): void {
+ screen.elements = screen.elements.map((element) =>
+ element.groupId === groupId ? { ...element, groupId: undefined } : element,
+ )
+}
diff --git a/src/stores/project/element-update-coalesce.test.ts b/src/stores/project/element-update-coalesce.test.ts
new file mode 100644
index 0000000..b86a82a
--- /dev/null
+++ b/src/stores/project/element-update-coalesce.test.ts
@@ -0,0 +1,46 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ COALESCE_WINDOW_MS,
+ buildElementCoalesceKey,
+ endElementCoalesce,
+ resetElementCoalesce,
+ resolveElementUpdateHistory,
+} from '@/stores/project/element-update-coalesce'
+
+describe('element-update-coalesce', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ resetElementCoalesce()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('builds a stable key from element id and sorted patch fields', () => {
+ expect(buildElementCoalesceKey('el-1', { opacity: 0.5, x: 10 })).toBe('el-1:opacity,x')
+ })
+
+ it('records history on the first update and coalesces rapid repeats', () => {
+ expect(resolveElementUpdateHistory('el-1', { opacity: 0.5 })).toBe(true)
+ expect(resolveElementUpdateHistory('el-1', { opacity: 0.6 })).toBe(false)
+ expect(resolveElementUpdateHistory('el-1', { opacity: 0.7 })).toBe(false)
+ })
+
+ it('records history again when patch fields change', () => {
+ resolveElementUpdateHistory('el-1', { opacity: 0.5 })
+ expect(resolveElementUpdateHistory('el-1', { x: 12 })).toBe(true)
+ })
+
+ it('records history again after endElementCoalesce', () => {
+ resolveElementUpdateHistory('el-1', { opacity: 0.5 })
+ endElementCoalesce()
+ expect(resolveElementUpdateHistory('el-1', { opacity: 0.6 })).toBe(true)
+ })
+
+ it('records history again after the coalesce window expires', () => {
+ resolveElementUpdateHistory('el-1', { opacity: 0.5 })
+ vi.advanceTimersByTime(COALESCE_WINDOW_MS + 1)
+ expect(resolveElementUpdateHistory('el-1', { opacity: 0.6 })).toBe(true)
+ })
+})
diff --git a/src/stores/project/element-update-coalesce.ts b/src/stores/project/element-update-coalesce.ts
new file mode 100644
index 0000000..d22886b
--- /dev/null
+++ b/src/stores/project/element-update-coalesce.ts
@@ -0,0 +1,39 @@
+import type { Element } from '@/lib/types'
+
+export const COALESCE_WINDOW_MS = 500
+
+let coalesceKey: string | null = null
+let coalesceTimer: ReturnType | null = null
+
+export function buildElementCoalesceKey(elementId: string, patch: Partial): string {
+ return `${elementId}:${Object.keys(patch).sort().join(',')}`
+}
+
+function scheduleCoalesceClear() {
+ if (coalesceTimer) clearTimeout(coalesceTimer)
+ coalesceTimer = setTimeout(resetElementCoalesce, COALESCE_WINDOW_MS)
+}
+
+/** Returns whether this update should push a new history baseline. */
+export function resolveElementUpdateHistory(
+ elementId: string,
+ patch: Partial,
+): boolean {
+ const key = buildElementCoalesceKey(elementId, patch)
+ const recordHistory = key !== coalesceKey
+ coalesceKey = key
+ scheduleCoalesceClear()
+ return recordHistory
+}
+
+export function resetElementCoalesce() {
+ coalesceKey = null
+ if (coalesceTimer) {
+ clearTimeout(coalesceTimer)
+ coalesceTimer = null
+ }
+}
+
+export function endElementCoalesce() {
+ resetElementCoalesce()
+}
diff --git a/src/stores/project/screen-mutations.test.ts b/src/stores/project/screen-mutations.test.ts
new file mode 100644
index 0000000..153fb00
--- /dev/null
+++ b/src/stores/project/screen-mutations.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import { resolveNextActiveScreenId } from '@/stores/project/screen-mutations'
+import { minimalScreen } from '@/test/fixtures/minimal-screen'
+
+describe('resolveNextActiveScreenId', () => {
+ const screens = [
+ minimalScreen({ id: 's1', name: 'One' }),
+ minimalScreen({ id: 's2', name: 'Two' }),
+ minimalScreen({ id: 's3', name: 'Three' }),
+ ]
+
+ it('returns null when the active screen remains valid', () => {
+ expect(resolveNextActiveScreenId(1, [screens[0], screens[2]], 's1')).toBeNull()
+ })
+
+ it('picks a neighbor when the active screen is deleted', () => {
+ expect(resolveNextActiveScreenId(1, [screens[0], screens[2]], 's2')).toBe('s3')
+ })
+})
diff --git a/src/stores/project/screen-mutations.ts b/src/stores/project/screen-mutations.ts
new file mode 100644
index 0000000..8f3c23f
--- /dev/null
+++ b/src/stores/project/screen-mutations.ts
@@ -0,0 +1,157 @@
+import { MAX_SCREENS } from '@/lib/constants'
+import {
+ cloneScreenDesign,
+ createScreenFromPrevious,
+} from '@/lib/factories'
+import {
+ cloneScreenForAndroid,
+ getScreenPlatform,
+ isAppleScreen,
+ sortScreensByPlatform,
+} from '@/lib/platform-copy'
+import { applyTemplateToScreenState, getAndroidDeviceId } from '@/stores/project/template-screen'
+import type { BackgroundConfig, Element, Project, Screen, TemplateApplyMode } from '@/lib/types'
+
+export function addScreenToProject(project: Project): void {
+ if (project.screens.length >= MAX_SCREENS) return
+ const previous = project.screens.at(-1)
+ project.screens.push(
+ createScreenFromPrevious(previous, `Screen ${project.screens.length + 1}`),
+ )
+}
+
+export function duplicateScreenInProject(project: Project, screenId: string): void {
+ if (project.screens.length >= MAX_SCREENS) return
+ const source = project.screens.find((screen) => screen.id === screenId)
+ if (!source) return
+ project.screens.push(cloneScreenDesign(source, `${source.name} copy`))
+}
+
+export function resolveNextActiveScreenId(
+ deletedScreenIndex: number,
+ remaining: Screen[],
+ activeScreenId: string | null,
+): string | null {
+ if (remaining.length === 0) return null
+ if (activeScreenId && remaining.some((screen) => screen.id === activeScreenId)) {
+ return null
+ }
+ return remaining[Math.min(deletedScreenIndex, remaining.length - 1)]?.id ?? remaining[0]?.id ?? null
+}
+
+export function deleteScreenFromProject(
+ project: Project,
+ screenId: string,
+): { remaining: Screen[]; deletedIndex: number } | null {
+ if (project.screens.length <= 1) return null
+ const deletedIndex = project.screens.findIndex((screen) => screen.id === screenId)
+ if (deletedIndex === -1) return null
+ const remaining = project.screens.filter((screen) => screen.id !== screenId)
+ project.screens = remaining
+ return { remaining, deletedIndex }
+}
+
+export function reorderScreensInProject(project: Project, screenIds: string[]): void {
+ const map = new Map(project.screens.map((screen) => [screen.id, screen]))
+ project.screens = screenIds
+ .map((id) => map.get(id))
+ .filter((screen): screen is Screen => Boolean(screen))
+}
+
+export function renameScreenInProject(project: Project, screenId: string, name: string): void {
+ const screen = project.screens.find((item) => item.id === screenId)
+ if (screen) screen.name = name
+}
+
+export function setScreenBackground(screen: Screen, background: BackgroundConfig): void {
+ screen.background = background
+}
+
+export function applyTemplateToAllScreensInProject(
+ project: Project,
+ elements: Element[],
+ background: BackgroundConfig,
+ mode: TemplateApplyMode = 'replace',
+): void {
+ for (const screen of project.screens) {
+ applyTemplateToScreenState(screen, elements, background, mode)
+ }
+}
+
+export function applyTemplateToScreenInProject(
+ project: Project,
+ screenId: string,
+ elements: Element[],
+ background: BackgroundConfig,
+ mode: TemplateApplyMode = 'replace',
+): void {
+ const screen = project.screens.find((item) => item.id === screenId)
+ if (!screen) return
+ applyTemplateToScreenState(screen, elements, background, mode)
+}
+
+export function copyScreenToAndroidInProject(
+ project: Project,
+ screenId: string,
+ targetDeviceId: string,
+): string | null {
+ const source = project.screens.find((screen) => screen.id === screenId)
+ if (!source || !isAppleScreen(source)) return null
+
+ project.screens = project.screens.filter(
+ (screen) =>
+ !(getScreenPlatform(screen) === 'android' && screen.sourceScreenId === screenId),
+ )
+
+ if (project.screens.length >= MAX_SCREENS) return null
+
+ const copy = cloneScreenForAndroid(source, targetDeviceId)
+ project.screens = sortScreensByPlatform([...project.screens, copy])
+ return copy.id
+}
+
+export function syncLinkedAndroidScreenInProject(
+ project: Project,
+ appleScreenId: string,
+): boolean {
+ const source = project.screens.find((screen) => screen.id === appleScreenId)
+ if (!source || !isAppleScreen(source)) return false
+
+ const linked = project.screens.find(
+ (screen) =>
+ getScreenPlatform(screen) === 'android' && screen.sourceScreenId === appleScreenId,
+ )
+ if (!linked) return false
+
+ const targetDeviceId = getAndroidDeviceId(linked) ?? getAndroidDeviceId(source)
+ if (!targetDeviceId) return false
+
+ const refreshed = cloneScreenForAndroid(source, targetDeviceId)
+ linked.background = refreshed.background
+ linked.elements = refreshed.elements
+ linked.width = refreshed.width
+ linked.height = refreshed.height
+ return true
+}
+
+export function copyAllScreensToAndroidInProject(
+ project: Project,
+ targetDeviceId: string,
+): string[] {
+ const appleScreens = project.screens.filter(isAppleScreen)
+ if (appleScreens.length === 0) return []
+
+ const appleIds = new Set(appleScreens.map((screen) => screen.id))
+ project.screens = project.screens.filter(
+ (screen) =>
+ getScreenPlatform(screen) !== 'android' ||
+ !screen.sourceScreenId ||
+ !appleIds.has(screen.sourceScreenId),
+ )
+
+ const slotsAvailable = MAX_SCREENS - project.screens.length
+ const toCopy = appleScreens.slice(0, slotsAvailable)
+ const copies = toCopy.map((screen) => cloneScreenForAndroid(screen, targetDeviceId))
+ project.screens = sortScreensByPlatform([...project.screens, ...copies])
+ return copies.map((screen) => screen.id)
+}
diff --git a/src/stores/project/template-screen.ts b/src/stores/project/template-screen.ts
new file mode 100644
index 0000000..c481ee0
--- /dev/null
+++ b/src/stores/project/template-screen.ts
@@ -0,0 +1,18 @@
+import { applyTemplateToScreen } from '@/lib/templates/apply-template-to-screen'
+import type { BackgroundConfig, Element, Screen, TemplateApplyMode } from '@/lib/types'
+
+export function applyTemplateToScreenState(
+ screen: Screen,
+ elements: Element[],
+ background: BackgroundConfig,
+ mode: TemplateApplyMode = 'replace',
+) {
+ const next = applyTemplateToScreen(screen, elements, background, mode)
+ screen.background = next.background
+ screen.elements = next.elements
+}
+
+export function getAndroidDeviceId(screen: Screen): string | undefined {
+ const device = screen.elements.find((element) => element.type === 'device')
+ return device?.type === 'device' ? device.deviceId : undefined
+}
diff --git a/src/test/fixtures/minimal-project.ts b/src/test/fixtures/minimal-project.ts
new file mode 100644
index 0000000..18113ef
--- /dev/null
+++ b/src/test/fixtures/minimal-project.ts
@@ -0,0 +1,9 @@
+import { createProject } from '@/lib/factories'
+import { minimalScreen } from '@/test/fixtures/minimal-screen'
+import type { Project } from '@/lib/types'
+
+export function minimalProject(overrides?: Partial): Project {
+ const project = createProject('Test Project')
+ project.screens = [minimalScreen()]
+ return { ...project, ...overrides }
+}
diff --git a/src/test/fixtures/minimal-screen.ts b/src/test/fixtures/minimal-screen.ts
new file mode 100644
index 0000000..d16f778
--- /dev/null
+++ b/src/test/fixtures/minimal-screen.ts
@@ -0,0 +1,14 @@
+import { createTextElement, createScreen } from '@/lib/factories'
+import type { BackgroundConfig, Screen } from '@/lib/types'
+
+export function minimalScreen(overrides?: Partial): Screen {
+ const screen = createScreen('Test Screen')
+ screen.elements = [
+ createTextElement({ id: 'existing-text', name: 'Headline', text: 'Hello', zIndex: 0 }),
+ ]
+ return { ...screen, ...overrides }
+}
+
+export function minimalBackground(): BackgroundConfig {
+ return { type: 'solid', color: '#0D9488' }
+}
From 876bfb8d99f2550a84ed76bbe6cb3eb94d4a551c Mon Sep 17 00:00:00 2001
From: Emdadul Islam
Date: Mon, 15 Jun 2026 08:27:20 +0600
Subject: [PATCH 2/2] Add GitHub Actions workflow to run unit tests on push and
PRs.
Co-authored-by: Cursor
---
.github/workflows/test.yml | 30 ++++++++++++++++++++++++++++++
1 file changed, 30 insertions(+)
create mode 100644 .github/workflows/test.yml
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..1e237ad
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,30 @@
+name: Test
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+concurrency:
+ group: test-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Unit tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.1
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Run tests
+ run: bun run test