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
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' }
+}