add app
This commit is contained in:
7
app/src/App.css
Normal file
7
app/src/App.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.logo.vite:hover {
|
||||
filter: drop-shadow(0 0 2em #747bff);
|
||||
}
|
||||
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafb);
|
||||
}
|
||||
39
app/src/App.tsx
Normal file
39
app/src/App.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import "./App.css";
|
||||
import Timeline from "./components/Timeline";
|
||||
import Canvas from "./components/Canvas";
|
||||
import Properties, { PropertiesContainer } from "components/Properties";
|
||||
import MenuBar from "components/MenuBar";
|
||||
import ToolBar from "components/ToolBar";
|
||||
import { useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import { useFontsStore } from "stores/fonts.store";
|
||||
|
||||
export default function App() {
|
||||
const { setFonts } = useFontsStore();
|
||||
|
||||
useEffect(() => {
|
||||
invoke("get_system_fonts").then((data) => {
|
||||
if (data && Array.isArray(data)) {
|
||||
setFonts(data);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-950 h-full w-full flex flex-col">
|
||||
<MenuBar />
|
||||
<div className="flex flex-row w-full h-full">
|
||||
<ToolBar />
|
||||
<div className="flex flex-col ml-4 w-full h-full">
|
||||
<div className="flex gap-4 flex-row mb-4 justify-center items-center">
|
||||
<Canvas />
|
||||
<PropertiesContainer>
|
||||
<Properties />
|
||||
</PropertiesContainer>
|
||||
</div>
|
||||
<Timeline />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
app/src/Old.tsx
Normal file
80
app/src/Old.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "./App.css";
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
import { useTimelineStore } from "./stores/timeline.store";
|
||||
import Timeline, { AnimationEntity } from "./components/Timeline";
|
||||
import InitCanvasKit from "canvaskit-wasm";
|
||||
import CanvasKitWasm from "canvaskit-wasm/bin/canvaskit.wasm?url";
|
||||
|
||||
const WIDTH = 1280 / 2;
|
||||
const HEIGHT = 720 / 2;
|
||||
|
||||
const ENTITIES: Array<AnimationEntity> = [
|
||||
{
|
||||
offset: 0.2,
|
||||
duration: 1.0,
|
||||
},
|
||||
];
|
||||
|
||||
function App() {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const [title, setTitle] = useState("Kleine");
|
||||
const [subTitle, setSubTitle] = useState("Dumpfkopf");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { currentFrame } = useTimelineStore();
|
||||
|
||||
useEffect(() => {
|
||||
console.time("render");
|
||||
invoke("render_timeline_frame_cpu", {
|
||||
currFrame: currentFrame,
|
||||
title,
|
||||
subTitle,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
}).then((data) => {
|
||||
console.timeEnd("render");
|
||||
if (canvas.current) {
|
||||
const canvasElement = canvas.current;
|
||||
|
||||
canvasElement.width = WIDTH;
|
||||
canvasElement.height = HEIGHT;
|
||||
// console.time("draw");
|
||||
const img = document.createElement("img");
|
||||
|
||||
const ctx = canvasElement.getContext("2d");
|
||||
|
||||
const arr = new Uint8ClampedArray(data as any);
|
||||
|
||||
const image = new Blob([arr], { type: "image/webp" });
|
||||
img.src = URL.createObjectURL(image);
|
||||
|
||||
if (ctx) {
|
||||
// ctx.fillStyle = "red";
|
||||
// ctx.fillRect(0, 0, 1920, 1080);
|
||||
}
|
||||
|
||||
img.onload = () => {
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
// console.timeEnd("draw");
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}, [currentFrame, title, subTitle]);
|
||||
|
||||
if (loading) return;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div style={{ width: "600px" }}>
|
||||
<canvas style={{ width: "100%", height: "100%" }} ref={canvas}></canvas>
|
||||
</div>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<input value={subTitle} onChange={(e) => setSubTitle(e.target.value)} />
|
||||
<Timeline entities={ENTITIES} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
app/src/assets/react.svg
Normal file
1
app/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
125
app/src/components/Canvas/index.tsx
Normal file
125
app/src/components/Canvas/index.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { FC } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
import { useTimelineStore } from "stores/timeline.store";
|
||||
import InitCanvasKit, { CanvasKit } from "canvaskit-wasm";
|
||||
import { Surface } from "canvaskit-wasm";
|
||||
import drawText from "drawers/text";
|
||||
import drawBox from "drawers/box";
|
||||
import { Entities, EntityType } from "primitives/Entities";
|
||||
import drawEllipse from "drawers/ellipse";
|
||||
import { useRenderStateStore } from "stores/render-state.store";
|
||||
import { useEntitiesStore } from "stores/entities.store";
|
||||
import { AnimatedEntities } from "primitives/AnimatedEntities";
|
||||
|
||||
type CanvasProps = {};
|
||||
|
||||
const CanvasComponent: FC<CanvasProps> = () => {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [canvasKit, setCanvasKit] = useState<CanvasKit>();
|
||||
const [fontData, setFontData] = useState<ArrayBuffer>();
|
||||
const surface = useRef<Surface>();
|
||||
|
||||
const renderState = useRenderStateStore((store) => store.renderState);
|
||||
const { fps, size, duration } = useTimelineStore((store) => ({
|
||||
fps: store.fps,
|
||||
size: store.size,
|
||||
duration: store.duration,
|
||||
}));
|
||||
const { entities } = useEntitiesStore((store) => ({
|
||||
entities: store.entities,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
InitCanvasKit({
|
||||
locateFile: (file) =>
|
||||
"https://unpkg.com/canvaskit-wasm@latest/bin/" + file,
|
||||
}).then((CanvasKit) => {
|
||||
setLoading(false);
|
||||
setCanvasKit(CanvasKit);
|
||||
|
||||
fetch("https://storage.googleapis.com/skia-cdn/misc/Roboto-Regular.ttf")
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((arrayBuffer) => {
|
||||
setFontData(arrayBuffer);
|
||||
});
|
||||
|
||||
if (canvas.current) {
|
||||
const CSurface = CanvasKit.MakeWebGLCanvasSurface(canvas.current);
|
||||
if (CSurface) {
|
||||
surface.current = CSurface;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// console.time("calculation");
|
||||
const parsedEntities = AnimatedEntities.parse(entities);
|
||||
|
||||
invoke("calculate_timeline_entities_at_frame", {
|
||||
timeline: {
|
||||
entities: parsedEntities,
|
||||
render_state: renderState,
|
||||
fps,
|
||||
size,
|
||||
duration,
|
||||
},
|
||||
}).then((data) => {
|
||||
// console.timeEnd("calculation");
|
||||
// console.log(data);
|
||||
|
||||
const entitiesResult = Entities.safeParse(data);
|
||||
//console.time("draw");
|
||||
|
||||
if (canvasKit && canvas.current && surface.current && fontData) {
|
||||
surface.current.flush();
|
||||
surface.current.requestAnimationFrame((skCanvas) => {
|
||||
skCanvas.clear(canvasKit.WHITE);
|
||||
if (entitiesResult.success) {
|
||||
const entities = entitiesResult.data;
|
||||
|
||||
entities.reverse().forEach((entity) => {
|
||||
switch (entity.type) {
|
||||
case EntityType.Enum.Box:
|
||||
drawBox(canvasKit, skCanvas, entity);
|
||||
break;
|
||||
case EntityType.Enum.Ellipse:
|
||||
drawEllipse(canvasKit, skCanvas, entity);
|
||||
break;
|
||||
case EntityType.Enum.Text:
|
||||
drawText(canvasKit, skCanvas, entity, fontData);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(entitiesResult.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
// console.timeEnd("draw");
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ width: "100%", height: "500px" }}
|
||||
>
|
||||
<canvas
|
||||
className="aspect-video h-full"
|
||||
height={720}
|
||||
width={1280}
|
||||
ref={canvas}
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanvasComponent;
|
||||
9
app/src/components/Loading.tsx
Normal file
9
app/src/components/Loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2>Lädt Skia...</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
129
app/src/components/MenuBar/index.tsx
Normal file
129
app/src/components/MenuBar/index.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { FC } from "react";
|
||||
import * as Menubar from "@radix-ui/react-menubar";
|
||||
import { ChevronRightIcon } from "@radix-ui/react-icons";
|
||||
import { open, save } from "@tauri-apps/api/dialog";
|
||||
|
||||
const MenuBarTrigger: FC<{ label: string }> = ({ label }) => {
|
||||
return (
|
||||
<Menubar.Trigger className="py-2 dark:text-gray-300 px-3 transition-colors hover:bg-indigo-700 outline-none select-none font-medium leading-none rounded text-[13px] flex items-center justify-between gap-[2px]">
|
||||
{label}
|
||||
</Menubar.Trigger>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuBarSubTrigger: FC<{ label: string }> = ({ label }) => {
|
||||
return (
|
||||
<Menubar.SubTrigger
|
||||
className="group dark:text-gray-300 text-[13px] hover:bg-indigo-800 transition-colors leading-none
|
||||
text-indigo11 rounded flex items-center h-[25px] px-[10px] relative select-none outline-none
|
||||
data-[state=open]:bg-indigo data-[state=open]:text-white data-[highlighted]:bg-gradient-to-br
|
||||
data-[highlighted]:from-indigo9 data-[highlighted]:to-indigo10 data-[highlighted]:text-indigo1
|
||||
data-[highlighted]:data-[state=open]:text-indigo1 data-[disabled]:text-mauve8 data-[disabled]:pointer-events-none"
|
||||
>
|
||||
{label}
|
||||
<div className="ml-auto pl-5 text-mauve9 group-data-[highlighted]:text-white group-data-[disabled]:text-mauve8">
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
</Menubar.SubTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuBarItem: FC<{ label: string; onClick?: () => void }> = ({
|
||||
label,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<Menubar.Item
|
||||
onClick={onClick}
|
||||
className="group dark:text-white text-[13px] leading-none
|
||||
rounded flex items-center h-[25px] px-[10px]
|
||||
relative select-none outline-none hover:bg-indigo-800
|
||||
data-[disabled]:pointer-events-none transition-colors"
|
||||
>
|
||||
{label}
|
||||
</Menubar.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuBarSeperator = () => {
|
||||
return <Menubar.Separator className="h-[1px] bg-slate-500 m-[5px]" />;
|
||||
};
|
||||
|
||||
const MenuBar = () => {
|
||||
const menuBarContentClassName =
|
||||
"min-w-[220px] bg-gray-800 rounded-md p-[5px]";
|
||||
|
||||
const menuBarSubContentClassName =
|
||||
"min-w-[220px] bg-gray-800 rounded-md p-[5px]";
|
||||
|
||||
return (
|
||||
<Menubar.Root className="flex bg-gray-900 p-[3px] ">
|
||||
<Menubar.Menu>
|
||||
<MenuBarTrigger label="File" />
|
||||
<Menubar.Portal>
|
||||
<Menubar.Content
|
||||
className={menuBarContentClassName}
|
||||
align="start"
|
||||
sideOffset={5}
|
||||
alignOffset={-3}
|
||||
>
|
||||
<MenuBarItem label="New File" />
|
||||
<MenuBarItem
|
||||
onClick={() => open({ multiple: false })}
|
||||
label="Open File"
|
||||
/>
|
||||
<MenuBarItem
|
||||
onClick={() =>
|
||||
save({
|
||||
title: "Save Project",
|
||||
defaultPath: "project.tbcp",
|
||||
}).then((val) => {
|
||||
console.log(val);
|
||||
})
|
||||
}
|
||||
label="Save"
|
||||
/>
|
||||
<MenuBarItem onClick={() => save()} label="Save as" />
|
||||
<MenuBarSeperator />
|
||||
<Menubar.Sub>
|
||||
<MenuBarSubTrigger label="Export as ..." />
|
||||
|
||||
<Menubar.Portal>
|
||||
<Menubar.SubContent
|
||||
className={menuBarSubContentClassName}
|
||||
alignOffset={-5}
|
||||
>
|
||||
<MenuBarItem label=".mp4" />
|
||||
<MenuBarItem label=".gif" />
|
||||
<MenuBarItem label=".mov" />
|
||||
<MenuBarItem label=".webm" />
|
||||
<MenuBarItem label=".webp" />
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Portal>
|
||||
</Menubar.Sub>
|
||||
</Menubar.Content>
|
||||
</Menubar.Portal>
|
||||
</Menubar.Menu>
|
||||
|
||||
<Menubar.Menu>
|
||||
<MenuBarTrigger label="Edit" />
|
||||
|
||||
<Menubar.Portal>
|
||||
<Menubar.Content
|
||||
className={menuBarContentClassName}
|
||||
align="start"
|
||||
sideOffset={5}
|
||||
alignOffset={-3}
|
||||
>
|
||||
<MenuBarItem label="Undo" />
|
||||
<MenuBarItem label="Redo" />
|
||||
<MenuBarItem label="Copy" />
|
||||
<MenuBarItem label="Paste" />
|
||||
</Menubar.Content>
|
||||
</Menubar.Portal>
|
||||
</Menubar.Menu>
|
||||
</Menubar.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuBar;
|
||||
148
app/src/components/Properties/Primitives.tsx
Normal file
148
app/src/components/Properties/Primitives.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { ease } from "@unom/style";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
AnimatedTextEntity,
|
||||
AnimatedBoxEntity,
|
||||
AnimatedEllipseEntity,
|
||||
} from "primitives/AnimatedEntities";
|
||||
import { Paint, PaintStyle, PaintStyleType } from "primitives/Paint";
|
||||
import { FC } from "react";
|
||||
import { z } from "zod";
|
||||
import { AnimatedVec2Properties } from "./Values";
|
||||
import { PropertiesProps } from "./common";
|
||||
|
||||
type TextPropertiesProps = PropertiesProps<z.input<typeof AnimatedTextEntity>>;
|
||||
type PaintPropertiesProps = PropertiesProps<z.input<typeof Paint>>;
|
||||
type BoxPropertiesProps = PropertiesProps<z.input<typeof AnimatedBoxEntity>>;
|
||||
type EllipsePropertiesProps = PropertiesProps<
|
||||
z.input<typeof AnimatedEllipseEntity>
|
||||
>;
|
||||
|
||||
export const PaintProperties: FC<PaintPropertiesProps> = ({
|
||||
entity,
|
||||
onUpdate,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<label className="flex flex-col items-start">
|
||||
<span className="label">PaintStyle</span>
|
||||
<select
|
||||
value={entity.style.type}
|
||||
onChange={(e) => {
|
||||
if (entity.style.type !== e.target.value) {
|
||||
const paintStyle = { type: e.target.value };
|
||||
|
||||
const parsedPaintStyle = PaintStyle.parse(paintStyle);
|
||||
|
||||
onUpdate({ style: parsedPaintStyle });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.keys(PaintStyleType.Values).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TextProperties: FC<TextPropertiesProps> = ({
|
||||
entity,
|
||||
onUpdate,
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
variants={{ enter: { opacity: 1, y: 0 }, from: { opacity: 0, y: 50 } }}
|
||||
animate="enter"
|
||||
initial="from"
|
||||
transition={ease.quint(0.9).out}
|
||||
>
|
||||
<label className="flex flex-col items-start">
|
||||
<span className="label">Text</span>
|
||||
<input
|
||||
value={entity.text}
|
||||
onChange={(e) => onUpdate({ ...entity, text: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col items-start">
|
||||
<span className="label">Size</span>
|
||||
<input
|
||||
value={entity.paint.size}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
...entity,
|
||||
paint: { ...entity.paint, size: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
></input>
|
||||
</label>
|
||||
<AnimatedVec2Properties
|
||||
onUpdate={(updatedEntity) =>
|
||||
onUpdate({ ...entity, origin: updatedEntity })
|
||||
}
|
||||
label="Origin"
|
||||
entity={entity.origin}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BoxProperties: FC<BoxPropertiesProps> = ({ entity, onUpdate }) => {
|
||||
return (
|
||||
<div className="dark:text-white">
|
||||
<PaintProperties
|
||||
entity={entity.paint}
|
||||
onUpdate={(paint) =>
|
||||
onUpdate({ ...entity, paint: { ...entity.paint, ...paint } })
|
||||
}
|
||||
/>
|
||||
<AnimatedVec2Properties
|
||||
onUpdate={(updatedEntity) =>
|
||||
onUpdate({ ...entity, position: updatedEntity })
|
||||
}
|
||||
label="Position"
|
||||
entity={entity.position}
|
||||
/>
|
||||
<AnimatedVec2Properties
|
||||
onUpdate={(updatedEntity) =>
|
||||
onUpdate({ ...entity, size: updatedEntity })
|
||||
}
|
||||
label="Size"
|
||||
entity={entity.size}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EllipseProperties: FC<EllipsePropertiesProps> = ({
|
||||
entity,
|
||||
onUpdate,
|
||||
}) => {
|
||||
return (
|
||||
<div className="dark:text-white">
|
||||
<PaintProperties
|
||||
entity={entity.paint}
|
||||
onUpdate={(paint) =>
|
||||
onUpdate({ ...entity, paint: { ...entity.paint, ...paint } })
|
||||
}
|
||||
/>
|
||||
<AnimatedVec2Properties
|
||||
onUpdate={(updatedEntity) =>
|
||||
onUpdate({ ...entity, position: updatedEntity })
|
||||
}
|
||||
label="Position"
|
||||
entity={entity.position}
|
||||
/>
|
||||
<AnimatedVec2Properties
|
||||
onUpdate={(updatedEntity) =>
|
||||
onUpdate({ ...entity, radius: updatedEntity })
|
||||
}
|
||||
label="Size"
|
||||
entity={entity.radius}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
176
app/src/components/Properties/Values.tsx
Normal file
176
app/src/components/Properties/Values.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { AnimatedNumber, AnimatedVec2 } from "primitives/Values";
|
||||
import { PropertiesProps } from "./common";
|
||||
import { FC } from "react";
|
||||
import { z } from "zod";
|
||||
import { produce } from "immer";
|
||||
import { Interpolation } from "primitives/Interpolation";
|
||||
import { Color } from "primitives/Paint";
|
||||
|
||||
const InterpolationProperties: FC<
|
||||
PropertiesProps<z.input<typeof Interpolation>>
|
||||
> = ({ entity, onUpdate }) => {
|
||||
return <div>Interpolation: {entity.type}</div>;
|
||||
};
|
||||
|
||||
const AnimatedNumberProperties: FC<
|
||||
PropertiesProps<z.input<typeof AnimatedNumber>> & { label: string }
|
||||
> = ({ entity, onUpdate, label }) => {
|
||||
return (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
{entity.keyframes.values.map((keyframe, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="flex flex-row gap-3">
|
||||
<label className="flex flex-col items-start w-16">
|
||||
<span className="label text-sm opacity-70">Offset</span>
|
||||
<input
|
||||
value={keyframe.offset}
|
||||
onChange={(e) =>
|
||||
onUpdate(
|
||||
produce(entity, (draft) => {
|
||||
draft.keyframes.values[index].offset = Number(
|
||||
e.target.value
|
||||
);
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col items-start">
|
||||
<span className="label text-sm opacity-70">Value</span>
|
||||
<input
|
||||
value={keyframe.value}
|
||||
onChange={(e) =>
|
||||
onUpdate(
|
||||
produce(entity, (draft) => {
|
||||
draft.keyframes.values[index].value = Number(
|
||||
e.target.value
|
||||
);
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{keyframe.interpolation && (
|
||||
<InterpolationProperties
|
||||
onUpdate={(updatedEntity) =>
|
||||
onUpdate(
|
||||
produce(entity, (draft) => {
|
||||
draft.keyframes.values[index].interpolation =
|
||||
updatedEntity;
|
||||
})
|
||||
)
|
||||
}
|
||||
entity={keyframe.interpolation}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ColorProperties: FC<
|
||||
PropertiesProps<z.input<typeof Color>> & {
|
||||
label: string;
|
||||
}
|
||||
> = ({ entity, onUpdate }) => {
|
||||
return (
|
||||
<label className="flex flex-col items-start">
|
||||
<span className="label">Color</span>
|
||||
<div className="flex flex-row gap-3">
|
||||
<input
|
||||
value={entity.value[0]}
|
||||
type="number"
|
||||
max={255}
|
||||
onChange={(e) =>
|
||||
onUpdate(
|
||||
produce(entity, (draft) => {
|
||||
draft.value[0] = Number(e.target.value);
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
<input
|
||||
value={entity.value[1]}
|
||||
type="number"
|
||||
max={255}
|
||||
onChange={(e) =>
|
||||
onUpdate(
|
||||
produce(entity, (draft) => {
|
||||
draft.value[1] = Number(e.target.value);
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
<input
|
||||
value={entity.value[2]}
|
||||
type="number"
|
||||
max={255}
|
||||
onChange={(e) =>
|
||||
onUpdate(
|
||||
produce(entity, (draft) => {
|
||||
draft.value[2] = Number(e.target.value);
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
<input
|
||||
value={entity.value[3]}
|
||||
type="number"
|
||||
max={1}
|
||||
onChange={(e) =>
|
||||
onUpdate(
|
||||
produce(entity, (draft) => {
|
||||
draft.value[3] = Number(e.target.value);
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnimatedVec2Properties: FC<
|
||||
PropertiesProps<z.input<typeof AnimatedVec2>> & { label: string }
|
||||
> = ({ entity, onUpdate, label }) => {
|
||||
return (
|
||||
<div>
|
||||
<label className="flex flex-col items-start">
|
||||
<span className="label">{label}</span>
|
||||
<AnimatedNumberProperties
|
||||
entity={entity.keyframes[0]}
|
||||
label="X"
|
||||
onUpdate={(updatedEntity) =>
|
||||
onUpdate(
|
||||
produce(entity, (draft) => {
|
||||
draft.keyframes[0] = {
|
||||
...draft.keyframes[0],
|
||||
...updatedEntity,
|
||||
};
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
<AnimatedNumberProperties
|
||||
entity={entity.keyframes[1]}
|
||||
label="Y"
|
||||
onUpdate={(updatedEntity) =>
|
||||
onUpdate(
|
||||
produce(entity, (draft) => {
|
||||
draft.keyframes[1] = {
|
||||
...draft.keyframes[1],
|
||||
...updatedEntity,
|
||||
};
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
app/src/components/Properties/common.tsx
Normal file
4
app/src/components/Properties/common.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export type PropertiesProps<E> = {
|
||||
entity: E;
|
||||
onUpdate: (entity: E) => void;
|
||||
};
|
||||
69
app/src/components/Properties/index.tsx
Normal file
69
app/src/components/Properties/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { FC, ReactNode } from "react";
|
||||
import { useEntitiesStore } from "stores/entities.store";
|
||||
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { BoxProperties, EllipseProperties, TextProperties } from "./Primitives";
|
||||
|
||||
const PropertiesContainer: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div className="w-full rounded-md h-[500px] overflow-auto border transition-colors focus-within:border-gray-400 border-gray-600 flex flex-col items-start p-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Properties = () => {
|
||||
const { selectedEntity, entities, updateEntity } = useEntitiesStore(
|
||||
(store) => ({
|
||||
updateEntity: store.updateEntity,
|
||||
selectedEntity: store.selectedEntity,
|
||||
entities: store.entities,
|
||||
}),
|
||||
shallow
|
||||
);
|
||||
|
||||
const entity = selectedEntity !== undefined && entities[selectedEntity];
|
||||
|
||||
if (entity) {
|
||||
switch (entity.type) {
|
||||
case "Text":
|
||||
return (
|
||||
<TextProperties
|
||||
key={selectedEntity}
|
||||
onUpdate={(entity) => updateEntity(selectedEntity, entity)}
|
||||
entity={entity}
|
||||
/>
|
||||
);
|
||||
|
||||
case "Box":
|
||||
return (
|
||||
<BoxProperties
|
||||
key={selectedEntity}
|
||||
onUpdate={(entity) => updateEntity(selectedEntity, entity)}
|
||||
entity={entity}
|
||||
/>
|
||||
);
|
||||
|
||||
case "Ellipse":
|
||||
return (
|
||||
<EllipseProperties
|
||||
key={selectedEntity}
|
||||
onUpdate={(entity) => updateEntity(selectedEntity, entity)}
|
||||
entity={entity}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Wähle ein Element aus</h3>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { PropertiesContainer };
|
||||
export default Properties;
|
||||
29
app/src/components/TimePicker.tsx
Normal file
29
app/src/components/TimePicker.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { FC } from "react";
|
||||
import * as Slider from "@radix-ui/react-slider";
|
||||
import { useRenderStateStore } from "stores/render-state.store";
|
||||
|
||||
export type TimePickerProps = {};
|
||||
|
||||
const TimePicker: FC<TimePickerProps> = () => {
|
||||
const { renderState, setCurrentFrame } = useRenderStateStore();
|
||||
|
||||
return (
|
||||
<Slider.Root
|
||||
className="relative flex select-none h-5 w-full items-center"
|
||||
defaultValue={[50]}
|
||||
style={{ width: 100 * 10 }}
|
||||
value={[renderState.curr_frame]}
|
||||
onValueChange={(val) => setCurrentFrame(val[0])}
|
||||
max={60 * 10}
|
||||
step={1}
|
||||
aria-label="Current Frame"
|
||||
>
|
||||
<Slider.Track className="SliderTrack">
|
||||
<Slider.Range className="SliderRange" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="SliderThumb" />
|
||||
</Slider.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimePicker;
|
||||
211
app/src/components/Timeline.tsx
Normal file
211
app/src/components/Timeline.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { FC } from "react";
|
||||
import { z } from "zod";
|
||||
import { AnimationData } from "primitives/AnimatedEntities";
|
||||
import { motion } from "framer-motion";
|
||||
import TimePicker from "./TimePicker";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { useEntitiesStore } from "stores/entities.store";
|
||||
import { ease } from "@unom/style";
|
||||
import Timestamp from "./Timestamp";
|
||||
import { Keyframe, Keyframes } from "primitives/Keyframe";
|
||||
import { flattenedKeyframesByEntity } from "utils";
|
||||
|
||||
export type AnimationEntity = {
|
||||
offset: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
type TimelineProps = {};
|
||||
|
||||
type TrackProps = {
|
||||
animationData: z.input<typeof AnimationData>;
|
||||
name: string;
|
||||
index: number;
|
||||
keyframes: Array<z.input<typeof Keyframe>>;
|
||||
};
|
||||
|
||||
const KeyframeIndicator: FC<{
|
||||
keyframe: z.input<typeof Keyframe>;
|
||||
animationData: z.input<typeof AnimationData>;
|
||||
}> = ({ keyframe, animationData }) => {
|
||||
return (
|
||||
<motion.div
|
||||
animate={{
|
||||
x: (animationData.offset + keyframe.offset) * 100 + 4,
|
||||
}}
|
||||
transition={ease.quint(0.4).out}
|
||||
style={{
|
||||
clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
|
||||
}}
|
||||
className="bg-indigo-300 absolute w-2 h-2 z-30 top-[39%]"
|
||||
></motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
|
||||
const { updateEntity, selectEntity, selectedEntity, deselectEntity } =
|
||||
useEntitiesStore(
|
||||
(store) => ({
|
||||
updateEntity: store.updateEntity,
|
||||
selectedEntity: store.selectedEntity,
|
||||
selectEntity: store.selectEntity,
|
||||
deselectEntity: store.deselectEntity,
|
||||
}),
|
||||
shallow
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-8 w-100 flex flex-row gap-1">
|
||||
<div
|
||||
onClick={() =>
|
||||
selectedEntity !== undefined && selectedEntity === index
|
||||
? deselectEntity()
|
||||
: selectEntity(index)
|
||||
}
|
||||
className={`h-full transition-all rounded-sm flex-shrink-0 w-96 p-1 px-2 flex flex-row ${
|
||||
selectedEntity === index ? "bg-gray-800" : "bg-gray-900"
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-white-800">{name}</h3>
|
||||
</div>
|
||||
<div
|
||||
style={{ width: "1000px" }}
|
||||
className="flex w-full h-full flex-row relative bg-gray-900"
|
||||
>
|
||||
{keyframes.map((keyframe, index) => (
|
||||
<KeyframeIndicator
|
||||
animationData={animationData}
|
||||
keyframe={keyframe}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
<motion.div
|
||||
drag="x"
|
||||
animate={{
|
||||
x: animationData.offset * 100,
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
}}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
}}
|
||||
transition={ease.circ(0.6).out}
|
||||
dragElastic={false}
|
||||
dragConstraints={{ left: 0, right: 900 }}
|
||||
onDragEnd={(e, info) => {
|
||||
let offset = info.offset.x;
|
||||
|
||||
offset *= 0.01;
|
||||
|
||||
const animationOffset =
|
||||
animationData.offset + offset < 0
|
||||
? 0
|
||||
: animationData.offset + offset;
|
||||
|
||||
const duration = animationData.duration - offset;
|
||||
|
||||
updateEntity(index, {
|
||||
animation_data: {
|
||||
...animationData,
|
||||
offset: animationOffset < 0 ? 0 : animationOffset,
|
||||
duration: duration < 0 ? 0 : duration,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="z-10 w-4 bg-slate-500 h-full absolute rounded-md"
|
||||
/>
|
||||
<motion.div
|
||||
drag="x"
|
||||
animate={{
|
||||
x: (animationData.duration + animationData.offset) * 100 - 16,
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
}}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
}}
|
||||
transition={ease.circ(0.6).out}
|
||||
dragConstraints={{ left: 0, right: 900 }}
|
||||
onDragEnd={(e, info) => {
|
||||
let offset = info.offset.x;
|
||||
|
||||
offset *= 0.01;
|
||||
|
||||
const duration = animationData.duration + offset;
|
||||
|
||||
updateEntity(index, {
|
||||
animation_data: {
|
||||
...animationData,
|
||||
duration: duration < 0 ? 0 : duration,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="z-10 w-4 bg-slate-500 h-full absolute rounded-md"
|
||||
/>
|
||||
<motion.div
|
||||
drag="x"
|
||||
animate={{
|
||||
width: animationData.duration * 100,
|
||||
x: animationData.offset * 100,
|
||||
}}
|
||||
whileHover={{ scaleY: 1.1 }}
|
||||
whileTap={{ scaleY: 0.9 }}
|
||||
dragConstraints={{
|
||||
left: 0,
|
||||
right: 900,
|
||||
}}
|
||||
transition={ease.circ(0.8).out}
|
||||
onDragEnd={(e, info) => {
|
||||
let offset = info.offset.x;
|
||||
|
||||
offset *= 0.01;
|
||||
|
||||
offset += animationData.offset;
|
||||
|
||||
console.log(offset);
|
||||
|
||||
updateEntity(index, {
|
||||
animation_data: {
|
||||
...animationData,
|
||||
offset: offset < 0 ? 0 : offset,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="z-5 h-full absolute rounded-md transition-colors bg-gray-700 hover:bg-gray-600"
|
||||
></motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Timeline: FC<TimelineProps> = () => {
|
||||
const { entities } = useEntitiesStore((store) => ({
|
||||
entities: store.entities,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-4 border transition-colors focus-within:border-gray-400 border-gray-600 rounded-md">
|
||||
<Timestamp />
|
||||
<div className="gap-1 flex flex-col overflow-hidden">
|
||||
<div className="z-20 flex flex-row gap-2">
|
||||
<div className="flex-shrink-0 w-96" />
|
||||
<TimePicker />
|
||||
</div>
|
||||
|
||||
{entities.map((entity, index) => (
|
||||
<Track
|
||||
name={entity.type}
|
||||
index={index}
|
||||
key={index}
|
||||
keyframes={flattenedKeyframesByEntity(entity)}
|
||||
animationData={entity.animation_data}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timeline;
|
||||
19
app/src/components/Timestamp.tsx
Normal file
19
app/src/components/Timestamp.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useRenderStateStore } from "stores/render-state.store";
|
||||
import { useTimelineStore } from "stores/timeline.store";
|
||||
|
||||
const Timestamp = () => {
|
||||
const { renderState } = useRenderStateStore();
|
||||
const timeline = useTimelineStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Frame {renderState.curr_frame}</h3>
|
||||
<h2 className="text-xl font-bold">
|
||||
{((renderState.curr_frame * timeline.fps) / 60 / 60).toPrecision(3)}{" "}
|
||||
<span className="text-sm font-light">/ {timeline.fps}FPS</span>
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timestamp;
|
||||
58
app/src/components/ToolBar/index.tsx
Normal file
58
app/src/components/ToolBar/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
BoxIcon,
|
||||
CircleIcon,
|
||||
CursorArrowIcon,
|
||||
MixIcon,
|
||||
Pencil1Icon,
|
||||
Pencil2Icon,
|
||||
SymbolIcon,
|
||||
TextIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import * as Toolbar from "@radix-ui/react-toolbar";
|
||||
import { FC, ReactNode } from "react";
|
||||
|
||||
const ToolBarButton: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<Toolbar.Button
|
||||
className="text-white p-[10px] bg-gray-900 flex-shrink-0 flex-grow-0
|
||||
basis-auto w-[40px] h-[40px] rounded inline-flex text-[13px] leading-none
|
||||
items-center justify-center outline-none hover:bg-indigo-900
|
||||
transition-colors
|
||||
focus:relative focus:shadow-[0_0_0_2px] focus:shadow-indigo"
|
||||
>
|
||||
{children}
|
||||
</Toolbar.Button>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolBar = () => {
|
||||
return (
|
||||
<Toolbar.Root
|
||||
className="bg-gray-800 flex flex-col gap-1 p-1 h-full"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ToolBarButton>
|
||||
<CursorArrowIcon width="100%" height="100%" />
|
||||
</ToolBarButton>
|
||||
<Toolbar.Separator />
|
||||
<ToolBarButton>
|
||||
<BoxIcon width="100%" height="100%" />
|
||||
</ToolBarButton>
|
||||
<ToolBarButton>
|
||||
<CircleIcon width="100%" height="100%" />
|
||||
</ToolBarButton>
|
||||
<ToolBarButton>
|
||||
<Pencil1Icon width="100%" height="100%" />
|
||||
</ToolBarButton>
|
||||
<ToolBarButton>
|
||||
<MixIcon width="100%" height="100%" />
|
||||
</ToolBarButton>
|
||||
<Toolbar.Separator />
|
||||
<ToolBarButton>
|
||||
<TextIcon width="100%" height="100%" />
|
||||
</ToolBarButton>
|
||||
</Toolbar.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolBar;
|
||||
47
app/src/drawers/box.ts
Normal file
47
app/src/drawers/box.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Canvas, CanvasKit } from "canvaskit-wasm";
|
||||
import { z } from "zod";
|
||||
import { BoxEntity } from "primitives/Entities";
|
||||
import { buildPaintStyle } from "./paint";
|
||||
|
||||
export default function drawBox(
|
||||
CanvasKit: CanvasKit,
|
||||
canvas: Canvas,
|
||||
entity: z.infer<typeof BoxEntity>
|
||||
) {
|
||||
const paint = new CanvasKit.Paint();
|
||||
|
||||
const debugPaint = new CanvasKit.Paint();
|
||||
debugPaint.setColor(CanvasKit.RED);
|
||||
buildPaintStyle(CanvasKit, paint, entity.paint);
|
||||
|
||||
let targetPosition = entity.position;
|
||||
|
||||
canvas.drawCircle(targetPosition[0], targetPosition[1], 10, debugPaint);
|
||||
|
||||
targetPosition = targetPosition.map((val, index) => {
|
||||
let temp = val - entity.size[index] * 0.5;
|
||||
|
||||
return temp;
|
||||
});
|
||||
|
||||
debugPaint.setColor(CanvasKit.BLUE);
|
||||
|
||||
canvas.drawCircle(targetPosition[0], targetPosition[1], 10, debugPaint);
|
||||
|
||||
debugPaint.setColor(CanvasKit.GREEN);
|
||||
|
||||
canvas.drawCircle(targetPosition[0], targetPosition[1], 10, debugPaint);
|
||||
|
||||
console.log(targetPosition[0], targetPosition[1]);
|
||||
|
||||
const rect = CanvasKit.XYWHRect(
|
||||
targetPosition[0],
|
||||
targetPosition[1],
|
||||
entity.size[0],
|
||||
entity.size[1]
|
||||
);
|
||||
|
||||
canvas.drawRect(rect, paint);
|
||||
|
||||
canvas.drawCircle(targetPosition[0], targetPosition[1], 10, debugPaint);
|
||||
}
|
||||
24
app/src/drawers/ellipse.ts
Normal file
24
app/src/drawers/ellipse.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { convertToFloat } from "@tempblade/common";
|
||||
import { Canvas, CanvasKit } from "canvaskit-wasm";
|
||||
import { EllipseEntity } from "primitives/Entities";
|
||||
import { z } from "zod";
|
||||
import { buildPaintStyle } from "./paint";
|
||||
|
||||
export default function drawEllipse(
|
||||
CanvasKit: CanvasKit,
|
||||
canvas: Canvas,
|
||||
entity: z.infer<typeof EllipseEntity>
|
||||
) {
|
||||
const paint = new CanvasKit.Paint();
|
||||
|
||||
buildPaintStyle(CanvasKit, paint, entity.paint);
|
||||
|
||||
const rect = CanvasKit.XYWHRect(
|
||||
entity.position[0],
|
||||
entity.position[1],
|
||||
entity.radius[0],
|
||||
entity.radius[1]
|
||||
);
|
||||
|
||||
canvas.drawOval(rect, paint);
|
||||
}
|
||||
30
app/src/drawers/paint.ts
Normal file
30
app/src/drawers/paint.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { convertToFloat } from "@tempblade/common";
|
||||
import { Paint as SkPaint, CanvasKit } from "canvaskit-wasm";
|
||||
import { Paint } from "primitives/Paint";
|
||||
import { z } from "zod";
|
||||
|
||||
export function buildPaintStyle(
|
||||
CanvasKit: CanvasKit,
|
||||
skPaint: SkPaint,
|
||||
paint: z.output<typeof Paint>
|
||||
) {
|
||||
const color = convertToFloat(paint.style.color.value);
|
||||
|
||||
skPaint.setAntiAlias(true);
|
||||
skPaint.setColor(color);
|
||||
|
||||
switch (paint.style.type) {
|
||||
case "Fill":
|
||||
skPaint.setStyle(CanvasKit.PaintStyle.Fill);
|
||||
break;
|
||||
|
||||
case "Stroke":
|
||||
skPaint.setStyle(CanvasKit.PaintStyle.Stroke);
|
||||
skPaint.setStrokeWidth(paint.style.width);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error("Paint Style not supported!");
|
||||
break;
|
||||
}
|
||||
}
|
||||
42
app/src/drawers/text.ts
Normal file
42
app/src/drawers/text.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Canvas, CanvasKit } from "canvaskit-wasm";
|
||||
import { TextEntity } from "primitives/Entities";
|
||||
import { convertToFloat } from "@tempblade/common";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function drawText(
|
||||
CanvasKit: CanvasKit,
|
||||
canvas: Canvas,
|
||||
entity: z.infer<typeof TextEntity>,
|
||||
fontData: ArrayBuffer
|
||||
) {
|
||||
const fontMgr = CanvasKit.FontMgr.FromData(fontData);
|
||||
|
||||
if (!fontMgr) {
|
||||
console.error("No FontMgr");
|
||||
return;
|
||||
}
|
||||
|
||||
const paint = new CanvasKit.Paint();
|
||||
|
||||
const color = convertToFloat(entity.paint.style.color.value);
|
||||
|
||||
paint.setColor(color);
|
||||
|
||||
const pStyle = new CanvasKit.ParagraphStyle({
|
||||
textStyle: {
|
||||
color: color,
|
||||
fontFamilies: ["Roboto"],
|
||||
fontSize: entity.paint.size,
|
||||
},
|
||||
textDirection: CanvasKit.TextDirection.LTR,
|
||||
textAlign: CanvasKit.TextAlign[entity.paint.align],
|
||||
});
|
||||
|
||||
const builder = CanvasKit.ParagraphBuilder.Make(pStyle, fontMgr);
|
||||
builder.addText(entity.text);
|
||||
const p = builder.build();
|
||||
p.layout(900);
|
||||
const height = p.getHeight() / 2;
|
||||
const width = p.getMaxWidth() / 2;
|
||||
canvas.drawParagraph(p, entity.origin[0] - width, entity.origin[1] - height);
|
||||
}
|
||||
190
app/src/example.ts
Normal file
190
app/src/example.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { AnimatedEntity } from "primitives/AnimatedEntities";
|
||||
import { Color } from "primitives/Paint";
|
||||
import { Timeline } from "primitives/Timeline";
|
||||
import { staticAnimatedNumber, staticAnimatedVec2 } from "primitives/Values";
|
||||
import { z } from "zod";
|
||||
|
||||
function buildBox1(
|
||||
offset: number,
|
||||
color: z.infer<typeof Color>
|
||||
): z.input<typeof AnimatedEntity> {
|
||||
return {
|
||||
type: "Box",
|
||||
paint: {
|
||||
style: {
|
||||
type: "Stroke",
|
||||
width: 50,
|
||||
color,
|
||||
},
|
||||
},
|
||||
size: {
|
||||
keyframes: [
|
||||
{
|
||||
keyframes: {
|
||||
values: [
|
||||
{
|
||||
interpolation: {
|
||||
type: "EasingFunction",
|
||||
easing_function: "CircOut",
|
||||
},
|
||||
value: 0.0,
|
||||
offset: 0.0,
|
||||
},
|
||||
{
|
||||
interpolation: {
|
||||
type: "Linear",
|
||||
},
|
||||
value: 1280.0,
|
||||
offset: 4.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
staticAnimatedNumber(720),
|
||||
],
|
||||
},
|
||||
origin: staticAnimatedVec2(1280 / 2, 720 / 2),
|
||||
position: staticAnimatedVec2(0, 0),
|
||||
animation_data: {
|
||||
offset,
|
||||
duration: 10.0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildBox(
|
||||
offset: number,
|
||||
color: z.infer<typeof Color>
|
||||
): z.input<typeof AnimatedEntity> {
|
||||
return {
|
||||
type: "Box",
|
||||
paint: {
|
||||
style: {
|
||||
type: "Fill",
|
||||
color,
|
||||
},
|
||||
},
|
||||
size: {
|
||||
keyframes: [
|
||||
{
|
||||
keyframes: {
|
||||
values: [
|
||||
{
|
||||
interpolation: {
|
||||
type: "Linear",
|
||||
},
|
||||
value: 1280.0,
|
||||
offset: 0.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
keyframes: {
|
||||
values: [
|
||||
{
|
||||
interpolation: {
|
||||
type: "EasingFunction",
|
||||
easing_function: "CircOut",
|
||||
},
|
||||
value: 0.0,
|
||||
offset: 0.0,
|
||||
},
|
||||
{
|
||||
interpolation: {
|
||||
type: "Linear",
|
||||
},
|
||||
value: 720.0,
|
||||
offset: 4.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
origin: staticAnimatedVec2(0, -720),
|
||||
position: staticAnimatedVec2(1280 / 2, 720 / 2),
|
||||
animation_data: {
|
||||
offset,
|
||||
duration: 10.0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildText(
|
||||
text: string,
|
||||
offset: number,
|
||||
size: number,
|
||||
y_offset: number,
|
||||
color: z.infer<typeof Color>
|
||||
): z.input<typeof AnimatedEntity> {
|
||||
return {
|
||||
type: "Text",
|
||||
paint: {
|
||||
style: {
|
||||
type: "Fill",
|
||||
color,
|
||||
},
|
||||
size,
|
||||
align: "Center",
|
||||
},
|
||||
text,
|
||||
animation_data: {
|
||||
offset,
|
||||
duration: 5.0,
|
||||
},
|
||||
origin: {
|
||||
keyframes: [
|
||||
{
|
||||
keyframes: {
|
||||
values: [
|
||||
{
|
||||
interpolation: {
|
||||
type: "Spring",
|
||||
mass: 1,
|
||||
stiffness: 100,
|
||||
damping: 15,
|
||||
},
|
||||
value: (1280 / 2) * -1 - 300,
|
||||
offset: 0.0,
|
||||
},
|
||||
{
|
||||
interpolation: {
|
||||
type: "EasingFunction",
|
||||
easing_function: "QuartOut",
|
||||
},
|
||||
value: 1280 / 2,
|
||||
offset: 5.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
staticAnimatedNumber(720 / 2 + y_offset),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const EXAMPLE_ANIMATED_ENTITIES: Array<z.input<typeof AnimatedEntity>> =
|
||||
[
|
||||
buildText("Kleine Dumpfkopf!", 1.0, 80, -30, {
|
||||
value: [255, 255, 255, 1.0],
|
||||
}),
|
||||
buildText("Wie gehts?", 1.5, 40, 30, { value: [255, 255, 255, 1.0] }),
|
||||
buildBox(0.6, { value: [30, 30, 30, 1.0] }),
|
||||
buildBox(0.4, { value: [20, 20, 20, 1.0] }),
|
||||
buildBox(0.2, { value: [10, 10, 10, 1.0] }),
|
||||
buildBox(0, { value: [0, 0, 0, 1.0] }),
|
||||
];
|
||||
|
||||
const ExampleTimeline: z.input<typeof Timeline> = {
|
||||
size: [1920, 1080],
|
||||
duration: 10.0,
|
||||
render_state: {
|
||||
curr_frame: 20,
|
||||
},
|
||||
fps: 60,
|
||||
entities: EXAMPLE_ANIMATED_ENTITIES,
|
||||
};
|
||||
|
||||
export { ExampleTimeline };
|
||||
10
app/src/main.tsx
Normal file
10
app/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
37
app/src/primitives/AnimatedEntities.ts
Normal file
37
app/src/primitives/AnimatedEntities.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
import { BoxEntity, EllipseEntity, TextEntity } from "./Entities";
|
||||
import { AnimatedVec2 } from "./Values";
|
||||
|
||||
export const AnimationData = z.object({
|
||||
offset: z.number(),
|
||||
duration: z.number(),
|
||||
visible: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export const AnimatedBoxEntity = BoxEntity.extend({
|
||||
position: AnimatedVec2,
|
||||
size: AnimatedVec2,
|
||||
origin: AnimatedVec2,
|
||||
|
||||
animation_data: AnimationData,
|
||||
});
|
||||
|
||||
export const AnimatedTextEntity = TextEntity.extend({
|
||||
origin: AnimatedVec2,
|
||||
animation_data: AnimationData,
|
||||
});
|
||||
|
||||
export const AnimatedEllipseEntity = EllipseEntity.extend({
|
||||
radius: AnimatedVec2,
|
||||
position: AnimatedVec2,
|
||||
origin: AnimatedVec2,
|
||||
animation_data: AnimationData,
|
||||
});
|
||||
|
||||
export const AnimatedEntity = z.discriminatedUnion("type", [
|
||||
AnimatedBoxEntity,
|
||||
AnimatedTextEntity,
|
||||
AnimatedEllipseEntity,
|
||||
]);
|
||||
|
||||
export const AnimatedEntities = z.array(AnimatedEntity);
|
||||
40
app/src/primitives/Entities.ts
Normal file
40
app/src/primitives/Entities.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import { Vec2 } from "./Values";
|
||||
import { Paint, TextPaint } from "./Paint";
|
||||
|
||||
const EntityTypeOptions = ["Text", "Ellipse", "Box"] as const;
|
||||
|
||||
export const EntityType = z.enum(EntityTypeOptions);
|
||||
|
||||
export const GeometryEntity = z.object({
|
||||
paint: Paint,
|
||||
});
|
||||
|
||||
export const BoxEntity = GeometryEntity.extend({
|
||||
type: z.literal(EntityType.Enum.Box),
|
||||
size: Vec2,
|
||||
position: Vec2,
|
||||
origin: Vec2,
|
||||
});
|
||||
|
||||
export const EllipseEntity = GeometryEntity.extend({
|
||||
type: z.literal(EntityType.Enum.Ellipse),
|
||||
radius: Vec2,
|
||||
position: Vec2,
|
||||
origin: Vec2,
|
||||
});
|
||||
|
||||
export const TextEntity = z.object({
|
||||
type: z.literal(EntityType.Enum.Text),
|
||||
paint: TextPaint,
|
||||
origin: Vec2,
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export const Entity = z.discriminatedUnion("type", [
|
||||
BoxEntity,
|
||||
EllipseEntity,
|
||||
TextEntity,
|
||||
]);
|
||||
|
||||
export const Entities = z.array(Entity);
|
||||
53
app/src/primitives/Interpolation.ts
Normal file
53
app/src/primitives/Interpolation.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const InterpolationTypeOptions = [
|
||||
"Linear",
|
||||
"Spring",
|
||||
"EasingFunction",
|
||||
] as const;
|
||||
|
||||
const EasingFunctionOptions = [
|
||||
"QuintOut",
|
||||
"QuintIn",
|
||||
"QuintInOut",
|
||||
"CircOut",
|
||||
"CircIn",
|
||||
"CircInOut",
|
||||
"CubicOut",
|
||||
"CubicIn",
|
||||
"CubicInOut",
|
||||
"ExpoOut",
|
||||
"ExpoIn",
|
||||
"ExpoInOut",
|
||||
"QuadOut",
|
||||
"QuadIn",
|
||||
"QuadInOut",
|
||||
"QuartOut",
|
||||
"QuartIn",
|
||||
"QuartInOut",
|
||||
] as const;
|
||||
|
||||
export const EasingFunction = z.enum(EasingFunctionOptions);
|
||||
export const InterpolationType = z.enum(InterpolationTypeOptions);
|
||||
|
||||
export const LinearInterpolation = z.object({
|
||||
type: z.literal(InterpolationType.Enum.Linear),
|
||||
});
|
||||
|
||||
export const EasingFunctionInterpolation = z.object({
|
||||
type: z.literal(InterpolationType.Enum.EasingFunction),
|
||||
easing_function: EasingFunction,
|
||||
});
|
||||
|
||||
export const SpringInterpolation = z.object({
|
||||
mass: z.number(),
|
||||
damping: z.number(),
|
||||
stiffness: z.number(),
|
||||
type: z.literal(InterpolationType.Enum.Spring),
|
||||
});
|
||||
|
||||
export const Interpolation = z.discriminatedUnion("type", [
|
||||
SpringInterpolation,
|
||||
EasingFunctionInterpolation,
|
||||
LinearInterpolation,
|
||||
]);
|
||||
12
app/src/primitives/Keyframe.ts
Normal file
12
app/src/primitives/Keyframe.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { Interpolation } from "./Interpolation";
|
||||
|
||||
export const Keyframe = z.object({
|
||||
value: z.number(),
|
||||
offset: z.number(),
|
||||
interpolation: z.optional(Interpolation),
|
||||
});
|
||||
|
||||
export const Keyframes = z.object({
|
||||
values: z.array(Keyframe),
|
||||
});
|
||||
49
app/src/primitives/Paint.ts
Normal file
49
app/src/primitives/Paint.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const Color = z.object({
|
||||
value: z.array(z.number().min(0).max(255)).max(4),
|
||||
});
|
||||
|
||||
const PaintStyleTypeOptions = ["Fill", "Stroke"] as const;
|
||||
|
||||
export const PaintStyleType = z.enum(PaintStyleTypeOptions);
|
||||
|
||||
const ColorWithDefault = Color.optional().default({ value: [0, 0, 0, 1] });
|
||||
|
||||
export const StrokeStyle = z.object({
|
||||
width: z.number().min(0).optional().default(10),
|
||||
color: ColorWithDefault,
|
||||
type: z.literal(PaintStyleType.Enum.Stroke),
|
||||
});
|
||||
|
||||
export const FillStyle = z.object({
|
||||
color: ColorWithDefault,
|
||||
type: z.literal(PaintStyleType.Enum.Fill),
|
||||
});
|
||||
|
||||
export const TextAlign = z.enum(["Left", "Center", "Right"]);
|
||||
|
||||
export const PaintStyle = z.discriminatedUnion("type", [
|
||||
StrokeStyle,
|
||||
FillStyle,
|
||||
]);
|
||||
|
||||
export const Paint = z.object({
|
||||
style: PaintStyle,
|
||||
});
|
||||
|
||||
export const TextPaint = z.object({
|
||||
style: PaintStyle,
|
||||
align: TextAlign,
|
||||
size: z.number().min(0),
|
||||
});
|
||||
|
||||
/* const NestedFillStyle = FillStyle.omit({ type: true }).default({});
|
||||
const NestedStrokeStyle = StrokeStyle.omit({ type: true }).default({});
|
||||
|
||||
export const StrokeAndFillStyle = z.object({
|
||||
color: ColorWithDefault,
|
||||
type: z.literal(PaintStyleType.Enum.StrokeAndFill),
|
||||
fill: NestedFillStyle,
|
||||
stroke: NestedStrokeStyle,
|
||||
}); */
|
||||
14
app/src/primitives/Timeline.ts
Normal file
14
app/src/primitives/Timeline.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { AnimatedEntities } from "./AnimatedEntities";
|
||||
|
||||
export const RenderState = z.object({
|
||||
curr_frame: z.number(),
|
||||
});
|
||||
|
||||
export const Timeline = z.object({
|
||||
entities: AnimatedEntities,
|
||||
render_state: RenderState,
|
||||
duration: z.number(),
|
||||
fps: z.number().int(),
|
||||
size: z.array(z.number().int()).length(2),
|
||||
});
|
||||
67
app/src/primitives/Values.ts
Normal file
67
app/src/primitives/Values.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
import { Keyframes } from "./Keyframe";
|
||||
import { Interpolation } from "./Interpolation";
|
||||
|
||||
export const Vec2 = z.array(z.number()).length(2);
|
||||
|
||||
export const AnimatedNumber = z.object({
|
||||
keyframes: Keyframes,
|
||||
});
|
||||
|
||||
export const AnimatedVec2 = z.object({
|
||||
keyframes: z.array(AnimatedNumber).length(2),
|
||||
});
|
||||
|
||||
export function staticAnimatedNumber(
|
||||
number: number
|
||||
): z.infer<typeof AnimatedNumber> {
|
||||
return {
|
||||
keyframes: {
|
||||
values: [
|
||||
{
|
||||
interpolation: {
|
||||
type: "Linear",
|
||||
},
|
||||
value: number,
|
||||
offset: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function staticAnimatedVec2(
|
||||
x: number,
|
||||
y: number
|
||||
): z.infer<typeof AnimatedVec2> {
|
||||
return {
|
||||
keyframes: [
|
||||
{
|
||||
keyframes: {
|
||||
values: [
|
||||
{
|
||||
interpolation: {
|
||||
type: "Linear",
|
||||
},
|
||||
value: x,
|
||||
offset: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
keyframes: {
|
||||
values: [
|
||||
{
|
||||
interpolation: {
|
||||
type: "Linear",
|
||||
},
|
||||
value: y,
|
||||
offset: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
27
app/src/services/project.service.ts
Normal file
27
app/src/services/project.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useTimelineStore } from "stores/timeline.store";
|
||||
import { useEntitiesStore } from "stores/entities.store";
|
||||
import { useRenderStateStore } from "stores/render-state.store";
|
||||
import { z } from "zod";
|
||||
import { Timeline } from "primitives/Timeline";
|
||||
|
||||
export class ProjectService {
|
||||
public saveProject() {
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const entitiesStore = useEntitiesStore.getState();
|
||||
const renderStateStore = useRenderStateStore.getState();
|
||||
|
||||
const timeline: z.input<typeof Timeline> = {
|
||||
...timelineStore,
|
||||
entities: entitiesStore.entities,
|
||||
render_state: renderStateStore.renderState,
|
||||
};
|
||||
|
||||
const parsedTimeline = Timeline.parse(timeline);
|
||||
|
||||
const serializedTimeline = JSON.stringify(parsedTimeline);
|
||||
|
||||
return serializedTimeline;
|
||||
}
|
||||
|
||||
public loadProject() {}
|
||||
}
|
||||
35
app/src/stores/entities.store.ts
Normal file
35
app/src/stores/entities.store.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { EXAMPLE_ANIMATED_ENTITIES } from "example";
|
||||
import { produce } from "immer";
|
||||
import { AnimatedEntities, AnimatedEntity } from "primitives/AnimatedEntities";
|
||||
import { z } from "zod";
|
||||
import { create } from "zustand";
|
||||
|
||||
interface EntitiesStore {
|
||||
entities: z.input<typeof AnimatedEntities>;
|
||||
selectedEntity: number | undefined;
|
||||
selectEntity: (index: number) => void;
|
||||
deselectEntity: () => void;
|
||||
updateEntity: (
|
||||
index: number,
|
||||
entity: Partial<z.input<typeof AnimatedEntity>>
|
||||
) => void;
|
||||
}
|
||||
|
||||
const useEntitiesStore = create<EntitiesStore>((set) => ({
|
||||
entities: EXAMPLE_ANIMATED_ENTITIES,
|
||||
selectEntity: (index) => set(() => ({ selectedEntity: index })),
|
||||
deselectEntity: () => set(() => ({ selectedEntity: undefined })),
|
||||
selectedEntity: undefined,
|
||||
updateEntity: (index, entity) =>
|
||||
set(({ entities }) => {
|
||||
const nextEntities = produce(entities, (draft) => {
|
||||
draft[index] = { ...draft[index], ...entity } as z.infer<
|
||||
typeof AnimatedEntity
|
||||
>;
|
||||
});
|
||||
|
||||
return { entities: nextEntities };
|
||||
}),
|
||||
}));
|
||||
|
||||
export { useEntitiesStore };
|
||||
13
app/src/stores/fonts.store.ts
Normal file
13
app/src/stores/fonts.store.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface FontsStore {
|
||||
fonts: Array<string>;
|
||||
setFonts: (fonts: Array<string>) => void;
|
||||
}
|
||||
|
||||
const useFontsStore = create<FontsStore>((set) => ({
|
||||
fonts: [],
|
||||
setFonts: (fonts) => ({ fonts }),
|
||||
}));
|
||||
|
||||
export { useFontsStore };
|
||||
24
app/src/stores/render-state.store.ts
Normal file
24
app/src/stores/render-state.store.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { RenderState } from "primitives/Timeline";
|
||||
import { z } from "zod";
|
||||
import { create } from "zustand";
|
||||
|
||||
interface RenderStateStore {
|
||||
renderState: z.infer<typeof RenderState>;
|
||||
setCurrentFrame: (target: number) => void;
|
||||
}
|
||||
|
||||
const useRenderStateStore = create<RenderStateStore>((set) => ({
|
||||
renderState: {
|
||||
curr_frame: 20,
|
||||
},
|
||||
setCurrentFrame: (target) =>
|
||||
set((store) => {
|
||||
store.renderState = {
|
||||
curr_frame: target,
|
||||
};
|
||||
|
||||
return { renderState: store.renderState };
|
||||
}),
|
||||
}));
|
||||
|
||||
export { useRenderStateStore };
|
||||
15
app/src/stores/timeline.store.ts
Normal file
15
app/src/stores/timeline.store.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface TimelineStore {
|
||||
fps: number;
|
||||
duration: number;
|
||||
size: [number, number];
|
||||
}
|
||||
|
||||
const useTimelineStore = create<TimelineStore>((set) => ({
|
||||
fps: 60,
|
||||
size: [1920, 1080],
|
||||
duration: 10.0,
|
||||
}));
|
||||
|
||||
export { useTimelineStore };
|
||||
137
app/src/styles.css
Normal file
137
app/src/styles.css
Normal file
@@ -0,0 +1,137 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
h1 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
h2 {
|
||||
@apply text-xl;
|
||||
}
|
||||
h3 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
a,
|
||||
label {
|
||||
@apply dark:text-white;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-blue-600 underline;
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
@apply box-border bg-gray-900 shadow-indigo-100 hover:shadow-indigo-400
|
||||
focus:shadow-indigo-600 selection:bg-indigo-400 selection:text-black
|
||||
outline-none px-3 py-2 rounded-md shadow-[0_0_0_1px];
|
||||
}
|
||||
|
||||
input {
|
||||
@apply appearance-none items-center justify-center
|
||||
w-full text-base leading-none
|
||||
text-white transition-all;
|
||||
}
|
||||
|
||||
select {
|
||||
@apply appearance-none;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply mb-1;
|
||||
}
|
||||
|
||||
label {
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.SliderRoot {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
width: 200px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.SliderTrack {
|
||||
background-color: var(--black);
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
border-radius: 9999px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.SliderRange {
|
||||
position: absolute;
|
||||
background-color: #ddd;
|
||||
border-radius: 9999px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.SliderThumb {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
transition: opacity 0.1s linear, filter 0.1s linear;
|
||||
}
|
||||
|
||||
.SliderThumb:hover {
|
||||
filter: drop-shadow(0px 10px 10px white);
|
||||
}
|
||||
|
||||
.SliderThumb::before {
|
||||
content: "";
|
||||
background-color: var(--indigo-400);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: -8.5px;
|
||||
top: -10px;
|
||||
clip-path: polygon(100% 0, 0 0, 50% 75%);
|
||||
display: block;
|
||||
|
||||
@apply bg-indigo-300;
|
||||
}
|
||||
|
||||
.SliderThumb::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
width: 3px;
|
||||
height: 1000px;
|
||||
z-index: 200;
|
||||
opacity: 1;
|
||||
|
||||
@apply bg-indigo-300;
|
||||
}
|
||||
45
app/src/utils/index.ts
Normal file
45
app/src/utils/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { AnimatedEntity } from "primitives/AnimatedEntities";
|
||||
import { Keyframe } from "primitives/Keyframe";
|
||||
import { AnimatedNumber, AnimatedVec2 } from "primitives/Values";
|
||||
import { z } from "zod";
|
||||
|
||||
export function flattenAnimatedNumberKeyframes(
|
||||
aNumber: z.input<typeof AnimatedNumber>
|
||||
): Array<z.input<typeof Keyframe>> {
|
||||
return aNumber.keyframes.values;
|
||||
}
|
||||
|
||||
export function flattenAnimatedVec2Keyframes(
|
||||
aVec2: z.input<typeof AnimatedVec2>
|
||||
): Array<z.input<typeof Keyframe>> {
|
||||
const keyframes: Array<z.input<typeof Keyframe>> = [
|
||||
...flattenAnimatedNumberKeyframes(aVec2.keyframes[0]),
|
||||
...flattenAnimatedNumberKeyframes(aVec2.keyframes[1]),
|
||||
];
|
||||
|
||||
return keyframes;
|
||||
}
|
||||
|
||||
export function flattenedKeyframesByEntity(
|
||||
entity: z.input<typeof AnimatedEntity>
|
||||
): Array<z.input<typeof Keyframe>> {
|
||||
const keyframes: Array<z.input<typeof Keyframe>> = [];
|
||||
|
||||
switch (entity.type) {
|
||||
case "Text":
|
||||
keyframes.push(...flattenAnimatedVec2Keyframes(entity.origin));
|
||||
break;
|
||||
case "Box":
|
||||
keyframes.push(...flattenAnimatedVec2Keyframes(entity.position));
|
||||
keyframes.push(...flattenAnimatedVec2Keyframes(entity.size));
|
||||
break;
|
||||
case "Ellipse":
|
||||
keyframes.push(...flattenAnimatedVec2Keyframes(entity.position));
|
||||
keyframes.push(...flattenAnimatedVec2Keyframes(entity.radius));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return keyframes;
|
||||
}
|
||||
1
app/src/vite-env.d.ts
vendored
Normal file
1
app/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user