import { CSSProperties, Dispatch, SetStateAction, useCallback, useRef, useState, } from "react" import "../style/editor.css" import TitleInput from "../components/TitleInput" import { BasketCourt } from "../components/editor/BasketCourt" import plainCourt from "../assets/court/full_court.svg" import halfCourt from "../assets/court/half_court.svg" import { BallPiece } from "../components/editor/BallPiece" import { Rack } from "../components/Rack" import { PlayerPiece } from "../components/editor/PlayerPiece" import { Player } from "../tactic/Player" import { Tactic, TacticContent } from "../tactic/Tactic" import { fetchAPI } from "../Fetcher" import { Team } from "../tactic/Team" import { calculateRatio } from "../Utils" import SavingState, { SaveState, SaveStates, } from "../components/editor/SavingState" import {CourtObject} from "../tactic/CourtObjects"; const ERROR_STYLE: CSSProperties = { borderColor: "red", } const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content" const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" export interface EditorViewProps { tactic: Tactic onContentChange: (tactic: TacticContent) => Promise onNameChange: (name: string) => Promise courtType: "PLAIN" | "HALF" } export interface EditorProps { id: number name: string content: string courtType: "PLAIN" | "HALF" } /** * information about a player that is into a rack */ interface RackedPlayer { team: Team key: string } type RackedCourtObject = { key: "ball" } export default 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(`tactic/${id}/save`, { content }).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(`tactic/${id}/edit/name`, { name }).then( (r) => r.ok, ) }} courtType={courtType} /> ) } function EditorView({ tactic: { id, name, content: initialContent }, onContentChange, onNameChange, courtType, }: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) const [content, setContent, saveState] = useContentState( initialContent, isInGuestMode ? SaveStates.Guest : SaveStates.Ok, onContentChange, ) const [allies, setAllies] = useState( getRackPlayers(Team.Allies, content.players), ) const [opponents, setOpponents] = useState( getRackPlayers(Team.Opponents, content.players), ) const [objects, setObjects] = useState( isBallOnCourt(content) ? [] : [{ key: "ball" }], ) const courtDivContentRef = useRef(null) const isBoundsOnCourt = (bounds: DOMRect) => { const courtBounds = courtDivContentRef.current!.getBoundingClientRect() // check if refBounds overlaps courtBounds return !( bounds.top > courtBounds.bottom || bounds.right < courtBounds.left || bounds.bottom < courtBounds.top || bounds.left > courtBounds.right ) } const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => { const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const { x, y } = calculateRatio(refBounds, courtBounds) setContent((content) => { return { ...content, players: [ ...content.players, { id: "player-" + content.players.length, team: element.team, role: element.key, rightRatio: x, bottomRatio: y, hasBall: false, }, ], } }) } const onObjectDetach = ( ref: HTMLDivElement, rackedObject: RackedCourtObject, ) => { const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const { x, y } = calculateRatio(refBounds, courtBounds) let courtObject: CourtObject switch (rackedObject.key) { case "ball": const ballObj = content.objects.findIndex( (o) => o.type == "ball", ) const playerCollidedIdx = getPlayerCollided( refBounds, content.players, ) if (playerCollidedIdx != -1) { onBallDropOnPlayer(playerCollidedIdx) setContent((content) => { return { ...content, objects: content.objects.toSpliced(ballObj, 1), } }) return } else { courtObject = { type: "ball", rightRatio: x, bottomRatio: y, } } break default: throw new Error("unknown court object ", rackedObject.key) } setContent((content) => { return { ...content, objects: [...content.objects, courtObject], } }) } const getPlayerCollided = ( bounds: DOMRect, players: Player[], ): number | -1 => { for (let i = 0; i < players.length; i++) { const player = players[i] const playerBounds = document .getElementById(player.id)! .getBoundingClientRect() const doesOverlap = !( bounds.top > playerBounds.bottom || bounds.right < playerBounds.left || bounds.bottom < playerBounds.top || bounds.left > playerBounds.right ) if (doesOverlap) { return i } } return -1 } const onBallDropOnPlayer = (playerCollidedIdx: number) => { setContent((content) => { const ballObj = content.objects.findIndex((o) => o.type == "ball") let player = content.players.at(playerCollidedIdx) as Player return { ...content, players: content.players.toSpliced(playerCollidedIdx, 1, { ...player, hasBall: true, }), objects: content.objects.toSpliced(ballObj, 1), } }) } const onBallDrop = (refBounds: DOMRect) => { if (!isBoundsOnCourt(refBounds)) { setContent((content) => { const ballObj = content.objects.findIndex( (o) => o.type == "ball", ) return { ...content, objects: content.objects.toSpliced(ballObj, 1), } }) setObjects([{ key: "ball" }]) } const playerCollidedIdx = getPlayerCollided(refBounds, content.players) if (playerCollidedIdx != -1) { setContent((content) => { return { ...content, players: content.players.map((player) => ({ ...player, hasBall: false, })), } }) onBallDropOnPlayer(playerCollidedIdx) return } if (content.objects.findIndex((o) => o.type == "ball") != -1) { return } const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const { x, y } = calculateRatio(refBounds, courtBounds) let courtObject: CourtObject courtObject = { type: "ball", rightRatio: x, bottomRatio: y, } setContent((content) => { return { ...content, players: content.players.map((player) => ({ ...player, hasBall: false, })), objects: [...content.objects, courtObject], } }) } return (
{ onNameChange(new_name).then((success) => { setTitleStyle(success ? {} : ERROR_STYLE) }) }} />
isBoundsOnCourt(div.getBoundingClientRect()) } onElementDetached={onPieceDetach} render={({ team, key }) => ( )} /> isBoundsOnCourt(div.getBoundingClientRect()) } onElementDetached={onObjectDetach} render={renderCourtObject} /> isBoundsOnCourt(div.getBoundingClientRect()) } onElementDetached={onPieceDetach} render={({ team, key }) => ( )} />
{ setContent((content) => ({ ...content, players: toSplicedPlayers( content.players, player, true, ), })) }} onPlayerRemove={(player) => { setContent((content) => ({ ...content, players: toSplicedPlayers( content.players, player, false, ), objects: [...content.objects], })) let setter switch (player.team) { case Team.Opponents: setter = setOpponents break case Team.Allies: setter = setAllies } if (player.hasBall) { setObjects([{ key: "ball" }]) } setter((players) => [ ...players, { team: player.team, pos: player.role, key: player.role, }, ]) }} onBallRemove={() => { setContent((content) => { const ballObj = content.objects.findIndex( (o) => o.type == "ball", ) return { ...content, objects: content.objects.toSpliced( ballObj, 1, ), } }) setObjects([{ key: "ball" }]) }} />
) } function isBallOnCourt(content: TacticContent) { if (content.players.findIndex((p) => p.hasBall) != -1) { return true } return content.objects.findIndex((o) => o.type == "ball") != -1 } function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { return } throw new Error("unknown racked court object ", courtObject.key) } function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] { return ["1", "2", "3", "4", "5"] .filter( (role) => players.findIndex((p) => p.team == team && p.role == role) == -1, ) .map((key) => ({ team, key })) } 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] } function toSplicedPlayers( players: Player[], player: Player, replace: boolean, ): Player[] { const idx = players.findIndex( (p) => p.team === player.team && p.role === player.role, ) return players.toSpliced(idx, 1, ...(replace ? [player] : [])) }