Skip to content

Instantly share code, notes, and snippets.

@GrantCuster
Last active April 3, 2025 18:38
Show Gist options
  • Save GrantCuster/337393057b457f4d00051155aa317a7f to your computer and use it in GitHub Desktop.
Save GrantCuster/337393057b457f4d00051155aa317a7f to your computer and use it in GitHub Desktop.
test deploy
.DS_Store
node_modules/
package-lock.json
*.js
import { RefUpdater } from "./RefUpdater";
import { Toolbar } from "./Toolbar";
import { Zoom } from "./Zoom";
export function App() {
return (
<div className="w-full relative h-[100dvh]">
<Zoom />
<Toolbar />
<RefUpdater />
</div>
);
}
export default App;
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { BlockType, ModeType, StateRefType } from "./types";
import { defaultRenderPrompt } from "./consts";
import { v4 as uuid } from "uuid";
export const CameraAtom = atom({
x: 0,
y: 0,
z: 1,
});
export const ZoomContainerAtom = atom<HTMLDivElement | null>(null);
export const ModeAtom = atom<ModeType>("move");
export const RenderPromptAtom = atomWithStorage<string>(
"render-prompt-3",
defaultRenderPrompt,
);
const starterBlocks = [
{
id: uuid(),
type: "image",
src: "/cat.jpg",
x: -400,
y: -400,
width: 400,
height: 300,
},
{
id: uuid(),
type: "image",
src: "/pumpkins.jpg",
x: 200,
y: -400,
width: 400,
height: 300,
},
{
id: uuid(),
type: "image",
src: "/clock.jpg",
x: -200,
y: 100,
width: 400,
height: 300,
}
];
export const BlockIdsAtom = atom<string[]>(starterBlocks.map((block) => block.id));
let starterBlockMap: Record<string, BlockType> = {};
for (const block of starterBlocks) {
starterBlockMap[block.id] = block as BlockType;
}
export const BlockMapAtom = atom<Record<string, BlockType>>(starterBlockMap);
export const PromptCreatorAtom = atom<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
export const RenderCreatorAtom = atom<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
export const SelectedBlockIdsAtom = atom<string[]>([]);
export const BlockSelectorAtom = atom<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
export const StateRefAtom = atom<StateRefType>({
camera: { x: 0, y: 0, z: 1 },
blockIds: [],
blockMap: {},
mode: "move",
zoomContainer: null,
selectedBlockIds: [],
blockSelector: null,
});
import { useDrag } from "@use-gesture/react";
import { useAtom } from "jotai";
import { useRef } from "react";
import { BlockMapAtom, CameraAtom, ZoomContainerAtom } from "./atoms";
import { makeZIndex } from "./utils";
import { screenToCanvas } from "./Camera";
export function BlockResizers({ id }: { id: string }) {
const [blockMap, setBlockMap] = useAtom(BlockMapAtom);
const block = blockMap[id];
const [camera] = useAtom(CameraAtom);
const cameraRef = useRef(camera);
cameraRef.current = camera;
const [zoomContainer] = useAtom(ZoomContainerAtom);
const zoomContainerRef = useRef(zoomContainer);
zoomContainerRef.current = zoomContainer;
const preserveAspectRatio = block.type === "image";
const size = 24;
const keepCornerRef = useRef({ x: block.x, y: block.y });
const dirs = ["nw", "ne", "sw", "se"];
const binds = dirs.map((dir) => {
return useDrag(({ first, event, xy: [x, y] }) => {
event.stopPropagation();
if (first) {
if (dir === "nw") {
keepCornerRef.current = {
x: block.x + block.width,
y: block.y + block.height,
};
} else if (dir === "ne") {
keepCornerRef.current = {
x: block.x,
y: block.y + block.height,
};
} else if (dir === "sw") {
keepCornerRef.current = {
x: block.x + block.width,
y: block.y,
};
} else if (dir === "se") {
keepCornerRef.current = {
x: block.x,
y: block.y,
};
}
} else {
const canvasPoint = screenToCanvas(
{ x, y },
cameraRef.current,
zoomContainerRef.current!,
);
const minX =
dir === "ne" || dir === "se"
? keepCornerRef.current.x
: canvasPoint.x;
const minY =
dir === "sw" || dir === "se"
? keepCornerRef.current.y
: canvasPoint.y;
const maxX =
dir === "nw" || dir === "sw"
? keepCornerRef.current.x
: canvasPoint.x;
const maxY =
dir === "nw" || dir === "ne"
? keepCornerRef.current.y
: canvasPoint.y;
let startX = minX;
let startY = minY;
let endX = maxX;
let endY = maxY;
if (preserveAspectRatio) {
const aspectRatio = block.width / block.height;
const newWidth = endX - startX;
const newHeight = endY - startY;
const newAspectRatio = newWidth / newHeight;
if (newAspectRatio > aspectRatio) {
if (dir === "nw" || dir === "sw") {
startX = endX - newHeight * aspectRatio;
} else {
endX = startX + newHeight * aspectRatio;
}
} else {
if (dir === "nw" || dir === "ne") {
startY = endY - newWidth / aspectRatio;
} else {
endY = startY + newWidth / aspectRatio;
}
}
}
let width = endX - startX;
let height = endY - startY;
const minSize = 48;
if (width < minSize) {
if (dir === "nw" || dir === "sw") {
startX = endX - minSize;
} else {
endX = startX + minSize;
}
width = endX - startX;
}
if (height < minSize) {
if (dir === "nw" || dir === "ne") {
startY = endY - minSize;
} else {
endY = startY + minSize;
}
height = endY - startY;
}
setBlockMap((prev) => ({
...prev,
[id]: {
...block,
x: startX,
y: startY,
width,
height,
zIndex: makeZIndex(),
},
}));
}
});
});
return (
<>
<div
{...binds[0]()}
className="absolute touch-none pointer-events-auto"
style={{
left: -size / 2,
top: -size / 2,
cursor: "nwse-resize",
width: size,
height: size,
}}
/>
<div
{...binds[1]()}
className="absolute touch-none pointer-events-auto"
style={{
right: -size / 2,
top: -size / 2,
cursor: "nesw-resize",
width: size,
height: size,
}}
/>
<div
{...binds[2]()}
className="absolute touch-none pointer-events-auto"
style={{
left: -size / 2,
bottom: -size / 2,
cursor: "nesw-resize",
width: size,
height: size,
}}
/>
<div
{...binds[3]()}
className="absolute touch-none pointer-events-auto"
style={{
right: -size / 2,
bottom: -size / 2,
cursor: "nwse-resize",
width: size,
height: size,
}}
/>
</>
);
}
import { useAtom } from "jotai";
import {
BlockIdsAtom,
BlockMapAtom,
SelectedBlockIdsAtom,
} from "./atoms";
import { RenderBlockType, ImageBlockType, PromptBlockType } from "./types";
import { PromptBlock } from "./PromptBlock";
import { RenderBlock } from "./RenderBlock";
import { ImageBlock } from "./ImageBlock";
import { BlockResizers } from "./BlockResizers";
export function Blocks() {
const [blockIds] = useAtom(BlockIdsAtom);
const [blockMap] = useAtom(BlockMapAtom);
return (
<>
<div className="absolute z-0">
{blockIds.map((id) => {
const block = blockMap[id];
if (block.type !== "render" && block.type !== "prompt") {
return <BlockWrapper key={id} id={id} />;
}
})}
</div>
<div className="absolute z-0">
{blockIds.map((id) => {
const block = blockMap[id];
if (block.type === "prompt") {
return <BlockWrapper key={id} id={id} />;
}
})}
</div>
<div className="absolute z-0">
{blockIds.map((id) => {
const block = blockMap[id];
if (block.type === "render") {
return <BlockWrapper key={id} id={id} />;
}
})}
</div>
</>
);
}
export function BlockWrapper({ id }: { id: string }) {
const [blockMap] = useAtom(BlockMapAtom);
const block = blockMap[id];
const [selectedBlockIds] = useAtom(SelectedBlockIdsAtom);
const isSelected = selectedBlockIds.includes(id);
return (
<div
className={`absolute border-2 ${isSelected ? "border-blue-500" : "border-transparent"} touch-none pointer-events-auto`}
style={{
left: block.x,
top: block.y,
width: block.width,
height: block.height,
zIndex: block.zIndex,
pointerEvents: block.type === "render" ? "none" : "auto",
}}
>
<BlockFactory id={id} />
<BlockResizers id={id} />
</div>
);
}
export function BlockFactory({ id }: { id: string }) {
const [blockMap] = useAtom(BlockMapAtom);
const block = blockMap[id];
switch (block.type) {
case "image":
return <ImageBlock block={block as ImageBlockType} />;
case "render":
return <RenderBlock block={block as RenderBlockType} />;
case "prompt":
return <PromptBlock block={block as PromptBlockType} />;
}
}
import { CameraType, PointType } from "./types";
export function screenToCanvas(
point: PointType,
camera: CameraType,
container: HTMLDivElement,
) {
const x = (point.x - container.clientWidth / 2) / camera.z - camera.x;
const y = (point.y - container.clientHeight / 2) / camera.z - camera.y;
return { x, y };
}
export function canvasToScreen(
point: PointType,
camera: CameraType,
container: HTMLDivElement,
) {
const x = (point.x + camera.x) * camera.z + container.clientWidth / 2;
const y = (point.y + camera.y) * camera.z + container.clientHeight / 2;
return { x, y };
}
export function panCamera(
camera: CameraType,
dx: number,
dy: number,
): CameraType {
return {
x: camera.x - dx / camera.z,
y: camera.y - dy / camera.z,
z: camera.z,
};
}
export function zoomCamera(
camera: CameraType,
point: PointType,
dz: number,
container: HTMLDivElement,
): CameraType {
const zoom = camera.z - dz * camera.z;
const p1 = screenToCanvas(point, camera, container);
const p2 = screenToCanvas(point, { ...camera, z: zoom }, container);
return {
x: camera.x + p2.x - p1.x,
y: camera.y + p2.y - p1.y,
z: zoom,
};
}
export const maxSize = 512;
export const defaultRenderPrompt =
"Generate an image based on the contents provided by the user. If the user provides a collage of styles, combine them into one coherent image in a high-fidelity style. Move and reposition subjects if needed to match the user's intent.";
import { useAtom } from "jotai";
import {
BlockIdsAtom,
BlockMapAtom,
CameraAtom,
ZoomContainerAtom,
} from "./atoms";
import { BlockType } from "./types";
import { useEffect, useRef } from "react";
import { v4 as uuid } from "uuid";
import { screenToCanvas } from "./Camera";
import { loadImage, makeZIndex } from "./utils";
import { maxSize } from "./consts";
export function useCreateBlock() {
const [, setBlockIds] = useAtom(BlockIdsAtom);
const [, setBlockMap] = useAtom(BlockMapAtom);
return (block: BlockType) => {
setBlockIds((prev) => [...prev, block.id]);
setBlockMap((prev) => ({ ...prev, [block.id]: block }));
};
}
export function useUpdateBlock() {
const [, setBlockMap] = useAtom(BlockMapAtom);
return (id: string, updates: Partial<BlockType>) => {
setBlockMap((prev) => ({
...prev,
[id]: { ...prev[id], ...updates } as BlockType,
}));
};
}
export function useHandleDropImage() {
const createBlock = useCreateBlock();
const [camera] = useAtom(CameraAtom);
const [zoomContainer] = useAtom(ZoomContainerAtom);
const cameraRef = useRef(camera);
cameraRef.current = camera;
const zoomContainerRef = useRef<HTMLDivElement | null>(null);
zoomContainerRef.current = zoomContainer;
function handleDragOver(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault();
}
function handleDropImage(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault();
const file = event.dataTransfer?.files[0];
if (file) {
const reader = new FileReader();
reader.onload = async () => {
const src = reader.result as string;
const image = await loadImage(src);
const scale = Math.min(maxSize / image.width, maxSize / image.height);
const canvasPoint = screenToCanvas(
{ x: event.clientX, y: event.clientY },
cameraRef.current,
zoomContainerRef.current!,
);
createBlock({
id: uuid(),
type: "image",
src,
x: canvasPoint.x - (image.width * scale) / 2,
y: canvasPoint.y - (image.height * scale) / 2,
width: image.width * scale,
height: image.height * scale,
zIndex: makeZIndex(),
});
};
reader.readAsDataURL(file);
}
}
useEffect(() => {
window.addEventListener("drop", handleDropImage);
window.addEventListener("dragover", handleDragOver);
return () => {
window.removeEventListener("drop", handleDropImage);
window.removeEventListener("dragover", handleDragOver);
};
}, []);
}
export function useHandlePasteImage() {
const createBlock = useCreateBlock();
const [camera] = useAtom(CameraAtom);
const [zoomContainer] = useAtom(ZoomContainerAtom);
const cameraRef = useRef(camera);
cameraRef.current = camera;
const zoomContainerRef = useRef<HTMLDivElement | null>(null);
zoomContainerRef.current = zoomContainer;
function handlePasteImage(event: ClipboardEvent) {
const file = event.clipboardData?.files[0];
if (file) {
const reader = new FileReader();
reader.onload = async () => {
const src = reader.result as string;
const image = await loadImage(src);
const scale = Math.min(maxSize / image.width, maxSize / image.height);
const canvasPoint = screenToCanvas(
{
x: zoomContainerRef.current!.clientWidth / 2,
y: zoomContainerRef.current!.clientHeight / 2,
},
cameraRef.current,
zoomContainerRef.current!,
);
createBlock({
id: uuid(),
type: "image",
src,
x: canvasPoint.x - (image.width * scale) / 2,
y: canvasPoint.y - (image.height * scale) / 2,
width: image.width * scale,
height: image.height * scale,
zIndex: makeZIndex(),
});
};
reader.readAsDataURL(file);
}
}
useEffect(() => {
window.addEventListener("paste", handlePasteImage);
return () => {
window.removeEventListener("paste", handlePasteImage);
};
}, []);
}
import { useEffect, useMemo, useState } from "react";
import { ImageBlockType } from "./types";
export function ImageBlock({ block }: { block: ImageBlockType }) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
useEffect(() => {
async function main() {
const noStartingSlash = block.src.startsWith("/") ? block.src.slice(1) : block.src;
const fetched = await fetch(noStartingSlash);
const blob = await fetched.blob();
const url = URL.createObjectURL(blob);
setImageUrl(url);
}
main();
}, [block.src]);
return (
<img
draggable={false}
src={imageUrl || ''}
style={{ width: "100%", height: "100%" }}
/>
);
}
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
html, body, #root {
height: 100dvh;
font-size: 16px;
overflow: hidden;
font-family: monospace;
}
body {
background-color: theme('colors.neutral.900');
color: theme('colors.neutral.200');
}
input, textarea {
background-color: theme('colors.neutral.800');
color: theme('colors.neutral.200');
}
.border, .border-l, .border-t, .border-b, .border-r {
border-color: theme('colors.neutral.700');
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>App</title>
<script type="importmap">
{
"imports": {
"@mediapipe/tasks-vision": "https://esm.sh/@mediapipe/tasks-vision",
"@mediapipe/tasks-vision/": "https://esm.sh/@mediapipe/tasks-vision/",
"@types/uuid": "https://esm.sh/@types/uuid",
"@types/uuid/": "https://esm.sh/@types/uuid/",
"@use-gesture/react": "https://esm.sh/@use-gesture/react",
"@use-gesture/react/": "https://esm.sh/@use-gesture/react/",
"jotai": "https://esm.sh/jotai",
"jotai/": "https://esm.sh/jotai/",
"react": "https://esm.sh/react",
"react/": "https://esm.sh/react/",
"react-dom": "https://esm.sh/react-dom",
"react-dom/": "https://esm.sh/react-dom/",
"uuid": "https://esm.sh/uuid",
"uuid/": "https://esm.sh/uuid/",
"@tailwindcss/browser": "https://esm.sh/@tailwindcss/browser",
"@tailwindcss/browser/": "https://esm.sh/@tailwindcss/browser/"
}
}
</script>
<script type="application/javascript">
if (window.location.hostname === "localhost") {
let API_KEY = localStorage.getItem("GEMINI_API_KEY");
if (!API_KEY) {
API_KEY = prompt("Please enter your API key from AI Studio:");
if (API_KEY) {
localStorage.setItem("GEMINI_API_KEY", API_KEY);
}
}
globalThis.process = { env: { API_KEY: API_KEY } };
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="index.js"></script>
</body>
</html>
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from "./App"
import '@tailwindcss/browser'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
{
"name": "helloapplet",
"version": "1.0.0",
"main": "index.tsx",
"scripts": {
"importmap": "node -e \"const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); let indexHtml = fs.readFileSync('index.html', 'utf8'); const importMap = { imports: {} }; Object.keys(pkg.dependencies || {}).forEach(k => { importMap.imports[k] = 'https://esm.sh/' + k; importMap.imports[k + '/'] = 'https://esm.sh/' + k + '/'; }); const importMapString = JSON.stringify(importMap, null, 2); const regex = /<script\\s+type=\\\"importmap\\\">([\\s\\S]*?)<\\/script>/i; indexHtml = indexHtml.replace(regex, '<script type=\\\"importmap\\\">\\n'+importMapString+'\\n</script>'); fs.writeFileSync('index.html', indexHtml, 'utf8');\"",
"start": "npm run importmap && tsc --watch",
"dev": "vite"
},
"author": "",
"license": "Apache-2.0",
"description": "",
"devDependencies": {
"http-server": "^14.1.1",
"typescript": "^5.8.2",
"vite": "^6.2.4"
},
"dependencies": {
"@mediapipe/tasks-vision": "^0.10.0",
"@types/uuid": "^10.0.0",
"@use-gesture/react": "^10.3.1",
"jotai": "^2.12.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"uuid": "^11.1.0",
"@tailwindcss/browser": "^4.0.17"
}
}
import { useSetAtom } from "jotai";
import { BlockMapAtom } from "./atoms";
import { PromptBlockType } from "./types";
import { useLayoutEffect, useRef } from "react";
export function PromptBlock({ block }: { block: PromptBlockType }) {
const setBlockMap = useSetAtom(BlockMapAtom);
const doubleClickedRef = useRef(false);
function updateBlock(id: string, updates: Partial<PromptBlockType>) {
setBlockMap((prev) => ({
...prev,
[id]: { ...prev[id], ...updates } as PromptBlockType,
}));
}
const setBlock = (updates: Partial<PromptBlockType>) => {
setBlockMap((prev) => ({
...prev,
[block.id]: { ...block, ...updates },
}));
};
const textSizingRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
if (!textSizingRef.current) return;
const height = textSizingRef.current.offsetHeight;
const topBar = 0;
if (block.height < height + topBar) {
updateBlock(block.id, { height: height + topBar });
}
}, [block.text, block.width, block.height]);
// TODO placeholder text
return (
<div
className="absolute overflow-hidden"
style={{ width: "100%", height: "100%" }}
>
<div className="absolute inset-0 flex flex-col bg-neutral-800 bg-opacity-80 rounded-xl">
<div className="uppercase hidden text-xs px-3 pt-2">PROMPT</div>
<div
className="absolute pointer-events-none left-0 top-0 w-full px-3 py-2 whitespace-pre-wrap break-words select-none opacity-0 rounded-xl"
ref={textSizingRef}
>
{block.text}&#xfeff;
</div>
<div className="relative grow">
{block.editing ? (
<textarea
autoFocus
className="absolute left-0 top-0 px-3 py-2 w-full h-full focus:bg-transparent focus:outline-none resize-none"
placeholder="Your prompt"
value={block.text}
onFocus={(e) => {
if (doubleClickedRef.current) {
// put cursor at end of text
e.target.selectionStart = e.target.selectionEnd =
e.target.value.length;
doubleClickedRef.current = false;
}
}}
onPointerDown={(e) => e.stopPropagation()}
onChange={(e) => setBlock({ text: e.target.value })}
onBlur={() => setBlock({ editing: false })}
/>
) : (
<div
className="absolute left-0 top-0 w-full px-3 py-2 whitespace-pre-wrap break-words h-full select-none"
onDoubleClick={() => {
doubleClickedRef.current = true;
setBlock({ editing: true });
}}
>
<div className="">{block.text}</div>
</div>
)}
</div>
</div>
</div>
);
}
import { useAtom } from "jotai";
import {
CameraAtom,
ModeAtom,
PromptCreatorAtom,
ZoomContainerAtom,
} from "./atoms";
import { useEffect, useRef } from "react";
import { screenToCanvas } from "./Camera";
import { useCreateBlock } from "./hooks";
import { v4 as uuid } from "uuid";
import { makeZIndex } from "./utils";
export function PromptCreator() {
const [mode, setMode] = useAtom(ModeAtom);
const [promptCreator, setPromptCreator] = useAtom(PromptCreatorAtom);
const createBlock = useCreateBlock();
const [camera] = useAtom(CameraAtom);
const [zoomContainer] = useAtom(ZoomContainerAtom);
const cameraRef = useRef(camera);
cameraRef.current = camera;
const zoomContainerRef = useRef<HTMLDivElement | null>(null);
zoomContainerRef.current = zoomContainer;
const pointRef = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
if (mode !== "prompt") return;
function handlePointerDown(e: PointerEvent) {
(e.target as Element).setPointerCapture(e.pointerId);
const canvasPoint = screenToCanvas(
{ x: e.clientX, y: e.clientY },
cameraRef.current,
zoomContainerRef.current!,
);
pointRef.current = { x: canvasPoint.x, y: canvasPoint.y };
}
function handlePointerMove(e: PointerEvent) {
if (e.buttons !== 1) return;
if (!pointRef.current) return;
const canvasPoint = screenToCanvas(
{ x: e.clientX, y: e.clientY },
cameraRef.current,
zoomContainerRef.current!,
);
const x = Math.min(canvasPoint.x, pointRef.current.x);
const y = Math.min(canvasPoint.y, pointRef.current.y);
const width = Math.abs(canvasPoint.x - pointRef.current.x);
const height = Math.abs(canvasPoint.y - pointRef.current.y);
setPromptCreator({ x, y, width, height });
}
function handlePointerUp(e: PointerEvent) {
(e.target as Element).releasePointerCapture(e.pointerId);
if (!pointRef.current) return;
const canvasPoint = screenToCanvas(
{ x: e.clientX, y: e.clientY },
cameraRef.current,
zoomContainerRef.current!,
);
const x = Math.min(canvasPoint.x, pointRef.current.x);
const y = Math.min(canvasPoint.y, pointRef.current.y);
const width = Math.max(Math.abs(canvasPoint.x - pointRef.current.x), 180)
const height = Math.max(Math.abs(canvasPoint.y - pointRef.current.y), 24)
pointRef.current = null;
createBlock({
id: uuid(),
type: "prompt",
x,
y,
width,
height,
text: "",
editing: true,
zIndex: makeZIndex(),
});
setPromptCreator(null);
setMode("move");
}
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}, [mode, zoomContainer]);
return promptCreator ? (
<div
className="border-2 border-blue-500"
style={{
position: "absolute",
left: promptCreator.x,
top: promptCreator.y,
width: promptCreator.width,
height: promptCreator.height,
}}
/>
) : null;
}
import { useAtom } from "jotai";
import { BlockIdsAtom, BlockMapAtom, BlockSelectorAtom, CameraAtom, ModeAtom, SelectedBlockIdsAtom, StateRefAtom, ZoomContainerAtom } from "./atoms";
import { useEffect } from "react";
export function RefUpdater() {
const [stateRef] = useAtom(StateRefAtom);
const [camera] = useAtom(CameraAtom);
const [blockIds] = useAtom(BlockIdsAtom);
const [blockMap] = useAtom(BlockMapAtom);
const [mode] = useAtom(ModeAtom);
const [zoomContainer] = useAtom(ZoomContainerAtom);
const [selectedBlockIds] = useAtom(SelectedBlockIdsAtom);
const [blockSelector] = useAtom(BlockSelectorAtom);
// If you add a new entry make sure you add it to the dependency array
useEffect(() => {
stateRef.camera = camera;
stateRef.blockIds = blockIds;
stateRef.blockMap = blockMap;
stateRef.mode = mode;
stateRef.zoomContainer = zoomContainer;
stateRef.selectedBlockIds = selectedBlockIds;
}, [camera, blockIds, blockMap, mode, zoomContainer, selectedBlockIds, blockSelector]);
return null
}
import { useAtom } from "jotai";
import { v4 as uuid } from "uuid";
import {
BlockIdsAtom,
BlockMapAtom,
RenderPromptAtom,
} from "./atoms";
import { maxSize } from "./consts";
import { useCreateBlock, useUpdateBlock } from "./hooks";
import { predict } from "./modelUtils";
import { RenderBlockType } from "./types";
import { loadImage, makeZIndex } from "./utils";
// import { GenerateContentRequest } from "@google/generative-ai";
const sides = ["top", "right", "bottom", "left"] as const;
export function RenderBlock({ block }: { block: RenderBlockType }) {
const [blockIds] = useAtom(BlockIdsAtom);
const [blockMap] = useAtom(BlockMapAtom);
const createBlock = useCreateBlock();
const updateBlock = useUpdateBlock();
const [systemInstruction] = useAtom(RenderPromptAtom);
async function handleRender() {
const canvas = document.createElement("canvas");
canvas.width = block.width;
canvas.height = block.height;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "black";
ctx.fillRect(0, 0, block.width, block.height);
const imageBlocks = blockIds
.map((id) => blockMap[id])
.filter((block) => block.type === "image");
// sort by zIndex
imageBlocks.sort((a, b) => a.zIndex - b.zIndex);
let imageActive = false;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const imageBlock of imageBlocks) {
if (
imageBlock.x + imageBlock.width < block.x ||
imageBlock.x > block.x + block.width
)
continue;
if (
imageBlock.y + imageBlock.height < block.y ||
imageBlock.y > block.y + block.height
)
continue;
imageActive = true;
minX = Math.min(minX, imageBlock.x - block.x);
minY = Math.min(minY, imageBlock.y - block.y);
maxX = Math.max(maxX, imageBlock.x - block.x + imageBlock.width);
maxY = Math.max(maxY, imageBlock.y - block.y + imageBlock.height);
const image = await loadImage(imageBlock.src);
ctx.drawImage(
image,
imageBlock.x - block.x,
imageBlock.y - block.y,
imageBlock.width,
imageBlock.height,
);
}
let image = canvas.toDataURL();
let croppedImage = null;
let blurredImage = null;
if (imageActive) {
croppedImage = document.createElement("canvas");
croppedImage.width = maxX - minX;
croppedImage.height = maxY - minY;
const croppedCtx = croppedImage.getContext("2d")!;
croppedCtx.drawImage(
canvas,
minX,
minY,
maxX - minX,
maxY - minY,
0,
0,
maxX - minX,
maxY - minY,
);
image = croppedImage.toDataURL();
const blurredCanvas = document.createElement("canvas");
blurredCanvas.width = croppedImage.width;
blurredCanvas.height = croppedImage.height;
const blurredCtx = blurredCanvas.getContext("2d")!;
blurredCtx.filter = "blur(16px)";
blurredCtx.drawImage(croppedImage, 0, 0);
blurredImage = blurredCanvas.toDataURL();
}
// update to use for labels
// function textWrap(text: string, maxWidth: number) {
// const words = text.split(" ");
// const lines: string[] = [];
// let line = "";
// for (const word of words) {
// const testLine = line + word + " ";
// const testWidth = ctx.measureText(testLine).width;
// if (testWidth > maxWidth) {
// lines.push(line);
// line = word + " ";
// } else {
// line = testLine;
// }
// }
// lines.push(line);
// return lines;
// }
// const promptBlocks = blockIds
// .map((id) => blockMap[id])
// .filter((block) => block.type === "prompt");
// for (const promptBlock of promptBlocks) {
// if (
// promptBlock.x + promptBlock.width < block.x ||
// promptBlock.x > block.x + block.width
// )
// continue;
// if (
// promptBlock.y + promptBlock.height < block.y ||
// promptBlock.y > block.y + block.height
// )
// continue;
// ctx.font = "16px sans-serif";
// ctx.fillStyle = "white";
// const lines = textWrap(promptBlock.text, promptBlock.width);
// for (let i = 0; i < lines.length; i++) {
// ctx.fillText(
// lines[i],
// promptBlock.x - block.x,
// promptBlock.y - block.y + 16 * (i + 1),
// );
// }
// }
let activePrompts = [];
const promptBlocks = blockIds
.map((id) => blockMap[id])
.filter((block) => block.type === "prompt");
for (const promptBlock of promptBlocks) {
if (
promptBlock.x + promptBlock.width < block.x ||
promptBlock.x > block.x + block.width
)
continue;
if (
promptBlock.y + promptBlock.height < block.y ||
promptBlock.y > block.y + block.height
)
continue;
activePrompts.push(promptBlock.text);
}
const id = uuid();
// visualize debug
createBlock({
id,
type: "image",
src: blurredImage || image,
x: block.x + block.width + 16,
y: block.y,
width: croppedImage ? croppedImage.width : block.width,
height: croppedImage ? croppedImage.height : block.height,
zIndex: makeZIndex(),
});
// let contents: GenerateContentRequest = {
// contents: [
// {
// parts: [],
// role: "user",
// },
// ],
// };
//
// if (imageActive) {
// contents.contents[0].parts.push({
// inlineData: {
// mimeType: "image/jpeg",
// data: croppedImage!.toDataURL().split(",")[1],
// },
// });
// }
//
// contents.contents[0].parts.push({
// text: "Instructions: " + systemInstruction,
// });
//
// if (activePrompts.length > 0) {
// for (const prompt of activePrompts) {
// contents.contents[0].parts.push({
// text: prompt,
// });
// }
// }
//
// const responseParts = await predict({
// contents,
// });
//
// let lastImagePart = null;
//
// for (const part of responseParts) {
// if (part.inlineData) {
// lastImagePart = part;
// }
// }
//
// if (lastImagePart) {
// const generatedImageSrc =
// "data:" +
// lastImagePart.inlineData.mimeType +
// ";base64," +
// lastImagePart.inlineData.data;
// const generatedImage = await loadImage(generatedImageSrc);
// const newCanvas = document.createElement("canvas");
// const scale = Math.min(
// maxSize / generatedImage.width,
// maxSize / generatedImage.height,
// );
// newCanvas.width = generatedImage.width * scale;
// newCanvas.height = generatedImage.height * scale;
// const newCtx = newCanvas.getContext("2d")!;
// newCtx.drawImage(
// generatedImage,
// 0,
// 0,
// generatedImage.width,
// generatedImage.height,
// 0,
// 0,
// newCanvas.width,
// newCanvas.height,
// );
// updateBlock(id, {
// type: "image",
// src: newCanvas.toDataURL(),
// width: newCanvas.width,
// height: newCanvas.height,
// zIndex: makeZIndex(),
// });
// }
//
// setLoadingMessage(null);
}
return (
<div
className="border-2 border-red-500 absolute"
style={{
width: "100%",
height: "100%",
}}
>
<button
className="absolute pointer-events-auto -bottom-9 right-0 px-3 py-1 text-sm rounded-lg bg-red-500 hover:bg-red-600 text-white"
onClick={handleRender}
>
Render
</button>
{sides.map((side) => (
<div
key={side}
className="absolute cursor-move pointer-events-auto"
style={{
[side]: -8,
width: side === "top" || side === "bottom" ? "100%" : 16,
height: side === "top" || side === "bottom" ? 16 : "100%",
}}
/>
))}
</div>
);
}
import { useAtom } from "jotai";
import {
BlockIdsAtom,
BlockMapAtom,
CameraAtom,
RenderCreatorAtom,
ModeAtom,
ZoomContainerAtom,
} from "./atoms";
import { useEffect, useRef } from "react";
import { screenToCanvas } from "./Camera";
import { useCreateBlock } from "./hooks";
import { v4 as uuid } from "uuid";
import { makeZIndex } from "./utils";
export function RenderCreator() {
const [mode, setMode] = useAtom(ModeAtom);
const [renderCreator, setRenderCreator] = useAtom(RenderCreatorAtom);
const createBlock = useCreateBlock();
const [camera] = useAtom(CameraAtom);
const [zoomContainer] = useAtom(ZoomContainerAtom);
const cameraRef = useRef(camera);
cameraRef.current = camera;
const zoomContainerRef = useRef<HTMLDivElement | null>(null);
zoomContainerRef.current = zoomContainer;
const pointRef = useRef<{ x: number; y: number } | null>(null);
const [blockIds] = useAtom(BlockIdsAtom);
const [blockMap] = useAtom(BlockMapAtom);
const blocks = blockIds.map((id) => blockMap[id]);
const blocksRef = useRef(blocks);
blocksRef.current = blocks;
useEffect(() => {
if (mode !== "render") return;
function handlePointerDown(e: PointerEvent) {
(e.target as Element).setPointerCapture(e.pointerId);
const point = screenToCanvas(
{ x: e.clientX, y: e.clientY },
cameraRef.current,
zoomContainerRef.current!,
);
const overBlocks = blocksRef.current
.filter((block) => block.type === "render")
.filter((block) => {
return (
point.x > block.x &&
point.x < block.x + block.width &&
point.y > block.y &&
point.y < block.y + block.height
);
})
.sort((a, b) => b.zIndex - a.zIndex);
if (overBlocks.length === 0) {
pointRef.current = { x: point.x, y: point.y };
}
}
function handlePointerMove(e: PointerEvent) {
if (e.buttons !== 1) return;
if (!pointRef.current) return;
const canvasPoint = screenToCanvas(
{ x: e.clientX, y: e.clientY },
cameraRef.current,
zoomContainerRef.current!,
);
const x = Math.min(canvasPoint.x, pointRef.current.x);
const y = Math.min(canvasPoint.y, pointRef.current.y);
const width = Math.abs(canvasPoint.x - pointRef.current.x);
const height = Math.abs(canvasPoint.y - pointRef.current.y);
setRenderCreator({ x, y, width, height });
}
function handlePointerUp(e: PointerEvent) {
(e.target as Element).releasePointerCapture(e.pointerId);
if (!pointRef.current) return;
const canvasPoint = screenToCanvas(
{ x: e.clientX, y: e.clientY },
cameraRef.current,
zoomContainerRef.current!,
);
const x = Math.min(canvasPoint.x, pointRef.current.x);
const y = Math.min(canvasPoint.y, pointRef.current.y);
const width = Math.max(Math.abs(canvasPoint.x - pointRef.current.x), 96);
const height = Math.max(Math.abs(canvasPoint.y - pointRef.current.y), 48);
pointRef.current = null;
createBlock({
id: uuid(),
type: "render",
x,
y,
width,
height,
prompt: "make image",
zIndex: makeZIndex(),
});
setRenderCreator(null);
setMode("move");
}
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}, [mode, zoomContainer]);
return renderCreator ? (
<div
className="border-2 border-blue-500"
style={{
position: "absolute",
left: renderCreator.x,
top: renderCreator.y,
width: renderCreator.width,
height: renderCreator.height,
}}
/>
) : null;
}
import { useAtom } from "jotai";
import { useRef, useEffect } from "react";
import { ModeAtom } from "./atoms";
import { ModeType } from "./types";
export function Toolbar() {
const [mode, setMode] = useAtom(ModeAtom);
const lastModeRef = useRef<ModeType>(mode);
useEffect(() => {
function handleModifierDown(event: KeyboardEvent) {
if (event.key === "Shift") {
lastModeRef.current = mode;
setMode("segment");
}
}
function handleModifierUp(event: KeyboardEvent) {
if (event.key === "Shift") {
setMode(lastModeRef.current);
}
}
window.addEventListener("keydown", handleModifierDown);
window.addEventListener("keyup", handleModifierUp);
return () => {
window.removeEventListener("keydown", handleModifierDown);
window.removeEventListener("keyup", handleModifierUp);
};
}, [mode, setMode]);
return (
<>
<div className="absolute left-0 top-0 pointer-events-none">
<div className="px-3 py-3">Blocks Lite</div>
</div>
<div className="flex gap-2 absolute bottom-4 left-1/2 -translate-x-1/2">
<button
className={`${mode === "move" ? "bg-neutral-800" : "bg-neutral-700"} px-3 py-1 rounded-lg`}
onPointerDown={(e) => {
e.stopPropagation();
}}
onClick={() => {
setMode("move");
}}
>
Move
</button>
<button
className={`${mode === "prompt" ? "bg-neutral-800" : "bg-neutral-700"} px-3 py-1 rounded-lg`}
onPointerDown={(e) => {
e.stopPropagation();
}}
onClick={() => {
setMode("prompt");
}}
>
Prompt
</button>
<button
className={`${mode === "segment" ? "bg-neutral-800" : "bg-neutral-700"} px-3 py-1 rounded-lg`}
onPointerDown={(e) => {
e.stopPropagation();
}}
onClick={() => {
setMode("segment");
}}
>
Segment
</button>
</div>
</>
);
}
{
"compilerOptions": {
"jsx": "react-jsx",
"skipLibCheck": true,
"module": "esnext",
"target": "es2024",
"moduleResolution": "bundler",
"typeRoots": [
"./node_modules/@types"
],
"lib": [
"DOM",
"ESNext"
]
}
}
import { useDrag } from "@use-gesture/react";
import { useAtom } from "jotai";
import {
BlockIdsAtom,
BlockMapAtom,
BlockSelectorAtom,
ModeAtom,
SelectedBlockIdsAtom,
StateRefAtom,
} from "./atoms";
import { useRef } from "react";
import { screenToCanvas } from "./Camera";
import {
blockIntersectBlocks,
loadImage,
makeZIndex,
pointIntersectBlocks,
} from "./utils";
import { BlockType, ImageBlockType, PointType } from "./types";
import { v4 as uuid } from "uuid";
import { FilesetResolver, InteractiveSegmenter } from "@mediapipe/tasks-vision";
let interactiveSegmenter: InteractiveSegmenter;
const createSegmenter = async () => {
// Move to CDN
const filesetResolver = await FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/[email protected]/wasm",
);
interactiveSegmenter = await InteractiveSegmenter.createFromOptions(
filesetResolver,
{
baseOptions: {
modelAssetPath: `https://storage.googleapis.com/mediapipe-models/interactive_segmenter/magic_touch/float32/1/magic_touch.tflite`,
delegate: "GPU",
},
outputCategoryMask: true,
outputConfidenceMasks: false,
},
);
};
createSegmenter();
export function useDragAndSelect() {
const [, setBlockSelector] = useAtom(BlockSelectorAtom);
const [stateRef] = useAtom(StateRefAtom);
const [, setSelectedBlockIds] = useAtom(SelectedBlockIdsAtom);
const [, setBlockMap] = useAtom(BlockMapAtom);
const [, setBlockIds] = useAtom(BlockIdsAtom);
const [, setMode] = useAtom(ModeAtom);
const initialPositionsRef = useRef<PointType[]>([]);
const initialPointRef = useRef({ x: 0, y: 0 });
return useDrag(async ({ first, last, xy: [x, y] }) => {
const canvasPoint = screenToCanvas(
{ x, y },
stateRef.camera,
stateRef.zoomContainer!,
);
const currentBlocks = stateRef.blockIds.map((id) => stateRef.blockMap[id]);
if (first) {
initialPointRef.current = { x: canvasPoint.x, y: canvasPoint.y };
}
switch (stateRef.mode) {
case "move": {
if (first) {
const intersected = pointIntersectBlocks(canvasPoint, currentBlocks);
if (intersected.length > 0) {
let promptBlocks = intersected.filter(
(block) => block.type === "prompt",
);
promptBlocks = promptBlocks.sort((a, b) => b.zIndex - a.zIndex);
let imageBlocks = intersected.filter(
(block) => block.type === "image",
);
imageBlocks = imageBlocks.sort((a, b) => b.zIndex - a.zIndex);
const sortedBlocks = [...promptBlocks, ...imageBlocks];
const topBlock = sortedBlocks[0].id;
if (stateRef.selectedBlockIds.includes(topBlock)) {
// keep selection
} else {
stateRef.selectedBlockIds = [topBlock];
setSelectedBlockIds([topBlock]);
}
initialPositionsRef.current = stateRef.selectedBlockIds.map(
(id) => {
const block = stateRef.blockMap[id];
return {
x: block.x,
y: block.y,
};
},
);
} else {
stateRef.selectedBlockIds = [];
setSelectedBlockIds([]);
}
}
if (stateRef.selectedBlockIds.length === 0) {
const minX = Math.min(canvasPoint.x, initialPointRef.current.x);
const minY = Math.min(canvasPoint.y, initialPointRef.current.y);
const maxX = Math.max(canvasPoint.x, initialPointRef.current.x);
const maxY = Math.max(canvasPoint.y, initialPointRef.current.y);
const blockSelector = {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
stateRef.blockSelector = blockSelector;
setBlockSelector(blockSelector);
} else {
// moveBlocks
const selectedBlocks = stateRef.selectedBlockIds.map(
(id) => stateRef.blockMap[id],
);
let newBlockObj: Record<string, BlockType> = {};
for (let i = 0; i < selectedBlocks.length; i++) {
const block = selectedBlocks[i];
newBlockObj[block.id] = {
...block,
x:
initialPositionsRef.current[i].x +
(canvasPoint.x - initialPointRef.current.x),
y:
initialPositionsRef.current[i].y +
(canvasPoint.y - initialPointRef.current.y),
zIndex: makeZIndex() + 1,
} as BlockType;
}
setBlockMap((prev) => {
return { ...prev, ...newBlockObj };
});
}
if (last) {
if (stateRef.blockSelector) {
const _selectedBlocks = blockIntersectBlocks(
stateRef.blockSelector as BlockType,
currentBlocks,
);
let promptBlocks = _selectedBlocks.filter(
(block) => block.type === "prompt",
);
promptBlocks = promptBlocks.sort((a, b) => b.zIndex - a.zIndex);
let imageBlocks = _selectedBlocks.filter(
(block) => block.type === "image",
);
imageBlocks = imageBlocks.sort((a, b) => b.zIndex - a.zIndex);
const sortedBlocks = [...promptBlocks, ...imageBlocks];
const sortedBlocksIds = sortedBlocks.map((block) => block.id);
stateRef.selectedBlockIds = sortedBlocksIds;
setSelectedBlockIds(sortedBlocksIds);
stateRef.blockSelector = null;
setBlockSelector(null);
}
}
break;
}
case "segment": {
if (first) {
const intersected = pointIntersectBlocks(canvasPoint, currentBlocks);
let intersectedImages = intersected.filter(
(block) => block.type === "image",
);
if (intersectedImages.length > 0) {
intersectedImages = intersectedImages.sort(
(a, b) => b.zIndex - a.zIndex,
);
const topBlock = intersectedImages[0];
const imageCanvas = document.createElement("canvas");
imageCanvas.width = topBlock.width;
imageCanvas.height = topBlock.height;
const image = await loadImage(topBlock.src);
const ctx = imageCanvas.getContext("2d")!;
ctx.drawImage(image, 0, 0, imageCanvas.width, imageCanvas.height);
const imageData = ctx.getImageData(
0,
0,
imageCanvas.width,
imageCanvas.height,
);
interactiveSegmenter.segment(
imageCanvas,
{
keypoint: {
x: (canvasPoint.x - topBlock.x) / topBlock.width,
y: (canvasPoint.y - topBlock.y) / topBlock.height,
},
},
(result) => {
const mask = result.categoryMask;
if (!mask) return;
const resultCanvas = document.createElement("canvas");
resultCanvas.width = topBlock.width;
resultCanvas.height = topBlock.height;
const resultCtx = resultCanvas.getContext("2d")!;
resultCtx.drawImage(
imageCanvas,
0,
0,
topBlock.width,
topBlock.height,
);
const tempData = resultCtx.getImageData(
0,
0,
resultCanvas.width,
resultCanvas.height,
);
let minX = resultCanvas.width;
let minY = resultCanvas.height;
let maxX = 0;
let maxY = 0;
const maskData = mask.getAsFloat32Array();
for (let i = 0; i < tempData.data.length; i += 4) {
if (Math.round(maskData[i / 4] * 255.0) === 0) {
const x = (i / 4) % resultCanvas.width;
const y = Math.floor(i / 4 / resultCanvas.width);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
tempData.data[i] = imageData.data[i];
tempData.data[i + 1] = imageData.data[i + 1];
tempData.data[i + 2] = imageData.data[i + 2];
} else {
tempData.data[i + 3] = 0;
}
}
resultCtx.putImageData(tempData, 0, 0);
const finalCanvas = document.createElement("canvas");
finalCanvas.width = maxX - minX;
finalCanvas.height = maxY - minY;
const finalCtx = finalCanvas.getContext("2d")!;
finalCtx.drawImage(
resultCanvas,
minX,
minY,
finalCanvas.width,
finalCanvas.height,
0,
0,
finalCanvas.width,
finalCanvas.height,
);
const finalSrc = finalCanvas.toDataURL();
const newId = uuid();
const newBlock = {
id: newId,
type: "image",
src: finalSrc,
x: topBlock.x + minX,
y: topBlock.y + minY,
width: maxX - minX,
height: maxY - minY,
zIndex: makeZIndex(),
} as ImageBlockType;
const newMap = { ...stateRef.blockMap, [newId]: newBlock };
const newIds = [...stateRef.blockIds, newId];
stateRef.blockMap = newMap;
setBlockMap(newMap);
stateRef.blockIds = newIds;
setBlockIds(newIds);
stateRef.selectedBlockIds = [newId];
setSelectedBlockIds([newId]);
initialPositionsRef.current = [
{
x: topBlock.x + minX,
y: topBlock.y + minY,
},
];
stateRef.mode = "move";
setMode("move");
},
);
}
}
break;
}
default: {
break;
}
}
});
}
import { BlockType, PointType } from "./types";
// load image as promise
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
export function makeZIndex() {
return Math.round((Date.now() - 1729536285367) / 100);
}
export function pointIntersectBlocks({ x, y }: PointType, blocks: BlockType[]) {
return blocks.filter((block) => {
return (
x >= block.x &&
x <= block.x + block.width &&
y >= block.y &&
y <= block.y + block.height
);
});
}
// TODO make this work
export function blockIntersectBlocks(
{ x, y, width, height }: BlockType,
blocks: BlockType[],
) {
return blocks.filter((block) => {
return (
x < block.x + block.width &&
x + width > block.x &&
y < block.y + block.height &&
y + height > block.y
);
});
}
/// <reference types="vite/client" />
Display the source blob
Display the rendered blob
Raw
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
import { useAtom } from "jotai";
import { useEffect } from "react";
import { BlockSelectorAtom, CameraAtom, ZoomContainerAtom } from "./atoms";
import { Blocks } from "./Blocks";
import { useHandleDropImage, useHandlePasteImage } from "./hooks";
import { RenderCreator } from "./RenderCreator";
import { PromptCreator } from "./PromptCreator";
import { panCamera, zoomCamera } from "./Camera";
import { useDragAndSelect } from "./useDragAndSelect";
export function Zoom() {
const [camera, setCamera] = useAtom(CameraAtom);
const [zoomContainer, setZoomContainer] = useAtom(ZoomContainerAtom);
useHandleDropImage();
useHandlePasteImage();
const useDragBind = useDragAndSelect();
useEffect(() => {
function handleWheel(event: WheelEvent) {
if (zoomContainer) {
event.preventDefault();
const { clientX: x, clientY: y, deltaX, deltaY, ctrlKey } = event;
if (ctrlKey) {
setCamera((camera) =>
zoomCamera(camera, { x, y }, deltaY / 400, zoomContainer),
);
} else {
if (event.shiftKey) {
setCamera((camera) => panCamera(camera, deltaY, 0));
} else {
setCamera((camera) => panCamera(camera, deltaX, deltaY));
}
}
}
}
window.addEventListener("wheel", handleWheel, { passive: false });
return () => window.removeEventListener("wheel", handleWheel);
}, [zoomContainer, setCamera]);
return (
<div
{...useDragBind()}
className="absolute inset-0 touch-none"
ref={(div) => {
if (div) {
setZoomContainer(div);
}
}}
>
<div
style={{
position: "absolute",
left: "50%",
top: "50%",
width: "100%",
height: "100%",
transformOrigin: "0 0",
transform: `scale(${camera.z}) translate(-50%, -50%) translate(${camera.x}px, ${camera.y}px)`,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div className="relative pointer-events-none">
<Blocks />
<RenderCreator />
<PromptCreator />
<BlockSelector />
</div>
</div>
</div>
);
}
function BlockSelector() {
const [blockSelector] = useAtom(BlockSelectorAtom);
return (
blockSelector ? <div
className="absolute pointer-events-none border-[2px] border-blue-500"
style={{
left: blockSelector.x,
top: blockSelector.y,
width: blockSelector.width,
height: blockSelector.height,
}}
/> : null
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment