steps can now contain individual content on editor side

pull/114/head
maxime 1 year ago
parent 034afc3649
commit d26edd791a

@ -1,5 +1 @@
<svg viewBox="0 0 80 49" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M240-440q-17 0-28.5-11.5T200-480q0-17 11.5-28.5T240-520h480q17 0 28.5 11.5T760-480q0 17-11.5 28.5T720-440H240Z"/></svg>
<path d="M24.5 4.5H55.5C66.5457 4.5 75.5 13.4543 75.5 24.5C75.5 35.5457 66.5457 44.5 55.5 44.5H24.5C13.4543 44.5 4.5 35.5457 4.5 24.5C4.5 13.4543 13.4543 4.5 24.5 4.5Z"
stroke="black" stroke-width="9"/>
<line x1="24.5" y1="24.5" x2="55.5" y2="24.5" stroke="black" stroke-width="9" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 405 B

After

Width:  |  Height:  |  Size: 216 B

@ -1,45 +1,87 @@
import {StepInfoNode} from "../../model/tactic/Tactic";
import "../../style/steps-tree.css" import "../../style/steps-tree.css"
import BendableArrow from "../arrows/BendableArrow"; import { StepInfoNode } from "../../model/tactic/Tactic"
import {useRef} from "react"; import BendableArrow from "../arrows/BendableArrow"
import { useRef } from "react"
import AddSvg from "../../assets/icon/add.svg?react"
import RemoveSvg from "../../assets/icon/remove.svg?react"
export interface StepsTreeProps { export interface StepsTreeProps {
root: StepInfoNode root: StepInfoNode
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void
} }
export default function StepsTree({root}: StepsTreeProps) { export default function StepsTree({
return <div className="steps-tree"> root,
<StepsTreeNode node={root}/> onAddChildren,
</div> onRemoveNode,
onStepSelected,
}: StepsTreeProps) {
return (
<div className="steps-tree">
<StepsTreeNode
node={root}
isNodeRoot={true}
onStepSelected={onStepSelected}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
/>
</div>
)
} }
interface StepsTreeContentProps { interface StepsTreeContentProps {
node: StepInfoNode node: StepInfoNode
isNodeRoot: boolean
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void
} }
function StepsTreeNode({node}: StepsTreeContentProps) { function StepsTreeNode({
node,
isNodeRoot,
onAddChildren,
onRemoveNode,
onStepSelected,
}: StepsTreeContentProps) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
return ( return (
<div ref={ref} <div ref={ref} className={"step-group"}>
className={"step-group"}> <StepPiece
<StepPiece id={node.id}/> id={node.id}
{node.children.map(child => ( onAddButtonClicked={() => onAddChildren(node)}
onRemoveButtonClicked={
isNodeRoot ? undefined : () => onRemoveNode(node)
}
onSelected={() => onStepSelected(node)}
/>
{node.children.map((child) => (
<BendableArrow <BendableArrow
key={child.id} key={child.id}
area={ref} area={ref}
startPos={"step-piece-" + node.id} startPos={"step-piece-" + node.id}
segments={[{next: "step-piece-" + child.id}]} segments={[{ next: "step-piece-" + child.id }]}
onSegmentsChanges={() => { onSegmentsChanges={() => {}}
}}
forceStraight={true} forceStraight={true}
wavy={false} wavy={false}
//TODO remove magic constant //TODO remove magic constants
startRadius={10} startRadius={10}
endRadius={10} endRadius={10}
/> />
))} ))}
<div className={"step-children"}> <div className={"step-children"}>
{node.children.map(child => <StepsTreeNode key={child.id} node={child}/>)} {node.children.map((child) => (
<StepsTreeNode
key={child.id}
isNodeRoot={false}
node={child}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
onStepSelected={onStepSelected}
/>
))}
</div> </div>
</div> </div>
) )
@ -47,11 +89,37 @@ function StepsTreeNode({node}: StepsTreeContentProps) {
interface StepPieceProps { interface StepPieceProps {
id: number id: number
onAddButtonClicked?: () => void
onRemoveButtonClicked?: () => void
onSelected: () => void
} }
function StepPiece({id}: StepPieceProps) { function StepPiece({
id,
onAddButtonClicked,
onRemoveButtonClicked,
onSelected,
}: StepPieceProps) {
return ( return (
<div id={"step-piece-" + id} className={"step-piece"}> <div
id={"step-piece-" + id}
tabIndex={1}
className={"step-piece"}
onClick={onSelected}>
<div className="step-piece-actions">
{onAddButtonClicked && (
<AddSvg
onClick={() => onAddButtonClicked()}
className={"add-icon"}
/>
)}
{onRemoveButtonClicked && (
<RemoveSvg
onClick={() => onRemoveButtonClicked()}
className={"remove-icon"}
/>
)}
</div>
<p>{id}</p> <p>{id}</p>
</div> </div>
) )

@ -4,15 +4,15 @@ import {
PlayerLike, PlayerLike,
PlayerPhantom, PlayerPhantom,
} from "../model/tactic/Player" } from "../model/tactic/Player"
import {ratioWithinBase} from "../geo/Pos" import { ratioWithinBase } from "../geo/Pos"
import { import {
ComponentId, ComponentId,
TacticComponent, TacticComponent,
StepContent, StepContent,
} from "../model/tactic/Tactic" } from "../model/tactic/Tactic"
import {overlaps} from "../geo/Box" import { overlaps } from "../geo/Box"
import {Action, ActionKind, moves} from "../model/tactic/Action" import { Action, ActionKind, moves } from "../model/tactic/Action"
import {removeBall, updateComponent} from "./TacticContentDomains" import { removeBall, updateComponent } from "./TacticContentDomains"
import { import {
areInSamePath, areInSamePath,
changePlayerBallState, changePlayerBallState,
@ -22,7 +22,7 @@ import {
isNextInPath, isNextInPath,
removePlayer, removePlayer,
} from "./PlayerDomains" } from "./PlayerDomains"
import {BALL_TYPE} from "../model/tactic/CourtObjects" import { BALL_TYPE } from "../model/tactic/CourtObjects"
export function getActionKind( export function getActionKind(
target: TacticComponent | null, target: TacticComponent | null,
@ -31,12 +31,12 @@ export function getActionKind(
switch (ballState) { switch (ballState) {
case BallState.HOLDS_ORIGIN: case BallState.HOLDS_ORIGIN:
return target return target
? {kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN} ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN }
: {kind: ActionKind.DRIBBLE, nextState: ballState} : { kind: ActionKind.DRIBBLE, nextState: ballState }
case BallState.HOLDS_BY_PASS: case BallState.HOLDS_BY_PASS:
return target return target
? {kind: ActionKind.SHOOT, nextState: BallState.PASSED} ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED }
: {kind: ActionKind.DRIBBLE, nextState: ballState} : { kind: ActionKind.DRIBBLE, nextState: ballState }
case BallState.PASSED_ORIGIN: case BallState.PASSED_ORIGIN:
case BallState.PASSED: case BallState.PASSED:
case BallState.NONE: case BallState.NONE:
@ -222,7 +222,7 @@ export function createAction(
forceHasBall: boolean, forceHasBall: boolean,
attachedTo?: ComponentId, attachedTo?: ComponentId,
): ComponentId { ): ComponentId {
const {x, y} = ratioWithinBase(arrowHead, courtBounds) const { x, y } = ratioWithinBase(arrowHead, courtBounds)
let itemIndex: number let itemIndex: number
let originPlayer: Player let originPlayer: Player
@ -274,14 +274,14 @@ export function createAction(
id: phantomId, id: phantomId,
pos: attachedTo pos: attachedTo
? { ? {
type: "follows", type: "follows",
attach: attachedTo, attach: attachedTo,
} }
: { : {
type: "fixed", type: "fixed",
x, x,
y, y,
}, },
originPlayerId: originPlayer.id, originPlayerId: originPlayer.id,
ballState: phantomState, ballState: phantomState,
actions: [], actions: [],
@ -320,13 +320,13 @@ export function createAction(
action = { action = {
target: toId, target: toId,
type: actionKind, type: actionKind,
segments: [{next: toId}], segments: [{ next: toId }],
} }
} else { } else {
action = { action = {
target: toId, target: toId,
type: actionKind, type: actionKind,
segments: [{next: toId}], segments: [{ next: toId }],
} }
} }
@ -355,7 +355,7 @@ export function createAction(
const action: Action = { const action: Action = {
target: phantomId, target: phantomId,
type: actionKind, type: actionKind,
segments: [{next: phantomId}], segments: [{ next: phantomId }],
} }
return { return {
newContent: updateComponent( newContent: updateComponent(
@ -535,7 +535,7 @@ export function spreadNewStateFromOriginStateChange(
i-- // step back i-- // step back
} else { } else {
// do not change the action type if it is a shoot action // do not change the action type if it is a shoot action
const {kind, nextState} = getActionKindBetween( const { kind, nextState } = getActionKindBetween(
origin, origin,
actionTarget, actionTarget,
newState, newState,

@ -1,11 +1,31 @@
import {BallState, Player, PlayerLike, PlayerPhantom,} from "../model/tactic/Player" import {
import {ComponentId, StepContent, TacticComponent,} from "../model/tactic/Tactic" BallState,
Player,
import {removeComponent, updateComponent} from "./TacticContentDomains" PlayerLike,
import {removeAllActionsTargeting, spreadNewStateFromOriginStateChange,} from "./ActionsDomains" PlayerPhantom,
import {ActionKind} from "../model/tactic/Action" } from "../model/tactic/Player"
import {add, minus, norm, Pos, posWithinBase, ratioWithinBase, relativeTo,} from "../geo/Pos.ts" import {
import {PLAYER_RADIUS_PIXELS} from "../components/editor/CourtPlayer.tsx" ComponentId,
StepContent,
TacticComponent,
} from "../model/tactic/Tactic"
import { removeComponent, updateComponent } from "./TacticContentDomains"
import {
removeAllActionsTargeting,
spreadNewStateFromOriginStateChange,
} from "./ActionsDomains"
import { ActionKind } from "../model/tactic/Action"
import {
add,
minus,
norm,
Pos,
posWithinBase,
ratioWithinBase,
relativeTo,
} from "../geo/Pos.ts"
import { PLAYER_RADIUS_PIXELS } from "../components/editor/CourtPlayer.tsx"
export function getOrigin( export function getOrigin(
pathItem: PlayerPhantom, pathItem: PlayerPhantom,
@ -270,9 +290,9 @@ export function truncatePlayerPath(
truncateStartIdx == 0 truncateStartIdx == 0
? null ? null
: { : {
...path, ...path,
items: path.items.toSpliced(truncateStartIdx), items: path.items.toSpliced(truncateStartIdx),
}, },
}, },
content, content,
) )

@ -0,0 +1,36 @@
import { StepInfoNode } from "../model/tactic/Tactic"
export function addStepNode(
root: StepInfoNode,
parent: StepInfoNode,
child: StepInfoNode,
): StepInfoNode {
if (root.id === parent.id) {
return {
...root,
children: root.children.concat(child),
}
}
return {
...root,
children: root.children.map((c) => addStepNode(c, parent, child)),
}
}
export function removeStepNode(
root: StepInfoNode,
node: StepInfoNode,
): StepInfoNode | null {
if (root.id === node.id) {
return null
}
return {
...root,
children: root.children.flatMap((child) => {
const result = removeStepNode(child, node)
return result ? [result] : []
}),
}
}

@ -1,4 +1,5 @@
import {Pos, ratioWithinBase} from "../geo/Pos" import { Pos, ratioWithinBase } from "../geo/Pos"
import { import {
BallState, BallState,
Player, Player,
@ -18,10 +19,10 @@ import {
StepContent, StepContent,
} from "../model/tactic/Tactic" } from "../model/tactic/Tactic"
import {overlaps} from "../geo/Box" import { overlaps } from "../geo/Box"
import {RackedCourtObject, RackedPlayer} from "./RackedItems" import { RackedCourtObject, RackedPlayer } from "./RackedItems"
import {changePlayerBallState, getComponent, getOrigin} from "./PlayerDomains" import { changePlayerBallState, getComponent, getOrigin } from "./PlayerDomains"
import {ActionKind} from "../model/tactic/Action.ts" import { ActionKind } from "../model/tactic/Action.ts"
export function placePlayerAt( export function placePlayerAt(
refBounds: DOMRect, refBounds: DOMRect,
@ -50,7 +51,6 @@ export function placeObjectAt(
): StepContent { ): StepContent {
const pos = ratioWithinBase(refBounds, courtBounds) const pos = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject let courtObject: CourtObject
switch (rackedObject.key) { switch (rackedObject.key) {
@ -188,9 +188,9 @@ export function moveComponent(
phantomIdx == 0 phantomIdx == 0
? origin ? origin
: getComponent( : getComponent(
originPathItems[phantomIdx - 1], originPathItems[phantomIdx - 1],
content.components, content.components,
) )
// detach the action from the screen target and transform it to a regular move action to the phantom. // detach the action from the screen target and transform it to a regular move action to the phantom.
content = updateComponent( content = updateComponent(
{ {
@ -198,18 +198,18 @@ export function moveComponent(
actions: playerBeforePhantom.actions.map((a) => actions: playerBeforePhantom.actions.map((a) =>
a.target === referent a.target === referent
? { ? {
...a, ...a,
segments: a.segments.toSpliced( segments: a.segments.toSpliced(
a.segments.length - 2, a.segments.length - 2,
1, 1,
{ {
...a.segments[a.segments.length - 1], ...a.segments[a.segments.length - 1],
next: component.id, next: component.id,
}, },
), ),
target: component.id, target: component.id,
type: ActionKind.MOVE, type: ActionKind.MOVE,
} }
: a, : a,
), ),
}, },
@ -222,9 +222,9 @@ export function moveComponent(
...component, ...component,
pos: isPhantom pos: isPhantom
? { ? {
type: "fixed", type: "fixed",
...newPos, ...newPos,
} }
: newPos, : newPos,
}, },
content, content,
@ -287,5 +287,5 @@ export function getRackPlayers(
c.type == "player" && c.team == team && c.role == role, c.type == "player" && c.team == team && c.role == role,
) == -1, ) == -1,
) )
.map((key) => ({team, key})) .map((key) => ({ team, key }))
} }

@ -2,16 +2,19 @@ import { Player, PlayerPhantom } from "./Player"
import { Action } from "./Action" import { Action } from "./Action"
import { CourtObject } from "./CourtObjects" import { CourtObject } from "./CourtObjects"
export interface Tactic { export interface TacticInfo {
readonly id: number readonly id: number
readonly name: string readonly name: string
readonly courtType: CourtType readonly courtType: CourtType
readonly currentStepContent: StepContent
readonly rootStepNode: StepInfoNode readonly rootStepNode: StepInfoNode
} }
export interface StepContent { export interface TacticStep {
readonly stepId: number readonly stepId: number
readonly content: StepContent
}
export interface StepContent {
readonly components: TacticComponent[] readonly components: TacticComponent[]
} }

@ -14,19 +14,24 @@ import TitleInput from "../components/TitleInput"
import PlainCourt from "../assets/court/full_court.svg?react" import PlainCourt from "../assets/court/full_court.svg?react"
import HalfCourt from "../assets/court/half_court.svg?react" import HalfCourt from "../assets/court/half_court.svg?react"
import {BallPiece} from "../components/editor/BallPiece" import { BallPiece } from "../components/editor/BallPiece"
import {Rack} from "../components/Rack" import { Rack } from "../components/Rack"
import {PlayerPiece} from "../components/editor/PlayerPiece" import { PlayerPiece } from "../components/editor/PlayerPiece"
import { import {
CourtType, StepContent, StepInfoNode, CourtType,
Tactic, StepContent,
StepInfoNode,
TacticComponent, TacticComponent,
TacticInfo,
} from "../model/tactic/Tactic" } from "../model/tactic/Tactic"
import { fetchAPI, fetchAPIGet } from "../Fetcher" import { fetchAPI, fetchAPIGet } from "../Fetcher"
import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" import SavingState, {
SaveState,
SaveStates,
} from "../components/editor/SavingState"
import { BALL_TYPE } from "../model/tactic/CourtObjects" import { BALL_TYPE } from "../model/tactic/CourtObjects"
import { CourtAction } from "../components/editor/CourtAction" import { CourtAction } from "../components/editor/CourtAction"
@ -45,13 +50,25 @@ import {
updateComponent, updateComponent,
} from "../editor/TacticContentDomains" } from "../editor/TacticContentDomains"
import {BallState, Player, PlayerInfo, PlayerLike, PlayerTeam,} from "../model/tactic/Player" import {
import {RackedCourtObject, RackedPlayer} from "../editor/RackedItems" BallState,
Player,
PlayerInfo,
PlayerLike,
PlayerTeam,
} from "../model/tactic/Player"
import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems"
import CourtPlayer from "../components/editor/CourtPlayer" import CourtPlayer from "../components/editor/CourtPlayer"
import {createAction, getActionKind, isActionValid, removeAction,} from "../editor/ActionsDomains" import {
createAction,
getActionKind,
isActionValid,
removeAction,
} from "../editor/ActionsDomains"
import ArrowAction from "../components/actions/ArrowAction" import ArrowAction from "../components/actions/ArrowAction"
import {middlePos, Pos, ratioWithinBase} from "../geo/Pos" import { middlePos, Pos, ratioWithinBase } from "../geo/Pos"
import {Action, ActionKind} from "../model/tactic/Action" import { Action, ActionKind } from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction" import BallAction from "../components/actions/BallAction"
import { import {
changePlayerBallState, changePlayerBallState,
@ -63,6 +80,7 @@ import { CourtBall } from "../components/editor/CourtBall"
import { useNavigate, useParams } from "react-router-dom" import { useNavigate, useParams } from "react-router-dom"
import { DEFAULT_TACTIC_NAME } from "./NewTacticPage.tsx" import { DEFAULT_TACTIC_NAME } from "./NewTacticPage.tsx"
import StepsTree from "../components/editor/StepsTree" import StepsTree from "../components/editor/StepsTree"
import { addStepNode, removeStepNode } from "../editor/StepsDomain"
const ERROR_STYLE: CSSProperties = { const ERROR_STYLE: CSSProperties = {
borderColor: "red", borderColor: "red",
@ -72,15 +90,16 @@ const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content"
const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
export interface EditorViewProps { export interface EditorViewProps {
tactic: Tactic tactic: TacticInfo
onContentChange: (tactic: StepContent) => Promise<SaveState> onContentChange: (tactic: StepContent) => Promise<SaveState>
onNameChange: (name: string) => Promise<boolean> onNameChange: (name: string) => Promise<boolean>
} }
interface TacticDto { interface TacticDto {
id: number id: number
name: string name: string
courtType: CourtType courtType: CourtType
content: string content: { components: TacticComponent[] }
root: StepInfoNode root: StepInfoNode
} }
@ -94,9 +113,9 @@ export default function EditorPage({ guestMode }: EditorPageProps) {
return { return {
id: -1, id: -1,
courtType: "PLAIN", courtType: "PLAIN",
content: '{"components": []}', content: { components: [] },
name: DEFAULT_TACTIC_NAME, name: DEFAULT_TACTIC_NAME,
root: {id: 1, children: []} root: { id: 1, children: [] },
} }
} }
return null return null
@ -117,13 +136,17 @@ export default function EditorPage({ guestMode }: EditorPageProps) {
const treeResponse = await treeResponsePromise const treeResponse = await treeResponsePromise
const contentResponse = await contentResponsePromise const contentResponse = await contentResponsePromise
if (infoResponse.status == 401 || contentResponse.status == 401) { if (
infoResponse.status == 401 ||
treeResponse.status == 401 ||
contentResponse.status == 401
) {
navigation("/login") navigation("/login")
return return
} }
const { name, courtType } = await infoResponse.json() const { name, courtType } = await infoResponse.json()
const content = await contentResponse.text() const content = await contentResponse.json()
const { root } = await treeResponse.json() const { root } = await treeResponse.json()
setTactic({ id, name, courtType, content, root }) setTactic({ id, name, courtType, content, root })
@ -136,10 +159,11 @@ export default function EditorPage({ guestMode }: EditorPageProps) {
return ( return (
<Editor <Editor
id={id} id={id}
rootStepNode={tactic.root} initialStepsNode={tactic.root}
courtType={tactic.courtType} courtType={tactic.courtType}
content={tactic.content} initialStepContent={tactic.content}
name={tactic.name} initialName={tactic.name}
initialStepId={1}
/> />
) )
} }
@ -153,23 +177,63 @@ function EditorLoadingScreen() {
export interface EditorProps { export interface EditorProps {
id: number id: number
name: string initialName: string
content: string courtType: "PLAIN" | "HALF"
courtType: CourtType, initialStepContent: StepContent
rootStepNode: StepInfoNode initialStepId: number
initialStepsNode: StepInfoNode
} }
function Editor({ id, name, courtType, content, rootStepNode }: EditorProps) { function Editor({
id,
initialName,
courtType,
initialStepContent,
initialStepId,
initialStepsNode,
}: EditorProps) {
const isInGuestMode = id == -1 const isInGuestMode = id == -1
const navigate = useNavigate()
const storageContent = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) const storageContent = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY)
const stepContent = const stepInitialContent = {
isInGuestMode && storageContent != null ? storageContent : content ...(isInGuestMode && storageContent != null
? JSON.parse(storageContent)
: initialStepContent),
}
const storageName = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) const storage_name = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY)
const editorName = isInGuestMode && storageName != null ? storageName : name const editorName =
isInGuestMode && storage_name != null ? storage_name : initialName
const navigate = useNavigate() const [stepId, setStepId] = useState(initialStepId)
const [stepContent, setStepContent, saveState] = useContentState(
stepInitialContent,
isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
useMemo(
() =>
debounceAsync(async (content: StepContent) => {
if (isInGuestMode) {
localStorage.setItem(
GUEST_MODE_CONTENT_STORAGE_KEY,
JSON.stringify(content),
)
return SaveStates.Guest
}
const response = await fetchAPI(
`tactics/${id}/steps/${stepId}`,
{
content: {
components: content.components,
},
},
"PUT",
)
return response.ok ? SaveStates.Ok : SaveStates.Err
}, 250),
[id, isInGuestMode, stepId],
),
)
return ( return (
<EditorView <EditorView
@ -177,12 +241,13 @@ function Editor({ id, name, courtType, content, rootStepNode }: EditorProps) {
name: editorName, name: editorName,
id, id,
courtType, courtType,
currentStepContent: { rootStepNode: initialStepsNode,
stepId: 1,
...JSON.parse(stepContent)
},
rootStepNode,
}} }}
currentStepContent={stepContent}
currentStepId={stepId}
setCurrentStepContent={(content) => setStepContent(content, true)}
courtType={courtType}
saveState={saveState}
onContentChange={async (content: StepContent) => { onContentChange={async (content: StepContent) => {
if (isInGuestMode) { if (isInGuestMode) {
localStorage.setItem( localStorage.setItem(
@ -195,7 +260,6 @@ function Editor({ id, name, courtType, content, rootStepNode }: EditorProps) {
`tactics/${id}/steps/1`, `tactics/${id}/steps/1`,
{ content }, { content },
"PUT", "PUT",
) )
if (response.status == 401) { if (response.status == 401) {
navigate("/login") navigate("/login")
@ -212,47 +276,70 @@ function Editor({ id, name, courtType, content, rootStepNode }: EditorProps) {
`tactics/${id}/name`, `tactics/${id}/name`,
{ name }, { name },
"PUT", "PUT",
) )
if (response.status == 401) { if (response.status == 401) {
navigate("/login") navigate("/login")
} }
return response.ok return response.ok
}} }}
onStepSelected={() => { selectStep={async (step) => {
const response = await fetchAPIGet(
`tactics/${id}/steps/${step}`,
)
if (!response.ok) return null
setStepContent(
{ stepId: step, ...(await response.json()) },
false,
)
}}
onAddStep={async (parent) => {
const response = await fetchAPI(`tactics/${id}/steps`, {
parentId: parent.id,
})
if (!response.ok) return null
const { stepId } = await response.json()
return { id: stepId, children: [] }
}}
onRemoveStep={async (step) => {
const response = await fetchAPI(
`tactics/${id}/steps/${step.id}`,
{},
"DELETE",
)
return response.ok
}} }}
stepsContentsRoot={rootStepNode}
courtType={courtType}
/> />
) )
} }
export interface EditorViewProps { export interface EditorViewProps {
tactic: Tactic tactic: TacticInfo
onContentChange: (tactic: StepContent) => Promise<SaveState> currentStepContent: StepContent
onStepSelected: (stepId: number) => void, currentStepId: number
saveState: SaveState
setCurrentStepContent: Dispatch<SetStateAction<StepContent>>
selectStep: (stepId: number) => void
onNameChange: (name: string) => Promise<boolean> onNameChange: (name: string) => Promise<boolean>
stepsContentsRoot: StepInfoNode onRemoveStep: (step: StepInfoNode) => Promise<boolean>
onAddStep: (parent: StepInfoNode) => Promise<StepInfoNode | null>
courtType: "PLAIN" | "HALF" courtType: "PLAIN" | "HALF"
} }
function EditorView({ function EditorView({
tactic: { name, rootStepNode: initialStepsNode },
tactic: {id, name, currentStepContent: initialContent}, setCurrentStepContent: setContent,
onContentChange, currentStepContent: content,
onNameChange, saveState,
onStepSelected, onNameChange,
stepsContentsRoot, selectStep,
courtType, onRemoveStep,
}: EditorViewProps) { onAddStep,
const isInGuestMode = id == -1 courtType,
}: EditorViewProps) {
const [titleStyle, setTitleStyle] = useState<CSSProperties>({}) const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
const [content, setContent, saveState] = useContentState(
initialContent, const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode)
isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
useMemo(() => debounceAsync(onContentChange), [onContentChange]),
)
const [allies, setAllies] = useState(() => const [allies, setAllies] = useState(() =>
getRackPlayers(PlayerTeam.Allies, content.components), getRackPlayers(PlayerTeam.Allies, content.components),
@ -262,7 +349,7 @@ function EditorView({
) )
const [objects, setObjects] = useState<RackedCourtObject[]>(() => const [objects, setObjects] = useState<RackedCourtObject[]>(() =>
isBallOnCourt(content) ? [] : [{key: "ball"}], isBallOnCourt(content) ? [] : [{ key: "ball" }],
) )
const [previewAction, setPreviewAction] = useState<ActionPreview | null>( const [previewAction, setPreviewAction] = useState<ActionPreview | null>(
@ -287,7 +374,7 @@ function EditorView({
) )
useEffect(() => { useEffect(() => {
setObjects(isBallOnCourt(content) ? [] : [{key: "ball"}]) setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }])
}, [setObjects, content]) }, [setObjects, content])
const insertRackedPlayer = (player: Player) => { const insertRackedPlayer = (player: Player) => {
@ -300,7 +387,7 @@ function EditorView({
setter = setAllies setter = setAllies
} }
if (player.ballState == BallState.HOLDS_BY_PASS) { if (player.ballState == BallState.HOLDS_BY_PASS) {
setObjects([{key: "ball"}]) setObjects([{ key: "ball" }])
} }
setter((players) => [ setter((players) => [
...players, ...players,
@ -499,7 +586,7 @@ function EditorView({
setContent((content) => removeBall(content)) setContent((content) => removeBall(content))
setObjects((objects) => [ setObjects((objects) => [
...objects, ...objects,
{key: "ball"}, { key: "ball" },
]) ])
}} }}
/> />
@ -536,7 +623,7 @@ function EditorView({
<div id="main-div"> <div id="main-div">
<div id="topbar-div"> <div id="topbar-div">
<div id="topbar-left"> <div id="topbar-left">
<SavingState state={saveState}/> <SavingState state={saveState} />
</div> </div>
<div id="title-input-div"> <div id="title-input-div">
<TitleInput <TitleInput
@ -608,7 +695,7 @@ function EditorView({
<div id="court-div-bounds"> <div id="court-div-bounds">
<BasketCourt <BasketCourt
components={content.components} components={content.components}
courtImage={<Court courtType={courtType}/>} courtImage={<Court courtType={courtType} />}
courtRef={courtRef} courtRef={courtRef}
previewAction={previewAction} previewAction={previewAction}
renderComponent={renderComponent} renderComponent={renderComponent}
@ -617,7 +704,39 @@ function EditorView({
</div> </div>
</div> </div>
</div> </div>
<EditorStepsTree isVisible={isStepsTreeVisible} root={stepsContentsRoot}/> <EditorStepsTree
isVisible={isStepsTreeVisible}
root={rootStepsNode}
onAddChildren={useCallback(
async (parent) => {
const addedNode = await onAddStep(parent)
if (addedNode == null) {
console.error(
"could not add step : onAddStep returned null node",
)
return
}
setRootStepsNode((root) =>
addStepNode(root, parent, addedNode),
)
},
[onAddStep],
)}
onRemoveNode={useCallback(
async (removed) => {
const isOk = await onRemoveStep(removed)
if (isOk)
setRootStepsNode(
(root) => removeStepNode(root, removed)!,
)
},
[onRemoveStep],
)}
onStepSelected={useCallback(
(node) => selectStep(node.id),
[selectStep],
)}
/>
</div> </div>
</div> </div>
) )
@ -626,60 +745,30 @@ function EditorView({
interface EditorStepsTreeProps { interface EditorStepsTreeProps {
isVisible: boolean isVisible: boolean
root: StepInfoNode root: StepInfoNode
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void
} }
function EditorStepsTree({isVisible, root}: EditorStepsTreeProps) { function EditorStepsTree({
const fakeRoot: StepInfoNode = { isVisible,
id: 0, root,
children: [ onAddChildren,
{ onRemoveNode,
id: 1, onStepSelected,
children: [ }: EditorStepsTreeProps) {
{
id: 2,
children: []
},
{
id: 3,
children: [{
id: 4,
children: []
}]
}
]
},
{
id: 5,
children: [
{
id: 6,
children: []
},
{
id: 7,
children: []
}
]
},
{
id: 8,
children: [
{
id: 9,
children: [
{
id: 10,
children: []
}
]
}
]
}
]
}
return ( return (
<div id="steps-div" style={{transform: isVisible ? "translateX(0)" : "translateX(100%)"}}> <div
<StepsTree root={fakeRoot}/> id="steps-div"
style={{
transform: isVisible ? "translateX(0)" : "translateX(100%)",
}}>
<StepsTree
root={root}
onStepSelected={onStepSelected}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
/>
</div> </div>
) )
} }
@ -695,12 +784,12 @@ interface PlayerRackProps {
} }
function PlayerRack({ function PlayerRack({
id, id,
objects, objects,
setObjects, setObjects,
courtRef, courtRef,
setComponents, setComponents,
}: PlayerRackProps) { }: PlayerRackProps) {
const courtBounds = useCallback( const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(), () => courtRef.current!.getBoundingClientRect(),
[courtRef], [courtRef],
@ -728,7 +817,7 @@ function PlayerRack({
[courtBounds, setComponents], [courtBounds, setComponents],
)} )}
render={useCallback( render={useCallback(
({team, key}: { team: PlayerTeam; key: string }) => ( ({ team, key }: { team: PlayerTeam; key: string }) => (
<PlayerPiece <PlayerPiece
team={team} team={team}
text={key} text={key}
@ -754,15 +843,15 @@ interface CourtPlayerArrowActionProps {
} }
function CourtPlayerArrowAction({ function CourtPlayerArrowAction({
playerInfo, playerInfo,
player, player,
isInvalid, isInvalid,
content, content,
setContent, setContent,
setPreviewAction, setPreviewAction,
courtRef, courtRef,
}: CourtPlayerArrowActionProps) { }: CourtPlayerArrowActionProps) {
const courtBounds = useCallback( const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(), () => courtRef.current!.getBoundingClientRect(),
[courtRef], [courtRef],
@ -817,7 +906,7 @@ function CourtPlayerArrowAction({
} }
setContent((content) => { setContent((content) => {
let {createdAction, newContent} = createAction( let { createdAction, newContent } = createAction(
player, player,
courtBounds(), courtBounds(),
headRect, headRect,
@ -870,18 +959,18 @@ function isBallOnCourt(content: StepContent) {
function renderCourtObject(courtObject: RackedCourtObject) { function renderCourtObject(courtObject: RackedCourtObject) {
if (courtObject.key == "ball") { if (courtObject.key == "ball") {
return <BallPiece/> return <BallPiece />
} }
throw new Error("unknown racked court object " + courtObject.key) throw new Error("unknown racked court object " + courtObject.key)
} }
function Court({courtType}: { courtType: string }) { function Court({ courtType }: { courtType: string }) {
return ( return (
<div id="court-image-div"> <div id="court-image-div">
{courtType == "PLAIN" ? ( {courtType == "PLAIN" ? (
<PlainCourt id="court-image"/> <PlainCourt id="court-image" />
) : ( ) : (
<HalfCourt id="court-image"/> <HalfCourt id="court-image" />
)} )}
</div> </div>
) )
@ -904,19 +993,23 @@ function useContentState<S>(
initialContent: S, initialContent: S,
initialSaveState: SaveState, initialSaveState: SaveState,
saveStateCallback: (s: S) => Promise<SaveState>, saveStateCallback: (s: S) => Promise<SaveState>,
): [S, Dispatch<SetStateAction<S>>, SaveState] { ): [
S,
(newState: SetStateAction<S>, callSaveCallback: boolean) => void,
SaveState,
] {
const [content, setContent] = useState(initialContent) const [content, setContent] = useState(initialContent)
const [savingState, setSavingState] = useState(initialSaveState) const [savingState, setSavingState] = useState(initialSaveState)
const setContentSynced = useCallback( const setContentSynced = useCallback(
(newState: SetStateAction<S>) => { (newState: SetStateAction<S>, callSaveCallback: boolean) => {
setContent((content) => { setContent((content) => {
const state = const state =
typeof newState === "function" typeof newState === "function"
? (newState as (state: S) => S)(content) ? (newState as (state: S) => S)(content)
: newState : newState
if (state !== content) { if (state !== content && callSaveCallback) {
setSavingState(SaveStates.Saving) setSavingState(SaveStates.Saving)
saveStateCallback(state) saveStateCallback(state)
.then(setSavingState) .then(setSavingState)

@ -61,11 +61,15 @@
#steps-div { #steps-div {
background-color: var(--editor-tree-background); background-color: var(--editor-tree-background);
overflow: hidden;
width: 20%; width: 20%;
transform: translateX(100%); transform: translateX(100%);
transition: transform 500ms; transition: transform 500ms;
overflow: scroll;
}
#steps-div::-webkit-scrollbar {
display: none;
} }
#allies-rack, #allies-rack,

@ -1,4 +1,5 @@
.step-piece { .step-piece {
position: relative;
font-family: monospace; font-family: monospace;
pointer-events: all; pointer-events: all;
@ -16,6 +17,42 @@
justify-content: center; justify-content: center;
user-select: none; user-select: none;
cursor: pointer;
}
.step-piece:focus,
.step-piece:hover {
background-color: var(--editor-tree-step-piece-hovered);
}
.step-piece:focus-within .step-piece-actions {
visibility: visible;
}
.step-piece:hover .step-piece-actions {
visibility: visible;
}
.step-piece-actions {
visibility: hidden;
display: flex;
position: absolute;
column-gap: 5px;
top: -140%;
}
.add-icon,
.remove-icon {
background-color: white;
border-radius: 100%;
}
.add-icon {
fill: var(--add-icon-fill);
}
.remove-icon {
fill: var(--remove-icon-fill);
} }
.step-children { .step-children {

@ -31,4 +31,8 @@
--font-content: Helvetica; --font-content: Helvetica;
--editor-tree-background: #503636; --editor-tree-background: #503636;
--editor-tree-step-piece: #0bd9d9; --editor-tree-step-piece: #0bd9d9;
--editor-tree-step-piece-hovered: #ea9b9b;
--add-icon-fill: #00a206;
--remove-icon-fill: #e50046;
} }

Loading…
Cancel
Save