import { CSSProperties, ReactNode, RefObject, SetStateAction, useCallback, useEffect, useMemo, useRef, useState, } from "react" import "../style/editor.css" import TitleInput from "../components/TitleInput" import { BallPiece } from "../components/editor/BallPiece" import { Rack } from "../components/Rack" import { PlayerPiece } from "../components/editor/PlayerPiece" import { CourtType, StepContent, StepInfoNode, TacticComponent, } from "../model/tactic/Tactic" import SavingState, { SaveState, SaveStates, } from "../components/editor/SavingState" import { BALL_TYPE } from "../model/tactic/CourtObjects" import { CourtAction } from "../components/editor/CourtAction" import { ActionPreview, BasketCourt, Court, } from "../components/editor/BasketCourt" import { overlaps } from "../geo/Box" import { computeTerminalState, drainTerminalStateOnChildContent, dropBallOnComponent, getComponentCollided, getRackPlayers, mapToParentContent, moveComponent, placeBallAt, placeObjectAt, placePlayerAt, removeBall, selectContent, updateComponent, } from "../domains/TacticContentDomains.ts" 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 "../domains/ActionsDomains.ts" 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 { ComputedRelativePositions, computeRelativePositions, getOrigin, getPhantomInfo, removePlayer, } from "../domains/PlayerDomains.ts" import { CourtBall } from "../components/editor/CourtBall" import StepsTree from "../components/editor/StepsTree" import { addStepNode, getParent, getStepNode, removeStepNode, } from "../domains/StepsDomain.ts" import SplitLayout from "../components/SplitLayout.tsx" import { MutableTacticService, ServiceError, } from "../service/MutableTacticService.ts" import { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts" import { APITacticService } from "../service/APITacticService.ts" import { useNavigate, useParams } from "react-router-dom" import { ContentVersions } from "../editor/ContentVersions.ts" import { useAppFetcher } from "../App.tsx" const ERROR_STYLE: CSSProperties = { borderColor: "red", } type ComputedStepContent = { content: StepContent relativePositions: ComputedRelativePositions } export interface EditorProps { guestMode: boolean } interface EditorService { addStep( parent: StepInfoNode, content: StepContent, ): Promise removeStep(step: number): Promise selectStep(step: number): Promise setContent(content: SetStateAction): void setName(name: string): Promise openVisualizer(): Promise } export default function Editor({ guestMode }: EditorProps) { const { tacticId: idStr } = useParams() const fetcher = useAppFetcher() const navigate = useNavigate() if (guestMode || !idStr) { return ( navigate("/tactic/view-guest")} /> ) } return ( navigate(`/tactic/${idStr}/view`)} /> ) } interface EditorPageWrapperProps { service: MutableTacticService openVisualizer(): void } function EditorPageWrapper({ service, openVisualizer, }: EditorPageWrapperProps) { const [panicMessage, setPanicMessage] = useState() const [stepId, setStepId] = useState() const [tacticName, setTacticName] = useState() const [courtType, setCourtType] = useState() const [stepsTree, setStepsTree] = useState() const [parentContent, setParentContent] = useState(null) const courtRef = useRef(null) const stepsVersions = useMemo>( () => new Map(), [], ) const saveContent = useCallback( async (content: StepContent) => { const result = await service.saveContent(stepId!, content) if (typeof result === "string") return SaveStates.Err await updateStepContents( stepId!, stepsTree!, async (id) => { const content = await service.getContent(id) if (typeof content === "string") throw new Error( "Error when retrieving children content", ) const courtBounds = courtRef.current!.getBoundingClientRect() const relativePositions = computeRelativePositions( courtBounds, content, ) if (id === stepId) { let versions = stepsVersions.get(stepId!) if (versions == undefined) { versions = new ContentVersions() stepsVersions.set(stepId!, versions) } versions.insertAndCut(content) } else { stepsVersions.delete(id) } return { content, relativePositions, } }, async (id, content) => { const result = await service.saveContent(id, content) if (typeof result === "string") throw new Error("Error when updating children content") }, ) return SaveStates.Ok }, [stepsVersions, service, stepId, stepsTree], ) const [stepContent, setStepContent, saveState] = useContentState( { components: [] }, SaveStates.Ok, useMemo(() => debounceAsync(saveContent, 250), [saveContent]), ) const isNotInit = !tacticName || !stepId || !stepsTree || !courtType useEffect(() => { const handleGlobalControls = (e: KeyboardEvent) => { if (!e.ctrlKey) return if (e.key == "z" || e.key == "y") { let versions = stepsVersions.get(stepId!) if (versions == undefined) { versions = new ContentVersions() stepsVersions.set(stepId!, versions) } const newContent = e.key == "z" ? versions.previous() : versions.next() if (newContent) { setStepContent(newContent, false) } } } document.addEventListener("keydown", handleGlobalControls) return () => document.removeEventListener("keydown", handleGlobalControls) }, [stepsVersions, setStepContent, stepId]) 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 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 versions = new ContentVersions() stepsVersions.set(stepId, versions) versions.insertAndCut(contentResult) setStepContent(contentResult, false) } if (isNotInit) init() }, [isNotInit, service, setStepContent, stepsVersions]) const editorService: EditorService = useMemo(() => { let internalStepsTree = stepsTree return { async addStep( parent: StepInfoNode, content: StepContent, ): Promise { const result = await service.addStep(parent, content) if (typeof result !== "string") { internalStepsTree = addStepNode( internalStepsTree!, parent, result, ) setStepsTree(internalStepsTree) } return result }, async removeStep(step: number): Promise { const result = await service.removeStep(step) if (typeof result !== "string") { internalStepsTree = removeStepNode(internalStepsTree!, step) setStepsTree(internalStepsTree) } stepsVersions.delete(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 const stepParent = getParent(internalStepsTree!, step)?.id if (stepParent) { const parentResult = await service.getContent(stepParent) if (typeof parentResult === "string") return parentResult setParentContent(mapToParentContent(parentResult)) } else { setParentContent(null) } setStepId(step) setStepContent(result, false) }, async openVisualizer(): Promise { openVisualizer() }, } }, [stepsTree, service, stepsVersions, setStepContent, openVisualizer]) if (panicMessage) { return

{panicMessage}

} if (isNotInit) { return

Retrieving editor context. Please wait...

} return ( ) } export interface EditorViewProps { stepsTree: StepInfoNode name: string courtType: CourtType contentSaveState: SaveState stepId: number parentContent: StepContent | null content: StepContent courtRef: RefObject service: EditorService } function EditorPage({ name, courtType, parentContent, content, stepId, contentSaveState, stepsTree, courtRef, service, }: EditorViewProps) { const [titleStyle, setTitleStyle] = useState({}) 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(true) 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) => { service.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) => { service.setContent((c) => removePlayer(component, c)) if (component.type == "player") insertRackedPlayer(component) }, [service], ) const doMoveBall = useCallback( (newBounds: DOMRect, from?: PlayerLike) => { service.setContent((content) => { if (from) { content = spreadNewStateFromOriginStateChange( from, BallState.NONE, content, ) ?? content } content = placeBallAt(newBounds, courtBounds(), content) return content }) }, [courtBounds, service], ) const validatePlayerPosition = useCallback( (player: PlayerLike, info: PlayerInfo, newPos: Pos) => { service.setContent((content) => moveComponent( newPos, player, info, courtBounds(), content, (content) => { if (player.type === "player") insertRackedPlayer(player) return removePlayer(player, content) }, ), ) }, [courtBounds, service], ) 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, selectContent(player.id, content, parentContent).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, parentContent, previewAction?.isInvalid, service.setContent, ], ) const renderPlayer = useCallback( (component: PlayerLike, isFromParent: boolean) => { let info: PlayerInfo const isPhantom = component.type == "phantom" let forceFreeze = isFromParent const usedContent = isFromParent ? parentContent! : content if (isPhantom) { info = getPhantomInfo( component, usedContent, relativePositions, courtBounds(), ) } else { info = component forceFreeze ||= component.frozen } const className = (isPhantom ? "phantom" : "player") + " " + (isFromParent ? "from-parent" : "") if (forceFreeze) { return ( renderAvailablePlayerActions(info, component) } /> ) } return ( validatePlayerPosition(component, info, newPos) } onRemove={() => doRemovePlayer(component)} courtRef={courtRef} availableActions={() => renderAvailablePlayerActions(info, component) } /> ) }, [ parentContent, content, courtRef, relativePositions, courtBounds, renderAvailablePlayerActions, validatePlayerPosition, doRemovePlayer, ], ) const doDeleteAction = useCallback( (_: Action, idx: number, origin: TacticComponent) => { service.setContent((content) => removeAction(origin, idx, content)) }, [service], ) const doUpdateAction = useCallback( (component: TacticComponent, action: Action, actionIndex: number) => { service.setContent((content) => updateComponent( { ...component, actions: component.actions.toSpliced( actionIndex, 1, action, ), }, content, ), ) }, [service], ) const renderComponent = useCallback( (component: TacticComponent, isFromParent: boolean): ReactNode => { if (component.type === "player" || component.type === "phantom") { return renderPlayer(component, isFromParent) } if (component.type === BALL_TYPE && !isFromParent) { return ( { service.setContent((content) => removeBall(content)) setObjects((objects) => [ ...objects, { key: "ball" }, ]) }} /> ) } return <> }, [service, renderPlayer, doMoveBall], ) const renderActions = useCallback( (component: TacticComponent, isFromParent: boolean) => component.actions.map((action, i) => { return ( { if (!isFromParent) doDeleteAction(action, i, component) }} onActionChanges={(action) => { if (!isFromParent) doUpdateAction(component, action, i) }} /> ) }), [courtRef, doDeleteAction, doUpdateAction], ) const contentNode = (
overlaps( courtBounds(), div.getBoundingClientRect(), ), [courtBounds], )} onElementDetached={useCallback( (r, e: RackedCourtObject) => service.setContent((content) => placeObjectAt( r.getBoundingClientRect(), courtBounds(), e, content, ), ), [courtBounds, service], )} render={renderCourtObject} />
} courtRef={courtRef} previewAction={previewAction} renderComponent={renderComponent} renderActions={renderActions} />
) const stepsTreeNode = ( { const addedNode = await service.addStep( parent, computeTerminalState(content, relativePositions), ) if (typeof addedNode === "string") { console.error("could not add step : " + addedNode) return } await service.selectStep(addedNode.id) }, [service, content, relativePositions], )} onRemoveNode={useCallback( async (removed) => { await service.removeStep(removed.id) await service.selectStep( getParent(stepsTree, removed.id)!.id, ) }, [service, stepsTree], )} onStepSelected={useCallback( (node) => service.selectStep(node.id), [service], )} /> ) return (
{ service.setName(new_name).then((state) => { setTitleStyle( state == SaveStates.Ok ? {} : ERROR_STYLE, ) }) }, [service], )} />
{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 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] } 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) }