You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Application-Web/src/pages/Editor.tsx

1167 lines
37 KiB

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<StepInfoNode | ServiceError>
removeStep(step: number): Promise<void | ServiceError>
selectStep(step: number): Promise<void | ServiceError>
setContent(content: SetStateAction<StepContent>): void
setName(name: string): Promise<SaveState>
openVisualizer(): Promise<void>
}
export default function Editor({ guestMode }: EditorProps) {
const { tacticId: idStr } = useParams()
const fetcher = useAppFetcher()
const navigate = useNavigate()
if (guestMode || !idStr) {
return (
<EditorPageWrapper
service={LocalStorageTacticService.init()}
openVisualizer={() => navigate("/tactic/view-guest")}
/>
)
}
return (
<EditorPageWrapper
service={new APITacticService(fetcher, parseInt(idStr))}
openVisualizer={() => navigate(`/tactic/${idStr}/view`)}
/>
)
}
interface EditorPageWrapperProps {
service: MutableTacticService
openVisualizer(): void
}
function EditorPageWrapper({
service,
openVisualizer,
}: EditorPageWrapperProps) {
const [panicMessage, setPanicMessage] = useState<string>()
const [stepId, setStepId] = useState<number>()
const [tacticName, setTacticName] = useState<string>()
const [courtType, setCourtType] = useState<CourtType>()
const [stepsTree, setStepsTree] = useState<StepInfoNode>()
const [parentContent, setParentContent] = useState<StepContent | null>(null)
const courtRef = useRef<HTMLDivElement>(null)
const stepsVersions = useMemo<Map<number, ContentVersions>>(
() => 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<StepContent>(
{ 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<StepInfoNode | ServiceError> {
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<void | ServiceError> {
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<SaveState> {
const result = await service.setName(name)
if (typeof result === "string") return SaveStates.Err
setTacticName(name)
return SaveStates.Ok
},
async selectStep(step: number): Promise<void | ServiceError> {
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<void> {
openVisualizer()
},
}
}, [stepsTree, service, stepsVersions, setStepContent, openVisualizer])
if (panicMessage) {
return <p>{panicMessage}</p>
}
if (isNotInit) {
return <p>Retrieving editor context. Please wait...</p>
}
return (
<EditorPage
name={tacticName}
courtType={courtType}
stepId={stepId}
stepsTree={stepsTree}
contentSaveState={saveState}
parentContent={parentContent}
content={stepContent}
service={editorService}
courtRef={courtRef}
/>
)
}
export interface EditorViewProps {
stepsTree: StepInfoNode
name: string
courtType: CourtType
contentSaveState: SaveState
stepId: number
parentContent: StepContent | null
content: StepContent
courtRef: RefObject<HTMLDivElement>
service: EditorService
}
function EditorPage({
name,
courtType,
parentContent,
content,
stepId,
contentSaveState,
stepsTree,
courtRef,
service,
}: EditorViewProps) {
const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
const allies = getRackPlayers(PlayerTeam.Allies, content.components)
const opponents = getRackPlayers(PlayerTeam.Opponents, content.components)
const [objects, setObjects] = useState<RackedCourtObject[]>(() =>
isBallOnCourt(content) ? [] : [{ key: "ball" }],
)
const [previewAction, setPreviewAction] = useState<ActionPreview | null>(
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<TacticComponent[]>) => {
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 && (
<CourtPlayerArrowAction
key={1}
player={player}
isInvalid={previewAction?.isInvalid ?? false}
setPreviewAction={setPreviewAction}
playerInfo={info}
content={content}
courtRef={courtRef}
setContent={service.setContent}
/>
),
!isFrozen &&
(info.ballState === BallState.HOLDS_ORIGIN ||
info.ballState === BallState.PASSED_ORIGIN) && (
<BallAction
key={2}
onDrop={(ballBounds) => {
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 (
<CourtPlayer
key={component.id}
playerInfo={info}
className={className}
availableActions={() =>
renderAvailablePlayerActions(info, component)
}
/>
)
}
return (
<EditableCourtPlayer
key={component.id}
className={className}
playerInfo={info}
onPositionValidated={(newPos) =>
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 (
<CourtBall
key="ball"
ball={component}
onPosValidated={doMoveBall}
onRemove={() => {
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 (
<CourtAction
key={"action-" + component.id + "-" + i}
action={action}
origin={component.id}
courtRef={courtRef}
color={isFromParent ? "gray" : "black"}
isEditable={!isFromParent}
onActionDeleted={() => {
if (!isFromParent)
doDeleteAction(action, i, component)
}}
onActionChanges={(action) => {
if (!isFromParent)
doUpdateAction(component, action, i)
}}
/>
)
}),
[courtRef, doDeleteAction, doUpdateAction],
)
const contentNode = (
<div id="content-div">
<div id="racks" >
<PlayerRack
id={"allies"}
objects={allies}
setComponents={setComponents}
courtRef={courtRef}
/>
<Rack
id={"objects"}
objects={objects}
onChange={setObjects}
canDetach={useCallback(
(div) =>
overlaps(
courtBounds(),
div.getBoundingClientRect(),
),
[courtBounds],
)}
onElementDetached={useCallback(
(r, e: RackedCourtObject) =>
service.setContent((content) =>
placeObjectAt(
r.getBoundingClientRect(),
courtBounds(),
e,
content,
),
),
[courtBounds, service],
)}
render={renderCourtObject}
/>
<PlayerRack
id={"opponents"}
objects={opponents}
setComponents={setComponents}
courtRef={courtRef}
/>
</div>
<div id="court-div">
<div id="court-div-bounds">
<BasketCourt
parentComponents={parentContent?.components ?? null}
components={content.components}
courtImage={<Court courtType={courtType} />}
courtRef={courtRef}
previewAction={previewAction}
renderComponent={renderComponent}
renderActions={renderActions}
/>
</div>
</div>
</div>
)
const stepsTreeNode = (
<EditorStepsTree
selectedStepId={stepId}
root={stepsTree}
onAddChildren={useCallback(
async (parent) => {
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 (
<div id="main-div">
<div id="topbar-div">
<div id="topbar-left">
<SavingState state={contentSaveState} />
</div>
<TitleInput
style={titleStyle}
default_value={name}
onValidated={useCallback(
(new_name) => {
service.setName(new_name).then((state) => {
setTitleStyle(
state == SaveStates.Ok ? {} : ERROR_STYLE,
)
})
},
[service],
)}
/>
<div id="topbar-right">
<button
id="toggle-visualisation"
onClick={service.openVisualizer}>
VISUALISER
</button>
<button
id={"show-steps-button"}
onClick={() => setStepsTreeVisible((b) => !b)}>
ETAPES
</button>
</div>
</div>
<div id="editor-div">
{isStepsTreeVisible ? (
<SplitLayout
rightWidth={editorContentCurtainWidth}
onRightWidthChange={setEditorContentCurtainWidth}>
{contentNode}
{stepsTreeNode}
</SplitLayout>
) : (
contentNode
)}
</div>
</div>
)
}
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 (
<div id="steps-div">
<StepsTree
root={root}
selectedStepId={selectedStepId}
onStepSelected={onStepSelected}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
/>
</div>
)
}
interface PlayerRackProps {
id: string
objects: RackedPlayer[]
setObjects?: (state: RackedPlayer[]) => void
setComponents: (
f: (components: TacticComponent[]) => TacticComponent[],
) => void
courtRef: RefObject<HTMLDivElement>
}
function PlayerRack({
id,
objects,
setObjects,
courtRef,
setComponents,
}: PlayerRackProps) {
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],
)
return (
<Rack
id={id}
objects={objects}
onChange={setObjects}
canDetach={useCallback(
(div) => 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 }) => (
<PlayerPiece
team={team}
text={key}
key={key}
hasBall={false}
/>
),
[],
)}
/>
)
}
interface CourtPlayerArrowActionProps {
playerInfo: PlayerInfo
player: PlayerLike
isInvalid: boolean
content: StepContent
setContent: (state: SetStateAction<StepContent>) => void
setPreviewAction: (state: SetStateAction<ActionPreview | null>) => void
courtRef: RefObject<HTMLDivElement>
}
function CourtPlayerArrowAction({
playerInfo,
player,
isInvalid,
content,
setContent,
setPreviewAction,
courtRef,
}: CourtPlayerArrowActionProps) {
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],
)
return (
<ArrowAction
key={1}
onHeadMoved={(headPos) => {
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 <BallPiece />
}
throw new Error("unknown racked court object " + courtObject.key)
}
function debounceAsync<A, B>(
f: (args: A) => Promise<B>,
delay = 1000,
): (args: A) => Promise<B> {
let task = 0
return (args: A) => {
clearTimeout(task)
return new Promise((resolve, reject) => {
task = setTimeout(() => f(args).then(resolve).catch(reject), delay)
})
}
}
function useContentState<S>(
initialContent: S,
initialSaveState: SaveState,
applyStateCallback: (content: S) => Promise<SaveState>,
): [S, (newState: SetStateAction<S>, runCallback: boolean) => void, SaveState] {
const [content, setContent] = useState(initialContent)
const [savingState, setSavingState] = useState(initialSaveState)
const setContentSynced = useCallback(
(newState: SetStateAction<S>, 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<ComputedStepContent>,
setStepContent: (stepId: number, content: StepContent) => Promise<void>,
) {
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)
}