feat: part 1 of redesign of open image dialog
This commit is contained in:
parent
0c8c4b1870
commit
25f519074a
7 changed files with 383 additions and 66 deletions
|
|
@ -12,6 +12,7 @@
|
|||
"dependencies": {
|
||||
"@pixi/react": "7",
|
||||
"@pixi/tilemap": "4.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-menubar": "^1.1.2",
|
||||
|
|
@ -19,6 +20,7 @@
|
|||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,29 @@
|
|||
import { useContext, useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
||||
import { CircleAlertIcon, UploadIcon } from "lucide-react";
|
||||
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CircleAlertIcon, LinkIcon, UploadIcon } from "lucide-react";
|
||||
|
||||
import { CanvasContext } from "@/context/Canvas";
|
||||
import { ImageContext } from "@/context/Image";
|
||||
import { LoadingContext } from "@/context/Loading";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CheckedState } from "@radix-ui/react-checkbox";
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
|
||||
import { getBlockData } from "@/utils/getBlockData";
|
||||
|
||||
import BlockSelector from "./open-image/BlockSelector";
|
||||
|
||||
function OpenImage({ close }: DialogProps) {
|
||||
const { version } = useContext(CanvasContext);
|
||||
const { setLoading } = useContext(LoadingContext);
|
||||
const { setImage: setContextImage, setImageDimensions: setContextImageDimensions } = useContext(ImageContext);
|
||||
|
||||
|
|
@ -21,9 +33,24 @@ function OpenImage({ close }: DialogProps) {
|
|||
},
|
||||
});
|
||||
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [image, setImage] = useState<HTMLImageElement>();
|
||||
const [imageDimensions, setImageDimensions] = useState<Dimension>({ width: 0, height: 0 });
|
||||
const [aspectRatio, setAspectRatio] = useState(1);
|
||||
const [linkAspectRatio, setLinkAspectRatio] = useState(true);
|
||||
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [stageWidth, setStageWidth] = useState(0);
|
||||
|
||||
const [selectedBlocks, setSelectedBlocks] = useState<string[]>(["stone"]);
|
||||
const [blockTypeCheckboxesChecked, setBlockTypeCheckboxesChecked] = useState({
|
||||
creative: false,
|
||||
tile_entity: false,
|
||||
fallable: false,
|
||||
});
|
||||
|
||||
const blockData = getBlockData(version);
|
||||
|
||||
useEffect(() => {
|
||||
if (acceptedFiles[0]) {
|
||||
|
|
@ -41,15 +68,27 @@ function OpenImage({ close }: DialogProps) {
|
|||
const newDimension = Number(e.target.value);
|
||||
if (newDimension < 1 || newDimension > 10000) return;
|
||||
|
||||
setImageDimensions(() => {
|
||||
if (isWidth) {
|
||||
return { width: newDimension, height: Math.round(newDimension / aspectRatio) };
|
||||
} else {
|
||||
return { width: Math.round(newDimension * aspectRatio), height: newDimension };
|
||||
}
|
||||
setImageDimensions((prev) => {
|
||||
if (isWidth)
|
||||
return linkAspectRatio ? { width: newDimension, height: Math.round(newDimension / aspectRatio) } : { ...prev, width: newDimension };
|
||||
return linkAspectRatio ? { width: Math.round(newDimension * aspectRatio), height: newDimension } : { ...prev, height: newDimension };
|
||||
});
|
||||
};
|
||||
|
||||
const onBlockTypeCheckedChange = (checked: CheckedState, property: keyof BlockData[string]) => {
|
||||
const blocksWithProperty = Object.entries(blockData)
|
||||
.filter(([, data]) => data[property] === true)
|
||||
.map(([blockName]) => blockName);
|
||||
|
||||
if (checked) {
|
||||
setSelectedBlocks((prev) => [...prev, ...blocksWithProperty]);
|
||||
} else {
|
||||
setSelectedBlocks((prev) => prev.filter((block) => !blocksWithProperty.includes(block)));
|
||||
}
|
||||
|
||||
setBlockTypeCheckboxesChecked((prev) => ({ ...prev, [property]: checked }));
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
if (image) {
|
||||
setLoading(true);
|
||||
|
|
@ -63,14 +102,39 @@ function OpenImage({ close }: DialogProps) {
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Object.keys(blockTypeCheckboxesChecked).forEach((property) => {
|
||||
const blocksWithProperty = Object.entries(blockData)
|
||||
.filter(([, data]) => data[property as keyof BlockData[string]] === true)
|
||||
.map(([blockName]) => blockName);
|
||||
|
||||
const propertyChecked = blocksWithProperty.every((block) => selectedBlocks.includes(block));
|
||||
setBlockTypeCheckboxesChecked((prev) => ({ ...prev, [property]: propertyChecked }));
|
||||
});
|
||||
}, [selectedBlocks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!divRef.current) return;
|
||||
setStageWidth(divRef.current.clientWidth);
|
||||
|
||||
console.log(stageWidth);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogContent ref={divRef}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Open Image</DialogTitle>
|
||||
<DialogDescription>Open your image to load as blocks into the canvas</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div ref={divRef}>
|
||||
<Tabs defaultValue="image">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="image">Image</TabsTrigger>
|
||||
<TabsTrigger value="blocks">Blocks</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="image" className="flex flex-col gap-2">
|
||||
<div
|
||||
{...getRootProps({
|
||||
className: "flex flex-col justify-center items-center gap-2 p-4 rounded border border-2 border-dashed select-none",
|
||||
|
|
@ -78,7 +142,7 @@ function OpenImage({ close }: DialogProps) {
|
|||
>
|
||||
<input {...getInputProps({ multiple: false })} />
|
||||
<UploadIcon />
|
||||
<p>Drag and drop your image here</p>
|
||||
<p>Drag and drop your image here or click to open</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[auto,1fr] gap-2">
|
||||
|
|
@ -93,15 +157,34 @@ function OpenImage({ close }: DialogProps) {
|
|||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<Label>File name</Label>
|
||||
<p>{acceptedFiles[0].name}</p>
|
||||
<Label htmlFor="file-name">File name</Label>
|
||||
<p id="file-name" className="text-wrap">
|
||||
{acceptedFiles[0].name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] gap-1">
|
||||
<div>
|
||||
<Label htmlFor="width">Width (blocks)</Label>
|
||||
<Input type="number" id="width" placeholder="Width" value={imageDimensions.width} onChange={(e) => onDimensionChange(e, true)} />
|
||||
<Input
|
||||
type="number"
|
||||
id="width"
|
||||
placeholder="Width"
|
||||
value={imageDimensions.width}
|
||||
onChange={(e) => onDimensionChange(e, true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
aria-label="Link aspect ratio"
|
||||
variant="outline"
|
||||
pressed={linkAspectRatio}
|
||||
onPressedChange={() => setLinkAspectRatio(!linkAspectRatio)}
|
||||
className="h-8 !min-w-8 p-0 mt-auto mb-1"
|
||||
>
|
||||
<LinkIcon />
|
||||
</Toggle>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="height">Height (blocks)</Label>
|
||||
<Input
|
||||
|
|
@ -113,24 +196,82 @@ function OpenImage({ close }: DialogProps) {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="items-center">
|
||||
{imageDimensions.height > 384 && (
|
||||
<div className="flex items-center gap-1 h-min mr-auto">
|
||||
<div className="flex items-center gap-1 mt-auto mb-1">
|
||||
<CircleAlertIcon className="text-red-400" size={22} />
|
||||
<span className="text-red-400 text-sm">The height is above 384 blocks!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="blocks" className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="grid grid-rows-3 gap-2 *:flex *:items-center *:gap-1">
|
||||
<div>
|
||||
<Checkbox
|
||||
id="creative"
|
||||
checked={blockTypeCheckboxesChecked.creative}
|
||||
onCheckedChange={(value) => onBlockTypeCheckedChange(value, "creative")}
|
||||
/>
|
||||
<Label htmlFor="creative">Creative only</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
id="tile_entity"
|
||||
checked={blockTypeCheckboxesChecked.tile_entity}
|
||||
onCheckedChange={(value) => onBlockTypeCheckedChange(value, "tile_entity")}
|
||||
/>
|
||||
<Label htmlFor="tile_entity">Tile entities</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
id="fallable"
|
||||
checked={blockTypeCheckboxesChecked.fallable}
|
||||
onCheckedChange={(value) => onBlockTypeCheckedChange(value, "fallable")}
|
||||
/>
|
||||
<Label htmlFor="fallable">Fallable</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-rows-2 gap-1">
|
||||
<Button className="h-8" onClick={() => setSelectedBlocks(Object.keys(blockData))}>
|
||||
Check all blocks
|
||||
</Button>
|
||||
<Button className="h-8" onClick={() => setSelectedBlocks([])}>
|
||||
Uncheck all blocks
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Input placeholder="Search for blocks..." value={searchInput} onChange={(e) => setSearchInput(e.target.value)} />
|
||||
|
||||
<ScrollArea className="h-96">
|
||||
<BlockSelector
|
||||
stageWidth={stageWidth}
|
||||
searchInput={searchInput}
|
||||
selectedBlocks={selectedBlocks}
|
||||
setSelectedBlocks={setSelectedBlocks}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{/* todo: add version selector here */}
|
||||
|
||||
<Button variant="outline" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" onClick={onSubmit}>
|
||||
Submit
|
||||
Open
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
95
src/components/dialogs/open-image/BlockSelector.tsx
Normal file
95
src/components/dialogs/open-image/BlockSelector.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import React, { useContext, useMemo, useState } from "react";
|
||||
import { Container, Graphics, Sprite, Stage } from "@pixi/react";
|
||||
|
||||
import { CanvasContext } from "@/context/Canvas";
|
||||
import { TexturesContext } from "@/context/Textures";
|
||||
import { ThemeContext } from "@/context/Theme";
|
||||
|
||||
import { getBlockData } from "@/utils/getBlockData";
|
||||
|
||||
interface Props {
|
||||
stageWidth: number;
|
||||
searchInput: string;
|
||||
selectedBlocks: string[];
|
||||
setSelectedBlocks: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
function BlockSelector({ stageWidth, searchInput, selectedBlocks, setSelectedBlocks }: Props) {
|
||||
const { version } = useContext(CanvasContext);
|
||||
const { missingTexture, textures } = useContext(TexturesContext);
|
||||
const { isDark } = useContext(ThemeContext);
|
||||
|
||||
const blockData = getBlockData(version);
|
||||
|
||||
const [hoverPosition, setHoverPosition] = useState<Position | null>(null);
|
||||
|
||||
const filteredBlocks = useMemo(() => Object.keys(blockData).filter((value) => value.includes(searchInput)), [searchInput, blockData]);
|
||||
const blocksPerColumn = Math.floor(462 / (32 + 2));
|
||||
|
||||
const onClick = (block: string) => {
|
||||
if (selectedBlocks.includes(block)) {
|
||||
setSelectedBlocks((prev) => prev.filter((blockName) => blockName !== block));
|
||||
} else {
|
||||
setSelectedBlocks((prev) => [...prev, block]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stage
|
||||
width={462}
|
||||
height={Math.ceil(Object.keys(blockData).length / blocksPerColumn) * (32 + 2)}
|
||||
options={{ backgroundAlpha: 0 }}
|
||||
onMouseLeave={() => setHoverPosition(null)}
|
||||
>
|
||||
<Container>
|
||||
{filteredBlocks.map((block, index) => {
|
||||
const texture = textures[`${block}.png`] ?? missingTexture;
|
||||
const x = (index % blocksPerColumn) * (32 + 2) + 2;
|
||||
const y = Math.floor(index / blocksPerColumn) * (32 + 2) + 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sprite
|
||||
key={block}
|
||||
texture={texture}
|
||||
x={x}
|
||||
y={y}
|
||||
scale={2}
|
||||
interactive={true}
|
||||
pointerover={() => setHoverPosition({ x, y })}
|
||||
click={() => onClick(block)}
|
||||
/>
|
||||
|
||||
{selectedBlocks.includes(block) && (
|
||||
<Graphics
|
||||
x={x}
|
||||
y={y}
|
||||
draw={(g) => {
|
||||
g.clear();
|
||||
g.beginFill(0x000000, 0.5);
|
||||
g.lineStyle(2, isDark ? 0xffffff : 0x000000, 1, 1);
|
||||
g.drawRect(0, 0, 32, 32);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
|
||||
{hoverPosition && (
|
||||
<Graphics
|
||||
x={hoverPosition.x}
|
||||
y={hoverPosition.y}
|
||||
draw={(g) => {
|
||||
g.clear();
|
||||
g.lineStyle(2, isDark ? 0xffffff : 0x000000, 1, 1);
|
||||
g.drawRect(0, 0, 32, 32);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockSelector;
|
||||
28
src/components/ui/checkbox.tsx
Normal file
28
src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-zinc-200 border-zinc-900 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-zinc-900 data-[state=checked]:text-zinc-50 dark:border-zinc-800 dark:border-zinc-50 dark:ring-offset-zinc-950 dark:focus-visible:ring-zinc-300 dark:data-[state=checked]:bg-zinc-50 dark:data-[state=checked]:text-zinc-900",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-zinc-100 p-1 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-zinc-950 data-[state=active]:shadow-sm dark:ring-offset-zinc-950 dark:focus-visible:ring-zinc-300 dark:data-[state=active]:bg-zinc-950 dark:data-[state=active]:text-zinc-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 dark:ring-offset-zinc-950 dark:focus-visible:ring-zinc-300",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const TexturesProvider = ({ children }: Props) => {
|
|||
const [solidTextures, setSolidTextures] = useState<Record<string, PIXI.Texture>>({});
|
||||
|
||||
useEffect(() => {
|
||||
// Load missing texture
|
||||
// Load missing texture through data string just incase of network errors
|
||||
const missingBaseTexture = new PIXI.BaseTexture(
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGUlEQVR42mPABX4w/MCKaKJhVMPgcOuoBgDZRfgBVl5QdQAAAABJRU5ErkJggg=="
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue