From 72273e3f3e3ee66bc258508cd76833b6e0b64395 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Tue, 27 Feb 2024 19:20:59 +0100 Subject: [PATCH] spread changes of step content to its direct children --- src/editor/ActionsDomains.ts | 48 ++-- src/editor/PlayerDomains.ts | 66 +++-- src/editor/StepsDomain.ts | 16 +- src/editor/TacticContentDomains.ts | 118 ++++++--- src/geo/Pos.ts | 5 + src/model/tactic/Player.ts | 2 + src/pages/Editor.tsx | 404 ++++++++++++++++++++--------- 7 files changed, 456 insertions(+), 203 deletions(-) diff --git a/src/editor/ActionsDomains.ts b/src/editor/ActionsDomains.ts index 7894cce..58e1613 100644 --- a/src/editor/ActionsDomains.ts +++ b/src/editor/ActionsDomains.ts @@ -7,15 +7,14 @@ import { import { ratioWithinBase } from "../geo/Pos" import { ComponentId, - TacticComponent, StepContent, + TacticComponent, } from "../model/tactic/Tactic" import { overlaps } from "../geo/Box" import { Action, ActionKind, moves } from "../model/tactic/Action" import { removeBall, updateComponent } from "./TacticContentDomains" import { areInSamePath, - changePlayerBallState, getComponent, getOrigin, getPlayerNextTo, @@ -411,20 +410,27 @@ export function removeAction( (origin.type === "player" || origin.type === "phantom") ) { if (target.type === "player" || target.type === "phantom") - content = changePlayerBallState(target, BallState.NONE, content) + content = + spreadNewStateFromOriginStateChange( + target, + BallState.NONE, + content, + ) ?? content if (origin.ballState === BallState.PASSED) { - content = changePlayerBallState( - origin, - BallState.HOLDS_BY_PASS, - content, - ) + content = + spreadNewStateFromOriginStateChange( + origin, + BallState.HOLDS_BY_PASS, + content, + ) ?? content } else if (origin.ballState === BallState.PASSED_ORIGIN) { - content = changePlayerBallState( - origin, - BallState.HOLDS_ORIGIN, - content, - ) + content = + spreadNewStateFromOriginStateChange( + origin, + BallState.HOLDS_ORIGIN, + content, + ) ?? content } } @@ -456,6 +462,7 @@ export function removeAction( /** * Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with * the given newState. + * @returns the new state if it has been updated, or null if no changes were operated * @param origin * @param newState * @param content @@ -464,9 +471,9 @@ export function spreadNewStateFromOriginStateChange( origin: PlayerLike, newState: BallState, content: StepContent, -): StepContent { +): StepContent | null { if (origin.ballState === newState) { - return content + return null } origin = { @@ -552,11 +559,12 @@ export function spreadNewStateFromOriginStateChange( content = updateComponent(origin, content) } - content = spreadNewStateFromOriginStateChange( - actionTarget, - targetState, - content, - ) + content = + spreadNewStateFromOriginStateChange( + actionTarget, + targetState, + content, + ) ?? content } return content diff --git a/src/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts index 0a59847..fd5c914 100644 --- a/src/editor/PlayerDomains.ts +++ b/src/editor/PlayerDomains.ts @@ -55,11 +55,23 @@ export function getPlayerNextTo( : getComponent(pathItems[targetIdx - 1], components) } -//FIXME this function can be a bottleneck if the phantom's position is -// following another phantom and / or the origin of the phantom is another +export function getPrecomputedPosition( + phantom: PlayerPhantom, + computedPositions: Map, +): Pos | undefined { + const positioning = phantom.pos + + // If the position is already known and fixed, return the pos + if (positioning.type === "fixed") return positioning + + return computedPositions.get(phantom.id) +} + + export function computePhantomPositioning( phantom: PlayerPhantom, content: StepContent, + computedPositions: Map, area: DOMRect, ): Pos { const positioning = phantom.pos @@ -67,6 +79,9 @@ export function computePhantomPositioning( // If the position is already known and fixed, return the pos if (positioning.type === "fixed") return positioning + const storedPos = computedPositions.get(phantom.id) + if (storedPos) return storedPos + // If the position is to determine (positioning.type = "follows"), determine the phantom's pos // by calculating it from the referent position, and the action that targets the referent. @@ -77,7 +92,12 @@ export function computePhantomPositioning( const referentPos = referent.type === "player" ? referent.pos - : computePhantomPositioning(referent, content, area) + : computePhantomPositioning( + referent, + content, + computedPositions, + area, + ) // Get the origin const origin = getOrigin(phantom, components) @@ -109,10 +129,11 @@ export function computePhantomPositioning( pivotPoint = playerBeforePhantom.type === "phantom" ? computePhantomPositioning( - playerBeforePhantom, - content, - area, - ) + playerBeforePhantom, + content, + computedPositions, + area, + ) : playerBeforePhantom.pos } } @@ -126,14 +147,23 @@ export function computePhantomPositioning( }) const segmentProjectionRatio: Pos = ratioWithinBase(segmentProjection, area) - return add(referentPos, segmentProjectionRatio) + const result = add(referentPos, segmentProjectionRatio) + computedPositions.set(phantom.id, result) + return result } export function getComponent( id: string, components: TacticComponent[], ): T { - return components.find((c) => c.id === id)! as T + return tryGetComponent(id, components)! +} + +export function tryGetComponent( + id: string, + components: TacticComponent[], +): T | undefined { + return components.find((c) => c.id === id) as T } export function areInSamePath(a: PlayerLike, b: PlayerLike) { @@ -254,10 +284,12 @@ export function removePlayer( const actionTarget = content.components.find( (c) => c.id === action.target, )! as PlayerLike - return spreadNewStateFromOriginStateChange( - actionTarget, - BallState.NONE, - content, + return ( + spreadNewStateFromOriginStateChange( + actionTarget, + BallState.NONE, + content, + ) ?? content ) } @@ -297,11 +329,3 @@ export function truncatePlayerPath( content, ) } - -export function changePlayerBallState( - player: PlayerLike, - newState: BallState, - content: StepContent, -): StepContent { - return spreadNewStateFromOriginStateChange(player, newState, content) -} diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts index bb92dd4..88904e0 100644 --- a/src/editor/StepsDomain.ts +++ b/src/editor/StepsDomain.ts @@ -18,12 +18,24 @@ export function addStepNode( } } +export function getStepNode( + root: StepInfoNode, + stepId: number, +): StepInfoNode | undefined { + if (root.id === stepId) return root + + for (const child of root.children) { + const result = getStepNode(child, stepId) + if (result) return result + } +} + export function removeStepNode( root: StepInfoNode, node: StepInfoNode, -): StepInfoNode | null { +): StepInfoNode | undefined { if (root.id === node.id) { - return null + return undefined } return { diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index c6ac64f..8eeacd9 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -1,4 +1,4 @@ -import { Pos, ratioWithinBase } from "../geo/Pos" +import { equals, Pos, ratioWithinBase } from "../geo/Pos" import { BallState, @@ -23,12 +23,13 @@ import { import { overlaps } from "../geo/Box" import { RackedCourtObject, RackedPlayer } from "./RackedItems" import { - changePlayerBallState, - computePhantomPositioning, getComponent, getOrigin, + getPrecomputedPosition, + tryGetComponent, } from "./PlayerDomains" import { ActionKind } from "../model/tactic/Action.ts" +import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts" export function placePlayerAt( refBounds: DOMRect, @@ -103,7 +104,9 @@ export function dropBallOnComponent( ? BallState.HOLDS_ORIGIN : BallState.HOLDS_BY_PASS - content = changePlayerBallState(component, newState, content) + content = + spreadNewStateFromOriginStateChange(component, newState, content) ?? + content } return removeBall(content) @@ -194,9 +197,9 @@ export function moveComponent( phantomIdx == 0 ? origin : getComponent( - originPathItems[phantomIdx - 1], - content.components, - ) + originPathItems[phantomIdx - 1], + content.components, + ) // detach the action from the screen target and transform it to a regular move action to the phantom. content = updateComponent( { @@ -204,18 +207,18 @@ export function moveComponent( actions: playerBeforePhantom.actions.map((a) => a.target === referent ? { - ...a, - segments: a.segments.toSpliced( - a.segments.length - 2, - 1, - { - ...a.segments[a.segments.length - 1], - next: component.id, - }, - ), - target: component.id, - type: ActionKind.MOVE, - } + ...a, + segments: a.segments.toSpliced( + a.segments.length - 2, + 1, + { + ...a.segments[a.segments.length - 1], + next: component.id, + }, + ), + target: component.id, + type: ActionKind.MOVE, + } : a, ), }, @@ -228,9 +231,9 @@ export function moveComponent( ...component, pos: isPhantom ? { - type: "fixed", - ...newPos, - } + type: "fixed", + ...newPos, + } : newPos, }, content, @@ -299,21 +302,21 @@ export function getRackPlayers( /** * Returns a step content that only contains the terminal state of each components inside the given content * @param content - * @param courtArea + * @param computedPositions */ -export function getTerminalState( +export function computeTerminalState( content: StepContent, - courtArea: DOMRect, + computedPositions: Map, ): 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) + ? getPlayerTerminalState(comp, content, computedPositions) : comp, ) @@ -325,7 +328,7 @@ export function getTerminalState( function getPlayerTerminalState( player: Player, content: StepContent, - area: DOMRect, + computedPositions: Map, ): Player { function stateAfter(state: BallState): BallState { switch (state) { @@ -342,9 +345,15 @@ function getPlayerTerminalState( } function getTerminalPos(component: PlayerLike): Pos { - return component.type === "phantom" - ? computePhantomPositioning(component, content, area) - : component.pos + if (component.type === "phantom") { + const pos = getPrecomputedPosition(component, computedPositions) + if (!pos) + throw new Error( + `Attempted to get the terminal state of a step content with missing position for phantom ${component.id}`, + ) + return pos + } + return component.pos } const phantoms = player.path?.items @@ -377,3 +386,50 @@ function getPlayerTerminalState( pos, } } + +export function drainTerminalStateOnChildContent( + parentTerminalState: StepContent, + childContent: StepContent, +): StepContent | null { + let gotUpdated = false + + for (const parentComponent of parentTerminalState.components) { + const childComponent = tryGetComponent( + parentComponent.id, + childContent.components, + ) + + if (!childComponent) { + //if the child does not contain the parent's component, add it to the children's content. + childContent = { + ...childContent, + components: [...childContent.components, parentComponent], + } + gotUpdated = true + continue + } + + // ensure that the component is a player + if (parentComponent.type !== "player" || childComponent.type !== "player") continue + + const newContentResult = spreadNewStateFromOriginStateChange( + childComponent, + parentComponent.ballState, + childContent, + ) + if (newContentResult) { + gotUpdated = true + childContent = newContentResult + } + // also update the position of the player if it has been moved + if (!equals(childComponent.pos, parentComponent.pos)) { + gotUpdated = true + childContent = updateComponent({ + ...childComponent, + pos: parentComponent.pos, + }, childContent) + } + } + + return gotUpdated ? childContent : null +} diff --git a/src/geo/Pos.ts b/src/geo/Pos.ts index be7a704..d3d7337 100644 --- a/src/geo/Pos.ts +++ b/src/geo/Pos.ts @@ -3,6 +3,11 @@ export interface Pos { y: number } +export function equals(a: Pos, b: Pos): boolean { + return a.x === b.x && a.y === b.y +} + + export const NULL_POS: Pos = { x: 0, y: 0 } /** diff --git a/src/model/tactic/Player.ts b/src/model/tactic/Player.ts index 2dee897..842bbbe 100644 --- a/src/model/tactic/Player.ts +++ b/src/model/tactic/Player.ts @@ -71,6 +71,8 @@ export type PhantomPositioning = | FixedPhantomPositioning | FollowsPhantomPositioning + + /** * A player phantom is a kind of component that represents the future state of a player * according to the court's step information diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 5068f85..2fd6627 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -20,6 +20,7 @@ import { Rack } from "../components/Rack" import { PlayerPiece } from "../components/editor/PlayerPiece" import { + ComponentId, CourtType, StepContent, StepInfoNode, @@ -39,10 +40,11 @@ import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" import { overlaps } from "../geo/Box" import { + computeTerminalState, + drainTerminalStateOnChildContent, dropBallOnComponent, getComponentCollided, getRackPlayers, - getTerminalState, moveComponent, placeBallAt, placeObjectAt, @@ -66,13 +68,13 @@ import { 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 { - changePlayerBallState, computePhantomPositioning, getOrigin, removePlayer, @@ -84,6 +86,7 @@ import { addStepNode, getAvailableId, getParent, + getStepNode, removeStepNode, } from "../editor/StepsDomain" @@ -96,13 +99,19 @@ 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 DEFAULT_STEP_ID = 1 +const ROOT_STEP_ID = 1 + +type ComputedRelativePositions = Map + +type ComputedStepContent = { + content: StepContent + relativePositions: ComputedRelativePositions +} interface TacticDto { id: number name: string courtType: CourtType - root: StepInfoNode } export interface EditorPageProps { @@ -122,13 +131,19 @@ function GuestModeEditor() { GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0", ) - const stepInitialContent = { - ...(storageContent == null - ? { components: [] } - : JSON.parse(storageContent)), - stepId: 0, + const stepInitialContent: ComputedStepContent = { + content: { + ...(storageContent == null + ? { components: [] } + : JSON.parse(storageContent)), + }, + relativePositions: new Map(), } + 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( @@ -136,37 +151,72 @@ function GuestModeEditor() { JSON.stringify({ id: 0, children: [] }), ) localStorage.setItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepInitialContent.stepId, + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + ROOT_STEP_ID, JSON.stringify(stepInitialContent), ) } - const [stepId, setStepId] = useState(DEFAULT_STEP_ID) + const [stepId, setStepId] = useState(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), - ) - return SaveStates.Guest - }, 250), - [stepId], + debounceAsync( + async ({ + content, + relativePositions, + }: ComputedStepContent) => { + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, + JSON.stringify(content), + ) + + const terminalState = computeTerminalState( + content, + relativePositions, + ) + const currentStepNode = getStepNode( + rootStepNode, + stepId, + )! + + for (const child of currentStepNode.children) { + const childCurrentContent = getStepContent(child.id) + const childUpdatedContent = + drainTerminalStateOnChildContent( + terminalState, + childCurrentContent, + ) + + if (childUpdatedContent) { + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + + stepId, + JSON.stringify(childUpdatedContent), + ) + } + } + + return SaveStates.Guest + }, + 250, + ), + [rootStepNode, stepId], ), ) + function getStepContent(step: number): StepContent { + return JSON.parse( + localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!, + ) + } + return ( { setStepId(step) setStepContent( - { - ...JSON.parse( - localStorage.getItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step, - )!, - ), - }, + () => ({ + content: getStepContent(step), + relativePositions: new Map(), + }), false, ) return @@ -235,39 +282,80 @@ function GuestModeEditor() { function UserModeEditor() { const [tactic, setTactic] = useState(null) + const [stepsTree, setStepsTree] = useState({ id: ROOT_STEP_ID, children: [] }) const { tacticId: idStr } = useParams() - const id = parseInt(idStr!) + const tacticId = parseInt(idStr!) const navigation = useNavigate() const [stepId, setStepId] = useState(1) + + const saveContent = useCallback( + async ({ content, relativePositions }: ComputedStepContent) => { + const response = await fetchAPI( + `tactics/${tacticId}/steps/${stepId}`, + { content }, + "PUT", + ) + + const terminalStateContent = computeTerminalState( + content, + relativePositions, + ) + const currentNode = getStepNode(stepsTree!, stepId)! + + const tasks = currentNode.children.map(async (child) => { + const response = await fetchAPIGet( + `tactics/${tacticId}/steps/${child.id}`, + ) + if (!response.ok) + throw new Error("Error when retrieving children content") + + const childContent: StepContent = await response.json() + const childUpdatedContent = drainTerminalStateOnChildContent( + terminalStateContent, + childContent, + ) + if (childUpdatedContent) { + const response = await fetchAPI( + `tactics/${tacticId}/steps/${child.id}`, + { content: childUpdatedContent }, + "PUT", + ) + if (!response.ok) { + throw new Error( + "Error when updated new children content", + ) + } + } + }) + + for (const task of tasks) { + await task + } + + return response.ok ? SaveStates.Ok : SaveStates.Err + }, + [tacticId, stepId, stepsTree], + ) + + const [stepContent, setStepContent, saveState] = - useContentState( - { components: [] }, + useContentState( + { + content: { components: [] }, + relativePositions: new Map(), + }, 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], - ), + useMemo(() => debounceAsync(saveContent, 250), [saveContent]), ) + useEffect(() => { async function initialize() { - const infoResponsePromise = fetchAPIGet(`tactics/${id}`) - const treeResponsePromise = fetchAPIGet(`tactics/${id}/tree`) + const infoResponsePromise = fetchAPIGet(`tactics/${tacticId}`) + const treeResponsePromise = fetchAPIGet(`tactics/${tacticId}/tree`) const contentResponsePromise = fetchAPIGet( - `tactics/${id}/steps/${DEFAULT_STEP_ID}`, + `tactics/${tacticId}/steps/${ROOT_STEP_ID}`, ) const infoResponse = await infoResponsePromise @@ -287,48 +375,59 @@ function UserModeEditor() { const content = await contentResponse.json() const { root } = await treeResponse.json() - setTactic({ id, name, courtType, root }) - setStepContent(content, false) + setTactic({ id: tacticId, name, courtType }) + setStepsTree(root) + setStepContent({ content, relativePositions: new Map() }, false) } - initialize() - }, [id, idStr, navigation]) + if (tactic === null) + initialize() + }, [tactic, tacticId, idStr, navigation, setStepContent]) const onNameChange = useCallback( (name: string) => - fetchAPI(`tactics/${id}/name`, { name }, "PUT").then((r) => r.ok), - [id], + fetchAPI(`tactics/${tacticId}/name`, { name }, "PUT").then((r) => r.ok), + [tacticId], ) const selectStep = useCallback( async (step: number) => { - const response = await fetchAPIGet(`tactics/${id}/steps/${step}`) + const response = await fetchAPIGet(`tactics/${tacticId}/steps/${step}`) if (!response.ok) return setStepId(step) - setStepContent({ ...(await response.json()) }, false) + setStepContent( + { + content: await response.json(), + relativePositions: new Map(), + }, + false, + ) }, - [id, setStepContent], + [tacticId, setStepContent], ) const onAddStep = useCallback( async (parent: StepInfoNode, content: StepContent) => { - const response = await fetchAPI(`tactics/${id}/steps`, { + const response = await fetchAPI(`tactics/${tacticId}/steps`, { parentId: parent.id, content, }) if (!response.ok) return null const { stepId } = await response.json() - return { id: stepId, children: [] } + const child = { id: stepId, children: [] } + setStepsTree(addStepNode(stepsTree, parent, child)) + return child }, - [id], + [tacticId, stepsTree], ) const onRemoveStep = useCallback( - (step: StepInfoNode) => - fetchAPI(`tactics/${id}/steps/${step.id}`, {}, "DELETE").then( - (r) => r.ok, - ), - [id], + 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 @@ -336,9 +435,9 @@ function UserModeEditor() { return ( > + setCurrentStepContent: Dispatch> selectStep: (stepId: number) => void onNameChange: (name: string) => Promise @@ -374,16 +473,16 @@ export interface EditorViewProps { } function EditorPage({ - tactic: { name, rootStepNode: initialStepsNode, courtType }, - currentStepId, - setCurrentStepContent: setContent, - currentStepContent: content, - saveState, - onNameChange, - selectStep, - onRemoveStep, - onAddStep, -}: EditorViewProps) { + tactic: { name, rootStepNode: initialStepsNode, courtType }, + currentStepId, + setCurrentStepContent, + currentStepContent: { content, relativePositions }, + saveState, + onNameChange, + selectStep, + onRemoveStep, + onAddStep, + }: EditorViewProps) { const [titleStyle, setTitleStyle] = useState({}) const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) @@ -406,6 +505,32 @@ function EditorPage({ const [isStepsTreeVisible, setStepsTreeVisible] = useState(false) const courtRef = useRef(null) + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) + + const setContent = useCallback( + (newState: SetStateAction) => { + setCurrentStepContent((c) => { + const state = + typeof newState === "function" + ? newState(c.content) + : newState + + const courtBounds = courtRef.current?.getBoundingClientRect() + const relativePositions: ComputedRelativePositions = courtBounds ? computeRelativePositions(courtBounds, state) : new Map() + + console.log("in set: ", relativePositions) + + return { + content: state, + relativePositions, + } + }) + }, + [setCurrentStepContent], + ) const setComponents = (action: SetStateAction) => { setContent((c) => ({ @@ -415,11 +540,6 @@ function EditorPage({ })) } - const courtBounds = useCallback( - () => courtRef.current!.getBoundingClientRect(), - [courtRef], - ) - useEffect(() => { setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) }, [setObjects, content]) @@ -458,11 +578,12 @@ function EditorPage({ (newBounds: DOMRect, from?: PlayerLike) => { setContent((content) => { if (from) { - content = changePlayerBallState( - from, - BallState.NONE, - content, - ) + content = + spreadNewStateFromOriginStateChange( + from, + BallState.NONE, + content, + ) ?? content } content = placeBallAt(newBounds, courtBounds(), content) @@ -560,6 +681,7 @@ function EditorPage({ pos: computePhantomPositioning( component, content, + relativePositions, courtBounds(), ), ballState: component.ballState, @@ -585,10 +707,12 @@ function EditorPage({ ) }, [ - content.components, + content, + relativePositions, + courtBounds, + validatePlayerPosition, doRemovePlayer, renderAvailablePlayerActions, - validatePlayerPosition, ], ) @@ -759,7 +883,10 @@ function EditorPage({ async (parent) => { const addedNode = await onAddStep( parent, - getTerminalState(content, courtBounds()), + computeTerminalState( + content, + relativePositions, + ), ) if (addedNode == null) { console.error( @@ -772,7 +899,7 @@ function EditorPage({ addStepNode(root, parent, addedNode), ) }, - [content, courtBounds, onAddStep, selectStep], + [content, onAddStep, selectStep, relativePositions], )} onRemoveNode={useCallback( async (removed) => { @@ -805,13 +932,13 @@ interface EditorStepsTreeProps { } function EditorStepsTree({ - isVisible, - selectedStepId, - root, - onAddChildren, - onRemoveNode, - onStepSelected, -}: EditorStepsTreeProps) { + isVisible, + selectedStepId, + root, + onAddChildren, + onRemoveNode, + onStepSelected, + }: EditorStepsTreeProps) { return (
courtRef.current!.getBoundingClientRect(), [courtRef], @@ -899,15 +1026,15 @@ interface CourtPlayerArrowActionProps { } function CourtPlayerArrowAction({ - playerInfo, - player, - isInvalid, - - content, - setContent, - setPreviewAction, - courtRef, -}: CourtPlayerArrowActionProps) { + playerInfo, + player, + isInvalid, + + content, + setContent, + setPreviewAction, + courtRef, + }: CourtPlayerArrowActionProps) { const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], @@ -1048,12 +1175,8 @@ function debounceAsync( function useContentState( initialContent: S, initialSaveState: SaveState, - saveStateCallback: (s: S) => Promise, -): [ - S, - (newState: SetStateAction, callSaveCallback: boolean) => void, - SaveState, -] { + applyStateCallback: (content: S) => Promise, +): [S, (newState: SetStateAction, runCallback: boolean) => void, SaveState] { const [content, setContent] = useState(initialContent) const [savingState, setSavingState] = useState(initialSaveState) @@ -1067,15 +1190,38 @@ function useContentState( if (state !== content && callSaveCallback) { setSavingState(SaveStates.Saving) - saveStateCallback(state) + applyStateCallback(state) .then(setSavingState) - .catch(() => setSavingState(SaveStates.Err)) + .catch((e) => { + setSavingState(SaveStates.Err) + console.error(e) + }) } return state }) }, - [saveStateCallback], + [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, + ) + } + + console.log("computed bounds: ", relativePositionsCache) + + + return relativePositionsCache +} \ No newline at end of file