diff --git a/components/canvas/frames/Frame3DOverlay.tsx b/components/canvas/frames/Frame3DOverlay.tsx index 9347c0d..15b3e7a 100644 --- a/components/canvas/frames/Frame3DOverlay.tsx +++ b/components/canvas/frames/Frame3DOverlay.tsx @@ -1,8 +1,10 @@ 'use client'; +import { useEffect, useState } from 'react'; + export interface FrameConfig { enabled: boolean; - type: 'none' | 'solid' | 'glassy' | 'infinite-mirror' | 'window' | 'stack' | 'ruler' | 'eclipse' | 'dotted' | 'focus'; + type: 'none' | 'arc-light' | 'arc-dark' | 'macos-dark' | 'macos-light' | 'windows-dark' | 'windows-light' | 'photograph'; width: number; color: string; theme?: 'light' | 'dark'; @@ -10,6 +12,16 @@ export interface FrameConfig { title?: string; } +const borderImageMap: Record = { + 'arc-light': '/border/arc-light.webp', + 'arc-dark': '/border/arc-dark.webp', + 'macos-dark': '/border/macos-black.webp', + 'macos-light': '/border/macos-black.webp', + 'windows-dark': '/border/macos-black.webp', + 'windows-light': '/border/macos-black.webp', + 'photograph': '/border/photograph.webp', +}; + interface Frame3DOverlayProps { frame: FrameConfig; showFrame: boolean; @@ -24,6 +36,45 @@ interface Frame3DOverlayProps { screenshotRadius: number; } +function ImageFrame3D({ + imageUrl, + width, + height, + cornerRadius +}: { + imageUrl: string; + width: number; + height: number; + cornerRadius: number; +}) { + const [image, setImage] = useState(null); + + useEffect(() => { + const img = new window.Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => setImage(img); + img.onerror = () => setImage(null); + img.src = imageUrl; + }, [imageUrl]); + + if (!image) return null; + + return ( + frame + ); +} + export function Frame3DOverlay({ frame, showFrame, @@ -41,362 +92,23 @@ export function Frame3DOverlay({ return null; } - switch (frame.type) { - case 'solid': - return ( -
- ); - - case 'glassy': - return ( -
- ); - - case 'ruler': - return ( -
-
-
- {Array.from({ length: Math.floor(framedW / 10) - 1 }).map( - (_, i) => ( -
- ) - )} -
-
- {Array.from({ length: Math.floor(framedH / 10) - 1 }).map( - (_, i) => ( -
- ) - )} -
-
-
- ); - - case 'infinite-mirror': - return ( - <> - {Array.from({ length: 4 }).map((_, i) => ( -
- ))} - - ); - - case 'eclipse': - return ( -
- ); - - case 'stack': - return ( - <> -
-
-
- - ); - - case 'window': - return ( - <> -
-
-
-
-
-
-
- {frame.title && ( -
- {frame.title} -
- )} -
- - ); - - case 'dotted': - return ( -
- ); + if (frame.type === 'arc-light' || frame.type === 'arc-dark') { + return null; + } - case 'focus': - return ( - <> -
-
-
-
-
-
-
-
- - ); + const imageUrl = borderImageMap[frame.type]; + if (imageUrl) { + return ( + + ); + } + switch (frame.type) { default: return null; } diff --git a/components/canvas/frames/FrameRenderer.tsx b/components/canvas/frames/FrameRenderer.tsx index 0294069..66c5ded 100644 --- a/components/canvas/frames/FrameRenderer.tsx +++ b/components/canvas/frames/FrameRenderer.tsx @@ -1,11 +1,12 @@ 'use client'; -import { Rect, Group, Circle, Text, Path } from 'react-konva'; +import { Image as KonvaImage } from 'react-konva'; +import { useEffect, useState } from 'react'; import { ShadowProps } from '../utils/shadow-utils'; export interface FrameConfig { enabled: boolean; - type: 'none' | 'solid' | 'glassy' | 'infinite-mirror' | 'window' | 'stack' | 'ruler' | 'eclipse' | 'dotted' | 'focus'; + type: 'none' | 'arc-light' | 'arc-dark' | 'macos-dark' | 'macos-light' | 'windows-dark' | 'windows-light' | 'photograph'; width: number; color: string; theme?: 'light' | 'dark'; @@ -13,6 +14,16 @@ export interface FrameConfig { title?: string; } +const borderImageMap: Record = { + 'arc-light': '/border/arc-light.webp', + 'arc-dark': '/border/arc-dark.webp', + 'macos-dark': '/border/macos-black.webp', + 'macos-light': '/border/macos-black.webp', + 'windows-dark': '/border/macos-black.webp', + 'windows-light': '/border/macos-black.webp', + 'photograph': '/border/photograph.webp', +}; + interface FrameRendererProps { frame: FrameConfig; showFrame: boolean; @@ -29,6 +40,39 @@ interface FrameRendererProps { has3DTransform: boolean; } +function ImageFrame({ + imageUrl, + width, + height, + cornerRadius +}: { + imageUrl: string; + width: number; + height: number; + cornerRadius: number; +}) { + const [image, setImage] = useState(null); + + useEffect(() => { + const img = new window.Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => setImage(img); + img.onerror = () => setImage(null); + img.src = imageUrl; + }, [imageUrl]); + + if (!image) return null; + + return ( + + ); +} + export function FrameRenderer({ frame, showFrame, @@ -48,309 +92,23 @@ export function FrameRenderer({ return null; } - switch (frame.type) { - case 'solid': - return ( - - ); - - case 'glassy': - return ( - - ); - - case 'ruler': - return ( - - - - - - - {Array.from({ - length: Math.floor(framedW / 10) - 1, - }).map((_, i) => ( - - ))} - {Array.from({ - length: Math.floor(framedH / 10) - 1, - }).map((_, i) => ( - - ))} - {Array.from({ - length: Math.floor(framedH / 10) - 1, - }).map((_, i) => ( - - ))} - {Array.from({ - length: Math.floor(framedW / 10) - 1, - }).map((_, i) => ( - - ))} - - - - - ); - - case 'infinite-mirror': - return ( - <> - {Array.from({ length: 4 }).map((_, i) => ( - - ))} - - ); - - case 'eclipse': - return ( - - - - - ); - - case 'stack': - return ( - <> - - - - - ); - - case 'window': - return ( - <> - - - - - - - - ); - - case 'dotted': - return ( - - ); + if (frame.type === 'arc-light' || frame.type === 'arc-dark') { + return null; + } - case 'focus': - return ( - - - - - - - ); + const imageUrl = borderImageMap[frame.type]; + if (imageUrl) { + return ( + + ); + } + switch (frame.type) { default: return null; } diff --git a/components/canvas/layers/MainImageLayer.tsx b/components/canvas/layers/MainImageLayer.tsx index 340ecf4..da0803f 100644 --- a/components/canvas/layers/MainImageLayer.tsx +++ b/components/canvas/layers/MainImageLayer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Layer, Group, Image as KonvaImage, Transformer } from 'react-konva'; +import { Layer, Group, Image as KonvaImage, Rect, Transformer } from 'react-konva'; import Konva from 'konva'; import { useRef, useEffect } from 'react'; import { FrameRenderer } from '../frames/FrameRenderer'; @@ -147,13 +147,7 @@ export function MainImageLayer({ width={imageScaledW} height={imageScaledH} opacity={has3DTransform ? 0 : imageOpacity} - cornerRadius={ - showFrame && frame.type === 'window' - ? [0, 0, screenshot.radius, screenshot.radius] - : showFrame && frame.type === 'ruler' - ? screenshot.radius * 0.8 - : screenshot.radius - } + cornerRadius={frame.type === 'arc-light' || frame.type === 'arc-dark' ? 8 : screenshot.radius} imageSmoothingEnabled={false} draggable={false} onClick={(e) => { @@ -182,6 +176,19 @@ export function MainImageLayer({ }} {...shadowProps} /> + {!has3DTransform && showFrame && (frame.type === 'arc-light' || frame.type === 'arc-dark') && ( + + )}
diff --git a/components/controls/BorderControls.tsx b/components/controls/BorderControls.tsx index 93ad1fe..a092ae3 100644 --- a/components/controls/BorderControls.tsx +++ b/components/controls/BorderControls.tsx @@ -1,456 +1,68 @@ 'use client' import * as React from 'react' - import { useImageStore } from '@/lib/store' -import { Slider } from '@/components/ui/slider' - -import { Input } from '@/components/ui/input' - -import { useState, useEffect } from 'react' - -const isValidHex = (color: string) => /^#[0-9A-F]{6}$/i.test(color) - -function ColorInput({ - - value, - - onChange, - - className = '', - -}: { - - value: string - - onChange: (value: string) => void - - className?: string - -}) { - - const [localValue, setLocalValue] = useState(value) - - useEffect(() => { - - setLocalValue(value) - - }, [value]) - - const handleBlur = () => { - - if (isValidHex(localValue)) { - - onChange(localValue) - - } else { - - setLocalValue(value) - - } - - } - - return ( - - setLocalValue(e.target.value)} - - onBlur={handleBlur} - - className={className} - - /> - - ) - -} - -const frameOptions = [ - - { value: 'none', label: 'None' }, - - { value: 'solid', label: 'Solid' }, - - { value: 'glassy', label: 'Glassy' }, - - { value: 'infinite-mirror', label: 'Mirror' }, - - { value: 'window-light', label: 'Window' }, - - { value: 'window-dark', label: 'Dark Window' }, - - { value: 'stack-light', label: 'Stack' }, - - { value: 'stack-dark', label: 'Dark Stack' }, - - { value: 'ruler', label: 'Ruler' }, - - { value: 'eclipse', label: 'Neo' }, - - { value: 'dotted', label: 'Dotted' }, - - { value: 'focus', label: 'Focus' }, - +const borderOptions = [ + { value: 'none', label: 'None', image: '/border/none.webp' }, + { value: 'arc-light', label: 'Arc Light', image: '/border/arc-light.webp' }, + { value: 'arc-dark', label: 'Arc Dark', image: '/border/arc-dark.webp' }, + { value: 'macos-dark', label: 'macOS Dark', image: '/border/macos-black.webp' }, + { value: 'macos-light', label: 'macOS Light', image: '/border/macos-black.webp' }, + { value: 'windows-dark', label: 'Windows Dark', image: '/border/macos-black.webp' }, + { value: 'windows-light', label: 'Windows Light', image: '/border/macos-black.webp' }, + { value: 'photograph', label: 'Photograph', image: '/border/photograph.webp' }, ] as const -type FrameType = (typeof frameOptions)[number]['value'] - -function FramePreview({ - - type, - - selected, - - onSelect, - - children, - -}: { - - type: FrameType - - selected: boolean - - onSelect: () => void - - children: React.ReactNode - -}) { - - return ( - -
- - - -
{frameOptions.find((f) => f.value === type)?.label}
- -
- - ) - -} - -const framePreviews: Record = { - - none:
, - - solid:
, - - glassy:
, - - 'window-light': ( - -
- -
- -
- -
- -
- -
- -
- - ), - - 'window-dark': ( - -
- -
- -
- -
- -
- -
- -
- - ), - - 'infinite-mirror': ( - -
- -
- -
- -
- -
- - ), - - ruler: ( - -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- - ), - - eclipse:
, - - 'stack-light': ( - -
- -
- -
- -
- - ), - - 'stack-dark': ( - -
- -
- -
- -
- - ), - - dotted:
, - - focus: ( - -
- -
- -
- -
- -
- -
- - ), - -} +type BorderType = (typeof borderOptions)[number]['value'] export function BorderControls() { - const { imageBorder, setImageBorder } = useImageStore() - const handleSelect = (value: FrameType) => { - - if (value.startsWith('window-') || value.startsWith('stack-')) { - - const [type, theme] = value.split('-') - - setImageBorder({ type: type as 'window' | 'stack', theme: theme as 'light' | 'dark', enabled: true }) - - } else { - - setImageBorder({ type: value as Exclude, enabled: true }) - - } - + const handleSelect = (value: BorderType) => { + setImageBorder({ type: value, enabled: value !== 'none' }) } - const isSelected = (value: FrameType) => { - - if (value.startsWith('window-') || value.startsWith('stack-')) { - - const [type, theme] = value.split('-') - - return imageBorder.type === type && imageBorder.theme === theme - - } - + const isSelected = (value: BorderType) => { return imageBorder.type === value - } return (
Frame
- -
- - - -
- - {frameOptions.map(({ value }) => ( - - handleSelect(value)} - - > - - {framePreviews[value]} - - - - ))} - -
- -
- - {['solid', 'dotted', 'infinite-mirror', 'eclipse', 'focus', 'ruler'].includes(imageBorder.type) && ( - -
- - - -
- -
- - setImageBorder({ color: e.target.value, enabled: true })} - - className="absolute inset-0 size-full cursor-pointer opacity-0" - +
+ {borderOptions.map((option) => ( +
- -
- - )} - - {['solid', 'glassy', 'dotted', 'eclipse', 'ruler', 'focus'].includes(imageBorder.type) && ( - -
- setImageBorder({ width: value })} - min={1} - max={50} - step={0.5} - label="Width" - valueDisplay={`${imageBorder.width}px`} - /> -
- - )} - - {imageBorder.type === 'window' && ( - - <> - -
- - - - setImageBorder({ title: e.target.value, enabled: true })} - - /> - -
- -
- setImageBorder({ padding: value })} - min={0} - max={100} - step={1} - label="Padding" - valueDisplay={`${imageBorder.padding || 20}px`} - /> -
- - - - )} - + + ))} +
-
- ) - } diff --git a/lib/store/index.ts b/lib/store/index.ts index 7896443..fbd47c0 100644 --- a/lib/store/index.ts +++ b/lib/store/index.ts @@ -49,7 +49,7 @@ export interface ImageBorder { enabled: boolean width: number color: string - type: 'none' | 'solid' | 'glassy' | 'infinite-mirror' | 'window' | 'stack' | 'ruler' | 'eclipse' | 'dotted' | 'focus' + type: 'none' | 'arc-light' | 'arc-dark' | 'macos-dark' | 'macos-light' | 'windows-dark' | 'windows-light' | 'photograph' theme?: 'light' | 'dark' padding?: number title?: string @@ -157,7 +157,7 @@ export interface EditorState { // Frame state (same as imageBorder) frame: { enabled: boolean - type: 'none' | 'solid' | 'glassy' | 'infinite-mirror' | 'window' | 'stack' | 'ruler' | 'eclipse' | 'dotted' | 'focus' + type: 'none' | 'arc-light' | 'arc-dark' | 'macos-dark' | 'macos-light' | 'windows-dark' | 'windows-light' | 'photograph' width: number color: string theme?: 'light' | 'dark' @@ -509,7 +509,7 @@ export const useImageStore = create((set, get) => ({ enabled: false, width: 2, color: '#000000', - type: 'none', + type: 'none' as const, theme: 'light', padding: 20, title: '', diff --git a/public/border/arc-dark.webp b/public/border/arc-dark.webp new file mode 100644 index 0000000..d7de3fd Binary files /dev/null and b/public/border/arc-dark.webp differ diff --git a/public/border/arc-light.webp b/public/border/arc-light.webp new file mode 100644 index 0000000..7c955a5 Binary files /dev/null and b/public/border/arc-light.webp differ diff --git a/public/border/macos-black.webp b/public/border/macos-black.webp new file mode 100644 index 0000000..5e9836e Binary files /dev/null and b/public/border/macos-black.webp differ diff --git a/public/border/none.webp b/public/border/none.webp new file mode 100644 index 0000000..29fb9dd Binary files /dev/null and b/public/border/none.webp differ diff --git a/public/border/photograph.webp b/public/border/photograph.webp new file mode 100644 index 0000000..120266c Binary files /dev/null and b/public/border/photograph.webp differ