diff --git a/.vscode/launch.json b/.vscode/launch.json index 158e0ca6..1f09e038 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,10 +4,17 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Debug ChessBots Client", + "request": "launch", + "type": "chrome", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}" + }, { "type": "node", "request": "launch", - "name": "Debug ChessBot Server", + "name": "Debug ChessBots Server", "runtimeExecutable": "ts-node", "program": "${workspaceFolder}/src/server/main.ts", "restart": true, diff --git a/.vscode/settings.json b/.vscode/settings.json index 9537b95b..8c9b7acb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "task.autoDetect": "off", "cSpell.words": [ "blueprintjs", + "cbor", "Chessbots", "Premoves", "runtypes", diff --git a/docs/Home.md b/docs/Home.md index 9ffbb1fc..c0ab582a 100644 --- a/docs/Home.md +++ b/docs/Home.md @@ -1,4 +1,4 @@ -Welcome to the ChessBot wiki! +Welcome to the ChessBots wiki! If you want to learn more about the project or get involved, this is the place to be! @@ -10,17 +10,17 @@ Please have a look at the [Server Structure](Server-Structure) if you want a hig If you want to want to start developing the server side, first check out our [Server Setup Guide](Server-Setup). You can then read through the [Server Development Guide](Server-Development). -## ChessBot Software +## ChessBots Software -The ChessBot software handles receiving movement commands from the server, and figuring out how to move the motors and read the sensors to comply with the command. The ChessBot uses an ESP32-S2 Mini as the microcontroller and transmitter/receiver. The onboard logic uses C++ for ESP-IDF. +ChessBots software handles receiving movement commands from the server, and figuring out how to move the motors and read the sensors to comply with the command. The ChessBot uses an ESP32-S2 Mini as the microcontroller and transmitter/receiver. The onboard logic uses C++ for ESP-IDF. Please have a look at the [ESP Structure](ESP-Structure) if you want a high level overview of its various aspects. If you want to want to start developing the bot side, first check out our [ESP Setup Guide](ESP-Setup). You can then read through the [ESP Development Guide](ESP-Development). -## ChessBot Hardware +## ChessBots Hardware -A new PCB is being made for the ChessBots that will allow them to work easily with an ESP32-S2 Mini microcontroller. +A PCB is being made for the ChessBots that will allow them to work easily with an ESP32-S2 Mini microcontroller. ## Resource Library diff --git a/readme.md b/readme.md index 83557066..db00eb40 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# Welcome to chessBots! +# Welcome to ChessBots! ## Setup diff --git a/src/client/debug/debug.tsx b/src/client/debug/debug.tsx index 80cdc13d..f58f8105 100644 --- a/src/client/debug/debug.tsx +++ b/src/client/debug/debug.tsx @@ -37,6 +37,12 @@ export function Debug() { fetchIds(); }, [setRobotIds]); + const danceConfigLoad = async () => { + const response = await get("/do-parallel"); + if (!response.ok) { + console.warn("Failed to load dance config"); + } + }; // create the select and move buttons let body: ReactNode; if (robotIds === undefined) { @@ -50,6 +56,9 @@ export function Debug() { selectedRobotId={selectedRobotId} onRobotIdSelected={setSelectedRobotId} /> +
+
{selectedRobotId === undefined ? null : ( <>
diff --git a/src/client/debug/simulator.tsx b/src/client/debug/simulator.tsx index e482773b..118e9038 100644 --- a/src/client/debug/simulator.tsx +++ b/src/client/debug/simulator.tsx @@ -7,7 +7,8 @@ import { Tag, CompoundTag, } from "@blueprintjs/core"; -import { useEffect, useReducer, useRef, useState } from "react"; +import type { CSSProperties, PropsWithChildren } from "react"; +import { forwardRef, useEffect, useReducer, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { get, useSocket } from "../api"; import type { @@ -16,7 +17,7 @@ import type { } from "../../common/message/simulator-message"; import { SimulatorUpdateMessage } from "../../common/message/simulator-message"; import "./simulator.scss"; -import { clampHeading } from "../../common/units"; +import { clampHeading, GRID_CELL_PX } from "../../common/units"; import { bgColor, darkModeIcon, @@ -28,10 +29,12 @@ import { toggleUserSetting, } from "../check-dark-mode"; -const tileSize = 60; -const robotSize = tileSize / 2; +const tileSize = GRID_CELL_PX; +export const robotSize = tileSize / 2; const cellCount = 12; +export type RobotState = { [robotId: string]: SimulatedRobotLocation }; + /** * Creates a robot simulator for testing robot commands * @@ -41,8 +44,6 @@ const cellCount = 12; export function Simulator() { const navigate = useNavigate(); - type RobotState = { [robotId: string]: SimulatedRobotLocation }; - type Action = | { type: "SET_ALL_ROBOTS"; payload: RobotState } | { @@ -157,45 +158,7 @@ export function Simulator() {
-
- {new Array(cellCount * cellCount) - .fill(undefined) - .map((_, i) => { - const row = Math.floor(i / cellCount); - const col = i % cellCount; - const isCenterCell = - row >= 2 && row < 10 && col >= 2 && col < 10; - return ( -
- ); - })} - {/* TODO: implement onTopOfRobots */} - {Object.entries(robotState).map(([robotId, pos]) => ( - - ))} -
+
{ await fetch(`/__open-in-editor?${params.toString()}`); }; +// TODO: refactor out of debug since we use it in more than just simulator? +export function RobotGrid({ + robotState, + children, +}: PropsWithChildren<{ robotState: RobotState }>) { + return ( +
+ {new Array(cellCount * cellCount).fill(undefined).map((_, i) => { + const row = Math.floor(i / cellCount); + const col = i % cellCount; + const isCenterCell = + row >= 2 && row < 10 && col >= 2 && col < 10; + return ( +
+ ); + })} + {/* TODO: implement onTopOfRobots */} + {Object.entries(robotState).map(([robotId, pos]) => ( + + ))} + {children} +
+ ); +} + /** * the message log, used to show the commands sent to the robot * @param props - the message and time @@ -347,25 +358,31 @@ function LogEntry(props: { message: SimulatorUpdateMessage; ts: Date }) { * @param props - the robot position and id * @returns the robot icon scaled to the board */ -function Robot(props: { - pos: SimulatedRobotLocation; - robotId: string; - onTopOfRobots: string[]; -}) { +export const Robot = forwardRef< + HTMLDivElement, + { + pos: SimulatedRobotLocation; + robotId: string; + onTopOfRobots: string[]; + style?: CSSProperties; + } +>(function Robot({ pos, robotId, onTopOfRobots, style }, ref) { return (
- +
); -} +}); diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx new file mode 100644 index 00000000..fc9b2052 --- /dev/null +++ b/src/client/editor/editor.tsx @@ -0,0 +1,834 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + Section, + EditableText, + Button, + H2, + Card, + ButtonGroup, + Tag, + useHotkeys, + type HotkeyConfig, + SegmentedControl, + Divider, + NumericInput, + Pre, + NonIdealState, + SectionCard, +} from "@blueprintjs/core"; +import { RobotGrid, robotSize } from "../debug/simulator"; +import type { NonStartPointEvent, TimelineLayerType } from "../../common/show"; +import { NonStartPointEventSchema } from "../../common/show"; +import { + GridCursorMode, + millisToPixels, + pixelsToMillis, + RULER_TICK_GAP_PX, + TimelineDurationUpdateMode, +} from "../../common/show-interface-utils"; +import { + Ruler, + TimelineLayer, + TimelineEvent, + ReorderableTimelineEvent, +} from "./timeline"; +import type { Midpoint } from "../../common/spline"; +import { SplinePointType } from "../../common/spline"; +import type { MotionValue } from "motion/react"; +import { + motion, + useTransform, + useAnimate, + useMotionValueEvent, + Reorder, +} from "motion/react"; +import { useShowfile } from "./showfile-state"; +import { getRobotStateAtTime } from "../../common/getRobotStateAtTime"; +import { MotionRobot } from "./motion-robot"; +import { SplineEditor } from "./spline-editor"; +import interact from "interactjs"; +import { Array as RArray } from "runtypes"; +import { post } from "../api"; + +// Helper component to manage animation for a single robot +function AnimatedRobotRenderer({ + layer, + timestamp, + robotId, +}: { + layer: TimelineLayerType; + timestamp: MotionValue; + robotId: string; +}) { + const [scope, animate] = useAnimate(); + + // Get initial state to avoid undefined on first render + const initialState = getRobotStateAtTime(layer, timestamp.get()); + + useMotionValueEvent(timestamp, "change", (latestTimestamp) => { + const { position, headingRadians } = getRobotStateAtTime( + layer, + latestTimestamp, + ); + const targetX = position.x - robotSize / 2; + const targetY = position.y - robotSize / 2; + const targetRotate = headingRadians * (180 / Math.PI); + + // Animate using transform + if (scope.current) { + animate( + scope.current, + { + transform: `translateX(${targetX}px) translateY(${targetY}px) rotate(${targetRotate}deg)`, + }, + { duration: 0, ease: "linear" }, + ); + } + }); + + // Calculate initial transform string + const initialX = initialState.position.x - robotSize / 2; + const initialY = initialState.position.y - robotSize / 2; + const initialRotate = initialState.headingRadians * (180 / Math.PI); + const initialTransform = `translateX(${initialX}px) translateY(${initialY}px) rotate(${initialRotate}deg)`; + + return ( + + ); +} + +export function Editor() { + const { + show, + unsavedChanges, + loadAudioFromFile, + handleStartPointMove, + handlePointMove, + handleControlPointMove, + handleControlPoint2Move, + handleDeleteStartPoint, + handleDeletePoint, + handleSwitchPointType, + saveShowfile, + openShowfile, + undo, + redo, + editName, + addRobot, + currentTimestamp, + playing, + togglePlaying, + canUndo, + canRedo, + deleteLayer, + sequenceLengthMs, + updateTimelineEventOrders, + updateTimelineEventDurations, + setTimelineDurationUpdateMode, + timelineDurationUpdateMode, + gridCursorMode, + setGridCursorMode, + defaultPointType, + setDefaultPointType, + setDefaultEventDurationMs, + defaultEventDurationMs, + addPointToSelectedLayer, + setSelectedLayerIndex, + selectedLayerIndex, + removeAudio, + deleteTimelineEvent, + setTimestamp, + addWaitEventAtIndex, + addTurnEventAtIndex, + getLayerIndexFromEventId, + addBulkEventsToSelectedLayer, + selectedTimelineEventIndex, + setSelectedTimelineEventIndex, + } = useShowfile(); + + // TODO: fix viewport height / timeline height + + const seekBarWidth = useTransform(() => + millisToPixels(currentTimestamp.get()), + ); + + const hotkeys = useMemo( + () => [ + { + combo: "mod+s", + group: "File", + global: true, + label: "Save", + onKeyDown: (e) => { + e.preventDefault(); + saveShowfile(); + }, + }, + { + combo: "mod+o", + group: "File", + global: true, + label: "Open...", + onKeyDown: (e) => { + e.preventDefault(); + openShowfile(); + }, + }, + { + combo: "mod+z", + group: "Edit", + global: true, + label: "Undo", + onKeyDown: (e) => { + e.preventDefault(); + undo(); + }, + }, + { + combo: "mod+y", + group: "Edit", + global: true, + label: "Redo", + onKeyDown: (e) => { + e.preventDefault(); + redo(); + }, + }, + { + combo: "space", + group: "Play/Pause", + global: true, + label: "Play/Pause", + onKeyDown: (e) => { + e.preventDefault(); + togglePlaying(); + }, + }, + { + combo: "mod+c", + group: "Editor", + global: true, + label: "Copy", + onKeyDown: async (e) => { + const selection = window.getSelection(); + if (!selection) return; + + const startEl = + selection?.anchorNode?.parentElement?.parentElement; + const endEl = + selection?.focusNode?.parentElement?.parentElement; + if (!startEl || !endEl) return; + console.log(startEl, endEl); + + if ( + startEl.parentNode?.parentNode?.parentNode !== + endEl.parentNode?.parentNode?.parentNode + ) { + console.warn( + "Selection is not within the same layer", + startEl.parentNode?.parentNode?.parentNode, + endEl.parentNode?.parentNode?.parentNode, + ); + return; + } + + const timelineEventIdRegex = /^timeline-event-(.+)$/; + const startElId = startEl.id; + const endElId = endEl.id; + const startEventId = + startElId.match(timelineEventIdRegex)?.[1]; + const endEventId = endElId.match(timelineEventIdRegex)?.[1]; + console.log(startEventId, endEventId); + + if (!startEventId || !endEventId) return; + + e.preventDefault(); + e.stopPropagation(); + + console.log("WOO"); + + const layerIndex = getLayerIndexFromEventId(startEventId); + if (layerIndex === -1) { + console.warn("Layer not found"); + return; + } + + const layer = show.timeline[layerIndex]; + + const startIdx = layer.remainingEvents.findIndex( + (event) => event.id === startEventId, + ); + const endIdx = layer.remainingEvents.findIndex( + (event) => event.id === endEventId, + ); + + if (startIdx === -1 || endIdx === -1) return; + + const selectedEvents = layer.remainingEvents.slice( + startIdx, + endIdx + 1, + ); + console.log(selectedEvents); + + await navigator.clipboard.writeText( + JSON.stringify(selectedEvents), + ); + }, + }, + { + combo: "mod+v", + group: "Editor", + global: true, + label: "Paste", + onKeyDown: async (e) => { + const pasteResult = await navigator.clipboard.readText(); + if (!pasteResult) return; + + let pastedEvents: unknown; + try { + pastedEvents = JSON.parse(pasteResult); + } catch (e) { + console.warn("Failed to parse pasted events", e); + return; + } + + if (!Array.isArray(pastedEvents)) { + console.warn("Pasted events are not an array"); + return; + } + + const checkResult = RArray( + NonStartPointEventSchema, + ).validate(pastedEvents); + if (!checkResult.success) { + console.warn( + "Pasted events are not valid", + checkResult, + ); + return; + } + + e.preventDefault(); + e.stopPropagation(); + + for (const event of checkResult.value) { + event.id = crypto.randomUUID(); + } + + addBulkEventsToSelectedLayer(checkResult.value); + }, + }, + ], + [ + saveShowfile, + openShowfile, + undo, + redo, + togglePlaying, + getLayerIndexFromEventId, + show.timeline, + addBulkEventsToSelectedLayer, + ], + ); + + const { handleKeyDown, handleKeyUp } = useHotkeys(hotkeys); + + const seekBarRef = useRef(null); + + useEffect(() => { + if (!seekBarRef.current) return; + const el = seekBarRef.current; + interact(el).resizable({ + edges: { + right: true, + }, + listeners: { + move: function (event) { + event.preventDefault(); + event.stopPropagation(); + const newTimestamp = pixelsToMillis(event.rect.width); + setTimestamp(newTimestamp); + }, + }, + }); + }, [setTimestamp]); + + const handleSeekBarClick = useCallback( + (e: React.MouseEvent) => { + const { x: mouseX } = e.nativeEvent; + const seekBar = seekBarRef.current; + if (!seekBar) return; + const { x: seekBarStartX } = seekBar.getBoundingClientRect(); + const newTimestamp = pixelsToMillis(mouseX - seekBarStartX); + setTimestamp(newTimestamp); + }, + [setTimestamp], + ); + return ( +
+ +
+

+ +

+ {unsavedChanges && ( + + Unsaved changes + + )} +
+ + +
+ ); +} diff --git a/src/client/editor/hooks.ts b/src/client/editor/hooks.ts new file mode 100644 index 00000000..80b39110 --- /dev/null +++ b/src/client/editor/hooks.ts @@ -0,0 +1,227 @@ +import { useMotionValue, useTime, useMotionValueEvent } from "motion/react"; +import { + type RefObject, + useEffect, + useState, + useReducer, + useCallback, + useMemo, +} from "react"; + +export function useDraggable( + elRef: RefObject, + updatePosition: (screenX: number, screenY: number) => void, +) { + useEffect(() => { + const el = elRef.current; + if (!el) return; + + // TODO: better name for these? + let pos1 = 0, + pos2 = 0, + pos3 = 0, + pos4 = 0; + + const onMouseDown = (e) => { + e.preventDefault(); + // get the mouse cursor position at startup: + pos3 = e.clientX; + pos4 = e.clientY; + document.onmouseup = closeDragElement; + // call a fn whenever the cursor moves: + document.onmousemove = elementDrag; + }; + + const elementDrag = (e) => { + e.preventDefault(); + pos1 = pos3 - e.clientX; + pos2 = pos4 - e.clientY; + pos3 = e.clientX; + pos4 = e.clientY; + const x = el.offsetLeft - pos1; + const y = el.offsetTop - pos2; + updatePosition(x, y); + }; + + const closeDragElement = () => { + document.onmouseup = null; + document.onmousemove = null; + }; + + el.addEventListener("mousedown", onMouseDown); + + return () => { + el.removeEventListener("mousedown", onMouseDown); + document.removeEventListener("mousemove", elementDrag); + document.removeEventListener("mouseup", closeDragElement); + }; + }, [elRef, updatePosition]); +} + +/* + * A hook that prevents the user from exiting the page with unsaved changes. + * + * The hook provides a boolean indicating if there are unsaved changes, and a function to set the unsaved changes state. + */ +export function usePreventExitWithUnsavedChanges() { + const [unsavedChanges, setUnsavedChanges] = useState(false); + + useEffect(() => { + const onBeforeUnload = (e: BeforeUnloadEvent) => { + if (unsavedChanges) { + e.preventDefault(); + e.returnValue = ""; + } + }; + + window.addEventListener("beforeunload", onBeforeUnload); + + return () => { + window.removeEventListener("beforeunload", onBeforeUnload); + }; + }, [unsavedChanges]); + + return [unsavedChanges, setUnsavedChanges] as const; +} + +/* + * A hook that provides a state with a history of changes, and undo/redo functionality. + * + * The state is a simple object with a value and a history of values, and an index into the history. + * The value is the current state of the state, and the history is an array of all the previous states. + * The index is the index into the history of the current state. + * + * The hook also provides functions to set the value, undo, redo, and check if the state can be undone or redone. + * + * The hook also provides a callback to replace the current value with a new value, which is useful for the first time you load data since that shouldn't count as a tracked action. + */ +export function useStateWithTrackedHistory(initialValue: T) { + type Action = + | { type: "set"; value: T } + | { type: "undo" } + | { type: "redo" } + | { type: "replace"; value: T }; + type State = { + history: T[]; + index: number; + }; + const [state, dispatch] = useReducer( + (state: State, action: Action): State => { + switch (action.type) { + case "set": { + // Truncate history after the current index if we've undone previously + const newHistory = state.history.slice(0, state.index + 1); + newHistory.push(action.value); + return { + history: newHistory, + index: newHistory.length - 1, + }; + } + case "undo": { + if (state.index === 0) return state; // Cannot undo past the initial state + return { + ...state, + index: state.index - 1, + }; + } + case "redo": { + // Cannot redo if already at the latest state + if (state.index === state.history.length - 1) return state; + return { + ...state, + index: state.index + 1, + }; + } + case "replace": { + // Reset history with the new value + return { + history: [action.value], + index: 0, + }; + } + } + }, + // Initial state: history contains only the initial value at index 0 + { + history: [initialValue], + index: 0, + }, + ); + + const value = useMemo( + () => state.history[state.index], + [state.history, state.index], + ); + const setValue = useCallback( + (newValue: T) => { + // Avoid adding identical consecutive states to history + if (newValue !== value) { + dispatch({ type: "set", value: newValue }); + } + }, + [value], + ); // Add value dependency to check against current value + + const undo = useCallback(() => dispatch({ type: "undo" }), []); + const redo = useCallback(() => dispatch({ type: "redo" }), []); + const canUndo = useMemo(() => state.index > 0, [state.index]); + const canRedo = useMemo( + () => state.index < state.history.length - 1, + [state.index, state.history.length], + ); + // Add a function to replace the state without affecting history tracking logic like 'set' does + const replaceState = useCallback((newValue: T) => { + dispatch({ type: "replace", value: newValue }); + }, []); + + return { value, setValue, canUndo, canRedo, undo, redo, replaceState }; // Expose replaceState if needed +} + +export function usePlayHead(endDurationMs: number, startMs = 0) { + const [playing, setPlaying] = useState(false); + const [stoppedAtEnd, setStoppedAtEnd] = useState(false); + const currentTimestamp = useMotionValue(startMs); + const time = useTime(); + const lastAccessedTime = useMotionValue(0); + + const setTimestamp = (timestamp: number) => { + currentTimestamp.set(timestamp); + }; + + const togglePlaying = useCallback(() => { + setPlaying((p) => { + const newPlaying = !p; + if (newPlaying) { + if (stoppedAtEnd) { + currentTimestamp.set(0); + } + setStoppedAtEnd(false); + // When starting playback, initialize the lastAccessedTime + lastAccessedTime.set(time.get()); + } + return newPlaying; + }); + }, [stoppedAtEnd, lastAccessedTime, time, currentTimestamp]); + + useMotionValueEvent(time, "change", (currentTime) => { + if (!playing) { + return; + } + + const prevTime = lastAccessedTime.get(); + const elapsedMs = currentTime - prevTime; + + const newTimestamp = currentTimestamp.get() + elapsedMs; + + if (newTimestamp >= endDurationMs) { + setPlaying(false); + setStoppedAtEnd(true); + } else { + currentTimestamp.set(newTimestamp); + } + + lastAccessedTime.set(currentTime); + }); + + return { currentTimestamp, playing, togglePlaying, setTimestamp }; +} diff --git a/src/client/editor/motion-robot.tsx b/src/client/editor/motion-robot.tsx new file mode 100644 index 00000000..c4b591bc --- /dev/null +++ b/src/client/editor/motion-robot.tsx @@ -0,0 +1,91 @@ +import type { CSSProperties } from "react"; +import { forwardRef } from "react"; +import { motion } from "framer-motion"; +import { Tooltip } from "@blueprintjs/core"; +import { robotSize } from "../debug/simulator"; // Reuse size +import { robotColor, innerRobotColor } from "../check-dark-mode"; +import type { Coords } from "../../common/spline"; + +// Simple representation for display, doesn't need full simulation data +interface MotionRobotProps { + robotId: string; + position?: Coords; // Optional for initial render + style?: CSSProperties; + onTopOfRobotsCount?: number; // Simplified collision indication +} + +/** + * A Framer Motion animated robot component for the editor grid. + * Uses x, y, rotate for positioning and animation. + */ +export const MotionRobot = forwardRef( + function MotionRobot( + { robotId, position, style, onTopOfRobotsCount = 0 }, + ref, + ) { + // Convert position (if provided) to initial styles, but expect + // x, y, rotate to be controlled by useAnimate in the parent. + const initialStyle = + position ? + { + // Framer motion uses different origin - center based? Check docs. + // Let's assume x/y map directly for now and adjust if needed. + // The original used left/bottom. Motion x/y work from top/left. + x: `${position.x}px`, + y: `${position.y}px`, + // We apply rotation via the rotate transform property. + } + : {}; + + return ( + + +
+
+
+ + + ); + }, +); diff --git a/src/client/editor/points.tsx b/src/client/editor/points.tsx new file mode 100644 index 00000000..613f134c --- /dev/null +++ b/src/client/editor/points.tsx @@ -0,0 +1,113 @@ +import { ContextMenu, Menu, MenuItem, MenuDivider } from "@blueprintjs/core"; +import { useRef } from "react"; +import { type Point, SplinePointType, type Coords } from "../../common/spline"; +import { robotSize } from "../debug/simulator"; +import { GRID_CELL_PX } from "../../common/units"; +import { useDraggable } from "./hooks"; + +export function SplinePoint({ + point, + onMove, + onJumpToPoint, + onSwitchToQuadratic = undefined, + onSwitchToCubic = undefined, + onDelete = undefined, +}: { + point: Point; + onMove: (x: number, y: number) => void; + onDelete?: () => void; + onSwitchToQuadratic?: () => void; + onSwitchToCubic?: () => void; + onJumpToPoint: () => void; +}) { + // TODO: fix context menu positioning + const mainPointRef = useRef(null); + useDraggable(mainPointRef, (screenX, screenY) => + onMove(screenX + robotSize / 4, screenY + robotSize / 4), + ); + + const mainPointColor = + point.type === SplinePointType.StartPoint ? "blue" : "black"; + const mainCoords = + point.type === SplinePointType.StartPoint ? + { x: point.point.x * GRID_CELL_PX, y: point.point.y * GRID_CELL_PX } + : { + x: point.endPoint.x * GRID_CELL_PX, + y: point.endPoint.y * GRID_CELL_PX, + }; + + return ( + + {point.type === SplinePointType.CubicBezier && ( + + )} + {point.type === SplinePointType.QuadraticBezier && ( + + )} + + + + + + } + > + + + ); +} +export function SplineControlPoint({ + point, + onMove, +}: { + point: Coords; + onMove: (x: number, y: number) => void; +}) { + const controlPointRef = useRef(null); + useDraggable(controlPointRef, (screenX, screenY) => + onMove(screenX + robotSize / 4, screenY + robotSize / 4), + ); + + return ( + + ); +} diff --git a/src/client/editor/showfile-state.ts b/src/client/editor/showfile-state.ts new file mode 100644 index 00000000..d122531e --- /dev/null +++ b/src/client/editor/showfile-state.ts @@ -0,0 +1,837 @@ +// @ts-expect-error: chessbots client is a CommonJS module, but this library is a ES Module, so we need to tell TypeScript that it's okay +import { encode as cborEncode } from "cbor-x"; + +// @ts-expect-error: chessbots client is a CommonJS module, but this library is a ES Module, so we need to tell TypeScript that it's okay +import { fileOpen, fileSave } from "browser-fs-access"; + +import { diff } from "deep-object-diff"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import type { + StartPointEvent, + GoToPointEvent, + TimelineLayerType, + NonStartPointEvent, + WaitEvent, + TurnEvent, +} from "../../common/show"; +import { + createNewShowfile, + loadShowfileFromBinary, + TimelineEventTypes, + CHESSBOTS_SHOWFILE_MIME_TYPE, + CHESSBOTS_SHOWFILE_EXTENSION, +} from "../../common/show"; +import { + TimelineDurationUpdateMode, + GridCursorMode, +} from "../../common/show-interface-utils"; +import { SplinePointType } from "../../common/spline"; +import type { + CubicBezier, + QuadraticBezier, + Coords, + Midpoint, +} from "../../common/spline"; +import { + usePlayHead, + usePreventExitWithUnsavedChanges, + useStateWithTrackedHistory, +} from "./hooks"; +import { GRID_CELL_PX } from "../../common/units"; + +export function useShowfile() { + // used to store the initial showfile state before any changes were made in the editor, so we have something to compare against to see if there are unsaved changes + const [initialShow, setInitialShow] = useState(createNewShowfile()); + + // See comment on TimelineDurationUpdateMode declaration in src/common/show.ts for context + const [timelineDurationUpdateMode, setTimelineDurationUpdateMode] = + useState< + (typeof TimelineDurationUpdateMode)[keyof typeof TimelineDurationUpdateMode] + >(TimelineDurationUpdateMode.Ripple); + + const [gridCursorMode, setGridCursorMode] = useState< + (typeof GridCursorMode)[keyof typeof GridCursorMode] + >(GridCursorMode.Pen); + + const [defaultPointType, setDefaultPointType] = useState( + SplinePointType.QuadraticBezier, + ); + + const [defaultEventDurationMs, setDefaultEventDurationMs] = useState(3750); + + const [selectedLayerIndex, setSelectedLayerIndex] = useState(0); + const [selectedTimelineEventIndex, setSelectedTimelineEventIndex] = + useState<{ layerIndex: number; eventId: string } | null>(null); + + // used to store the handle to the file system file that the showfile is currently loaded from, + // so we can save to the same file we've already opened. not all the browsers' file system API + // implementations support this - Chrome does, Safari doesn't, not sure about Firefox. + const [fsHandle, setFsHandle] = useState(null); + + const [unsavedChanges, setUnsavedChanges] = + usePreventExitWithUnsavedChanges(); + + const { + value: show, + setValue: setShow, + canUndo, + canRedo, + undo, + redo, + } = useStateWithTrackedHistory(initialShow); + + // update the unsaved changes state whenever the showfile changes + useEffect(() => { + const result = diff(initialShow, show); + const differenceBetweenSavedShowAndCurrentShow = + Object.keys(result).length === 0; + if (differenceBetweenSavedShowAndCurrentShow) { + setUnsavedChanges(false); + } else { + setUnsavedChanges(true); + } + }, [initialShow, show, setUnsavedChanges]); + + // handles opening a showfile from the file system, parsing it, and loading it into the editor + const openShowfile = useCallback(async () => { + const blob = await fileOpen({ + mimeTypes: [CHESSBOTS_SHOWFILE_MIME_TYPE], + extensions: [CHESSBOTS_SHOWFILE_EXTENSION], + description: "Chess Bots Showfile", + }); + + const show = loadShowfileFromBinary( + new Uint8Array(await blob.arrayBuffer()), + ); + if (!show) return; + + setInitialShow(show); + setShow(show); + if (blob.handle) setFsHandle(blob.handle); + }, [setShow]); + + // handles encoding the showfile as a CBOR and saving it to the file system + const saveShowfile = useCallback(async () => { + const blob = new Blob([cborEncode(show)], { + type: CHESSBOTS_SHOWFILE_MIME_TYPE, + }); + await fileSave( + blob, + { + mimeTypes: [CHESSBOTS_SHOWFILE_MIME_TYPE], + extensions: [CHESSBOTS_SHOWFILE_EXTENSION], + fileName: show.name + CHESSBOTS_SHOWFILE_EXTENSION, + }, + fsHandle, + ); + + setInitialShow(show); + }, [fsHandle, show]); + + // handles loading an audio file from the file system, and adding it to the showfile + const loadAudioFromFile = useCallback(async () => { + const blob = await fileOpen({ + mimeTypes: ["audio/mpeg", "audio/wav"], + extensions: [".mp3", ".wav"], + }); + const audio = new Uint8Array(await blob.arrayBuffer()); + setShow({ + ...show, + audio: { + data: audio, + mimeType: blob.type, + }, + }); + }, [setShow, show]); + + const handleStartPointMove = useCallback( + (layerIndex: number, newCoords: Coords) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (layer) { + const { startPoint, remainingEvents } = layer; + const newStartPointEvent: StartPointEvent = { + ...startPoint, + target: { + ...startPoint.target, + point: { + x: newCoords.x / GRID_CELL_PX, + y: newCoords.y / GRID_CELL_PX, + }, + }, + }; + newTimeline[layerIndex] = { + startPoint: newStartPointEvent, + remainingEvents, + }; + setShow({ ...show, timeline: newTimeline }); + } + }, + [show, setShow], + ); + + const handlePointMove = useCallback( + (layerIndex: number, pointIndex: number, newCoords: Coords) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) return; + const { startPoint, remainingEvents } = layer; + const events = [...remainingEvents]; + const eventToUpdate = events[pointIndex]; + + if ( + eventToUpdate && + eventToUpdate.type === TimelineEventTypes.GoToPointEvent + ) { + events[pointIndex] = { + ...eventToUpdate, + target: { + ...eventToUpdate.target, + endPoint: { + x: newCoords.x / GRID_CELL_PX, + y: newCoords.y / GRID_CELL_PX, + }, + }, + }; + newTimeline[layerIndex] = { + startPoint, + remainingEvents: events, + }; + setShow({ ...show, timeline: newTimeline }); + } + }, + [show, setShow], + ); + + const handleControlPointMove = useCallback( + (layerIndex: number, pointIndex: number, newCoords: Coords) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) return; + const { startPoint, remainingEvents } = layer; + const events = [...remainingEvents]; + const eventToUpdate = events[pointIndex]; + + if (eventToUpdate?.type === TimelineEventTypes.GoToPointEvent) { + events[pointIndex] = { + ...eventToUpdate, + target: { + ...eventToUpdate.target, + controlPoint: { + x: newCoords.x / GRID_CELL_PX, + y: newCoords.y / GRID_CELL_PX, + }, + }, + }; + newTimeline[layerIndex] = { + startPoint, + remainingEvents: events, + }; + setShow({ ...show, timeline: newTimeline }); + } + }, + [show, setShow], + ); + const handleControlPoint2Move = useCallback( + (layerIndex: number, pointIndex: number, newCoords: Coords) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) return; + const { startPoint, remainingEvents } = layer; + const events = [...remainingEvents]; + const eventToUpdate = events[pointIndex]; + + if ( + eventToUpdate?.type === TimelineEventTypes.GoToPointEvent && + eventToUpdate.target.type === SplinePointType.CubicBezier + ) { + events[pointIndex] = { + ...eventToUpdate, + target: { + ...eventToUpdate.target, + controlPoint2: { + x: newCoords.x / GRID_CELL_PX, + y: newCoords.y / GRID_CELL_PX, + }, + }, + }; + newTimeline[layerIndex] = { + startPoint, + remainingEvents: events, + }; + setShow({ ...show, timeline: newTimeline }); + } + }, + [show, setShow], + ); + const handleDeleteStartPoint = useCallback( + (layerIndex: number) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) return; + + const { remainingEvents } = layer; + + // Promote the first GoToPoint event (if any) to be the new start point + const firstGoToPointIndex = remainingEvents.findIndex( + (e) => e.type === TimelineEventTypes.GoToPointEvent, + ); + + if (firstGoToPointIndex !== -1) { + const firstGoToPointEvent = remainingEvents[ + firstGoToPointIndex + ] as GoToPointEvent; + const newStartPointEvent: StartPointEvent = { + id: crypto.randomUUID(), + type: TimelineEventTypes.StartPointEvent, + durationMs: firstGoToPointEvent.durationMs, + target: { + type: SplinePointType.StartPoint, + point: firstGoToPointEvent.target.endPoint, + }, + }; + const newRemainingEvents = [ + ...remainingEvents.slice(0, firstGoToPointIndex), + ...remainingEvents.slice(firstGoToPointIndex + 1), + ]; + newTimeline[layerIndex] = { + startPoint: newStartPointEvent, + remainingEvents: newRemainingEvents, + }; + setShow({ ...show, timeline: newTimeline }); + } else { + console.warn( + "Tried to delete a start point with no subsequent GoTo points. Consider deleting layer instead.", + ); + } + }, + [show, setShow], + ); + + const handleDeletePoint = useCallback( + (layerIndex: number, pointIndex: number) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) { + return; + } + const { startPoint, remainingEvents } = layer; + const events = [ + ...remainingEvents.slice(0, pointIndex), + ...remainingEvents.slice(pointIndex + 1), + ]; // Remove the event + newTimeline[layerIndex] = { startPoint, remainingEvents: events }; + setShow({ ...show, timeline: newTimeline }); + }, + [show, setShow], + ); + + const handleSwitchPointType = useCallback( + ( + layerIndex: number, + pointIndex: number, + newType: + | SplinePointType.QuadraticBezier + | SplinePointType.CubicBezier, + ) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) return; + + const { startPoint, remainingEvents } = layer; + const events = [...remainingEvents]; + + const eventToUpdate = events[pointIndex]; + + if (eventToUpdate?.type !== TimelineEventTypes.GoToPointEvent) { + console.warn( + "Tried to switch point type on non-GoToPointEvent", + ); + return; + } + let newTarget: CubicBezier | QuadraticBezier; + if ( + newType === SplinePointType.CubicBezier && + eventToUpdate.target.type === SplinePointType.QuadraticBezier + ) { + newTarget = { + type: SplinePointType.CubicBezier, + controlPoint: { + x: + (eventToUpdate.target.endPoint.x - 20) / + GRID_CELL_PX, + y: + (eventToUpdate.target.endPoint.y - 20) / + GRID_CELL_PX, + }, + controlPoint2: { + x: + (eventToUpdate.target.endPoint.x + 20) / + GRID_CELL_PX, + y: + (eventToUpdate.target.endPoint.y + 20) / + GRID_CELL_PX, + }, + endPoint: eventToUpdate.target.endPoint, + }; + } else if ( + newType === SplinePointType.QuadraticBezier && + eventToUpdate.target.type === SplinePointType.CubicBezier + ) { + newTarget = { + type: SplinePointType.QuadraticBezier, + endPoint: eventToUpdate.target.endPoint, + controlPoint: { + x: + (eventToUpdate.target.endPoint.x + 20) / + GRID_CELL_PX, + y: + (eventToUpdate.target.endPoint.y + 20) / + GRID_CELL_PX, + }, + }; + } else { + console.warn("Tried to switch point type with invalid type"); + return; + } + + events[pointIndex] = { + ...eventToUpdate, + target: newTarget, + }; + newTimeline[layerIndex] = { startPoint, remainingEvents: events }; + setShow({ ...show, timeline: newTimeline }); + }, + [show, setShow], + ); + + const editName = useCallback( + (value: string) => setShow({ ...show, name: value }), + [show, setShow], + ); + + const addRobot = useCallback(() => { + const newLayer: TimelineLayerType = { + startPoint: { + id: crypto.randomUUID(), + type: TimelineEventTypes.StartPointEvent, + target: { + type: SplinePointType.StartPoint, + point: { + x: 0, + y: 10, + }, + }, + durationMs: defaultEventDurationMs, + }, + remainingEvents: [], + }; + const layers = [...show.timeline, newLayer]; + setShow({ + ...show, + timeline: layers, + }); + + setSelectedLayerIndex(layers.length - 1); + }, [show, setShow, defaultEventDurationMs]); + + // Calculate sequenceLengthMs dynamically + const sequenceLengthMs = useMemo(() => { + let maxDuration = 0; + show.timeline.forEach((layer) => { + let currentLayerDuration = 0; + // Add start point duration first if it exists + if (layer.startPoint) { + currentLayerDuration += layer.startPoint.durationMs; + } + // Add remaining events durations + layer.remainingEvents.forEach((event) => { + currentLayerDuration += event.durationMs; + }); + maxDuration = Math.max(maxDuration, currentLayerDuration); + }); + // Use the max duration across all layers. + // Provide a default minimum duration if there are no events. + return maxDuration > 0 ? maxDuration : 10000; // Default to 10s + }, [show.timeline]); + + // TODO: continue adding comments to code below this line + const { currentTimestamp, playing, togglePlaying, setTimestamp } = + usePlayHead(sequenceLengthMs); + + const startShow = useEffect(() => {}); + + const { audio } = show; + const audioRef = useRef(new Audio()); + useEffect(() => { + if (!audio) return; + + audioRef.current.src = URL.createObjectURL( + new Blob([audio.data], { + type: audio.mimeType, + }), + ); + audioRef.current.load(); + }, [audio]); + + useEffect(() => { + if (!audioRef.current) return; + + if (audioRef.current.readyState !== 4) { + return; + } + + if (playing && audioRef.current.paused) { + audioRef.current.currentTime = Math.min( + currentTimestamp.get() / 1000, + audioRef.current.duration, + ); + audioRef.current.play(); + } + + if (!playing && !audioRef.current.paused) { + audioRef.current.pause(); + } + }, [playing, currentTimestamp]); + + const deleteLayer = useCallback( + (i: number) => + setShow({ + ...show, + timeline: [ + ...show.timeline.slice(0, i), + ...show.timeline.slice(i + 1), + ], + }), + [show, setShow], + ); + + const updateTimelineEventOrders = useCallback( + (layerIndex: number, newList: NonStartPointEvent[]) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) return; + const { startPoint } = layer; + newTimeline[layerIndex] = { startPoint, remainingEvents: newList }; + setShow({ ...show, timeline: newTimeline }); + }, + [show, setShow], + ); + + const updateTimelineEventDurations = useCallback( + (layerIndex: number, eventId: string, deltaMs: number) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) { + console.log("Layer not found"); + return; + } + + const { startPoint, remainingEvents } = layer; + + const updatedEventList = [startPoint, ...remainingEvents]; + const eventToUpdateIndex = updatedEventList.findIndex( + (event) => event.id === eventId, + ); + if (eventToUpdateIndex === -1) { + console.log("Event not found", updatedEventList, layer); + return; + } + + const shouldUpdateSubsequentEventDuration = + timelineDurationUpdateMode === + TimelineDurationUpdateMode.Rolling && + eventToUpdateIndex < updatedEventList.length - 1; + + const eventToUpdate = updatedEventList[eventToUpdateIndex]; + const newDurationMs = eventToUpdate.durationMs + deltaMs; + eventToUpdate.durationMs = newDurationMs; + updatedEventList[eventToUpdateIndex] = eventToUpdate; + + if (shouldUpdateSubsequentEventDuration) { + const subsequentEventIndex = eventToUpdateIndex + 1; + const subsequentEvent = updatedEventList[subsequentEventIndex]; + const newSubsequentEventDurationMs = + subsequentEvent.durationMs - deltaMs; + subsequentEvent.durationMs = newSubsequentEventDurationMs; + updatedEventList[subsequentEventIndex] = subsequentEvent; + } + + newTimeline[layerIndex] = { + startPoint: updatedEventList[0] as StartPointEvent, + remainingEvents: updatedEventList.slice( + 1, + ) as NonStartPointEvent[], + }; + setShow({ ...show, timeline: newTimeline }); + }, + [show, setShow, timelineDurationUpdateMode], + ); + + const addPointToSelectedLayer = useCallback( + (x: number, y: number) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[selectedLayerIndex]; + if (!layer) return; + const { startPoint, remainingEvents } = layer; + const newEvents = [...remainingEvents]; + switch (defaultPointType) { + case SplinePointType.QuadraticBezier: + newEvents.push({ + type: TimelineEventTypes.GoToPointEvent, + durationMs: defaultEventDurationMs, + target: { + type: SplinePointType.QuadraticBezier, + controlPoint: { + x: (x - 10) / GRID_CELL_PX, + y: (y - 10) / GRID_CELL_PX, + }, + endPoint: { + x: x / GRID_CELL_PX, + y: y / GRID_CELL_PX, + }, + }, + id: crypto.randomUUID(), + }); + break; + case SplinePointType.CubicBezier: + newEvents.push({ + type: TimelineEventTypes.GoToPointEvent, + durationMs: defaultEventDurationMs, + target: { + type: SplinePointType.CubicBezier, + endPoint: { + x: x / GRID_CELL_PX, + y: y / GRID_CELL_PX, + }, + controlPoint: { + x: (x + 10) / GRID_CELL_PX, + y: (y + 10) / GRID_CELL_PX, + }, + controlPoint2: { + x: (x - 10) / GRID_CELL_PX, + y: (y - 10) / GRID_CELL_PX, + }, + }, + id: crypto.randomUUID(), + }); + break; + default: + console.warn("Tried to add point with invalid point type"); + return; + } + + newTimeline[selectedLayerIndex] = { + startPoint, + remainingEvents: newEvents, + }; + setShow({ ...show, timeline: newTimeline }); + }, + [ + show, + setShow, + selectedLayerIndex, + defaultPointType, + defaultEventDurationMs, + ], + ); + + const removeAudio = useCallback(() => { + setShow({ + ...show, + audio: undefined, + }); + }, [show, setShow]); + + const deleteTimelineEvent = useCallback( + (layerIndex: number, eventId: string) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) { + return; + } + const { startPoint, remainingEvents } = layer; + const eventIndex = remainingEvents.findIndex( + (event) => event.id === eventId, + ); + const events = [ + ...remainingEvents.slice(0, eventIndex), + ...remainingEvents.slice(eventIndex + 1), + ]; // Remove the event + newTimeline[layerIndex] = { startPoint, remainingEvents: events }; + setShow({ ...show, timeline: newTimeline }); + }, + [show, setShow], + ); + + const addWaitEventAtIndex = useCallback( + (layerIndex: number, eventIndex: number) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) return; + const { startPoint, remainingEvents } = layer; + const events = [...remainingEvents]; + const eventToAdd: WaitEvent = { + id: crypto.randomUUID(), + type: TimelineEventTypes.WaitEvent, + durationMs: defaultEventDurationMs, + }; + events.splice(eventIndex, 0, eventToAdd); + newTimeline[layerIndex] = { + startPoint, + remainingEvents: events, + }; + setShow({ ...show, timeline: newTimeline }); + }, + [show, defaultEventDurationMs, setShow], + ); + + const addTurnEventAtIndex = useCallback( + (layerIndex: number, eventIndex: number) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) return; + const { startPoint, remainingEvents } = layer; + const events = [...remainingEvents]; + const eventToAdd: TurnEvent = { + id: crypto.randomUUID(), + type: TimelineEventTypes.TurnEvent, + durationMs: defaultEventDurationMs, + radians: 2 * Math.PI, + }; + events.splice(eventIndex, 0, eventToAdd); + newTimeline[layerIndex] = { + startPoint, + remainingEvents: events, + }; + setShow({ ...show, timeline: newTimeline }); + }, + [show, defaultEventDurationMs, setShow], + ); + + const getLayerIndexFromEventId = useCallback( + (eventId: string) => { + const layerIndex = show.timeline.findIndex((layer) => + layer.remainingEvents.find((event) => event.id === eventId), + ); + return layerIndex; + }, + [show], + ); + + const addBulkEventsToSelectedLayer = useCallback( + (events: NonStartPointEvent[]) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[selectedLayerIndex]; + if (!layer) return; + const { startPoint } = layer; + newTimeline[selectedLayerIndex] = { + startPoint, + remainingEvents: [...layer.remainingEvents, ...events], + }; + setShow({ ...show, timeline: newTimeline }); + }, + [show, selectedLayerIndex, setShow], + ); + + const showfileApi = useMemo( + () => ({ + updateTimelineEventOrders, + show, + unsavedChanges, + loadAudioFromFile, + handleStartPointMove, + handlePointMove, + handleControlPointMove, + handleControlPoint2Move, + handleDeleteStartPoint, + handleDeletePoint, + handleSwitchPointType, + saveShowfile, + openShowfile, + editName, + undo, + redo, + addRobot, + currentTimestamp, + playing, + togglePlaying, + startShow, + deleteLayer, + canRedo, + canUndo, + sequenceLengthMs, + timelineDurationUpdateMode, + setTimelineDurationUpdateMode, + updateTimelineEventDurations, + gridCursorMode, + setGridCursorMode, + defaultPointType, + setDefaultPointType, + setDefaultEventDurationMs, + defaultEventDurationMs, + addPointToSelectedLayer, + setSelectedLayerIndex, + selectedLayerIndex, + removeAudio, + deleteTimelineEvent, + setTimestamp, + addWaitEventAtIndex, + addTurnEventAtIndex, + getLayerIndexFromEventId, + addBulkEventsToSelectedLayer, + selectedTimelineEventIndex, + setSelectedTimelineEventIndex, + }), + [ + updateTimelineEventOrders, + show, + unsavedChanges, + loadAudioFromFile, + handleStartPointMove, + handlePointMove, + handleControlPointMove, + handleControlPoint2Move, + handleDeleteStartPoint, + handleDeletePoint, + handleSwitchPointType, + saveShowfile, + openShowfile, + editName, + undo, + redo, + addRobot, + currentTimestamp, + playing, + togglePlaying, + startShow, + deleteLayer, + canRedo, + canUndo, + sequenceLengthMs, + timelineDurationUpdateMode, + setTimelineDurationUpdateMode, + updateTimelineEventDurations, + gridCursorMode, + setGridCursorMode, + defaultPointType, + setDefaultPointType, + setDefaultEventDurationMs, + defaultEventDurationMs, + addPointToSelectedLayer, + setSelectedLayerIndex, + selectedLayerIndex, + removeAudio, + deleteTimelineEvent, + setTimestamp, + addWaitEventAtIndex, + addTurnEventAtIndex, + getLayerIndexFromEventId, + addBulkEventsToSelectedLayer, + selectedTimelineEventIndex, + setSelectedTimelineEventIndex, + ], + ); + + return showfileApi; +} diff --git a/src/client/editor/spline-editor.tsx b/src/client/editor/spline-editor.tsx new file mode 100644 index 00000000..0897cbf1 --- /dev/null +++ b/src/client/editor/spline-editor.tsx @@ -0,0 +1,186 @@ +import { useMemo, useCallback, Fragment } from "react"; +import { + type Coords, + SplinePointType, + splineToSvgDrawAttribute, +} from "../../common/spline"; +import { SplinePoint, SplineControlPoint } from "./points"; +import { + type TimelineLayerType, + timelineLayerToSpline, + TimelineEventTypes, +} from "../../common/show"; +import { GRID_CELL_PX } from "../../common/units"; + +interface SplineEditorProps { + layer: TimelineLayerType; + onStartPointMove: (newCoords: Coords) => void; + onPointMove: (pointIndex: number, newCoords: Coords) => void; + onControlPointMove: (pointIndex: number, newCoords: Coords) => void; + onControlPoint2Move: (pointIndex: number, newCoords: Coords) => void; + onDeleteStartPoint: () => void; + onDeletePoint: (pointIndex: number) => void; + onSwitchPointType: ( + pointIndex: number, + newType: SplinePointType.QuadraticBezier | SplinePointType.CubicBezier, + ) => void; +} + +export function SplineEditor({ + layer, + onStartPointMove, + onPointMove, + onControlPointMove, + onControlPoint2Move, + onDeleteStartPoint, + onDeletePoint, + onSwitchPointType, +}: SplineEditorProps) { + const { remainingEvents } = layer; + const spline = useMemo(() => timelineLayerToSpline(layer), [layer]); + + const path = useMemo(() => splineToSvgDrawAttribute(spline), [spline]); + + const getOriginalEventIndex = useCallback( + (goToPointIndex: number): number => { + let count = 0; + for (let i = 0; i < remainingEvents.length; i++) { + if ( + remainingEvents[i].type === + TimelineEventTypes.GoToPointEvent + ) { + if (count === goToPointIndex) { + return i; + } + count++; + } + } + return -1; + }, + [remainingEvents], + ); + + return ( + <> + + + + 0 ? onDeleteStartPoint : undefined + } + onMove={(x, y) => onStartPointMove({ x, y })} + onJumpToPoint={() => { + // TODO: i don't like that we're touching elements by ids and mucking w dom outside of react - see if theres a way to refactor this in a more idiomatic react way + const el = document.getElementById( + `timeline-event-${layer.startPoint.id}`, + ); + navigateAndIdentifyTimelineElement(el as HTMLElement); + }} + /> + {spline.points.map((point, index) => ( + + + onDeletePoint(getOriginalEventIndex(index)) + } + onMove={(x, y) => + onPointMove(getOriginalEventIndex(index), { x, y }) + } + onSwitchToQuadratic={() => + onSwitchPointType( + getOriginalEventIndex(index), + SplinePointType.QuadraticBezier, + ) + } + onSwitchToCubic={() => + onSwitchPointType( + getOriginalEventIndex(index), + SplinePointType.CubicBezier, + ) + } + onJumpToPoint={() => { + const originalIndex = getOriginalEventIndex(index); + const el = document.getElementById( + `timeline-event-${layer.remainingEvents[originalIndex].id}`, + ); + if (!el) return; + navigateAndIdentifyTimelineElement(el); + }} + /> + {/* TODO: add line between control point and end point */} + {point.type === SplinePointType.CubicBezier && ( + <> + + onControlPointMove( + getOriginalEventIndex(index), + { x, y }, + ) + } + /> + + onControlPoint2Move( + getOriginalEventIndex(index), + { x, y }, + ) + } + /> + + )} + {point.type === SplinePointType.QuadraticBezier && ( + + onControlPointMove( + getOriginalEventIndex(index), + { x, y }, + ) + } + /> + )} + + ))} + + ); +} + +function navigateAndIdentifyTimelineElement(element: HTMLElement) { + element.scrollIntoView({ + inline: "start", + block: "end", + }); + const originalBackgroundColor = element.style.backgroundColor; + const flashColor = "black"; + element.style.backgroundColor = flashColor; + setTimeout(() => { + element.style.backgroundColor = originalBackgroundColor; + }, 100); + setTimeout(() => { + element.style.backgroundColor = flashColor; + }, 200); + setTimeout(() => { + element.style.backgroundColor = originalBackgroundColor; + }, 300); + setTimeout(() => { + element.style.backgroundColor = flashColor; + }, 400); + setTimeout(() => { + element.style.backgroundColor = originalBackgroundColor; + }, 500); +} diff --git a/src/client/editor/timeline.tsx b/src/client/editor/timeline.tsx new file mode 100644 index 00000000..1eaa9b8e --- /dev/null +++ b/src/client/editor/timeline.tsx @@ -0,0 +1,286 @@ +import { + Card, + SectionCard, + Button, + Text, + ContextMenu, + Menu, + MenuItem, +} from "@blueprintjs/core"; +import interact from "interactjs"; +import { useDragControls, Reorder } from "motion/react"; +import type { PropsWithChildren } from "react"; +import { forwardRef, useEffect, useRef } from "react"; +import type { TimelineEvents } from "../../common/show"; +import { + EVENT_TYPE_TO_COLOR, + millisToPixels, + RULER_TICK_GAP_PX, + RULER_TICK_INTERVAL_MS, + RULER_EXTRA_TICK_COUNT, + pixelsToMillis, +} from "../../common/show-interface-utils"; + +export function ReorderableTimelineEvent({ + event, + onDurationChange, + onDelete, + onAddWaitEvent, + onAddTurnEvent, + onSelect, + selected, +}: PropsWithChildren<{ + event: TimelineEvents; + onDurationChange: (ms: number) => void; + onDelete?: () => void; + onAddWaitEvent?: (position: "before" | "after") => void; + onAddTurnEvent?: (position: "before" | "after") => void; + onSelect: () => void; + selected: boolean; +}>) { + const controls = useDragControls(); + + return ( + + controls.start(e)} + onDurationChange={onDurationChange} + onDelete={onDelete} + onAddWaitEvent={onAddWaitEvent} + onAddTurnEvent={onAddTurnEvent} + onSelect={onSelect} + selected={selected} + /> + + ); +} + +export function TimelineEvent(props: { + event: TimelineEvents; + onPointerDownOnDragHandle?: ( + event: React.PointerEvent, + ) => void; + onDurationChange?: (deltaMs: number) => void; + onDelete?: () => void; + onAddWaitEvent?: (position: "before" | "after") => void; + onAddTurnEvent?: (position: "before" | "after") => void; + selected: boolean; + onSelect: () => void; +}) { + const ref = useRef(null); + const { + event, + onPointerDownOnDragHandle, + onDurationChange, + onDelete, + onAddWaitEvent, + onAddTurnEvent, + onSelect, + selected, + } = props; + useEffect(() => { + if (!onDurationChange) return; + if (!ref.current) return; + const el = ref.current; + + interact(el).resizable({ + edges: { + right: true, + }, + listeners: { + move: function (event) { + event.preventDefault(); + event.stopPropagation(); + onDurationChange( + Math.floor(pixelsToMillis(event.deltaRect.width)), + ); + }, + }, + }); + }, [event.type, onDurationChange]); + + return ( + + {onDelete && ( + + )} + {onAddWaitEvent && ( + <> + onAddWaitEvent("after")} + /> + onAddWaitEvent("before")} + /> + + )} + {onAddTurnEvent && ( + <> + onAddTurnEvent("after")} + /> + onAddTurnEvent("before")} + /> + + )} + + } + > + + + {event.type} + + {onPointerDownOnDragHandle && ( + + + + + + + + + + + + + + )} + + + ); +} + +export const TimelineLayer = forwardRef< + HTMLDivElement, + PropsWithChildren<{ + title: string; + onDelete?: () => void; + onActive?: () => void; + active?: boolean; + }> +>(function TimelineLayer({ title, children, onDelete, onActive, active }, ref) { + // TODO: add borders between columns. v low priority + // https://codepen.io/Kevin-Geary/pen/BavwqYX + // https://www.youtube.com/watch?v=EQYft7JPKto + return ( + + + + {title}{" "} + {onActive && ( +