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/package.json b/package.json index e179fb94..9d2b2d73 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "dependencies": { "chess.js": "^1.0.0-beta.6", "eslint-plugin-tsdoc": "^0.2.17", - "typescript": "^4.9.3", + "typescript": "5.5.4", "vite-express": "^0.11.1" }, "devDependencies": { @@ -39,12 +39,12 @@ "nodemon": "^3.0.3", "prettier": "^3.2.4", "ts-node": "^10.9.1", - "typedoc": "^0.23.0", + "typedoc": "^0.28.0", "vite": "^4.5.5", "vitest": "^3.0.8" }, "optionalDependencies": { "bufferutil": "^4.0.8" }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "packageManager": "yarn@1.19.0+sha512.40b88ca23f991e8da44f5ef1d6dedeaceea0cd1fbdc526b9cfb2e67a2d6a60cd528f7ef088816febb910707fa792c86c3b47f4dc89970a57e410a5209ec32b79" } diff --git a/src/client/debug/debug.tsx b/src/client/debug/debug.tsx index eeadd893..46342f4d 100644 --- a/src/client/debug/debug.tsx +++ b/src/client/debug/debug.tsx @@ -36,6 +36,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) { @@ -49,6 +55,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 4c6d628b..6600abda 100644 --- a/src/client/debug/simulator.tsx +++ b/src/client/debug/simulator.tsx @@ -1,5 +1,13 @@ import { Card, Button, H1, Tooltip, Collapse } from "@blueprintjs/core"; -import { useEffect, useReducer, useRef, useState } from "react"; +import { + CSSProperties, + forwardRef, + type PropsWithChildren, + useEffect, + useReducer, + useRef, + useState, +} from "react"; import { useNavigate } from "react-router-dom"; import { get, useSocket } from "../api"; import { @@ -22,9 +30,11 @@ import { } from "../check-dark-mode"; const tileSize = 60; -const robotSize = tileSize / 2; +export const robotSize = tileSize / 2; const cellCount = 12; +export type RobotState = { [robotId: string]: SimulatedRobotLocation }; + /** * Creates a robot simulator for testing robot commands * @@ -34,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 } | { @@ -150,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 @@ -336,25 +354,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..df5e0dc0 --- /dev/null +++ b/src/client/editor/editor.tsx @@ -0,0 +1,836 @@ +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 { + NonStartPointEvent, + 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 { Midpoint, SplinePointType } from "../../common/spline"; +import { + motion, + MotionValue, + useTransform, + useAnimate, + useMotionValueEvent, +} from "motion/react"; +import { useShowfile } from "./showfile-state"; +import { Reorder } from "motion/react"; +import { getRobotStateAtTime } from "../../common/getRobotStateAtTime"; +import { MotionRobot } from "./motion-robot"; +import { TimelineLayerType } from "../../common/show"; +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..fd00b2d9 --- /dev/null +++ b/src/client/editor/motion-robot.tsx @@ -0,0 +1,90 @@ +import { CSSProperties, 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 { 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..1f97fda6 --- /dev/null +++ b/src/client/editor/points.tsx @@ -0,0 +1,109 @@ +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 { 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 ? + point.point + : point.endPoint; + + 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..bd2f1921 --- /dev/null +++ b/src/client/editor/showfile-state.ts @@ -0,0 +1,783 @@ +// @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 { + createNewShowfile, + loadShowfileFromBinary, + StartPointEvent, + TimelineEventTypes, + GoToPointEvent, + TimelineLayerType, + NonStartPointEvent, + CHESSBOTS_SHOWFILE_MIME_TYPE, + CHESSBOTS_SHOWFILE_EXTENSION, + WaitEvent, + TurnEvent, +} from "../../common/show"; +import { + TimelineDurationUpdateMode, + GridCursorMode, +} from "../../common/show-interface-utils"; +import { + SplinePointType, + CubicBezier, + QuadraticBezier, + type Coords, + type Midpoint, +} from "../../common/spline"; +import { + usePlayHead, + usePreventExitWithUnsavedChanges, + useStateWithTrackedHistory, +} from "./hooks"; + +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: newCoords }, + }; + 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: newCoords, + }, + }; + 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: newCoords, + }, + }; + 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: newCoords, + }, + }; + 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.toSpliced( + 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.toSpliced(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, + y: eventToUpdate.target.endPoint.y - 20, + }, + controlPoint2: { + x: eventToUpdate.target.endPoint.x + 20, + y: eventToUpdate.target.endPoint.y + 20, + }, + 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, + y: eventToUpdate.target.endPoint.y + 20, + }, + }; + } 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: 70, + }, + }, + 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.toSpliced(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, y: y - 10 }, + endPoint: { x, y }, + }, + id: crypto.randomUUID(), + }); + break; + case SplinePointType.CubicBezier: + newEvents.push({ + type: TimelineEventTypes.GoToPointEvent, + durationMs: defaultEventDurationMs, + target: { + type: SplinePointType.CubicBezier, + endPoint: { x, y }, + controlPoint: { x: x + 10, y: y + 10 }, + controlPoint2: { x: x - 10, y: y - 10 }, + }, + 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.toSpliced(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..975229df --- /dev/null +++ b/src/client/editor/spline-editor.tsx @@ -0,0 +1,184 @@ +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"; + +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..e5571698 --- /dev/null +++ b/src/client/editor/timeline.tsx @@ -0,0 +1,285 @@ +import { + Card, + SectionCard, + Button, + Text, + ContextMenu, + Menu, + MenuItem, +} from "@blueprintjs/core"; +import interact from "interactjs"; +import { useDragControls, Reorder } from "motion/react"; +import { forwardRef, PropsWithChildren, useEffect, useRef } from "react"; +import { 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 && ( +