add app
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user