From 034afc3649fb94df16a1e3b76bbee65a7ea79474 Mon Sep 17 00:00:00 2001 From: maxime Date: Wed, 31 Jan 2024 21:14:15 +0100 Subject: [PATCH 01/16] add a steps visualizer (fake data) --- src/components/editor/BasketCourt.tsx | 2 +- src/components/editor/CourtAction.tsx | 4 +- src/components/editor/StepsTree.tsx | 58 +++++ src/editor/ActionsDomains.ts | 61 ++--- src/editor/PlayerDomains.ts | 63 ++--- src/editor/TacticContentDomains.ts | 84 +++---- src/model/tactic/Tactic.ts | 22 +- src/pages/Editor.tsx | 318 ++++++++++++++++---------- src/style/editor.css | 24 +- src/style/steps-tree.css | 48 ++++ src/style/theme/default.css | 2 + 11 files changed, 446 insertions(+), 240 deletions(-) create mode 100644 src/components/editor/StepsTree.tsx create mode 100644 src/style/steps-tree.css 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/StepsTree.tsx b/src/components/editor/StepsTree.tsx new file mode 100644 index 0000000..45d6492 --- /dev/null +++ b/src/components/editor/StepsTree.tsx @@ -0,0 +1,58 @@ +import {StepInfoNode} from "../../model/tactic/Tactic"; +import "../../style/steps-tree.css" +import BendableArrow from "../arrows/BendableArrow"; +import {useRef} from "react"; + +export interface StepsTreeProps { + root: StepInfoNode +} + +export default function StepsTree({root}: StepsTreeProps) { + return
+ +
+} + +interface StepsTreeContentProps { + node: StepInfoNode +} + +function StepsTreeNode({node}: StepsTreeContentProps) { + const ref = useRef(null) + return ( +
+ + {node.children.map(child => ( + { + }} + forceStraight={true} + wavy={false} + //TODO remove magic constant + startRadius={10} + endRadius={10} + /> + ))} +
+ {node.children.map(child => )} +
+
+ ) +} + +interface StepPieceProps { + id: number +} + +function StepPiece({id}: StepPieceProps) { + return ( +
+

{id}

+
+ ) +} diff --git a/src/editor/ActionsDomains.ts b/src/editor/ActionsDomains.ts index c9e7e66..3e94bc8 100644 --- a/src/editor/ActionsDomains.ts +++ b/src/editor/ActionsDomains.ts @@ -4,15 +4,15 @@ import { PlayerLike, PlayerPhantom, } from "../model/tactic/Player" -import { ratioWithinBase } from "../geo/Pos" +import {ratioWithinBase} from "../geo/Pos" import { ComponentId, TacticComponent, - TacticContent, + StepContent, } from "../model/tactic/Tactic" -import { overlaps } from "../geo/Box" -import { Action, ActionKind, moves } from "../model/tactic/Action" -import { removeBall, updateComponent } from "./TacticContentDomains" +import {overlaps} from "../geo/Box" +import {Action, ActionKind, moves} from "../model/tactic/Action" +import {removeBall, updateComponent} from "./TacticContentDomains" import { areInSamePath, changePlayerBallState, @@ -22,7 +22,7 @@ import { isNextInPath, removePlayer, } from "./PlayerDomains" -import { BALL_TYPE } from "../model/tactic/CourtObjects" +import {BALL_TYPE} from "../model/tactic/CourtObjects" export function getActionKind( target: TacticComponent | null, @@ -31,12 +31,12 @@ export function getActionKind( switch (ballState) { case BallState.HOLDS_ORIGIN: return target - ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN } - : { kind: ActionKind.DRIBBLE, nextState: ballState } + ? {kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN} + : {kind: ActionKind.DRIBBLE, nextState: ballState} case BallState.HOLDS_BY_PASS: return target - ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED } - : { kind: ActionKind.DRIBBLE, nextState: ballState } + ? {kind: ActionKind.SHOOT, nextState: BallState.PASSED} + : {kind: ActionKind.DRIBBLE, nextState: ballState} case BallState.PASSED_ORIGIN: case BallState.PASSED: case BallState.NONE: @@ -212,8 +212,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. @@ -222,7 +222,7 @@ export function createAction( forceHasBall: boolean, attachedTo?: ComponentId, ): ComponentId { - const { x, y } = ratioWithinBase(arrowHead, courtBounds) + const {x, y} = ratioWithinBase(arrowHead, courtBounds) let itemIndex: number let originPlayer: Player @@ -274,14 +274,14 @@ export function createAction( id: phantomId, pos: attachedTo ? { - type: "follows", - attach: attachedTo, - } + type: "follows", + attach: attachedTo, + } : { - type: "fixed", - x, - y, - }, + type: "fixed", + x, + y, + }, originPlayerId: originPlayer.id, ballState: phantomState, actions: [], @@ -320,13 +320,13 @@ export function createAction( action = { target: toId, type: actionKind, - segments: [{ next: toId }], + segments: [{next: toId}], } } else { action = { target: toId, type: actionKind, - segments: [{ next: toId }], + segments: [{next: toId}], } } @@ -355,7 +355,7 @@ export function createAction( const action: Action = { target: phantomId, type: actionKind, - segments: [{ next: phantomId }], + segments: [{next: phantomId}], } return { newContent: updateComponent( @@ -371,8 +371,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 +391,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), @@ -462,8 +463,8 @@ export function removeAction( export function spreadNewStateFromOriginStateChange( origin: PlayerLike, newState: BallState, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { if (origin.ballState === newState) { return content } @@ -534,7 +535,7 @@ export function spreadNewStateFromOriginStateChange( i-- // step back } else { // do not change the action type if it is a shoot action - const { kind, nextState } = getActionKindBetween( + const {kind, nextState} = getActionKindBetween( origin, actionTarget, newState, diff --git a/src/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts index 0473d72..40da34d 100644 --- a/src/editor/PlayerDomains.ts +++ b/src/editor/PlayerDomains.ts @@ -1,30 +1,11 @@ -import { - BallState, - Player, - PlayerLike, - PlayerPhantom, -} from "../model/tactic/Player" -import { - ComponentId, - TacticComponent, - TacticContent, -} from "../model/tactic/Tactic" -import { removeComponent, updateComponent } from "./TacticContentDomains" -import { - removeAllActionsTargeting, - spreadNewStateFromOriginStateChange, -} from "./ActionsDomains" -import { ActionKind } from "../model/tactic/Action" -import { - add, - minus, - norm, - Pos, - posWithinBase, - ratioWithinBase, - relativeTo, -} from "../geo/Pos.ts" -import { PLAYER_RADIUS_PIXELS } from "../components/editor/CourtPlayer.tsx" +import {BallState, Player, PlayerLike, PlayerPhantom,} from "../model/tactic/Player" +import {ComponentId, StepContent, TacticComponent,} from "../model/tactic/Tactic" + +import {removeComponent, updateComponent} from "./TacticContentDomains" +import {removeAllActionsTargeting, spreadNewStateFromOriginStateChange,} from "./ActionsDomains" +import {ActionKind} from "../model/tactic/Action" +import {add, minus, norm, Pos, posWithinBase, ratioWithinBase, relativeTo,} from "../geo/Pos.ts" +import {PLAYER_RADIUS_PIXELS} from "../components/editor/CourtPlayer.tsx" export function getOrigin( pathItem: PlayerPhantom, @@ -58,7 +39,7 @@ export function getPlayerNextTo( // following another phantom and / or the origin of the phantom is another export function computePhantomPositioning( phantom: PlayerPhantom, - content: TacticContent, + content: StepContent, area: DOMRect, ): Pos { const positioning = phantom.pos @@ -171,8 +152,8 @@ export function isNextInPath( export function clearPlayerPath( player: Player, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { if (player.path == null) { return content } @@ -192,8 +173,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 +194,8 @@ function removeAllPhantomsAttached( export function removePlayer( player: PlayerLike, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { content = removeAllActionsTargeting(player.id, content) content = removeAllPhantomsAttached(player.id, content) @@ -266,8 +247,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! @@ -289,9 +270,9 @@ export function truncatePlayerPath( truncateStartIdx == 0 ? null : { - ...path, - items: path.items.toSpliced(truncateStartIdx), - }, + ...path, + items: path.items.toSpliced(truncateStartIdx), + }, }, content, ) @@ -300,7 +281,7 @@ export function truncatePlayerPath( export function changePlayerBallState( player: PlayerLike, newState: BallState, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { return spreadNewStateFromOriginStateChange(player, newState, content) } diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 1f3a9cc..0fc76e1 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -1,4 +1,4 @@ -import { Pos, ratioWithinBase } from "../geo/Pos" +import {Pos, ratioWithinBase} from "../geo/Pos" import { BallState, Player, @@ -15,12 +15,13 @@ import { import { ComponentId, TacticComponent, - TacticContent, + StepContent, } from "../model/tactic/Tactic" -import { overlaps } from "../geo/Box" -import { RackedCourtObject, RackedPlayer } from "./RackedItems" -import { changePlayerBallState, getComponent, getOrigin } from "./PlayerDomains" -import { ActionKind } from "../model/tactic/Action.ts" + +import {overlaps} from "../geo/Box" +import {RackedCourtObject, RackedPlayer} from "./RackedItems" +import {changePlayerBallState, getComponent, getOrigin} from "./PlayerDomains" +import {ActionKind} from "../model/tactic/Action.ts" export function placePlayerAt( refBounds: DOMRect, @@ -45,14 +46,15 @@ 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, @@ -69,7 +71,7 @@ export function placeObjectAt( actions: [], } break - + } default: throw new Error("unknown court object " + rackedObject.key) } @@ -82,9 +84,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") { @@ -101,7 +103,7 @@ export function dropBallOnComponent( 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 +119,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) } @@ -162,9 +164,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() @@ -186,9 +188,9 @@ export function moveComponent( phantomIdx == 0 ? origin : getComponent( - originPathItems[phantomIdx - 1], - content.components, - ) + originPathItems[phantomIdx - 1], + content.components, + ) // detach the action from the screen target and transform it to a regular move action to the phantom. content = updateComponent( { @@ -196,18 +198,18 @@ export function moveComponent( actions: playerBeforePhantom.actions.map((a) => a.target === referent ? { - ...a, - segments: a.segments.toSpliced( - a.segments.length - 2, - 1, - { - ...a.segments[a.segments.length - 1], - next: component.id, - }, - ), - target: component.id, - type: ActionKind.MOVE, - } + ...a, + segments: a.segments.toSpliced( + a.segments.length - 2, + 1, + { + ...a.segments[a.segments.length - 1], + next: component.id, + }, + ), + target: component.id, + type: ActionKind.MOVE, + } : a, ), }, @@ -220,9 +222,9 @@ export function moveComponent( ...component, pos: isPhantom ? { - type: "fixed", - ...newPos, - } + type: "fixed", + ...newPos, + } : newPos, }, content, @@ -232,8 +234,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 +244,8 @@ export function removeComponent( export function updateComponent( component: TacticComponent, - content: TacticContent, -): TacticContent { + content: StepContent, +): StepContent { return { ...content, components: content.components.map((c) => @@ -285,5 +287,5 @@ export function getRackPlayers( c.type == "player" && c.team == team && c.role == role, ) == -1, ) - .map((key) => ({ team, key })) + .map((key) => ({team, key})) } diff --git a/src/model/tactic/Tactic.ts b/src/model/tactic/Tactic.ts index 0ad312c..4dadca8 100644 --- a/src/model/tactic/Tactic.ts +++ b/src/model/tactic/Tactic.ts @@ -2,21 +2,27 @@ import { Player, PlayerPhantom } from "./Player" import { Action } from "./Action" import { CourtObject } from "./CourtObjects" -export type CourtType = "HALF" | "PLAIN" - export interface Tactic { - id: number - name: string - courtType: CourtType - content: TacticContent + readonly id: number + readonly name: string + readonly courtType: CourtType + readonly currentStepContent: StepContent + readonly rootStepNode: StepInfoNode +} + +export interface StepContent { + readonly stepId: number + 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 { /** diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 83c4dac..e65a852 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -14,28 +14,25 @@ import TitleInput from "../components/TitleInput" import PlainCourt from "../assets/court/full_court.svg?react" import HalfCourt from "../assets/court/half_court.svg?react" -import { BallPiece } from "../components/editor/BallPiece" +import {BallPiece} from "../components/editor/BallPiece" -import { Rack } from "../components/Rack" -import { PlayerPiece } from "../components/editor/PlayerPiece" +import {Rack} from "../components/Rack" +import {PlayerPiece} from "../components/editor/PlayerPiece" import { - CourtType, + CourtType, StepContent, StepInfoNode, Tactic, TacticComponent, - TacticContent, } from "../model/tactic/Tactic" import { fetchAPI, fetchAPIGet } from "../Fetcher" -import SavingState, { - SaveState, - SaveStates, -} from "../components/editor/SavingState" +import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" 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 { dropBallOnComponent, getComponentCollided, @@ -47,24 +44,14 @@ import { removeBall, updateComponent, } from "../editor/TacticContentDomains" -import { - BallState, - Player, - PlayerInfo, - PlayerLike, - PlayerTeam, -} from "../model/tactic/Player" -import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" + +import {BallState, Player, PlayerInfo, PlayerLike, PlayerTeam,} from "../model/tactic/Player" +import {RackedCourtObject, RackedPlayer} from "../editor/RackedItems" import CourtPlayer from "../components/editor/CourtPlayer" -import { - createAction, - getActionKind, - isActionValid, - removeAction, -} from "../editor/ActionsDomains" +import {createAction, getActionKind, isActionValid, removeAction,} from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" -import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" -import { Action, ActionKind } from "../model/tactic/Action" +import {middlePos, Pos, ratioWithinBase} from "../geo/Pos" +import {Action, ActionKind} from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" import { changePlayerBallState, @@ -75,6 +62,7 @@ import { 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" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -85,7 +73,7 @@ const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" export interface EditorViewProps { tactic: Tactic - onContentChange: (tactic: TacticContent) => Promise + onContentChange: (tactic: StepContent) => Promise onNameChange: (name: string) => Promise } interface TacticDto { @@ -93,6 +81,7 @@ interface TacticDto { name: string courtType: CourtType content: string + root: StepInfoNode } interface EditorPageProps { @@ -107,6 +96,7 @@ export default function EditorPage({ guestMode }: EditorPageProps) { courtType: "PLAIN", content: '{"components": []}', name: DEFAULT_TACTIC_NAME, + root: {id: 1, children: []} } } return null @@ -120,9 +110,11 @@ export default function EditorPage({ guestMode }: EditorPageProps) { async function initialize() { const infoResponsePromise = fetchAPIGet(`tactics/${id}`) + const treeResponsePromise = fetchAPIGet(`tactics/${id}/tree`) const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/1`) const infoResponse = await infoResponsePromise + const treeResponse = await treeResponsePromise const contentResponse = await contentResponsePromise if (infoResponse.status == 401 || contentResponse.status == 401) { @@ -132,8 +124,9 @@ export default function EditorPage({ guestMode }: EditorPageProps) { const { name, courtType } = await infoResponse.json() const content = await contentResponse.text() + const { root } = await treeResponse.json() - setTactic({ id, name, courtType, content }) + setTactic({ id, name, courtType, content, root }) } initialize() @@ -143,6 +136,7 @@ export default function EditorPage({ guestMode }: EditorPageProps) { return ( { + onContentChange={async (content: StepContent) => { if (isInGuestMode) { localStorage.setItem( GUEST_MODE_CONTENT_STORAGE_KEY, @@ -196,6 +195,7 @@ function Editor({ id, name, courtType, content }: EditorProps) { `tactics/${id}/steps/1`, { content }, "PUT", + ) if (response.status == 401) { navigate("/login") @@ -212,21 +212,39 @@ function Editor({ id, name, courtType, content }: EditorProps) { `tactics/${id}/name`, { name }, "PUT", + ) if (response.status == 401) { navigate("/login") } return response.ok }} + onStepSelected={() => { + }} + stepsContentsRoot={rootStepNode} + courtType={courtType} /> ) } +export interface EditorViewProps { + tactic: Tactic + onContentChange: (tactic: StepContent) => Promise + onStepSelected: (stepId: number) => void, + onNameChange: (name: string) => Promise + stepsContentsRoot: StepInfoNode + courtType: "PLAIN" | "HALF" +} + function EditorView({ - tactic: { id, name, content: initialContent, courtType }, - onContentChange, - onNameChange, -}: EditorViewProps) { + + tactic: {id, name, currentStepContent: initialContent}, + onContentChange, + onNameChange, + onStepSelected, + stepsContentsRoot, + courtType, + }: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) @@ -244,13 +262,15 @@ function EditorView({ ) const [objects, setObjects] = useState(() => - isBallOnCourt(content) ? [] : [{ key: "ball" }], + isBallOnCourt(content) ? [] : [{key: "ball"}], ) const [previewAction, setPreviewAction] = useState( null, ) + const [isStepsTreeVisible, setStepsTreeVisible] = useState(false) + const courtRef = useRef(null) const setComponents = (action: SetStateAction) => { @@ -267,7 +287,7 @@ function EditorView({ ) useEffect(() => { - setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) + setObjects(isBallOnCourt(content) ? [] : [{key: "ball"}]) }, [setObjects, content]) const insertRackedPlayer = (player: Player) => { @@ -280,7 +300,7 @@ function EditorView({ setter = setAllies } if (player.ballState == BallState.HOLDS_BY_PASS) { - setObjects([{ key: "ball" }]) + setObjects([{key: "ball"}]) } setter((players) => [ ...players, @@ -479,7 +499,7 @@ function EditorView({ setContent((content) => removeBall(content)) setObjects((objects) => [ ...objects, - { key: "ball" }, + {key: "ball"}, ]) }} /> @@ -516,7 +536,7 @@ function EditorView({
- +
-
+
+ +
-
-
- +
+
+
+ - - overlaps( - courtBounds(), - div.getBoundingClientRect(), - ), - [courtBounds], - )} - onElementDetached={useCallback( - (r, e: RackedCourtObject) => - setContent((content) => - placeObjectAt( - r.getBoundingClientRect(), + + overlaps( courtBounds(), - e, - content, + div.getBoundingClientRect(), ), - ), - [courtBounds, setContent], - )} - render={renderCourtObject} - /> + [courtBounds], + )} + onElementDetached={useCallback( + (r, e: RackedCourtObject) => + setContent((content) => + placeObjectAt( + r.getBoundingClientRect(), + courtBounds(), + e, + content, + ), + ), + [courtBounds, setContent], + )} + render={renderCourtObject} + /> - -
-
-
- } +
+
+
+ } + courtRef={courtRef} + previewAction={previewAction} + renderComponent={renderComponent} + renderActions={renderActions} + /> +
+
+
) } +interface EditorStepsTreeProps { + isVisible: boolean + root: StepInfoNode +} + +function EditorStepsTree({isVisible, root}: EditorStepsTreeProps) { + const fakeRoot: StepInfoNode = { + id: 0, + children: [ + { + id: 1, + children: [ + { + id: 2, + children: [] + }, + { + id: 3, + children: [{ + id: 4, + children: [] + }] + } + ] + }, + { + id: 5, + children: [ + { + id: 6, + children: [] + }, + { + id: 7, + children: [] + } + ] + }, + { + id: 8, + children: [ + { + id: 9, + children: [ + { + id: 10, + children: [] + } + ] + } + ] + } + ] + } + return ( +
+ +
+ ) +} + interface PlayerRackProps { id: string objects: RackedPlayer[] @@ -607,12 +695,12 @@ interface PlayerRackProps { } function PlayerRack({ - id, - objects, - setObjects, - courtRef, - setComponents, -}: PlayerRackProps) { + id, + objects, + setObjects, + courtRef, + setComponents, + }: PlayerRackProps) { const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], @@ -640,7 +728,7 @@ function PlayerRack({ [courtBounds, setComponents], )} render={useCallback( - ({ team, key }: { team: PlayerTeam; key: string }) => ( + ({team, key}: { team: PlayerTeam; key: string }) => ( ) => void + content: StepContent + setContent: (state: SetStateAction) => void setPreviewAction: (state: SetStateAction) => void courtRef: RefObject } function CourtPlayerArrowAction({ - playerInfo, - player, - isInvalid, - - content, - setContent, - setPreviewAction, - courtRef, -}: CourtPlayerArrowActionProps) { + playerInfo, + player, + isInvalid, + + content, + setContent, + setPreviewAction, + courtRef, + }: CourtPlayerArrowActionProps) { const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], @@ -729,7 +817,7 @@ function CourtPlayerArrowAction({ } setContent((content) => { - let { createdAction, newContent } = createAction( + let {createdAction, newContent} = createAction( player, courtBounds(), headRect, @@ -768,7 +856,7 @@ function CourtPlayerArrowAction({ ) } -function isBallOnCourt(content: TacticContent) { +function isBallOnCourt(content: StepContent) { return ( content.components.findIndex( (c) => @@ -782,18 +870,18 @@ function isBallOnCourt(content: TacticContent) { function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { - return + return } throw new Error("unknown racked court object " + courtObject.key) } -function Court({ courtType }: { courtType: string }) { +function Court({courtType}: { courtType: string }) { return (
{courtType == "PLAIN" ? ( - + ) : ( - + )}
) diff --git a/src/style/editor.css b/src/style/editor.css index b6a8ea4..8f506b2 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,28 @@ align-self: center; } -#edit-div { +#editor-div { + display: flex; + flex-direction: row; +} + +#content-div, +#editor-div { height: 100%; + width: 100%; +} + +#content-div { + width: 100%; +} + +#steps-div { + background-color: var(--editor-tree-background); + overflow: hidden; + width: 20%; + + transform: translateX(100%); + transition: transform 500ms; } #allies-rack, diff --git a/src/style/steps-tree.css b/src/style/steps-tree.css new file mode 100644 index 0000000..28e3f4f --- /dev/null +++ b/src/style/steps-tree.css @@ -0,0 +1,48 @@ +.step-piece { + 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; +} + +.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; + + width: 100%; + height: 100%; +} + +.steps-tree { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-top: 10%; + + height: 100%; +} \ No newline at end of file diff --git a/src/style/theme/default.css b/src/style/theme/default.css index caa5162..62bf848 100644 --- a/src/style/theme/default.css +++ b/src/style/theme/default.css @@ -29,4 +29,6 @@ --main-contrast-color: #e6edf3; --font-title: Helvetica; --font-content: Helvetica; + --editor-tree-background: #503636; + --editor-tree-step-piece: #0bd9d9; } From d26edd791a678b028dd6e8628e98847bed2cefa9 Mon Sep 17 00:00:00 2001 From: maxime Date: Wed, 31 Jan 2024 22:20:33 +0100 Subject: [PATCH 02/16] steps can now contain individual content on editor side --- src/assets/icon/remove.svg | 6 +- src/components/editor/StepsTree.tsx | 106 ++++++-- src/editor/ActionsDomains.ts | 42 ++-- src/editor/PlayerDomains.ts | 42 +++- src/editor/StepsDomain.ts | 36 +++ src/editor/TacticContentDomains.ts | 50 ++-- src/model/tactic/Tactic.ts | 9 +- src/pages/Editor.tsx | 375 +++++++++++++++++----------- src/style/editor.css | 6 +- src/style/steps-tree.css | 39 ++- src/style/theme/default.css | 4 + 11 files changed, 488 insertions(+), 227 deletions(-) create mode 100644 src/editor/StepsDomain.ts 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/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx index 45d6492..b6443ed 100644 --- a/src/components/editor/StepsTree.tsx +++ b/src/components/editor/StepsTree.tsx @@ -1,45 +1,87 @@ -import {StepInfoNode} from "../../model/tactic/Tactic"; import "../../style/steps-tree.css" -import BendableArrow from "../arrows/BendableArrow"; -import {useRef} from "react"; +import { StepInfoNode } from "../../model/tactic/Tactic" +import BendableArrow from "../arrows/BendableArrow" +import { useRef } from "react" +import AddSvg from "../../assets/icon/add.svg?react" +import RemoveSvg from "../../assets/icon/remove.svg?react" export interface StepsTreeProps { root: StepInfoNode + onAddChildren: (parent: StepInfoNode) => void + onRemoveNode: (node: StepInfoNode) => void + onStepSelected: (node: StepInfoNode) => void } -export default function StepsTree({root}: StepsTreeProps) { - return
- -
+export default function StepsTree({ + root, + onAddChildren, + onRemoveNode, + onStepSelected, +}: StepsTreeProps) { + return ( +
+ +
+ ) } interface StepsTreeContentProps { node: StepInfoNode + isNodeRoot: boolean + onAddChildren: (parent: StepInfoNode) => void + onRemoveNode: (node: StepInfoNode) => void + onStepSelected: (node: StepInfoNode) => void } -function StepsTreeNode({node}: StepsTreeContentProps) { +function StepsTreeNode({ + node, + isNodeRoot, + onAddChildren, + onRemoveNode, + onStepSelected, +}: StepsTreeContentProps) { const ref = useRef(null) return ( -
- - {node.children.map(child => ( +
+ onAddChildren(node)} + onRemoveButtonClicked={ + isNodeRoot ? undefined : () => onRemoveNode(node) + } + onSelected={() => onStepSelected(node)} + /> + {node.children.map((child) => ( { - }} + segments={[{ next: "step-piece-" + child.id }]} + onSegmentsChanges={() => {}} forceStraight={true} wavy={false} - //TODO remove magic constant + //TODO remove magic constants startRadius={10} endRadius={10} /> ))}
- {node.children.map(child => )} + {node.children.map((child) => ( + + ))}
) @@ -47,11 +89,37 @@ function StepsTreeNode({node}: StepsTreeContentProps) { interface StepPieceProps { id: number + onAddButtonClicked?: () => void + onRemoveButtonClicked?: () => void + onSelected: () => void } -function StepPiece({id}: StepPieceProps) { +function StepPiece({ + id, + onAddButtonClicked, + onRemoveButtonClicked, + onSelected, +}: StepPieceProps) { return ( -
+
+
+ {onAddButtonClicked && ( + onAddButtonClicked()} + className={"add-icon"} + /> + )} + {onRemoveButtonClicked && ( + onRemoveButtonClicked()} + className={"remove-icon"} + /> + )} +

{id}

) diff --git a/src/editor/ActionsDomains.ts b/src/editor/ActionsDomains.ts index 3e94bc8..7894cce 100644 --- a/src/editor/ActionsDomains.ts +++ b/src/editor/ActionsDomains.ts @@ -4,15 +4,15 @@ import { PlayerLike, PlayerPhantom, } from "../model/tactic/Player" -import {ratioWithinBase} from "../geo/Pos" +import { ratioWithinBase } from "../geo/Pos" import { ComponentId, TacticComponent, StepContent, } from "../model/tactic/Tactic" -import {overlaps} from "../geo/Box" -import {Action, ActionKind, moves} from "../model/tactic/Action" -import {removeBall, updateComponent} from "./TacticContentDomains" +import { overlaps } from "../geo/Box" +import { Action, ActionKind, moves } from "../model/tactic/Action" +import { removeBall, updateComponent } from "./TacticContentDomains" import { areInSamePath, changePlayerBallState, @@ -22,7 +22,7 @@ import { isNextInPath, removePlayer, } from "./PlayerDomains" -import {BALL_TYPE} from "../model/tactic/CourtObjects" +import { BALL_TYPE } from "../model/tactic/CourtObjects" export function getActionKind( target: TacticComponent | null, @@ -31,12 +31,12 @@ export function getActionKind( switch (ballState) { case BallState.HOLDS_ORIGIN: return target - ? {kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN} - : {kind: ActionKind.DRIBBLE, nextState: ballState} + ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN } + : { kind: ActionKind.DRIBBLE, nextState: ballState } case BallState.HOLDS_BY_PASS: return target - ? {kind: ActionKind.SHOOT, nextState: BallState.PASSED} - : {kind: ActionKind.DRIBBLE, nextState: ballState} + ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED } + : { kind: ActionKind.DRIBBLE, nextState: ballState } case BallState.PASSED_ORIGIN: case BallState.PASSED: case BallState.NONE: @@ -222,7 +222,7 @@ export function createAction( forceHasBall: boolean, attachedTo?: ComponentId, ): ComponentId { - const {x, y} = ratioWithinBase(arrowHead, courtBounds) + const { x, y } = ratioWithinBase(arrowHead, courtBounds) let itemIndex: number let originPlayer: Player @@ -274,14 +274,14 @@ export function createAction( id: phantomId, pos: attachedTo ? { - type: "follows", - attach: attachedTo, - } + type: "follows", + attach: attachedTo, + } : { - type: "fixed", - x, - y, - }, + type: "fixed", + x, + y, + }, originPlayerId: originPlayer.id, ballState: phantomState, actions: [], @@ -320,13 +320,13 @@ export function createAction( action = { target: toId, type: actionKind, - segments: [{next: toId}], + segments: [{ next: toId }], } } else { action = { target: toId, type: actionKind, - segments: [{next: toId}], + segments: [{ next: toId }], } } @@ -355,7 +355,7 @@ export function createAction( const action: Action = { target: phantomId, type: actionKind, - segments: [{next: phantomId}], + segments: [{ next: phantomId }], } return { newContent: updateComponent( @@ -535,7 +535,7 @@ export function spreadNewStateFromOriginStateChange( i-- // step back } else { // do not change the action type if it is a shoot action - const {kind, nextState} = getActionKindBetween( + const { kind, nextState } = getActionKindBetween( origin, actionTarget, newState, diff --git a/src/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts index 40da34d..0a59847 100644 --- a/src/editor/PlayerDomains.ts +++ b/src/editor/PlayerDomains.ts @@ -1,11 +1,31 @@ -import {BallState, Player, PlayerLike, PlayerPhantom,} from "../model/tactic/Player" -import {ComponentId, StepContent, TacticComponent,} from "../model/tactic/Tactic" - -import {removeComponent, updateComponent} from "./TacticContentDomains" -import {removeAllActionsTargeting, spreadNewStateFromOriginStateChange,} from "./ActionsDomains" -import {ActionKind} from "../model/tactic/Action" -import {add, minus, norm, Pos, posWithinBase, ratioWithinBase, relativeTo,} from "../geo/Pos.ts" -import {PLAYER_RADIUS_PIXELS} from "../components/editor/CourtPlayer.tsx" +import { + BallState, + Player, + PlayerLike, + PlayerPhantom, +} from "../model/tactic/Player" +import { + ComponentId, + StepContent, + TacticComponent, +} from "../model/tactic/Tactic" + +import { removeComponent, updateComponent } from "./TacticContentDomains" +import { + removeAllActionsTargeting, + spreadNewStateFromOriginStateChange, +} from "./ActionsDomains" +import { ActionKind } from "../model/tactic/Action" +import { + add, + minus, + norm, + Pos, + posWithinBase, + ratioWithinBase, + relativeTo, +} from "../geo/Pos.ts" +import { PLAYER_RADIUS_PIXELS } from "../components/editor/CourtPlayer.tsx" export function getOrigin( pathItem: PlayerPhantom, @@ -270,9 +290,9 @@ export function truncatePlayerPath( truncateStartIdx == 0 ? null : { - ...path, - items: path.items.toSpliced(truncateStartIdx), - }, + ...path, + items: path.items.toSpliced(truncateStartIdx), + }, }, content, ) diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts new file mode 100644 index 0000000..46c7515 --- /dev/null +++ b/src/editor/StepsDomain.ts @@ -0,0 +1,36 @@ +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 removeStepNode( + root: StepInfoNode, + node: StepInfoNode, +): StepInfoNode | null { + if (root.id === node.id) { + return null + } + + return { + ...root, + children: root.children.flatMap((child) => { + const result = removeStepNode(child, node) + return result ? [result] : [] + }), + } +} diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 0fc76e1..2c682f5 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -1,4 +1,5 @@ -import {Pos, ratioWithinBase} from "../geo/Pos" +import { Pos, ratioWithinBase } from "../geo/Pos" + import { BallState, Player, @@ -18,10 +19,10 @@ import { StepContent, } from "../model/tactic/Tactic" -import {overlaps} from "../geo/Box" -import {RackedCourtObject, RackedPlayer} from "./RackedItems" -import {changePlayerBallState, getComponent, getOrigin} from "./PlayerDomains" -import {ActionKind} from "../model/tactic/Action.ts" +import { overlaps } from "../geo/Box" +import { RackedCourtObject, RackedPlayer } from "./RackedItems" +import { changePlayerBallState, getComponent, getOrigin } from "./PlayerDomains" +import { ActionKind } from "../model/tactic/Action.ts" export function placePlayerAt( refBounds: DOMRect, @@ -50,7 +51,6 @@ export function placeObjectAt( ): StepContent { const pos = ratioWithinBase(refBounds, courtBounds) - let courtObject: CourtObject switch (rackedObject.key) { @@ -188,9 +188,9 @@ export function moveComponent( phantomIdx == 0 ? origin : getComponent( - originPathItems[phantomIdx - 1], - content.components, - ) + originPathItems[phantomIdx - 1], + content.components, + ) // detach the action from the screen target and transform it to a regular move action to the phantom. content = updateComponent( { @@ -198,18 +198,18 @@ export function moveComponent( actions: playerBeforePhantom.actions.map((a) => a.target === referent ? { - ...a, - segments: a.segments.toSpliced( - a.segments.length - 2, - 1, - { - ...a.segments[a.segments.length - 1], - next: component.id, - }, - ), - target: component.id, - type: ActionKind.MOVE, - } + ...a, + segments: a.segments.toSpliced( + a.segments.length - 2, + 1, + { + ...a.segments[a.segments.length - 1], + next: component.id, + }, + ), + target: component.id, + type: ActionKind.MOVE, + } : a, ), }, @@ -222,9 +222,9 @@ export function moveComponent( ...component, pos: isPhantom ? { - type: "fixed", - ...newPos, - } + type: "fixed", + ...newPos, + } : newPos, }, content, @@ -287,5 +287,5 @@ export function getRackPlayers( c.type == "player" && c.team == team && c.role == role, ) == -1, ) - .map((key) => ({team, key})) + .map((key) => ({ team, key })) } diff --git a/src/model/tactic/Tactic.ts b/src/model/tactic/Tactic.ts index 4dadca8..aa33ea7 100644 --- a/src/model/tactic/Tactic.ts +++ b/src/model/tactic/Tactic.ts @@ -2,16 +2,19 @@ import { Player, PlayerPhantom } from "./Player" import { Action } from "./Action" import { CourtObject } from "./CourtObjects" -export interface Tactic { +export interface TacticInfo { readonly id: number readonly name: string readonly courtType: CourtType - readonly currentStepContent: StepContent readonly rootStepNode: StepInfoNode } -export interface StepContent { +export interface TacticStep { readonly stepId: number + readonly content: StepContent +} + +export interface StepContent { readonly components: TacticComponent[] } diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index e65a852..94a77db 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -14,19 +14,24 @@ import TitleInput from "../components/TitleInput" import PlainCourt from "../assets/court/full_court.svg?react" import HalfCourt from "../assets/court/half_court.svg?react" -import {BallPiece} from "../components/editor/BallPiece" +import { BallPiece } from "../components/editor/BallPiece" -import {Rack} from "../components/Rack" -import {PlayerPiece} from "../components/editor/PlayerPiece" +import { Rack } from "../components/Rack" +import { PlayerPiece } from "../components/editor/PlayerPiece" import { - CourtType, StepContent, StepInfoNode, - Tactic, + CourtType, + StepContent, + StepInfoNode, TacticComponent, + TacticInfo, } from "../model/tactic/Tactic" import { fetchAPI, fetchAPIGet } from "../Fetcher" -import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" +import SavingState, { + SaveState, + SaveStates, +} from "../components/editor/SavingState" import { BALL_TYPE } from "../model/tactic/CourtObjects" import { CourtAction } from "../components/editor/CourtAction" @@ -45,13 +50,25 @@ import { updateComponent, } from "../editor/TacticContentDomains" -import {BallState, Player, PlayerInfo, PlayerLike, PlayerTeam,} from "../model/tactic/Player" -import {RackedCourtObject, RackedPlayer} from "../editor/RackedItems" +import { + BallState, + Player, + PlayerInfo, + PlayerLike, + PlayerTeam, +} from "../model/tactic/Player" + +import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" import CourtPlayer from "../components/editor/CourtPlayer" -import {createAction, getActionKind, isActionValid, removeAction,} from "../editor/ActionsDomains" +import { + createAction, + getActionKind, + isActionValid, + removeAction, +} from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" -import {middlePos, Pos, ratioWithinBase} from "../geo/Pos" -import {Action, ActionKind} from "../model/tactic/Action" +import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" +import { Action, ActionKind } from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" import { changePlayerBallState, @@ -63,6 +80,7 @@ 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, removeStepNode } from "../editor/StepsDomain" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -72,15 +90,16 @@ const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content" const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" export interface EditorViewProps { - tactic: Tactic + tactic: TacticInfo onContentChange: (tactic: StepContent) => Promise onNameChange: (name: string) => Promise } + interface TacticDto { id: number name: string courtType: CourtType - content: string + content: { components: TacticComponent[] } root: StepInfoNode } @@ -94,9 +113,9 @@ export default function EditorPage({ guestMode }: EditorPageProps) { return { id: -1, courtType: "PLAIN", - content: '{"components": []}', + content: { components: [] }, name: DEFAULT_TACTIC_NAME, - root: {id: 1, children: []} + root: { id: 1, children: [] }, } } return null @@ -117,13 +136,17 @@ export default function EditorPage({ guestMode }: EditorPageProps) { const treeResponse = await treeResponsePromise const contentResponse = await contentResponsePromise - if (infoResponse.status == 401 || contentResponse.status == 401) { + if ( + infoResponse.status == 401 || + treeResponse.status == 401 || + contentResponse.status == 401 + ) { navigation("/login") return } const { name, courtType } = await infoResponse.json() - const content = await contentResponse.text() + const content = await contentResponse.json() const { root } = await treeResponse.json() setTactic({ id, name, courtType, content, root }) @@ -136,10 +159,11 @@ export default function EditorPage({ guestMode }: EditorPageProps) { return ( ) } @@ -153,23 +177,63 @@ function EditorLoadingScreen() { export interface EditorProps { id: number - name: string - content: string - courtType: CourtType, - rootStepNode: StepInfoNode + initialName: string + courtType: "PLAIN" | "HALF" + initialStepContent: StepContent + initialStepId: number + initialStepsNode: StepInfoNode } -function Editor({ id, name, courtType, content, rootStepNode }: EditorProps) { +function Editor({ + id, + initialName, + courtType, + initialStepContent, + initialStepId, + initialStepsNode, +}: EditorProps) { const isInGuestMode = id == -1 + const navigate = useNavigate() const storageContent = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) - const stepContent = - isInGuestMode && storageContent != null ? storageContent : content + const stepInitialContent = { + ...(isInGuestMode && storageContent != null + ? JSON.parse(storageContent) + : initialStepContent), + } - const storageName = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) - const editorName = isInGuestMode && storageName != null ? storageName : name + const storage_name = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) + const editorName = + isInGuestMode && storage_name != null ? storage_name : initialName - const navigate = useNavigate() + const [stepId, setStepId] = useState(initialStepId) + const [stepContent, setStepContent, saveState] = useContentState( + stepInitialContent, + isInGuestMode ? SaveStates.Guest : SaveStates.Ok, + useMemo( + () => + debounceAsync(async (content: StepContent) => { + if (isInGuestMode) { + localStorage.setItem( + GUEST_MODE_CONTENT_STORAGE_KEY, + JSON.stringify(content), + ) + return SaveStates.Guest + } + const response = await fetchAPI( + `tactics/${id}/steps/${stepId}`, + { + content: { + components: content.components, + }, + }, + "PUT", + ) + return response.ok ? SaveStates.Ok : SaveStates.Err + }, 250), + [id, isInGuestMode, stepId], + ), + ) return ( setStepContent(content, true)} + courtType={courtType} + saveState={saveState} onContentChange={async (content: StepContent) => { if (isInGuestMode) { localStorage.setItem( @@ -195,7 +260,6 @@ function Editor({ id, name, courtType, content, rootStepNode }: EditorProps) { `tactics/${id}/steps/1`, { content }, "PUT", - ) if (response.status == 401) { navigate("/login") @@ -212,47 +276,70 @@ function Editor({ id, name, courtType, content, rootStepNode }: EditorProps) { `tactics/${id}/name`, { name }, "PUT", - ) if (response.status == 401) { navigate("/login") } return response.ok }} - onStepSelected={() => { + selectStep={async (step) => { + const response = await fetchAPIGet( + `tactics/${id}/steps/${step}`, + ) + if (!response.ok) return null + setStepContent( + { stepId: step, ...(await response.json()) }, + false, + ) + }} + onAddStep={async (parent) => { + const response = await fetchAPI(`tactics/${id}/steps`, { + parentId: parent.id, + }) + if (!response.ok) return null + const { stepId } = await response.json() + return { id: stepId, children: [] } + }} + onRemoveStep={async (step) => { + const response = await fetchAPI( + `tactics/${id}/steps/${step.id}`, + {}, + "DELETE", + ) + return response.ok }} - stepsContentsRoot={rootStepNode} - courtType={courtType} /> ) } export interface EditorViewProps { - tactic: Tactic - onContentChange: (tactic: StepContent) => Promise - onStepSelected: (stepId: number) => void, + tactic: TacticInfo + currentStepContent: StepContent + currentStepId: number + saveState: SaveState + setCurrentStepContent: Dispatch> + + selectStep: (stepId: number) => void onNameChange: (name: string) => Promise - stepsContentsRoot: StepInfoNode + onRemoveStep: (step: StepInfoNode) => Promise + onAddStep: (parent: StepInfoNode) => Promise courtType: "PLAIN" | "HALF" } function EditorView({ - - tactic: {id, name, currentStepContent: initialContent}, - onContentChange, - onNameChange, - onStepSelected, - stepsContentsRoot, - courtType, - }: EditorViewProps) { - const isInGuestMode = id == -1 - + tactic: { name, rootStepNode: initialStepsNode }, + setCurrentStepContent: setContent, + currentStepContent: content, + saveState, + onNameChange, + selectStep, + onRemoveStep, + onAddStep, + courtType, +}: EditorViewProps) { const [titleStyle, setTitleStyle] = useState({}) - const [content, setContent, saveState] = useContentState( - initialContent, - isInGuestMode ? SaveStates.Guest : SaveStates.Ok, - useMemo(() => debounceAsync(onContentChange), [onContentChange]), - ) + + const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) const [allies, setAllies] = useState(() => getRackPlayers(PlayerTeam.Allies, content.components), @@ -262,7 +349,7 @@ function EditorView({ ) const [objects, setObjects] = useState(() => - isBallOnCourt(content) ? [] : [{key: "ball"}], + isBallOnCourt(content) ? [] : [{ key: "ball" }], ) const [previewAction, setPreviewAction] = useState( @@ -287,7 +374,7 @@ function EditorView({ ) useEffect(() => { - setObjects(isBallOnCourt(content) ? [] : [{key: "ball"}]) + setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) }, [setObjects, content]) const insertRackedPlayer = (player: Player) => { @@ -300,7 +387,7 @@ function EditorView({ setter = setAllies } if (player.ballState == BallState.HOLDS_BY_PASS) { - setObjects([{key: "ball"}]) + setObjects([{ key: "ball" }]) } setter((players) => [ ...players, @@ -499,7 +586,7 @@ function EditorView({ setContent((content) => removeBall(content)) setObjects((objects) => [ ...objects, - {key: "ball"}, + { key: "ball" }, ]) }} /> @@ -536,7 +623,7 @@ function EditorView({
- +
} + courtImage={} courtRef={courtRef} previewAction={previewAction} renderComponent={renderComponent} @@ -617,7 +704,39 @@ function EditorView({
- + { + const addedNode = await onAddStep(parent) + if (addedNode == null) { + console.error( + "could not add step : onAddStep returned null node", + ) + return + } + setRootStepsNode((root) => + addStepNode(root, parent, addedNode), + ) + }, + [onAddStep], + )} + onRemoveNode={useCallback( + async (removed) => { + const isOk = await onRemoveStep(removed) + if (isOk) + setRootStepsNode( + (root) => removeStepNode(root, removed)!, + ) + }, + [onRemoveStep], + )} + onStepSelected={useCallback( + (node) => selectStep(node.id), + [selectStep], + )} + />
) @@ -626,60 +745,30 @@ function EditorView({ interface EditorStepsTreeProps { isVisible: boolean root: StepInfoNode + onAddChildren: (parent: StepInfoNode) => void + onRemoveNode: (node: StepInfoNode) => void + onStepSelected: (node: StepInfoNode) => void } -function EditorStepsTree({isVisible, root}: EditorStepsTreeProps) { - const fakeRoot: StepInfoNode = { - id: 0, - children: [ - { - id: 1, - children: [ - { - id: 2, - children: [] - }, - { - id: 3, - children: [{ - id: 4, - children: [] - }] - } - ] - }, - { - id: 5, - children: [ - { - id: 6, - children: [] - }, - { - id: 7, - children: [] - } - ] - }, - { - id: 8, - children: [ - { - id: 9, - children: [ - { - id: 10, - children: [] - } - ] - } - ] - } - ] - } +function EditorStepsTree({ + isVisible, + root, + onAddChildren, + onRemoveNode, + onStepSelected, +}: EditorStepsTreeProps) { return ( -
- +
+
) } @@ -695,12 +784,12 @@ interface PlayerRackProps { } function PlayerRack({ - id, - objects, - setObjects, - courtRef, - setComponents, - }: PlayerRackProps) { + id, + objects, + setObjects, + courtRef, + setComponents, +}: PlayerRackProps) { const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], @@ -728,7 +817,7 @@ function PlayerRack({ [courtBounds, setComponents], )} render={useCallback( - ({team, key}: { team: PlayerTeam; key: string }) => ( + ({ team, key }: { team: PlayerTeam; key: string }) => ( courtRef.current!.getBoundingClientRect(), [courtRef], @@ -817,7 +906,7 @@ function CourtPlayerArrowAction({ } setContent((content) => { - let {createdAction, newContent} = createAction( + let { createdAction, newContent } = createAction( player, courtBounds(), headRect, @@ -870,18 +959,18 @@ function isBallOnCourt(content: StepContent) { function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { - return + return } throw new Error("unknown racked court object " + courtObject.key) } -function Court({courtType}: { courtType: string }) { +function Court({ courtType }: { courtType: string }) { return (
{courtType == "PLAIN" ? ( - + ) : ( - + )}
) @@ -904,19 +993,23 @@ function useContentState( initialContent: S, initialSaveState: SaveState, saveStateCallback: (s: S) => Promise, -): [S, Dispatch>, SaveState] { +): [ + S, + (newState: SetStateAction, callSaveCallback: 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) .then(setSavingState) diff --git a/src/style/editor.css b/src/style/editor.css index 8f506b2..b66ed67 100644 --- a/src/style/editor.css +++ b/src/style/editor.css @@ -61,11 +61,15 @@ #steps-div { background-color: var(--editor-tree-background); - overflow: hidden; width: 20%; transform: translateX(100%); transition: transform 500ms; + overflow: scroll; +} + +#steps-div::-webkit-scrollbar { + display: none; } #allies-rack, diff --git a/src/style/steps-tree.css b/src/style/steps-tree.css index 28e3f4f..00ff699 100644 --- a/src/style/steps-tree.css +++ b/src/style/steps-tree.css @@ -1,4 +1,5 @@ .step-piece { + position: relative; font-family: monospace; pointer-events: all; @@ -16,6 +17,42 @@ justify-content: center; user-select: none; + cursor: pointer; +} + +.step-piece:focus, +.step-piece:hover { + background-color: var(--editor-tree-step-piece-hovered); +} + +.step-piece:focus-within .step-piece-actions { + visibility: visible; +} + +.step-piece:hover .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 { @@ -45,4 +82,4 @@ padding-top: 10%; height: 100%; -} \ No newline at end of file +} diff --git a/src/style/theme/default.css b/src/style/theme/default.css index 62bf848..950450a 100644 --- a/src/style/theme/default.css +++ b/src/style/theme/default.css @@ -31,4 +31,8 @@ --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; } From 6756e4064eb9b598091487ac840aa50b34e68e15 Mon Sep 17 00:00:00 2001 From: maxime Date: Fri, 9 Feb 2024 19:54:38 +0100 Subject: [PATCH 03/16] fix routes --- src/pages/Editor.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 94a77db..8d2da94 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -283,14 +283,11 @@ function Editor({ return response.ok }} selectStep={async (step) => { - const response = await fetchAPIGet( - `tactics/${id}/steps/${step}`, - ) - if (!response.ok) return null - setStepContent( - { stepId: step, ...(await response.json()) }, - false, - ) + const response = await fetchAPIGet(`tactics/${id}/steps/${step}`) + if (!response.ok) + return null + setStepId(step) + setStepContent({ ...await response.json() }, false) }} onAddStep={async (parent) => { const response = await fetchAPI(`tactics/${id}/steps`, { From 65672c404a40765a0c73d6a21b490629f288a420 Mon Sep 17 00:00:00 2001 From: maxime Date: Fri, 9 Feb 2024 20:20:00 +0100 Subject: [PATCH 04/16] fix ci --- ci/build_and_deploy_to.sh | 8 +++++--- ci/prepare_php.sh | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 ci/prepare_php.sh diff --git a/ci/build_and_deploy_to.sh b/ci/build_and_deploy_to.sh index 61c5ecf..a5a6601 100755 --- a/ci/build_and_deploy_to.sh +++ b/ci/build_and_deploy_to.sh @@ -1,5 +1,7 @@ set -e +set -xeu + export OUTPUT=$1 export BASE=$2 @@ -10,9 +12,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/ci/prepare_php.sh b/ci/prepare_php.sh new file mode 100644 index 0000000..f2525df --- /dev/null +++ b/ci/prepare_php.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +mkdir -p /outputs/public +# this sed command will replace the included `profile/dev-config-profile.php` to `profile/prod-config-file.php` in the config.php file. +sed -E -i 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php +DRONE_BRANCH_ESCAPED=$(sed -E 's/\//\\\//g' <<< "$DRONE_BRANCH") +sed -E -i "s/const BASE_PATH = .*;/const BASE_PATH = \"\/IQBall\/$DRONE_BRANCH_ESCAPED\/public\";/" profiles/prod-config-profile.php +rm profiles/dev-config-profile.php +mv src config.php sql profiles vendor /outputs/ \ No newline at end of file From 6b4e5ba36ba93d1d77f258d2920a74e6ed04c78b Mon Sep 17 00:00:00 2001 From: maxime Date: Sat, 10 Feb 2024 16:08:24 +0100 Subject: [PATCH 05/16] support steps in guest mode, EditorView refactored --- src/components/editor/StepsTree.tsx | 77 ++-- src/editor/StepsDomain.ts | 22 ++ src/pages/Editor.tsx | 378 +++++++++---------- src/style/{steps-tree.css => steps_tree.css} | 13 +- src/style/theme/default.css | 1 + 5 files changed, 262 insertions(+), 229 deletions(-) rename src/style/{steps-tree.css => steps_tree.css} (81%) diff --git a/src/components/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx index b6443ed..f719066 100644 --- a/src/components/editor/StepsTree.tsx +++ b/src/components/editor/StepsTree.tsx @@ -1,7 +1,7 @@ -import "../../style/steps-tree.css" +import "../../style/steps_tree.css" import { StepInfoNode } from "../../model/tactic/Tactic" import BendableArrow from "../arrows/BendableArrow" -import { useRef } from "react" +import { useRef, useState } from "react" import AddSvg from "../../assets/icon/add.svg?react" import RemoveSvg from "../../assets/icon/remove.svg?react" @@ -13,19 +13,26 @@ export interface StepsTreeProps { } export default function StepsTree({ - root, - onAddChildren, - onRemoveNode, - onStepSelected, -}: StepsTreeProps) { + root, + onAddChildren, + onRemoveNode, + onStepSelected, + }: StepsTreeProps) { + + const [selectedStepId, setSelectedStepId] = useState(root.id) + return (
{ + setSelectedStepId(step.id) + onStepSelected(step) + }} />
) @@ -34,36 +41,34 @@ export default function StepsTree({ interface StepsTreeContentProps { node: StepInfoNode isNodeRoot: boolean + selectedStepId: number, onAddChildren: (parent: StepInfoNode) => void onRemoveNode: (node: StepInfoNode) => void onStepSelected: (node: StepInfoNode) => void } function StepsTreeNode({ - node, - isNodeRoot, - onAddChildren, - onRemoveNode, - onStepSelected, -}: StepsTreeContentProps) { + node, + isNodeRoot, + selectedStepId, + onAddChildren, + onRemoveNode, + onStepSelected, + }: StepsTreeContentProps) { const ref = useRef(null) + return ( -
- onAddChildren(node)} - onRemoveButtonClicked={ - isNodeRoot ? undefined : () => onRemoveNode(node) - } - onSelected={() => onStepSelected(node)} - /> +
+ {node.children.map((child) => ( {}} + segments={[{next: "step-piece-" + child.id}]} + onSegmentsChanges={() => { + }} forceStraight={true} wavy={false} //TODO remove magic constants @@ -71,11 +76,19 @@ function StepsTreeNode({ endRadius={10} /> ))} + onAddChildren(node)} + onRemoveButtonClicked={isNodeRoot ? undefined : () => onRemoveNode(node)} + onSelected={() => onStepSelected(node)} + />
{node.children.map((child) => ( void onRemoveButtonClicked?: () => void onSelected: () => void } function StepPiece({ - id, - onAddButtonClicked, - onRemoveButtonClicked, - onSelected, -}: StepPieceProps) { + id, + isSelected, + onAddButtonClicked, + onRemoveButtonClicked, + onSelected, + }: StepPieceProps) { return (
{onAddButtonClicked && ( diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts index 46c7515..835b147 100644 --- a/src/editor/StepsDomain.ts +++ b/src/editor/StepsDomain.ts @@ -34,3 +34,25 @@ export function removeStepNode( }), } } + +/** + * 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 +} \ No newline at end of file diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 8d2da94..d05bcd7 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -78,22 +78,19 @@ import { } 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, removeStepNode } from "../editor/StepsDomain" +import { addStepNode, getAvailableId, getParent, removeStepNode } from "../editor/StepsDomain" const ERROR_STYLE: CSSProperties = { borderColor: "red", } -const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content" +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 interface EditorViewProps { - tactic: TacticInfo - onContentChange: (tactic: StepContent) => Promise - onNameChange: (name: string) => Promise -} +// The step identifier the editor will always open on +const DEFAULT_STEP_ID = 1 interface TacticDto { id: number @@ -103,34 +100,103 @@ interface TacticDto { root: StepInfoNode } -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, - root: { id: 1, children: [] }, - } - } - return null +export default function Editor({ guestMode }: EditorPageProps) { + return +} + + +function EditorPortal({ guestMode }: EditorPageProps) { + return guestMode ? : +} + +function GuestModeEditor() { + const storageContent = localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0") + + const stepInitialContent = ({ + ...(storageContent == null ? { components: [] } : JSON.parse(storageContent)), + stepId: 0, }) + + // initialize local storage if we launch in guest mode + if (storageContent == null) { + localStorage.setItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, JSON.stringify({ id: 0, children: [] })) + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepInitialContent.stepId, + JSON.stringify(stepInitialContent), + ) + } + + + const [stepId, setStepId] = useState(DEFAULT_STEP_ID) + const [stepContent, setStepContent, saveState] = useContentState( + stepInitialContent, + SaveStates.Guest, + useMemo(() => debounceAsync(async (content: StepContent) => { + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, + JSON.stringify(content), + ) + return SaveStates.Guest + }, 250), [stepId]), + ) + + return setStepContent(content, true)} + saveState={saveState} + currentStepId={stepId} + onNameChange={useCallback(async name => { + localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) + return true //simulate that the name has been changed + }, [])} + selectStep={useCallback(step => { + setStepId(step) + setStepContent({ ...JSON.parse(localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!) }, false) + return + }, [setStepContent])} + onAddStep={useCallback(async parent => { + const root: StepInfoNode = JSON.parse(localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!) + + const nodeId = getAvailableId(root) + const node = { id: nodeId, children: [] } + + localStorage.setItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, JSON.stringify(addStepNode(root, parent, node))) + localStorage.setItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id, JSON.stringify({ + stepId: node.id, + components: [], + })) + return node + }, [])} + onRemoveStep={useCallback(async step => { + 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, step))) + return true + }, [])} + /> +} + +function UserModeEditor() { + const [tactic, setTactic] = useState(null) const { tacticId: idStr } = useParams() - const id = guestMode ? -1 : parseInt(idStr!) + const id = parseInt(idStr!) const navigation = useNavigate() useEffect(() => { - if (guestMode) return async function initialize() { const infoResponsePromise = fetchAPIGet(`tactics/${id}`) const treeResponsePromise = fetchAPIGet(`tactics/${id}/tree`) - const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/1`) + const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/${DEFAULT_STEP_ID}`) const infoResponse = await infoResponsePromise const treeResponse = await treeResponsePromise @@ -153,160 +219,81 @@ export default function EditorPage({ guestMode }: EditorPageProps) { } initialize() - }, [guestMode, id, idStr, navigation]) - - if (tactic) { - return ( - - ) - } - - return -} - -function EditorLoadingScreen() { - return
Loading Editor, please wait...
-} - -export interface EditorProps { - id: number - initialName: string - courtType: "PLAIN" | "HALF" - initialStepContent: StepContent - initialStepId: number - initialStepsNode: StepInfoNode -} - -function Editor({ - id, - initialName, - courtType, - initialStepContent, - initialStepId, - initialStepsNode, -}: EditorProps) { - const isInGuestMode = id == -1 - const navigate = useNavigate() - - const storageContent = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) - const stepInitialContent = { - ...(isInGuestMode && storageContent != null - ? JSON.parse(storageContent) - : initialStepContent), - } + }, [id, idStr, navigation]) - const storage_name = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) - const editorName = - isInGuestMode && storage_name != null ? storage_name : initialName - const [stepId, setStepId] = useState(initialStepId) + const [stepId, setStepId] = useState(1) const [stepContent, setStepContent, saveState] = useContentState( - stepInitialContent, - isInGuestMode ? SaveStates.Guest : SaveStates.Ok, - useMemo( - () => - debounceAsync(async (content: StepContent) => { - if (isInGuestMode) { - localStorage.setItem( - GUEST_MODE_CONTENT_STORAGE_KEY, - JSON.stringify(content), - ) - return SaveStates.Guest - } - const response = await fetchAPI( - `tactics/${id}/steps/${stepId}`, - { - content: { - components: content.components, - }, - }, - "PUT", - ) - return response.ok ? SaveStates.Ok : SaveStates.Err - }, 250), - [id, isInGuestMode, stepId], - ), + tactic?.content ?? { components: [] }, + SaveStates.Ok, + useMemo(() => debounceAsync(async (content: StepContent) => { + const response = await fetchAPI( + `tactics/${id}/steps/${stepId}`, + { + content: { + components: content.components, + }, + }, + "PUT", + ) + return response.ok ? SaveStates.Ok : SaveStates.Err + }, 250), [id, stepId]), ) + const onNameChange = useCallback((name: string) => + fetchAPI(`tactics/${id}/edit/name`, { name }) + .then((r) => r.ok) + , [id]) + + const selectStep = useCallback(async (step: number) => { + const response = await fetchAPIGet(`tactics/${id}/steps/${step}`) + if (!response.ok) + return + setStepId(step) + setStepContent({ ...await response.json() }, false) + }, [id, setStepContent]) + + const onAddStep = useCallback(async (parent: StepInfoNode) => { + const response = await fetchAPI(`tactics/${id}/steps`, { + parentId: parent.id, + }) + if (!response.ok) + return null + const { stepId } = await response.json() + return { id: stepId, children: [] } + }, [id]) + + const onRemoveStep = useCallback((step: StepInfoNode) => + fetchAPI( + `tactics/${id}/steps/${step.id}`, + {}, + "DELETE", + ).then(r => r.ok) + , [id]) + + + if (!tactic) + return + + return setStepContent(content, true)} + saveState={saveState} + onNameChange={onNameChange} + selectStep={selectStep} + onAddStep={onAddStep} + onRemoveStep={onRemoveStep} + /> +} - return ( - setStepContent(content, true)} - courtType={courtType} - saveState={saveState} - onContentChange={async (content: StepContent) => { - if (isInGuestMode) { - localStorage.setItem( - GUEST_MODE_CONTENT_STORAGE_KEY, - JSON.stringify(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 - } - - const response = await fetchAPI( - `tactics/${id}/name`, - { name }, - "PUT", - ) - if (response.status == 401) { - navigate("/login") - } - return response.ok - }} - selectStep={async (step) => { - const response = await fetchAPIGet(`tactics/${id}/steps/${step}`) - if (!response.ok) - return null - setStepId(step) - setStepContent({ ...await response.json() }, false) - }} - onAddStep={async (parent) => { - const response = await fetchAPI(`tactics/${id}/steps`, { - parentId: parent.id, - }) - if (!response.ok) return null - const { stepId } = await response.json() - return { id: stepId, children: [] } - }} - onRemoveStep={async (step) => { - const response = await fetchAPI( - `tactics/${id}/steps/${step.id}`, - {}, - "DELETE", - ) - return response.ok - }} - /> - ) +function EditorLoadingScreen() { + return

Loading Editor, Please wait...

} export interface EditorViewProps { @@ -320,20 +307,25 @@ export interface EditorViewProps { onNameChange: (name: string) => Promise onRemoveStep: (step: StepInfoNode) => Promise onAddStep: (parent: StepInfoNode) => Promise - courtType: "PLAIN" | "HALF" } -function EditorView({ - tactic: { name, rootStepNode: initialStepsNode }, - setCurrentStepContent: setContent, - currentStepContent: content, - saveState, - onNameChange, - selectStep, - onRemoveStep, - onAddStep, - courtType, -}: EditorViewProps) { + + +function EditorPage({ + tactic: { + name, + rootStepNode: initialStepsNode, + courtType, + }, + setCurrentStepContent: setContent, + currentStepContent: content, + saveState, + onNameChange, + selectStep, + onRemoveStep, + onAddStep, + }: EditorViewProps) { + const [titleStyle, setTitleStyle] = useState({}) const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) @@ -570,10 +562,10 @@ function EditorView({ 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 ( { const isOk = await onRemoveStep(removed) - if (isOk) - setRootStepsNode( - (root) => removeStepNode(root, removed)!, - ) + selectStep(getParent(rootStepsNode, removed)!.id) + if (isOk) setRootStepsNode( + (root) => removeStepNode(root, removed)!, + ) }, - [onRemoveStep], + [rootStepsNode, onRemoveStep, selectStep], )} onStepSelected={useCallback( (node) => selectStep(node.id), @@ -879,7 +871,7 @@ function CourtPlayerArrowAction({ })) }} onHeadPicked={(headPos) => { - ;(document.activeElement as HTMLElement).blur() + (document.activeElement as HTMLElement).blur() setPreviewAction({ origin: playerInfo.id, diff --git a/src/style/steps-tree.css b/src/style/steps_tree.css similarity index 81% rename from src/style/steps-tree.css rename to src/style/steps_tree.css index 00ff699..4b58090 100644 --- a/src/style/steps-tree.css +++ b/src/style/steps_tree.css @@ -18,18 +18,21 @@ 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:focus-within .step-piece-actions { - visibility: visible; -} - -.step-piece:hover .step-piece-actions { +.step-piece-selected .step-piece-actions, .step-piece:hover .step-piece-actions, .step-piece:focus-within .step-piece-actions { visibility: visible; } diff --git a/src/style/theme/default.css b/src/style/theme/default.css index 950450a..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; From 1d235da809ecdd28edbbb103c3cb0bf2915bd1a1 Mon Sep 17 00:00:00 2001 From: maxime Date: Sun, 11 Feb 2024 17:36:09 +0100 Subject: [PATCH 06/16] fixed step remove on backend side, adding a step now duplicates the content of the parent --- src/pages/Editor.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index d05bcd7..02b81cd 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -164,17 +164,14 @@ function GuestModeEditor() { setStepContent({ ...JSON.parse(localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!) }, false) return }, [setStepContent])} - onAddStep={useCallback(async parent => { + onAddStep={useCallback(async (parent, content) => { const root: StepInfoNode = JSON.parse(localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!) const nodeId = getAvailableId(root) const node = { id: nodeId, children: [] } localStorage.setItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, JSON.stringify(addStepNode(root, parent, node))) - localStorage.setItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id, JSON.stringify({ - stepId: node.id, - components: [], - })) + localStorage.setItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id, JSON.stringify(content)) return node }, [])} onRemoveStep={useCallback(async step => { @@ -240,7 +237,7 @@ function UserModeEditor() { }, 250), [id, stepId]), ) const onNameChange = useCallback((name: string) => - fetchAPI(`tactics/${id}/edit/name`, { name }) + fetchAPI(`tactics/${id}/name`, { name }, "PUT") .then((r) => r.ok) , [id]) @@ -252,9 +249,10 @@ function UserModeEditor() { setStepContent({ ...await response.json() }, false) }, [id, setStepContent]) - const onAddStep = useCallback(async (parent: StepInfoNode) => { + const onAddStep = useCallback(async (parent: StepInfoNode, content: StepContent) => { const response = await fetchAPI(`tactics/${id}/steps`, { parentId: parent.id, + content }) if (!response.ok) return null @@ -306,7 +304,7 @@ export interface EditorViewProps { selectStep: (stepId: number) => void onNameChange: (name: string) => Promise onRemoveStep: (step: StepInfoNode) => Promise - onAddStep: (parent: StepInfoNode) => Promise + onAddStep: (parent: StepInfoNode, content: StepContent) => Promise } @@ -698,7 +696,7 @@ function EditorPage({ root={rootStepsNode} onAddChildren={useCallback( async (parent) => { - const addedNode = await onAddStep(parent) + const addedNode = await onAddStep(parent, content) if (addedNode == null) { console.error( "could not add step : onAddStep returned null node", @@ -709,7 +707,7 @@ function EditorPage({ addStepNode(root, parent, addedNode), ) }, - [onAddStep], + [content, onAddStep], )} onRemoveNode={useCallback( async (removed) => { From 4cee7649af685b51f81db2e7fb6ee71ba5e05283 Mon Sep 17 00:00:00 2001 From: maxime Date: Sun, 11 Feb 2024 19:10:41 +0100 Subject: [PATCH 07/16] compute the terminal state of a step and apply it on newly created children --- src/components/editor/StepsTree.tsx | 67 +++--- src/editor/StepsDomain.ts | 13 +- src/editor/TacticContentDomains.ts | 92 ++++++- src/pages/Editor.tsx | 361 +++++++++++++++++----------- src/style/steps_tree.css | 4 +- 5 files changed, 348 insertions(+), 189 deletions(-) diff --git a/src/components/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx index f719066..c019aab 100644 --- a/src/components/editor/StepsTree.tsx +++ b/src/components/editor/StepsTree.tsx @@ -1,26 +1,25 @@ import "../../style/steps_tree.css" import { StepInfoNode } from "../../model/tactic/Tactic" import BendableArrow from "../arrows/BendableArrow" -import { useRef, useState } from "react" +import { useRef } from "react" import AddSvg from "../../assets/icon/add.svg?react" import RemoveSvg from "../../assets/icon/remove.svg?react" export interface StepsTreeProps { root: StepInfoNode + selectedStepId: number onAddChildren: (parent: StepInfoNode) => void onRemoveNode: (node: StepInfoNode) => void onStepSelected: (node: StepInfoNode) => void } export default function StepsTree({ - root, - onAddChildren, - onRemoveNode, - onStepSelected, - }: StepsTreeProps) { - - const [selectedStepId, setSelectedStepId] = useState(root.id) - + root, + selectedStepId, + onAddChildren, + onRemoveNode, + onStepSelected, +}: StepsTreeProps) { return (
{ - setSelectedStepId(step.id) - onStepSelected(step) - }} + onStepSelected={onStepSelected} />
) @@ -41,34 +37,31 @@ export default function StepsTree({ interface StepsTreeContentProps { node: StepInfoNode isNodeRoot: boolean - selectedStepId: number, + selectedStepId: number onAddChildren: (parent: StepInfoNode) => void onRemoveNode: (node: StepInfoNode) => void onStepSelected: (node: StepInfoNode) => void } function StepsTreeNode({ - node, - isNodeRoot, - selectedStepId, - onAddChildren, - onRemoveNode, - onStepSelected, - }: StepsTreeContentProps) { + node, + isNodeRoot, + selectedStepId, + onAddChildren, + onRemoveNode, + onStepSelected, +}: StepsTreeContentProps) { const ref = useRef(null) return ( -
- +
{node.children.map((child) => ( { - }} + segments={[{ next: "step-piece-" + child.id }]} + onSegmentsChanges={() => {}} forceStraight={true} wavy={false} //TODO remove magic constants @@ -80,7 +73,9 @@ function StepsTreeNode({ id={node.id} isSelected={selectedStepId === node.id} onAddButtonClicked={() => onAddChildren(node)} - onRemoveButtonClicked={isNodeRoot ? undefined : () => onRemoveNode(node)} + onRemoveButtonClicked={ + isNodeRoot ? undefined : () => onRemoveNode(node) + } onSelected={() => onStepSelected(node)} />
@@ -109,17 +104,19 @@ interface StepPieceProps { } function StepPiece({ - id, - isSelected, - onAddButtonClicked, - onRemoveButtonClicked, - onSelected, - }: StepPieceProps) { + id, + isSelected, + onAddButtonClicked, + onRemoveButtonClicked, + onSelected, +}: StepPieceProps) { return (
{onAddButtonClicked && ( diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts index 835b147..bb92dd4 100644 --- a/src/editor/StepsDomain.ts +++ b/src/editor/StepsDomain.ts @@ -40,13 +40,16 @@ export function removeStepNode( * @param root */ export function getAvailableId(root: StepInfoNode): number { - const acc = (root: StepInfoNode): number => Math.max(root.id, ...root.children.map(acc)) + 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 +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) @@ -55,4 +58,4 @@ export function getParent(root: StepInfoNode, node: StepInfoNode): StepInfoNode } } return null -} \ No newline at end of file +} diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 2c682f5..c6ac64f 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -5,6 +5,7 @@ import { Player, PlayerInfo, PlayerLike, + PlayerPhantom, PlayerTeam, } from "../model/tactic/Player" import { @@ -15,13 +16,18 @@ import { } from "../model/tactic/CourtObjects" import { ComponentId, - TacticComponent, StepContent, + TacticComponent, } from "../model/tactic/Tactic" import { overlaps } from "../geo/Box" import { RackedCourtObject, RackedPlayer } from "./RackedItems" -import { changePlayerBallState, getComponent, getOrigin } from "./PlayerDomains" +import { + changePlayerBallState, + computePhantomPositioning, + getComponent, + getOrigin, +} from "./PlayerDomains" import { ActionKind } from "../model/tactic/Action.ts" export function placePlayerAt( @@ -289,3 +295,85 @@ 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 courtArea + */ +export function getTerminalState( + content: StepContent, + courtArea: DOMRect, +): StepContent { + const nonPhantomComponents: (Player | CourtObject)[] = + content.components.filter((c) => c.type !== "phantom") as ( + | Player + | CourtObject + )[] + + const componentsTargetedState = nonPhantomComponents.map((comp) => + comp.type === "player" + ? getPlayerTerminalState(comp, content, courtArea) + : comp, + ) + + return { + components: componentsTargetedState, + } +} + +function getPlayerTerminalState( + player: Player, + content: StepContent, + area: DOMRect, +): 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 { + return component.type === "phantom" + ? computePhantomPositioning(component, content, area) + : component.pos + } + + const phantoms = player.path?.items + if (!phantoms || phantoms.length === 0) { + const pos = getTerminalPos(player) + + return { + ...player, + ballState: stateAfter(player.ballState), + actions: [], + pos, + } + } + 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, + } +} diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 02b81cd..5068f85 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -42,6 +42,7 @@ import { dropBallOnComponent, getComponentCollided, getRackPlayers, + getTerminalState, moveComponent, placeBallAt, placeObjectAt, @@ -79,7 +80,12 @@ import { import { CourtBall } from "../components/editor/CourtBall" import { useNavigate, useParams } from "react-router-dom" import StepsTree from "../components/editor/StepsTree" -import { addStepNode, getAvailableId, getParent, removeStepNode } from "../editor/StepsDomain" +import { + addStepNode, + getAvailableId, + getParent, + removeStepNode, +} from "../editor/StepsDomain" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -96,7 +102,6 @@ interface TacticDto { id: number name: string courtType: CourtType - content: { components: TacticComponent[] } root: StepInfoNode } @@ -108,78 +113,124 @@ export default function Editor({ guestMode }: EditorPageProps) { return } - function EditorPortal({ guestMode }: EditorPageProps) { return guestMode ? : } function GuestModeEditor() { - const storageContent = localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0") + const storageContent = localStorage.getItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0", + ) - const stepInitialContent = ({ - ...(storageContent == null ? { components: [] } : JSON.parse(storageContent)), + const stepInitialContent = { + ...(storageContent == null + ? { components: [] } + : JSON.parse(storageContent)), stepId: 0, - }) + } // initialize local storage if we launch in guest mode if (storageContent == null) { - localStorage.setItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, JSON.stringify({ id: 0, children: [] })) + localStorage.setItem( + GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, + JSON.stringify({ id: 0, children: [] }), + ) localStorage.setItem( GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepInitialContent.stepId, JSON.stringify(stepInitialContent), ) } - const [stepId, setStepId] = useState(DEFAULT_STEP_ID) const [stepContent, setStepContent, saveState] = useContentState( stepInitialContent, SaveStates.Guest, - useMemo(() => debounceAsync(async (content: StepContent) => { - localStorage.setItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, - JSON.stringify(content), - ) - return SaveStates.Guest - }, 250), [stepId]), + useMemo( + () => + debounceAsync(async (content: StepContent) => { + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, + JSON.stringify(content), + ) + return SaveStates.Guest + }, 250), + [stepId], + ), ) - return setStepContent(content, true)} - saveState={saveState} - currentStepId={stepId} - onNameChange={useCallback(async name => { - localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) - return true //simulate that the name has been changed - }, [])} - selectStep={useCallback(step => { - setStepId(step) - setStepContent({ ...JSON.parse(localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!) }, false) - return - }, [setStepContent])} - onAddStep={useCallback(async (parent, content) => { - const root: StepInfoNode = JSON.parse(localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!) - - const nodeId = getAvailableId(root) - const node = { id: nodeId, children: [] } - - localStorage.setItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, JSON.stringify(addStepNode(root, parent, node))) - localStorage.setItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id, JSON.stringify(content)) - return node - }, [])} - onRemoveStep={useCallback(async step => { - 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, step))) - return true - }, [])} - /> + return ( + setStepContent(content, true)} + saveState={saveState} + currentStepId={stepId} + onNameChange={useCallback(async (name) => { + localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) + return true //simulate that the name has been changed + }, [])} + selectStep={useCallback( + (step) => { + setStepId(step) + setStepContent( + { + ...JSON.parse( + localStorage.getItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step, + )!, + ), + }, + false, + ) + return + }, + [setStepContent], + )} + onAddStep={useCallback(async (parent, content) => { + const root: StepInfoNode = JSON.parse( + localStorage.getItem( + GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, + )!, + ) + + const nodeId = getAvailableId(root) + const node = { id: nodeId, children: [] } + + localStorage.setItem( + GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, + JSON.stringify(addStepNode(root, parent, node)), + ) + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id, + JSON.stringify(content), + ) + return node + }, [])} + onRemoveStep={useCallback(async (step) => { + 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, step)), + ) + return true + }, [])} + /> + ) } function UserModeEditor() { @@ -188,12 +239,36 @@ function UserModeEditor() { const id = parseInt(idStr!) const navigation = useNavigate() - useEffect(() => { + const [stepId, setStepId] = useState(1) + const [stepContent, setStepContent, saveState] = + useContentState( + { components: [] }, + SaveStates.Ok, + useMemo( + () => + debounceAsync(async (content: StepContent) => { + const response = await fetchAPI( + `tactics/${id}/steps/${stepId}`, + { + content: { + components: content.components, + }, + }, + "PUT", + ) + return response.ok ? SaveStates.Ok : SaveStates.Err + }, 250), + [id, stepId], + ), + ) + useEffect(() => { async function initialize() { const infoResponsePromise = fetchAPIGet(`tactics/${id}`) const treeResponsePromise = fetchAPIGet(`tactics/${id}/tree`) - const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/${DEFAULT_STEP_ID}`) + const contentResponsePromise = fetchAPIGet( + `tactics/${id}/steps/${DEFAULT_STEP_ID}`, + ) const infoResponse = await infoResponsePromise const treeResponse = await treeResponsePromise @@ -212,82 +287,70 @@ function UserModeEditor() { const content = await contentResponse.json() const { root } = await treeResponse.json() - setTactic({ id, name, courtType, content, root }) + setTactic({ id, name, courtType, root }) + setStepContent(content, false) } initialize() }, [id, idStr, navigation]) + const onNameChange = useCallback( + (name: string) => + fetchAPI(`tactics/${id}/name`, { name }, "PUT").then((r) => r.ok), + [id], + ) - const [stepId, setStepId] = useState(1) - const [stepContent, setStepContent, saveState] = useContentState( - tactic?.content ?? { components: [] }, - SaveStates.Ok, - useMemo(() => debounceAsync(async (content: StepContent) => { - const response = await fetchAPI( - `tactics/${id}/steps/${stepId}`, - { - content: { - components: content.components, - }, - }, - "PUT", - ) - return response.ok ? SaveStates.Ok : SaveStates.Err - }, 250), [id, stepId]), + const selectStep = useCallback( + async (step: number) => { + const response = await fetchAPIGet(`tactics/${id}/steps/${step}`) + if (!response.ok) return + setStepId(step) + setStepContent({ ...(await response.json()) }, false) + }, + [id, setStepContent], + ) + + const onAddStep = useCallback( + async (parent: StepInfoNode, content: StepContent) => { + const response = await fetchAPI(`tactics/${id}/steps`, { + parentId: parent.id, + content, + }) + if (!response.ok) return null + const { stepId } = await response.json() + return { id: stepId, children: [] } + }, + [id], + ) + + const onRemoveStep = useCallback( + (step: StepInfoNode) => + fetchAPI(`tactics/${id}/steps/${step.id}`, {}, "DELETE").then( + (r) => r.ok, + ), + [id], + ) + + if (!tactic) return + + return ( + setStepContent(content, true)} + saveState={saveState} + onNameChange={onNameChange} + selectStep={selectStep} + onAddStep={onAddStep} + onRemoveStep={onRemoveStep} + /> ) - const onNameChange = useCallback((name: string) => - fetchAPI(`tactics/${id}/name`, { name }, "PUT") - .then((r) => r.ok) - , [id]) - - const selectStep = useCallback(async (step: number) => { - const response = await fetchAPIGet(`tactics/${id}/steps/${step}`) - if (!response.ok) - return - setStepId(step) - setStepContent({ ...await response.json() }, false) - }, [id, setStepContent]) - - const onAddStep = useCallback(async (parent: StepInfoNode, content: StepContent) => { - const response = await fetchAPI(`tactics/${id}/steps`, { - parentId: parent.id, - content - }) - if (!response.ok) - return null - const { stepId } = await response.json() - return { id: stepId, children: [] } - }, [id]) - - const onRemoveStep = useCallback((step: StepInfoNode) => - fetchAPI( - `tactics/${id}/steps/${step.id}`, - {}, - "DELETE", - ).then(r => r.ok) - , [id]) - - - if (!tactic) - return - - return setStepContent(content, true)} - saveState={saveState} - onNameChange={onNameChange} - selectStep={selectStep} - onAddStep={onAddStep} - onRemoveStep={onRemoveStep} - /> } function EditorLoadingScreen() { @@ -304,26 +367,23 @@ export interface EditorViewProps { selectStep: (stepId: number) => void onNameChange: (name: string) => Promise onRemoveStep: (step: StepInfoNode) => Promise - onAddStep: (parent: StepInfoNode, content: StepContent) => Promise + onAddStep: ( + parent: StepInfoNode, + content: StepContent, + ) => Promise } - - function EditorPage({ - tactic: { - name, - rootStepNode: initialStepsNode, - courtType, - }, - setCurrentStepContent: setContent, - currentStepContent: content, - saveState, - onNameChange, - selectStep, - onRemoveStep, - onAddStep, - }: EditorViewProps) { - + tactic: { name, rootStepNode: initialStepsNode, courtType }, + currentStepId, + setCurrentStepContent: setContent, + currentStepContent: content, + saveState, + onNameChange, + selectStep, + onRemoveStep, + onAddStep, +}: EditorViewProps) { const [titleStyle, setTitleStyle] = useState({}) const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) @@ -693,29 +753,35 @@ function EditorPage({
{ - const addedNode = await onAddStep(parent, content) + const addedNode = await onAddStep( + parent, + getTerminalState(content, courtBounds()), + ) if (addedNode == null) { console.error( "could not add step : onAddStep returned null node", ) return } + selectStep(addedNode.id) setRootStepsNode((root) => addStepNode(root, parent, addedNode), ) }, - [content, onAddStep], + [content, courtBounds, onAddStep, selectStep], )} onRemoveNode={useCallback( async (removed) => { const isOk = await onRemoveStep(removed) selectStep(getParent(rootStepsNode, removed)!.id) - if (isOk) setRootStepsNode( - (root) => removeStepNode(root, removed)!, - ) + if (isOk) + setRootStepsNode( + (root) => removeStepNode(root, removed)!, + ) }, [rootStepsNode, onRemoveStep, selectStep], )} @@ -731,6 +797,7 @@ function EditorPage({ interface EditorStepsTreeProps { isVisible: boolean + selectedStepId: number root: StepInfoNode onAddChildren: (parent: StepInfoNode) => void onRemoveNode: (node: StepInfoNode) => void @@ -739,6 +806,7 @@ interface EditorStepsTreeProps { function EditorStepsTree({ isVisible, + selectedStepId, root, onAddChildren, onRemoveNode, @@ -752,6 +820,7 @@ function EditorStepsTree({ }}> { - (document.activeElement as HTMLElement).blur() + ;(document.activeElement as HTMLElement).blur() setPreviewAction({ origin: playerInfo.id, diff --git a/src/style/steps_tree.css b/src/style/steps_tree.css index 4b58090..462b930 100644 --- a/src/style/steps_tree.css +++ b/src/style/steps_tree.css @@ -32,7 +32,9 @@ 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 { +.step-piece-selected .step-piece-actions, +.step-piece:hover .step-piece-actions, +.step-piece:focus-within .step-piece-actions { visibility: visible; } From 72273e3f3e3ee66bc258508cd76833b6e0b64395 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Tue, 27 Feb 2024 19:20:59 +0100 Subject: [PATCH 08/16] spread changes of step content to its direct children --- src/editor/ActionsDomains.ts | 48 ++-- src/editor/PlayerDomains.ts | 66 +++-- src/editor/StepsDomain.ts | 16 +- src/editor/TacticContentDomains.ts | 118 ++++++--- src/geo/Pos.ts | 5 + src/model/tactic/Player.ts | 2 + src/pages/Editor.tsx | 404 ++++++++++++++++++++--------- 7 files changed, 456 insertions(+), 203 deletions(-) diff --git a/src/editor/ActionsDomains.ts b/src/editor/ActionsDomains.ts index 7894cce..58e1613 100644 --- a/src/editor/ActionsDomains.ts +++ b/src/editor/ActionsDomains.ts @@ -7,15 +7,14 @@ import { import { ratioWithinBase } from "../geo/Pos" import { ComponentId, - TacticComponent, StepContent, + TacticComponent, } 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, @@ -411,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 } } @@ -456,6 +462,7 @@ export function removeAction( /** * Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with * the given newState. + * @returns the new state if it has been updated, or null if no changes were operated * @param origin * @param newState * @param content @@ -464,9 +471,9 @@ export function spreadNewStateFromOriginStateChange( origin: PlayerLike, newState: BallState, content: StepContent, -): StepContent { +): StepContent | null { if (origin.ballState === newState) { - return content + return null } origin = { @@ -552,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 0a59847..fd5c914 100644 --- a/src/editor/PlayerDomains.ts +++ b/src/editor/PlayerDomains.ts @@ -55,11 +55,23 @@ 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 positioning = phantom.pos + + // If the position is already known and fixed, return the pos + if (positioning.type === "fixed") return positioning + + return computedPositions.get(phantom.id) +} + + export function computePhantomPositioning( phantom: PlayerPhantom, content: StepContent, + computedPositions: Map, area: DOMRect, ): Pos { const positioning = phantom.pos @@ -67,6 +79,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. @@ -77,7 +92,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) @@ -109,10 +129,11 @@ export function computePhantomPositioning( pivotPoint = playerBeforePhantom.type === "phantom" ? computePhantomPositioning( - playerBeforePhantom, - content, - area, - ) + playerBeforePhantom, + content, + computedPositions, + area, + ) : playerBeforePhantom.pos } } @@ -126,14 +147,23 @@ export function computePhantomPositioning( }) 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) { @@ -254,10 +284,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 ) } @@ -297,11 +329,3 @@ export function truncatePlayerPath( content, ) } - -export function changePlayerBallState( - player: PlayerLike, - newState: BallState, - content: StepContent, -): StepContent { - return spreadNewStateFromOriginStateChange(player, newState, content) -} diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts index bb92dd4..88904e0 100644 --- a/src/editor/StepsDomain.ts +++ b/src/editor/StepsDomain.ts @@ -18,12 +18,24 @@ export function addStepNode( } } +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, node: StepInfoNode, -): StepInfoNode | null { +): StepInfoNode | undefined { if (root.id === node.id) { - return null + return undefined } return { diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index c6ac64f..8eeacd9 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -1,4 +1,4 @@ -import { Pos, ratioWithinBase } from "../geo/Pos" +import { equals, Pos, ratioWithinBase } from "../geo/Pos" import { BallState, @@ -23,12 +23,13 @@ import { import { overlaps } from "../geo/Box" import { RackedCourtObject, RackedPlayer } from "./RackedItems" import { - changePlayerBallState, - computePhantomPositioning, getComponent, getOrigin, + getPrecomputedPosition, + tryGetComponent, } from "./PlayerDomains" import { ActionKind } from "../model/tactic/Action.ts" +import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts" export function placePlayerAt( refBounds: DOMRect, @@ -103,7 +104,9 @@ export function dropBallOnComponent( ? BallState.HOLDS_ORIGIN : BallState.HOLDS_BY_PASS - content = changePlayerBallState(component, newState, content) + content = + spreadNewStateFromOriginStateChange(component, newState, content) ?? + content } return removeBall(content) @@ -194,9 +197,9 @@ export function moveComponent( phantomIdx == 0 ? origin : getComponent( - originPathItems[phantomIdx - 1], - content.components, - ) + originPathItems[phantomIdx - 1], + content.components, + ) // detach the action from the screen target and transform it to a regular move action to the phantom. content = updateComponent( { @@ -204,18 +207,18 @@ export function moveComponent( actions: playerBeforePhantom.actions.map((a) => a.target === referent ? { - ...a, - segments: a.segments.toSpliced( - a.segments.length - 2, - 1, - { - ...a.segments[a.segments.length - 1], - next: component.id, - }, - ), - target: component.id, - type: ActionKind.MOVE, - } + ...a, + segments: a.segments.toSpliced( + a.segments.length - 2, + 1, + { + ...a.segments[a.segments.length - 1], + next: component.id, + }, + ), + target: component.id, + type: ActionKind.MOVE, + } : a, ), }, @@ -228,9 +231,9 @@ export function moveComponent( ...component, pos: isPhantom ? { - type: "fixed", - ...newPos, - } + type: "fixed", + ...newPos, + } : newPos, }, content, @@ -299,21 +302,21 @@ export function getRackPlayers( /** * Returns a step content that only contains the terminal state of each components inside the given content * @param content - * @param courtArea + * @param computedPositions */ -export function getTerminalState( +export function computeTerminalState( content: StepContent, - courtArea: DOMRect, + computedPositions: Map, ): StepContent { const nonPhantomComponents: (Player | CourtObject)[] = content.components.filter((c) => c.type !== "phantom") as ( | Player | CourtObject - )[] + )[] const componentsTargetedState = nonPhantomComponents.map((comp) => comp.type === "player" - ? getPlayerTerminalState(comp, content, courtArea) + ? getPlayerTerminalState(comp, content, computedPositions) : comp, ) @@ -325,7 +328,7 @@ export function getTerminalState( function getPlayerTerminalState( player: Player, content: StepContent, - area: DOMRect, + computedPositions: Map, ): Player { function stateAfter(state: BallState): BallState { switch (state) { @@ -342,9 +345,15 @@ function getPlayerTerminalState( } function getTerminalPos(component: PlayerLike): Pos { - return component.type === "phantom" - ? computePhantomPositioning(component, content, area) - : component.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 @@ -377,3 +386,50 @@ function getPlayerTerminalState( pos, } } + +export function drainTerminalStateOnChildContent( + parentTerminalState: StepContent, + childContent: StepContent, +): StepContent | null { + let gotUpdated = false + + for (const parentComponent of parentTerminalState.components) { + const 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 + } + // also update the position of the player if it has been moved + if (!equals(childComponent.pos, parentComponent.pos)) { + gotUpdated = true + childContent = updateComponent({ + ...childComponent, + pos: parentComponent.pos, + }, childContent) + } + } + + return gotUpdated ? childContent : null +} diff --git a/src/geo/Pos.ts b/src/geo/Pos.ts index be7a704..d3d7337 100644 --- a/src/geo/Pos.ts +++ b/src/geo/Pos.ts @@ -3,6 +3,11 @@ 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/Player.ts b/src/model/tactic/Player.ts index 2dee897..842bbbe 100644 --- a/src/model/tactic/Player.ts +++ b/src/model/tactic/Player.ts @@ -71,6 +71,8 @@ export type PhantomPositioning = | FixedPhantomPositioning | FollowsPhantomPositioning + + /** * A player phantom is a kind of component that represents the future state of a player * according to the court's step information diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 5068f85..2fd6627 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -20,6 +20,7 @@ import { Rack } from "../components/Rack" import { PlayerPiece } from "../components/editor/PlayerPiece" import { + ComponentId, CourtType, StepContent, StepInfoNode, @@ -39,10 +40,11 @@ import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" import { overlaps } from "../geo/Box" import { + computeTerminalState, + drainTerminalStateOnChildContent, dropBallOnComponent, getComponentCollided, getRackPlayers, - getTerminalState, moveComponent, placeBallAt, placeObjectAt, @@ -66,13 +68,13 @@ import { 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, @@ -84,6 +86,7 @@ import { addStepNode, getAvailableId, getParent, + getStepNode, removeStepNode, } from "../editor/StepsDomain" @@ -96,13 +99,19 @@ const GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY = "guest_mode_step_tree" const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" // The step identifier the editor will always open on -const DEFAULT_STEP_ID = 1 +const ROOT_STEP_ID = 1 + +type ComputedRelativePositions = Map + +type ComputedStepContent = { + content: StepContent + relativePositions: ComputedRelativePositions +} interface TacticDto { id: number name: string courtType: CourtType - root: StepInfoNode } export interface EditorPageProps { @@ -122,13 +131,19 @@ function GuestModeEditor() { GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0", ) - const stepInitialContent = { - ...(storageContent == null - ? { components: [] } - : JSON.parse(storageContent)), - stepId: 0, + const stepInitialContent: ComputedStepContent = { + content: { + ...(storageContent == null + ? { components: [] } + : JSON.parse(storageContent)), + }, + relativePositions: new Map(), } + const rootStepNode: StepInfoNode = JSON.parse( + localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!, + ) + // initialize local storage if we launch in guest mode if (storageContent == null) { localStorage.setItem( @@ -136,37 +151,72 @@ function GuestModeEditor() { JSON.stringify({ id: 0, children: [] }), ) localStorage.setItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepInitialContent.stepId, + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + ROOT_STEP_ID, JSON.stringify(stepInitialContent), ) } - const [stepId, setStepId] = useState(DEFAULT_STEP_ID) + const [stepId, setStepId] = useState(ROOT_STEP_ID) const [stepContent, setStepContent, saveState] = useContentState( stepInitialContent, SaveStates.Guest, useMemo( () => - debounceAsync(async (content: StepContent) => { - localStorage.setItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, - JSON.stringify(content), - ) - return SaveStates.Guest - }, 250), - [stepId], + debounceAsync( + async ({ + content, + relativePositions, + }: ComputedStepContent) => { + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, + JSON.stringify(content), + ) + + const terminalState = computeTerminalState( + content, + relativePositions, + ) + const currentStepNode = getStepNode( + rootStepNode, + stepId, + )! + + for (const child of currentStepNode.children) { + const childCurrentContent = getStepContent(child.id) + const childUpdatedContent = + drainTerminalStateOnChildContent( + terminalState, + childCurrentContent, + ) + + if (childUpdatedContent) { + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + + stepId, + JSON.stringify(childUpdatedContent), + ) + } + } + + return SaveStates.Guest + }, + 250, + ), + [rootStepNode, stepId], ), ) + function getStepContent(step: number): StepContent { + return JSON.parse( + localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!, + ) + } + return ( { setStepId(step) setStepContent( - { - ...JSON.parse( - localStorage.getItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step, - )!, - ), - }, + () => ({ + content: getStepContent(step), + relativePositions: new Map(), + }), false, ) return @@ -235,39 +282,80 @@ function GuestModeEditor() { function UserModeEditor() { const [tactic, setTactic] = useState(null) + const [stepsTree, setStepsTree] = useState({ id: ROOT_STEP_ID, children: [] }) const { tacticId: idStr } = useParams() - const id = parseInt(idStr!) + const tacticId = parseInt(idStr!) const navigation = useNavigate() const [stepId, setStepId] = useState(1) + + const saveContent = useCallback( + async ({ content, relativePositions }: ComputedStepContent) => { + const response = await fetchAPI( + `tactics/${tacticId}/steps/${stepId}`, + { content }, + "PUT", + ) + + const terminalStateContent = computeTerminalState( + content, + relativePositions, + ) + const currentNode = getStepNode(stepsTree!, stepId)! + + const tasks = currentNode.children.map(async (child) => { + const response = await fetchAPIGet( + `tactics/${tacticId}/steps/${child.id}`, + ) + if (!response.ok) + throw new Error("Error when retrieving children content") + + const childContent: StepContent = await response.json() + const childUpdatedContent = drainTerminalStateOnChildContent( + terminalStateContent, + childContent, + ) + if (childUpdatedContent) { + const response = await fetchAPI( + `tactics/${tacticId}/steps/${child.id}`, + { content: childUpdatedContent }, + "PUT", + ) + if (!response.ok) { + throw new Error( + "Error when updated new children content", + ) + } + } + }) + + for (const task of tasks) { + await task + } + + return response.ok ? SaveStates.Ok : SaveStates.Err + }, + [tacticId, stepId, stepsTree], + ) + + const [stepContent, setStepContent, saveState] = - useContentState( - { components: [] }, + useContentState( + { + content: { components: [] }, + relativePositions: new Map(), + }, SaveStates.Ok, - useMemo( - () => - debounceAsync(async (content: StepContent) => { - const response = await fetchAPI( - `tactics/${id}/steps/${stepId}`, - { - content: { - components: content.components, - }, - }, - "PUT", - ) - return response.ok ? SaveStates.Ok : SaveStates.Err - }, 250), - [id, stepId], - ), + useMemo(() => debounceAsync(saveContent, 250), [saveContent]), ) + useEffect(() => { async function initialize() { - const infoResponsePromise = fetchAPIGet(`tactics/${id}`) - const treeResponsePromise = fetchAPIGet(`tactics/${id}/tree`) + const infoResponsePromise = fetchAPIGet(`tactics/${tacticId}`) + const treeResponsePromise = fetchAPIGet(`tactics/${tacticId}/tree`) const contentResponsePromise = fetchAPIGet( - `tactics/${id}/steps/${DEFAULT_STEP_ID}`, + `tactics/${tacticId}/steps/${ROOT_STEP_ID}`, ) const infoResponse = await infoResponsePromise @@ -287,48 +375,59 @@ function UserModeEditor() { const content = await contentResponse.json() const { root } = await treeResponse.json() - setTactic({ id, name, courtType, root }) - setStepContent(content, false) + setTactic({ id: tacticId, name, courtType }) + setStepsTree(root) + setStepContent({ content, relativePositions: new Map() }, false) } - initialize() - }, [id, idStr, navigation]) + if (tactic === null) + initialize() + }, [tactic, tacticId, idStr, navigation, setStepContent]) const onNameChange = useCallback( (name: string) => - fetchAPI(`tactics/${id}/name`, { name }, "PUT").then((r) => r.ok), - [id], + fetchAPI(`tactics/${tacticId}/name`, { name }, "PUT").then((r) => r.ok), + [tacticId], ) const selectStep = useCallback( async (step: number) => { - const response = await fetchAPIGet(`tactics/${id}/steps/${step}`) + const response = await fetchAPIGet(`tactics/${tacticId}/steps/${step}`) if (!response.ok) return setStepId(step) - setStepContent({ ...(await response.json()) }, false) + setStepContent( + { + content: await response.json(), + relativePositions: new Map(), + }, + false, + ) }, - [id, setStepContent], + [tacticId, setStepContent], ) const onAddStep = useCallback( async (parent: StepInfoNode, content: StepContent) => { - const response = await fetchAPI(`tactics/${id}/steps`, { + const response = await fetchAPI(`tactics/${tacticId}/steps`, { parentId: parent.id, content, }) if (!response.ok) return null const { stepId } = await response.json() - return { id: stepId, children: [] } + const child = { id: stepId, children: [] } + setStepsTree(addStepNode(stepsTree, parent, child)) + return child }, - [id], + [tacticId, stepsTree], ) const onRemoveStep = useCallback( - (step: StepInfoNode) => - fetchAPI(`tactics/${id}/steps/${step.id}`, {}, "DELETE").then( - (r) => r.ok, - ), - [id], + async (step: StepInfoNode) => { + const response = await fetchAPI(`tactics/${tacticId}/steps/${step.id}`, {}, "DELETE") + setStepsTree(removeStepNode(stepsTree, step)!) + return response.ok + }, + [tacticId, stepsTree], ) if (!tactic) return @@ -336,9 +435,9 @@ function UserModeEditor() { return ( > + setCurrentStepContent: Dispatch> selectStep: (stepId: number) => void onNameChange: (name: string) => Promise @@ -374,16 +473,16 @@ export interface EditorViewProps { } function EditorPage({ - tactic: { name, rootStepNode: initialStepsNode, courtType }, - currentStepId, - setCurrentStepContent: setContent, - currentStepContent: content, - saveState, - onNameChange, - selectStep, - onRemoveStep, - onAddStep, -}: EditorViewProps) { + tactic: { name, rootStepNode: initialStepsNode, courtType }, + currentStepId, + setCurrentStepContent, + currentStepContent: { content, relativePositions }, + saveState, + onNameChange, + selectStep, + onRemoveStep, + onAddStep, + }: EditorViewProps) { const [titleStyle, setTitleStyle] = useState({}) const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) @@ -406,6 +505,32 @@ function EditorPage({ const [isStepsTreeVisible, setStepsTreeVisible] = useState(false) const courtRef = useRef(null) + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) + + const setContent = useCallback( + (newState: SetStateAction) => { + setCurrentStepContent((c) => { + const state = + typeof newState === "function" + ? newState(c.content) + : newState + + const courtBounds = courtRef.current?.getBoundingClientRect() + const relativePositions: ComputedRelativePositions = courtBounds ? computeRelativePositions(courtBounds, state) : new Map() + + console.log("in set: ", relativePositions) + + return { + content: state, + relativePositions, + } + }) + }, + [setCurrentStepContent], + ) const setComponents = (action: SetStateAction) => { setContent((c) => ({ @@ -415,11 +540,6 @@ function EditorPage({ })) } - const courtBounds = useCallback( - () => courtRef.current!.getBoundingClientRect(), - [courtRef], - ) - useEffect(() => { setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) }, [setObjects, content]) @@ -458,11 +578,12 @@ function EditorPage({ (newBounds: DOMRect, from?: PlayerLike) => { setContent((content) => { if (from) { - content = changePlayerBallState( - from, - BallState.NONE, - content, - ) + content = + spreadNewStateFromOriginStateChange( + from, + BallState.NONE, + content, + ) ?? content } content = placeBallAt(newBounds, courtBounds(), content) @@ -560,6 +681,7 @@ function EditorPage({ pos: computePhantomPositioning( component, content, + relativePositions, courtBounds(), ), ballState: component.ballState, @@ -585,10 +707,12 @@ function EditorPage({ ) }, [ - content.components, + content, + relativePositions, + courtBounds, + validatePlayerPosition, doRemovePlayer, renderAvailablePlayerActions, - validatePlayerPosition, ], ) @@ -759,7 +883,10 @@ function EditorPage({ async (parent) => { const addedNode = await onAddStep( parent, - getTerminalState(content, courtBounds()), + computeTerminalState( + content, + relativePositions, + ), ) if (addedNode == null) { console.error( @@ -772,7 +899,7 @@ function EditorPage({ addStepNode(root, parent, addedNode), ) }, - [content, courtBounds, onAddStep, selectStep], + [content, onAddStep, selectStep, relativePositions], )} onRemoveNode={useCallback( async (removed) => { @@ -805,13 +932,13 @@ interface EditorStepsTreeProps { } function EditorStepsTree({ - isVisible, - selectedStepId, - root, - onAddChildren, - onRemoveNode, - onStepSelected, -}: EditorStepsTreeProps) { + isVisible, + selectedStepId, + root, + onAddChildren, + onRemoveNode, + onStepSelected, + }: EditorStepsTreeProps) { return (
courtRef.current!.getBoundingClientRect(), [courtRef], @@ -899,15 +1026,15 @@ interface CourtPlayerArrowActionProps { } function CourtPlayerArrowAction({ - playerInfo, - player, - isInvalid, - - content, - setContent, - setPreviewAction, - courtRef, -}: CourtPlayerArrowActionProps) { + playerInfo, + player, + isInvalid, + + content, + setContent, + setPreviewAction, + courtRef, + }: CourtPlayerArrowActionProps) { const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], @@ -1048,12 +1175,8 @@ function debounceAsync( function useContentState( initialContent: S, initialSaveState: SaveState, - saveStateCallback: (s: S) => Promise, -): [ - S, - (newState: SetStateAction, callSaveCallback: boolean) => void, - SaveState, -] { + applyStateCallback: (content: S) => Promise, +): [S, (newState: SetStateAction, runCallback: boolean) => void, SaveState] { const [content, setContent] = useState(initialContent) const [savingState, setSavingState] = useState(initialSaveState) @@ -1067,15 +1190,38 @@ function useContentState( 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, + ) + } + + console.log("computed bounds: ", relativePositionsCache) + + + return relativePositionsCache +} \ No newline at end of file From f5b7b6141198aa8530b9867edc4e6d69a877bcae Mon Sep 17 00:00:00 2001 From: maxime Date: Mon, 4 Mar 2024 12:19:34 +0100 Subject: [PATCH 09/16] add read only court players --- src/components/editor/CourtPlayer.tsx | 137 ++++++++++++++++++-------- src/editor/PlayerDomains.ts | 11 +-- src/editor/TacticContentDomains.ts | 87 +++++++--------- src/geo/Pos.ts | 1 - src/model/tactic/Player.ts | 4 +- src/pages/Editor.tsx | 88 +++++++---------- 6 files changed, 177 insertions(+), 151 deletions(-) diff --git a/src/components/editor/CourtPlayer.tsx b/src/components/editor/CourtPlayer.tsx index 6b8e8dd..d85e51f 100644 --- a/src/components/editor/CourtPlayer.tsx +++ b/src/components/editor/CourtPlayer.tsx @@ -1,4 +1,4 @@ -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 +8,55 @@ 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({ - playerInfo, - className, - - onPositionValidated, - onRemove, - courtRef, - availableActions, -}: CourtPlayerProps) { - const usesBall = playerInfo.ballState != BallState.NONE - const { x, y } = playerInfo.pos +export function EditableCourtPlayer({ + playerInfo, + className, + courtRef, + + onPositionValidated, + onRemove, + availableActions, + }: EditableCourtPlayerProps) { const pieceRef = useRef(null) + const { x, y } = playerInfo.pos + return ( { const pieceBounds = pieceRef.current!.getBoundingClientRect() @@ -53,34 +70,68 @@ export default function CourtPlayer({ ) 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 extends CourtPlayerProps { + 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()} +
+ ) + } + +
+
+ ) +} \ No newline at end of file diff --git a/src/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts index fd5c914..19ca23e 100644 --- a/src/editor/PlayerDomains.ts +++ b/src/editor/PlayerDomains.ts @@ -67,7 +67,6 @@ export function getPrecomputedPosition( return computedPositions.get(phantom.id) } - export function computePhantomPositioning( phantom: PlayerPhantom, content: StepContent, @@ -129,11 +128,11 @@ export function computePhantomPositioning( pivotPoint = playerBeforePhantom.type === "phantom" ? computePhantomPositioning( - playerBeforePhantom, - content, - computedPositions, - area, - ) + playerBeforePhantom, + content, + computedPositions, + area, + ) : playerBeforePhantom.pos } } diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 8eeacd9..15191a8 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -1,33 +1,12 @@ import { equals, Pos, ratioWithinBase } from "../geo/Pos" -import { - BallState, - Player, - PlayerInfo, - PlayerLike, - PlayerPhantom, - PlayerTeam, -} from "../model/tactic/Player" -import { - Ball, - BALL_ID, - BALL_TYPE, - CourtObject, -} from "../model/tactic/CourtObjects" -import { - ComponentId, - StepContent, - TacticComponent, -} from "../model/tactic/Tactic" +import { BallState, Player, PlayerInfo, PlayerLike, PlayerPhantom, PlayerTeam } from "../model/tactic/Player" +import { Ball, BALL_ID, BALL_TYPE, CourtObject } from "../model/tactic/CourtObjects" +import { ComponentId, StepContent, TacticComponent } from "../model/tactic/Tactic" import { overlaps } from "../geo/Box" import { RackedCourtObject, RackedPlayer } from "./RackedItems" -import { - getComponent, - getOrigin, - getPrecomputedPosition, - tryGetComponent, -} from "./PlayerDomains" +import { getComponent, getOrigin, getPrecomputedPosition, tryGetComponent } from "./PlayerDomains" import { ActionKind } from "../model/tactic/Action.ts" import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts" @@ -47,6 +26,7 @@ export function placePlayerAt( ballState: BallState.NONE, path: null, actions: [], + frozen: false } } @@ -197,9 +177,9 @@ export function moveComponent( phantomIdx == 0 ? origin : getComponent( - originPathItems[phantomIdx - 1], - content.components, - ) + originPathItems[phantomIdx - 1], + content.components, + ) // detach the action from the screen target and transform it to a regular move action to the phantom. content = updateComponent( { @@ -207,18 +187,18 @@ export function moveComponent( actions: playerBeforePhantom.actions.map((a) => a.target === referent ? { - ...a, - segments: a.segments.toSpliced( - a.segments.length - 2, - 1, - { - ...a.segments[a.segments.length - 1], - next: component.id, - }, - ), - target: component.id, - type: ActionKind.MOVE, - } + ...a, + segments: a.segments.toSpliced( + a.segments.length - 2, + 1, + { + ...a.segments[a.segments.length - 1], + next: component.id, + }, + ), + target: component.id, + type: ActionKind.MOVE, + } : a, ), }, @@ -231,9 +211,9 @@ export function moveComponent( ...component, pos: isPhantom ? { - type: "fixed", - ...newPos, - } + type: "fixed", + ...newPos, + } : newPos, }, content, @@ -312,7 +292,7 @@ export function computeTerminalState( content.components.filter((c) => c.type !== "phantom") as ( | Player | CourtObject - )[] + )[] const componentsTargetedState = nonPhantomComponents.map((comp) => comp.type === "player" @@ -365,6 +345,7 @@ function getPlayerTerminalState( ballState: stateAfter(player.ballState), actions: [], pos, + frozen: true, } } const lastPhantomId = phantoms[phantoms.length - 1] @@ -384,6 +365,7 @@ function getPlayerTerminalState( ballState: stateAfter(lastPhantom.ballState), id: player.id, pos, + frozen: true } } @@ -410,7 +392,11 @@ export function drainTerminalStateOnChildContent( } // ensure that the component is a player - if (parentComponent.type !== "player" || childComponent.type !== "player") continue + if ( + parentComponent.type !== "player" || + childComponent.type !== "player" + ) + continue const newContentResult = spreadNewStateFromOriginStateChange( childComponent, @@ -424,10 +410,13 @@ export function drainTerminalStateOnChildContent( // also update the position of the player if it has been moved if (!equals(childComponent.pos, parentComponent.pos)) { gotUpdated = true - childContent = updateComponent({ - ...childComponent, - pos: parentComponent.pos, - }, childContent) + childContent = updateComponent( + { + ...childComponent, + pos: parentComponent.pos, + }, + childContent, + ) } } diff --git a/src/geo/Pos.ts b/src/geo/Pos.ts index d3d7337..0e591b3 100644 --- a/src/geo/Pos.ts +++ b/src/geo/Pos.ts @@ -7,7 +7,6 @@ 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/Player.ts b/src/model/tactic/Player.ts index 842bbbe..5f1d3df 100644 --- a/src/model/tactic/Player.ts +++ b/src/model/tactic/Player.ts @@ -48,6 +48,8 @@ export interface Player extends Component<"player", Pos>, PlayerInfo { readonly ballState: BallState readonly path: MovementPath | null + + readonly frozen: boolean } export interface MovementPath { @@ -71,8 +73,6 @@ export type PhantomPositioning = | FixedPhantomPositioning | FollowsPhantomPositioning - - /** * A player phantom is a kind of component that represents the future state of a player * according to the court's step information diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 2fd6627..0c1b792 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -19,20 +19,10 @@ import { BallPiece } from "../components/editor/BallPiece" import { Rack } from "../components/Rack" import { PlayerPiece } from "../components/editor/PlayerPiece" -import { - ComponentId, - CourtType, - StepContent, - StepInfoNode, - TacticComponent, - TacticInfo, -} from "../model/tactic/Tactic" +import { ComponentId, CourtType, StepContent, StepInfoNode, TacticComponent, TacticInfo } from "../model/tactic/Tactic" import { fetchAPI, fetchAPIGet } from "../Fetcher" -import SavingState, { - SaveState, - SaveStates, -} from "../components/editor/SavingState" +import SavingState, { SaveState, SaveStates } from "../components/editor/SavingState" import { BALL_TYPE } from "../model/tactic/CourtObjects" import { CourtAction } from "../components/editor/CourtAction" @@ -53,16 +43,10 @@ import { updateComponent, } from "../editor/TacticContentDomains" -import { - BallState, - Player, - PlayerInfo, - PlayerLike, - PlayerTeam, -} from "../model/tactic/Player" +import { BallState, Player, PlayerInfo, 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, @@ -74,21 +58,11 @@ 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 { - computePhantomPositioning, - getOrigin, - removePlayer, -} from "../editor/PlayerDomains" +import { computePhantomPositioning, getOrigin, removePlayer } from "../editor/PlayerDomains" import { CourtBall } from "../components/editor/CourtBall" import { useNavigate, useParams } from "react-router-dom" import StepsTree from "../components/editor/StepsTree" -import { - addStepNode, - getAvailableId, - getParent, - getStepNode, - removeStepNode, -} from "../editor/StepsDomain" +import { addStepNode, getAvailableId, getParent, getStepNode, removeStepNode } from "../editor/StepsDomain" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -282,7 +256,10 @@ function GuestModeEditor() { function UserModeEditor() { const [tactic, setTactic] = useState(null) - const [stepsTree, setStepsTree] = useState({ id: ROOT_STEP_ID, children: [] }) + const [stepsTree, setStepsTree] = useState({ + id: ROOT_STEP_ID, + children: [], + }) const { tacticId: idStr } = useParams() const tacticId = parseInt(idStr!) const navigation = useNavigate() @@ -338,7 +315,6 @@ function UserModeEditor() { [tacticId, stepId, stepsTree], ) - const [stepContent, setStepContent, saveState] = useContentState( { @@ -349,7 +325,6 @@ function UserModeEditor() { useMemo(() => debounceAsync(saveContent, 250), [saveContent]), ) - useEffect(() => { async function initialize() { const infoResponsePromise = fetchAPIGet(`tactics/${tacticId}`) @@ -380,19 +355,22 @@ function UserModeEditor() { setStepContent({ content, relativePositions: new Map() }, false) } - if (tactic === null) - initialize() + if (tactic === null) initialize() }, [tactic, tacticId, idStr, navigation, setStepContent]) const onNameChange = useCallback( (name: string) => - fetchAPI(`tactics/${tacticId}/name`, { name }, "PUT").then((r) => r.ok), + fetchAPI(`tactics/${tacticId}/name`, { name }, "PUT").then( + (r) => r.ok, + ), [tacticId], ) const selectStep = useCallback( async (step: number) => { - const response = await fetchAPIGet(`tactics/${tacticId}/steps/${step}`) + const response = await fetchAPIGet( + `tactics/${tacticId}/steps/${step}`, + ) if (!response.ok) return setStepId(step) setStepContent( @@ -423,7 +401,11 @@ function UserModeEditor() { const onRemoveStep = useCallback( async (step: StepInfoNode) => { - const response = await fetchAPI(`tactics/${tacticId}/steps/${step.id}`, {}, "DELETE") + const response = await fetchAPI( + `tactics/${tacticId}/steps/${step.id}`, + {}, + "DELETE", + ) setStepsTree(removeStepNode(stepsTree, step)!) return response.ok }, @@ -519,9 +501,9 @@ function EditorPage({ : newState const courtBounds = courtRef.current?.getBoundingClientRect() - const relativePositions: ComputedRelativePositions = courtBounds ? computeRelativePositions(courtBounds, state) : new Map() - - console.log("in set: ", relativePositions) + const relativePositions: ComputedRelativePositions = courtBounds + ? computeRelativePositions(courtBounds, state) + : new Map() return { content: state, @@ -617,6 +599,7 @@ function EditorPage({ const renderAvailablePlayerActions = useCallback( (info: PlayerInfo, player: PlayerLike) => { let canPlaceArrows: boolean + let isFrozen: boolean = false if (player.type == "player") { canPlaceArrows = @@ -624,6 +607,7 @@ function EditorPage({ player.actions.findIndex( (p) => p.type != ActionKind.SHOOT, ) == -1 + isFrozen = player.frozen } else { const origin = getOrigin(player, content.components) const path = origin.path! @@ -654,7 +638,7 @@ function EditorPage({ setContent={setContent} /> ), - (info.ballState === BallState.HOLDS_ORIGIN || + !isFrozen && (info.ballState === BallState.HOLDS_ORIGIN || info.ballState === BallState.PASSED_ORIGIN) && ( renderAvailablePlayerActions(info, component)} + /> + } } return ( - ( return [content, setContentSynced, savingState] } - function computeRelativePositions(courtBounds: DOMRect, content: StepContent) { const relativePositionsCache: ComputedRelativePositions = new Map() @@ -1220,8 +1211,5 @@ function computeRelativePositions(courtBounds: DOMRect, content: StepContent) { ) } - console.log("computed bounds: ", relativePositionsCache) - - return relativePositionsCache -} \ No newline at end of file +} From 2577974bfeb2349d0d097d5cbd7e00613da3ca4e Mon Sep 17 00:00:00 2001 From: maxime Date: Mon, 4 Mar 2024 13:14:41 +0100 Subject: [PATCH 10/16] drain changes on all children --- src/components/editor/CourtPlayer.tsx | 73 ++++--- src/editor/TacticContentDomains.ts | 36 +++- src/pages/Editor.tsx | 290 +++++++++++++++----------- 3 files changed, 238 insertions(+), 161 deletions(-) diff --git a/src/components/editor/CourtPlayer.tsx b/src/components/editor/CourtPlayer.tsx index d85e51f..c25b36a 100644 --- a/src/components/editor/CourtPlayer.tsx +++ b/src/components/editor/CourtPlayer.tsx @@ -1,4 +1,10 @@ -import React, { KeyboardEventHandler, 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" @@ -22,11 +28,10 @@ const MOVE_AREA_SENSIBILITY = 0.001 export const PLAYER_RADIUS_PIXELS = 20 export function CourtPlayer({ - playerInfo, - className, - availableActions, - }: CourtPlayerProps) { - + playerInfo, + className, + availableActions, +}: CourtPlayerProps) { const pieceRef = useRef(null) return courtPlayerPiece({ @@ -41,18 +46,17 @@ export function CourtPlayer({ * A player that is placed on the court, which can be selected, and moved in the associated bounds * */ export function EditableCourtPlayer({ - playerInfo, - className, - courtRef, - - onPositionValidated, - onRemove, - availableActions, - }: EditableCourtPlayerProps) { + playerInfo, + className, + courtRef, + + onPositionValidated, + onRemove, + availableActions, +}: EditableCourtPlayerProps) { const pieceRef = useRef(null) const { x, y } = playerInfo.pos - return ( = MOVE_AREA_SENSIBILITY || Math.abs(pos.y - y) >= MOVE_AREA_SENSIBILITY - ) + ) { onPositionValidated(pos) + } }, [courtRef, onPositionValidated, x, y])}> - {courtPlayerPiece({ playerInfo, className, @@ -87,23 +91,24 @@ export function EditableCourtPlayer({ ) } -interface CourtPlayerPieceProps extends CourtPlayerProps { +interface CourtPlayerPieceProps { + playerInfo: PlayerInfo + className?: string pieceRef?: RefObject availableActions?: () => ReactNode[] onKeyUp?: KeyboardEventHandler } function courtPlayerPiece({ - playerInfo, - className, - pieceRef, - onKeyUp, - availableActions, - }: CourtPlayerPieceProps) { + playerInfo, + className, + pieceRef, + onKeyUp, + availableActions, +}: CourtPlayerPieceProps) { const usesBall = playerInfo.ballState != BallState.NONE const { x, y } = playerInfo.pos - return (
-
- { - availableActions && ( -
- {availableActions()} -
- ) - } +
+ {availableActions && ( +
{availableActions()}
+ )}
) -} \ No newline at end of file +} diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 15191a8..28ab879 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -1,12 +1,33 @@ import { equals, Pos, ratioWithinBase } from "../geo/Pos" -import { BallState, Player, PlayerInfo, PlayerLike, PlayerPhantom, PlayerTeam } from "../model/tactic/Player" -import { Ball, BALL_ID, BALL_TYPE, CourtObject } from "../model/tactic/CourtObjects" -import { ComponentId, StepContent, TacticComponent } from "../model/tactic/Tactic" +import { + BallState, + Player, + PlayerInfo, + PlayerLike, + PlayerPhantom, + PlayerTeam, +} from "../model/tactic/Player" +import { + Ball, + BALL_ID, + BALL_TYPE, + CourtObject, +} from "../model/tactic/CourtObjects" +import { + ComponentId, + StepContent, + TacticComponent, +} from "../model/tactic/Tactic" import { overlaps } from "../geo/Box" import { RackedCourtObject, RackedPlayer } from "./RackedItems" -import { getComponent, getOrigin, getPrecomputedPosition, tryGetComponent } from "./PlayerDomains" +import { + getComponent, + getOrigin, + getPrecomputedPosition, + tryGetComponent, +} from "./PlayerDomains" import { ActionKind } from "../model/tactic/Action.ts" import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts" @@ -26,7 +47,7 @@ export function placePlayerAt( ballState: BallState.NONE, path: null, actions: [], - frozen: false + frozen: false, } } @@ -365,7 +386,7 @@ function getPlayerTerminalState( ballState: stateAfter(lastPhantom.ballState), id: player.id, pos, - frozen: true + frozen: true, } } @@ -395,8 +416,9 @@ export function drainTerminalStateOnChildContent( if ( parentComponent.type !== "player" || childComponent.type !== "player" - ) + ) { continue + } const newContentResult = spreadNewStateFromOriginStateChange( childComponent, diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 0c1b792..da9697d 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -19,10 +19,20 @@ import { BallPiece } from "../components/editor/BallPiece" import { Rack } from "../components/Rack" import { PlayerPiece } from "../components/editor/PlayerPiece" -import { ComponentId, CourtType, StepContent, StepInfoNode, TacticComponent, TacticInfo } from "../model/tactic/Tactic" +import { + ComponentId, + CourtType, + StepContent, + StepInfoNode, + TacticComponent, + TacticInfo, +} from "../model/tactic/Tactic" import { fetchAPI, fetchAPIGet } from "../Fetcher" -import SavingState, { SaveState, SaveStates } from "../components/editor/SavingState" +import SavingState, { + SaveState, + SaveStates, +} from "../components/editor/SavingState" import { BALL_TYPE } from "../model/tactic/CourtObjects" import { CourtAction } from "../components/editor/CourtAction" @@ -43,10 +53,19 @@ import { updateComponent, } from "../editor/TacticContentDomains" -import { BallState, Player, PlayerInfo, PlayerLike, PlayerTeam } from "../model/tactic/Player" +import { + BallState, + Player, + PlayerInfo, + PlayerLike, + PlayerTeam, +} from "../model/tactic/Player" import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" -import { CourtPlayer, EditableCourtPlayer } from "../components/editor/CourtPlayer.tsx" +import { + CourtPlayer, + EditableCourtPlayer, +} from "../components/editor/CourtPlayer.tsx" import { createAction, getActionKind, @@ -58,11 +77,21 @@ 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 { computePhantomPositioning, getOrigin, removePlayer } from "../editor/PlayerDomains" +import { + computePhantomPositioning, + getOrigin, + removePlayer, +} from "../editor/PlayerDomains" import { CourtBall } from "../components/editor/CourtBall" import { useNavigate, useParams } from "react-router-dom" import StepsTree from "../components/editor/StepsTree" -import { addStepNode, getAvailableId, getParent, getStepNode, removeStepNode } from "../editor/StepsDomain" +import { + addStepNode, + getAvailableId, + getParent, + getStepNode, + removeStepNode, +} from "../editor/StepsDomain" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -105,13 +134,10 @@ function GuestModeEditor() { GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0", ) - const stepInitialContent: ComputedStepContent = { - content: { - ...(storageContent == null - ? { components: [] } - : JSON.parse(storageContent)), - }, - relativePositions: new Map(), + const stepInitialContent: StepContent = { + ...(storageContent == null + ? { components: [] } + : JSON.parse(storageContent)), } const rootStepNode: StepInfoNode = JSON.parse( @@ -130,6 +156,7 @@ function GuestModeEditor() { ) } + const courtRef = useRef(null) const [stepId, setStepId] = useState(ROOT_STEP_ID) const [stepContent, setStepContent, saveState] = useContentState( stepInitialContent, @@ -137,46 +164,40 @@ function GuestModeEditor() { useMemo( () => debounceAsync( - async ({ - content, - relativePositions, - }: ComputedStepContent) => { + async (content: StepContent) => { localStorage.setItem( GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, JSON.stringify(content), ) - const terminalState = computeTerminalState( - content, - relativePositions, + const stepsTree: StepInfoNode = JSON.parse( + localStorage.getItem( + GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, + )!, ) - const currentStepNode = getStepNode( - rootStepNode, - stepId, - )! - - for (const child of currentStepNode.children) { - const childCurrentContent = getStepContent(child.id) - const childUpdatedContent = - drainTerminalStateOnChildContent( - terminalState, - childCurrentContent, - ) - if (childUpdatedContent) { - localStorage.setItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + - stepId, - JSON.stringify(childUpdatedContent), - ) - } - } + await updateStepContents( + stepId, + stepsTree, + async (stepId) => { + const content = JSON.parse(localStorage.getItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, + )!) + const courtBounds = courtRef.current!.getBoundingClientRect() + const relativePositions = computeRelativePositions(courtBounds, content) + return { content, relativePositions } + }, + async (stepId, content) => localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, + JSON.stringify(content), + ), + ) return SaveStates.Guest }, 250, ), - [rootStepNode, stepId], + [stepId], ), ) @@ -196,6 +217,7 @@ function GuestModeEditor() { "Nouvelle Tactique", courtType: "PLAIN", }} + courtRef={courtRef} currentStepContent={stepContent} setCurrentStepContent={(content) => setStepContent(content, true)} saveState={saveState} @@ -207,13 +229,7 @@ function GuestModeEditor() { selectStep={useCallback( (step) => { setStepId(step) - setStepContent( - () => ({ - content: getStepContent(step), - relativePositions: new Map(), - }), - false, - ) + setStepContent(getStepContent(step), false) return }, [setStepContent], @@ -264,38 +280,41 @@ function UserModeEditor() { const tacticId = parseInt(idStr!) const navigation = useNavigate() + + const courtRef = useRef(null) const [stepId, setStepId] = useState(1) const saveContent = useCallback( - async ({ content, relativePositions }: ComputedStepContent) => { + async (content: StepContent) => { const response = await fetchAPI( `tactics/${tacticId}/steps/${stepId}`, { content }, "PUT", ) - const terminalStateContent = computeTerminalState( - content, - relativePositions, - ) - const currentNode = getStepNode(stepsTree!, stepId)! + await updateStepContents( + stepId, + stepsTree, + async (id) => { + const response = await fetchAPIGet( + `tactics/${tacticId}/steps/${id}`, + ) + if (!response.ok) + throw new Error("Error when retrieving children content") - const tasks = currentNode.children.map(async (child) => { - const response = await fetchAPIGet( - `tactics/${tacticId}/steps/${child.id}`, - ) - if (!response.ok) - throw new Error("Error when retrieving children content") - const childContent: StepContent = await response.json() - const childUpdatedContent = drainTerminalStateOnChildContent( - terminalStateContent, - childContent, - ) - if (childUpdatedContent) { + const content = await response.json() + const courtBounds = courtRef.current!.getBoundingClientRect() + const relativePositions = computeRelativePositions(courtBounds, content) + return { + content, + relativePositions, + } + }, + async (id, content) => { const response = await fetchAPI( - `tactics/${tacticId}/steps/${child.id}`, - { content: childUpdatedContent }, + `tactics/${tacticId}/steps/${id}`, + { content }, "PUT", ) if (!response.ok) { @@ -303,12 +322,8 @@ function UserModeEditor() { "Error when updated new children content", ) } - } - }) - - for (const task of tasks) { - await task - } + }, + ) return response.ok ? SaveStates.Ok : SaveStates.Err }, @@ -316,11 +331,8 @@ function UserModeEditor() { ) const [stepContent, setStepContent, saveState] = - useContentState( - { - content: { components: [] }, - relativePositions: new Map(), - }, + useContentState( + { components: [] }, SaveStates.Ok, useMemo(() => debounceAsync(saveContent, 250), [saveContent]), ) @@ -352,7 +364,7 @@ function UserModeEditor() { setTactic({ id: tacticId, name, courtType }) setStepsTree(root) - setStepContent({ content, relativePositions: new Map() }, false) + setStepContent(content, false) } if (tactic === null) initialize() @@ -374,10 +386,7 @@ function UserModeEditor() { if (!response.ok) return setStepId(step) setStepContent( - { - content: await response.json(), - relativePositions: new Map(), - }, + await response.json(), false, ) }, @@ -422,6 +431,7 @@ function UserModeEditor() { rootStepNode: stepsTree, courtType: tactic?.courtType, }} + courtRef={courtRef} currentStepId={stepId} currentStepContent={stepContent} setCurrentStepContent={(content) => setStepContent(content, true)} @@ -440,10 +450,12 @@ function EditorLoadingScreen() { export interface EditorViewProps { tactic: TacticInfo - currentStepContent: ComputedStepContent + currentStepContent: StepContent currentStepId: number saveState: SaveState - setCurrentStepContent: Dispatch> + setCurrentStepContent: Dispatch> + + courtRef: RefObject selectStep: (stepId: number) => void onNameChange: (name: string) => Promise @@ -457,13 +469,15 @@ export interface EditorViewProps { function EditorPage({ tactic: { name, rootStepNode: initialStepsNode, courtType }, currentStepId, - setCurrentStepContent, - currentStepContent: { content, relativePositions }, + setCurrentStepContent: setContent, + currentStepContent: content, saveState, onNameChange, selectStep, onRemoveStep, onAddStep, + + courtRef, }: EditorViewProps) { const [titleStyle, setTitleStyle] = useState({}) @@ -486,33 +500,34 @@ function EditorPage({ const [isStepsTreeVisible, setStepsTreeVisible] = useState(false) - const courtRef = useRef(null) const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], ) - const setContent = useCallback( - (newState: SetStateAction) => { - setCurrentStepContent((c) => { - const state = - typeof newState === "function" - ? newState(c.content) - : newState - - const courtBounds = courtRef.current?.getBoundingClientRect() - const relativePositions: ComputedRelativePositions = courtBounds - ? computeRelativePositions(courtBounds, state) - : new Map() - - return { - content: state, - relativePositions, - } - }) - }, - [setCurrentStepContent], - ) + const relativePositions = useMemo(() => { + const courtBounds = courtRef.current?.getBoundingClientRect() + return courtBounds ? computeRelativePositions(courtBounds, content) : new Map() + }, [content, courtRef]) + + // const setContent = useCallback( + // (newState: SetStateAction) => { + // setCurrentStepContent((c) => { + // const state = + // typeof newState === "function" + // ? newState(c.content) + // : newState + // + // const courtBounds = courtRef.current?.getBoundingClientRect() + // const relativePositions: ComputedRelativePositions = courtBounds + // ? computeRelativePositions(courtBounds, state) + // : new Map() + // + // return state + // }) + // }, + // [setCurrentStepContent], + // ) const setComponents = (action: SetStateAction) => { setContent((c) => ({ @@ -638,7 +653,8 @@ function EditorPage({ setContent={setContent} /> ), - !isFrozen && (info.ballState === BallState.HOLDS_ORIGIN || + !isFrozen && + (info.ballState === BallState.HOLDS_ORIGIN || info.ballState === BallState.PASSED_ORIGIN) && ( renderAvailablePlayerActions(info, component)} - /> + return ( + + renderAvailablePlayerActions(info, component) + } + /> + ) } } @@ -1213,3 +1234,40 @@ function computeRelativePositions(courtBounds: DOMRect, content: StepContent) { 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, + ) + + const tasks = step.children.map(async (child) => { + 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) + } + }) + + for (const task of tasks) { + await task + } + } + + const { content, relativePositions } = await getStepContent(stepId) + const startNode = getStepNode(stepsTree!, stepId)! + + await updateSteps(startNode, content, relativePositions) +} \ No newline at end of file From 32b79ed5c47780fcb6d3fc17d8b64a5a4bdfa0e4 Mon Sep 17 00:00:00 2001 From: maxime Date: Tue, 5 Mar 2024 19:41:20 +0100 Subject: [PATCH 11/16] fix guest mode editor, avoid some illegal actions --- src/components/editor/CourtBall.tsx | 53 ++++++++++++++++++++--------- src/editor/PlayerDomains.ts | 2 +- src/editor/TacticContentDomains.ts | 50 ++++++++++++++++----------- src/model/tactic/CourtObjects.ts | 4 ++- src/pages/Editor.tsx | 13 +++---- 5 files changed, 78 insertions(+), 44 deletions(-) diff --git a/src/components/editor/CourtBall.tsx b/src/components/editor/CourtBall.tsx index c9c48dc..6eb1a8a 100644 --- a/src/components/editor/CourtBall.tsx +++ b/src/components/editor/CourtBall.tsx @@ -1,34 +1,31 @@ -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) { - const pieceRef = useRef(null) - const { x, y } = ball.pos +export function CourtBall({ onPosValidated, ball, onRemove }: EditableCourtBallProps) { + const pieceRef = useRef(null) - 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 +33,30 @@ 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/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts index 19ca23e..6eef2a3 100644 --- a/src/editor/PlayerDomains.ts +++ b/src/editor/PlayerDomains.ts @@ -258,7 +258,7 @@ export function removePlayer( content.components, )! const actions = playerBefore.actions.filter( - (a) => a.target === pos.attach, + (a) => a.target !== pos.attach, ) content = updateComponent( { diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 28ab879..e2e8a2f 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -77,6 +77,7 @@ export function placeObjectAt( id: BALL_ID, pos, actions: [], + frozen: false } break } @@ -153,6 +154,7 @@ export function placeBallAt( id: BALL_ID, pos, actions: [], + frozen: false, } let components = content.components @@ -198,9 +200,9 @@ export function moveComponent( phantomIdx == 0 ? origin : getComponent( - originPathItems[phantomIdx - 1], - content.components, - ) + originPathItems[phantomIdx - 1], + content.components, + ) // detach the action from the screen target and transform it to a regular move action to the phantom. content = updateComponent( { @@ -208,18 +210,18 @@ export function moveComponent( actions: playerBeforePhantom.actions.map((a) => a.target === referent ? { - ...a, - segments: a.segments.toSpliced( - a.segments.length - 2, - 1, - { - ...a.segments[a.segments.length - 1], - next: component.id, - }, - ), - target: component.id, - type: ActionKind.MOVE, - } + ...a, + segments: a.segments.toSpliced( + a.segments.length - 2, + 1, + { + ...a.segments[a.segments.length - 1], + next: component.id, + }, + ), + target: component.id, + type: ActionKind.MOVE, + } : a, ), }, @@ -232,9 +234,9 @@ export function moveComponent( ...component, pos: isPhantom ? { - type: "fixed", - ...newPos, - } + type: "fixed", + ...newPos, + } : newPos, }, content, @@ -313,12 +315,15 @@ export function computeTerminalState( content.components.filter((c) => c.type !== "phantom") as ( | Player | CourtObject - )[] + )[] const componentsTargetedState = nonPhantomComponents.map((comp) => comp.type === "player" ? getPlayerTerminalState(comp, content, computedPositions) - : comp, + : { + ...comp, + frozen: true, + }, ) return { @@ -396,6 +401,11 @@ export function drainTerminalStateOnChildContent( ): StepContent | null { let gotUpdated = false + //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))) + } + for (const parentComponent of parentTerminalState.components) { const childComponent = tryGetComponent( parentComponent.id, diff --git a/src/model/tactic/CourtObjects.ts b/src/model/tactic/CourtObjects.ts index 5f72199..6751e65 100644 --- a/src/model/tactic/CourtObjects.ts +++ b/src/model/tactic/CourtObjects.ts @@ -7,4 +7,6 @@ export const BALL_TYPE = "ball" //place here all different kinds of objects export type CourtObject = Ball -export type Ball = Component +export interface Ball extends Component { + readonly frozen: boolean +} diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index da9697d..9d9c4f6 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -131,7 +131,7 @@ function EditorPortal({ guestMode }: EditorPageProps) { function GuestModeEditor() { const storageContent = localStorage.getItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0", + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + ROOT_STEP_ID, ) const stepInitialContent: StepContent = { @@ -148,7 +148,7 @@ function GuestModeEditor() { if (storageContent == null) { localStorage.setItem( GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, - JSON.stringify({ id: 0, children: [] }), + JSON.stringify({ id: ROOT_STEP_ID, children: [] }), ) localStorage.setItem( GUEST_MODE_STEP_CONTENT_STORAGE_KEY + ROOT_STEP_ID, @@ -156,6 +156,8 @@ function GuestModeEditor() { ) } + const tacticName = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? "Nouvelle Tactique" + const courtRef = useRef(null) const [stepId, setStepId] = useState(ROOT_STEP_ID) const [stepContent, setStepContent, saveState] = useContentState( @@ -212,9 +214,7 @@ function GuestModeEditor() { tactic={{ id: -1, rootStepNode, - name: - localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? - "Nouvelle Tactique", + name: tacticName, courtType: "PLAIN", }} courtRef={courtRef} @@ -244,9 +244,10 @@ function GuestModeEditor() { 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(addStepNode(root, parent, node)), + JSON.stringify(resultTree), ) localStorage.setItem( GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id, From 64e8362e53143395360f33d91fa5fc541537280f Mon Sep 17 00:00:00 2001 From: maxime Date: Tue, 5 Mar 2024 20:18:15 +0100 Subject: [PATCH 12/16] format --- src/components/editor/CourtBall.tsx | 28 ++-- src/editor/TacticContentDomains.ts | 53 +++---- src/pages/Editor.tsx | 205 +++++++++++++++------------- 3 files changed, 154 insertions(+), 132 deletions(-) diff --git a/src/components/editor/CourtBall.tsx b/src/components/editor/CourtBall.tsx index 6eb1a8a..503f44c 100644 --- a/src/components/editor/CourtBall.tsx +++ b/src/components/editor/CourtBall.tsx @@ -13,13 +13,18 @@ export interface EditableCourtBallProps extends CourtBallProps { onRemove: () => void } - -export function CourtBall({ onPosValidated, ball, onRemove }: EditableCourtBallProps) { +export function CourtBall({ + onPosValidated, + ball, + onRemove, +}: EditableCourtBallProps) { const pieceRef = useRef(null) - function courtBallPiece({ x, y }: Pos, - pieceRef?: RefObject, - onKeyUp?: KeyboardEventHandler) { + function courtBallPiece( + { x, y }: Pos, + pieceRef?: RefObject, + onKeyUp?: KeyboardEventHandler, + ) { return (
- {courtBallPiece( - ball.pos, - pieceRef, - (e) => { - if (e.key == "Delete") onRemove() - }, - )} + {courtBallPiece(ball.pos, pieceRef, (e) => { + if (e.key == "Delete") onRemove() + })} ) } - - - diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index e2e8a2f..34dd0f3 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -77,7 +77,7 @@ export function placeObjectAt( id: BALL_ID, pos, actions: [], - frozen: false + frozen: false, } break } @@ -200,9 +200,9 @@ export function moveComponent( phantomIdx == 0 ? origin : getComponent( - originPathItems[phantomIdx - 1], - content.components, - ) + originPathItems[phantomIdx - 1], + content.components, + ) // detach the action from the screen target and transform it to a regular move action to the phantom. content = updateComponent( { @@ -210,18 +210,18 @@ export function moveComponent( actions: playerBeforePhantom.actions.map((a) => a.target === referent ? { - ...a, - segments: a.segments.toSpliced( - a.segments.length - 2, - 1, - { - ...a.segments[a.segments.length - 1], - next: component.id, - }, - ), - target: component.id, - type: ActionKind.MOVE, - } + ...a, + segments: a.segments.toSpliced( + a.segments.length - 2, + 1, + { + ...a.segments[a.segments.length - 1], + next: component.id, + }, + ), + target: component.id, + type: ActionKind.MOVE, + } : a, ), }, @@ -234,9 +234,9 @@ export function moveComponent( ...component, pos: isPhantom ? { - type: "fixed", - ...newPos, - } + type: "fixed", + ...newPos, + } : newPos, }, content, @@ -315,15 +315,15 @@ export function computeTerminalState( content.components.filter((c) => c.type !== "phantom") as ( | Player | CourtObject - )[] + )[] const componentsTargetedState = nonPhantomComponents.map((comp) => comp.type === "player" ? getPlayerTerminalState(comp, content, computedPositions) : { - ...comp, - frozen: true, - }, + ...comp, + frozen: true, + }, ) return { @@ -403,7 +403,12 @@ export function drainTerminalStateOnChildContent( //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))) + components: childContent.components.filter( + (comp) => + comp.type === "phantom" || + (comp.frozen && + tryGetComponent(comp.id, parentTerminalState.components)), + ), } for (const parentComponent of parentTerminalState.components) { diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 9d9c4f6..1264466 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -156,7 +156,9 @@ function GuestModeEditor() { ) } - const tacticName = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? "Nouvelle Tactique" + const tacticName = + localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? + "Nouvelle Tactique" const courtRef = useRef(null) const [stepId, setStepId] = useState(ROOT_STEP_ID) @@ -165,40 +167,45 @@ function GuestModeEditor() { SaveStates.Guest, useMemo( () => - debounceAsync( - async (content: StepContent) => { - localStorage.setItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, - JSON.stringify(content), - ) + debounceAsync(async (content: StepContent) => { + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, + JSON.stringify(content), + ) - const stepsTree: StepInfoNode = JSON.parse( - localStorage.getItem( - GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, - )!, - ) + const stepsTree: StepInfoNode = JSON.parse( + localStorage.getItem( + GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, + )!, + ) - await updateStepContents( - stepId, - stepsTree, - async (stepId) => { - const content = JSON.parse(localStorage.getItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, - )!) - const courtBounds = courtRef.current!.getBoundingClientRect() - const relativePositions = computeRelativePositions(courtBounds, content) - return { content, relativePositions } - }, - async (stepId, content) => localStorage.setItem( + await updateStepContents( + stepId, + stepsTree, + async (stepId) => { + const content = JSON.parse( + localStorage.getItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + + stepId, + )!, + ) + const courtBounds = + courtRef.current!.getBoundingClientRect() + const relativePositions = computeRelativePositions( + courtBounds, + content, + ) + return { content, relativePositions } + }, + async (stepId, content) => + localStorage.setItem( GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, JSON.stringify(content), ), - ) + ) - return SaveStates.Guest - }, - 250, - ), + return SaveStates.Guest + }, 250), [stepId], ), ) @@ -281,7 +288,6 @@ function UserModeEditor() { const tacticId = parseInt(idStr!) const navigation = useNavigate() - const courtRef = useRef(null) const [stepId, setStepId] = useState(1) @@ -301,12 +307,17 @@ function UserModeEditor() { `tactics/${tacticId}/steps/${id}`, ) if (!response.ok) - throw new Error("Error when retrieving children content") - + throw new Error( + "Error when retrieving children content", + ) const content = await response.json() - const courtBounds = courtRef.current!.getBoundingClientRect() - const relativePositions = computeRelativePositions(courtBounds, content) + const courtBounds = + courtRef.current!.getBoundingClientRect() + const relativePositions = computeRelativePositions( + courtBounds, + content, + ) return { content, relativePositions, @@ -386,10 +397,7 @@ function UserModeEditor() { ) if (!response.ok) return setStepId(step) - setStepContent( - await response.json(), - false, - ) + setStepContent(await response.json(), false) }, [tacticId, setStepContent], ) @@ -468,18 +476,18 @@ export interface EditorViewProps { } function EditorPage({ - tactic: { name, rootStepNode: initialStepsNode, courtType }, - currentStepId, - setCurrentStepContent: setContent, - currentStepContent: content, - saveState, - onNameChange, - selectStep, - onRemoveStep, - onAddStep, - - courtRef, - }: EditorViewProps) { + tactic: { name, rootStepNode: initialStepsNode, courtType }, + currentStepId, + setCurrentStepContent: setContent, + currentStepContent: content, + saveState, + onNameChange, + selectStep, + onRemoveStep, + onAddStep, + + courtRef, +}: EditorViewProps) { const [titleStyle, setTitleStyle] = useState({}) const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) @@ -508,7 +516,9 @@ function EditorPage({ const relativePositions = useMemo(() => { const courtBounds = courtRef.current?.getBoundingClientRect() - return courtBounds ? computeRelativePositions(courtBounds, content) : new Map() + return courtBounds + ? computeRelativePositions(courtBounds, content) + : new Map() }, [content, courtRef]) // const setContent = useCallback( @@ -655,15 +665,15 @@ function EditorPage({ /> ), !isFrozen && - (info.ballState === BallState.HOLDS_ORIGIN || - info.ballState === BallState.PASSED_ORIGIN) && ( - { - doMoveBall(ballBounds, player) - }} - /> - ), + (info.ballState === BallState.HOLDS_ORIGIN || + info.ballState === BallState.PASSED_ORIGIN) && ( + { + doMoveBall(ballBounds, player) + }} + /> + ), ] }, [content, doMoveBall, previewAction?.isInvalid, setContent], @@ -946,13 +956,13 @@ interface EditorStepsTreeProps { } function EditorStepsTree({ - isVisible, - selectedStepId, - root, - onAddChildren, - onRemoveNode, - onStepSelected, - }: EditorStepsTreeProps) { + isVisible, + selectedStepId, + root, + onAddChildren, + onRemoveNode, + onStepSelected, +}: EditorStepsTreeProps) { return (
courtRef.current!.getBoundingClientRect(), [courtRef], @@ -1040,15 +1050,15 @@ interface CourtPlayerArrowActionProps { } function CourtPlayerArrowAction({ - playerInfo, - player, - isInvalid, - - content, - setContent, - setPreviewAction, - courtRef, - }: CourtPlayerArrowActionProps) { + playerInfo, + player, + isInvalid, + + content, + setContent, + setPreviewAction, + courtRef, +}: CourtPlayerArrowActionProps) { const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], @@ -1236,29 +1246,38 @@ function computeRelativePositions(courtBounds: DOMRect, content: StepContent) { return relativePositionsCache } -async function updateStepContents(stepId: number, - stepsTree: StepInfoNode, - getStepContent: (stepId: number) => Promise, - setStepContent: (stepId: number, content: StepContent) => Promise, +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) { + async function updateSteps( + step: StepInfoNode, + content: StepContent, + relativePositions: ComputedRelativePositions, + ) { const terminalStateContent = computeTerminalState( content, relativePositions, ) const tasks = step.children.map(async (child) => { - const { content: childContent, relativePositions: childRelativePositions } = await getStepContent(child.id) + 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) + await updateSteps( + child, + childUpdatedContent, + childRelativePositions, + ) } }) @@ -1271,4 +1290,4 @@ async function updateStepContents(stepId: number, const startNode = getStepNode(stepsTree!, stepId)! await updateSteps(startNode, content, relativePositions) -} \ No newline at end of file +} From fcd0a945355b0c495875987dae0f7defc39681ab Mon Sep 17 00:00:00 2001 From: maxime Date: Tue, 5 Mar 2024 20:40:52 +0100 Subject: [PATCH 13/16] do not paralellise steps updates --- src/pages/Editor.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 1264466..23e276b 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -1262,7 +1262,7 @@ async function updateStepContents( relativePositions, ) - const tasks = step.children.map(async (child) => { + for (const child of step.children) { const { content: childContent, relativePositions: childRelativePositions, @@ -1279,10 +1279,6 @@ async function updateStepContents( childRelativePositions, ) } - }) - - for (const task of tasks) { - await task } } From 4fe1ddfbd2f455e643fcdae1a8a402fe8d3360c6 Mon Sep 17 00:00:00 2001 From: maxime Date: Sat, 9 Mar 2024 01:08:23 +0100 Subject: [PATCH 14/16] fix step tree and step update --- .env.TEST | 2 + package.json | 8 +-- src/Fetcher.ts | 3 +- src/components/Rack.tsx | 27 ++++---- src/components/editor/StepsTree.tsx | 27 ++++---- src/editor/StepsDomain.ts | 17 +++++ src/editor/TacticContentDomains.ts | 79 ++++++++++++---------- src/pages/Editor.tsx | 101 +++++++++------------------- test/api/tactics.test.ts | 40 +++++++++++ vite.config.ts | 3 + 10 files changed, 172 insertions(+), 135 deletions(-) create mode 100644 .env.TEST create mode 100644 test/api/tactics.test.ts diff --git a/.env.TEST b/.env.TEST new file mode 100644 index 0000000..5fc3483 --- /dev/null +++ b/.env.TEST @@ -0,0 +1,2 @@ +VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master +#VITE_API_ENDPOINT=http://localhost:5254 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/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/components/Rack.tsx b/src/components/Rack.tsx index 2a7511f..fac282f 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 @@ -20,13 +20,13 @@ interface RackItemProps { * A container of draggable objects * */ export function Rack({ - id, - objects, - onChange, - canDetach, - onElementDetached, - render, -}: RackProps) { + id, + objects, + onChange, + canDetach, + onElementDetached, + render, + }: RackProps) { return (
({ const index = objects.findIndex( (o) => o.key === element.key, ) - onChange(objects.toSpliced(index, 1)) + if (onChange) + onChange(objects.toSpliced(index, 1)) onElementDetached(ref, element) }} @@ -55,10 +56,10 @@ export function Rack({ } function RackItem({ - item, - onTryDetach, - render, -}: RackItemProps) { + item, + onTryDetach, + render, + }: RackItemProps) { const divRef = useRef(null) return ( diff --git a/src/components/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx index c019aab..5a4af5e 100644 --- a/src/components/editor/StepsTree.tsx +++ b/src/components/editor/StepsTree.tsx @@ -4,6 +4,7 @@ import BendableArrow from "../arrows/BendableArrow" import { 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 @@ -24,7 +25,7 @@ export default function StepsTree({
void onRemoveNode: (node: StepInfoNode) => void @@ -45,7 +47,7 @@ interface StepsTreeContentProps { function StepsTreeNode({ node, - isNodeRoot, + rootNode, selectedStepId, onAddChildren, onRemoveNode, @@ -53,14 +55,15 @@ function StepsTreeNode({ }: StepsTreeContentProps) { const ref = useRef(null) + const stepId = getStepName(rootNode, node.id) return (
{node.children.map((child) => ( {}} forceStraight={true} wavy={false} @@ -70,11 +73,11 @@ function StepsTreeNode({ /> ))} onAddChildren(node)} onRemoveButtonClicked={ - isNodeRoot ? undefined : () => onRemoveNode(node) + rootNode.id === node.id ? undefined : () => onRemoveNode(node) } onSelected={() => onStepSelected(node)} /> @@ -82,7 +85,7 @@ function StepsTreeNode({ {node.children.map((child) => ( void onRemoveButtonClicked?: () => void @@ -104,7 +107,7 @@ interface StepPieceProps { } function StepPiece({ - id, + name, isSelected, onAddButtonClicked, onRemoveButtonClicked, @@ -112,7 +115,7 @@ function StepPiece({ }: StepPieceProps) { return (
)}
-

{id}

+

{name}

) } diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts index 88904e0..5ae681e 100644 --- a/src/editor/StepsDomain.ts +++ b/src/editor/StepsDomain.ts @@ -18,6 +18,23 @@ export function addStepNode( } } +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, diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 34dd0f3..9f05e14 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -200,9 +200,9 @@ export function moveComponent( phantomIdx == 0 ? origin : getComponent( - originPathItems[phantomIdx - 1], - content.components, - ) + originPathItems[phantomIdx - 1], + content.components, + ) // detach the action from the screen target and transform it to a regular move action to the phantom. content = updateComponent( { @@ -210,18 +210,18 @@ export function moveComponent( actions: playerBeforePhantom.actions.map((a) => a.target === referent ? { - ...a, - segments: a.segments.toSpliced( - a.segments.length - 2, - 1, - { - ...a.segments[a.segments.length - 1], - next: component.id, - }, - ), - target: component.id, - type: ActionKind.MOVE, - } + ...a, + segments: a.segments.toSpliced( + a.segments.length - 2, + 1, + { + ...a.segments[a.segments.length - 1], + next: component.id, + }, + ), + target: component.id, + type: ActionKind.MOVE, + } : a, ), }, @@ -234,9 +234,9 @@ export function moveComponent( ...component, pos: isPhantom ? { - type: "fixed", - ...newPos, - } + type: "fixed", + ...newPos, + } : newPos, }, content, @@ -315,15 +315,15 @@ export function computeTerminalState( content.components.filter((c) => c.type !== "phantom") as ( | Player | CourtObject - )[] + )[] const componentsTargetedState = nonPhantomComponents.map((comp) => comp.type === "player" ? getPlayerTerminalState(comp, content, computedPositions) : { - ...comp, - frozen: true, - }, + ...comp, + frozen: true, + }, ) return { @@ -399,20 +399,12 @@ export function drainTerminalStateOnChildContent( parentTerminalState: StepContent, childContent: StepContent, ): StepContent | null { - let gotUpdated = false - //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)), - ), - } + + let gotUpdated = false for (const parentComponent of parentTerminalState.components) { - const childComponent = tryGetComponent( + let childComponent = tryGetComponent( parentComponent.id, childContent.components, ) @@ -443,13 +435,16 @@ export function drainTerminalStateOnChildContent( if (newContentResult) { gotUpdated = true childContent = newContentResult + childComponent = getComponent(childComponent.id, newContentResult?.components) } - // also update the position of the player if it has been moved - if (!equals(childComponent.pos, parentComponent.pos)) { + // 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, @@ -457,5 +452,19 @@ export function drainTerminalStateOnChildContent( } } + 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/pages/Editor.tsx b/src/pages/Editor.tsx index 23e276b..da6a3d2 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -102,7 +102,7 @@ const GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY = "guest_mode_step_tree" const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" // The step identifier the editor will always open on -const ROOT_STEP_ID = 1 +const GUEST_MODE_ROOT_STEP_ID = 1 type ComputedRelativePositions = Map @@ -131,7 +131,7 @@ function EditorPortal({ guestMode }: EditorPageProps) { function GuestModeEditor() { const storageContent = localStorage.getItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + ROOT_STEP_ID, + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + GUEST_MODE_ROOT_STEP_ID, ) const stepInitialContent: StepContent = { @@ -148,10 +148,10 @@ function GuestModeEditor() { if (storageContent == null) { localStorage.setItem( GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, - JSON.stringify({ id: ROOT_STEP_ID, children: [] }), + JSON.stringify({ id: GUEST_MODE_ROOT_STEP_ID, children: [] }), ) localStorage.setItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + ROOT_STEP_ID, + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + GUEST_MODE_ROOT_STEP_ID, JSON.stringify(stepInitialContent), ) } @@ -161,7 +161,7 @@ function GuestModeEditor() { "Nouvelle Tactique" const courtRef = useRef(null) - const [stepId, setStepId] = useState(ROOT_STEP_ID) + const [stepId, setStepId] = useState(GUEST_MODE_ROOT_STEP_ID) const [stepContent, setStepContent, saveState] = useContentState( stepInitialContent, SaveStates.Guest, @@ -281,7 +281,7 @@ function GuestModeEditor() { function UserModeEditor() { const [tactic, setTactic] = useState(null) const [stepsTree, setStepsTree] = useState({ - id: ROOT_STEP_ID, + id: -1, children: [], }) const { tacticId: idStr } = useParams() @@ -289,7 +289,7 @@ function UserModeEditor() { const navigation = useNavigate() const courtRef = useRef(null) - const [stepId, setStepId] = useState(1) + const [stepId, setStepId] = useState(-1) const saveContent = useCallback( async (content: StepContent) => { @@ -353,29 +353,38 @@ function UserModeEditor() { async function initialize() { const infoResponsePromise = fetchAPIGet(`tactics/${tacticId}`) const treeResponsePromise = fetchAPIGet(`tactics/${tacticId}/tree`) - const contentResponsePromise = fetchAPIGet( - `tactics/${tacticId}/steps/${ROOT_STEP_ID}`, - ) const infoResponse = await infoResponsePromise const treeResponse = await treeResponsePromise - const contentResponse = await contentResponsePromise + + const { name, courtType } = await infoResponse.json() + const { root } = await treeResponse.json() if ( infoResponse.status == 401 || - treeResponse.status == 401 || - contentResponse.status == 401 + treeResponse.status == 401 ) { navigation("/login") return } - const { name, courtType } = await infoResponse.json() + const contentResponsePromise = fetchAPIGet( + `tactics/${tacticId}/steps/${root.id}`, + ) + + + const contentResponse = await contentResponsePromise + + if (contentResponse.status == 401) { + navigation("/login") + return + } + const content = await contentResponse.json() - const { root } = await treeResponse.json() setTactic({ id: tacticId, name, courtType }) setStepsTree(root) + setStepId(root.id) setStepContent(content, false) } @@ -492,12 +501,8 @@ function EditorPage({ const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) - 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" }], @@ -521,25 +526,6 @@ function EditorPage({ : new Map() }, [content, courtRef]) - // const setContent = useCallback( - // (newState: SetStateAction) => { - // setCurrentStepContent((c) => { - // const state = - // typeof newState === "function" - // ? newState(c.content) - // : newState - // - // const courtBounds = courtRef.current?.getBoundingClientRect() - // const relativePositions: ComputedRelativePositions = courtBounds - // ? computeRelativePositions(courtBounds, state) - // : new Map() - // - // return state - // }) - // }, - // [setCurrentStepContent], - // ) - const setComponents = (action: SetStateAction) => { setContent((c) => ({ ...c, @@ -553,25 +539,9 @@ function EditorPage({ }, [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( @@ -676,7 +646,7 @@ function EditorPage({ ), ] }, - [content, doMoveBall, previewAction?.isInvalid, setContent], + [content, courtRef, doMoveBall, previewAction?.isInvalid, setContent], ) const renderPlayer = useCallback( @@ -730,14 +700,7 @@ function EditorPage({ /> ) }, - [ - content, - relativePositions, - courtBounds, - validatePlayerPosition, - doRemovePlayer, - renderAvailablePlayerActions, - ], + [courtRef, content, relativePositions, courtBounds, renderAvailablePlayerActions, validatePlayerPosition, doRemovePlayer], ) const doDeleteAction = useCallback( @@ -811,7 +774,7 @@ function EditorPage({ /> ) }), - [doDeleteAction, doUpdateAction], + [courtRef, doDeleteAction, doUpdateAction], ) return ( @@ -836,7 +799,7 @@ function EditorPage({
@@ -846,7 +809,6 @@ function EditorPage({ @@ -881,7 +843,6 @@ function EditorPage({ @@ -983,7 +944,7 @@ function EditorStepsTree({ interface PlayerRackProps { id: string objects: RackedPlayer[] - setObjects: (state: RackedPlayer[]) => void + setObjects?: (state: RackedPlayer[]) => void setComponents: ( f: (components: TacticComponent[]) => TacticComponent[], ) => void diff --git a/test/api/tactics.test.ts b/test/api/tactics.test.ts new file mode 100644 index 0000000..552bfaa --- /dev/null +++ b/test/api/tactics.test.ts @@ -0,0 +1,40 @@ +import { beforeAll, expect, test } from "vitest" +import { fetchAPI } from "../../src/Fetcher" +import { saveSession } from "../../src/api/session" + +async function login() { + const response = await fetchAPI("auth/token/", { email: "maxime@mail.com", password: "123456" }) + expect(response.status).toBe(200) + const { token, expirationDate } = await response.json() + saveSession({ auth: { token, expirationDate: Date.parse(expirationDate) } }) +} + +beforeAll(login) + +test("create tactic", async () => { + await login() + const response = await fetchAPI("tactics", { courtType: "PLAIN", name: "test tactic" }) + expect(response.status).toBe(200) +}) + +test("spam step creation test", async () => { + const createTacticResponse = await fetchAPI("tactics", { courtType: "PLAIN", name: "test tactic" }) + expect(createTacticResponse.status).toBe(200) + const { id } = await createTacticResponse.json() + + const tasks = Array.from({length: 200}) + .map(async () => { + const response = await fetchAPI(`tactics/${id}/steps`, { parentId: 1, content: { components: [] } }) + expect(response.status).toBe(200) + const { stepId } = await response.json() + return stepId + }) + + const steps = [] + for (const task of tasks) { + steps.push(await task) + } + steps.sort((a, b) => a - b) + const expected = Array.from({length: 200}, (_, i) => i + 2) + expect(steps).toEqual(expected) +}) \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 214327e..4126e3b 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({ From df10eba8d258c1657ebeadd32ea41728b917970f Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Sat, 9 Mar 2024 09:53:54 +0100 Subject: [PATCH 15/16] the tree is now shown with a resizeable curtain-slide view --- ci/build_and_deploy_to.sh | 3 +- src/components/CurtainLayout.tsx | 76 ++++++++ src/components/Rack.tsx | 25 ++- src/components/arrows/BendableArrow.tsx | 9 + src/components/editor/StepsTree.tsx | 11 +- src/editor/StepsDomain.ts | 4 +- src/editor/TacticContentDomains.ts | 58 +++--- src/pages/Editor.tsx | 242 +++++++++++++----------- src/style/editor.css | 22 ++- src/style/steps_tree.css | 1 + test/api/tactics.test.ts | 33 ++-- vite.config.ts | 2 +- 12 files changed, 305 insertions(+), 181 deletions(-) create mode 100644 src/components/CurtainLayout.tsx diff --git a/ci/build_and_deploy_to.sh b/ci/build_and_deploy_to.sh index a5a6601..eb5d387 100755 --- a/ci/build_and_deploy_to.sh +++ b/ci/build_and_deploy_to.sh @@ -1,5 +1,4 @@ -set -e - +#!/usr/bin/env bash set -xeu export OUTPUT=$1 diff --git a/src/components/CurtainLayout.tsx b/src/components/CurtainLayout.tsx new file mode 100644 index 0000000..0be39fb --- /dev/null +++ b/src/components/CurtainLayout.tsx @@ -0,0 +1,76 @@ +import { ReactNode, useCallback, useEffect, useRef, useState } from "react" + +export interface SlideLayoutProps { + children: [ReactNode, ReactNode] + rightWidth: number + onRightWidthChange: (w: number) => void +} + +export default function CurtainLayout({ + children, + rightWidth, + onRightWidthChange, +}: SlideLayoutProps) { + const curtainRef = useRef(null) + const sliderRef = useRef(null) + + const resize = useCallback( + (e: MouseEvent) => { + const sliderPosX = e.clientX + const curtainWidth = + curtainRef.current!.getBoundingClientRect().width + + onRightWidthChange((sliderPosX / curtainWidth) * 100) + }, + [curtainRef, onRightWidthChange], + ) + + const [resizing, setResizing] = useState(false) + + useEffect(() => { + const curtain = curtainRef.current! + const slider = sliderRef.current! + + if (resizing) { + const handleMouseUp = () => setResizing(false) + + curtain.addEventListener("mousemove", resize) + curtain.addEventListener("mouseup", handleMouseUp) + return () => { + curtain.removeEventListener("mousemove", resize) + curtain.removeEventListener("mouseup", handleMouseUp) + } + } + + const handleMouseDown = () => setResizing(true) + + slider.addEventListener("mousedown", handleMouseDown) + + return () => { + slider.removeEventListener("mousedown", handleMouseDown) + } + }, [sliderRef, curtainRef, resizing, setResizing, resize]) + + return ( +
+
+ {children[0]} +
+
+ +
+ {children[1]} +
+
+ ) +} diff --git a/src/components/Rack.tsx b/src/components/Rack.tsx index fac282f..2e4e75a 100644 --- a/src/components/Rack.tsx +++ b/src/components/Rack.tsx @@ -20,13 +20,13 @@ interface RackItemProps { * A container of draggable objects * */ export function Rack({ - id, - objects, - onChange, - canDetach, - onElementDetached, - render, - }: RackProps) { + id, + objects, + onChange, + canDetach, + onElementDetached, + render, +}: RackProps) { return (
({ const index = objects.findIndex( (o) => o.key === element.key, ) - if (onChange) - onChange(objects.toSpliced(index, 1)) + if (onChange) onChange(objects.toSpliced(index, 1)) onElementDetached(ref, element) }} @@ -56,10 +55,10 @@ export function Rack({ } function RackItem({ - item, - onTryDetach, - render, - }: RackItemProps) { + item, + onTryDetach, + render, +}: RackItemProps) { const divRef = useRef(null) return ( diff --git a/src/components/arrows/BendableArrow.tsx b/src/components/arrows/BendableArrow.tsx index 18aeea3..a08204e 100644 --- a/src/components/arrows/BendableArrow.tsx +++ b/src/components/arrows/BendableArrow.tsx @@ -389,6 +389,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 +403,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/StepsTree.tsx b/src/components/editor/StepsTree.tsx index 5a4af5e..f9c9a07 100644 --- a/src/components/editor/StepsTree.tsx +++ b/src/components/editor/StepsTree.tsx @@ -63,7 +63,12 @@ function StepsTreeNode({ key={child.id} area={ref} startPos={"step-piece-" + stepId} - segments={[{ next: "step-piece-" + getStepName(rootNode, child.id)}]} + segments={[ + { + next: + "step-piece-" + getStepName(rootNode, child.id), + }, + ]} onSegmentsChanges={() => {}} forceStraight={true} wavy={false} @@ -77,7 +82,9 @@ function StepsTreeNode({ isSelected={selectedStepId === node.id} onAddButtonClicked={() => onAddChildren(node)} onRemoveButtonClicked={ - rootNode.id === node.id ? undefined : () => onRemoveNode(node) + rootNode.id === node.id + ? undefined + : () => onRemoveNode(node) } onSelected={() => onStepSelected(node)} /> diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts index 5ae681e..15de927 100644 --- a/src/editor/StepsDomain.ts +++ b/src/editor/StepsDomain.ts @@ -19,14 +19,12 @@ export function addStepNode( } 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 + if (node.id === step) break ord++ nodes.push(...[...node.children].reverse()) diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 9f05e14..de631ca 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -200,9 +200,9 @@ export function moveComponent( phantomIdx == 0 ? origin : getComponent( - originPathItems[phantomIdx - 1], - content.components, - ) + originPathItems[phantomIdx - 1], + content.components, + ) // detach the action from the screen target and transform it to a regular move action to the phantom. content = updateComponent( { @@ -210,18 +210,18 @@ export function moveComponent( actions: playerBeforePhantom.actions.map((a) => a.target === referent ? { - ...a, - segments: a.segments.toSpliced( - a.segments.length - 2, - 1, - { - ...a.segments[a.segments.length - 1], - next: component.id, - }, - ), - target: component.id, - type: ActionKind.MOVE, - } + ...a, + segments: a.segments.toSpliced( + a.segments.length - 2, + 1, + { + ...a.segments[a.segments.length - 1], + next: component.id, + }, + ), + target: component.id, + type: ActionKind.MOVE, + } : a, ), }, @@ -234,9 +234,9 @@ export function moveComponent( ...component, pos: isPhantom ? { - type: "fixed", - ...newPos, - } + type: "fixed", + ...newPos, + } : newPos, }, content, @@ -315,15 +315,15 @@ export function computeTerminalState( content.components.filter((c) => c.type !== "phantom") as ( | Player | CourtObject - )[] + )[] const componentsTargetedState = nonPhantomComponents.map((comp) => comp.type === "player" ? getPlayerTerminalState(comp, content, computedPositions) : { - ...comp, - frozen: true, - }, + ...comp, + frozen: true, + }, ) return { @@ -399,8 +399,6 @@ export function drainTerminalStateOnChildContent( parentTerminalState: StepContent, childContent: StepContent, ): StepContent | null { - - let gotUpdated = false for (const parentComponent of parentTerminalState.components) { @@ -435,11 +433,17 @@ export function drainTerminalStateOnChildContent( if (newContentResult) { gotUpdated = true childContent = newContentResult - childComponent = getComponent(childComponent.id, newContentResult?.components) + 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)) { + if ( + !childComponent.frozen || + !equals(childComponent.pos, parentComponent.pos) + ) { gotUpdated = true childContent = updateComponent( { @@ -460,7 +464,7 @@ export function drainTerminalStateOnChildContent( (comp) => comp.type === "phantom" || !comp.frozen || - tryGetComponent(comp.id, parentTerminalState.components) + tryGetComponent(comp.id, parentTerminalState.components), ), } diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index da6a3d2..158441a 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -92,6 +92,7 @@ import { getStepNode, removeStepNode, } from "../editor/StepsDomain" +import CurtainLayout from "../components/CurtainLayout" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -360,10 +361,7 @@ function UserModeEditor() { const { name, courtType } = await infoResponse.json() const { root } = await treeResponse.json() - if ( - infoResponse.status == 401 || - treeResponse.status == 401 - ) { + if (infoResponse.status == 401 || treeResponse.status == 401) { navigation("/login") return } @@ -372,7 +370,6 @@ function UserModeEditor() { `tactics/${tacticId}/steps/${root.id}`, ) - const contentResponse = await contentResponsePromise if (contentResponse.status == 401) { @@ -519,6 +516,9 @@ function EditorPage({ [courtRef], ) + const [editorContentCurtainWidth, setEditorContentCurtainWidth] = + useState(80) + const relativePositions = useMemo(() => { const courtBounds = courtRef.current?.getBoundingClientRect() return courtBounds @@ -700,7 +700,15 @@ function EditorPage({ /> ) }, - [courtRef, content, relativePositions, courtBounds, renderAvailablePlayerActions, validatePlayerPosition, doRemovePlayer], + [ + courtRef, + content, + relativePositions, + courtBounds, + renderAvailablePlayerActions, + validatePlayerPosition, + doRemovePlayer, + ], ) const doDeleteAction = useCallback( @@ -774,7 +782,107 @@ function EditorPage({ /> ) }), - [courtRef, doDeleteAction, doUpdateAction], + [courtRef, doDeleteAction, doUpdateAction, editorContentCurtainWidth], + ) + + const contentNode = ( +
+
+ + + + overlaps( + courtBounds(), + div.getBoundingClientRect(), + ), + [courtBounds], + )} + onElementDetached={useCallback( + (r, e: RackedCourtObject) => + setContent((content) => + placeObjectAt( + r.getBoundingClientRect(), + courtBounds(), + e, + content, + ), + ), + [courtBounds, setContent], + )} + render={renderCourtObject} + /> + + +
+
+
+ } + courtRef={courtRef} + previewAction={previewAction} + renderComponent={renderComponent} + renderActions={renderActions} + /> +
+
+
+ ) + + const stepsTreeNode = ( + { + const addedNode = await onAddStep( + parent, + computeTerminalState(content, relativePositions), + ) + if (addedNode == null) { + console.error( + "could not add step : onAddStep returned null node", + ) + return + } + selectStep(addedNode.id) + setRootStepsNode((root) => + addStepNode(root, parent, addedNode), + ) + }, + [content, onAddStep, selectStep, relativePositions], + )} + onRemoveNode={useCallback( + async (removed) => { + const isOk = await onRemoveStep(removed) + selectStep(getParent(rootStepsNode, removed)!.id) + if (isOk) + setRootStepsNode( + (root) => removeStepNode(root, removed)!, + ) + }, + [rootStepsNode, onRemoveStep, selectStep], + )} + onStepSelected={useCallback( + (node) => selectStep(node.id), + [selectStep], + )} + /> ) return ( @@ -798,117 +906,30 @@ function EditorPage({ />
-
-
-
- - - - overlaps( - courtBounds(), - div.getBoundingClientRect(), - ), - [courtBounds], - )} - onElementDetached={useCallback( - (r, e: RackedCourtObject) => - setContent((content) => - placeObjectAt( - r.getBoundingClientRect(), - courtBounds(), - e, - content, - ), - ), - [courtBounds, setContent], - )} - render={renderCourtObject} - /> - - -
-
-
- } - courtRef={courtRef} - previewAction={previewAction} - renderComponent={renderComponent} - renderActions={renderActions} - /> -
-
-
- { - const addedNode = await onAddStep( - parent, - computeTerminalState( - content, - relativePositions, - ), - ) - if (addedNode == null) { - console.error( - "could not add step : onAddStep returned null node", - ) - return - } - selectStep(addedNode.id) - setRootStepsNode((root) => - addStepNode(root, parent, addedNode), - ) - }, - [content, onAddStep, selectStep, relativePositions], - )} - onRemoveNode={useCallback( - async (removed) => { - const isOk = await onRemoveStep(removed) - selectStep(getParent(rootStepsNode, removed)!.id) - if (isOk) - setRootStepsNode( - (root) => removeStepNode(root, removed)!, - ) - }, - [rootStepsNode, onRemoveStep, selectStep], - )} - onStepSelected={useCallback( - (node) => selectStep(node.id), - [selectStep], - )} - /> + {isStepsTreeVisible ? ( + + {contentNode} + {stepsTreeNode} + + ) : ( + contentNode + )}
) } interface EditorStepsTreeProps { - isVisible: boolean selectedStepId: number root: StepInfoNode onAddChildren: (parent: StepInfoNode) => void @@ -917,7 +938,6 @@ interface EditorStepsTreeProps { } function EditorStepsTree({ - isVisible, selectedStepId, root, onAddChildren, @@ -925,11 +945,7 @@ function EditorStepsTree({ onStepSelected, }: EditorStepsTreeProps) { return ( -
+
{ await login() - const response = await fetchAPI("tactics", { courtType: "PLAIN", name: "test tactic" }) + const response = await fetchAPI("tactics", { + courtType: "PLAIN", + name: "test tactic", + }) expect(response.status).toBe(200) }) test("spam step creation test", async () => { - const createTacticResponse = await fetchAPI("tactics", { courtType: "PLAIN", name: "test tactic" }) + const createTacticResponse = await fetchAPI("tactics", { + courtType: "PLAIN", + name: "test tactic", + }) expect(createTacticResponse.status).toBe(200) const { id } = await createTacticResponse.json() - const tasks = Array.from({length: 200}) - .map(async () => { - const response = await fetchAPI(`tactics/${id}/steps`, { parentId: 1, content: { components: [] } }) - expect(response.status).toBe(200) - const { stepId } = await response.json() - return stepId + const tasks = Array.from({ length: 200 }).map(async () => { + const response = await fetchAPI(`tactics/${id}/steps`, { + parentId: 1, + content: { components: [] }, }) + expect(response.status).toBe(200) + const { stepId } = await response.json() + return stepId + }) const steps = [] for (const task of tasks) { steps.push(await task) } steps.sort((a, b) => a - b) - const expected = Array.from({length: 200}, (_, i) => i + 2) + const expected = Array.from({ length: 200 }, (_, i) => i + 2) expect(steps).toEqual(expected) -}) \ No newline at end of file +}) diff --git a/vite.config.ts b/vite.config.ts index 4126e3b..8d932e7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ target: "es2021", }, test: { - environment: "jsdom" + environment: "jsdom", }, plugins: [ react(), From b87db24e9e6a10f8c66a8c573da0fcafaa09cc91 Mon Sep 17 00:00:00 2001 From: maxime Date: Mon, 11 Mar 2024 17:56:20 +0100 Subject: [PATCH 16/16] Apply suggestions --- .env.TEST | 2 - ci/prepare_php.sh | 9 - .../{CurtainLayout.tsx => SplitLayout.tsx} | 44 +- src/components/arrows/BendableArrow.tsx | 6 +- src/components/editor/StepsTree.tsx | 34 +- src/editor/ActionsDomains.ts | 2 +- src/editor/PlayerDomains.ts | 9 +- src/editor/StepsDomain.ts | 42 +- src/editor/TacticContentDomains.ts | 8 +- src/model/tactic/Action.ts | 6 +- src/model/tactic/CourtObjects.ts | 8 +- src/model/tactic/Player.ts | 26 +- src/model/tactic/Tactic.ts | 4 + src/pages/Editor.tsx | 535 ++++++------------ src/pages/HomePage.tsx | 2 +- src/service/APITacticService.ts | 84 +++ src/service/LocalStorageTacticService.ts | 99 ++++ src/service/TacticService.ts | 32 ++ src/style/editor.css | 3 - test/api/tactics.test.ts | 51 -- 20 files changed, 485 insertions(+), 521 deletions(-) delete mode 100644 .env.TEST delete mode 100644 ci/prepare_php.sh rename src/components/{CurtainLayout.tsx => SplitLayout.tsx} (50%) create mode 100644 src/service/APITacticService.ts create mode 100644 src/service/LocalStorageTacticService.ts create mode 100644 src/service/TacticService.ts delete mode 100644 test/api/tactics.test.ts diff --git a/.env.TEST b/.env.TEST deleted file mode 100644 index 5fc3483..0000000 --- a/.env.TEST +++ /dev/null @@ -1,2 +0,0 @@ -VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master -#VITE_API_ENDPOINT=http://localhost:5254 diff --git a/ci/prepare_php.sh b/ci/prepare_php.sh deleted file mode 100644 index f2525df..0000000 --- a/ci/prepare_php.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -mkdir -p /outputs/public -# this sed command will replace the included `profile/dev-config-profile.php` to `profile/prod-config-file.php` in the config.php file. -sed -E -i 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php -DRONE_BRANCH_ESCAPED=$(sed -E 's/\//\\\//g' <<< "$DRONE_BRANCH") -sed -E -i "s/const BASE_PATH = .*;/const BASE_PATH = \"\/IQBall\/$DRONE_BRANCH_ESCAPED\/public\";/" profiles/prod-config-profile.php -rm profiles/dev-config-profile.php -mv src config.php sql profiles vendor /outputs/ \ No newline at end of file diff --git a/src/components/CurtainLayout.tsx b/src/components/SplitLayout.tsx similarity index 50% rename from src/components/CurtainLayout.tsx rename to src/components/SplitLayout.tsx index 0be39fb..89c0364 100644 --- a/src/components/CurtainLayout.tsx +++ b/src/components/SplitLayout.tsx @@ -1,21 +1,20 @@ -import { ReactNode, useCallback, useEffect, useRef, useState } from "react" +import React, { ReactNode, useCallback, useRef, useState } from "react" -export interface SlideLayoutProps { +export interface SplitLayoutProps { children: [ReactNode, ReactNode] rightWidth: number onRightWidthChange: (w: number) => void } -export default function CurtainLayout({ +export default function SplitLayout({ children, rightWidth, onRightWidthChange, -}: SlideLayoutProps) { +}: SplitLayoutProps) { const curtainRef = useRef(null) - const sliderRef = useRef(null) const resize = useCallback( - (e: MouseEvent) => { + (e: React.MouseEvent) => { const sliderPosX = e.clientX const curtainWidth = curtainRef.current!.getBoundingClientRect().width @@ -27,37 +26,18 @@ export default function CurtainLayout({ const [resizing, setResizing] = useState(false) - useEffect(() => { - const curtain = curtainRef.current! - const slider = sliderRef.current! - - if (resizing) { - const handleMouseUp = () => setResizing(false) - - curtain.addEventListener("mousemove", resize) - curtain.addEventListener("mouseup", handleMouseUp) - return () => { - curtain.removeEventListener("mousemove", resize) - curtain.removeEventListener("mouseup", handleMouseUp) - } - } - - const handleMouseDown = () => setResizing(true) - - slider.addEventListener("mousedown", handleMouseDown) - - return () => { - slider.removeEventListener("mousedown", handleMouseDown) - } - }, [sliderRef, curtainRef, resizing, setResizing, resize]) - return ( -
+
setResizing(false)}>
{children[0]}
setResizing(true)} style={{ width: 4, height: "100%", diff --git a/src/components/arrows/BendableArrow.tsx b/src/components/arrows/BendableArrow.tsx index a08204e..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 diff --git a/src/components/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx index f9c9a07..952b268 100644 --- a/src/components/editor/StepsTree.tsx +++ b/src/components/editor/StepsTree.tsx @@ -1,7 +1,7 @@ import "../../style/steps_tree.css" import { StepInfoNode } from "../../model/tactic/Tactic" import BendableArrow from "../arrows/BendableArrow" -import { useRef } from "react" +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" @@ -55,18 +55,16 @@ function StepsTreeNode({ }: StepsTreeContentProps) { const ref = useRef(null) - const stepId = getStepName(rootNode, node.id) return (
{node.children.map((child) => ( {}} @@ -78,7 +76,7 @@ function StepsTreeNode({ /> ))} onAddChildren(node)} onRemoveButtonClicked={ @@ -86,8 +84,14 @@ function StepsTreeNode({ ? undefined : () => onRemoveNode(node) } - onSelected={() => onStepSelected(node)} - /> + onSelected={() => onStepSelected(node)}> +

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

+
{node.children.map((child) => ( void onRemoveButtonClicked?: () => void onSelected: () => void + children?: ReactNode } function StepPiece({ - name, + id, isSelected, onAddButtonClicked, onRemoveButtonClicked, onSelected, + children, }: StepPieceProps) { return (
{onAddButtonClicked && ( onAddButtonClicked()} + onClick={onAddButtonClicked} className={"add-icon"} /> )} {onRemoveButtonClicked && ( onRemoveButtonClicked()} + onClick={onRemoveButtonClicked} className={"remove-icon"} /> )}
-

{name}

+ {children}
) } diff --git a/src/editor/ActionsDomains.ts b/src/editor/ActionsDomains.ts index 58e1613..8ad9e5a 100644 --- a/src/editor/ActionsDomains.ts +++ b/src/editor/ActionsDomains.ts @@ -462,10 +462,10 @@ export function removeAction( /** * Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with * the given newState. - * @returns the new state if it has been updated, or null if no changes were operated * @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, diff --git a/src/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts index 6eef2a3..1b3c918 100644 --- a/src/editor/PlayerDomains.ts +++ b/src/editor/PlayerDomains.ts @@ -59,10 +59,10 @@ export function getPrecomputedPosition( phantom: PlayerPhantom, computedPositions: Map, ): Pos | undefined { - const positioning = phantom.pos + const pos = phantom.pos // If the position is already known and fixed, return the pos - if (positioning.type === "fixed") return positioning + if (pos.type === "fixed") return pos return computedPositions.get(phantom.id) } @@ -139,10 +139,9 @@ 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) diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts index 15de927..e788a9c 100644 --- a/src/editor/StepsDomain.ts +++ b/src/editor/StepsDomain.ts @@ -47,19 +47,43 @@ export function getStepNode( export function removeStepNode( root: StepInfoNode, - node: StepInfoNode, + targetId: number, ): StepInfoNode | undefined { - if (root.id === node.id) { - return 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 { - ...root, - children: root.children.flatMap((child) => { - const result = removeStepNode(child, node) - return result ? [result] : [] - }), + 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 [] } /** diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index de631ca..319e4a0 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -312,10 +312,10 @@ export function computeTerminalState( computedPositions: Map, ): StepContent { const nonPhantomComponents: (Player | CourtObject)[] = - content.components.filter((c) => c.type !== "phantom") as ( - | Player - | CourtObject - )[] + content.components.filter( + (c): c is Exclude => + c.type !== "phantom", + ) const componentsTargetedState = nonPhantomComponents.map((comp) => comp.type === "player" 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 6751e65..55eaf6f 100644 --- a/src/model/tactic/CourtObjects.ts +++ b/src/model/tactic/CourtObjects.ts @@ -1,12 +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 interface Ball extends Component { - readonly frozen: boolean -} diff --git a/src/model/tactic/Player.ts b/src/model/tactic/Player.ts index 5f1d3df..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,31 +25,21 @@ 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 - - readonly frozen: boolean } export interface MovementPath { diff --git a/src/model/tactic/Tactic.ts b/src/model/tactic/Tactic.ts index aa33ea7..ccf6d43 100644 --- a/src/model/tactic/Tactic.ts +++ b/src/model/tactic/Tactic.ts @@ -41,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 158441a..a0b6708 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -1,6 +1,5 @@ import { CSSProperties, - Dispatch, RefObject, SetStateAction, useCallback, @@ -25,9 +24,7 @@ import { StepContent, StepInfoNode, TacticComponent, - TacticInfo, } from "../model/tactic/Tactic" -import { fetchAPI, fetchAPIGet } from "../Fetcher" import SavingState, { SaveState, @@ -83,28 +80,23 @@ import { removePlayer, } from "../editor/PlayerDomains" import { CourtBall } from "../components/editor/CourtBall" -import { useNavigate, useParams } from "react-router-dom" import StepsTree from "../components/editor/StepsTree" import { addStepNode, - getAvailableId, getParent, getStepNode, removeStepNode, } from "../editor/StepsDomain" -import CurtainLayout from "../components/CurtainLayout" +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_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" - -// The step identifier the editor will always open on -const GUEST_MODE_ROOT_STEP_ID = 1 - type ComputedRelativePositions = Map type ComputedStepContent = { @@ -112,12 +104,6 @@ type ComputedStepContent = { relativePositions: ComputedRelativePositions } -interface TacticDto { - id: number - name: string - courtType: CourtType -} - export interface EditorPageProps { guestMode: boolean } @@ -126,193 +112,56 @@ export default function Editor({ guestMode }: EditorPageProps) { return } -function EditorPortal({ guestMode }: EditorPageProps) { - return guestMode ? : -} - -function GuestModeEditor() { - const storageContent = localStorage.getItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + GUEST_MODE_ROOT_STEP_ID, - ) - - const stepInitialContent: StepContent = { - ...(storageContent == null - ? { components: [] } - : JSON.parse(storageContent)), - } - - const rootStepNode: StepInfoNode = JSON.parse( - localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!, - ) +interface EditorService { + addStep( + parent: StepInfoNode, + content: StepContent, + ): Promise - // initialize local storage if we launch in guest mode - if (storageContent == null) { - localStorage.setItem( - GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, - JSON.stringify({ id: GUEST_MODE_ROOT_STEP_ID, children: [] }), - ) - localStorage.setItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + GUEST_MODE_ROOT_STEP_ID, - JSON.stringify(stepInitialContent), - ) - } + removeStep(step: number): Promise - const tacticName = - localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? - "Nouvelle Tactique" + selectStep(step: number): Promise - const courtRef = useRef(null) - const [stepId, setStepId] = useState(GUEST_MODE_ROOT_STEP_ID) - const [stepContent, setStepContent, saveState] = useContentState( - stepInitialContent, - SaveStates.Guest, - useMemo( - () => - debounceAsync(async (content: StepContent) => { - localStorage.setItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, - JSON.stringify(content), - ) - - const stepsTree: StepInfoNode = JSON.parse( - localStorage.getItem( - GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, - )!, - ) + setContent(content: SetStateAction): void - await updateStepContents( - stepId, - stepsTree, - async (stepId) => { - const content = JSON.parse( - localStorage.getItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + - stepId, - )!, - ) - const courtBounds = - courtRef.current!.getBoundingClientRect() - const relativePositions = computeRelativePositions( - courtBounds, - content, - ) - return { content, relativePositions } - }, - async (stepId, content) => - localStorage.setItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, - JSON.stringify(content), - ), - ) + setName(name: string): Promise +} - return SaveStates.Guest - }, 250), - [stepId], - ), - ) +function EditorPortal({ guestMode }: EditorPageProps) { + const { tacticId: idStr } = useParams() - function getStepContent(step: number): StepContent { - return JSON.parse( - localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!, - ) + if (guestMode || !idStr) { + return } - return ( - setStepContent(content, true)} - saveState={saveState} - currentStepId={stepId} - onNameChange={useCallback(async (name) => { - localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) - return true //simulate that the name has been changed - }, [])} - selectStep={useCallback( - (step) => { - setStepId(step) - setStepContent(getStepContent(step), false) - return - }, - [setStepContent], - )} - onAddStep={useCallback(async (parent, content) => { - 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 - }, [])} - onRemoveStep={useCallback(async (step) => { - 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, step)), - ) - return true - }, [])} - /> - ) + return } -function UserModeEditor() { - const [tactic, setTactic] = useState(null) - const [stepsTree, setStepsTree] = useState({ - id: -1, - children: [], - }) - const { tacticId: idStr } = useParams() - const tacticId = parseInt(idStr!) - const navigation = useNavigate() +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() const courtRef = useRef(null) - const [stepId, setStepId] = useState(-1) const saveContent = useCallback( async (content: StepContent) => { - const response = await fetchAPI( - `tactics/${tacticId}/steps/${stepId}`, - { content }, - "PUT", - ) + const result = await service.saveContent(stepId!, content) + + if (typeof result === "string") return SaveStates.Err await updateStepContents( - stepId, - stepsTree, + stepId!, + stepsTree!, async (id) => { - const response = await fetchAPIGet( - `tactics/${tacticId}/steps/${id}`, - ) - if (!response.ok) + const content = await service.getContent(id) + if (typeof content === "string") throw new Error( "Error when retrieving children content", ) - const content = await response.json() const courtBounds = courtRef.current!.getBoundingClientRect() const relativePositions = computeRelativePositions( @@ -325,22 +174,14 @@ function UserModeEditor() { } }, async (id, content) => { - const response = await fetchAPI( - `tactics/${tacticId}/steps/${id}`, - { content }, - "PUT", - ) - if (!response.ok) { - throw new Error( - "Error when updated new children content", - ) - } + const result = await service.saveContent(id, content) + if (typeof result === "string") + throw new Error("Error when updating children content") }, ) - - return response.ok ? SaveStates.Ok : SaveStates.Err + return SaveStates.Ok }, - [tacticId, stepId, stepsTree], + [service, stepId, stepsTree], ) const [stepContent, setStepContent, saveState] = @@ -350,154 +191,125 @@ function UserModeEditor() { useMemo(() => debounceAsync(saveContent, 250), [saveContent]), ) - useEffect(() => { - async function initialize() { - const infoResponsePromise = fetchAPIGet(`tactics/${tacticId}`) - const treeResponsePromise = fetchAPIGet(`tactics/${tacticId}/tree`) - - const infoResponse = await infoResponsePromise - const treeResponse = await treeResponsePromise + const isNotInit = !tacticName || !stepId || !stepsTree || !courtType - const { name, courtType } = await infoResponse.json() - const { root } = await treeResponse.json() - - if (infoResponse.status == 401 || treeResponse.status == 401) { - navigation("/login") + 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, + ) return } - - const contentResponsePromise = fetchAPIGet( - `tactics/${tacticId}/steps/${root.id}`, - ) - - const contentResponse = await contentResponsePromise - - if (contentResponse.status == 401) { - navigation("/login") + 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 } - - const content = await contentResponse.json() - - setTactic({ id: tacticId, name, courtType }) - setStepsTree(root) - setStepId(root.id) - setStepContent(content, false) + setStepContent(contentResult, false) } - if (tactic === null) initialize() - }, [tactic, tacticId, idStr, navigation, setStepContent]) - - const onNameChange = useCallback( - (name: string) => - fetchAPI(`tactics/${tacticId}/name`, { name }, "PUT").then( - (r) => r.ok, - ), - [tacticId], - ) - - const selectStep = useCallback( - async (step: number) => { - const response = await fetchAPIGet( - `tactics/${tacticId}/steps/${step}`, - ) - if (!response.ok) return - setStepId(step) - setStepContent(await response.json(), false) - }, - [tacticId, setStepContent], - ) - - const onAddStep = useCallback( - async (parent: StepInfoNode, content: StepContent) => { - const response = await fetchAPI(`tactics/${tacticId}/steps`, { - parentId: parent.id, - content, - }) - if (!response.ok) return null - const { stepId } = await response.json() - const child = { id: stepId, children: [] } - setStepsTree(addStepNode(stepsTree, parent, child)) - return child - }, - [tacticId, stepsTree], + 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], ) - const onRemoveStep = useCallback( - async (step: StepInfoNode) => { - const response = await fetchAPI( - `tactics/${tacticId}/steps/${step.id}`, - {}, - "DELETE", - ) - setStepsTree(removeStepNode(stepsTree, step)!) - return response.ok - }, - [tacticId, stepsTree], - ) + if (panicMessage) { + return

{panicMessage}

+ } - if (!tactic) return + if (isNotInit) { + return

Retrieving editor context. Please wait...

+ } return ( setStepContent(content, true)} - saveState={saveState} - onNameChange={onNameChange} - selectStep={selectStep} - onAddStep={onAddStep} - onRemoveStep={onRemoveStep} /> ) } -function EditorLoadingScreen() { - return

Loading Editor, Please wait...

-} - export interface EditorViewProps { - tactic: TacticInfo - currentStepContent: StepContent - currentStepId: number - saveState: SaveState - setCurrentStepContent: Dispatch> + stepsTree: StepInfoNode + name: string + courtType: CourtType + content: StepContent + contentSaveState: SaveState + stepId: number courtRef: RefObject - selectStep: (stepId: number) => void - onNameChange: (name: string) => Promise - onRemoveStep: (step: StepInfoNode) => Promise - onAddStep: ( - parent: StepInfoNode, - content: StepContent, - ) => Promise + service: EditorService } function EditorPage({ - tactic: { name, rootStepNode: initialStepsNode, courtType }, - currentStepId, - setCurrentStepContent: setContent, - currentStepContent: content, - saveState, - onNameChange, - selectStep, - onRemoveStep, - onAddStep, - + name, + courtType, + content, + stepId, + contentSaveState, + stepsTree, courtRef, + service, }: EditorViewProps) { const [titleStyle, setTitleStyle] = useState({}) - const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) - const allies = getRackPlayers(PlayerTeam.Allies, content.components) const opponents = getRackPlayers(PlayerTeam.Opponents, content.components) @@ -527,7 +339,7 @@ function EditorPage({ }, [content, courtRef]) const setComponents = (action: SetStateAction) => { - setContent((c) => ({ + service.setContent((c) => ({ ...c, components: typeof action == "function" ? action(c.components) : action, @@ -546,15 +358,15 @@ function EditorPage({ 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 = spreadNewStateFromOriginStateChange( @@ -569,12 +381,12 @@ function EditorPage({ return content }) }, - [courtBounds, setContent], + [courtBounds, service], ) const validatePlayerPosition = useCallback( (player: PlayerLike, info: PlayerInfo, newPos: Pos) => { - setContent((content) => + service.setContent((content) => moveComponent( newPos, player, @@ -589,7 +401,7 @@ function EditorPage({ ), ) }, - [courtBounds, setContent], + [courtBounds, service], ) const renderAvailablePlayerActions = useCallback( @@ -631,7 +443,7 @@ function EditorPage({ playerInfo={info} content={content} courtRef={courtRef} - setContent={setContent} + setContent={service.setContent} /> ), !isFrozen && @@ -646,7 +458,13 @@ function EditorPage({ ), ] }, - [content, courtRef, doMoveBall, previewAction?.isInvalid, setContent], + [ + content, + courtRef, + doMoveBall, + previewAction?.isInvalid, + service.setContent, + ], ) const renderPlayer = useCallback( @@ -713,14 +531,14 @@ function EditorPage({ const doDeleteAction = useCallback( (_: Action, idx: number, origin: TacticComponent) => { - 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, @@ -734,7 +552,7 @@ function EditorPage({ ), ) }, - [setContent], + [service], ) const renderComponent = useCallback( @@ -749,7 +567,7 @@ function EditorPage({ ball={component} onPosValidated={doMoveBall} onRemove={() => { - setContent((content) => removeBall(content)) + service.setContent((content) => removeBall(content)) setObjects((objects) => [ ...objects, { key: "ball" }, @@ -760,7 +578,7 @@ function EditorPage({ } throw new Error("unknown tactic component " + component) }, - [renderPlayer, doMoveBall, setContent], + [service, renderPlayer, doMoveBall], ) const renderActions = useCallback( @@ -782,7 +600,7 @@ function EditorPage({ /> ) }), - [courtRef, doDeleteAction, doUpdateAction, editorContentCurtainWidth], + [courtRef, doDeleteAction, doUpdateAction], ) const contentNode = ( @@ -809,7 +627,7 @@ function EditorPage({ )} onElementDetached={useCallback( (r, e: RackedCourtObject) => - setContent((content) => + service.setContent((content) => placeObjectAt( r.getBoundingClientRect(), courtBounds(), @@ -817,7 +635,7 @@ function EditorPage({ content, ), ), - [courtBounds, setContent], + [courtBounds, service], )} render={renderCourtObject} /> @@ -846,41 +664,32 @@ function EditorPage({ const stepsTreeNode = ( { - const addedNode = await onAddStep( + const addedNode = await service.addStep( parent, computeTerminalState(content, relativePositions), ) - if (addedNode == null) { - console.error( - "could not add step : onAddStep returned null node", - ) + if (typeof addedNode === "string") { + console.error("could not add step : " + addedNode) return } - selectStep(addedNode.id) - setRootStepsNode((root) => - addStepNode(root, parent, addedNode), - ) + await service.selectStep(addedNode.id) }, - [content, onAddStep, selectStep, relativePositions], + [service, content, relativePositions], )} onRemoveNode={useCallback( async (removed) => { - const isOk = await onRemoveStep(removed) - selectStep(getParent(rootStepsNode, removed)!.id) - if (isOk) - setRootStepsNode( - (root) => removeStepNode(root, removed)!, - ) + await service.removeStep(removed.id) + await service.selectStep(getParent(stepsTree, removed)!.id) }, - [rootStepsNode, onRemoveStep, selectStep], + [service, stepsTree], )} onStepSelected={useCallback( - (node) => selectStep(node.id), - [selectStep], + (node) => service.selectStep(node.id), + [service], )} /> ) @@ -889,7 +698,7 @@ function EditorPage({
- +
{ - onNameChange(new_name).then((success) => { - setTitleStyle(success ? {} : ERROR_STYLE) + service.setName(new_name).then((state) => { + setTitleStyle( + state == SaveStates.Ok + ? {} + : ERROR_STYLE, + ) }) }, - [onNameChange], + [service], )} />
@@ -915,12 +728,12 @@ function EditorPage({
{isStepsTreeVisible ? ( - {contentNode} {stepsTreeNode} - + ) : ( contentNode )} 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/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 38b7c2b..5ce1a38 100644 --- a/src/style/editor.css +++ b/src/style/editor.css @@ -133,7 +133,6 @@ height: 100%; width: 100%; user-select: none; - -webkit-user-drag: none; } #court-image * { @@ -154,8 +153,6 @@ .save-state, #show-steps-button { user-select: none; - -moz-user-select: none; - -ms-user-select: none; } .save-state-error { diff --git a/test/api/tactics.test.ts b/test/api/tactics.test.ts deleted file mode 100644 index 16f5ac8..0000000 --- a/test/api/tactics.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { beforeAll, expect, test } from "vitest" -import { fetchAPI } from "../../src/Fetcher" -import { saveSession } from "../../src/api/session" - -async function login() { - const response = await fetchAPI("auth/token/", { - email: "maxime@mail.com", - password: "123456", - }) - expect(response.status).toBe(200) - const { token, expirationDate } = await response.json() - saveSession({ auth: { token, expirationDate: Date.parse(expirationDate) } }) -} - -beforeAll(login) - -test("create tactic", async () => { - await login() - const response = await fetchAPI("tactics", { - courtType: "PLAIN", - name: "test tactic", - }) - expect(response.status).toBe(200) -}) - -test("spam step creation test", async () => { - const createTacticResponse = await fetchAPI("tactics", { - courtType: "PLAIN", - name: "test tactic", - }) - expect(createTacticResponse.status).toBe(200) - const { id } = await createTacticResponse.json() - - const tasks = Array.from({ length: 200 }).map(async () => { - const response = await fetchAPI(`tactics/${id}/steps`, { - parentId: 1, - content: { components: [] }, - }) - expect(response.status).toBe(200) - const { stepId } = await response.json() - return stepId - }) - - const steps = [] - for (const task of tasks) { - steps.push(await task) - } - steps.sort((a, b) => a - b) - const expected = Array.from({ length: 200 }, (_, i) => i + 2) - expect(steps).toEqual(expected) -})