import { CSSProperties, Dispatch, RefObject, SetStateAction, useCallback, useEffect, useMemo, useRef, useState, } from "react" import "../style/editor.css" 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 { Rack } from "../components/Rack" import { PlayerPiece } from "../components/editor/PlayerPiece" 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 { BALL_TYPE } from "../model/tactic/CourtObjects" import { CourtAction } from "../components/editor/CourtAction" import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" import { overlaps } from "../geo/Box" import { computeTerminalState, drainTerminalStateOnChildContent, dropBallOnComponent, getComponentCollided, getRackPlayers, moveComponent, placeBallAt, placeObjectAt, placePlayerAt, removeBall, updateComponent, } from "../editor/TacticContentDomains" 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 { createAction, getActionKind, isActionValid, removeAction, spreadNewStateFromOriginStateChange, } from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" import { Action, ActionKind } from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" import { 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 CurtainLayout from "../components/CurtainLayout" 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 = { content: StepContent relativePositions: ComputedRelativePositions } interface TacticDto { id: number name: string courtType: CourtType } export interface EditorPageProps { guestMode: boolean } 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)!, ) // 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), ) } const tacticName = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? "Nouvelle Tactique" 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, )!, ) 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), [stepId], ), ) function getStepContent(step: number): StepContent { return JSON.parse( localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!, ) } 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 }, [])} /> ) } 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() 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", ) 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 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/${id}`, { content }, "PUT", ) if (!response.ok) { throw new Error( "Error when updated new children content", ) } }, ) return response.ok ? SaveStates.Ok : SaveStates.Err }, [tacticId, stepId, stepsTree], ) const [stepContent, setStepContent, saveState] = useContentState( { components: [] }, SaveStates.Ok, 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 { name, courtType } = await infoResponse.json() const { root } = await treeResponse.json() if (infoResponse.status == 401 || treeResponse.status == 401) { navigation("/login") return } const contentResponsePromise = fetchAPIGet( `tactics/${tacticId}/steps/${root.id}`, ) const contentResponse = await contentResponsePromise if (contentResponse.status == 401) { navigation("/login") return } const content = await contentResponse.json() setTactic({ id: tacticId, name, courtType }) setStepsTree(root) setStepId(root.id) setStepContent(content, 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], ) 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 (!tactic) return 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> courtRef: RefObject selectStep: (stepId: number) => void onNameChange: (name: string) => Promise onRemoveStep: (step: StepInfoNode) => Promise onAddStep: ( parent: StepInfoNode, content: StepContent, ) => Promise } function EditorPage({ 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) const allies = getRackPlayers(PlayerTeam.Allies, content.components) const opponents = getRackPlayers(PlayerTeam.Opponents, content.components) const [objects, setObjects] = useState(() => isBallOnCourt(content) ? [] : [{ key: "ball" }], ) const [previewAction, setPreviewAction] = useState( null, ) const [isStepsTreeVisible, setStepsTreeVisible] = useState(false) const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], ) const [editorContentCurtainWidth, setEditorContentCurtainWidth] = useState(80) const relativePositions = useMemo(() => { const courtBounds = courtRef.current?.getBoundingClientRect() return courtBounds ? computeRelativePositions(courtBounds, content) : new Map() }, [content, courtRef]) const setComponents = (action: SetStateAction) => { setContent((c) => ({ ...c, components: typeof action == "function" ? action(c.components) : action, })) } useEffect(() => { setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) }, [setObjects, content]) const insertRackedPlayer = (player: Player) => { if (player.ballState == BallState.HOLDS_BY_PASS) { setObjects([{ key: "ball" }]) } } const doRemovePlayer = useCallback( (component: PlayerLike) => { setContent((c) => removePlayer(component, c)) if (component.type == "player") insertRackedPlayer(component) }, [setContent], ) const doMoveBall = useCallback( (newBounds: DOMRect, from?: PlayerLike) => { setContent((content) => { if (from) { content = spreadNewStateFromOriginStateChange( from, BallState.NONE, content, ) ?? content } content = placeBallAt(newBounds, courtBounds(), content) return content }) }, [courtBounds, setContent], ) const validatePlayerPosition = useCallback( (player: PlayerLike, info: PlayerInfo, newPos: Pos) => { setContent((content) => moveComponent( newPos, player, info, courtBounds(), content, (content) => { if (player.type === "player") insertRackedPlayer(player) return removePlayer(player, content) }, ), ) }, [courtBounds, setContent], ) const renderAvailablePlayerActions = useCallback( (info: PlayerInfo, player: PlayerLike) => { let canPlaceArrows: boolean let isFrozen: boolean = false if (player.type == "player") { canPlaceArrows = player.path == null || player.actions.findIndex( (p) => p.type != ActionKind.SHOOT, ) == -1 isFrozen = player.frozen } else { const origin = getOrigin(player, content.components) const path = origin.path! // phantoms can only place other arrows if they are the head of the path canPlaceArrows = path.items.indexOf(player.id) == path.items.length - 1 if (canPlaceArrows) { // and if their only action is to shoot the ball const phantomActions = player.actions canPlaceArrows = phantomActions.length == 0 || phantomActions.findIndex( (c) => c.type != ActionKind.SHOOT, ) == -1 } } return [ canPlaceArrows && ( ), !isFrozen && (info.ballState === BallState.HOLDS_ORIGIN || info.ballState === BallState.PASSED_ORIGIN) && ( { doMoveBall(ballBounds, player) }} /> ), ] }, [content, courtRef, doMoveBall, previewAction?.isInvalid, setContent], ) const renderPlayer = useCallback( (component: PlayerLike) => { let info: PlayerInfo const isPhantom = component.type == "phantom" if (isPhantom) { const origin = getOrigin(component, content.components) info = { id: component.id, team: origin.team, role: origin.role, pos: computePhantomPositioning( component, content, relativePositions, courtBounds(), ), ballState: component.ballState, } } else { info = component if (component.frozen) { return ( renderAvailablePlayerActions(info, component) } /> ) } } return ( validatePlayerPosition(component, info, newPos) } onRemove={() => doRemovePlayer(component)} courtRef={courtRef} availableActions={() => renderAvailablePlayerActions(info, component) } /> ) }, [ courtRef, content, relativePositions, courtBounds, renderAvailablePlayerActions, validatePlayerPosition, doRemovePlayer, ], ) const doDeleteAction = useCallback( (_: Action, idx: number, origin: TacticComponent) => { setContent((content) => removeAction(origin, idx, content)) }, [setContent], ) const doUpdateAction = useCallback( (component: TacticComponent, action: Action, actionIndex: number) => { setContent((content) => updateComponent( { ...component, actions: component.actions.toSpliced( actionIndex, 1, action, ), }, content, ), ) }, [setContent], ) const renderComponent = useCallback( (component: TacticComponent) => { if (component.type === "player" || component.type === "phantom") { return renderPlayer(component) } if (component.type === BALL_TYPE) { return ( { setContent((content) => removeBall(content)) setObjects((objects) => [ ...objects, { key: "ball" }, ]) }} /> ) } throw new Error("unknown tactic component " + component) }, [renderPlayer, doMoveBall, setContent], ) const renderActions = useCallback( (component: TacticComponent) => component.actions.map((action, i) => { return ( { doDeleteAction(action, i, component) }} onActionChanges={(action) => doUpdateAction(component, action, i) } renderDependency={editorContentCurtainWidth} /> ) }), [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 (
{ onNameChange(new_name).then((success) => { setTitleStyle(success ? {} : ERROR_STYLE) }) }, [onNameChange], )} />
{isStepsTreeVisible ? ( {contentNode} {stepsTreeNode} ) : ( contentNode )}
) } interface EditorStepsTreeProps { selectedStepId: number root: StepInfoNode onAddChildren: (parent: StepInfoNode) => void onRemoveNode: (node: StepInfoNode) => void onStepSelected: (node: StepInfoNode) => void } function EditorStepsTree({ selectedStepId, root, onAddChildren, onRemoveNode, onStepSelected, }: EditorStepsTreeProps) { return (
) } interface PlayerRackProps { id: string objects: RackedPlayer[] setObjects?: (state: RackedPlayer[]) => void setComponents: ( f: (components: TacticComponent[]) => TacticComponent[], ) => void courtRef: RefObject } function PlayerRack({ id, objects, setObjects, courtRef, setComponents, }: PlayerRackProps) { const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], ) return ( overlaps(courtBounds(), div.getBoundingClientRect()), [courtBounds], )} onElementDetached={useCallback( (r, e: RackedPlayer) => setComponents((components) => [ ...components, placePlayerAt( r.getBoundingClientRect(), courtBounds(), e, ), ]), [courtBounds, setComponents], )} render={useCallback( ({ team, key }: { team: PlayerTeam; key: string }) => ( ), [], )} /> ) } interface CourtPlayerArrowActionProps { playerInfo: PlayerInfo player: PlayerLike isInvalid: boolean content: StepContent setContent: (state: SetStateAction) => void setPreviewAction: (state: SetStateAction) => void courtRef: RefObject } function CourtPlayerArrowAction({ playerInfo, player, isInvalid, content, setContent, setPreviewAction, courtRef, }: CourtPlayerArrowActionProps) { const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], ) return ( { const arrowHeadPos = middlePos(headPos) const targetIdx = getComponentCollided( headPos, content.components, ) const target = content.components[targetIdx] setPreviewAction((action) => ({ ...action!, segments: [ { next: ratioWithinBase(arrowHeadPos, courtBounds()), }, ], type: getActionKind(target, playerInfo.ballState).kind, isInvalid: !overlaps(headPos, courtBounds()) || !isActionValid(player, target, content.components), })) }} onHeadPicked={(headPos) => { ;(document.activeElement as HTMLElement).blur() setPreviewAction({ origin: playerInfo.id, type: getActionKind(null, playerInfo.ballState).kind, target: ratioWithinBase(headPos, courtBounds()), segments: [ { next: ratioWithinBase( middlePos(headPos), courtBounds(), ), }, ], isInvalid: false, }) }} onHeadDropped={(headRect) => { if (isInvalid) { setPreviewAction(null) return } setContent((content) => { let { createdAction, newContent } = createAction( player, courtBounds(), headRect, content, ) if (createdAction.type == ActionKind.SHOOT) { const targetIdx = newContent.components.findIndex( (c) => c.id == createdAction.target, ) newContent = dropBallOnComponent( targetIdx, newContent, false, ) const ballState = player.ballState === BallState.HOLDS_ORIGIN ? BallState.PASSED_ORIGIN : BallState.PASSED newContent = updateComponent( { ...(newContent.components.find( (c) => c.id == player.id, )! as PlayerLike), ballState, }, newContent, ) } return newContent }) setPreviewAction(null) }} /> ) } function isBallOnCourt(content: StepContent) { return ( content.components.findIndex( (c) => ((c.type === "player" || c.type === "phantom") && (c.ballState === BallState.HOLDS_ORIGIN || c.ballState === BallState.PASSED_ORIGIN)) || c.type === BALL_TYPE, ) != -1 ) } function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { return } throw new Error("unknown racked court object " + courtObject.key) } function Court({ courtType }: { courtType: string }) { return (
{courtType == "PLAIN" ? ( ) : ( )}
) } function debounceAsync( f: (args: A) => Promise, delay = 1000, ): (args: A) => Promise { let task = 0 return (args: A) => { clearTimeout(task) return new Promise((resolve, reject) => { task = setTimeout(() => f(args).then(resolve).catch(reject), delay) }) } } function useContentState( initialContent: S, initialSaveState: SaveState, applyStateCallback: (content: S) => Promise, ): [S, (newState: SetStateAction, runCallback: boolean) => void, SaveState] { const [content, setContent] = useState(initialContent) const [savingState, setSavingState] = useState(initialSaveState) const setContentSynced = useCallback( (newState: SetStateAction, callSaveCallback: boolean) => { setContent((content) => { const state = typeof newState === "function" ? (newState as (state: S) => S)(content) : newState if (state !== content && callSaveCallback) { setSavingState(SaveStates.Saving) applyStateCallback(state) .then(setSavingState) .catch((e) => { setSavingState(SaveStates.Err) console.error(e) }) } return state }) }, [applyStateCallback], ) return [content, setContentSynced, savingState] } function computeRelativePositions(courtBounds: DOMRect, content: StepContent) { const relativePositionsCache: ComputedRelativePositions = new Map() for (const component of content.components) { if (component.type !== "phantom") continue computePhantomPositioning( component, content, relativePositionsCache, courtBounds, ) } return relativePositionsCache } async function updateStepContents( stepId: number, stepsTree: StepInfoNode, getStepContent: (stepId: number) => Promise, setStepContent: (stepId: number, content: StepContent) => Promise, ) { async function updateSteps( step: StepInfoNode, content: StepContent, relativePositions: ComputedRelativePositions, ) { const terminalStateContent = computeTerminalState( content, relativePositions, ) for (const child of step.children) { const { content: childContent, relativePositions: childRelativePositions, } = await getStepContent(child.id) const childUpdatedContent = drainTerminalStateOnChildContent( terminalStateContent, childContent, ) if (childUpdatedContent) { await setStepContent(child.id, childUpdatedContent) await updateSteps( child, childUpdatedContent, childRelativePositions, ) } } } const { content, relativePositions } = await getStepContent(stepId) const startNode = getStepNode(stepsTree!, stepId)! await updateSteps(startNode, content, relativePositions) }