blockmatic/src/components/dialogs/OpenImage.tsx

363 lines
11 KiB
TypeScript

import { useContext, useEffect, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import { CircleAlertIcon, LinkIcon, UploadIcon } from "lucide-react";
import { CanvasContext } from "@/context/Canvas";
import { HistoryContext } from "@/context/History";
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 { useBlockData } from "@/hooks/useBlockData";
import BlockSelector from "./open-image/BlockSelector";
import VersionCombobox from "@/components/VersionCombobox";
import { findBlockFromRgb } from "@/utils/findBlockFromRgb";
function OpenImage({ close }: DialogProps) {
const { blocks, version, setBlocks, setVersion, centerCanvas } = useContext(CanvasContext);
const { addHistory } = useContext(HistoryContext);
const { setLoading } = useContext(LoadingContext);
const { acceptedFiles, getRootProps, getInputProps } = useDropzone({
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".svg"],
},
});
const blockData = useBlockData(version);
const divRef = useRef<HTMLDivElement>(null);
const userModifiedBlocks = useRef(false);
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[]>(Object.keys(blockData));
const [blockTypeCheckboxesChecked, setBlockTypeCheckboxesChecked] = useState<Record<string, CheckedState>>({
creative: false,
tile_entity: false,
falling: false,
});
// Used for centering the canvas
const isFinished = useRef(false);
useEffect(() => {
if (acceptedFiles[0]) {
const img = new Image();
img.onload = () => {
setImage(img);
setImageDimensions({ width: img.width, height: img.height });
setAspectRatio(img.width / img.height);
};
img.src = URL.createObjectURL(acceptedFiles[0]);
}
}, [acceptedFiles]);
const onDimensionChange = (e: React.ChangeEvent<HTMLInputElement>, isWidth: boolean) => {
const newDimension = Number(e.target.value);
if (newDimension < 1 || newDimension > 10000) return;
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 onBlockSelectionChange = (newValue: string[]) => {
userModifiedBlocks.current = true;
setSelectedBlocks(newValue);
};
const onSubmit = async () => {
if (image) {
const oldBlocks = [...blocks];
isFinished.current = false;
setLoading(true);
// Wait for loading indicator to appear
await new Promise((resolve) => setTimeout(resolve, 1));
// Load image through JS canvas
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (ctx) {
canvas.width = imageDimensions.width;
canvas.height = imageDimensions.height;
ctx.drawImage(image, 0, 0, imageDimensions.width, imageDimensions.height);
const imageData = ctx.getImageData(0, 0, imageDimensions.width, imageDimensions.height);
const newBlocks: Block[] = [];
// Go through each pixel in the image and find block by checking closest RGB to the average color of the texture
for (let i = 0; i < imageData.data.length; i += 4) {
const block = findBlockFromRgb(selectedBlocks, imageData.data[i], imageData.data[i + 1], imageData.data[i + 2], imageData.data[i + 3]);
if (block == "air") continue;
const x = Math.floor((i / 4) % imageData.width);
const y = Math.floor(i / 4 / imageData.width);
newBlocks.push({
name: block,
x,
y,
});
}
setBlocks(newBlocks);
addHistory(
"Open Image",
() => setBlocks(newBlocks),
() => setBlocks(oldBlocks)
);
}
setLoading(false);
isFinished.current = true;
}
};
useEffect(() => {
if (!isFinished.current) return;
centerCanvas();
close();
return () => {
isFinished.current = false;
};
}, [isFinished, centerCanvas, close]);
useEffect(() => {
setBlockTypeCheckboxesChecked((prev) => {
const newState = { ...prev };
Object.keys(prev).forEach((property) => {
const blocksWithProperty = Object.entries(blockData)
.filter(([, data]) => data[property as keyof BlockData[string]] === true)
.map(([blockName]) => blockName);
const selectedCount = blocksWithProperty.filter((block) => selectedBlocks.includes(block)).length;
if (selectedCount === 0) {
newState[property] = false;
} else if (selectedCount === blocksWithProperty.length) {
newState[property] = true;
} else {
newState[property] = "indeterminate";
}
});
return newState;
});
}, [selectedBlocks]);
useEffect(() => {
if (!userModifiedBlocks.current) {
setSelectedBlocks(Object.keys(blockData));
}
}, [version]);
useEffect(() => {
if (!divRef.current) return;
setStageWidth(divRef.current.clientWidth);
}, [divRef.current?.clientWidth]);
return (
<DialogContent>
<DialogHeader>
<DialogTitle>Open Image</DialogTitle>
<DialogDescription>Open your image to load as blocks into the canvas</DialogDescription>
</DialogHeader>
<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",
})}
>
<input {...getInputProps({ multiple: false })} />
<UploadIcon size={30} />
<p className="text-center">
Drag and drop your image here
<br />
or click to open
</p>
</div>
<div className="grid grid-cols-[auto,1fr] gap-2">
{image && acceptedFiles[0] && (
<>
<img
src={image.src}
alt="your image"
className="w-48 h-48 object-contain border rounded-lg"
style={{ background: "repeating-conic-gradient(#fff 0 90deg, #bbb 0 180deg) 0 0/25% 25%" }}
/>
<div className="flex flex-col gap-2">
<div>
<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)}
/>
</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
type="number"
id="height"
placeholder="Height"
value={imageDimensions.height}
onChange={(e) => onDimensionChange(e, false)}
/>
</div>
</div>
{imageDimensions.height > (version >= 1180 ? 384 : 256) && (
<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 {version >= 1180 ? 384 : 256} 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="falling"
checked={blockTypeCheckboxesChecked.falling}
onCheckedChange={(value) => onBlockTypeCheckedChange(value, "falling")}
/>
<Label htmlFor="falling">Falling</Label>
</div>
</div>
<div className="grid grid-rows-2 gap-1">
<Button className="h-8" onClick={() => onBlockSelectionChange(Object.keys(blockData))}>
Check all blocks
</Button>
<Button className="h-8" onClick={() => onBlockSelectionChange([])}>
Uncheck all blocks
</Button>
</div>
</div>
<Separator />
<Input placeholder="Search for blocks..." value={searchInput} onChange={(e) => setSearchInput(e.target.value)} />
<ScrollArea className="h-60">
<BlockSelector
stageWidth={stageWidth}
searchInput={searchInput}
selectedBlocks={selectedBlocks}
setSelectedBlocks={setSelectedBlocks}
userModifiedBlocks={userModifiedBlocks}
/>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
<DialogFooter className="!justify-between">
<VersionCombobox version={version} setVersion={setVersion} isContext />
<div className="flex gap-2">
<Button variant="outline" onClick={close}>
Cancel
</Button>
<Button type="submit" onClick={onSubmit}>
Open
</Button>
</div>
</DialogFooter>
</DialogContent>
);
}
export default OpenImage;