+
+
+ {onAddButtonClicked && (
+
onAddButtonClicked()}
+ className={"add-icon"}
+ />
+ )}
+ {onRemoveButtonClicked && (
+ onRemoveButtonClicked()}
+ className={"remove-icon"}
+ />
+ )}
+
{id}
)
diff --git a/src/editor/ActionsDomains.ts b/src/editor/ActionsDomains.ts
index 3e94bc8..7894cce 100644
--- a/src/editor/ActionsDomains.ts
+++ b/src/editor/ActionsDomains.ts
@@ -4,15 +4,15 @@ import {
PlayerLike,
PlayerPhantom,
} from "../model/tactic/Player"
-import {ratioWithinBase} from "../geo/Pos"
+import { ratioWithinBase } from "../geo/Pos"
import {
ComponentId,
TacticComponent,
StepContent,
} from "../model/tactic/Tactic"
-import {overlaps} from "../geo/Box"
-import {Action, ActionKind, moves} from "../model/tactic/Action"
-import {removeBall, updateComponent} from "./TacticContentDomains"
+import { overlaps } from "../geo/Box"
+import { Action, ActionKind, moves } from "../model/tactic/Action"
+import { removeBall, updateComponent } from "./TacticContentDomains"
import {
areInSamePath,
changePlayerBallState,
@@ -22,7 +22,7 @@ import {
isNextInPath,
removePlayer,
} from "./PlayerDomains"
-import {BALL_TYPE} from "../model/tactic/CourtObjects"
+import { BALL_TYPE } from "../model/tactic/CourtObjects"
export function getActionKind(
target: TacticComponent | null,
@@ -31,12 +31,12 @@ export function getActionKind(
switch (ballState) {
case BallState.HOLDS_ORIGIN:
return target
- ? {kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN}
- : {kind: ActionKind.DRIBBLE, nextState: ballState}
+ ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN }
+ : { kind: ActionKind.DRIBBLE, nextState: ballState }
case BallState.HOLDS_BY_PASS:
return target
- ? {kind: ActionKind.SHOOT, nextState: BallState.PASSED}
- : {kind: ActionKind.DRIBBLE, nextState: ballState}
+ ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED }
+ : { kind: ActionKind.DRIBBLE, nextState: ballState }
case BallState.PASSED_ORIGIN:
case BallState.PASSED:
case BallState.NONE:
@@ -222,7 +222,7 @@ export function createAction(
forceHasBall: boolean,
attachedTo?: ComponentId,
): ComponentId {
- const {x, y} = ratioWithinBase(arrowHead, courtBounds)
+ const { x, y } = ratioWithinBase(arrowHead, courtBounds)
let itemIndex: number
let originPlayer: Player
@@ -274,14 +274,14 @@ export function createAction(
id: phantomId,
pos: attachedTo
? {
- type: "follows",
- attach: attachedTo,
- }
+ type: "follows",
+ attach: attachedTo,
+ }
: {
- type: "fixed",
- x,
- y,
- },
+ type: "fixed",
+ x,
+ y,
+ },
originPlayerId: originPlayer.id,
ballState: phantomState,
actions: [],
@@ -320,13 +320,13 @@ export function createAction(
action = {
target: toId,
type: actionKind,
- segments: [{next: toId}],
+ segments: [{ next: toId }],
}
} else {
action = {
target: toId,
type: actionKind,
- segments: [{next: toId}],
+ segments: [{ next: toId }],
}
}
@@ -355,7 +355,7 @@ export function createAction(
const action: Action = {
target: phantomId,
type: actionKind,
- segments: [{next: phantomId}],
+ segments: [{ next: phantomId }],
}
return {
newContent: updateComponent(
@@ -535,7 +535,7 @@ export function spreadNewStateFromOriginStateChange(
i-- // step back
} else {
// do not change the action type if it is a shoot action
- const {kind, nextState} = getActionKindBetween(
+ const { kind, nextState } = getActionKindBetween(
origin,
actionTarget,
newState,
diff --git a/src/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts
index 40da34d..0a59847 100644
--- a/src/editor/PlayerDomains.ts
+++ b/src/editor/PlayerDomains.ts
@@ -1,11 +1,31 @@
-import {BallState, Player, PlayerLike, PlayerPhantom,} from "../model/tactic/Player"
-import {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"
+import {
+ BallState,
+ Player,
+ PlayerLike,
+ PlayerPhantom,
+} from "../model/tactic/Player"
+import {
+ 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(
pathItem: PlayerPhantom,
@@ -270,9 +290,9 @@ export function truncatePlayerPath(
truncateStartIdx == 0
? null
: {
- ...path,
- items: path.items.toSpliced(truncateStartIdx),
- },
+ ...path,
+ items: path.items.toSpliced(truncateStartIdx),
+ },
},
content,
)
diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts
new file mode 100644
index 0000000..46c7515
--- /dev/null
+++ b/src/editor/StepsDomain.ts
@@ -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] : []
+ }),
+ }
+}
diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts
index 0fc76e1..2c682f5 100644
--- a/src/editor/TacticContentDomains.ts
+++ b/src/editor/TacticContentDomains.ts
@@ -1,4 +1,5 @@
-import {Pos, ratioWithinBase} from "../geo/Pos"
+import { Pos, ratioWithinBase } from "../geo/Pos"
+
import {
BallState,
Player,
@@ -18,10 +19,10 @@ import {
StepContent,
} from "../model/tactic/Tactic"
-import {overlaps} from "../geo/Box"
-import {RackedCourtObject, RackedPlayer} from "./RackedItems"
-import {changePlayerBallState, getComponent, getOrigin} from "./PlayerDomains"
-import {ActionKind} from "../model/tactic/Action.ts"
+import { overlaps } from "../geo/Box"
+import { RackedCourtObject, RackedPlayer } from "./RackedItems"
+import { changePlayerBallState, getComponent, getOrigin } from "./PlayerDomains"
+import { ActionKind } from "../model/tactic/Action.ts"
export function placePlayerAt(
refBounds: DOMRect,
@@ -50,7 +51,6 @@ export function placeObjectAt(
): StepContent {
const pos = ratioWithinBase(refBounds, courtBounds)
-
let courtObject: CourtObject
switch (rackedObject.key) {
@@ -188,9 +188,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(
{
@@ -198,18 +198,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,
),
},
@@ -222,9 +222,9 @@ export function moveComponent(
...component,
pos: isPhantom
? {
- type: "fixed",
- ...newPos,
- }
+ type: "fixed",
+ ...newPos,
+ }
: newPos,
},
content,
@@ -287,5 +287,5 @@ export function getRackPlayers(
c.type == "player" && c.team == team && c.role == role,
) == -1,
)
- .map((key) => ({team, key}))
+ .map((key) => ({ team, key }))
}
diff --git a/src/model/tactic/Tactic.ts b/src/model/tactic/Tactic.ts
index 4dadca8..aa33ea7 100644
--- a/src/model/tactic/Tactic.ts
+++ b/src/model/tactic/Tactic.ts
@@ -2,16 +2,19 @@ import { Player, PlayerPhantom } from "./Player"
import { Action } from "./Action"
import { CourtObject } from "./CourtObjects"
-export interface Tactic {
+export interface TacticInfo {
readonly id: number
readonly name: string
readonly courtType: CourtType
- readonly currentStepContent: StepContent
readonly rootStepNode: StepInfoNode
}
-export interface StepContent {
+export interface TacticStep {
readonly stepId: number
+ readonly content: StepContent
+}
+
+export interface StepContent {
readonly components: TacticComponent[]
}
diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx
index e65a852..94a77db 100644
--- a/src/pages/Editor.tsx
+++ b/src/pages/Editor.tsx
@@ -14,19 +14,24 @@ import TitleInput from "../components/TitleInput"
import PlainCourt from "../assets/court/full_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 {PlayerPiece} from "../components/editor/PlayerPiece"
+import { Rack } from "../components/Rack"
+import { PlayerPiece } from "../components/editor/PlayerPiece"
import {
- CourtType, StepContent, StepInfoNode,
- Tactic,
+ CourtType,
+ StepContent,
+ StepInfoNode,
TacticComponent,
+ TacticInfo,
} from "../model/tactic/Tactic"
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 { CourtAction } from "../components/editor/CourtAction"
@@ -45,13 +50,25 @@ import {
updateComponent,
} from "../editor/TacticContentDomains"
-import {BallState, Player, PlayerInfo, PlayerLike, PlayerTeam,} from "../model/tactic/Player"
-import {RackedCourtObject, RackedPlayer} from "../editor/RackedItems"
+import {
+ BallState,
+ Player,
+ PlayerInfo,
+ PlayerLike,
+ PlayerTeam,
+} from "../model/tactic/Player"
+
+import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems"
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 {middlePos, Pos, ratioWithinBase} from "../geo/Pos"
-import {Action, ActionKind} from "../model/tactic/Action"
+import { middlePos, Pos, ratioWithinBase } from "../geo/Pos"
+import { Action, ActionKind } from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction"
import {
changePlayerBallState,
@@ -63,6 +80,7 @@ import { CourtBall } from "../components/editor/CourtBall"
import { useNavigate, useParams } from "react-router-dom"
import { DEFAULT_TACTIC_NAME } from "./NewTacticPage.tsx"
import StepsTree from "../components/editor/StepsTree"
+import { addStepNode, removeStepNode } from "../editor/StepsDomain"
const ERROR_STYLE: CSSProperties = {
borderColor: "red",
@@ -72,15 +90,16 @@ const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content"
const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
export interface EditorViewProps {
- tactic: Tactic
+ tactic: TacticInfo
onContentChange: (tactic: StepContent) => Promise
onNameChange: (name: string) => Promise
}
+
interface TacticDto {
id: number
name: string
courtType: CourtType
- content: string
+ content: { components: TacticComponent[] }
root: StepInfoNode
}
@@ -94,9 +113,9 @@ export default function EditorPage({ guestMode }: EditorPageProps) {
return {
id: -1,
courtType: "PLAIN",
- content: '{"components": []}',
+ content: { components: [] },
name: DEFAULT_TACTIC_NAME,
- root: {id: 1, children: []}
+ root: { id: 1, children: [] },
}
}
return null
@@ -117,13 +136,17 @@ export default function EditorPage({ guestMode }: EditorPageProps) {
const treeResponse = await treeResponsePromise
const contentResponse = await contentResponsePromise
- if (infoResponse.status == 401 || contentResponse.status == 401) {
+ if (
+ infoResponse.status == 401 ||
+ treeResponse.status == 401 ||
+ contentResponse.status == 401
+ ) {
navigation("/login")
return
}
const { name, courtType } = await infoResponse.json()
- const content = await contentResponse.text()
+ const content = await contentResponse.json()
const { root } = await treeResponse.json()
setTactic({ id, name, courtType, content, root })
@@ -136,10 +159,11 @@ export default function EditorPage({ guestMode }: EditorPageProps) {
return (
)
}
@@ -153,23 +177,63 @@ function EditorLoadingScreen() {
export interface EditorProps {
id: number
- name: string
- content: string
- courtType: CourtType,
- rootStepNode: StepInfoNode
+ initialName: string
+ courtType: "PLAIN" | "HALF"
+ initialStepContent: StepContent
+ 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 navigate = useNavigate()
const storageContent = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY)
- const stepContent =
- isInGuestMode && storageContent != null ? storageContent : content
+ const stepInitialContent = {
+ ...(isInGuestMode && storageContent != null
+ ? JSON.parse(storageContent)
+ : initialStepContent),
+ }
- const storageName = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY)
- const editorName = isInGuestMode && storageName != null ? storageName : name
+ const storage_name = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY)
+ 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 (
setStepContent(content, true)}
+ courtType={courtType}
+ saveState={saveState}
onContentChange={async (content: StepContent) => {
if (isInGuestMode) {
localStorage.setItem(
@@ -195,7 +260,6 @@ function Editor({ id, name, courtType, content, rootStepNode }: EditorProps) {
`tactics/${id}/steps/1`,
{ content },
"PUT",
-
)
if (response.status == 401) {
navigate("/login")
@@ -212,47 +276,70 @@ function Editor({ id, name, courtType, content, rootStepNode }: EditorProps) {
`tactics/${id}/name`,
{ name },
"PUT",
-
)
if (response.status == 401) {
navigate("/login")
}
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 {
- tactic: Tactic
- onContentChange: (tactic: StepContent) => Promise
- onStepSelected: (stepId: number) => void,
+ tactic: TacticInfo
+ currentStepContent: StepContent
+ currentStepId: number
+ saveState: SaveState
+ setCurrentStepContent: Dispatch>
+
+ selectStep: (stepId: number) => void
onNameChange: (name: string) => Promise
- stepsContentsRoot: StepInfoNode
+ onRemoveStep: (step: StepInfoNode) => Promise
+ onAddStep: (parent: StepInfoNode) => Promise
courtType: "PLAIN" | "HALF"
}
function EditorView({
-
- tactic: {id, name, currentStepContent: initialContent},
- onContentChange,
- onNameChange,
- onStepSelected,
- stepsContentsRoot,
- courtType,
- }: EditorViewProps) {
- const isInGuestMode = id == -1
-
+ tactic: { name, rootStepNode: initialStepsNode },
+ setCurrentStepContent: setContent,
+ currentStepContent: content,
+ saveState,
+ onNameChange,
+ selectStep,
+ onRemoveStep,
+ onAddStep,
+ courtType,
+}: EditorViewProps) {
const [titleStyle, setTitleStyle] = useState({})
- const [content, setContent, saveState] = useContentState(
- initialContent,
- isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
- useMemo(() => debounceAsync(onContentChange), [onContentChange]),
- )
+
+ const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode)
const [allies, setAllies] = useState(() =>
getRackPlayers(PlayerTeam.Allies, content.components),
@@ -262,7 +349,7 @@ function EditorView({
)
const [objects, setObjects] = useState(() =>
- isBallOnCourt(content) ? [] : [{key: "ball"}],
+ isBallOnCourt(content) ? [] : [{ key: "ball" }],
)
const [previewAction, setPreviewAction] = useState(
@@ -287,7 +374,7 @@ function EditorView({
)
useEffect(() => {
- setObjects(isBallOnCourt(content) ? [] : [{key: "ball"}])
+ setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }])
}, [setObjects, content])
const insertRackedPlayer = (player: Player) => {
@@ -300,7 +387,7 @@ function EditorView({
setter = setAllies
}
if (player.ballState == BallState.HOLDS_BY_PASS) {
- setObjects([{key: "ball"}])
+ setObjects([{ key: "ball" }])
}
setter((players) => [
...players,
@@ -499,7 +586,7 @@ function EditorView({
setContent((content) => removeBall(content))
setObjects((objects) => [
...objects,
- {key: "ball"},
+ { key: "ball" },
])
}}
/>
@@ -536,7 +623,7 @@ function EditorView({
-
+
}
+ courtImage={}
courtRef={courtRef}
previewAction={previewAction}
renderComponent={renderComponent}
@@ -617,7 +704,39 @@ function EditorView({
-
+ {
+ 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],
+ )}
+ />