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 { CourtType, Tactic, TacticComponent, TacticContent } 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 { 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 from "../components/editor/CourtPlayer" 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 BallAction from "../components/actions/BallAction" import { changePlayerBallState, getOrigin, removePlayer } from "../editor/PlayerDomains" import { CourtBall } from "../components/editor/CourtBall" import { useParams } from "react-router-dom" const ERROR_STYLE: CSSProperties = { borderColor: "red", } const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content" const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" const DEFAULT_TACTIC_NAME = "Nouvelle tactique" export interface EditorViewProps { tactic: Tactic onContentChange: (tactic: TacticContent) => Promise onNameChange: (name: string) => Promise } type EditorCreateNewAction = { type: "new", courtType: CourtType } type EditorOpenAction = { type: "open" } type EditorAction = EditorCreateNewAction | EditorOpenAction export interface EditorPageProps { action: EditorAction } export default function EditorPage({ action }: EditorPageProps) { console.log(action) if (action.type === "new") { return } return } interface TacticDto { id: number name: string courtType: CourtType content: string } function EditorOpen() { const { tacticId: idStr } = useParams() const id = parseInt(idStr!) const [tactic, setTactic] = useState() useEffect(() => { async function initialize() { console.log("initializing") const infoResponse = fetchAPIGet(`tactics/${id}`) const contentResponse = fetchAPIGet(`tactics/${id}/1`) const { name, courtType } = await (await infoResponse).json() const { content } = await (await contentResponse).json() setTactic({ id, name, courtType, content }) } initialize() }, [id]) if (tactic) { return } return } function EditorCreateNew({ courtType }: EditorCreateNewAction) { const [id, setId] = useState() useEffect(() => { async function initialize() { const response = await fetchAPI("tactics", { name: DEFAULT_TACTIC_NAME, courtType }, "POST") const { id } = await response.json() setId(id) } initialize() }, [courtType]) if (id) { return } return } function EditorLoadingScreen() { return
Loading Editor, please wait...
} export interface EditorProps { id: number name: string content: string courtType: CourtType } function Editor({ id, name, courtType, content }: EditorProps) { const isInGuestMode = id == -1 const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) const editorContent = isInGuestMode && storage_content != null ? storage_content : content const storage_name = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) const editorName = isInGuestMode && storage_name != null ? storage_name : name return ( { if (isInGuestMode) { localStorage.setItem( GUEST_MODE_CONTENT_STORAGE_KEY, JSON.stringify(content), ) return SaveStates.Guest } return fetchAPI(`tactics/${id}/1`, { content }, "PUT").then((r) => r.ok ? SaveStates.Ok : SaveStates.Err, ) }} onNameChange={async (name: string) => { if (isInGuestMode) { localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) return true //simulate that the name has been changed } return fetchAPI(`tactics/${id}/name`, { name }, "PUT").then( (r) => r.ok, ) }} /> ) } function EditorView({ tactic: { id, name, content: initialContent, courtType }, onContentChange, onNameChange, }: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) const [content, setContent, saveState] = useContentState( initialContent, isInGuestMode ? SaveStates.Guest : SaveStates.Ok, useMemo(() => debounceAsync(onContentChange), [onContentChange]), ) const [allies, setAllies] = useState(() => getRackPlayers(PlayerTeam.Allies, content.components), ) const [opponents, setOpponents] = useState(() => getRackPlayers(PlayerTeam.Opponents, content.components), ) const [objects, setObjects] = useState(() => isBallOnCourt(content) ? [] : [{ key: "ball" }], ) const [previewAction, setPreviewAction] = useState( null, ) const courtRef = useRef(null) const setComponents = (action: SetStateAction) => { setContent((c) => ({ ...c, components: typeof action == "function" ? action(c.components) : action, })) } const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], ) useEffect(() => { setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) }, [setObjects, content]) const insertRackedPlayer = (player: Player) => { let setter switch (player.team) { case PlayerTeam.Opponents: setter = setOpponents break case PlayerTeam.Allies: setter = setAllies } if (player.ballState == BallState.HOLDS_BY_PASS) { setObjects([{ key: "ball" }]) } setter((players) => [ ...players, { team: player.team, pos: player.role, key: player.role, }, ]) } const doRemovePlayer = useCallback( (component: PlayerLike) => { setContent((c) => removePlayer(component, c)) if (component.type == "player") insertRackedPlayer(component) }, [setContent], ) const doMoveBall = useCallback( (newBounds: DOMRect, from?: PlayerLike) => { setContent((content) => { if (from) { content = changePlayerBallState( from, BallState.NONE, 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 if (player.type == "player") { canPlaceArrows = player.path == null || player.actions.findIndex( (p) => p.type != ActionKind.SHOOT, ) == -1 } 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 && ( ), (info.ballState === BallState.HOLDS_ORIGIN || info.ballState === BallState.PASSED_ORIGIN) && ( { doMoveBall(ballBounds, player) }} /> ), ] }, [content, 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, bottomRatio: component.bottomRatio, rightRatio: component.rightRatio, ballState: component.ballState, } } else { info = component } return ( validatePlayerPosition(component, info, newPos) } onRemove={() => doRemovePlayer(component)} courtRef={courtRef} availableActions={() => renderAvailablePlayerActions(info, component) } /> ) }, [ content.components, doRemovePlayer, renderAvailablePlayerActions, validatePlayerPosition, ], ) const doDeleteAction = useCallback( (action: Action, idx: number, origin: TacticComponent) => { setContent((content) => removeAction(origin, action, 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) } /> ) }), [doDeleteAction, doUpdateAction], ) return (
{ onNameChange(new_name).then((success) => { setTitleStyle(success ? {} : ERROR_STYLE) }) }, [onNameChange], )} />
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} />
) } 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: TacticContent 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: TacticContent) { 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, saveStateCallback: (s: S) => Promise, ): [S, Dispatch>, SaveState] { const [content, setContent] = useState(initialContent) const [savingState, setSavingState] = useState(initialSaveState) const setContentSynced = useCallback( (newState: SetStateAction) => { setContent((content) => { const state = typeof newState === "function" ? (newState as (state: S) => S)(content) : newState if (state !== content) { setSavingState(SaveStates.Saving) saveStateCallback(state) .then(setSavingState) .catch(() => setSavingState(SaveStates.Err)) } return state }) }, [saveStateCallback], ) return [content, setContentSynced, savingState] }