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; }