From 5d3d9f308d2709d1eddbfc6622a09fe11a0a19b7 Mon Sep 17 00:00:00 2001 From: Emdadul Islam Date: Wed, 17 Jun 2026 11:47:08 +0600 Subject: [PATCH] Extract panel hooks and unify shared canvas/store utilities. Thin LayersPanel and AssetsPanel behind testable hooks, consolidate z-order sorting and additive selection, and add renderer characterization tests to make the foundation refactor safer to extend. Co-authored-by: Cursor --- src/components/canvas/CanvasWorkspace.tsx | 108 +---- src/components/canvas/ElementGroupNode.tsx | 5 +- src/components/canvas/ElementNode.tsx | 15 +- .../canvas/InactiveScreenArtboard.tsx | 3 +- src/components/canvas/ScreenArtboard.tsx | 6 +- src/components/canvas/ScreensOverview.tsx | 5 +- src/components/panels/AssetsPanel.tsx | 280 ++---------- src/components/panels/LayersPanel.tsx | 242 ++++------- src/components/panels/ScreensPanel.tsx | 5 +- src/components/panels/assets/AssetRow.tsx | 60 +++ src/components/templates/ProjectThumbnail.tsx | 6 - src/components/toolbar/ExportDialog.tsx | 9 +- src/components/ui/TooltipIconButton.tsx | 38 ++ src/hooks/useAssetLibrary.ts | 213 ++++++++++ src/hooks/useAssetResolver.ts | 16 + src/hooks/useCanvasDrop.ts | 18 +- src/hooks/useCanvasSnapping.ts | 78 ++++ src/hooks/useCanvasViewport.ts | 21 + src/hooks/useKonvaStageBridge.ts | 35 ++ src/hooks/useLayerPanelActions.ts | 191 +++++++++ src/lib/assets/drag-files.ts | 11 + src/lib/canvas/background-presets.ts | 171 ++++++++ src/lib/canvas/background-render.ts | 204 +++++++++ src/lib/canvas/backgrounds.ts | 397 +----------------- src/lib/canvas/device-frame.ts | 27 +- src/lib/canvas/image-fit.test.ts | 69 +++ src/lib/canvas/image-fit.ts | 61 +++ src/lib/canvas/perf/content-signature.ts | 4 +- src/lib/canvas/selection-style.ts | 13 + src/lib/export/renderer.test.ts | 123 ++++++ src/lib/export/renderer.ts | 34 +- src/lib/factories.test.ts | 29 ++ src/lib/factories.ts | 10 +- src/lib/layers/layer-panel-logic.test.ts | 67 +++ src/lib/layers/layer-panel-logic.ts | 48 +++ .../selection/is-additive-selection.test.ts | 30 ++ src/lib/selection/is-additive-selection.ts | 13 + src/lib/utils.ts | 6 + src/routes/editor/$projectId.tsx | 9 +- src/stores/project-store.ts | 146 +++---- src/stores/project/with-active-screen.ts | 33 ++ src/test/setup.ts | 211 ++++++++++ 42 files changed, 1994 insertions(+), 1076 deletions(-) create mode 100644 src/components/panels/assets/AssetRow.tsx create mode 100644 src/components/ui/TooltipIconButton.tsx create mode 100644 src/hooks/useAssetLibrary.ts create mode 100644 src/hooks/useAssetResolver.ts create mode 100644 src/hooks/useCanvasSnapping.ts create mode 100644 src/hooks/useKonvaStageBridge.ts create mode 100644 src/hooks/useLayerPanelActions.ts create mode 100644 src/lib/assets/drag-files.ts create mode 100644 src/lib/canvas/background-presets.ts create mode 100644 src/lib/canvas/background-render.ts create mode 100644 src/lib/canvas/image-fit.test.ts create mode 100644 src/lib/canvas/image-fit.ts create mode 100644 src/lib/export/renderer.test.ts create mode 100644 src/lib/layers/layer-panel-logic.test.ts create mode 100644 src/lib/layers/layer-panel-logic.ts create mode 100644 src/lib/selection/is-additive-selection.test.ts create mode 100644 src/lib/selection/is-additive-selection.ts create mode 100644 src/stores/project/with-active-screen.ts diff --git a/src/components/canvas/CanvasWorkspace.tsx b/src/components/canvas/CanvasWorkspace.tsx index 172cf12..f9c82ca 100644 --- a/src/components/canvas/CanvasWorkspace.tsx +++ b/src/components/canvas/CanvasWorkspace.tsx @@ -15,18 +15,9 @@ import { getAddChipPosition, getAddFrameSize, } from '@/lib/canvas/workspace-layout' -import { - applySnapping, - computeSnap, -} from '@/lib/canvas/helpers' -import { - getAbsoluteNodePosition, - setAbsoluteNodePosition, -} from '@/lib/canvas/coordinates' import { rectsIntersectViewport } from '@/lib/canvas/perf/viewport' import { useBatchDraw } from '@/lib/canvas/perf/batch-draw' import { applyKonvaPixelRatio } from '@/lib/canvas/perf/konva-config' -import { exportActiveScreenBlobFromStage } from '@/lib/canvas/konva-export' import { SELECTION_BLUE, SELECTION_HANDLE_FILL, @@ -41,11 +32,13 @@ import { useCanvasSelection } from '@/hooks/useCanvasSelection' import { useCanvasDrop } from '@/hooks/useCanvasDrop' import { useCanvasTextEdit } from '@/hooks/useCanvasTextEdit' import { useScreenContextMenu } from '@/hooks/useScreenContextMenu' +import { useKonvaStageBridge } from '@/hooks/useKonvaStageBridge' +import { useCanvasSnapping } from '@/hooks/useCanvasSnapping' import { cn } from '@/lib/utils' import { useEditorStore } from '@/stores/editor-store' import { useProjectStore } from '@/stores/project-store' import { useSettingsStore } from '@/stores/settings-store' -import type { Element, Screen } from '@/lib/types' +import type { Screen } from '@/lib/types' interface CanvasWorkspaceProps { screens: Screen[] @@ -63,8 +56,6 @@ export function CanvasWorkspace({ screens, assetResolver }: CanvasWorkspaceProps const screenLayout = useEditorStore((state) => state.screenLayout) const syncScreenLayout = useEditorStore((state) => state.syncScreenLayout) - const setIsSpacePressed = useEditorStore((state) => state.setIsSpacePressed) - const setIsPanning = useEditorStore((state) => state.setIsPanning) const requestFit = useEditorStore((state) => state.requestFit) const scheduleDraw = useBatchDraw(stageRef) @@ -160,7 +151,7 @@ export function CanvasWorkspace({ screens, assetResolver }: CanvasWorkspaceProps const canvasCheckerboard = useSettingsStore((state) => state.preferences.workspace.canvasCheckerboard) const highDpiCanvas = useSettingsStore((state) => state.preferences.workspace.highDpiCanvas) - const setKonvaStageBridge = useEditorStore((state) => state.setKonvaStageBridge) + useKonvaStageBridge({ stageRef, activeScreenId }) useEffect(() => { applyKonvaPixelRatio(highDpiCanvas) @@ -181,88 +172,15 @@ export function CanvasWorkspace({ screens, assetResolver }: CanvasWorkspaceProps if (pos) overlayRef.current?.setScreenOffset(pos.x, pos.y) }, [activeScreenId, screenLayout]) - useEffect(() => { - const stage = stageRef.current - if (!stage) { - setKonvaStageBridge(null) - return - } - - setKonvaStageBridge({ - activeScreenId, - exportActiveScreen: async (screenId, options) => { - const currentStage = stageRef.current - if (!currentStage || screenId !== activeScreenId) return null - return exportActiveScreenBlobFromStage( - { stage: currentStage, screenId, isActiveOnCanvas: true }, - options, - ) - }, - }) - - return () => setKonvaStageBridge(null) - }, [activeScreenId, setKonvaStageBridge]) - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.code === 'Space' && !(event.target as HTMLElement).matches('input, textarea')) { - setIsSpacePressed(true) - } - } - const onKeyUp = (event: KeyboardEvent) => { - if (event.code === 'Space') { - setIsSpacePressed(false) - setIsPanning(false) - } - } - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - return () => { - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - } - }, [setIsPanning, setIsSpacePressed]) - - const handleElementChange = (screenId: string, id: string, patch: Partial) => { - setActiveScreenId(screenId) - const screen = screens.find((item) => item.id === screenId) - const element = screen?.elements.find((item) => item.id === id) - if (!element || !screen) return - - let next = { ...element, ...patch } as Element - if (showSmartGuides && ('x' in patch || 'y' in patch)) { - next = applySnapping( - next, - screen.elements.filter((item) => item.id !== id), - screen.width, - screen.height, - snapSensitivity, - ) - } - overlayRef.current?.clear() - updateElement(id, next) - } - - const handleDragMove = (screenId: string, id: string, node: Konva.Node) => { - setActiveScreenId(screenId) - if (!showSmartGuides) return - const screen = screens.find((item) => item.id === screenId) - const element = screen?.elements.find((item) => item.id === id) - if (!screen || !element) return - const absolute = getAbsoluteNodePosition(element, screen, node) - const moving = { ...element, x: absolute.x, y: absolute.y } as Element - const others = screen.elements.filter((item) => item.id !== id) - const { x, y, lines } = computeSnap( - moving, - others, - screen.width, - screen.height, - snapSensitivity, - ) - setAbsoluteNodePosition(element, screen, node, x, y) - overlayRef.current?.setGuides(lines, screen.width, screen.height) - scheduleDraw() - } + const { handleElementChange, handleDragMove } = useCanvasSnapping({ + screens, + showSmartGuides, + snapSensitivity, + updateElement, + setActiveScreenId, + overlayRef, + scheduleDraw, + }) const handleAddScreen = () => { if ((project?.screens.length ?? 0) >= MAX_SCREENS) return diff --git a/src/components/canvas/ElementGroupNode.tsx b/src/components/canvas/ElementGroupNode.tsx index 3464095..872ccab 100644 --- a/src/components/canvas/ElementGroupNode.tsx +++ b/src/components/canvas/ElementGroupNode.tsx @@ -2,6 +2,7 @@ import { memo } from 'react' import { Group } from 'react-konva' import type Konva from 'konva' import { ElementNode } from '@/components/canvas/ElementNode' +import { isAdditiveKonvaPointerEvent } from '@/lib/selection/is-additive-selection' import { useEditorStore } from '@/stores/editor-store' import type { Element } from '@/lib/types' @@ -71,11 +72,11 @@ function ElementGroupNodeInner({ draggable={isActive && !anyLocked && allSelected} onClick={(event) => { event.cancelBubble = true - handleGroupSelect(event.evt.shiftKey) + handleGroupSelect(isAdditiveKonvaPointerEvent(event)) }} onTap={(event) => { event.cancelBubble = true - handleGroupSelect(event.evt.shiftKey) + handleGroupSelect(isAdditiveKonvaPointerEvent(event)) }} onDragEnd={(event) => { const dx = event.target.x() - minX diff --git a/src/components/canvas/ElementNode.tsx b/src/components/canvas/ElementNode.tsx index 78df777..fc7019f 100644 --- a/src/components/canvas/ElementNode.tsx +++ b/src/components/canvas/ElementNode.tsx @@ -4,6 +4,7 @@ import { Group, Image as KonvaImage, Line, Rect, RegularPolygon, Text, Circle } import useImage from 'use-image' import { clearKonvaImageCache } from '@/lib/canvas/konva-lifecycle' import { getElementShadowProps, getGradientProps } from '@/lib/canvas/helpers' +import { isAdditiveKonvaPointerEvent } from '@/lib/selection/is-additive-selection' import { getCachedDeviceComposite } from '@/lib/canvas/perf/device-composite-cache' import type { DeviceElement, Element, ImageElement, ShapeElement, TextElement } from '@/lib/types' @@ -80,8 +81,8 @@ function ImageNode({ opacity={element.opacity} visible={element.visible} draggable={draggable} - onClick={(event) => props.onSelect(element.id, event.evt.shiftKey)} - onTap={(event) => props.onSelect(element.id, event.evt.shiftKey)} + onClick={(event) => props.onSelect(element.id, isAdditiveKonvaPointerEvent(event))} + onTap={(event) => props.onSelect(element.id, isAdditiveKonvaPointerEvent(event))} onDragMove={(event) => props.onDragMove?.(element.id, event.target)} onDragEnd={(event) => { props.onChange(element.id, { @@ -167,8 +168,8 @@ function DeviceNode({ opacity={element.opacity} visible={element.visible} draggable={draggable} - onClick={(event) => props.onSelect(element.id, event.evt.shiftKey)} - onTap={(event) => props.onSelect(element.id, event.evt.shiftKey)} + onClick={(event) => props.onSelect(element.id, isAdditiveKonvaPointerEvent(event))} + onTap={(event) => props.onSelect(element.id, isAdditiveKonvaPointerEvent(event))} onDragMove={(event) => props.onDragMove?.(element.id, event.target)} onDragEnd={(event) => { props.onChange(element.id, { @@ -262,8 +263,10 @@ function ElementNodeInner({ draggable: isDraggable, listening: !element.locked, perfectDrawEnabled: hasStroke ? false : undefined, - onClick: (event: { evt: { shiftKey: boolean } }) => onSelect(element.id, event.evt.shiftKey), - onTap: (event: { evt: { shiftKey: boolean } }) => onSelect(element.id, event.evt.shiftKey), + onClick: (event: { evt: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean } }) => + onSelect(element.id, isAdditiveKonvaPointerEvent(event)), + onTap: (event: { evt: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean } }) => + onSelect(element.id, isAdditiveKonvaPointerEvent(event)), onDragMove: (event: { target: Konva.Node }) => onDragMove?.(element.id, event.target), onDragEnd: (event: { target: Konva.Node }) => { onChange(element.id, { diff --git a/src/components/canvas/InactiveScreenArtboard.tsx b/src/components/canvas/InactiveScreenArtboard.tsx index 4abfce3..4ae8480 100644 --- a/src/components/canvas/InactiveScreenArtboard.tsx +++ b/src/components/canvas/InactiveScreenArtboard.tsx @@ -7,6 +7,7 @@ import { requestScreenSnapshot, } from '@/lib/canvas/perf/screen-snapshot-cache' import { screenContentSignature } from '@/lib/canvas/perf/content-signature' +import { isAdditiveKonvaPointerEvent } from '@/lib/selection/is-additive-selection' import type { BackgroundConfig, Screen } from '@/lib/types' function SnapshotImage({ @@ -111,7 +112,7 @@ export function InactiveScreenArtboard({ listening onMouseDown={(event) => { if (event.target !== event.currentTarget) return - onArtboardBackgroundClick(event.evt.shiftKey) + onArtboardBackgroundClick(isAdditiveKonvaPointerEvent(event)) }} /> diff --git a/src/components/canvas/ScreenArtboard.tsx b/src/components/canvas/ScreenArtboard.tsx index 76d7d50..a3048d1 100644 --- a/src/components/canvas/ScreenArtboard.tsx +++ b/src/components/canvas/ScreenArtboard.tsx @@ -6,6 +6,8 @@ import { ElementNode } from '@/components/canvas/ElementNode' import { ElementGroupNode } from '@/components/canvas/ElementGroupNode' import { selectionStrokeWidth } from '@/lib/canvas/selection-style' import { SELECTION_BLUE, SELECTION_BLUE_SOFT } from '@/lib/canvas/selection-style' +import { sortElementsByZIndex } from '@/lib/factories' +import { isAdditiveKonvaPointerEvent } from '@/lib/selection/is-additive-selection' import { getCachedBackgroundCanvas } from '@/lib/canvas/perf/background-cache' import { buildGridCanvas } from '@/lib/canvas/perf/grid-canvas' import type { BackgroundConfig, Element } from '@/lib/types' @@ -92,7 +94,7 @@ function ScreenArtboardInner({ onArtboardBackgroundClick, }: ScreenArtboardProps) { const sortedElements = useMemo( - () => [...elements].sort((a, b) => a.zIndex - b.zIndex), + () => sortElementsByZIndex(elements), [elements], ) const strokeScale = selectionStrokeWidth(workspaceZoom, 1) @@ -165,7 +167,7 @@ function ScreenArtboardInner({ fill="transparent" onMouseDown={(event) => { if (event.target !== event.currentTarget) return - onArtboardBackgroundClick(event.evt.shiftKey) + onArtboardBackgroundClick(isAdditiveKonvaPointerEvent(event)) }} /> diff --git a/src/components/canvas/ScreensOverview.tsx b/src/components/canvas/ScreensOverview.tsx index 427b18e..5c15761 100644 --- a/src/components/canvas/ScreensOverview.tsx +++ b/src/components/canvas/ScreensOverview.tsx @@ -19,6 +19,7 @@ import { renderScreenToDataUrl } from '@/lib/export/renderer' import { screenContentSignature } from '@/lib/canvas/perf/content-signature' import { LruMap } from '@/lib/canvas/perf/lru-map' import { enqueueThumbnailTask } from '@/lib/canvas/perf/thumbnail-queue' +import { SCREEN_OVERVIEW_ACTIVE, SCREEN_OVERVIEW_HOVER } from '@/lib/canvas/selection-style' import { cn } from '@/lib/utils' import type { Screen } from '@/lib/types' @@ -89,8 +90,8 @@ function ScreenThumbnail({ style={style} className={cn( 'group relative flex flex-col gap-2 rounded-lg border bg-card p-2 transition', - isActive ? 'border-[#18A0FB] ring-2 ring-[#18A0FB]/30' : 'border-border', - isDragging ? 'z-10 opacity-80 shadow-lg' : 'hover:border-[#18A0FB]/50', + isActive ? SCREEN_OVERVIEW_ACTIVE : 'border-border', + isDragging ? 'z-10 opacity-80 shadow-lg' : SCREEN_OVERVIEW_HOVER, )} >
diff --git a/src/components/panels/AssetsPanel.tsx b/src/components/panels/AssetsPanel.tsx index 50579a0..2b17d45 100644 --- a/src/components/panels/AssetsPanel.tsx +++ b/src/components/panels/AssetsPanel.tsx @@ -1,125 +1,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 { 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 { ImagePlus, Images, Search, Upload } from 'lucide-react' +import { AssetRow } from '@/components/panels/assets/AssetRow' +import { TooltipIconButton } from '@/components/ui/TooltipIconButton' +import { useAssetLibrary } from '@/hooks/useAssetLibrary' import { cn } from '@/lib/utils' -import { confirm } from '@/stores/confirm-store' -import type { AssetRecord } from '@/lib/types' - -const ASSET_ROW_HEIGHT = 72 - -function formatAssetType(type: AssetRecord['type']): string { - return type.charAt(0).toUpperCase() + type.slice(1) -} - -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` -} - -function hasImageFiles(event: React.DragEvent): boolean { - return Array.from(event.dataTransfer.types).includes('Files') -} - -function TooltipIconButton({ - icon: Icon, - label, - onClick, - className, -}: { - icon: LucideIcon - label: string - onClick: () => void - className?: string -}) { - return ( -
- - - {label} - -
- ) -} - -function AssetRow({ - asset, - url, - onUse, - onAddToCanvas, - onDelete, -}: { - asset: AssetRecord - url?: string - onUse: () => void - onAddToCanvas: () => void - onDelete: () => void -}) { - return ( -
-
- {url ? ( - {asset.name} - ) : ( -
- -
- )} -
-
-

- {asset.name} -

-
- - {formatAssetType(asset.type)} - - - {formatFileSize(asset.blob.size)} - -
-
-
- - - -
-
- ) -} function EmptyState({ icon: Icon, @@ -147,139 +31,35 @@ function EmptyState({ } export function AssetsPanel() { - const project = useProjectStore((state) => state.project) - const assetUrls = useProjectStore((state) => state.assetUrls) - const addImageFromAsset = useProjectStore((state) => state.addImageFromAsset) - const registerAssetUrl = useProjectStore((state) => state.registerAssetUrl) - const updateElement = useProjectStore((state) => state.updateElement) - const [assets, setAssets] = useState([]) - const [query, setQuery] = useState('') - const [uploadMode, setUploadMode] = useState<'library' | 'canvas'>('library') - const [isDragging, setIsDragging] = useState(false) - const parentRef = useRef(null) - const fileInputRef = useRef(null) - const dragCounterRef = useRef(0) - - const loadAssets = useCallback(async () => { - if (!project) return - const records = await getProjectAssets(project.id) - setAssets(records) - records.forEach((asset) => registerAssetUrl(asset.id, createAssetObjectUrl(asset))) - }, [project, registerAssetUrl]) - - useEffect(() => { - void loadAssets() - }, [loadAssets]) - - const filtered = useMemo( - () => assets.filter((asset) => asset.name.toLowerCase().includes(query.toLowerCase())), - [assets, query], - ) - - const virtualizer = useVirtualizer({ - count: filtered.length, - getScrollElement: () => parentRef.current, - estimateSize: () => ASSET_ROW_HEIGHT, - overscan: 6, - }) - - const uploadFiles = useCallback( - async (files: File[]) => { - if (!project) return - for (const file of files) { - if (!file.type.startsWith('image/')) continue - const asset = await persistProjectAsset(file, project.id, registerAssetUrl) - const url = createAssetObjectUrl(asset) - if (uploadMode === 'canvas') { - addImageFromAsset(asset.id, url) - } - } - await loadAssets() - }, - [project, registerAssetUrl, addImageFromAsset, loadAssets, uploadMode], - ) - - const onUpload = async (event: React.ChangeEvent) => { - const files = Array.from(event.target.files ?? []) - await uploadFiles(files) - event.target.value = '' - } - - const onDelete = async (assetId: string) => { - const confirmed = await confirm({ - title: 'Delete asset?', - description: 'This removes the asset from your library. Elements using it may appear broken.', - confirmLabel: 'Delete', - destructive: true, - }) - if (!confirmed) return - await deleteAsset(assetId) - await loadAssets() - } - - const onAddToCanvas = (assetId: string) => { - const url = assetUrls[assetId] - if (url) addImageFromAsset(assetId, url) - } - - const onAssignToDevice = (assetId: string) => { - const screen = useProjectStore.getState().getActiveScreen() - const selection = useEditorStore.getState().selectedElementIds - const device = screen?.elements.find( - (item) => selection.includes(item.id) && item.type === 'device', - ) - if (device) { - updateElement(device.id, { screenshotAssetId: assetId }) - } - } - - const onDragEnter = (event: React.DragEvent) => { - if (!hasImageFiles(event)) return - event.preventDefault() - dragCounterRef.current += 1 - setIsDragging(true) - } - - const onDragLeave = (event: React.DragEvent) => { - if (!hasImageFiles(event)) return - dragCounterRef.current -= 1 - if (dragCounterRef.current <= 0) { - dragCounterRef.current = 0 - setIsDragging(false) - } - } - - const onDragOver = (event: React.DragEvent) => { - if (!hasImageFiles(event)) return - event.preventDefault() - event.dataTransfer.dropEffect = 'copy' - } - - const onDrop = async (event: React.DragEvent) => { - if (!hasImageFiles(event)) return - event.preventDefault() - dragCounterRef.current = 0 - setIsDragging(false) - const files = Array.from(event.dataTransfer.files).filter((file) => - file.type.startsWith('image/'), - ) - await uploadFiles(files) - } - - const assetCountLabel = - assets.length === 0 - ? 'Empty' - : filtered.length === assets.length - ? `${assets.length} asset${assets.length === 1 ? '' : 's'}` - : `${filtered.length} of ${assets.length}` - - const showEmptyLibrary = assets.length === 0 - const showNoResults = !showEmptyLibrary && filtered.length === 0 + const { + filtered, + assetUrls, + query, + setQuery, + uploadMode, + setUploadMode, + isDragging, + parentRef, + fileInputRef, + virtualizer, + assetCountLabel, + showEmptyLibrary, + showNoResults, + onUpload, + onDelete, + onAddToCanvas, + onAssignToDevice, + onDragEnter, + onDragLeave, + onDragOver, + onDrop, + openFilePicker, + } = useAssetLibrary() const uploadButton = (
diff --git a/src/components/panels/LayersPanel.tsx b/src/components/panels/LayersPanel.tsx index 55f1d2a..c12a550 100644 --- a/src/components/panels/LayersPanel.tsx +++ b/src/components/panels/LayersPanel.tsx @@ -1,15 +1,7 @@ -import { useCallback, useState, type MouseEvent } from 'react' -import { - DndContext, - closestCenter, - type DragEndEvent, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core' +import { useState, type MouseEvent } from 'react' +import { DndContext, closestCenter } from '@dnd-kit/core' import { SortableContext, - arrayMove, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' @@ -26,9 +18,14 @@ import { Trash2, } from 'lucide-react' import { getLayerIcon } from '@/lib/elements/element-meta' +import { + LAYER_LIST_GROUP_SELECTED, + LAYER_LIST_GROUP_SELECTED_INDICATOR, + LAYER_LIST_ITEM_SELECTED, + LAYER_LIST_ITEM_SELECTED_INDICATOR, +} from '@/lib/canvas/selection-style' import { LayerContextMenu } from '@/components/panels/LayerContextMenu' -import { useProjectStore } from '@/stores/project-store' -import { useEditorStore } from '@/stores/editor-store' +import { useLayerPanelActions } from '@/hooks/useLayerPanelActions' import { cn } from '@/lib/utils' import type { Element } from '@/lib/types' @@ -37,22 +34,6 @@ function LayerTypeIcon({ element }: { element: Element }) { return } -function selectLayer( - elementId: string, - event: MouseEvent, - selectedIds: string[], - setSelected: (ids: string[]) => void, - toggle: (id: string) => void, -) { - const additive = event.shiftKey || event.metaKey || event.ctrlKey - if (additive) { - toggle(elementId) - return - } - if (selectedIds.length === 1 && selectedIds[0] === elementId) return - setSelected([elementId]) -} - function LayerRowActions({ element, onToggleVisible, @@ -99,21 +80,23 @@ function LayerRowActions({ function SortableLayerItem({ element, depth = 0, + isSelected, + onSelect, onContextMenu, + onToggleVisible, + onToggleLocked, }: { element: Element depth?: number + isSelected: boolean + onSelect: (elementId: string, event: MouseEvent) => void onContextMenu: (event: MouseEvent, elementId: string) => void + onToggleVisible: () => void + onToggleLocked: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: element.id, }) - const selectedElementIds = useEditorStore((state) => state.selectedElementIds) - const setSelectedElementIds = useEditorStore((state) => state.setSelectedElementIds) - const toggleSelection = useEditorStore((state) => state.toggleSelection) - const updateElement = useProjectStore((state) => state.updateElement) - - const isSelected = selectedElementIds.includes(element.id) const style = { transform: CSS.Transform.toString(transform), @@ -129,14 +112,12 @@ function SortableLayerItem({ 'group/layer relative flex h-7 cursor-default items-center gap-1 pr-1.5 text-[12px] select-none', isDragging && 'z-10 opacity-60', isSelected - ? 'bg-[#18A0FB]/12 text-foreground ring-1 ring-inset ring-[#18A0FB]/35' + ? cn(LAYER_LIST_ITEM_SELECTED) : 'text-foreground/90 hover:bg-muted/70', !element.visible && 'opacity-45', - isSelected && 'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-[#18A0FB]', + isSelected && LAYER_LIST_ITEM_SELECTED_INDICATOR, )} - onClick={(event) => { - selectLayer(element.id, event, selectedElementIds, setSelectedElementIds, toggleSelection) - }} + onClick={(event) => onSelect(element.id, event)} onContextMenu={(event) => onContextMenu(event, element.id)} > + + {label} + + + ) +} diff --git a/src/hooks/useAssetLibrary.ts b/src/hooks/useAssetLibrary.ts new file mode 100644 index 0000000..9e3ed68 --- /dev/null +++ b/src/hooks/useAssetLibrary.ts @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { createAssetObjectUrl } from '@/lib/assets/image-pipeline' +import { getImageFilesFromDataTransfer, hasImageFiles, isImageFile } from '@/lib/assets/drag-files' +import { persistProjectAsset } from '@/lib/assets/persist-project-asset' +import { deleteAsset, getProjectAssets } from '@/lib/db' +import { confirm } from '@/stores/confirm-store' +import { toast } from '@/stores/toast-store' +import { useEditorStore } from '@/stores/editor-store' +import { useProjectStore } from '@/stores/project-store' +import type { AssetRecord } from '@/lib/types' + +const ASSET_ROW_HEIGHT = 72 + +export function useAssetLibrary() { + const project = useProjectStore((state) => state.project) + const assetUrls = useProjectStore((state) => state.assetUrls) + const activeScreen = useProjectStore((state) => state.getActiveScreen()) + const selectedElementIds = useEditorStore((state) => state.selectedElementIds) + const addImageFromAsset = useProjectStore((state) => state.addImageFromAsset) + const registerAssetUrl = useProjectStore((state) => state.registerAssetUrl) + const updateElement = useProjectStore((state) => state.updateElement) + + const [assets, setAssets] = useState([]) + const [query, setQuery] = useState('') + const [uploadMode, setUploadMode] = useState<'library' | 'canvas'>('library') + const [isDragging, setIsDragging] = useState(false) + + const parentRef = useRef(null) + const fileInputRef = useRef(null) + const dragCounterRef = useRef(0) + + const loadAssets = useCallback(async () => { + if (!project) return + try { + const records = await getProjectAssets(project.id) + setAssets(records) + records.forEach((asset) => registerAssetUrl(asset.id, createAssetObjectUrl(asset))) + } catch (error) { + console.error('Failed to load project assets', error) + toast('Could not load assets', 'error') + } + }, [project, registerAssetUrl]) + + useEffect(() => { + void loadAssets() + }, [loadAssets]) + + const filtered = useMemo( + () => assets.filter((asset) => asset.name.toLowerCase().includes(query.toLowerCase())), + [assets, query], + ) + + const virtualizer = useVirtualizer({ + count: filtered.length, + getScrollElement: () => parentRef.current, + estimateSize: () => ASSET_ROW_HEIGHT, + overscan: 6, + }) + + const uploadFiles = useCallback( + async (files: File[]) => { + if (!project) return + const imageFiles = files.filter(isImageFile) + if (imageFiles.length === 0) return + + try { + for (const file of imageFiles) { + const asset = await persistProjectAsset(file, project.id, registerAssetUrl) + const url = createAssetObjectUrl(asset) + if (uploadMode === 'canvas') { + addImageFromAsset(asset.id, url) + } + } + await loadAssets() + } catch (error) { + console.error('Failed to upload assets', error) + toast('Upload failed', 'error') + } + }, + [project, registerAssetUrl, addImageFromAsset, loadAssets, uploadMode], + ) + + const onUpload = useCallback( + async (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []) + await uploadFiles(files) + event.target.value = '' + }, + [uploadFiles], + ) + + const onDelete = useCallback( + async (assetId: string) => { + const confirmed = await confirm({ + title: 'Delete asset?', + description: + 'This removes the asset from your library. Elements using it may appear broken.', + confirmLabel: 'Delete', + destructive: true, + }) + if (!confirmed) return + + try { + await deleteAsset(assetId) + await loadAssets() + } catch (error) { + console.error('Failed to delete asset', error) + toast('Could not delete asset', 'error') + } + }, + [loadAssets], + ) + + const onAddToCanvas = useCallback( + (assetId: string) => { + const url = assetUrls[assetId] + if (!url) { + toast('Asset preview is not ready yet', 'error') + return + } + addImageFromAsset(assetId, url) + }, + [assetUrls, addImageFromAsset], + ) + + const onAssignToDevice = useCallback( + (assetId: string) => { + const device = activeScreen?.elements.find( + (item) => selectedElementIds.includes(item.id) && item.type === 'device', + ) + if (!device) { + toast('Select a device frame first', 'error') + return + } + updateElement(device.id, { screenshotAssetId: assetId }) + }, + [activeScreen, selectedElementIds, updateElement], + ) + + const onDragEnter = useCallback((event: React.DragEvent) => { + if (!hasImageFiles(event)) return + event.preventDefault() + dragCounterRef.current += 1 + setIsDragging(true) + }, []) + + const onDragLeave = useCallback((event: React.DragEvent) => { + if (!hasImageFiles(event)) return + dragCounterRef.current -= 1 + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0 + setIsDragging(false) + } + }, []) + + const onDragOver = useCallback((event: React.DragEvent) => { + if (!hasImageFiles(event)) return + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + }, []) + + const onDrop = useCallback( + async (event: React.DragEvent) => { + if (!hasImageFiles(event)) return + event.preventDefault() + dragCounterRef.current = 0 + setIsDragging(false) + const files = getImageFilesFromDataTransfer(event.dataTransfer) + await uploadFiles(files) + }, + [uploadFiles], + ) + + const openFilePicker = useCallback(() => { + fileInputRef.current?.click() + }, []) + + const assetCountLabel = + assets.length === 0 + ? 'Empty' + : filtered.length === assets.length + ? `${assets.length} asset${assets.length === 1 ? '' : 's'}` + : `${filtered.length} of ${assets.length}` + + const showEmptyLibrary = assets.length === 0 + const showNoResults = !showEmptyLibrary && filtered.length === 0 + + return { + filtered, + assetUrls, + query, + setQuery, + uploadMode, + setUploadMode, + isDragging, + parentRef, + fileInputRef, + virtualizer, + assetCountLabel, + showEmptyLibrary, + showNoResults, + onUpload, + onDelete, + onAddToCanvas, + onAssignToDevice, + onDragEnter, + onDragLeave, + onDragOver, + onDrop, + openFilePicker, + } +} diff --git a/src/hooks/useAssetResolver.ts b/src/hooks/useAssetResolver.ts new file mode 100644 index 0000000..1cf4a53 --- /dev/null +++ b/src/hooks/useAssetResolver.ts @@ -0,0 +1,16 @@ +import { useCallback } from 'react' +import { useProjectStore } from '@/stores/project-store' + +export function buildAssetResolver( + assetUrls: Record, +): (assetId?: string) => string | undefined { + return (assetId?: string) => (assetId ? assetUrls[assetId] : undefined) +} + +export function useAssetResolver(): (assetId?: string) => string | undefined { + const assetUrls = useProjectStore((state) => state.assetUrls) + return useCallback( + (assetId?: string) => (assetId ? assetUrls[assetId] : undefined), + [assetUrls], + ) +} diff --git a/src/hooks/useCanvasDrop.ts b/src/hooks/useCanvasDrop.ts index c56cc34..bdb93be 100644 --- a/src/hooks/useCanvasDrop.ts +++ b/src/hooks/useCanvasDrop.ts @@ -1,7 +1,8 @@ import { useCallback } from 'react' import { findScreenAtPoint } from '@/lib/canvas/workspace-layout' import { workspaceToScreenLocal } from '@/lib/canvas/coordinates' -import { createImageElement } from '@/lib/factories' +import { createImageElement, sortElementsByZIndex } from '@/lib/factories' +import { getImageFilesFromDataTransfer, hasImageFiles } from '@/lib/assets/drag-files' import { usePersistAssetUpload } from '@/hooks/usePersistAssetUpload' import { useEditorStore } from '@/stores/editor-store' import { useProjectStore } from '@/stores/project-store' @@ -30,23 +31,22 @@ export function useCanvasDrop({ screens, clientToWorkspace }: UseCanvasDropOptio setActiveScreenId(targetScreen.id) - const files = Array.from(event.dataTransfer.files).filter((file) => - file.type.startsWith('image/'), - ) + const files = getImageFilesFromDataTransfer(event.dataTransfer) if (files.length === 0) return const localPoint = workspaceToScreenLocal(point, screenLayout, targetScreen.id) - const targetDevice = [...targetScreen.elements] - .filter( + const targetDevice = sortElementsByZIndex( + 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] + ), + 'desc', + )[0] for (const file of files) { const asset = await uploadAsset(file, 'screenshot') @@ -79,7 +79,7 @@ export function useCanvasDrop({ screens, clientToWorkspace }: UseCanvasDropOptio ) const handleDragOver = useCallback((event: React.DragEvent) => { - if (Array.from(event.dataTransfer.types).includes('Files')) { + if (hasImageFiles(event)) { event.preventDefault() event.dataTransfer.dropEffect = 'copy' } diff --git a/src/hooks/useCanvasSnapping.ts b/src/hooks/useCanvasSnapping.ts new file mode 100644 index 0000000..9998e04 --- /dev/null +++ b/src/hooks/useCanvasSnapping.ts @@ -0,0 +1,78 @@ +import { useCallback, type RefObject } from 'react' +import type Konva from 'konva' +import { applySnapping, computeSnap } from '@/lib/canvas/helpers' +import { + getAbsoluteNodePosition, + setAbsoluteNodePosition, +} from '@/lib/canvas/coordinates' +import type { InteractionOverlayHandle } from '@/components/canvas/InteractionOverlay' +import type { Element, Screen } from '@/lib/types' + +interface UseCanvasSnappingOptions { + screens: Screen[] + showSmartGuides: boolean + snapSensitivity: number + updateElement: (id: string, patch: Partial) => void + setActiveScreenId: (screenId: string) => void + overlayRef: RefObject + scheduleDraw: () => void +} + +export function useCanvasSnapping({ + screens, + showSmartGuides, + snapSensitivity, + updateElement, + setActiveScreenId, + overlayRef, + scheduleDraw, +}: UseCanvasSnappingOptions) { + const handleElementChange = useCallback( + (screenId: string, id: string, patch: Partial) => { + setActiveScreenId(screenId) + const screen = screens.find((item) => item.id === screenId) + const element = screen?.elements.find((item) => item.id === id) + if (!element || !screen) return + + let next = { ...element, ...patch } as Element + if (showSmartGuides && ('x' in patch || 'y' in patch)) { + next = applySnapping( + next, + screen.elements.filter((item) => item.id !== id), + screen.width, + screen.height, + snapSensitivity, + ) + } + overlayRef.current?.clear() + updateElement(id, next) + }, + [screens, showSmartGuides, snapSensitivity, updateElement, setActiveScreenId, overlayRef], + ) + + const handleDragMove = useCallback( + (screenId: string, id: string, node: Konva.Node) => { + setActiveScreenId(screenId) + if (!showSmartGuides) return + const screen = screens.find((item) => item.id === screenId) + const element = screen?.elements.find((item) => item.id === id) + if (!screen || !element) return + const absolute = getAbsoluteNodePosition(element, screen, node) + const moving = { ...element, x: absolute.x, y: absolute.y } as Element + const others = screen.elements.filter((item) => item.id !== id) + const { x, y, lines } = computeSnap( + moving, + others, + screen.width, + screen.height, + snapSensitivity, + ) + setAbsoluteNodePosition(element, screen, node, x, y) + overlayRef.current?.setGuides(lines, screen.width, screen.height) + scheduleDraw() + }, + [screens, showSmartGuides, snapSensitivity, setActiveScreenId, overlayRef, scheduleDraw], + ) + + return { handleElementChange, handleDragMove } +} diff --git a/src/hooks/useCanvasViewport.ts b/src/hooks/useCanvasViewport.ts index 1e9b46a..1ba6a38 100644 --- a/src/hooks/useCanvasViewport.ts +++ b/src/hooks/useCanvasViewport.ts @@ -31,6 +31,7 @@ export function useCanvasViewport({ containerRef, screens }: UseCanvasViewportOp const isPanning = useEditorStore((state) => state.isPanning) const isSpacePressed = useEditorStore((state) => state.isSpacePressed) const setIsPanning = useEditorStore((state) => state.setIsPanning) + const setIsSpacePressed = useEditorStore((state) => state.setIsSpacePressed) const fitRequest = useEditorStore((state) => state.fitRequest) const viewport = useMemo( @@ -115,6 +116,26 @@ export function useCanvasViewport({ containerRef, screens }: UseCanvasViewportOp useEditorStore.setState({ fitRequest: null }) }, [fitRequest, fitAll, fitActive]) + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.code === 'Space' && !(event.target as HTMLElement).matches('input, textarea')) { + setIsSpacePressed(true) + } + } + const onKeyUp = (event: KeyboardEvent) => { + if (event.code === 'Space') { + setIsSpacePressed(false) + setIsPanning(false) + } + } + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + } + }, [setIsPanning, setIsSpacePressed]) + const zoomAtPoint = useCallback( (clientX: number, clientY: number, nextZoom: number) => { const rect = containerRef.current?.getBoundingClientRect() diff --git a/src/hooks/useKonvaStageBridge.ts b/src/hooks/useKonvaStageBridge.ts new file mode 100644 index 0000000..4a830f6 --- /dev/null +++ b/src/hooks/useKonvaStageBridge.ts @@ -0,0 +1,35 @@ +import { useEffect, type RefObject } from 'react' +import type Konva from 'konva' +import { exportActiveScreenBlobFromStage } from '@/lib/canvas/konva-export' +import { useEditorStore } from '@/stores/editor-store' + +interface UseKonvaStageBridgeOptions { + stageRef: RefObject + activeScreenId: string | null +} + +export function useKonvaStageBridge({ stageRef, activeScreenId }: UseKonvaStageBridgeOptions) { + const setKonvaStageBridge = useEditorStore((state) => state.setKonvaStageBridge) + + useEffect(() => { + const stage = stageRef.current + if (!stage) { + setKonvaStageBridge(null) + return + } + + setKonvaStageBridge({ + activeScreenId, + exportActiveScreen: async (screenId, options) => { + const currentStage = stageRef.current + if (!currentStage || screenId !== activeScreenId) return null + return exportActiveScreenBlobFromStage( + { stage: currentStage, screenId, isActiveOnCanvas: true }, + options, + ) + }, + }) + + return () => setKonvaStageBridge(null) + }, [activeScreenId, setKonvaStageBridge, stageRef]) +} diff --git a/src/hooks/useLayerPanelActions.ts b/src/hooks/useLayerPanelActions.ts new file mode 100644 index 0000000..46f8151 --- /dev/null +++ b/src/hooks/useLayerPanelActions.ts @@ -0,0 +1,191 @@ +import { useCallback, useMemo, useState, type MouseEvent } from 'react' +import { PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core' +import { arrayMove } from '@dnd-kit/sortable' +import { + buildLayerContextTarget, + resolveContextMenuSelection, + sortLayersByZIndex, + toggleGroupSelection, +} from '@/lib/layers/layer-panel-logic' +import { isAdditiveSelection } from '@/lib/selection/is-additive-selection' +import { useEditorStore } from '@/stores/editor-store' +import { useProjectStore } from '@/stores/project-store' +import type { Element } from '@/lib/types' + +export interface LayerContextMenuState { + x: number + y: number + elementIds: string[] +} + +export type LayerContextTarget = ReturnType + +export interface LayerContextMenuActions { + duplicate: () => void + delete: () => void + bringForward: () => void + sendBackward: () => void + toggleVisible: () => void + toggleLocked: () => void +} + +export function useLayerPanelActions() { + const screen = useProjectStore((state) => state.getActiveScreen()) + const reorderElements = useProjectStore((state) => state.reorderElements) + const duplicateElements = useProjectStore((state) => state.duplicateElements) + const deleteElements = useProjectStore((state) => state.deleteElements) + const bringForward = useProjectStore((state) => state.bringForward) + const sendBackward = useProjectStore((state) => state.sendBackward) + const updateElement = useProjectStore((state) => state.updateElement) + + const selectedElementIds = useEditorStore((state) => state.selectedElementIds) + const setSelectedElementIds = useEditorStore((state) => state.setSelectedElementIds) + const toggleSelection = useEditorStore((state) => state.toggleSelection) + const clearSelection = useEditorStore((state) => state.clearSelection) + + const [contextMenu, setContextMenu] = useState(null) + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 4 }, + }), + ) + + const sortedLayers = useMemo( + () => (screen ? sortLayersByZIndex(screen) : []), + [screen], + ) + + const openContextMenu = useCallback( + (event: MouseEvent, elementIds: string[]) => { + event.preventDefault() + event.stopPropagation() + const uniqueIds = Array.from(new Set(elementIds)) + const hitsSelection = uniqueIds.every((id) => selectedElementIds.includes(id)) + const nextSelection = resolveContextMenuSelection(uniqueIds, selectedElementIds) + if (!hitsSelection || selectedElementIds.length === 0) { + setSelectedElementIds(uniqueIds) + } + setContextMenu({ + x: event.clientX, + y: event.clientY, + elementIds: nextSelection, + }) + }, + [selectedElementIds, setSelectedElementIds], + ) + + const contextTarget = useMemo((): LayerContextTarget | null => { + if (!contextMenu || !screen) return null + return buildLayerContextTarget(screen, contextMenu.elementIds) + }, [contextMenu, screen]) + + const closeContextMenu = useCallback(() => setContextMenu(null), []) + + const selectLayer = useCallback( + (elementId: string, event: MouseEvent) => { + if (isAdditiveSelection(event)) { + toggleSelection(elementId) + return + } + if (selectedElementIds.length === 1 && selectedElementIds[0] === elementId) return + setSelectedElementIds([elementId]) + }, + [selectedElementIds, setSelectedElementIds, toggleSelection], + ) + + const selectGroup = useCallback( + (memberIds: string[], event: MouseEvent) => { + setSelectedElementIds( + toggleGroupSelection(memberIds, selectedElementIds, isAdditiveSelection(event)), + ) + }, + [selectedElementIds, setSelectedElementIds], + ) + + const onDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + const oldIndex = sortedLayers.findIndex((element) => element.id === active.id) + const newIndex = sortedLayers.findIndex((element) => element.id === over.id) + const reordered = arrayMove(sortedLayers, oldIndex, newIndex).reverse() + reorderElements(reordered.map((element) => element.id)) + }, + [sortedLayers, reorderElements], + ) + + const duplicateSelection = useCallback(() => { + duplicateElements(selectedElementIds) + }, [duplicateElements, selectedElementIds]) + + const deleteSelection = useCallback(() => { + deleteElements(selectedElementIds) + clearSelection() + }, [deleteElements, selectedElementIds, clearSelection]) + + const contextMenuActions = useMemo((): LayerContextMenuActions | null => { + if (!contextMenu || !contextTarget) return null + const { elementIds } = contextMenu + + return { + duplicate: () => duplicateElements(elementIds), + delete: () => { + deleteElements(elementIds) + clearSelection() + }, + bringForward: () => { + for (const id of elementIds) bringForward(id) + }, + sendBackward: () => { + for (const id of elementIds) sendBackward(id) + }, + toggleVisible: () => { + const nextVisible = !contextTarget.allVisible + for (const id of elementIds) updateElement(id, { visible: nextVisible }) + }, + toggleLocked: () => { + const nextLocked = !contextTarget.allLocked + for (const id of elementIds) updateElement(id, { locked: nextLocked }) + }, + } + }, [ + contextMenu, + contextTarget, + duplicateElements, + deleteElements, + clearSelection, + bringForward, + sendBackward, + updateElement, + ]) + + const toggleLayerVisible = useCallback( + (element: Element) => updateElement(element.id, { visible: !element.visible }), + [updateElement], + ) + + const toggleLayerLocked = useCallback( + (element: Element) => updateElement(element.id, { locked: !element.locked }), + [updateElement], + ) + + return { + screen, + sortedLayers, + selectedElementIds, + sensors, + contextMenu, + contextTarget, + contextMenuActions, + openContextMenu, + closeContextMenu, + selectLayer, + selectGroup, + onDragEnd, + duplicateSelection, + deleteSelection, + toggleLayerVisible, + toggleLayerLocked, + } +} diff --git a/src/lib/assets/drag-files.ts b/src/lib/assets/drag-files.ts new file mode 100644 index 0000000..f75804e --- /dev/null +++ b/src/lib/assets/drag-files.ts @@ -0,0 +1,11 @@ +export function isImageFile(file: File): boolean { + return file.type.startsWith('image/') +} + +export function hasImageFiles(event: React.DragEvent): boolean { + return Array.from(event.dataTransfer.types).includes('Files') +} + +export function getImageFilesFromDataTransfer(dataTransfer: DataTransfer): File[] { + return Array.from(dataTransfer.files).filter(isImageFile) +} diff --git a/src/lib/canvas/background-presets.ts b/src/lib/canvas/background-presets.ts new file mode 100644 index 0000000..56eb7b3 --- /dev/null +++ b/src/lib/canvas/background-presets.ts @@ -0,0 +1,171 @@ +import type { BackgroundConfig, PatternKind } from '@/lib/types' +import { BRAND_PRIMARY } from '@/lib/constants' + +export interface BackgroundPreset { + id: string + label: string + background: BackgroundConfig +} + +function linear(angle: number, ...colors: string[]): BackgroundConfig { + return { + type: 'linear-gradient', + gradient: { + type: 'linear', + angle, + stops: colors.map((color, index) => ({ + offset: colors.length === 1 ? 0 : index / (colors.length - 1), + color, + })), + }, + } +} + +function mesh(colors: string[]): BackgroundConfig { + return { type: 'mesh', color: '#0f172a', meshColors: colors } +} + +function glass(angle: number, start: string, mid: string, end: string): BackgroundConfig { + return { + type: 'linear-gradient', + gradient: { + type: 'linear', + angle, + stops: [ + { offset: 0, color: start }, + { offset: 0.5, color: mid }, + { offset: 1, color: end }, + ], + }, + } +} + +function pattern( + patternKind: PatternKind, + color: string, + patternColor: string, + patternScale: number, +): BackgroundConfig { + return { type: 'pattern', patternKind, color, patternColor, patternScale } +} + +export const GRADIENT_PRESETS: BackgroundPreset[] = [ + { id: 'indigo', label: 'Teal', background: linear(145, BRAND_PRIMARY, '#1c1917') }, + { id: 'sunset', label: 'Sunset', background: linear(160, '#ff7e5f', '#feb47b') }, + { id: 'ocean', label: 'Ocean', background: linear(150, '#2193b0', '#6dd5ed') }, + { id: 'grape', label: 'Grape', background: linear(160, '#8e2de2', '#4a00e0') }, + { id: 'mint', label: 'Mint', background: linear(150, '#11998e', '#38ef7d') }, + { id: 'peach', label: 'Peach', background: linear(160, '#ed4264', '#ffedbc') }, + { id: 'midnight', label: 'Midnight', background: linear(160, '#232526', '#414345') }, + { id: 'rose', label: 'Rose', background: linear(150, '#ec008c', '#fc6767') }, + { id: 'aurora', label: 'Aurora', background: linear(135, '#667eea', '#764ba2') }, + { id: 'electric', label: 'Electric', background: linear(145, '#4776e6', '#8e54e9') }, + { id: 'cosmos', label: 'Cosmos', background: linear(160, '#0f0c29', '#302b63') }, + { id: 'cotton', label: 'Cotton', background: linear(135, '#a8edea', '#fed6e3') }, + { id: 'gold', label: 'Gold', background: linear(160, '#f7971e', '#ffd200') }, + { id: 'flamingo', label: 'Flamingo', background: linear(150, '#fa709a', '#fee140') }, + { id: 'blush', label: 'Blush', background: linear(150, '#ff9a9e', '#fad0c4') }, + { id: 'arctic', label: 'Arctic', background: linear(180, '#e0eafc', '#cfdef3') }, + { id: 'ember', label: 'Ember', background: linear(160, '#ff416c', '#ff4b2b') }, + { id: 'twilight', label: 'Twilight', background: linear(145, '#8360c3', '#2ebf91') }, + { id: 'navy', label: 'Navy', background: linear(160, '#141e30', '#243b55') }, + { id: 'lime', label: 'Lime', background: linear(150, '#a8ff78', '#78ffd6') }, + { id: 'slate', label: 'Slate', background: linear(160, '#64748b', '#0f172a') }, + { id: 'orchid', label: 'Orchid', background: linear(135, '#cc2b5e', '#753a88') }, + { id: 'sky', label: 'Sky', background: linear(180, '#89f7fe', '#66a6ff') }, + { id: 'wine', label: 'Wine', background: linear(150, '#200122', '#6f0000') }, +] + +export const MESH_PRESETS: BackgroundPreset[] = [ + { id: 'mesh-aurora', label: 'Aurora', background: mesh([BRAND_PRIMARY, '#ec4899', '#22d3ee', '#1c1917']) }, + { id: 'mesh-candy', label: 'Candy', background: mesh(['#f472b6', '#a78bfa', '#fbbf24', '#1e1b4b']) }, + { id: 'mesh-forest', label: 'Forest', background: mesh(['#10b981', '#34d399', '#065f46', '#022c22']) }, + { id: 'mesh-dusk', label: 'Dusk', background: mesh(['#f59e0b', '#ef4444', '#7c3aed', '#111827']) }, +] + +export const GLASS_PRESETS: BackgroundPreset[] = [ + { id: 'glass-frost', label: 'Frost', background: glass(135, '#e0e7ff', '#f5f3ff', '#cffafe') }, + { id: 'glass-smoke', label: 'Smoke', background: glass(135, '#334155', '#1e293b', '#0f172a') }, + { id: 'glass-pearl', label: 'Pearl', background: glass(145, '#ffffff', '#f8fafc', '#e2e8f0') }, + { id: 'glass-sand', label: 'Sand', background: glass(135, '#f5f7fa', '#e8edf2', '#c3cfe2') }, + { id: 'glass-crystal', label: 'Crystal', background: glass(160, '#eef2ff', '#f8fafc', '#e0f2fe') }, + { id: 'glass-blush', label: 'Blush', background: glass(150, '#fff1f2', '#fce7f3', '#fdf2f8') }, + { id: 'glass-lavender', label: 'Lavender', background: glass(135, '#ede9fe', '#f5f3ff', '#dbeafe') }, + { id: 'glass-mint', label: 'Mint', background: glass(150, '#ecfdf5', '#d1fae5', '#ccfbf1') }, + { id: 'glass-sky', label: 'Sky', background: glass(180, '#e0f2fe', '#f0f9ff', '#dbeafe') }, + { id: 'glass-honey', label: 'Honey', background: glass(160, '#fffbeb', '#fef3c7', '#fde68a') }, + { id: 'glass-aurora', label: 'Aurora', background: glass(135, '#e0e7ff', '#fae8ff', '#cffafe') }, + { id: 'glass-rose', label: 'Rose', background: glass(150, '#fef3c7', '#fde68a', '#fecdd3') }, + { id: 'glass-slate', label: 'Slate', background: glass(145, '#64748b', '#334155', '#1e293b') }, + { id: 'glass-midnight', label: 'Midnight', background: glass(160, '#1e293b', '#0f172a', '#020617') }, + { id: 'glass-obsidian', label: 'Obsidian', background: glass(145, '#374151', '#1f2937', '#111827') }, + { id: 'glass-indigo', label: 'Indigo', background: glass(135, '#4338ca', '#312e81', '#1e1b4b') }, +] + +export const PATTERN_PRESETS: BackgroundPreset[] = [ + { + id: 'pattern-dots', + label: 'Dots', + background: pattern('dots', '#1c1917', BRAND_PRIMARY, 28), + }, + { + id: 'pattern-grid', + label: 'Grid', + background: pattern('grid', '#0f172a', '#334155', 40), + }, + { + id: 'pattern-diagonal', + label: 'Stripes', + background: pattern('diagonal', '#1e1b4b', '#312e81', 32), + }, + { + id: 'pattern-crosshatch', + label: 'Crosshatch', + background: pattern('crosshatch', '#0f172a', '#1e293b', 28), + }, + { + id: 'pattern-checker', + label: 'Checker', + background: pattern('checker', '#0f172a', '#111827', 48), + }, + { + id: 'pattern-triangles', + label: 'Geometry', + background: pattern('triangles', '#1c1917', BRAND_PRIMARY, 56), + }, + { id: 'pattern-dots-cyan', label: 'Cyber', background: pattern('dots', '#020617', '#22d3ee', 24) }, + { id: 'pattern-dots-rose', label: 'Bloom', background: pattern('dots', '#18181b', '#f472b6', 26) }, + { id: 'pattern-dots-fine', label: 'Pinpoint', background: pattern('dots', '#0f172a', '#64748b', 16) }, + { id: 'pattern-dots-light', label: 'Mist', background: pattern('dots', '#f8fafc', '#cbd5e1', 22) }, + { id: 'pattern-grid-blueprint', label: 'Blueprint', background: pattern('grid', '#0a1628', '#1d4ed8', 36) }, + { id: 'pattern-grid-minimal', label: 'Minimal', background: pattern('grid', '#fafafa', '#e4e4e7', 32) }, + { id: 'pattern-diagonal-mint', label: 'Mint', background: pattern('diagonal', '#022c22', '#34d399', 28) }, + { id: 'pattern-diagonal-copper', label: 'Copper', background: pattern('diagonal', '#1c1917', '#f97316', 30) }, + { id: 'pattern-cross-violet', label: 'Mesh', background: pattern('crosshatch', '#0f0a1a', '#8b5cf6', 24) }, + { id: 'pattern-cross-emerald', label: 'Weave', background: pattern('crosshatch', '#042f2e', '#2dd4bf', 26) }, + { id: 'pattern-checker-tile', label: 'Tile', background: pattern('checker', '#18181b', '#27272a', 40) }, + { id: 'pattern-checker-lilac', label: 'Lilac', background: pattern('checker', '#1e1b4b', '#4338ca', 44) }, + { id: 'pattern-triangles-facet', label: 'Facet', background: pattern('triangles', '#0f172a', '#475569', 48) }, + { id: 'pattern-triangles-prism', label: 'Prism', background: pattern('triangles', '#09090b', BRAND_PRIMARY, 52) }, + { id: 'pattern-noise-film', label: 'Film', background: pattern('noise', '#171717', '#525252', 20) }, + { id: 'pattern-noise-paper', label: 'Paper', background: pattern('noise', '#fafaf9', '#d6d3d1', 18) }, + { id: 'pattern-dots-gold', label: 'Gilded', background: pattern('dots', '#0c0a09', '#fbbf24', 30) }, + { id: 'pattern-grid-slate', label: 'Graph', background: pattern('grid', '#111827', '#4b5563', 28) }, +] + +export const PATTERN_KINDS: PatternKind[] = [ + 'dots', + 'grid', + 'diagonal', + 'crosshatch', + 'checker', + 'triangles', + 'noise', +] + +export const ALL_BACKGROUND_PRESETS: { title: string; presets: BackgroundPreset[] }[] = [ + { title: 'Gradients', presets: GRADIENT_PRESETS }, + { title: 'Mesh', presets: MESH_PRESETS }, + { title: 'Glass', presets: GLASS_PRESETS }, + { title: 'Patterns', presets: PATTERN_PRESETS }, +] diff --git a/src/lib/canvas/background-render.ts b/src/lib/canvas/background-render.ts new file mode 100644 index 0000000..fef0eb9 --- /dev/null +++ b/src/lib/canvas/background-render.ts @@ -0,0 +1,204 @@ +import type { BackgroundConfig, MeshBlob, PatternKind } from '@/lib/types' +import { BRAND_PRIMARY } from '@/lib/constants' +import { fillRectWithCanvasGradient } from '@/lib/canvas/create-canvas-gradient' +import { drawImageWithObjectFit } from '@/lib/canvas/image-fit' + +const DEFAULT_MESH_LAYOUT: Array<{ x: number; y: number; radius: number }> = [ + { x: 0.2, y: 0.2, radius: 0.7 }, + { x: 0.85, y: 0.25, radius: 0.6 }, + { x: 0.25, y: 0.85, radius: 0.65 }, + { x: 0.8, y: 0.8, radius: 0.7 }, +] + +function resolveMeshBlobs(background: BackgroundConfig, width: number, height: number): MeshBlob[] { + if (background.meshBlobs?.length) return background.meshBlobs + const colors = background.meshColors ?? [BRAND_PRIMARY, '#ec4899', '#22d3ee', '#1c1917'] + const diag = Math.max(width, height) + return colors.slice(0, DEFAULT_MESH_LAYOUT.length).map((color, index) => { + const layout = DEFAULT_MESH_LAYOUT[index] ?? DEFAULT_MESH_LAYOUT[0] + return { + color, + x: layout.x * width, + y: layout.y * height, + radius: layout.radius * diag, + } + }) +} + +function drawPattern( + ctx: CanvasRenderingContext2D, + kind: PatternKind, + fg: string, + scale: number, + width: number, + height: number, +) { + ctx.fillStyle = fg + ctx.strokeStyle = fg + ctx.lineWidth = Math.max(1, scale / 16) + const s = Math.max(8, scale) + + if (kind === 'dots') { + const r = s / 8 + for (let y = s / 2; y < height; y += s) { + for (let x = s / 2; x < width; x += s) { + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.fill() + } + } + return + } + + if (kind === 'grid') { + for (let x = 0; x <= width; x += s) { + ctx.beginPath() + ctx.moveTo(x, 0) + ctx.lineTo(x, height) + ctx.stroke() + } + for (let y = 0; y <= height; y += s) { + ctx.beginPath() + ctx.moveTo(0, y) + ctx.lineTo(width, y) + ctx.stroke() + } + return + } + + if (kind === 'diagonal' || kind === 'crosshatch') { + for (let d = -height; d < width; d += s) { + ctx.beginPath() + ctx.moveTo(d, 0) + ctx.lineTo(d + height, height) + ctx.stroke() + } + if (kind === 'crosshatch') { + for (let d = 0; d < width + height; d += s) { + ctx.beginPath() + ctx.moveTo(d, 0) + ctx.lineTo(d - height, height) + ctx.stroke() + } + } + return + } + + if (kind === 'checker') { + for (let y = 0; y < height; y += s) { + for (let x = 0; x < width; x += s) { + if (((x / s) | 0) % 2 === ((y / s) | 0) % 2) { + ctx.fillRect(x, y, s, s) + } + } + } + return + } + + if (kind === 'triangles') { + for (let y = 0; y < height; y += s) { + for (let x = 0; x < width; x += s) { + ctx.globalAlpha = ((x / s + y / s) | 0) % 2 === 0 ? 0.5 : 0.18 + ctx.beginPath() + ctx.moveTo(x, y) + ctx.lineTo(x + s, y) + ctx.lineTo(x, y + s) + ctx.closePath() + ctx.fill() + } + } + ctx.globalAlpha = 1 + return + } + + if (kind === 'noise') { + const count = Math.floor((width * height) / (s * s)) * 6 + ctx.globalAlpha = 0.25 + for (let i = 0; i < count; i += 1) { + const x = Math.random() * width + const y = Math.random() * height + ctx.fillRect(x, y, 1.5, 1.5) + } + ctx.globalAlpha = 1 + } +} + +export function buildBackgroundCanvas( + background: BackgroundConfig, + width: number, + height: number, + image?: (CanvasImageSource & { width: number; height: number }) | null, + pixelRatio = 1, +): HTMLCanvasElement { + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(width * pixelRatio)) + canvas.height = Math.max(1, Math.round(height * pixelRatio)) + const ctx = canvas.getContext('2d') + if (!ctx) return canvas + ctx.scale(pixelRatio, pixelRatio) + + ctx.fillStyle = background.color ?? '#ffffff' + ctx.fillRect(0, 0, width, height) + + if (background.type === 'solid') { + return canvas + } + + if ( + (background.type === 'linear-gradient' || background.type === 'radial-gradient') && + background.gradient + ) { + fillRectWithCanvasGradient( + ctx, + { + type: background.type === 'radial-gradient' ? 'radial' : 'linear', + angle: background.gradient.angle, + stops: background.gradient.stops, + }, + width, + height, + ) + return canvas + } + + if (background.type === 'mesh') { + const blobs = resolveMeshBlobs(background, width, height) + for (const blob of blobs) { + const radial = ctx.createRadialGradient(blob.x, blob.y, 0, blob.x, blob.y, blob.radius) + radial.addColorStop(0, blob.color) + radial.addColorStop(1, 'rgba(0,0,0,0)') + ctx.fillStyle = radial + ctx.fillRect(0, 0, width, height) + } + return canvas + } + + if (background.type === 'pattern') { + drawPattern( + ctx, + background.patternKind ?? 'dots', + background.patternColor ?? BRAND_PRIMARY, + background.patternScale ?? 32, + width, + height, + ) + return canvas + } + + if (background.type === 'image' && image) { + drawImageWithObjectFit( + ctx, + image, + { x: 0, y: 0, width: image.width, height: image.height }, + { x: 0, y: 0, width, height }, + background.imageFit ?? 'cover', + ) + if (background.overlayColor) { + ctx.fillStyle = background.overlayColor + ctx.fillRect(0, 0, width, height) + } + return canvas + } + + return canvas +} diff --git a/src/lib/canvas/backgrounds.ts b/src/lib/canvas/backgrounds.ts index 4ec378c..dfc6666 100644 --- a/src/lib/canvas/backgrounds.ts +++ b/src/lib/canvas/backgrounds.ts @@ -1,387 +1,10 @@ -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 - label: string - background: BackgroundConfig -} - -function linear(angle: number, ...colors: string[]): BackgroundConfig { - return { - type: 'linear-gradient', - gradient: { - type: 'linear', - angle, - stops: colors.map((color, index) => ({ - offset: colors.length === 1 ? 0 : index / (colors.length - 1), - color, - })), - }, - } -} - -function mesh(colors: string[]): BackgroundConfig { - return { type: 'mesh', color: '#0f172a', meshColors: colors } -} - -function glass(angle: number, start: string, mid: string, end: string): BackgroundConfig { - return { - type: 'linear-gradient', - gradient: { - type: 'linear', - angle, - stops: [ - { offset: 0, color: start }, - { offset: 0.5, color: mid }, - { offset: 1, color: end }, - ], - }, - } -} - -function pattern( - patternKind: PatternKind, - color: string, - patternColor: string, - patternScale: number, -): BackgroundConfig { - return { type: 'pattern', patternKind, color, patternColor, patternScale } -} - -export const GRADIENT_PRESETS: BackgroundPreset[] = [ - { id: 'indigo', label: 'Teal', background: linear(145, BRAND_PRIMARY, '#1c1917') }, - { id: 'sunset', label: 'Sunset', background: linear(160, '#ff7e5f', '#feb47b') }, - { id: 'ocean', label: 'Ocean', background: linear(150, '#2193b0', '#6dd5ed') }, - { id: 'grape', label: 'Grape', background: linear(160, '#8e2de2', '#4a00e0') }, - { id: 'mint', label: 'Mint', background: linear(150, '#11998e', '#38ef7d') }, - { id: 'peach', label: 'Peach', background: linear(160, '#ed4264', '#ffedbc') }, - { id: 'midnight', label: 'Midnight', background: linear(160, '#232526', '#414345') }, - { id: 'rose', label: 'Rose', background: linear(150, '#ec008c', '#fc6767') }, - { id: 'aurora', label: 'Aurora', background: linear(135, '#667eea', '#764ba2') }, - { id: 'electric', label: 'Electric', background: linear(145, '#4776e6', '#8e54e9') }, - { id: 'cosmos', label: 'Cosmos', background: linear(160, '#0f0c29', '#302b63') }, - { id: 'cotton', label: 'Cotton', background: linear(135, '#a8edea', '#fed6e3') }, - { id: 'gold', label: 'Gold', background: linear(160, '#f7971e', '#ffd200') }, - { id: 'flamingo', label: 'Flamingo', background: linear(150, '#fa709a', '#fee140') }, - { id: 'blush', label: 'Blush', background: linear(150, '#ff9a9e', '#fad0c4') }, - { id: 'arctic', label: 'Arctic', background: linear(180, '#e0eafc', '#cfdef3') }, - { id: 'ember', label: 'Ember', background: linear(160, '#ff416c', '#ff4b2b') }, - { id: 'twilight', label: 'Twilight', background: linear(145, '#8360c3', '#2ebf91') }, - { id: 'navy', label: 'Navy', background: linear(160, '#141e30', '#243b55') }, - { id: 'lime', label: 'Lime', background: linear(150, '#a8ff78', '#78ffd6') }, - { id: 'slate', label: 'Slate', background: linear(160, '#64748b', '#0f172a') }, - { id: 'orchid', label: 'Orchid', background: linear(135, '#cc2b5e', '#753a88') }, - { id: 'sky', label: 'Sky', background: linear(180, '#89f7fe', '#66a6ff') }, - { id: 'wine', label: 'Wine', background: linear(150, '#200122', '#6f0000') }, -] - -export const MESH_PRESETS: BackgroundPreset[] = [ - { id: 'mesh-aurora', label: 'Aurora', background: mesh([BRAND_PRIMARY, '#ec4899', '#22d3ee', '#1c1917']) }, - { id: 'mesh-candy', label: 'Candy', background: mesh(['#f472b6', '#a78bfa', '#fbbf24', '#1e1b4b']) }, - { id: 'mesh-forest', label: 'Forest', background: mesh(['#10b981', '#34d399', '#065f46', '#022c22']) }, - { id: 'mesh-dusk', label: 'Dusk', background: mesh(['#f59e0b', '#ef4444', '#7c3aed', '#111827']) }, -] - -export const GLASS_PRESETS: BackgroundPreset[] = [ - { id: 'glass-frost', label: 'Frost', background: glass(135, '#e0e7ff', '#f5f3ff', '#cffafe') }, - { id: 'glass-smoke', label: 'Smoke', background: glass(135, '#334155', '#1e293b', '#0f172a') }, - { id: 'glass-pearl', label: 'Pearl', background: glass(145, '#ffffff', '#f8fafc', '#e2e8f0') }, - { id: 'glass-sand', label: 'Sand', background: glass(135, '#f5f7fa', '#e8edf2', '#c3cfe2') }, - { id: 'glass-crystal', label: 'Crystal', background: glass(160, '#eef2ff', '#f8fafc', '#e0f2fe') }, - { id: 'glass-blush', label: 'Blush', background: glass(150, '#fff1f2', '#fce7f3', '#fdf2f8') }, - { id: 'glass-lavender', label: 'Lavender', background: glass(135, '#ede9fe', '#f5f3ff', '#dbeafe') }, - { id: 'glass-mint', label: 'Mint', background: glass(150, '#ecfdf5', '#d1fae5', '#ccfbf1') }, - { id: 'glass-sky', label: 'Sky', background: glass(180, '#e0f2fe', '#f0f9ff', '#dbeafe') }, - { id: 'glass-honey', label: 'Honey', background: glass(160, '#fffbeb', '#fef3c7', '#fde68a') }, - { id: 'glass-aurora', label: 'Aurora', background: glass(135, '#e0e7ff', '#fae8ff', '#cffafe') }, - { id: 'glass-rose', label: 'Rose', background: glass(150, '#fef3c7', '#fde68a', '#fecdd3') }, - { id: 'glass-slate', label: 'Slate', background: glass(145, '#64748b', '#334155', '#1e293b') }, - { id: 'glass-midnight', label: 'Midnight', background: glass(160, '#1e293b', '#0f172a', '#020617') }, - { id: 'glass-obsidian', label: 'Obsidian', background: glass(145, '#374151', '#1f2937', '#111827') }, - { id: 'glass-indigo', label: 'Indigo', background: glass(135, '#4338ca', '#312e81', '#1e1b4b') }, -] - -export const PATTERN_PRESETS: BackgroundPreset[] = [ - { - id: 'pattern-dots', - label: 'Dots', - background: pattern('dots', '#1c1917', BRAND_PRIMARY, 28), - }, - { - id: 'pattern-grid', - label: 'Grid', - background: pattern('grid', '#0f172a', '#334155', 40), - }, - { - id: 'pattern-diagonal', - label: 'Stripes', - background: pattern('diagonal', '#1e1b4b', '#312e81', 32), - }, - { - id: 'pattern-crosshatch', - label: 'Crosshatch', - background: pattern('crosshatch', '#0f172a', '#1e293b', 28), - }, - { - id: 'pattern-checker', - label: 'Checker', - background: pattern('checker', '#0f172a', '#111827', 48), - }, - { - id: 'pattern-triangles', - label: 'Geometry', - background: pattern('triangles', '#1c1917', BRAND_PRIMARY, 56), - }, - { id: 'pattern-dots-cyan', label: 'Cyber', background: pattern('dots', '#020617', '#22d3ee', 24) }, - { id: 'pattern-dots-rose', label: 'Bloom', background: pattern('dots', '#18181b', '#f472b6', 26) }, - { id: 'pattern-dots-fine', label: 'Pinpoint', background: pattern('dots', '#0f172a', '#64748b', 16) }, - { id: 'pattern-dots-light', label: 'Mist', background: pattern('dots', '#f8fafc', '#cbd5e1', 22) }, - { id: 'pattern-grid-blueprint', label: 'Blueprint', background: pattern('grid', '#0a1628', '#1d4ed8', 36) }, - { id: 'pattern-grid-minimal', label: 'Minimal', background: pattern('grid', '#fafafa', '#e4e4e7', 32) }, - { id: 'pattern-diagonal-mint', label: 'Mint', background: pattern('diagonal', '#022c22', '#34d399', 28) }, - { id: 'pattern-diagonal-copper', label: 'Copper', background: pattern('diagonal', '#1c1917', '#f97316', 30) }, - { id: 'pattern-cross-violet', label: 'Mesh', background: pattern('crosshatch', '#0f0a1a', '#8b5cf6', 24) }, - { id: 'pattern-cross-emerald', label: 'Weave', background: pattern('crosshatch', '#042f2e', '#2dd4bf', 26) }, - { id: 'pattern-checker-tile', label: 'Tile', background: pattern('checker', '#18181b', '#27272a', 40) }, - { id: 'pattern-checker-lilac', label: 'Lilac', background: pattern('checker', '#1e1b4b', '#4338ca', 44) }, - { id: 'pattern-triangles-facet', label: 'Facet', background: pattern('triangles', '#0f172a', '#475569', 48) }, - { id: 'pattern-triangles-prism', label: 'Prism', background: pattern('triangles', '#09090b', BRAND_PRIMARY, 52) }, - { id: 'pattern-noise-film', label: 'Film', background: pattern('noise', '#171717', '#525252', 20) }, - { id: 'pattern-noise-paper', label: 'Paper', background: pattern('noise', '#fafaf9', '#d6d3d1', 18) }, - { id: 'pattern-dots-gold', label: 'Gilded', background: pattern('dots', '#0c0a09', '#fbbf24', 30) }, - { id: 'pattern-grid-slate', label: 'Graph', background: pattern('grid', '#111827', '#4b5563', 28) }, -] - -export const PATTERN_KINDS: PatternKind[] = [ - 'dots', - 'grid', - 'diagonal', - 'crosshatch', - 'checker', - 'triangles', - 'noise', -] - -const DEFAULT_MESH_LAYOUT: Array<{ x: number; y: number; radius: number }> = [ - { x: 0.2, y: 0.2, radius: 0.7 }, - { x: 0.85, y: 0.25, radius: 0.6 }, - { x: 0.25, y: 0.85, radius: 0.65 }, - { x: 0.8, y: 0.8, radius: 0.7 }, -] - -function resolveMeshBlobs(background: BackgroundConfig, width: number, height: number): MeshBlob[] { - if (background.meshBlobs?.length) return background.meshBlobs - const colors = background.meshColors ?? [BRAND_PRIMARY, '#ec4899', '#22d3ee', '#1c1917'] - const diag = Math.max(width, height) - return colors.slice(0, DEFAULT_MESH_LAYOUT.length).map((color, index) => { - const layout = DEFAULT_MESH_LAYOUT[index] ?? DEFAULT_MESH_LAYOUT[0] - return { - color, - x: layout.x * width, - y: layout.y * height, - radius: layout.radius * diag, - } - }) -} - -function drawPattern( - ctx: CanvasRenderingContext2D, - kind: PatternKind, - fg: string, - scale: number, - width: number, - height: number, -) { - ctx.fillStyle = fg - ctx.strokeStyle = fg - ctx.lineWidth = Math.max(1, scale / 16) - const s = Math.max(8, scale) - - if (kind === 'dots') { - const r = s / 8 - for (let y = s / 2; y < height; y += s) { - for (let x = s / 2; x < width; x += s) { - ctx.beginPath() - ctx.arc(x, y, r, 0, Math.PI * 2) - ctx.fill() - } - } - return - } - - if (kind === 'grid') { - for (let x = 0; x <= width; x += s) { - ctx.beginPath() - ctx.moveTo(x, 0) - ctx.lineTo(x, height) - ctx.stroke() - } - for (let y = 0; y <= height; y += s) { - ctx.beginPath() - ctx.moveTo(0, y) - ctx.lineTo(width, y) - ctx.stroke() - } - return - } - - if (kind === 'diagonal' || kind === 'crosshatch') { - for (let d = -height; d < width; d += s) { - ctx.beginPath() - ctx.moveTo(d, 0) - ctx.lineTo(d + height, height) - ctx.stroke() - } - if (kind === 'crosshatch') { - for (let d = 0; d < width + height; d += s) { - ctx.beginPath() - ctx.moveTo(d, 0) - ctx.lineTo(d - height, height) - ctx.stroke() - } - } - return - } - - if (kind === 'checker') { - for (let y = 0; y < height; y += s) { - for (let x = 0; x < width; x += s) { - if (((x / s) | 0) % 2 === ((y / s) | 0) % 2) { - ctx.fillRect(x, y, s, s) - } - } - } - return - } - - if (kind === 'triangles') { - for (let y = 0; y < height; y += s) { - for (let x = 0; x < width; x += s) { - ctx.globalAlpha = ((x / s + y / s) | 0) % 2 === 0 ? 0.5 : 0.18 - ctx.beginPath() - ctx.moveTo(x, y) - ctx.lineTo(x + s, y) - ctx.lineTo(x, y + s) - ctx.closePath() - ctx.fill() - } - } - ctx.globalAlpha = 1 - return - } - - if (kind === 'noise') { - const count = Math.floor((width * height) / (s * s)) * 6 - ctx.globalAlpha = 0.25 - for (let i = 0; i < count; i += 1) { - const x = Math.random() * width - const y = Math.random() * height - ctx.fillRect(x, y, 1.5, 1.5) - } - ctx.globalAlpha = 1 - } -} - -function drawImageFitted( - ctx: CanvasRenderingContext2D, - image: CanvasImageSource & { width: number; height: number }, - width: number, - height: number, - fit: BackgroundConfig['imageFit'], -) { - const iw = image.width - const ih = image.height - if (!iw || !ih) return - if (fit === 'fill') { - ctx.drawImage(image, 0, 0, width, height) - return - } - const scale = fit === 'contain' ? Math.min(width / iw, height / ih) : Math.max(width / iw, height / ih) - const sw = iw * scale - const sh = ih * scale - ctx.drawImage(image, (width - sw) / 2, (height - sh) / 2, sw, sh) -} - -export function buildBackgroundCanvas( - background: BackgroundConfig, - width: number, - height: number, - image?: (CanvasImageSource & { width: number; height: number }) | null, - pixelRatio = 1, -): HTMLCanvasElement { - const canvas = document.createElement('canvas') - canvas.width = Math.max(1, Math.round(width * pixelRatio)) - canvas.height = Math.max(1, Math.round(height * pixelRatio)) - const ctx = canvas.getContext('2d') - if (!ctx) return canvas - ctx.scale(pixelRatio, pixelRatio) - - // Base fill - ctx.fillStyle = background.color ?? '#ffffff' - ctx.fillRect(0, 0, width, height) - - if (background.type === 'solid') { - return canvas - } - - if ( - (background.type === 'linear-gradient' || background.type === 'radial-gradient') && - background.gradient - ) { - fillRectWithCanvasGradient( - ctx, - { - type: background.type === 'radial-gradient' ? 'radial' : 'linear', - angle: background.gradient.angle, - stops: background.gradient.stops, - }, - width, - height, - ) - return canvas - } - - if (background.type === 'mesh') { - const blobs = resolveMeshBlobs(background, width, height) - for (const blob of blobs) { - const radial = ctx.createRadialGradient(blob.x, blob.y, 0, blob.x, blob.y, blob.radius) - radial.addColorStop(0, blob.color) - radial.addColorStop(1, 'rgba(0,0,0,0)') - ctx.fillStyle = radial - ctx.fillRect(0, 0, width, height) - } - return canvas - } - - if (background.type === 'pattern') { - drawPattern( - ctx, - background.patternKind ?? 'dots', - background.patternColor ?? BRAND_PRIMARY, - background.patternScale ?? 32, - width, - height, - ) - return canvas - } - - if (background.type === 'image' && image) { - drawImageFitted(ctx, image, width, height, background.imageFit ?? 'cover') - if (background.overlayColor) { - ctx.fillStyle = background.overlayColor - ctx.fillRect(0, 0, width, height) - } - return canvas - } - - return canvas -} - -export const ALL_BACKGROUND_PRESETS: { title: string; presets: BackgroundPreset[] }[] = [ - { title: 'Gradients', presets: GRADIENT_PRESETS }, - { title: 'Mesh', presets: MESH_PRESETS }, - { title: 'Glass', presets: GLASS_PRESETS }, - { title: 'Patterns', presets: PATTERN_PRESETS }, -] +export type { BackgroundPreset } from '@/lib/canvas/background-presets' +export { + ALL_BACKGROUND_PRESETS, + GLASS_PRESETS, + GRADIENT_PRESETS, + MESH_PRESETS, + PATTERN_KINDS, + PATTERN_PRESETS, +} from '@/lib/canvas/background-presets' +export { buildBackgroundCanvas } from '@/lib/canvas/background-render' diff --git a/src/lib/canvas/device-frame.ts b/src/lib/canvas/device-frame.ts index d2ebfd2..831d26e 100644 --- a/src/lib/canvas/device-frame.ts +++ b/src/lib/canvas/device-frame.ts @@ -1,3 +1,4 @@ +import { drawImageWithObjectFit } from '@/lib/canvas/image-fit' import type { DeviceColorVariant, DeviceDefinition, ScreenshotFit } from '@/lib/types' export type ScreenshotSource = @@ -25,24 +26,14 @@ function drawImageFitted( dh: number, fit: ScreenshotFit, ) { - const iw = image.width - const ih = image.height - if (!iw || !ih) return - - const scale = - fit === 'cover' - ? Math.max(dw / iw, dh / ih) - : Math.min(dw / iw, dh / ih) - const sw = iw * scale - const sh = ih * scale - const ox = dx + (dw - sw) / 2 - const oy = dy + (dh - sh) / 2 - - if (fit === 'contain') { - ctx.fillStyle = '#000000' - ctx.fillRect(dx, dy, dw, dh) - } - ctx.drawImage(image, ox, oy, sw, sh) + drawImageWithObjectFit( + ctx, + image, + { x: 0, y: 0, width: image.width, height: image.height }, + { x: dx, y: dy, width: dw, height: dh }, + fit, + fit === 'contain' ? { letterboxFill: '#000000' } : undefined, + ) } function drawNotch( diff --git a/src/lib/canvas/image-fit.test.ts b/src/lib/canvas/image-fit.test.ts new file mode 100644 index 0000000..cf3671f --- /dev/null +++ b/src/lib/canvas/image-fit.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import { drawImageWithObjectFit } from '@/lib/canvas/image-fit' + +function createMockContext() { + return { + drawImage: vi.fn(), + fillRect: vi.fn(), + fillStyle: '', + } as unknown as CanvasRenderingContext2D +} + +describe('drawImageWithObjectFit', () => { + const image = {} as CanvasImageSource + const source = { x: 0, y: 0, width: 200, height: 100 } + + it('stretches to fill the destination for fill mode', () => { + const context = createMockContext() + drawImageWithObjectFit( + context, + image, + source, + { x: 10, y: 20, width: 300, height: 150 }, + 'fill', + ) + expect(context.drawImage).toHaveBeenCalledWith(image, 0, 0, 200, 100, 10, 20, 300, 150) + }) + + it('letterboxes contain mode when requested', () => { + const context = createMockContext() + drawImageWithObjectFit( + context, + image, + source, + { x: 0, y: 0, width: 200, height: 200 }, + 'contain', + { letterboxFill: '#000000' }, + ) + expect(context.fillRect).toHaveBeenCalledWith(0, 0, 200, 200) + expect(context.drawImage).toHaveBeenCalled() + }) + + it('covers destination preserving aspect ratio', () => { + const context = createMockContext() + drawImageWithObjectFit( + context, + image, + source, + { x: 0, y: 0, width: 200, height: 200 }, + 'cover', + ) + const call = vi.mocked(context.drawImage).mock.calls[0] + const drawWidth = call[7] as number + const drawHeight = call[8] as number + expect(drawWidth).toBeGreaterThanOrEqual(200) + expect(drawHeight).toBeGreaterThanOrEqual(200) + }) + + it('no-ops when source dimensions are zero', () => { + const context = createMockContext() + drawImageWithObjectFit( + context, + image, + { x: 0, y: 0, width: 0, height: 100 }, + { x: 0, y: 0, width: 100, height: 100 }, + 'cover', + ) + expect(context.drawImage).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/canvas/image-fit.ts b/src/lib/canvas/image-fit.ts new file mode 100644 index 0000000..437a0aa --- /dev/null +++ b/src/lib/canvas/image-fit.ts @@ -0,0 +1,61 @@ +export type ImageObjectFit = 'cover' | 'contain' | 'fill' + +export interface ImageFitRect { + x: number + y: number + width: number + height: number +} + +export function drawImageWithObjectFit( + context: CanvasRenderingContext2D, + image: CanvasImageSource, + source: ImageFitRect, + dest: ImageFitRect, + fit: ImageObjectFit, + options?: { letterboxFill?: string }, +) { + const { x: sourceX, y: sourceY, width: sourceWidth, height: sourceHeight } = source + if (sourceWidth <= 0 || sourceHeight <= 0) return + + if (fit === 'fill') { + context.drawImage( + image, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + dest.x, + dest.y, + dest.width, + dest.height, + ) + return + } + + const scale = + fit === 'contain' + ? Math.min(dest.width / sourceWidth, dest.height / sourceHeight) + : Math.max(dest.width / sourceWidth, dest.height / sourceHeight) + const drawWidth = sourceWidth * scale + const drawHeight = sourceHeight * scale + const drawX = dest.x + (dest.width - drawWidth) / 2 + const drawY = dest.y + (dest.height - drawHeight) / 2 + + if (fit === 'contain' && options?.letterboxFill) { + context.fillStyle = options.letterboxFill + context.fillRect(dest.x, dest.y, dest.width, dest.height) + } + + context.drawImage( + image, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + drawX, + drawY, + drawWidth, + drawHeight, + ) +} diff --git a/src/lib/canvas/perf/content-signature.ts b/src/lib/canvas/perf/content-signature.ts index 050f722..2610a35 100644 --- a/src/lib/canvas/perf/content-signature.ts +++ b/src/lib/canvas/perf/content-signature.ts @@ -1,4 +1,5 @@ import type { Element, Screen } from '@/lib/types' +import { sortElementsByZIndex } from '@/lib/factories' function elementSignature( element: Element, @@ -51,8 +52,7 @@ export function screenContentSignature( screen: Screen, assetResolver: (assetId?: string) => string | undefined, ): string { - const elements = [...screen.elements] - .sort((a, b) => a.zIndex - b.zIndex) + const elements = sortElementsByZIndex(screen.elements) .map((element) => elementSignature(element, assetResolver)) .join(';;') return [ diff --git a/src/lib/canvas/selection-style.ts b/src/lib/canvas/selection-style.ts index 0a1a900..7c13c35 100644 --- a/src/lib/canvas/selection-style.ts +++ b/src/lib/canvas/selection-style.ts @@ -4,6 +4,19 @@ export const SELECTION_BLUE_SOFT = 'rgba(24, 160, 251, 0.45)' export const SELECTION_FILL = 'rgba(24, 160, 251, 0.08)' export const SELECTION_HANDLE_FILL = '#FFFFFF' +/** Layers panel / overview list selection (Tailwind — keep hex in sync with SELECTION_BLUE). */ +export const LAYER_LIST_ITEM_SELECTED = + 'bg-[#18A0FB]/12 text-foreground ring-1 ring-inset ring-[#18A0FB]/35' +export const LAYER_LIST_ITEM_SELECTED_INDICATOR = + 'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-[#18A0FB]' +export const LAYER_LIST_GROUP_SELECTED = + 'bg-[#18A0FB]/10 text-foreground ring-1 ring-inset ring-[#18A0FB]/25' +export const LAYER_LIST_GROUP_SELECTED_INDICATOR = + 'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-[#18A0FB]/70' +export const SCREEN_OVERVIEW_ACTIVE = + 'border-[#18A0FB] ring-2 ring-[#18A0FB]/30' +export const SCREEN_OVERVIEW_HOVER = 'hover:border-[#18A0FB]/50' + /** Target on-screen size (px) for transformer UI when using inverse-scale wrapper. */ export const TRANSFORMER_ANCHOR_SIZE = 5 export const TRANSFORMER_BORDER_WIDTH = 1 diff --git a/src/lib/export/renderer.test.ts b/src/lib/export/renderer.test.ts new file mode 100644 index 0000000..d0b3ba4 --- /dev/null +++ b/src/lib/export/renderer.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest' +import { createShapeElement, createTextElement } from '@/lib/factories' +import { renderScreenToDataUrl } from '@/lib/export/renderer' +import { minimalBackground, minimalScreen } from '@/test/fixtures/minimal-screen' + +const noopResolver = () => undefined + +describe('renderScreenToDataUrl', () => { + it('returns a PNG data URL for a solid background screen', async () => { + const screen = minimalScreen({ + width: 200, + height: 100, + background: minimalBackground(), + elements: [], + }) + + const dataUrl = await renderScreenToDataUrl({ + screen, + assetResolver: noopResolver, + format: 'png', + }) + + expect(dataUrl).toMatch(/^data:image\/png;base64,/) + }) + + it('renders a text element', async () => { + const screen = minimalScreen({ + width: 300, + height: 200, + background: { type: 'solid', color: '#ffffff' }, + elements: [createTextElement({ text: 'Export test', zIndex: 0 })], + }) + + const dataUrl = await renderScreenToDataUrl({ screen, assetResolver: noopResolver }) + expect(dataUrl).toMatch(/^data:image\/png;base64,/) + }) + + it('renders shape elements by kind', async () => { + for (const kind of ['rectangle', 'circle', 'triangle', 'line'] as const) { + const screen = minimalScreen({ + width: 300, + height: 300, + background: { type: 'solid', color: '#ffffff' }, + elements: [createShapeElement(kind)], + }) + + const dataUrl = await renderScreenToDataUrl({ screen, assetResolver: noopResolver }) + expect(dataUrl).toMatch(/^data:image\/png;base64,/) + } + }) + + it('renders a patterned background deterministically', async () => { + const screen = minimalScreen({ + width: 120, + height: 120, + background: { + type: 'pattern', + patternKind: 'dots', + color: '#111111', + patternColor: '#00ff00', + patternScale: 24, + }, + elements: [], + }) + + const first = await renderScreenToDataUrl({ screen, assetResolver: noopResolver }) + const second = await renderScreenToDataUrl({ screen, assetResolver: noopResolver }) + expect(first).toBe(second) + }) + + it('skips invisible elements', async () => { + const visible = createTextElement({ text: 'Visible', visible: true, zIndex: 0 }) + const hidden = createTextElement({ text: 'Hidden', visible: false, zIndex: 1 }) + const screen = minimalScreen({ + background: { type: 'solid', color: '#ffffff' }, + elements: [visible, hidden], + }) + + const dataUrl = await renderScreenToDataUrl({ screen, assetResolver: noopResolver }) + expect(dataUrl).toMatch(/^data:image\/png;base64,/) + }) + + it('supports transparentBackground without error', async () => { + const screen = minimalScreen({ + width: 100, + height: 100, + background: { type: 'solid', color: '#ff0000' }, + elements: [], + }) + + const dataUrl = await renderScreenToDataUrl({ + screen, + assetResolver: noopResolver, + transparentBackground: true, + }) + + expect(dataUrl).toMatch(/^data:image\/png;base64,/) + }) + + it('scales output dimensions via pixelRatio', async () => { + const screen = minimalScreen({ width: 100, height: 50, elements: [] }) + const canvasSpy = vi.spyOn(document, 'createElement') + + await renderScreenToDataUrl({ screen, assetResolver: noopResolver, pixelRatio: 2 }) + + const canvas = canvasSpy.mock.results.find((result) => result.value instanceof HTMLCanvasElement) + ?.value as HTMLCanvasElement | undefined + expect(canvas?.width).toBe(200) + expect(canvas?.height).toBe(100) + + canvasSpy.mockRestore() + }) + + it('returns JPEG data URL when format is jpeg', async () => { + const screen = minimalScreen({ elements: [] }) + const dataUrl = await renderScreenToDataUrl({ + screen, + assetResolver: noopResolver, + format: 'jpeg', + }) + expect(dataUrl).toMatch(/^data:image\/jpeg;base64,/) + }) +}) diff --git a/src/lib/export/renderer.ts b/src/lib/export/renderer.ts index e2a7d5d..1fed49b 100644 --- a/src/lib/export/renderer.ts +++ b/src/lib/export/renderer.ts @@ -10,6 +10,8 @@ import type { import { createCanvasGradient as buildCanvasGradient } from '@/lib/canvas/create-canvas-gradient' import { renderDeviceComposite } from '@/lib/canvas/device-render' import { buildBackgroundCanvas } from '@/lib/canvas/backgrounds' +import { drawImageWithObjectFit } from '@/lib/canvas/image-fit' +import { sortElementsByZIndex } from '@/lib/factories' import { BRAND_PRIMARY } from '@/lib/constants' export interface RenderScreenOptions { @@ -159,37 +161,17 @@ function drawImageWithFit( const naturalHeight = image.naturalHeight || image.height if (!naturalWidth || !naturalHeight) return - // Crop is stored normalized (0..1); defaults are 0/0/1/1 (no crop). const sourceX = (element.cropX ?? 0) * naturalWidth const sourceY = (element.cropY ?? 0) * naturalHeight const sourceWidth = (element.cropWidth ?? 1) * naturalWidth const sourceHeight = (element.cropHeight ?? 1) * naturalHeight - if (sourceWidth <= 0 || sourceHeight <= 0) return - const { width, height } = element - const fit = element.objectFit ?? 'cover' - - if (fit === 'fill') { - context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, width, height) - return - } - - const scale = - fit === 'contain' - ? Math.min(width / sourceWidth, height / sourceHeight) - : Math.max(width / sourceWidth, height / sourceHeight) - const drawWidth = sourceWidth * scale - const drawHeight = sourceHeight * scale - context.drawImage( + drawImageWithObjectFit( + context, image, - sourceX, - sourceY, - sourceWidth, - sourceHeight, - (width - drawWidth) / 2, - (height - drawHeight) / 2, - drawWidth, - drawHeight, + { x: sourceX, y: sourceY, width: sourceWidth, height: sourceHeight }, + { x: 0, y: 0, width: element.width, height: element.height }, + element.objectFit ?? 'cover', ) } @@ -443,7 +425,7 @@ export async function renderScreenToDataUrl( drawBackground(context, screen.background, screen.width, screen.height, backgroundImage) } - const sorted = [...screen.elements].sort((a, b) => a.zIndex - b.zIndex) + const sorted = sortElementsByZIndex(screen.elements) for (const element of sorted) { await drawElement(context, element, assetResolver) } diff --git a/src/lib/factories.test.ts b/src/lib/factories.test.ts index dd73f82..5c0b0f5 100644 --- a/src/lib/factories.test.ts +++ b/src/lib/factories.test.ts @@ -5,6 +5,7 @@ import { createScreen, createScreenFromPrevious, createTextElement, + sortElementsByZIndex, } from '@/lib/factories' describe('createScreenFromPrevious', () => { @@ -53,3 +54,31 @@ describe('createDefaultScreen', () => { expect(screen.elements[0]?.type).toBe('device') }) }) + +describe('sortElementsByZIndex', () => { + it('sorts ascending by default', () => { + const elements = [ + createTextElement({ id: 'top', zIndex: 2 }), + createTextElement({ id: 'bottom', zIndex: 0 }), + createTextElement({ id: 'mid', zIndex: 1 }), + ] + + expect(sortElementsByZIndex(elements).map((element) => element.id)).toEqual([ + 'bottom', + 'mid', + 'top', + ]) + }) + + it('sorts descending when requested', () => { + const elements = [ + createTextElement({ id: 'top', zIndex: 2 }), + createTextElement({ id: 'bottom', zIndex: 0 }), + ] + + expect(sortElementsByZIndex(elements, 'desc').map((element) => element.id)).toEqual([ + 'top', + 'bottom', + ]) + }) +}) diff --git a/src/lib/factories.ts b/src/lib/factories.ts index 1647be6..2703552 100644 --- a/src/lib/factories.ts +++ b/src/lib/factories.ts @@ -195,8 +195,14 @@ export function duplicateElement(element: Element): Element { } } -export function sortElementsByZIndex(elements: Element[]): Element[] { - return [...elements].sort((a, b) => a.zIndex - b.zIndex) +export type ZIndexSortDirection = 'asc' | 'desc' + +export function sortElementsByZIndex( + elements: Element[], + direction: ZIndexSortDirection = 'asc', +): Element[] { + const factor = direction === 'asc' ? 1 : -1 + return [...elements].sort((a, b) => (a.zIndex - b.zIndex) * factor) } export function reindexElements(elements: Element[]): Element[] { diff --git a/src/lib/layers/layer-panel-logic.test.ts b/src/lib/layers/layer-panel-logic.test.ts new file mode 100644 index 0000000..c9305cb --- /dev/null +++ b/src/lib/layers/layer-panel-logic.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { createTextElement, createScreen } from '@/lib/factories' +import { + buildLayerContextTarget, + resolveContextMenuSelection, + sortLayersByZIndex, + toggleGroupSelection, +} from '@/lib/layers/layer-panel-logic' + +describe('sortLayersByZIndex', () => { + it('orders elements from highest z-index to lowest', () => { + const screen = createScreen() + screen.elements = [ + createTextElement({ id: 'back', zIndex: 0 }), + createTextElement({ id: 'front', zIndex: 2 }), + createTextElement({ id: 'mid', zIndex: 1 }), + ] + + expect(sortLayersByZIndex(screen).map((element) => element.id)).toEqual([ + 'front', + 'mid', + 'back', + ]) + }) +}) + +describe('resolveContextMenuSelection', () => { + it('keeps the current multi-selection when the target is already selected', () => { + const selected = ['a', 'b'] + expect(resolveContextMenuSelection(['a'], selected)).toEqual(selected) + }) + + it('replaces selection when right-clicking an unselected layer', () => { + expect(resolveContextMenuSelection(['c'], ['a', 'b'])).toEqual(['c']) + }) +}) + +describe('buildLayerContextTarget', () => { + it('reports visibility and lock state for the targeted elements', () => { + const screen = createScreen() + screen.elements = [ + createTextElement({ id: 'a', visible: true, locked: false }), + createTextElement({ id: 'b', visible: false, locked: true }), + ] + + expect(buildLayerContextTarget(screen, ['a', 'b'])).toEqual({ + elementIds: ['a', 'b'], + allVisible: false, + allLocked: false, + canDelete: true, + }) + }) +}) + +describe('toggleGroupSelection', () => { + it('selects the whole group without modifiers', () => { + expect(toggleGroupSelection(['a', 'b'], ['x'], false)).toEqual(['a', 'b']) + }) + + it('adds group members with additive selection', () => { + expect(toggleGroupSelection(['a', 'b'], ['x'], true)).toEqual(['x', 'a', 'b']) + }) + + it('removes group members when all are already selected', () => { + expect(toggleGroupSelection(['a', 'b'], ['a', 'b', 'c'], true)).toEqual(['c']) + }) +}) diff --git a/src/lib/layers/layer-panel-logic.ts b/src/lib/layers/layer-panel-logic.ts new file mode 100644 index 0000000..8eea516 --- /dev/null +++ b/src/lib/layers/layer-panel-logic.ts @@ -0,0 +1,48 @@ +import type { Element, Screen } from '@/lib/types' +import { sortElementsByZIndex } from '@/lib/factories' + +export function sortLayersByZIndex(screen: Screen): Element[] { + return sortElementsByZIndex(screen.elements, 'desc') +} + +export function resolveContextMenuSelection( + elementIds: string[], + selectedElementIds: string[], +): string[] { + const uniqueIds = Array.from(new Set(elementIds)) + const hitsSelection = uniqueIds.every((id) => selectedElementIds.includes(id)) + if (hitsSelection && selectedElementIds.length > 0) return selectedElementIds + return uniqueIds +} + +export function buildLayerContextTarget( + screen: Screen, + elementIds: string[], +): { + elementIds: string[] + allVisible: boolean + allLocked: boolean + canDelete: boolean +} { + const elements = screen.elements.filter((element) => elementIds.includes(element.id)) + return { + elementIds, + allVisible: elements.length > 0 && elements.every((element) => element.visible), + allLocked: elements.length > 0 && elements.every((element) => element.locked), + canDelete: elementIds.length > 0, + } +} + +export function toggleGroupSelection( + memberIds: string[], + selectedElementIds: string[], + additive: boolean, +): string[] { + if (!additive) return memberIds + + const allMembersSelected = memberIds.every((id) => selectedElementIds.includes(id)) + if (allMembersSelected) { + return selectedElementIds.filter((id) => !memberIds.includes(id)) + } + return Array.from(new Set([...selectedElementIds, ...memberIds])) +} diff --git a/src/lib/selection/is-additive-selection.test.ts b/src/lib/selection/is-additive-selection.test.ts new file mode 100644 index 0000000..d723c90 --- /dev/null +++ b/src/lib/selection/is-additive-selection.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { + isAdditiveKonvaPointerEvent, + isAdditiveSelection, +} from '@/lib/selection/is-additive-selection' + +describe('isAdditiveSelection', () => { + it('returns true when shift is held', () => { + expect(isAdditiveSelection({ shiftKey: true, metaKey: false, ctrlKey: false })).toBe(true) + }) + + it('returns true when meta or ctrl is held', () => { + expect(isAdditiveSelection({ shiftKey: false, metaKey: true, ctrlKey: false })).toBe(true) + expect(isAdditiveSelection({ shiftKey: false, metaKey: false, ctrlKey: true })).toBe(true) + }) + + it('returns false with no modifier keys', () => { + expect(isAdditiveSelection({ shiftKey: false, metaKey: false, ctrlKey: false })).toBe(false) + }) +}) + +describe('isAdditiveKonvaPointerEvent', () => { + it('reads modifier keys from Konva pointer events', () => { + expect( + isAdditiveKonvaPointerEvent({ + evt: { shiftKey: false, metaKey: true, ctrlKey: false }, + }), + ).toBe(true) + }) +}) diff --git a/src/lib/selection/is-additive-selection.ts b/src/lib/selection/is-additive-selection.ts new file mode 100644 index 0000000..6a2108f --- /dev/null +++ b/src/lib/selection/is-additive-selection.ts @@ -0,0 +1,13 @@ +export function isAdditiveSelection(event: { + shiftKey: boolean + metaKey: boolean + ctrlKey: boolean +}): boolean { + return event.shiftKey || event.metaKey || event.ctrlKey +} + +export function isAdditiveKonvaPointerEvent(event: { + evt: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean } +}): boolean { + return isAdditiveSelection(event.evt) +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 937b48b..54e033d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -61,3 +61,9 @@ export function applyFileNamePattern( ): string { return pattern.replace(/\{(\w+)\}/g, (_, key: string) => values[key] ?? key) } + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} diff --git a/src/routes/editor/$projectId.tsx b/src/routes/editor/$projectId.tsx index 6822da6..d56eed2 100644 --- a/src/routes/editor/$projectId.tsx +++ b/src/routes/editor/$projectId.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { createFileRoute } from '@tanstack/react-router' +import { useAssetResolver } from '@/hooks/useAssetResolver' import { Layers, SlidersHorizontal } from 'lucide-react' import { getProject, getProjectAssets, saveProject } from '@/lib/db' import { createAssetObjectUrl } from '@/lib/assets/image-pipeline' @@ -25,7 +26,6 @@ function EditorPage() { const project = useProjectStore((state) => state.project) const loadProject = useProjectStore((state) => state.loadProject) const registerAssetUrl = useProjectStore((state) => state.registerAssetUrl) - const assetUrls = useProjectStore((state) => state.assetUrls) const setActiveScreenId = useEditorStore((state) => state.setActiveScreenId) const viewMode = useEditorStore((state) => state.viewMode) const focusScreen = useEditorStore((state) => state.focusScreen) @@ -40,10 +40,7 @@ function EditorPage() { const [propertiesOpen, setPropertiesOpen] = useState(isDesktop) const [leftOpen, setLeftOpen] = useState(isDesktop) - const assetResolver = useCallback( - (assetId?: string) => (assetId ? assetUrls[assetId] : undefined), - [assetUrls], - ) + const assetResolver = useAssetResolver() useKeyboardShortcuts() useAutoSave() diff --git a/src/stores/project-store.ts b/src/stores/project-store.ts index 5e09573..d5ea1ef 100644 --- a/src/stores/project-store.ts +++ b/src/stores/project-store.ts @@ -27,6 +27,10 @@ import { ungroupElementsOnScreen, updateElementOnScreen, } from '@/stores/project/element-mutations' +import { + mutateOnActiveScreen, + resolveActiveScreenId, +} from '@/stores/project/with-active-screen' import { createDefaultDeviceElement } from '@/stores/project/create-device-element' import { addScreenToProject, @@ -158,9 +162,10 @@ export const useProjectStore = create()( getActiveScreen: () => { const project = get().project - if (!project || project.screens.length === 0) return null - const activeId = useEditorStore.getState().activeScreenId - return project.screens.find((screen) => screen.id === activeId) ?? project.screens[0] + if (!project) return null + const activeId = resolveActiveScreenId(project) + if (!activeId) return null + return findScreenById(project, activeId) ?? null }, setProjectName: (name) => { @@ -274,104 +279,68 @@ export const useProjectStore = create()( }, setActiveScreenBackground: (background) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (screen) setScreenBackground(screen, background) - }) + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + setScreenBackground(screen, background), + ) }, addElement: (element) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - addElementToScreen(screen, element) - }) + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + addElementToScreen(screen, element), + ) }, updateElement: (id, patch) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen) return const recordHistory = resolveElementUpdateHistory(id, patch) - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - updateElementOnScreen(screen, id, patch) - }, recordHistory) + mutateOnActiveScreen( + get().project, + get().updateProject, + (screen) => updateElementOnScreen(screen, id, patch), + recordHistory, + ) }, deleteElements: (ids) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - deleteElementsFromScreen(screen, ids) - }) + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + deleteElementsFromScreen(screen, ids), + ) }, duplicateElements: (ids) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - duplicateElementsOnScreen(screen, ids) - }) + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + duplicateElementsOnScreen(screen, ids), + ) }, reorderElements: (elementIds) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - reorderElementsOnScreen(screen, elementIds) - }) + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + reorderElementsOnScreen(screen, elementIds), + ) }, bringForward: (id) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - bringForwardElement(screen, id) - }) + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + bringForwardElement(screen, id), + ) }, sendBackward: (id) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - sendBackwardElement(screen, id) - }) + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + sendBackwardElement(screen, id), + ) }, groupElements: (ids) => { if (ids.length < 2) return - const activeScreen = get().getActiveScreen() - if (!activeScreen) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - groupElementsOnScreen(screen, ids) - }) + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + groupElementsOnScreen(screen, ids), + ) }, ungroupElements: (groupId) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - ungroupElementsOnScreen(screen, groupId) - }) + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + ungroupElementsOnScreen(screen, groupId), + ) }, applyTemplateToScreen: (screenId, elements, background, mode = 'replace') => { @@ -403,33 +372,24 @@ export const useProjectStore = create()( }, alignElements: (ids, alignment) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen || ids.length === 0) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - alignElementsOnScreen(screen, ids, alignment) - }) + if (ids.length === 0) return + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + alignElementsOnScreen(screen, ids, alignment), + ) }, alignToArtboard: (ids, axis) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen || ids.length === 0) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - alignElementsToArtboard(screen, ids, axis) - }) + if (ids.length === 0) return + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + alignElementsToArtboard(screen, ids, axis), + ) }, distributeElements: (ids, axis) => { - const activeScreen = get().getActiveScreen() - if (!activeScreen || ids.length < 3) return - get().updateProject((project) => { - const screen = findScreenById(project, activeScreen.id) - if (!screen) return - distributeElementsOnScreen(screen, ids, axis) - }) + if (ids.length < 3) return + mutateOnActiveScreen(get().project, get().updateProject, (screen) => + distributeElementsOnScreen(screen, ids, axis), + ) }, })), ) diff --git a/src/stores/project/with-active-screen.ts b/src/stores/project/with-active-screen.ts new file mode 100644 index 0000000..11bac7d --- /dev/null +++ b/src/stores/project/with-active-screen.ts @@ -0,0 +1,33 @@ +import { findScreenById } from '@/stores/project/element-mutations' +import { useEditorStore } from '@/stores/editor-store' +import type { Project, Screen } from '@/lib/types' + +export function resolveActiveScreenId(project: Project | null): string | null { + if (!project || project.screens.length === 0) return null + const activeId = useEditorStore.getState().activeScreenId + return project.screens.find((screen) => screen.id === activeId)?.id ?? project.screens[0].id +} + +export function withActiveScreen( + project: Project, + activeScreenId: string, + mutateScreen: (screen: Screen) => void, +) { + const screen = findScreenById(project, activeScreenId) + if (!screen) return + mutateScreen(screen) +} + +export function mutateOnActiveScreen( + project: Project | null, + updateProject: (updater: (draft: Project) => void, recordHistory?: boolean) => void, + mutateScreen: (screen: Screen) => void, + recordHistory = true, +): boolean { + const activeId = resolveActiveScreenId(project) + if (!activeId) return false + updateProject((draft) => { + withActiveScreen(draft, activeId, mutateScreen) + }, recordHistory) + return true +} diff --git a/src/test/setup.ts b/src/test/setup.ts index a9d0dd3..71142c4 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,212 @@ import '@testing-library/jest-dom/vitest' + +type MockContext = CanvasRenderingContext2D & { + __canvas: HTMLCanvasElement +} + +function createMockContext2d(canvas: HTMLCanvasElement): MockContext { + const state = { + fillStyle: '#000000', + strokeStyle: '#000000', + lineWidth: 1, + globalAlpha: 1, + font: '10px sans-serif', + textAlign: 'start' as CanvasTextAlign, + textBaseline: 'alphabetic' as CanvasTextBaseline, + shadowColor: 'rgba(0, 0, 0, 0)', + shadowBlur: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + lineJoin: 'miter' as CanvasLineJoin, + miterLimit: 10, + filter: 'none', + letterSpacing: '0px', + } + + const context = { + __canvas: canvas, + canvas, + save() {}, + restore() {}, + scale() {}, + translate() {}, + rotate() {}, + clip() {}, + beginPath() {}, + closePath() {}, + moveTo() {}, + lineTo() {}, + stroke() {}, + fill() {}, + arc() {}, + ellipse() {}, + rect() {}, + roundRect() {}, + strokeRect() {}, + fillRect() {}, + drawImage() {}, + measureText(text: string) { + return { width: text.length * 8 } + }, + fillText() {}, + strokeText() {}, + setLineDash() {}, + createLinearGradient() { + const stops: Array<{ offset: number; color: string }> = [] + return { + addColorStop(offset: number, color: string) { + stops.push({ offset, color }) + }, + stops, + } + }, + createRadialGradient() { + const stops: Array<{ offset: number; color: string }> = [] + return { + addColorStop(offset: number, color: string) { + stops.push({ offset, color }) + }, + stops, + } + }, + get fillStyle() { + return state.fillStyle + }, + set fillStyle(value: string) { + state.fillStyle = value + }, + get strokeStyle() { + return state.strokeStyle + }, + set strokeStyle(value: string) { + state.strokeStyle = value + }, + get lineWidth() { + return state.lineWidth + }, + set lineWidth(value: number) { + state.lineWidth = value + }, + get globalAlpha() { + return state.globalAlpha + }, + set globalAlpha(value: number) { + state.globalAlpha = value + }, + get font() { + return state.font + }, + set font(value: string) { + state.font = value + }, + get textAlign() { + return state.textAlign + }, + set textAlign(value: CanvasTextAlign) { + state.textAlign = value + }, + get textBaseline() { + return state.textBaseline + }, + set textBaseline(value: CanvasTextBaseline) { + state.textBaseline = value + }, + get shadowColor() { + return state.shadowColor + }, + set shadowColor(value: string) { + state.shadowColor = value + }, + get shadowBlur() { + return state.shadowBlur + }, + set shadowBlur(value: number) { + state.shadowBlur = value + }, + get shadowOffsetX() { + return state.shadowOffsetX + }, + set shadowOffsetX(value: number) { + state.shadowOffsetX = value + }, + get shadowOffsetY() { + return state.shadowOffsetY + }, + set shadowOffsetY(value: number) { + state.shadowOffsetY = value + }, + get lineJoin() { + return state.lineJoin + }, + set lineJoin(value: CanvasLineJoin) { + state.lineJoin = value + }, + get miterLimit() { + return state.miterLimit + }, + set miterLimit(value: number) { + state.miterLimit = value + }, + get filter() { + return state.filter + }, + set filter(value: string) { + state.filter = value + }, + get letterSpacing() { + return state.letterSpacing + }, + set letterSpacing(value: string) { + state.letterSpacing = value + }, + } + + return context as unknown as MockContext +} + +const originalCreateElement = document.createElement.bind(document) + +document.createElement = ((tagName: string, options?: ElementCreationOptions) => { + const element = originalCreateElement(tagName, options) + if (tagName.toLowerCase() !== 'canvas') return element + + const canvas = element as HTMLCanvasElement + const context = createMockContext2d(canvas) + + canvas.getContext = ((contextId: string) => { + if (contextId === '2d') return context + return null + }) as typeof canvas.getContext + + canvas.toDataURL = ((type?: string) => { + const mime = type?.includes('jpeg') ? 'image/jpeg' : 'image/png' + const payload = btoa(`${mime}:${canvas.width}x${canvas.height}`) + return `data:${mime};base64,${payload}` + }) as typeof canvas.toDataURL + + return canvas +}) as typeof document.createElement + +class MockImage { + onload: (() => void) | null = null + onerror: (() => void) | null = null + crossOrigin = '' + naturalWidth = 100 + naturalHeight = 100 + width = 100 + height = 100 + private _src = '' + + get src() { + return this._src + } + + set src(value: string) { + this._src = value + queueMicrotask(() => this.onload?.()) + } +} + +if (typeof globalThis.Image === 'undefined') { + globalThis.Image = MockImage as unknown as typeof Image +}