diff --git a/ci/build_and_deploy_to.sh b/ci/build_and_deploy_to.sh index 61c5ecf..eb5d387 100755 --- a/ci/build_and_deploy_to.sh +++ b/ci/build_and_deploy_to.sh @@ -1,4 +1,5 @@ -set -e +#!/usr/bin/env bash +set -xeu export OUTPUT=$1 export BASE=$2 @@ -10,9 +11,9 @@ echo "VITE_BASE=$BASE" >> .env.PROD ci/build_react.msh -mkdir -p $OUTPUT/profiles/ +mkdir -p "$OUTPUT"/profiles/ -sed -E 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php > $OUTPUT/config.php +sed -E 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php > "$OUTPUT"/config.php sed -E "s/const BASE_PATH = .*;/const BASE_PATH = \"$(sed s/\\//\\\\\\//g <<< "$BASE")\";/" profiles/prod-config-profile.php > $OUTPUT/profiles/prod-config-profile.php -cp -r vendor sql src public $OUTPUT +cp -r vendor sql src public "$OUTPUT" diff --git a/package.json b/package.json index 7eb6cfb..d89f82b 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,6 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "@types/jest": "^27.5.2", - "@types/node": "^16.18.59", "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", "eslint-plugin-react-refresh": "^0.4.5", @@ -23,7 +21,7 @@ "scripts": { "start": "vite --host", "build": "vite build", - "test": "vite test", + "test": "vitest", "format": "prettier --config .prettierrc '.' --write", "tsc": "tsc" }, @@ -34,8 +32,10 @@ "eslint": "^8.53.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", + "jsdom": "^24.0.0", "prettier": "^3.1.0", "typescript": "^5.2.2", - "vite-plugin-svgr": "^4.1.0" + "vite-plugin-svgr": "^4.1.0", + "vitest": "^1.3.1" } } diff --git a/src/App.tsx b/src/App.tsx index f79f052..29ab83b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom" import { Header } from "./pages/template/Header.tsx" import "./style/app.css" -import { lazy, ReactNode, Suspense, useEffect } from "react" +import { lazy, ReactNode, Suspense } from "react" import { BASE } from "./Constants.ts" const HomePage = lazy(() => import("./pages/HomePage.tsx")) @@ -15,7 +15,6 @@ const NewTacticPage = lazy(() => import("./pages/NewTacticPage.tsx")) const Editor = lazy(() => import("./pages/Editor.tsx")) export default function App() { - function suspense(node: ReactNode) { return ( Loading, please wait...

}> diff --git a/src/Fetcher.ts b/src/Fetcher.ts index 08872c2..a44022f 100644 --- a/src/Fetcher.ts +++ b/src/Fetcher.ts @@ -61,7 +61,8 @@ async function handleResponse( const expirationDate = Date.parse( response.headers.get("Next-Authorization-Expiration-Date")!, ) - saveSession({ ...session, auth: { token: nextToken, expirationDate } }) + if (nextToken && expirationDate) + saveSession({ ...session, auth: { token: nextToken, expirationDate } }) return response } diff --git a/src/assets/icon/remove.svg b/src/assets/icon/remove.svg index 29aec4e..a584e15 100644 --- a/src/assets/icon/remove.svg +++ b/src/assets/icon/remove.svg @@ -1,5 +1 @@ - - - - + \ No newline at end of file diff --git a/src/components/Rack.tsx b/src/components/Rack.tsx index 2a7511f..2e4e75a 100644 --- a/src/components/Rack.tsx +++ b/src/components/Rack.tsx @@ -4,7 +4,7 @@ import Draggable from "react-draggable" export interface RackProps { id: string objects: E[] - onChange: (objects: E[]) => void + onChange?: (objects: E[]) => void canDetach: (ref: HTMLDivElement) => boolean onElementDetached: (ref: HTMLDivElement, el: E) => void render: (e: E) => ReactElement @@ -44,7 +44,7 @@ export function Rack({ const index = objects.findIndex( (o) => o.key === element.key, ) - onChange(objects.toSpliced(index, 1)) + if (onChange) onChange(objects.toSpliced(index, 1)) onElementDetached(ref, element) }} diff --git a/src/components/SplitLayout.tsx b/src/components/SplitLayout.tsx new file mode 100644 index 0000000..89c0364 --- /dev/null +++ b/src/components/SplitLayout.tsx @@ -0,0 +1,56 @@ +import React, { ReactNode, useCallback, useRef, useState } from "react" + +export interface SplitLayoutProps { + children: [ReactNode, ReactNode] + rightWidth: number + onRightWidthChange: (w: number) => void +} + +export default function SplitLayout({ + children, + rightWidth, + onRightWidthChange, +}: SplitLayoutProps) { + const curtainRef = useRef(null) + + const resize = useCallback( + (e: React.MouseEvent) => { + const sliderPosX = e.clientX + const curtainWidth = + curtainRef.current!.getBoundingClientRect().width + + onRightWidthChange((sliderPosX / curtainWidth) * 100) + }, + [curtainRef, onRightWidthChange], + ) + + const [resizing, setResizing] = useState(false) + + return ( +
setResizing(false)}> +
+ {children[0]} +
+
setResizing(true)} + style={{ + width: 4, + height: "100%", + backgroundColor: "grey", + cursor: "col-resize", + userSelect: "none", + }}>
+ +
+ {children[1]} +
+
+ ) +} diff --git a/src/components/arrows/BendableArrow.tsx b/src/components/arrows/BendableArrow.tsx index 18aeea3..4b3615f 100644 --- a/src/components/arrows/BendableArrow.tsx +++ b/src/components/arrows/BendableArrow.tsx @@ -27,6 +27,7 @@ import { import "../../style/bendable_arrows.css" import Draggable from "react-draggable" +import { Segment } from "../../model/tactic/Action.ts" export interface BendableArrowProps { area: RefObject @@ -57,11 +58,6 @@ const ArrowStyleDefaults: ArrowStyle = { color: "black", } -export interface Segment { - next: Pos | string - controlPoint?: Pos -} - /** * Given a circle shaped by a central position, and a radius, return * a position that is constrained on its perimeter, pointing to the direction @@ -389,6 +385,7 @@ export default function BendableArrow({ useEffect(() => { const observer = new MutationObserver(update) const config = { attributes: true } + if (typeof startPos == "string") { observer.observe(document.getElementById(startPos)!, config) } @@ -402,6 +399,14 @@ export default function BendableArrow({ return () => observer.disconnect() }, [startPos, segments, update]) + useEffect(() => { + const observer = new ResizeObserver(update) + + observer.observe(area.current!, {}) + + return () => observer.disconnect() + }) + // Adds a selection handler // Also force an update when the window is resized useEffect(() => { diff --git a/src/components/editor/BasketCourt.tsx b/src/components/editor/BasketCourt.tsx index 4f4c216..68021f3 100644 --- a/src/components/editor/BasketCourt.tsx +++ b/src/components/editor/BasketCourt.tsx @@ -1,7 +1,7 @@ import { ReactElement, ReactNode, RefObject } from "react" import { Action } from "../../model/tactic/Action" -import { CourtAction } from "./CourtAction.tsx" +import { CourtAction } from "./CourtAction" import { ComponentId, TacticComponent } from "../../model/tactic/Tactic" export interface BasketCourtProps { diff --git a/src/components/editor/CourtAction.tsx b/src/components/editor/CourtAction.tsx index c26c0d9..84f7fd5 100644 --- a/src/components/editor/CourtAction.tsx +++ b/src/components/editor/CourtAction.tsx @@ -1,7 +1,7 @@ import { Action, ActionKind } from "../../model/tactic/Action" -import BendableArrow from "../../components/arrows/BendableArrow" +import BendableArrow from "../arrows/BendableArrow" import { RefObject } from "react" -import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" +import { MoveToHead, ScreenHead } from "../actions/ArrowAction" import { ComponentId } from "../../model/tactic/Tactic" export interface CourtActionProps { diff --git a/src/components/editor/CourtBall.tsx b/src/components/editor/CourtBall.tsx index c9c48dc..503f44c 100644 --- a/src/components/editor/CourtBall.tsx +++ b/src/components/editor/CourtBall.tsx @@ -1,34 +1,36 @@ -import { useRef } from "react" +import { KeyboardEventHandler, RefObject, useRef } from "react" import Draggable from "react-draggable" import { BallPiece } from "./BallPiece" -import { NULL_POS } from "../../geo/Pos" +import { NULL_POS, Pos } from "../../geo/Pos" import { Ball } from "../../model/tactic/CourtObjects" export interface CourtBallProps { + ball: Ball +} + +export interface EditableCourtBallProps extends CourtBallProps { onPosValidated: (rect: DOMRect) => void onRemove: () => void - ball: Ball } -export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) { +export function CourtBall({ + onPosValidated, + ball, + onRemove, +}: EditableCourtBallProps) { const pieceRef = useRef(null) - const { x, y } = ball.pos - - return ( - - onPosValidated(pieceRef.current!.getBoundingClientRect()) - } - position={NULL_POS} - nodeRef={pieceRef}> + function courtBallPiece( + { x, y }: Pos, + pieceRef?: RefObject, + onKeyUp?: KeyboardEventHandler, + ) { + return (
{ - if (e.key == "Delete") onRemove() - }} + onKeyUp={onKeyUp} style={{ position: "absolute", left: `${x * 100}%`, @@ -36,6 +38,23 @@ export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) { }}>
+ ) + } + + if (ball.frozen) { + return courtBallPiece(ball.pos) + } + + return ( + + onPosValidated(pieceRef.current!.getBoundingClientRect()) + } + position={NULL_POS} + nodeRef={pieceRef}> + {courtBallPiece(ball.pos, pieceRef, (e) => { + if (e.key == "Delete") onRemove() + })} ) } diff --git a/src/components/editor/CourtPlayer.tsx b/src/components/editor/CourtPlayer.tsx index 6b8e8dd..c25b36a 100644 --- a/src/components/editor/CourtPlayer.tsx +++ b/src/components/editor/CourtPlayer.tsx @@ -1,4 +1,10 @@ -import React, { ReactNode, RefObject, useCallback, useRef } from "react" +import React, { + KeyboardEventHandler, + ReactNode, + RefObject, + useCallback, + useRef, +} from "react" import "../../style/player.css" import Draggable from "react-draggable" import { PlayerPiece } from "./PlayerPiece" @@ -8,38 +14,53 @@ import { NULL_POS, Pos, ratioWithinBase } from "../../geo/Pos" export interface CourtPlayerProps { playerInfo: PlayerInfo className?: string + availableActions: (ro: HTMLElement) => ReactNode[] +} +export interface EditableCourtPlayerProps extends CourtPlayerProps { + courtRef: RefObject onPositionValidated: (newPos: Pos) => void onRemove: () => void - courtRef: RefObject - availableActions: (ro: HTMLElement) => ReactNode[] } const MOVE_AREA_SENSIBILITY = 0.001 export const PLAYER_RADIUS_PIXELS = 20 +export function CourtPlayer({ + playerInfo, + className, + availableActions, +}: CourtPlayerProps) { + const pieceRef = useRef(null) + + return courtPlayerPiece({ + playerInfo, + pieceRef, + className, + availableActions: () => availableActions(pieceRef.current!), + }) +} + /** * A player that is placed on the court, which can be selected, and moved in the associated bounds * */ -export default function CourtPlayer({ +export function EditableCourtPlayer({ playerInfo, className, + courtRef, onPositionValidated, onRemove, - courtRef, availableActions, -}: CourtPlayerProps) { - const usesBall = playerInfo.ballState != BallState.NONE - const { x, y } = playerInfo.pos +}: EditableCourtPlayerProps) { const pieceRef = useRef(null) + const { x, y } = playerInfo.pos return ( { const pieceBounds = pieceRef.current!.getBoundingClientRect() @@ -50,37 +71,64 @@ export default function CourtPlayer({ if ( Math.abs(pos.x - x) >= MOVE_AREA_SENSIBILITY || Math.abs(pos.y - y) >= MOVE_AREA_SENSIBILITY - ) + ) { onPositionValidated(pos) + } }, [courtRef, onPositionValidated, x, y])}> -
-
) => { - if (e.key == "Delete") onRemove() - }, - [onRemove], - )}> -
- {availableActions(pieceRef.current!)} -
- -
-
+ {courtPlayerPiece({ + playerInfo, + className, + pieceRef, + availableActions: () => availableActions(pieceRef.current!), + onKeyUp: useCallback( + (e: React.KeyboardEvent) => { + if (e.key == "Delete") onRemove() + }, + [onRemove], + ), + })}
) } + +interface CourtPlayerPieceProps { + playerInfo: PlayerInfo + className?: string + pieceRef?: RefObject + availableActions?: () => ReactNode[] + onKeyUp?: KeyboardEventHandler +} + +function courtPlayerPiece({ + playerInfo, + className, + pieceRef, + onKeyUp, + availableActions, +}: CourtPlayerPieceProps) { + const usesBall = playerInfo.ballState != BallState.NONE + const { x, y } = playerInfo.pos + + return ( +
+
+ {availableActions && ( +
{availableActions()}
+ )} + +
+
+ ) +} diff --git a/src/components/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx new file mode 100644 index 0000000..952b268 --- /dev/null +++ b/src/components/editor/StepsTree.tsx @@ -0,0 +1,154 @@ +import "../../style/steps_tree.css" +import { StepInfoNode } from "../../model/tactic/Tactic" +import BendableArrow from "../arrows/BendableArrow" +import { ReactNode, useMemo, useRef } from "react" +import AddSvg from "../../assets/icon/add.svg?react" +import RemoveSvg from "../../assets/icon/remove.svg?react" +import { getStepName } from "../../editor/StepsDomain.ts" + +export interface StepsTreeProps { + root: StepInfoNode + selectedStepId: number + onAddChildren: (parent: StepInfoNode) => void + onRemoveNode: (node: StepInfoNode) => void + onStepSelected: (node: StepInfoNode) => void +} + +export default function StepsTree({ + root, + selectedStepId, + onAddChildren, + onRemoveNode, + onStepSelected, +}: StepsTreeProps) { + return ( +
+ +
+ ) +} + +interface StepsTreeContentProps { + node: StepInfoNode + rootNode: StepInfoNode + + selectedStepId: number + onAddChildren: (parent: StepInfoNode) => void + onRemoveNode: (node: StepInfoNode) => void + onStepSelected: (node: StepInfoNode) => void +} + +function StepsTreeNode({ + node, + rootNode, + selectedStepId, + onAddChildren, + onRemoveNode, + onStepSelected, +}: StepsTreeContentProps) { + const ref = useRef(null) + + return ( +
+ {node.children.map((child) => ( + {}} + forceStraight={true} + wavy={false} + //TODO remove magic constants + startRadius={10} + endRadius={10} + /> + ))} + onAddChildren(node)} + onRemoveButtonClicked={ + rootNode.id === node.id + ? undefined + : () => onRemoveNode(node) + } + onSelected={() => onStepSelected(node)}> +

+ {useMemo( + () => getStepName(rootNode, node.id), + [node.id, rootNode], + )} +

+
+
+ {node.children.map((child) => ( + + ))} +
+
+ ) +} + +interface StepPieceProps { + id: number + isSelected: boolean + onAddButtonClicked?: () => void + onRemoveButtonClicked?: () => void + onSelected: () => void + children?: ReactNode +} + +function StepPiece({ + id, + isSelected, + onAddButtonClicked, + onRemoveButtonClicked, + onSelected, + children, +}: StepPieceProps) { + return ( +
+
+ {onAddButtonClicked && ( + + )} + {onRemoveButtonClicked && ( + + )} +
+ {children} +
+ ) +} diff --git a/src/editor/ActionsDomains.ts b/src/editor/ActionsDomains.ts index c9e7e66..8ad9e5a 100644 --- a/src/editor/ActionsDomains.ts +++ b/src/editor/ActionsDomains.ts @@ -7,15 +7,14 @@ import { import { ratioWithinBase } from "../geo/Pos" import { ComponentId, + StepContent, TacticComponent, - TacticContent, } from "../model/tactic/Tactic" import { overlaps } from "../geo/Box" import { Action, ActionKind, moves } from "../model/tactic/Action" import { removeBall, updateComponent } from "./TacticContentDomains" import { areInSamePath, - changePlayerBallState, getComponent, getOrigin, getPlayerNextTo, @@ -212,8 +211,8 @@ export function createAction( origin: PlayerLike, courtBounds: DOMRect, arrowHead: DOMRect, - content: TacticContent, -): { createdAction: Action; newContent: TacticContent } { + content: StepContent, +): { createdAction: Action; newContent: StepContent } { /** * Creates a new phantom component. * Be aware that this function will reassign the `content` parameter. @@ -371,8 +370,8 @@ export function createAction( export function removeAllActionsTargeting( componentId: ComponentId, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { const components = [] for (let i = 0; i < content.components.length; i++) { const component = content.components[i] @@ -391,9 +390,10 @@ export function removeAllActionsTargeting( export function removeAction( origin: TacticComponent, actionIdx: number, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { const action = origin.actions[actionIdx] + origin = { ...origin, actions: origin.actions.toSpliced(actionIdx, 1), @@ -410,20 +410,27 @@ export function removeAction( (origin.type === "player" || origin.type === "phantom") ) { if (target.type === "player" || target.type === "phantom") - content = changePlayerBallState(target, BallState.NONE, content) + content = + spreadNewStateFromOriginStateChange( + target, + BallState.NONE, + content, + ) ?? content if (origin.ballState === BallState.PASSED) { - content = changePlayerBallState( - origin, - BallState.HOLDS_BY_PASS, - content, - ) + content = + spreadNewStateFromOriginStateChange( + origin, + BallState.HOLDS_BY_PASS, + content, + ) ?? content } else if (origin.ballState === BallState.PASSED_ORIGIN) { - content = changePlayerBallState( - origin, - BallState.HOLDS_ORIGIN, - content, - ) + content = + spreadNewStateFromOriginStateChange( + origin, + BallState.HOLDS_ORIGIN, + content, + ) ?? content } } @@ -458,14 +465,15 @@ export function removeAction( * @param origin * @param newState * @param content + * @returns the new state if it has been updated, or null if no changes were operated */ export function spreadNewStateFromOriginStateChange( origin: PlayerLike, newState: BallState, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent | null { if (origin.ballState === newState) { - return content + return null } origin = { @@ -551,11 +559,12 @@ export function spreadNewStateFromOriginStateChange( content = updateComponent(origin, content) } - content = spreadNewStateFromOriginStateChange( - actionTarget, - targetState, - content, - ) + content = + spreadNewStateFromOriginStateChange( + actionTarget, + targetState, + content, + ) ?? content } return content diff --git a/src/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts index 0473d72..1b3c918 100644 --- a/src/editor/PlayerDomains.ts +++ b/src/editor/PlayerDomains.ts @@ -6,9 +6,10 @@ import { } from "../model/tactic/Player" import { ComponentId, + StepContent, TacticComponent, - TacticContent, } from "../model/tactic/Tactic" + import { removeComponent, updateComponent } from "./TacticContentDomains" import { removeAllActionsTargeting, @@ -54,11 +55,22 @@ export function getPlayerNextTo( : getComponent(pathItems[targetIdx - 1], components) } -//FIXME this function can be a bottleneck if the phantom's position is -// following another phantom and / or the origin of the phantom is another +export function getPrecomputedPosition( + phantom: PlayerPhantom, + computedPositions: Map, +): Pos | undefined { + const pos = phantom.pos + + // If the position is already known and fixed, return the pos + if (pos.type === "fixed") return pos + + return computedPositions.get(phantom.id) +} + export function computePhantomPositioning( phantom: PlayerPhantom, - content: TacticContent, + content: StepContent, + computedPositions: Map, area: DOMRect, ): Pos { const positioning = phantom.pos @@ -66,6 +78,9 @@ export function computePhantomPositioning( // If the position is already known and fixed, return the pos if (positioning.type === "fixed") return positioning + const storedPos = computedPositions.get(phantom.id) + if (storedPos) return storedPos + // If the position is to determine (positioning.type = "follows"), determine the phantom's pos // by calculating it from the referent position, and the action that targets the referent. @@ -76,7 +91,12 @@ export function computePhantomPositioning( const referentPos = referent.type === "player" ? referent.pos - : computePhantomPositioning(referent, content, area) + : computePhantomPositioning( + referent, + content, + computedPositions, + area, + ) // Get the origin const origin = getOrigin(phantom, components) @@ -110,6 +130,7 @@ export function computePhantomPositioning( ? computePhantomPositioning( playerBeforePhantom, content, + computedPositions, area, ) : playerBeforePhantom.pos @@ -118,21 +139,29 @@ export function computePhantomPositioning( const segment = posWithinBase(relativeTo(referentPos, pivotPoint), area) const segmentLength = norm(segment) - const phantomDistanceFromReferent = PLAYER_RADIUS_PIXELS //TODO Place this in constants const segmentProjection = minus(area, { - x: (segment.x / segmentLength) * phantomDistanceFromReferent, - y: (segment.y / segmentLength) * phantomDistanceFromReferent, + x: (segment.x / segmentLength) * PLAYER_RADIUS_PIXELS, + y: (segment.y / segmentLength) * PLAYER_RADIUS_PIXELS, }) const segmentProjectionRatio: Pos = ratioWithinBase(segmentProjection, area) - return add(referentPos, segmentProjectionRatio) + const result = add(referentPos, segmentProjectionRatio) + computedPositions.set(phantom.id, result) + return result } export function getComponent( id: string, components: TacticComponent[], ): T { - return components.find((c) => c.id === id)! as T + return tryGetComponent(id, components)! +} + +export function tryGetComponent( + id: string, + components: TacticComponent[], +): T | undefined { + return components.find((c) => c.id === id) as T } export function areInSamePath(a: PlayerLike, b: PlayerLike) { @@ -171,8 +200,8 @@ export function isNextInPath( export function clearPlayerPath( player: Player, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { if (player.path == null) { return content } @@ -192,8 +221,8 @@ export function clearPlayerPath( function removeAllPhantomsAttached( to: ComponentId, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { let i = 0 while (i < content.components.length) { const component = content.components[i] @@ -213,8 +242,8 @@ function removeAllPhantomsAttached( export function removePlayer( player: PlayerLike, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { content = removeAllActionsTargeting(player.id, content) content = removeAllPhantomsAttached(player.id, content) @@ -228,7 +257,7 @@ export function removePlayer( content.components, )! const actions = playerBefore.actions.filter( - (a) => a.target === pos.attach, + (a) => a.target !== pos.attach, ) content = updateComponent( { @@ -253,10 +282,12 @@ export function removePlayer( const actionTarget = content.components.find( (c) => c.id === action.target, )! as PlayerLike - return spreadNewStateFromOriginStateChange( - actionTarget, - BallState.NONE, - content, + return ( + spreadNewStateFromOriginStateChange( + actionTarget, + BallState.NONE, + content, + ) ?? content ) } @@ -266,8 +297,8 @@ export function removePlayer( export function truncatePlayerPath( player: Player, phantom: PlayerPhantom, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { if (player.path == null) return content const path = player.path! @@ -296,11 +327,3 @@ export function truncatePlayerPath( content, ) } - -export function changePlayerBallState( - player: PlayerLike, - newState: BallState, - content: TacticContent, -): TacticContent { - return spreadNewStateFromOriginStateChange(player, newState, content) -} diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts new file mode 100644 index 0000000..e788a9c --- /dev/null +++ b/src/editor/StepsDomain.ts @@ -0,0 +1,112 @@ +import { StepInfoNode } from "../model/tactic/Tactic" + +export function addStepNode( + root: StepInfoNode, + parent: StepInfoNode, + child: StepInfoNode, +): StepInfoNode { + if (root.id === parent.id) { + return { + ...root, + children: root.children.concat(child), + } + } + + return { + ...root, + children: root.children.map((c) => addStepNode(c, parent, child)), + } +} + +export function getStepName(root: StepInfoNode, step: number): string { + let ord = 1 + const nodes = [root] + while (nodes.length > 0) { + const node = nodes.pop()! + + if (node.id === step) break + + ord++ + nodes.push(...[...node.children].reverse()) + } + + return ord.toString() +} + +export function getStepNode( + root: StepInfoNode, + stepId: number, +): StepInfoNode | undefined { + if (root.id === stepId) return root + + for (const child of root.children) { + const result = getStepNode(child, stepId) + if (result) return result + } +} + +export function removeStepNode( + root: StepInfoNode, + targetId: number, +): StepInfoNode | undefined { + const path = getPathTo(root, targetId) + + path.reverse() + + const [removedNode, ...pathToRoot] = path + + let child = removedNode + + for (const node of pathToRoot) { + child = { + id: node.id, + children: node.children.flatMap((c) => { + if (c.id === removedNode.id) return [] + else if (c.id === child.id) { + return [child] + } + return [c] + }), + } + } + + return child +} + +export function getPathTo( + root: StepInfoNode, + targetId: number, +): StepInfoNode[] { + if (root.id === targetId) return [root] + + for (const child of root.children) { + const subPath = getPathTo(child, targetId) + if (subPath.length > 0) return [root, ...subPath] + } + return [] +} + +/** + * Returns an available identifier that is not already present into the given node tree + * @param root + */ +export function getAvailableId(root: StepInfoNode): number { + const acc = (root: StepInfoNode): number => + Math.max(root.id, ...root.children.map(acc)) + return acc(root) + 1 +} + +export function getParent( + root: StepInfoNode, + node: StepInfoNode, +): StepInfoNode | null { + if (root.children.find((n) => n.id === node.id)) return root + + for (const child of root.children) { + const result = getParent(child, node) + if (result != null) { + return result + } + } + return null +} diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 1f3a9cc..319e4a0 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -1,9 +1,11 @@ -import { Pos, ratioWithinBase } from "../geo/Pos" +import { equals, Pos, ratioWithinBase } from "../geo/Pos" + import { BallState, Player, PlayerInfo, PlayerLike, + PlayerPhantom, PlayerTeam, } from "../model/tactic/Player" import { @@ -14,13 +16,20 @@ import { } from "../model/tactic/CourtObjects" import { ComponentId, + StepContent, TacticComponent, - TacticContent, } from "../model/tactic/Tactic" + import { overlaps } from "../geo/Box" import { RackedCourtObject, RackedPlayer } from "./RackedItems" -import { changePlayerBallState, getComponent, getOrigin } from "./PlayerDomains" +import { + getComponent, + getOrigin, + getPrecomputedPosition, + tryGetComponent, +} from "./PlayerDomains" import { ActionKind } from "../model/tactic/Action.ts" +import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts" export function placePlayerAt( refBounds: DOMRect, @@ -38,6 +47,7 @@ export function placePlayerAt( ballState: BallState.NONE, path: null, actions: [], + frozen: false, } } @@ -45,14 +55,14 @@ export function placeObjectAt( refBounds: DOMRect, courtBounds: DOMRect, rackedObject: RackedCourtObject, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { const pos = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject switch (rackedObject.key) { - case BALL_TYPE: + case BALL_TYPE: { const playerCollidedIdx = getComponentCollided( refBounds, content.components, @@ -67,9 +77,10 @@ export function placeObjectAt( id: BALL_ID, pos, actions: [], + frozen: false, } break - + } default: throw new Error("unknown court object " + rackedObject.key) } @@ -82,9 +93,9 @@ export function placeObjectAt( export function dropBallOnComponent( targetedComponentIdx: number, - content: TacticContent, + content: StepContent, setAsOrigin: boolean, -): TacticContent { +): StepContent { const component = content.components[targetedComponentIdx] if (component.type === "player" || component.type === "phantom") { @@ -95,13 +106,15 @@ export function dropBallOnComponent( ? BallState.HOLDS_ORIGIN : BallState.HOLDS_BY_PASS - content = changePlayerBallState(component, newState, content) + content = + spreadNewStateFromOriginStateChange(component, newState, content) ?? + content } return removeBall(content) } -export function removeBall(content: TacticContent): TacticContent { +export function removeBall(content: StepContent): StepContent { const ballObjIdx = content.components.findIndex((o) => o.type == "ball") if (ballObjIdx == -1) { @@ -117,8 +130,8 @@ export function removeBall(content: TacticContent): TacticContent { export function placeBallAt( refBounds: DOMRect, courtBounds: DOMRect, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { if (!overlaps(courtBounds, refBounds)) { return removeBall(content) } @@ -141,6 +154,7 @@ export function placeBallAt( id: BALL_ID, pos, actions: [], + frozen: false, } let components = content.components @@ -162,9 +176,9 @@ export function moveComponent( component: TacticComponent, info: PlayerInfo, courtBounds: DOMRect, - content: TacticContent, - removed: (content: TacticContent) => TacticContent, -): TacticContent { + content: StepContent, + removed: (content: StepContent) => StepContent, +): StepContent { const playerBounds = document .getElementById(info.id)! .getBoundingClientRect() @@ -232,8 +246,8 @@ export function moveComponent( export function removeComponent( componentId: ComponentId, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { return { ...content, components: content.components.filter((c) => c.id !== componentId), @@ -242,8 +256,8 @@ export function removeComponent( export function updateComponent( component: TacticComponent, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { return { ...content, components: content.components.map((c) => @@ -287,3 +301,174 @@ export function getRackPlayers( ) .map((key) => ({ team, key })) } + +/** + * Returns a step content that only contains the terminal state of each components inside the given content + * @param content + * @param computedPositions + */ +export function computeTerminalState( + content: StepContent, + computedPositions: Map, +): StepContent { + const nonPhantomComponents: (Player | CourtObject)[] = + content.components.filter( + (c): c is Exclude => + c.type !== "phantom", + ) + + const componentsTargetedState = nonPhantomComponents.map((comp) => + comp.type === "player" + ? getPlayerTerminalState(comp, content, computedPositions) + : { + ...comp, + frozen: true, + }, + ) + + return { + components: componentsTargetedState, + } +} + +function getPlayerTerminalState( + player: Player, + content: StepContent, + computedPositions: Map, +): Player { + function stateAfter(state: BallState): BallState { + switch (state) { + case BallState.HOLDS_ORIGIN: + return BallState.HOLDS_ORIGIN + case BallState.PASSED_ORIGIN: + case BallState.PASSED: + return BallState.NONE + case BallState.HOLDS_BY_PASS: + return BallState.HOLDS_ORIGIN + case BallState.NONE: + return BallState.NONE + } + } + + function getTerminalPos(component: PlayerLike): Pos { + if (component.type === "phantom") { + const pos = getPrecomputedPosition(component, computedPositions) + if (!pos) + throw new Error( + `Attempted to get the terminal state of a step content with missing position for phantom ${component.id}`, + ) + return pos + } + return component.pos + } + + const phantoms = player.path?.items + if (!phantoms || phantoms.length === 0) { + const pos = getTerminalPos(player) + + return { + ...player, + ballState: stateAfter(player.ballState), + actions: [], + pos, + frozen: true, + } + } + const lastPhantomId = phantoms[phantoms.length - 1] + const lastPhantom = content.components.find( + (c) => c.id === lastPhantomId, + )! as PlayerPhantom + + const pos = getTerminalPos(lastPhantom) + + return { + type: "player", + path: { items: [] }, + role: player.role, + team: player.team, + + actions: [], + ballState: stateAfter(lastPhantom.ballState), + id: player.id, + pos, + frozen: true, + } +} + +export function drainTerminalStateOnChildContent( + parentTerminalState: StepContent, + childContent: StepContent, +): StepContent | null { + let gotUpdated = false + + for (const parentComponent of parentTerminalState.components) { + let childComponent = tryGetComponent( + parentComponent.id, + childContent.components, + ) + + if (!childComponent) { + //if the child does not contain the parent's component, add it to the children's content. + childContent = { + ...childContent, + components: [...childContent.components, parentComponent], + } + gotUpdated = true + continue + } + + // ensure that the component is a player + if ( + parentComponent.type !== "player" || + childComponent.type !== "player" + ) { + continue + } + + const newContentResult = spreadNewStateFromOriginStateChange( + childComponent, + parentComponent.ballState, + childContent, + ) + if (newContentResult) { + gotUpdated = true + childContent = newContentResult + childComponent = getComponent( + childComponent.id, + newContentResult?.components, + ) + } + // update the position of the player if it has been moved + // also force update if the child component is not frozen (the component was introduced previously by the child step but the parent added it afterward) + if ( + !childComponent.frozen || + !equals(childComponent.pos, parentComponent.pos) + ) { + gotUpdated = true + childContent = updateComponent( + { + ...childComponent, + frozen: true, + pos: parentComponent.pos, + }, + childContent, + ) + } + } + + const initialChildCompsCount = childContent.components.length + + //filter out all frozen components that are not present on the parent's terminal state anymore + childContent = { + components: childContent.components.filter( + (comp) => + comp.type === "phantom" || + !comp.frozen || + tryGetComponent(comp.id, parentTerminalState.components), + ), + } + + gotUpdated ||= childContent.components.length !== initialChildCompsCount + + return gotUpdated ? childContent : null +} diff --git a/src/geo/Pos.ts b/src/geo/Pos.ts index be7a704..0e591b3 100644 --- a/src/geo/Pos.ts +++ b/src/geo/Pos.ts @@ -3,6 +3,10 @@ export interface Pos { y: number } +export function equals(a: Pos, b: Pos): boolean { + return a.x === b.x && a.y === b.y +} + export const NULL_POS: Pos = { x: 0, y: 0 } /** diff --git a/src/model/tactic/Action.ts b/src/model/tactic/Action.ts index b2dca4f..d4e459f 100644 --- a/src/model/tactic/Action.ts +++ b/src/model/tactic/Action.ts @@ -1,5 +1,4 @@ import { Pos } from "../../geo/Pos" -import { Segment } from "../../components/arrows/BendableArrow" import { ComponentId } from "./Tactic" export enum ActionKind { @@ -11,6 +10,11 @@ export enum ActionKind { export type Action = MovementAction +export interface Segment { + next: Pos | string + controlPoint?: Pos +} + export interface MovementAction { type: ActionKind target: ComponentId | Pos diff --git a/src/model/tactic/CourtObjects.ts b/src/model/tactic/CourtObjects.ts index 5f72199..55eaf6f 100644 --- a/src/model/tactic/CourtObjects.ts +++ b/src/model/tactic/CourtObjects.ts @@ -1,10 +1,10 @@ -import { Component } from "./Tactic" +import { Component, Frozable } from "./Tactic" import { Pos } from "../../geo/Pos.ts" export const BALL_ID = "ball" export const BALL_TYPE = "ball" +export type Ball = Component & Frozable + //place here all different kinds of objects export type CourtObject = Ball - -export type Ball = Component diff --git a/src/model/tactic/Player.ts b/src/model/tactic/Player.ts index 2dee897..bd781ac 100644 --- a/src/model/tactic/Player.ts +++ b/src/model/tactic/Player.ts @@ -1,4 +1,4 @@ -import { Component, ComponentId } from "./Tactic" +import { Component, ComponentId, Frozable } from "./Tactic" import { Pos } from "../../geo/Pos.ts" export type PlayerId = string @@ -14,7 +14,7 @@ export enum PlayerTeam { * All information about a player */ export interface PlayerInfo { - readonly id: string + readonly id: ComponentId /** * the player's team * */ @@ -25,28 +25,20 @@ export interface PlayerInfo { * */ readonly role: string - /** - * True if the player has a basketball - */ readonly ballState: BallState readonly pos: Pos } export enum BallState { - NONE, - HOLDS_ORIGIN, - HOLDS_BY_PASS, - PASSED, - PASSED_ORIGIN, + NONE = "NONE", + HOLDS_ORIGIN = "HOLDS_ORIGIN", + HOLDS_BY_PASS = "HOLDS_BY_PASS", + PASSED = "PASSED", + PASSED_ORIGIN = "PASSED_ORIGIN", } -export interface Player extends Component<"player", Pos>, PlayerInfo { - /** - * True if the player has a basketball - */ - readonly ballState: BallState - +export interface Player extends Component<"player", Pos>, PlayerInfo, Frozable { readonly path: MovementPath | null } diff --git a/src/model/tactic/Tactic.ts b/src/model/tactic/Tactic.ts index 0ad312c..ccf6d43 100644 --- a/src/model/tactic/Tactic.ts +++ b/src/model/tactic/Tactic.ts @@ -2,21 +2,30 @@ import { Player, PlayerPhantom } from "./Player" import { Action } from "./Action" import { CourtObject } from "./CourtObjects" -export type CourtType = "HALF" | "PLAIN" +export interface TacticInfo { + readonly id: number + readonly name: string + readonly courtType: CourtType + readonly rootStepNode: StepInfoNode +} + +export interface TacticStep { + readonly stepId: number + readonly content: StepContent +} -export interface Tactic { - id: number - name: string - courtType: CourtType - content: TacticContent +export interface StepContent { + readonly components: TacticComponent[] } -export interface TacticContent { - components: TacticComponent[] +export interface StepInfoNode { + readonly id: number + readonly children: StepInfoNode[] } export type TacticComponent = Player | CourtObject | PlayerPhantom export type ComponentId = string +export type CourtType = "PLAIN" | "HALF" export interface Component { /** @@ -32,3 +41,7 @@ export interface Component { readonly actions: Action[] } + +export interface Frozable { + readonly frozen: boolean +} diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 83c4dac..a0b6708 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -1,6 +1,5 @@ import { CSSProperties, - Dispatch, RefObject, SetStateAction, useCallback, @@ -20,12 +19,12 @@ import { Rack } from "../components/Rack" import { PlayerPiece } from "../components/editor/PlayerPiece" import { + ComponentId, CourtType, - Tactic, + StepContent, + StepInfoNode, TacticComponent, - TacticContent, } from "../model/tactic/Tactic" -import { fetchAPI, fetchAPIGet } from "../Fetcher" import SavingState, { SaveState, @@ -36,7 +35,10 @@ import { BALL_TYPE } from "../model/tactic/CourtObjects" import { CourtAction } from "../components/editor/CourtAction" import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" import { overlaps } from "../geo/Box" + import { + computeTerminalState, + drainTerminalStateOnChildContent, dropBallOnComponent, getComponentCollided, getRackPlayers, @@ -47,6 +49,7 @@ import { removeBall, updateComponent, } from "../editor/TacticContentDomains" + import { BallState, Player, @@ -54,194 +57,261 @@ import { PlayerLike, PlayerTeam, } from "../model/tactic/Player" + import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" -import CourtPlayer from "../components/editor/CourtPlayer" +import { + CourtPlayer, + EditableCourtPlayer, +} from "../components/editor/CourtPlayer.tsx" import { createAction, getActionKind, isActionValid, removeAction, + spreadNewStateFromOriginStateChange, } from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" import { Action, ActionKind } from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" import { - changePlayerBallState, computePhantomPositioning, getOrigin, removePlayer, } from "../editor/PlayerDomains" import { CourtBall } from "../components/editor/CourtBall" -import { useNavigate, useParams } from "react-router-dom" -import { DEFAULT_TACTIC_NAME } from "./NewTacticPage.tsx" +import StepsTree from "../components/editor/StepsTree" +import { + addStepNode, + getParent, + getStepNode, + removeStepNode, +} from "../editor/StepsDomain" +import SplitLayout from "../components/SplitLayout.tsx" +import { ServiceError, TacticService } from "../service/TacticService.ts" +import { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts" +import { APITacticService } from "../service/APITacticService.ts" +import { useParams } from "react-router-dom" const ERROR_STYLE: CSSProperties = { borderColor: "red", } -const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content" -const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" +type ComputedRelativePositions = Map -export interface EditorViewProps { - tactic: Tactic - onContentChange: (tactic: TacticContent) => Promise - onNameChange: (name: string) => Promise -} -interface TacticDto { - id: number - name: string - courtType: CourtType - content: string +type ComputedStepContent = { + content: StepContent + relativePositions: ComputedRelativePositions } -interface EditorPageProps { +export interface EditorPageProps { guestMode: boolean } -export default function EditorPage({ guestMode }: EditorPageProps) { - const [tactic, setTactic] = useState(() => { - if (guestMode) { - return { - id: -1, - courtType: "PLAIN", - content: '{"components": []}', - name: DEFAULT_TACTIC_NAME, - } - } - return null - }) - const { tacticId: idStr } = useParams() - const id = guestMode ? -1 : parseInt(idStr!) - const navigation = useNavigate() - - useEffect(() => { - if (guestMode) return +export default function Editor({ guestMode }: EditorPageProps) { + return +} - async function initialize() { - const infoResponsePromise = fetchAPIGet(`tactics/${id}`) - const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/1`) +interface EditorService { + addStep( + parent: StepInfoNode, + content: StepContent, + ): Promise - const infoResponse = await infoResponsePromise - const contentResponse = await contentResponsePromise + removeStep(step: number): Promise - if (infoResponse.status == 401 || contentResponse.status == 401) { - navigation("/login") - return - } + selectStep(step: number): Promise - const { name, courtType } = await infoResponse.json() - const content = await contentResponse.text() + setContent(content: SetStateAction): void - setTactic({ id, name, courtType, content }) - } + setName(name: string): Promise +} - initialize() - }, [guestMode, id, idStr, navigation]) +function EditorPortal({ guestMode }: EditorPageProps) { + const { tacticId: idStr } = useParams() - if (tactic) { - return ( - - ) + if (guestMode || !idStr) { + return } - return -} - -function EditorLoadingScreen() { - return
Loading Editor, please wait...
+ return } -export interface EditorProps { - id: number - name: string - content: string - courtType: CourtType -} +function EditorPageWrapper({ service }: { service: TacticService }) { + const [panicMessage, setPanicMessage] = useState() + const [stepId, setStepId] = useState() + const [tacticName, setTacticName] = useState() + const [courtType, setCourtType] = useState() + const [stepsTree, setStepsTree] = useState() -function Editor({ id, name, courtType, content }: EditorProps) { - const isInGuestMode = id == -1 + const courtRef = useRef(null) - const storageContent = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) - const editorContent = - isInGuestMode && storageContent != null ? storageContent : content + const saveContent = useCallback( + async (content: StepContent) => { + const result = await service.saveContent(stepId!, content) - const storageName = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) - const editorName = isInGuestMode && storageName != null ? storageName : name + if (typeof result === "string") return SaveStates.Err - const navigate = useNavigate() + await updateStepContents( + stepId!, + stepsTree!, + async (id) => { + const content = await service.getContent(id) + if (typeof content === "string") + throw new Error( + "Error when retrieving children content", + ) - return ( - { - if (isInGuestMode) { - localStorage.setItem( - GUEST_MODE_CONTENT_STORAGE_KEY, - JSON.stringify(content), + const courtBounds = + courtRef.current!.getBoundingClientRect() + const relativePositions = computeRelativePositions( + courtBounds, + content, ) - return SaveStates.Guest - } - const response = await fetchAPI( - `tactics/${id}/steps/1`, - { content }, - "PUT", - ) - if (response.status == 401) { - navigate("/login") - } - return response.ok ? SaveStates.Ok : SaveStates.Err - }} - onNameChange={async (name: string) => { - if (isInGuestMode) { - localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) - return true //simulate that the name has been changed - } + return { + content, + relativePositions, + } + }, + async (id, content) => { + const result = await service.saveContent(id, content) + if (typeof result === "string") + throw new Error("Error when updating children content") + }, + ) + return SaveStates.Ok + }, + [service, stepId, stepsTree], + ) + + const [stepContent, setStepContent, saveState] = + useContentState( + { components: [] }, + SaveStates.Ok, + useMemo(() => debounceAsync(saveContent, 250), [saveContent]), + ) - const response = await fetchAPI( - `tactics/${id}/name`, - { name }, - "PUT", + const isNotInit = !tacticName || !stepId || !stepsTree || !courtType + + useEffect(() => { + async function init() { + const contextResult = await service.getContext() + if (typeof contextResult === "string") { + setPanicMessage( + "There has been an error retrieving the editor initial context : " + + contextResult, ) - if (response.status == 401) { - navigate("/login") - } - return response.ok - }} + return + } + const stepId = contextResult.stepsTree.id + setStepsTree(contextResult.stepsTree) + setStepId(stepId) + setCourtType(contextResult.courtType) + setTacticName(contextResult.name) + + const contentResult = await service.getContent(stepId) + + if (typeof contentResult === "string") { + setPanicMessage( + "There has been an error retrieving the tactic's root step content : " + + contentResult, + ) + return + } + setStepContent(contentResult, false) + } + + if (isNotInit) init() + }, [isNotInit, service, setStepContent]) + + const editorService: EditorService = useMemo( + () => ({ + async addStep( + parent: StepInfoNode, + content: StepContent, + ): Promise { + const result = await service.addStep(parent, content) + if (typeof result !== "string") + setStepsTree(addStepNode(stepsTree!, parent, result)) + return result + }, + async removeStep(step: number): Promise { + const result = await service.removeStep(step) + if (typeof result !== "string") + setStepsTree(removeStepNode(stepsTree!, step)) + return result + }, + + setContent(content: StepContent) { + setStepContent(content, true) + }, + + async setName(name: string): Promise { + const result = await service.setName(name) + if (typeof result === "string") return SaveStates.Err + setTacticName(name) + return SaveStates.Ok + }, + + async selectStep(step: number): Promise { + const result = await service.getContent(step) + if (typeof result === "string") return result + setStepId(step) + setStepContent(result, false) + }, + }), + [service, setStepContent, stepsTree], + ) + + if (panicMessage) { + return

{panicMessage}

+ } + + if (isNotInit) { + return

Retrieving editor context. Please wait...

+ } + + return ( + ) } -function EditorView({ - tactic: { id, name, content: initialContent, courtType }, - onContentChange, - onNameChange, -}: EditorViewProps) { - const isInGuestMode = id == -1 +export interface EditorViewProps { + stepsTree: StepInfoNode + name: string + courtType: CourtType + content: StepContent + contentSaveState: SaveState + stepId: number + courtRef: RefObject + + service: EditorService +} + +function EditorPage({ + name, + courtType, + content, + stepId, + contentSaveState, + stepsTree, + courtRef, + service, +}: EditorViewProps) { const [titleStyle, setTitleStyle] = useState({}) - const [content, setContent, saveState] = useContentState( - initialContent, - isInGuestMode ? SaveStates.Guest : SaveStates.Ok, - useMemo(() => debounceAsync(onContentChange), [onContentChange]), - ) - const [allies, setAllies] = useState(() => - getRackPlayers(PlayerTeam.Allies, content.components), - ) - const [opponents, setOpponents] = useState(() => - getRackPlayers(PlayerTeam.Opponents, content.components), - ) + const allies = getRackPlayers(PlayerTeam.Allies, content.components) + const opponents = getRackPlayers(PlayerTeam.Opponents, content.components) const [objects, setObjects] = useState(() => isBallOnCourt(content) ? [] : [{ key: "ball" }], @@ -251,64 +321,59 @@ function EditorView({ null, ) - const courtRef = useRef(null) + const [isStepsTreeVisible, setStepsTreeVisible] = useState(false) + + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) + + const [editorContentCurtainWidth, setEditorContentCurtainWidth] = + useState(80) + + const relativePositions = useMemo(() => { + const courtBounds = courtRef.current?.getBoundingClientRect() + return courtBounds + ? computeRelativePositions(courtBounds, content) + : new Map() + }, [content, courtRef]) const setComponents = (action: SetStateAction) => { - setContent((c) => ({ + service.setContent((c) => ({ ...c, components: typeof action == "function" ? action(c.components) : action, })) } - const courtBounds = useCallback( - () => courtRef.current!.getBoundingClientRect(), - [courtRef], - ) - useEffect(() => { setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) }, [setObjects, content]) const insertRackedPlayer = (player: Player) => { - let setter - switch (player.team) { - case PlayerTeam.Opponents: - setter = setOpponents - break - case PlayerTeam.Allies: - setter = setAllies - } if (player.ballState == BallState.HOLDS_BY_PASS) { setObjects([{ key: "ball" }]) } - setter((players) => [ - ...players, - { - team: player.team, - pos: player.role, - key: player.role, - }, - ]) } const doRemovePlayer = useCallback( (component: PlayerLike) => { - setContent((c) => removePlayer(component, c)) + service.setContent((c) => removePlayer(component, c)) if (component.type == "player") insertRackedPlayer(component) }, - [setContent], + [service], ) const doMoveBall = useCallback( (newBounds: DOMRect, from?: PlayerLike) => { - setContent((content) => { + service.setContent((content) => { if (from) { - content = changePlayerBallState( - from, - BallState.NONE, - content, - ) + content = + spreadNewStateFromOriginStateChange( + from, + BallState.NONE, + content, + ) ?? content } content = placeBallAt(newBounds, courtBounds(), content) @@ -316,12 +381,12 @@ function EditorView({ return content }) }, - [courtBounds, setContent], + [courtBounds, service], ) const validatePlayerPosition = useCallback( (player: PlayerLike, info: PlayerInfo, newPos: Pos) => { - setContent((content) => + service.setContent((content) => moveComponent( newPos, player, @@ -336,12 +401,13 @@ function EditorView({ ), ) }, - [courtBounds, setContent], + [courtBounds, service], ) const renderAvailablePlayerActions = useCallback( (info: PlayerInfo, player: PlayerLike) => { let canPlaceArrows: boolean + let isFrozen: boolean = false if (player.type == "player") { canPlaceArrows = @@ -349,6 +415,7 @@ function EditorView({ player.actions.findIndex( (p) => p.type != ActionKind.SHOOT, ) == -1 + isFrozen = player.frozen } else { const origin = getOrigin(player, content.components) const path = origin.path! @@ -376,21 +443,28 @@ function EditorView({ playerInfo={info} content={content} courtRef={courtRef} - setContent={setContent} - /> - ), - (info.ballState === BallState.HOLDS_ORIGIN || - info.ballState === BallState.PASSED_ORIGIN) && ( - { - doMoveBall(ballBounds, player) - }} + setContent={service.setContent} /> ), + !isFrozen && + (info.ballState === BallState.HOLDS_ORIGIN || + info.ballState === BallState.PASSED_ORIGIN) && ( + { + doMoveBall(ballBounds, player) + }} + /> + ), ] }, - [content, doMoveBall, previewAction?.isInvalid, setContent], + [ + content, + courtRef, + doMoveBall, + previewAction?.isInvalid, + service.setContent, + ], ) const renderPlayer = useCallback( @@ -406,16 +480,30 @@ function EditorView({ pos: computePhantomPositioning( component, content, + relativePositions, courtBounds(), ), ballState: component.ballState, } } else { info = component + + if (component.frozen) { + return ( + + renderAvailablePlayerActions(info, component) + } + /> + ) + } } return ( - { - setContent((content) => removeAction(origin, idx, content)) + service.setContent((content) => removeAction(origin, idx, content)) }, - [setContent], + [service], ) const doUpdateAction = useCallback( (component: TacticComponent, action: Action, actionIndex: number) => { - setContent((content) => + service.setContent((content) => updateComponent( { ...component, @@ -461,22 +552,22 @@ function EditorView({ ), ) }, - [setContent], + [service], ) const renderComponent = useCallback( (component: TacticComponent) => { - if (component.type == "player" || component.type == "phantom") { + if (component.type === "player" || component.type === "phantom") { return renderPlayer(component) } - if (component.type == BALL_TYPE) { + if (component.type === BALL_TYPE) { return ( { - setContent((content) => removeBall(content)) + service.setContent((content) => removeBall(content)) setObjects((objects) => [ ...objects, { key: "ball" }, @@ -487,7 +578,7 @@ function EditorView({ } throw new Error("unknown tactic component " + component) }, - [renderPlayer, doMoveBall, setContent], + [service, renderPlayer, doMoveBall], ) const renderActions = useCallback( @@ -509,14 +600,105 @@ function EditorView({ /> ) }), - [doDeleteAction, doUpdateAction], + [courtRef, doDeleteAction, doUpdateAction], + ) + + const contentNode = ( +
+
+ + + + overlaps( + courtBounds(), + div.getBoundingClientRect(), + ), + [courtBounds], + )} + onElementDetached={useCallback( + (r, e: RackedCourtObject) => + service.setContent((content) => + placeObjectAt( + r.getBoundingClientRect(), + courtBounds(), + e, + content, + ), + ), + [courtBounds, service], + )} + render={renderCourtObject} + /> + + +
+
+
+ } + courtRef={courtRef} + previewAction={previewAction} + renderComponent={renderComponent} + renderActions={renderActions} + /> +
+
+
+ ) + + const stepsTreeNode = ( + { + const addedNode = await service.addStep( + parent, + computeTerminalState(content, relativePositions), + ) + if (typeof addedNode === "string") { + console.error("could not add step : " + addedNode) + return + } + await service.selectStep(addedNode.id) + }, + [service, content, relativePositions], + )} + onRemoveNode={useCallback( + async (removed) => { + await service.removeStep(removed.id) + await service.selectStep(getParent(stepsTree, removed)!.id) + }, + [service, stepsTree], + )} + onStepSelected={useCallback( + (node) => service.selectStep(node.id), + [service], + )} + /> ) return (
- +
{ - onNameChange(new_name).then((success) => { - setTitleStyle(success ? {} : ERROR_STYLE) + service.setName(new_name).then((state) => { + setTitleStyle( + state == SaveStates.Ok + ? {} + : ERROR_STYLE, + ) }) }, - [onNameChange], + [service], )} />
-
+
+ +
-
-
- +
+ {isStepsTreeVisible ? ( + + {contentNode} + {stepsTreeNode} + + ) : ( + contentNode + )} +
+
+ ) +} - - overlaps( - courtBounds(), - div.getBoundingClientRect(), - ), - [courtBounds], - )} - onElementDetached={useCallback( - (r, e: RackedCourtObject) => - setContent((content) => - placeObjectAt( - r.getBoundingClientRect(), - courtBounds(), - e, - content, - ), - ), - [courtBounds, setContent], - )} - render={renderCourtObject} - /> +interface EditorStepsTreeProps { + selectedStepId: number + root: StepInfoNode + onAddChildren: (parent: StepInfoNode) => void + onRemoveNode: (node: StepInfoNode) => void + onStepSelected: (node: StepInfoNode) => void +} - -
-
-
- } - courtRef={courtRef} - previewAction={previewAction} - renderComponent={renderComponent} - renderActions={renderActions} - /> -
-
-
+function EditorStepsTree({ + selectedStepId, + root, + onAddChildren, + onRemoveNode, + onStepSelected, +}: EditorStepsTreeProps) { + return ( +
+
) } @@ -599,7 +773,7 @@ function EditorView({ interface PlayerRackProps { id: string objects: RackedPlayer[] - setObjects: (state: RackedPlayer[]) => void + setObjects?: (state: RackedPlayer[]) => void setComponents: ( f: (components: TacticComponent[]) => TacticComponent[], ) => void @@ -659,8 +833,8 @@ interface CourtPlayerArrowActionProps { player: PlayerLike isInvalid: boolean - content: TacticContent - setContent: (state: SetStateAction) => void + content: StepContent + setContent: (state: SetStateAction) => void setPreviewAction: (state: SetStateAction) => void courtRef: RefObject } @@ -768,7 +942,7 @@ function CourtPlayerArrowAction({ ) } -function isBallOnCourt(content: TacticContent) { +function isBallOnCourt(content: StepContent) { return ( content.components.findIndex( (c) => @@ -815,30 +989,91 @@ function debounceAsync( function useContentState( initialContent: S, initialSaveState: SaveState, - saveStateCallback: (s: S) => Promise, -): [S, Dispatch>, SaveState] { + applyStateCallback: (content: S) => Promise, +): [S, (newState: SetStateAction, runCallback: boolean) => void, SaveState] { const [content, setContent] = useState(initialContent) const [savingState, setSavingState] = useState(initialSaveState) const setContentSynced = useCallback( - (newState: SetStateAction) => { + (newState: SetStateAction, callSaveCallback: boolean) => { setContent((content) => { const state = typeof newState === "function" ? (newState as (state: S) => S)(content) : newState - if (state !== content) { + if (state !== content && callSaveCallback) { setSavingState(SaveStates.Saving) - saveStateCallback(state) + applyStateCallback(state) .then(setSavingState) - .catch(() => setSavingState(SaveStates.Err)) + .catch((e) => { + setSavingState(SaveStates.Err) + console.error(e) + }) } return state }) }, - [saveStateCallback], + [applyStateCallback], ) return [content, setContentSynced, savingState] } + +function computeRelativePositions(courtBounds: DOMRect, content: StepContent) { + const relativePositionsCache: ComputedRelativePositions = new Map() + + for (const component of content.components) { + if (component.type !== "phantom") continue + computePhantomPositioning( + component, + content, + relativePositionsCache, + courtBounds, + ) + } + + return relativePositionsCache +} + +async function updateStepContents( + stepId: number, + stepsTree: StepInfoNode, + getStepContent: (stepId: number) => Promise, + setStepContent: (stepId: number, content: StepContent) => Promise, +) { + async function updateSteps( + step: StepInfoNode, + content: StepContent, + relativePositions: ComputedRelativePositions, + ) { + const terminalStateContent = computeTerminalState( + content, + relativePositions, + ) + + for (const child of step.children) { + const { + content: childContent, + relativePositions: childRelativePositions, + } = await getStepContent(child.id) + const childUpdatedContent = drainTerminalStateOnChildContent( + terminalStateContent, + childContent, + ) + if (childUpdatedContent) { + await setStepContent(child.id, childUpdatedContent) + await updateSteps( + child, + childUpdatedContent, + childRelativePositions, + ) + } + } + } + + const { content, relativePositions } = await getStepContent(stepId) + const startNode = getStepNode(stepsTree!, stepId)! + + await updateSteps(startNode, content, relativePositions) +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index faba0d7..38dd127 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -151,7 +151,7 @@ function TitlePersonalSpace() { function TableData({ allTactics }: { allTactics: Tactic[] }) { const nbRow = Math.floor(allTactics.length / 3) + 1 - let listTactic = Array(nbRow) + const listTactic = Array(nbRow) for (let i = 0; i < nbRow; i++) { listTactic[i] = Array(0) } diff --git a/src/pages/NewTacticPage.tsx b/src/pages/NewTacticPage.tsx index 98568d3..35db2a4 100644 --- a/src/pages/NewTacticPage.tsx +++ b/src/pages/NewTacticPage.tsx @@ -66,7 +66,10 @@ function CourtKindButton({ ) if (response.status === 401) { - saveSession({...getSession(), urlTarget: location.pathname}) + saveSession({ + ...getSession(), + urlTarget: location.pathname, + }) // if unauthorized navigate("/login") return diff --git a/src/service/APITacticService.ts b/src/service/APITacticService.ts new file mode 100644 index 0000000..71f775f --- /dev/null +++ b/src/service/APITacticService.ts @@ -0,0 +1,84 @@ +import { TacticService, ServiceError, TacticContext } from "./TacticService.ts" +import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts" +import { fetchAPI, fetchAPIGet } from "../Fetcher.ts" + +export class APITacticService implements TacticService { + private readonly tacticId: number + + constructor(tacticId: number) { + this.tacticId = tacticId + } + + async getContext(): Promise { + const infoResponsePromise = fetchAPIGet(`tactics/${this.tacticId}`) + const treeResponsePromise = fetchAPIGet(`tactics/${this.tacticId}/tree`) + + const infoResponse = await infoResponsePromise + const treeResponse = await treeResponsePromise + + if (infoResponse.status == 401 || treeResponse.status == 401) { + return ServiceError.UNAUTHORIZED + } + const { name, courtType } = await infoResponse.json() + const { root } = await treeResponse.json() + + return { courtType, name, stepsTree: root } + } + + async addStep( + parent: StepInfoNode, + content: StepContent, + ): Promise { + const response = await fetchAPI(`tactics/${this.tacticId}/steps`, { + parentId: parent.id, + content, + }) + if (response.status == 404) return ServiceError.NOT_FOUND + if (response.status == 401) return ServiceError.UNAUTHORIZED + + const { stepId } = await response.json() + return { id: stepId, children: [] } + } + + async removeStep(id: number): Promise { + const response = await fetchAPI( + `tactics/${this.tacticId}/steps/${id}`, + {}, + "DELETE", + ) + if (response.status == 404) return ServiceError.NOT_FOUND + if (response.status == 401) return ServiceError.UNAUTHORIZED + } + + async setName(name: string): Promise { + const response = await fetchAPI( + `tactics/${this.tacticId}/name`, + { name }, + "PUT", + ) + if (response.status == 404) return ServiceError.NOT_FOUND + if (response.status == 401) return ServiceError.UNAUTHORIZED + } + + async saveContent( + step: number, + content: StepContent, + ): Promise { + const response = await fetchAPI( + `tactics/${this.tacticId}/steps/${step}`, + { content }, + "PUT", + ) + if (response.status == 404) return ServiceError.NOT_FOUND + if (response.status == 401) return ServiceError.UNAUTHORIZED + } + + async getContent(step: number): Promise { + const response = await fetchAPIGet( + `tactics/${this.tacticId}/steps/${step}`, + ) + if (response.status == 404) return ServiceError.NOT_FOUND + if (response.status == 401) return ServiceError.UNAUTHORIZED + return await response.json() + } +} diff --git a/src/service/LocalStorageTacticService.ts b/src/service/LocalStorageTacticService.ts new file mode 100644 index 0000000..daf05c4 --- /dev/null +++ b/src/service/LocalStorageTacticService.ts @@ -0,0 +1,99 @@ +import { TacticService, ServiceError, TacticContext } from "./TacticService.ts" +import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts" +import { + addStepNode, + getAvailableId, + removeStepNode, +} from "../editor/StepsDomain.ts" + +const GUEST_MODE_STEP_CONTENT_STORAGE_KEY = "guest_mode_step" +const GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY = "guest_mode_step_tree" +const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" + +export class LocalStorageTacticService implements TacticService { + private constructor() {} + + static init(): LocalStorageTacticService { + const root = localStorage.getItem( + GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, + ) + + if (root === null) { + localStorage.setItem( + GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, + JSON.stringify({ id: 1, children: [] }), + ) + } + + return new LocalStorageTacticService() + } + + async getContext(): Promise { + const stepsTree: StepInfoNode = JSON.parse( + localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!, + ) + const name = + localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? + "Nouvelle Tactique" + return { + stepsTree, + name, + courtType: "PLAIN", + } + } + + async addStep( + parent: StepInfoNode, + content: StepContent, + ): Promise { + const root: StepInfoNode = JSON.parse( + localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!, + ) + + const nodeId = getAvailableId(root) + const node = { id: nodeId, children: [] } + + const resultTree = addStepNode(root, parent, node) + + localStorage.setItem( + GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, + JSON.stringify(resultTree), + ) + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id, + JSON.stringify(content), + ) + return node + } + + async getContent(step: number): Promise { + const content = localStorage.getItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step, + ) + return content ? JSON.parse(content) : null + } + + async removeStep(id: number): Promise { + const root: StepInfoNode = JSON.parse( + localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!, + ) + localStorage.setItem( + GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, + JSON.stringify(removeStepNode(root, id)), + ) + } + + async saveContent( + step: number, + content: StepContent, + ): Promise { + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step, + JSON.stringify(content), + ) + } + + async setName(name: string): Promise { + localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) + } +} diff --git a/src/service/TacticService.ts b/src/service/TacticService.ts new file mode 100644 index 0000000..cafa43d --- /dev/null +++ b/src/service/TacticService.ts @@ -0,0 +1,32 @@ +import { CourtType, StepContent, StepInfoNode } from "../model/tactic/Tactic.ts" + +export interface TacticContext { + stepsTree: StepInfoNode + name: string + courtType: CourtType +} + +export enum ServiceError { + UNAUTHORIZED = "UNAUTHORIZED", + NOT_FOUND = "NOT_FOUND", +} + +export interface TacticService { + getContext(): Promise + + addStep( + parent: StepInfoNode, + content: StepContent, + ): Promise + + removeStep(id: number): Promise + + setName(name: string): Promise + + saveContent( + step: number, + content: StepContent, + ): Promise + + getContent(step: number): Promise +} diff --git a/src/style/editor.css b/src/style/editor.css index b6a8ea4..5ce1a38 100644 --- a/src/style/editor.css +++ b/src/style/editor.css @@ -26,13 +26,13 @@ width: 100%; display: flex; background-color: var(--main-color); - margin-bottom: 3px; justify-content: space-between; align-items: stretch; } #racks { + margin: 3px 6px 0 6px; display: flex; justify-content: space-between; align-items: center; @@ -44,8 +44,29 @@ align-self: center; } -#edit-div { +#editor-div { + display: flex; + flex-direction: row; +} + +#content-div, +#editor-div, +#steps-div { height: 100%; + width: 100%; +} + +#content-div { + overflow: hidden; +} + +.curtain { + width: 100%; +} + +#steps-div { + background-color: var(--editor-tree-background); + overflow: scroll; } #allies-rack, @@ -112,7 +133,6 @@ height: 100%; width: 100%; user-select: none; - -webkit-user-drag: none; } #court-image * { @@ -130,6 +150,11 @@ font-family: monospace; } +.save-state, +#show-steps-button { + user-select: none; +} + .save-state-error { color: red; } diff --git a/src/style/steps_tree.css b/src/style/steps_tree.css new file mode 100644 index 0000000..e8a92bb --- /dev/null +++ b/src/style/steps_tree.css @@ -0,0 +1,91 @@ +.step-piece { + position: relative; + font-family: monospace; + pointer-events: all; + + background-color: var(--editor-tree-step-piece); + color: var(--selected-team-secondarycolor); + + border-radius: 100px; + + width: 20px; + height: 20px; + + display: flex; + + align-items: center; + justify-content: center; + + user-select: none; + cursor: pointer; + + border: 2px solid var(--editor-tree-background); +} + +.step-piece-selected { + border: 2px solid var(--selection-color-light); +} + +.step-piece-selected, +.step-piece:focus, +.step-piece:hover { + background-color: var(--editor-tree-step-piece-hovered); +} + +.step-piece-selected .step-piece-actions, +.step-piece:hover .step-piece-actions, +.step-piece:focus-within .step-piece-actions { + visibility: visible; +} + +.step-piece-actions { + visibility: hidden; + display: flex; + position: absolute; + column-gap: 5px; + top: -140%; +} + +.add-icon, +.remove-icon { + background-color: white; + border-radius: 100%; +} + +.add-icon { + fill: var(--add-icon-fill); +} + +.remove-icon { + fill: var(--remove-icon-fill); +} + +.step-children { + margin-top: 10vh; + display: flex; + flex-direction: row; + width: 100%; + height: 100%; +} + +.step-group { + position: relative; + + display: flex; + flex-direction: column; + align-items: center; + + top: 0; + width: 100%; + height: 100%; +} + +.steps-tree { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-top: 10%; + + height: 100%; +} diff --git a/src/style/theme/default.css b/src/style/theme/default.css index caa5162..19702b0 100644 --- a/src/style/theme/default.css +++ b/src/style/theme/default.css @@ -13,6 +13,7 @@ --buttons-shadow-color: #a8a8a8; --selection-color: #3f7fc4; + --selection-color-light: #acd8f8; --border-color: #ffffff; @@ -29,4 +30,10 @@ --main-contrast-color: #e6edf3; --font-title: Helvetica; --font-content: Helvetica; + --editor-tree-background: #503636; + --editor-tree-step-piece: #0bd9d9; + --editor-tree-step-piece-hovered: #ea9b9b; + + --add-icon-fill: #00a206; + --remove-icon-fill: #e50046; } diff --git a/vite.config.ts b/vite.config.ts index 214327e..8d932e7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ build: { target: "es2021", }, + test: { + environment: "jsdom", + }, plugins: [ react(), cssInjectedByJsPlugin({