diff --git a/ci/build_and_deploy_to.sh b/ci/build_and_deploy_to.sh
index 61c5ecf..eb5d387 100755
--- a/ci/build_and_deploy_to.sh
+++ b/ci/build_and_deploy_to.sh
@@ -1,4 +1,5 @@
-set -e
+#!/usr/bin/env bash
+set -xeu
export OUTPUT=$1
export BASE=$2
@@ -10,9 +11,9 @@ echo "VITE_BASE=$BASE" >> .env.PROD
ci/build_react.msh
-mkdir -p $OUTPUT/profiles/
+mkdir -p "$OUTPUT"/profiles/
-sed -E 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php > $OUTPUT/config.php
+sed -E 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php > "$OUTPUT"/config.php
sed -E "s/const BASE_PATH = .*;/const BASE_PATH = \"$(sed s/\\//\\\\\\//g <<< "$BASE")\";/" profiles/prod-config-profile.php > $OUTPUT/profiles/prod-config-profile.php
-cp -r vendor sql src public $OUTPUT
+cp -r vendor sql src public "$OUTPUT"
diff --git a/package.json b/package.json
index 7eb6cfb..d89f82b 100644
--- a/package.json
+++ b/package.json
@@ -7,8 +7,6 @@
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
- "@types/jest": "^27.5.2",
- "@types/node": "^16.18.59",
"@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14",
"eslint-plugin-react-refresh": "^0.4.5",
@@ -23,7 +21,7 @@
"scripts": {
"start": "vite --host",
"build": "vite build",
- "test": "vite test",
+ "test": "vitest",
"format": "prettier --config .prettierrc '.' --write",
"tsc": "tsc"
},
@@ -34,8 +32,10 @@
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
+ "jsdom": "^24.0.0",
"prettier": "^3.1.0",
"typescript": "^5.2.2",
- "vite-plugin-svgr": "^4.1.0"
+ "vite-plugin-svgr": "^4.1.0",
+ "vitest": "^1.3.1"
}
}
diff --git a/src/App.tsx b/src/App.tsx
index f79f052..29ab83b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,7 +2,7 @@ import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom"
import { Header } from "./pages/template/Header.tsx"
import "./style/app.css"
-import { lazy, ReactNode, Suspense, useEffect } from "react"
+import { lazy, ReactNode, Suspense } from "react"
import { BASE } from "./Constants.ts"
const HomePage = lazy(() => import("./pages/HomePage.tsx"))
@@ -15,7 +15,6 @@ const NewTacticPage = lazy(() => import("./pages/NewTacticPage.tsx"))
const Editor = lazy(() => import("./pages/Editor.tsx"))
export default function App() {
-
function suspense(node: ReactNode) {
return (
Loading, please wait...
}>
diff --git a/src/Fetcher.ts b/src/Fetcher.ts
index 08872c2..a44022f 100644
--- a/src/Fetcher.ts
+++ b/src/Fetcher.ts
@@ -61,7 +61,8 @@ async function handleResponse(
const expirationDate = Date.parse(
response.headers.get("Next-Authorization-Expiration-Date")!,
)
- saveSession({ ...session, auth: { token: nextToken, expirationDate } })
+ if (nextToken && expirationDate)
+ saveSession({ ...session, auth: { token: nextToken, expirationDate } })
return response
}
diff --git a/src/assets/icon/remove.svg b/src/assets/icon/remove.svg
index 29aec4e..a584e15 100644
--- a/src/assets/icon/remove.svg
+++ b/src/assets/icon/remove.svg
@@ -1,5 +1 @@
-
+
\ No newline at end of file
diff --git a/src/components/Rack.tsx b/src/components/Rack.tsx
index 2a7511f..2e4e75a 100644
--- a/src/components/Rack.tsx
+++ b/src/components/Rack.tsx
@@ -4,7 +4,7 @@ import Draggable from "react-draggable"
export interface RackProps {
id: string
objects: E[]
- onChange: (objects: E[]) => void
+ onChange?: (objects: E[]) => void
canDetach: (ref: HTMLDivElement) => boolean
onElementDetached: (ref: HTMLDivElement, el: E) => void
render: (e: E) => ReactElement
@@ -44,7 +44,7 @@ export function Rack({
const index = objects.findIndex(
(o) => o.key === element.key,
)
- onChange(objects.toSpliced(index, 1))
+ if (onChange) onChange(objects.toSpliced(index, 1))
onElementDetached(ref, element)
}}
diff --git a/src/components/SplitLayout.tsx b/src/components/SplitLayout.tsx
new file mode 100644
index 0000000..89c0364
--- /dev/null
+++ b/src/components/SplitLayout.tsx
@@ -0,0 +1,56 @@
+import React, { ReactNode, useCallback, useRef, useState } from "react"
+
+export interface SplitLayoutProps {
+ children: [ReactNode, ReactNode]
+ rightWidth: number
+ onRightWidthChange: (w: number) => void
+}
+
+export default function SplitLayout({
+ children,
+ rightWidth,
+ onRightWidthChange,
+}: SplitLayoutProps) {
+ const curtainRef = useRef(null)
+
+ const resize = useCallback(
+ (e: React.MouseEvent) => {
+ const sliderPosX = e.clientX
+ const curtainWidth =
+ curtainRef.current!.getBoundingClientRect().width
+
+ onRightWidthChange((sliderPosX / curtainWidth) * 100)
+ },
+ [curtainRef, onRightWidthChange],
+ )
+
+ const [resizing, setResizing] = useState(false)
+
+ return (
+ setResizing(false)}>
+
+ {children[0]}
+
+
setResizing(true)}
+ style={{
+ width: 4,
+ height: "100%",
+ backgroundColor: "grey",
+ cursor: "col-resize",
+ userSelect: "none",
+ }}>
+
+
+ {children[1]}
+
+
+ )
+}
diff --git a/src/components/arrows/BendableArrow.tsx b/src/components/arrows/BendableArrow.tsx
index 18aeea3..4b3615f 100644
--- a/src/components/arrows/BendableArrow.tsx
+++ b/src/components/arrows/BendableArrow.tsx
@@ -27,6 +27,7 @@ import {
import "../../style/bendable_arrows.css"
import Draggable from "react-draggable"
+import { Segment } from "../../model/tactic/Action.ts"
export interface BendableArrowProps {
area: RefObject
@@ -57,11 +58,6 @@ const ArrowStyleDefaults: ArrowStyle = {
color: "black",
}
-export interface Segment {
- next: Pos | string
- controlPoint?: Pos
-}
-
/**
* Given a circle shaped by a central position, and a radius, return
* a position that is constrained on its perimeter, pointing to the direction
@@ -389,6 +385,7 @@ export default function BendableArrow({
useEffect(() => {
const observer = new MutationObserver(update)
const config = { attributes: true }
+
if (typeof startPos == "string") {
observer.observe(document.getElementById(startPos)!, config)
}
@@ -402,6 +399,14 @@ export default function BendableArrow({
return () => observer.disconnect()
}, [startPos, segments, update])
+ useEffect(() => {
+ const observer = new ResizeObserver(update)
+
+ observer.observe(area.current!, {})
+
+ return () => observer.disconnect()
+ })
+
// Adds a selection handler
// Also force an update when the window is resized
useEffect(() => {
diff --git a/src/components/editor/BasketCourt.tsx b/src/components/editor/BasketCourt.tsx
index 4f4c216..68021f3 100644
--- a/src/components/editor/BasketCourt.tsx
+++ b/src/components/editor/BasketCourt.tsx
@@ -1,7 +1,7 @@
import { ReactElement, ReactNode, RefObject } from "react"
import { Action } from "../../model/tactic/Action"
-import { CourtAction } from "./CourtAction.tsx"
+import { CourtAction } from "./CourtAction"
import { ComponentId, TacticComponent } from "../../model/tactic/Tactic"
export interface BasketCourtProps {
diff --git a/src/components/editor/CourtAction.tsx b/src/components/editor/CourtAction.tsx
index c26c0d9..84f7fd5 100644
--- a/src/components/editor/CourtAction.tsx
+++ b/src/components/editor/CourtAction.tsx
@@ -1,7 +1,7 @@
import { Action, ActionKind } from "../../model/tactic/Action"
-import BendableArrow from "../../components/arrows/BendableArrow"
+import BendableArrow from "../arrows/BendableArrow"
import { RefObject } from "react"
-import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction"
+import { MoveToHead, ScreenHead } from "../actions/ArrowAction"
import { ComponentId } from "../../model/tactic/Tactic"
export interface CourtActionProps {
diff --git a/src/components/editor/CourtBall.tsx b/src/components/editor/CourtBall.tsx
index c9c48dc..503f44c 100644
--- a/src/components/editor/CourtBall.tsx
+++ b/src/components/editor/CourtBall.tsx
@@ -1,34 +1,36 @@
-import { useRef } from "react"
+import { KeyboardEventHandler, RefObject, useRef } from "react"
import Draggable from "react-draggable"
import { BallPiece } from "./BallPiece"
-import { NULL_POS } from "../../geo/Pos"
+import { NULL_POS, Pos } from "../../geo/Pos"
import { Ball } from "../../model/tactic/CourtObjects"
export interface CourtBallProps {
+ ball: Ball
+}
+
+export interface EditableCourtBallProps extends CourtBallProps {
onPosValidated: (rect: DOMRect) => void
onRemove: () => void
- ball: Ball
}
-export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) {
+export function CourtBall({
+ onPosValidated,
+ ball,
+ onRemove,
+}: EditableCourtBallProps) {
const pieceRef = useRef(null)
- const { x, y } = ball.pos
-
- return (
-
- onPosValidated(pieceRef.current!.getBoundingClientRect())
- }
- position={NULL_POS}
- nodeRef={pieceRef}>
+ function courtBallPiece(
+ { x, y }: Pos,
+ pieceRef?: RefObject,
+ onKeyUp?: KeyboardEventHandler,
+ ) {
+ return (
{
- if (e.key == "Delete") onRemove()
- }}
+ onKeyUp={onKeyUp}
style={{
position: "absolute",
left: `${x * 100}%`,
@@ -36,6 +38,23 @@ export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) {
}}>
+ )
+ }
+
+ if (ball.frozen) {
+ return courtBallPiece(ball.pos)
+ }
+
+ return (
+
+ onPosValidated(pieceRef.current!.getBoundingClientRect())
+ }
+ position={NULL_POS}
+ nodeRef={pieceRef}>
+ {courtBallPiece(ball.pos, pieceRef, (e) => {
+ if (e.key == "Delete") onRemove()
+ })}
)
}
diff --git a/src/components/editor/CourtPlayer.tsx b/src/components/editor/CourtPlayer.tsx
index 6b8e8dd..c25b36a 100644
--- a/src/components/editor/CourtPlayer.tsx
+++ b/src/components/editor/CourtPlayer.tsx
@@ -1,4 +1,10 @@
-import React, { ReactNode, RefObject, useCallback, useRef } from "react"
+import React, {
+ KeyboardEventHandler,
+ ReactNode,
+ RefObject,
+ useCallback,
+ useRef,
+} from "react"
import "../../style/player.css"
import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece"
@@ -8,38 +14,53 @@ import { NULL_POS, Pos, ratioWithinBase } from "../../geo/Pos"
export interface CourtPlayerProps {
playerInfo: PlayerInfo
className?: string
+ availableActions: (ro: HTMLElement) => ReactNode[]
+}
+export interface EditableCourtPlayerProps extends CourtPlayerProps {
+ courtRef: RefObject
onPositionValidated: (newPos: Pos) => void
onRemove: () => void
- courtRef: RefObject
- availableActions: (ro: HTMLElement) => ReactNode[]
}
const MOVE_AREA_SENSIBILITY = 0.001
export const PLAYER_RADIUS_PIXELS = 20
+export function CourtPlayer({
+ playerInfo,
+ className,
+ availableActions,
+}: CourtPlayerProps) {
+ const pieceRef = useRef(null)
+
+ return courtPlayerPiece({
+ playerInfo,
+ pieceRef,
+ className,
+ availableActions: () => availableActions(pieceRef.current!),
+ })
+}
+
/**
* A player that is placed on the court, which can be selected, and moved in the associated bounds
* */
-export default function CourtPlayer({
+export function EditableCourtPlayer({
playerInfo,
className,
+ courtRef,
onPositionValidated,
onRemove,
- courtRef,
availableActions,
-}: CourtPlayerProps) {
- const usesBall = playerInfo.ballState != BallState.NONE
- const { x, y } = playerInfo.pos
+}: EditableCourtPlayerProps) {
const pieceRef = useRef(null)
+ const { x, y } = playerInfo.pos
return (
{
const pieceBounds = pieceRef.current!.getBoundingClientRect()
@@ -50,37 +71,64 @@ export default function CourtPlayer({
if (
Math.abs(pos.x - x) >= MOVE_AREA_SENSIBILITY ||
Math.abs(pos.y - y) >= MOVE_AREA_SENSIBILITY
- )
+ ) {
onPositionValidated(pos)
+ }
}, [courtRef, onPositionValidated, x, y])}>
-
-
) => {
- if (e.key == "Delete") onRemove()
- },
- [onRemove],
- )}>
-
- {availableActions(pieceRef.current!)}
-
-
-
-
+ {courtPlayerPiece({
+ playerInfo,
+ className,
+ pieceRef,
+ availableActions: () => availableActions(pieceRef.current!),
+ onKeyUp: useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key == "Delete") onRemove()
+ },
+ [onRemove],
+ ),
+ })}
)
}
+
+interface CourtPlayerPieceProps {
+ playerInfo: PlayerInfo
+ className?: string
+ pieceRef?: RefObject
+ availableActions?: () => ReactNode[]
+ onKeyUp?: KeyboardEventHandler
+}
+
+function courtPlayerPiece({
+ playerInfo,
+ className,
+ pieceRef,
+ onKeyUp,
+ availableActions,
+}: CourtPlayerPieceProps) {
+ const usesBall = playerInfo.ballState != BallState.NONE
+ const { x, y } = playerInfo.pos
+
+ return (
+
+
+ {availableActions && (
+
{availableActions()}
+ )}
+
+
+
+ )
+}
diff --git a/src/components/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx
new file mode 100644
index 0000000..952b268
--- /dev/null
+++ b/src/components/editor/StepsTree.tsx
@@ -0,0 +1,154 @@
+import "../../style/steps_tree.css"
+import { StepInfoNode } from "../../model/tactic/Tactic"
+import BendableArrow from "../arrows/BendableArrow"
+import { ReactNode, useMemo, useRef } from "react"
+import AddSvg from "../../assets/icon/add.svg?react"
+import RemoveSvg from "../../assets/icon/remove.svg?react"
+import { getStepName } from "../../editor/StepsDomain.ts"
+
+export interface StepsTreeProps {
+ root: StepInfoNode
+ selectedStepId: number
+ onAddChildren: (parent: StepInfoNode) => void
+ onRemoveNode: (node: StepInfoNode) => void
+ onStepSelected: (node: StepInfoNode) => void
+}
+
+export default function StepsTree({
+ root,
+ selectedStepId,
+ onAddChildren,
+ onRemoveNode,
+ onStepSelected,
+}: StepsTreeProps) {
+ return (
+
+
+
+ )
+}
+
+interface StepsTreeContentProps {
+ node: StepInfoNode
+ rootNode: StepInfoNode
+
+ selectedStepId: number
+ onAddChildren: (parent: StepInfoNode) => void
+ onRemoveNode: (node: StepInfoNode) => void
+ onStepSelected: (node: StepInfoNode) => void
+}
+
+function StepsTreeNode({
+ node,
+ rootNode,
+ selectedStepId,
+ onAddChildren,
+ onRemoveNode,
+ onStepSelected,
+}: StepsTreeContentProps) {
+ const ref = useRef(null)
+
+ return (
+
+ {node.children.map((child) => (
+
{}}
+ forceStraight={true}
+ wavy={false}
+ //TODO remove magic constants
+ startRadius={10}
+ endRadius={10}
+ />
+ ))}
+ onAddChildren(node)}
+ onRemoveButtonClicked={
+ rootNode.id === node.id
+ ? undefined
+ : () => onRemoveNode(node)
+ }
+ onSelected={() => onStepSelected(node)}>
+
+ {useMemo(
+ () => getStepName(rootNode, node.id),
+ [node.id, rootNode],
+ )}
+
+
+
+ {node.children.map((child) => (
+
+ ))}
+
+
+ )
+}
+
+interface StepPieceProps {
+ id: number
+ isSelected: boolean
+ onAddButtonClicked?: () => void
+ onRemoveButtonClicked?: () => void
+ onSelected: () => void
+ children?: ReactNode
+}
+
+function StepPiece({
+ id,
+ isSelected,
+ onAddButtonClicked,
+ onRemoveButtonClicked,
+ onSelected,
+ children,
+}: StepPieceProps) {
+ return (
+
+
+ {onAddButtonClicked && (
+
+ )}
+ {onRemoveButtonClicked && (
+
+ )}
+
+ {children}
+
+ )
+}
diff --git a/src/editor/ActionsDomains.ts b/src/editor/ActionsDomains.ts
index c9e7e66..8ad9e5a 100644
--- a/src/editor/ActionsDomains.ts
+++ b/src/editor/ActionsDomains.ts
@@ -7,15 +7,14 @@ import {
import { ratioWithinBase } from "../geo/Pos"
import {
ComponentId,
+ StepContent,
TacticComponent,
- TacticContent,
} 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,
@@ -212,8 +211,8 @@ export function createAction(
origin: PlayerLike,
courtBounds: DOMRect,
arrowHead: DOMRect,
- content: TacticContent,
-): { createdAction: Action; newContent: TacticContent } {
+ content: StepContent,
+): { createdAction: Action; newContent: StepContent } {
/**
* Creates a new phantom component.
* Be aware that this function will reassign the `content` parameter.
@@ -371,8 +370,8 @@ export function createAction(
export function removeAllActionsTargeting(
componentId: ComponentId,
- content: TacticContent,
-): TacticContent {
+ content: StepContent,
+): StepContent {
const components = []
for (let i = 0; i < content.components.length; i++) {
const component = content.components[i]
@@ -391,9 +390,10 @@ export function removeAllActionsTargeting(
export function removeAction(
origin: TacticComponent,
actionIdx: number,
- content: TacticContent,
-): TacticContent {
+ content: StepContent,
+): StepContent {
const action = origin.actions[actionIdx]
+
origin = {
...origin,
actions: origin.actions.toSpliced(actionIdx, 1),
@@ -410,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
}
}
@@ -458,14 +465,15 @@ export function removeAction(
* @param origin
* @param newState
* @param content
+ * @returns the new state if it has been updated, or null if no changes were operated
*/
export function spreadNewStateFromOriginStateChange(
origin: PlayerLike,
newState: BallState,
- content: TacticContent,
-): TacticContent {
+ content: StepContent,
+): StepContent | null {
if (origin.ballState === newState) {
- return content
+ return null
}
origin = {
@@ -551,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 0473d72..1b3c918 100644
--- a/src/editor/PlayerDomains.ts
+++ b/src/editor/PlayerDomains.ts
@@ -6,9 +6,10 @@ import {
} from "../model/tactic/Player"
import {
ComponentId,
+ StepContent,
TacticComponent,
- TacticContent,
} from "../model/tactic/Tactic"
+
import { removeComponent, updateComponent } from "./TacticContentDomains"
import {
removeAllActionsTargeting,
@@ -54,11 +55,22 @@ 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 pos = phantom.pos
+
+ // If the position is already known and fixed, return the pos
+ if (pos.type === "fixed") return pos
+
+ return computedPositions.get(phantom.id)
+}
+
export function computePhantomPositioning(
phantom: PlayerPhantom,
- content: TacticContent,
+ content: StepContent,
+ computedPositions: Map,
area: DOMRect,
): Pos {
const positioning = phantom.pos
@@ -66,6 +78,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.
@@ -76,7 +91,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)
@@ -110,6 +130,7 @@ export function computePhantomPositioning(
? computePhantomPositioning(
playerBeforePhantom,
content,
+ computedPositions,
area,
)
: playerBeforePhantom.pos
@@ -118,21 +139,29 @@ export function computePhantomPositioning(
const segment = posWithinBase(relativeTo(referentPos, pivotPoint), area)
const segmentLength = norm(segment)
- const phantomDistanceFromReferent = PLAYER_RADIUS_PIXELS //TODO Place this in constants
const segmentProjection = minus(area, {
- x: (segment.x / segmentLength) * phantomDistanceFromReferent,
- y: (segment.y / segmentLength) * phantomDistanceFromReferent,
+ x: (segment.x / segmentLength) * PLAYER_RADIUS_PIXELS,
+ y: (segment.y / segmentLength) * PLAYER_RADIUS_PIXELS,
})
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) {
@@ -171,8 +200,8 @@ export function isNextInPath(
export function clearPlayerPath(
player: Player,
- content: TacticContent,
-): TacticContent {
+ content: StepContent,
+): StepContent {
if (player.path == null) {
return content
}
@@ -192,8 +221,8 @@ export function clearPlayerPath(
function removeAllPhantomsAttached(
to: ComponentId,
- content: TacticContent,
-): TacticContent {
+ content: StepContent,
+): StepContent {
let i = 0
while (i < content.components.length) {
const component = content.components[i]
@@ -213,8 +242,8 @@ function removeAllPhantomsAttached(
export function removePlayer(
player: PlayerLike,
- content: TacticContent,
-): TacticContent {
+ content: StepContent,
+): StepContent {
content = removeAllActionsTargeting(player.id, content)
content = removeAllPhantomsAttached(player.id, content)
@@ -228,7 +257,7 @@ export function removePlayer(
content.components,
)!
const actions = playerBefore.actions.filter(
- (a) => a.target === pos.attach,
+ (a) => a.target !== pos.attach,
)
content = updateComponent(
{
@@ -253,10 +282,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
)
}
@@ -266,8 +297,8 @@ export function removePlayer(
export function truncatePlayerPath(
player: Player,
phantom: PlayerPhantom,
- content: TacticContent,
-): TacticContent {
+ content: StepContent,
+): StepContent {
if (player.path == null) return content
const path = player.path!
@@ -296,11 +327,3 @@ export function truncatePlayerPath(
content,
)
}
-
-export function changePlayerBallState(
- player: PlayerLike,
- newState: BallState,
- content: TacticContent,
-): TacticContent {
- return spreadNewStateFromOriginStateChange(player, newState, content)
-}
diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts
new file mode 100644
index 0000000..e788a9c
--- /dev/null
+++ b/src/editor/StepsDomain.ts
@@ -0,0 +1,112 @@
+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 getStepName(root: StepInfoNode, step: number): string {
+ let ord = 1
+ const nodes = [root]
+ while (nodes.length > 0) {
+ const node = nodes.pop()!
+
+ if (node.id === step) break
+
+ ord++
+ nodes.push(...[...node.children].reverse())
+ }
+
+ return ord.toString()
+}
+
+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,
+ targetId: number,
+): StepInfoNode | undefined {
+ const path = getPathTo(root, targetId)
+
+ path.reverse()
+
+ const [removedNode, ...pathToRoot] = path
+
+ let child = removedNode
+
+ for (const node of pathToRoot) {
+ child = {
+ id: node.id,
+ children: node.children.flatMap((c) => {
+ if (c.id === removedNode.id) return []
+ else if (c.id === child.id) {
+ return [child]
+ }
+ return [c]
+ }),
+ }
+ }
+
+ return child
+}
+
+export function getPathTo(
+ root: StepInfoNode,
+ targetId: number,
+): StepInfoNode[] {
+ if (root.id === targetId) return [root]
+
+ for (const child of root.children) {
+ const subPath = getPathTo(child, targetId)
+ if (subPath.length > 0) return [root, ...subPath]
+ }
+ return []
+}
+
+/**
+ * Returns an available identifier that is not already present into the given node tree
+ * @param root
+ */
+export function getAvailableId(root: StepInfoNode): number {
+ const acc = (root: StepInfoNode): number =>
+ Math.max(root.id, ...root.children.map(acc))
+ return acc(root) + 1
+}
+
+export function getParent(
+ root: StepInfoNode,
+ node: StepInfoNode,
+): StepInfoNode | null {
+ if (root.children.find((n) => n.id === node.id)) return root
+
+ for (const child of root.children) {
+ const result = getParent(child, node)
+ if (result != null) {
+ return result
+ }
+ }
+ return null
+}
diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts
index 1f3a9cc..319e4a0 100644
--- a/src/editor/TacticContentDomains.ts
+++ b/src/editor/TacticContentDomains.ts
@@ -1,9 +1,11 @@
-import { Pos, ratioWithinBase } from "../geo/Pos"
+import { equals, Pos, ratioWithinBase } from "../geo/Pos"
+
import {
BallState,
Player,
PlayerInfo,
PlayerLike,
+ PlayerPhantom,
PlayerTeam,
} from "../model/tactic/Player"
import {
@@ -14,13 +16,20 @@ import {
} from "../model/tactic/CourtObjects"
import {
ComponentId,
+ StepContent,
TacticComponent,
- TacticContent,
} from "../model/tactic/Tactic"
+
import { overlaps } from "../geo/Box"
import { RackedCourtObject, RackedPlayer } from "./RackedItems"
-import { changePlayerBallState, getComponent, getOrigin } from "./PlayerDomains"
+import {
+ getComponent,
+ getOrigin,
+ getPrecomputedPosition,
+ tryGetComponent,
+} from "./PlayerDomains"
import { ActionKind } from "../model/tactic/Action.ts"
+import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts"
export function placePlayerAt(
refBounds: DOMRect,
@@ -38,6 +47,7 @@ export function placePlayerAt(
ballState: BallState.NONE,
path: null,
actions: [],
+ frozen: false,
}
}
@@ -45,14 +55,14 @@ export function placeObjectAt(
refBounds: DOMRect,
courtBounds: DOMRect,
rackedObject: RackedCourtObject,
- content: TacticContent,
-): TacticContent {
+ content: StepContent,
+): StepContent {
const pos = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject
switch (rackedObject.key) {
- case BALL_TYPE:
+ case BALL_TYPE: {
const playerCollidedIdx = getComponentCollided(
refBounds,
content.components,
@@ -67,9 +77,10 @@ export function placeObjectAt(
id: BALL_ID,
pos,
actions: [],
+ frozen: false,
}
break
-
+ }
default:
throw new Error("unknown court object " + rackedObject.key)
}
@@ -82,9 +93,9 @@ export function placeObjectAt(
export function dropBallOnComponent(
targetedComponentIdx: number,
- content: TacticContent,
+ content: StepContent,
setAsOrigin: boolean,
-): TacticContent {
+): StepContent {
const component = content.components[targetedComponentIdx]
if (component.type === "player" || component.type === "phantom") {
@@ -95,13 +106,15 @@ export function dropBallOnComponent(
? BallState.HOLDS_ORIGIN
: BallState.HOLDS_BY_PASS
- content = changePlayerBallState(component, newState, content)
+ content =
+ spreadNewStateFromOriginStateChange(component, newState, content) ??
+ content
}
return removeBall(content)
}
-export function removeBall(content: TacticContent): TacticContent {
+export function removeBall(content: StepContent): StepContent {
const ballObjIdx = content.components.findIndex((o) => o.type == "ball")
if (ballObjIdx == -1) {
@@ -117,8 +130,8 @@ export function removeBall(content: TacticContent): TacticContent {
export function placeBallAt(
refBounds: DOMRect,
courtBounds: DOMRect,
- content: TacticContent,
-): TacticContent {
+ content: StepContent,
+): StepContent {
if (!overlaps(courtBounds, refBounds)) {
return removeBall(content)
}
@@ -141,6 +154,7 @@ export function placeBallAt(
id: BALL_ID,
pos,
actions: [],
+ frozen: false,
}
let components = content.components
@@ -162,9 +176,9 @@ export function moveComponent(
component: TacticComponent,
info: PlayerInfo,
courtBounds: DOMRect,
- content: TacticContent,
- removed: (content: TacticContent) => TacticContent,
-): TacticContent {
+ content: StepContent,
+ removed: (content: StepContent) => StepContent,
+): StepContent {
const playerBounds = document
.getElementById(info.id)!
.getBoundingClientRect()
@@ -232,8 +246,8 @@ export function moveComponent(
export function removeComponent(
componentId: ComponentId,
- content: TacticContent,
-): TacticContent {
+ content: StepContent,
+): StepContent {
return {
...content,
components: content.components.filter((c) => c.id !== componentId),
@@ -242,8 +256,8 @@ export function removeComponent(
export function updateComponent(
component: TacticComponent,
- content: TacticContent,
-): TacticContent {
+ content: StepContent,
+): StepContent {
return {
...content,
components: content.components.map((c) =>
@@ -287,3 +301,174 @@ export function getRackPlayers(
)
.map((key) => ({ team, key }))
}
+
+/**
+ * Returns a step content that only contains the terminal state of each components inside the given content
+ * @param content
+ * @param computedPositions
+ */
+export function computeTerminalState(
+ content: StepContent,
+ computedPositions: Map,
+): StepContent {
+ const nonPhantomComponents: (Player | CourtObject)[] =
+ content.components.filter(
+ (c): c is Exclude =>
+ c.type !== "phantom",
+ )
+
+ const componentsTargetedState = nonPhantomComponents.map((comp) =>
+ comp.type === "player"
+ ? getPlayerTerminalState(comp, content, computedPositions)
+ : {
+ ...comp,
+ frozen: true,
+ },
+ )
+
+ return {
+ components: componentsTargetedState,
+ }
+}
+
+function getPlayerTerminalState(
+ player: Player,
+ content: StepContent,
+ computedPositions: Map,
+): Player {
+ function stateAfter(state: BallState): BallState {
+ switch (state) {
+ case BallState.HOLDS_ORIGIN:
+ return BallState.HOLDS_ORIGIN
+ case BallState.PASSED_ORIGIN:
+ case BallState.PASSED:
+ return BallState.NONE
+ case BallState.HOLDS_BY_PASS:
+ return BallState.HOLDS_ORIGIN
+ case BallState.NONE:
+ return BallState.NONE
+ }
+ }
+
+ function getTerminalPos(component: PlayerLike): Pos {
+ 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
+ if (!phantoms || phantoms.length === 0) {
+ const pos = getTerminalPos(player)
+
+ return {
+ ...player,
+ ballState: stateAfter(player.ballState),
+ actions: [],
+ pos,
+ frozen: true,
+ }
+ }
+ const lastPhantomId = phantoms[phantoms.length - 1]
+ const lastPhantom = content.components.find(
+ (c) => c.id === lastPhantomId,
+ )! as PlayerPhantom
+
+ const pos = getTerminalPos(lastPhantom)
+
+ return {
+ type: "player",
+ path: { items: [] },
+ role: player.role,
+ team: player.team,
+
+ actions: [],
+ ballState: stateAfter(lastPhantom.ballState),
+ id: player.id,
+ pos,
+ frozen: true,
+ }
+}
+
+export function drainTerminalStateOnChildContent(
+ parentTerminalState: StepContent,
+ childContent: StepContent,
+): StepContent | null {
+ let gotUpdated = false
+
+ for (const parentComponent of parentTerminalState.components) {
+ let 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
+ childComponent = getComponent(
+ childComponent.id,
+ newContentResult?.components,
+ )
+ }
+ // update the position of the player if it has been moved
+ // also force update if the child component is not frozen (the component was introduced previously by the child step but the parent added it afterward)
+ if (
+ !childComponent.frozen ||
+ !equals(childComponent.pos, parentComponent.pos)
+ ) {
+ gotUpdated = true
+ childContent = updateComponent(
+ {
+ ...childComponent,
+ frozen: true,
+ pos: parentComponent.pos,
+ },
+ childContent,
+ )
+ }
+ }
+
+ const initialChildCompsCount = childContent.components.length
+
+ //filter out all frozen components that are not present on the parent's terminal state anymore
+ childContent = {
+ components: childContent.components.filter(
+ (comp) =>
+ comp.type === "phantom" ||
+ !comp.frozen ||
+ tryGetComponent(comp.id, parentTerminalState.components),
+ ),
+ }
+
+ gotUpdated ||= childContent.components.length !== initialChildCompsCount
+
+ return gotUpdated ? childContent : null
+}
diff --git a/src/geo/Pos.ts b/src/geo/Pos.ts
index be7a704..0e591b3 100644
--- a/src/geo/Pos.ts
+++ b/src/geo/Pos.ts
@@ -3,6 +3,10 @@ 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/Action.ts b/src/model/tactic/Action.ts
index b2dca4f..d4e459f 100644
--- a/src/model/tactic/Action.ts
+++ b/src/model/tactic/Action.ts
@@ -1,5 +1,4 @@
import { Pos } from "../../geo/Pos"
-import { Segment } from "../../components/arrows/BendableArrow"
import { ComponentId } from "./Tactic"
export enum ActionKind {
@@ -11,6 +10,11 @@ export enum ActionKind {
export type Action = MovementAction
+export interface Segment {
+ next: Pos | string
+ controlPoint?: Pos
+}
+
export interface MovementAction {
type: ActionKind
target: ComponentId | Pos
diff --git a/src/model/tactic/CourtObjects.ts b/src/model/tactic/CourtObjects.ts
index 5f72199..55eaf6f 100644
--- a/src/model/tactic/CourtObjects.ts
+++ b/src/model/tactic/CourtObjects.ts
@@ -1,10 +1,10 @@
-import { Component } from "./Tactic"
+import { Component, Frozable } from "./Tactic"
import { Pos } from "../../geo/Pos.ts"
export const BALL_ID = "ball"
export const BALL_TYPE = "ball"
+export type Ball = Component & Frozable
+
//place here all different kinds of objects
export type CourtObject = Ball
-
-export type Ball = Component
diff --git a/src/model/tactic/Player.ts b/src/model/tactic/Player.ts
index 2dee897..bd781ac 100644
--- a/src/model/tactic/Player.ts
+++ b/src/model/tactic/Player.ts
@@ -1,4 +1,4 @@
-import { Component, ComponentId } from "./Tactic"
+import { Component, ComponentId, Frozable } from "./Tactic"
import { Pos } from "../../geo/Pos.ts"
export type PlayerId = string
@@ -14,7 +14,7 @@ export enum PlayerTeam {
* All information about a player
*/
export interface PlayerInfo {
- readonly id: string
+ readonly id: ComponentId
/**
* the player's team
* */
@@ -25,28 +25,20 @@ export interface PlayerInfo {
* */
readonly role: string
- /**
- * True if the player has a basketball
- */
readonly ballState: BallState
readonly pos: Pos
}
export enum BallState {
- NONE,
- HOLDS_ORIGIN,
- HOLDS_BY_PASS,
- PASSED,
- PASSED_ORIGIN,
+ NONE = "NONE",
+ HOLDS_ORIGIN = "HOLDS_ORIGIN",
+ HOLDS_BY_PASS = "HOLDS_BY_PASS",
+ PASSED = "PASSED",
+ PASSED_ORIGIN = "PASSED_ORIGIN",
}
-export interface Player extends Component<"player", Pos>, PlayerInfo {
- /**
- * True if the player has a basketball
- */
- readonly ballState: BallState
-
+export interface Player extends Component<"player", Pos>, PlayerInfo, Frozable {
readonly path: MovementPath | null
}
diff --git a/src/model/tactic/Tactic.ts b/src/model/tactic/Tactic.ts
index 0ad312c..ccf6d43 100644
--- a/src/model/tactic/Tactic.ts
+++ b/src/model/tactic/Tactic.ts
@@ -2,21 +2,30 @@ import { Player, PlayerPhantom } from "./Player"
import { Action } from "./Action"
import { CourtObject } from "./CourtObjects"
-export type CourtType = "HALF" | "PLAIN"
+export interface TacticInfo {
+ readonly id: number
+ readonly name: string
+ readonly courtType: CourtType
+ readonly rootStepNode: StepInfoNode
+}
+
+export interface TacticStep {
+ readonly stepId: number
+ readonly content: StepContent
+}
-export interface Tactic {
- id: number
- name: string
- courtType: CourtType
- content: TacticContent
+export interface StepContent {
+ readonly components: TacticComponent[]
}
-export interface TacticContent {
- components: TacticComponent[]
+export interface StepInfoNode {
+ readonly id: number
+ readonly children: StepInfoNode[]
}
export type TacticComponent = Player | CourtObject | PlayerPhantom
export type ComponentId = string
+export type CourtType = "PLAIN" | "HALF"
export interface Component {
/**
@@ -32,3 +41,7 @@ export interface Component {
readonly actions: Action[]
}
+
+export interface Frozable {
+ readonly frozen: boolean
+}
diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx
index 83c4dac..a0b6708 100644
--- a/src/pages/Editor.tsx
+++ b/src/pages/Editor.tsx
@@ -1,6 +1,5 @@
import {
CSSProperties,
- Dispatch,
RefObject,
SetStateAction,
useCallback,
@@ -20,12 +19,12 @@ import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"
import {
+ ComponentId,
CourtType,
- Tactic,
+ StepContent,
+ StepInfoNode,
TacticComponent,
- TacticContent,
} from "../model/tactic/Tactic"
-import { fetchAPI, fetchAPIGet } from "../Fetcher"
import SavingState, {
SaveState,
@@ -36,7 +35,10 @@ import { BALL_TYPE } from "../model/tactic/CourtObjects"
import { CourtAction } from "../components/editor/CourtAction"
import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt"
import { overlaps } from "../geo/Box"
+
import {
+ computeTerminalState,
+ drainTerminalStateOnChildContent,
dropBallOnComponent,
getComponentCollided,
getRackPlayers,
@@ -47,6 +49,7 @@ import {
removeBall,
updateComponent,
} from "../editor/TacticContentDomains"
+
import {
BallState,
Player,
@@ -54,194 +57,261 @@ import {
PlayerLike,
PlayerTeam,
} from "../model/tactic/Player"
+
import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems"
-import CourtPlayer from "../components/editor/CourtPlayer"
+import {
+ CourtPlayer,
+ EditableCourtPlayer,
+} from "../components/editor/CourtPlayer.tsx"
import {
createAction,
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,
} from "../editor/PlayerDomains"
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,
+ getParent,
+ getStepNode,
+ removeStepNode,
+} from "../editor/StepsDomain"
+import SplitLayout from "../components/SplitLayout.tsx"
+import { ServiceError, TacticService } from "../service/TacticService.ts"
+import { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts"
+import { APITacticService } from "../service/APITacticService.ts"
+import { useParams } from "react-router-dom"
const ERROR_STYLE: CSSProperties = {
borderColor: "red",
}
-const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content"
-const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
+type ComputedRelativePositions = Map
-export interface EditorViewProps {
- tactic: Tactic
- onContentChange: (tactic: TacticContent) => Promise
- onNameChange: (name: string) => Promise
-}
-interface TacticDto {
- id: number
- name: string
- courtType: CourtType
- content: string
+type ComputedStepContent = {
+ content: StepContent
+ relativePositions: ComputedRelativePositions
}
-interface EditorPageProps {
+export interface EditorPageProps {
guestMode: boolean
}
-export default function EditorPage({ guestMode }: EditorPageProps) {
- const [tactic, setTactic] = useState(() => {
- if (guestMode) {
- return {
- id: -1,
- courtType: "PLAIN",
- content: '{"components": []}',
- name: DEFAULT_TACTIC_NAME,
- }
- }
- return null
- })
- const { tacticId: idStr } = useParams()
- const id = guestMode ? -1 : parseInt(idStr!)
- const navigation = useNavigate()
-
- useEffect(() => {
- if (guestMode) return
+export default function Editor({ guestMode }: EditorPageProps) {
+ return
+}
- async function initialize() {
- const infoResponsePromise = fetchAPIGet(`tactics/${id}`)
- const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/1`)
+interface EditorService {
+ addStep(
+ parent: StepInfoNode,
+ content: StepContent,
+ ): Promise
- const infoResponse = await infoResponsePromise
- const contentResponse = await contentResponsePromise
+ removeStep(step: number): Promise
- if (infoResponse.status == 401 || contentResponse.status == 401) {
- navigation("/login")
- return
- }
+ selectStep(step: number): Promise
- const { name, courtType } = await infoResponse.json()
- const content = await contentResponse.text()
+ setContent(content: SetStateAction): void
- setTactic({ id, name, courtType, content })
- }
+ setName(name: string): Promise
+}
- initialize()
- }, [guestMode, id, idStr, navigation])
+function EditorPortal({ guestMode }: EditorPageProps) {
+ const { tacticId: idStr } = useParams()
- if (tactic) {
- return (
-
- )
+ if (guestMode || !idStr) {
+ return
}
- return
-}
-
-function EditorLoadingScreen() {
- return Loading Editor, please wait...
+ return
}
-export interface EditorProps {
- id: number
- name: string
- content: string
- courtType: CourtType
-}
+function EditorPageWrapper({ service }: { service: TacticService }) {
+ const [panicMessage, setPanicMessage] = useState()
+ const [stepId, setStepId] = useState()
+ const [tacticName, setTacticName] = useState()
+ const [courtType, setCourtType] = useState()
+ const [stepsTree, setStepsTree] = useState()
-function Editor({ id, name, courtType, content }: EditorProps) {
- const isInGuestMode = id == -1
+ const courtRef = useRef(null)
- const storageContent = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY)
- const editorContent =
- isInGuestMode && storageContent != null ? storageContent : content
+ const saveContent = useCallback(
+ async (content: StepContent) => {
+ const result = await service.saveContent(stepId!, content)
- const storageName = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY)
- const editorName = isInGuestMode && storageName != null ? storageName : name
+ if (typeof result === "string") return SaveStates.Err
- const navigate = useNavigate()
+ await updateStepContents(
+ stepId!,
+ stepsTree!,
+ async (id) => {
+ const content = await service.getContent(id)
+ if (typeof content === "string")
+ throw new Error(
+ "Error when retrieving children content",
+ )
- return (
- {
- if (isInGuestMode) {
- localStorage.setItem(
- GUEST_MODE_CONTENT_STORAGE_KEY,
- JSON.stringify(content),
+ const courtBounds =
+ courtRef.current!.getBoundingClientRect()
+ const relativePositions = computeRelativePositions(
+ courtBounds,
+ content,
)
- return SaveStates.Guest
- }
- const response = await fetchAPI(
- `tactics/${id}/steps/1`,
- { content },
- "PUT",
- )
- if (response.status == 401) {
- navigate("/login")
- }
- return response.ok ? SaveStates.Ok : SaveStates.Err
- }}
- onNameChange={async (name: string) => {
- if (isInGuestMode) {
- localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
- return true //simulate that the name has been changed
- }
+ 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
+ },
+ [service, stepId, stepsTree],
+ )
+
+ const [stepContent, setStepContent, saveState] =
+ useContentState(
+ { components: [] },
+ SaveStates.Ok,
+ useMemo(() => debounceAsync(saveContent, 250), [saveContent]),
+ )
- const response = await fetchAPI(
- `tactics/${id}/name`,
- { name },
- "PUT",
+ const isNotInit = !tacticName || !stepId || !stepsTree || !courtType
+
+ 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,
)
- if (response.status == 401) {
- navigate("/login")
- }
- return response.ok
- }}
+ 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
+ }
+ setStepContent(contentResult, false)
+ }
+
+ if (isNotInit) init()
+ }, [isNotInit, service, setStepContent])
+
+ const editorService: EditorService = useMemo(
+ () => ({
+ async addStep(
+ parent: StepInfoNode,
+ content: StepContent,
+ ): Promise {
+ const result = await service.addStep(parent, content)
+ if (typeof result !== "string")
+ setStepsTree(addStepNode(stepsTree!, parent, result))
+ return result
+ },
+ async removeStep(step: number): Promise {
+ const result = await service.removeStep(step)
+ if (typeof result !== "string")
+ setStepsTree(removeStepNode(stepsTree!, step))
+ return result
+ },
+
+ setContent(content: StepContent) {
+ setStepContent(content, true)
+ },
+
+ async setName(name: string): Promise {
+ const result = await service.setName(name)
+ if (typeof result === "string") return SaveStates.Err
+ setTacticName(name)
+ return SaveStates.Ok
+ },
+
+ async selectStep(step: number): Promise {
+ const result = await service.getContent(step)
+ if (typeof result === "string") return result
+ setStepId(step)
+ setStepContent(result, false)
+ },
+ }),
+ [service, setStepContent, stepsTree],
+ )
+
+ if (panicMessage) {
+ return {panicMessage}
+ }
+
+ if (isNotInit) {
+ return Retrieving editor context. Please wait...
+ }
+
+ return (
+
)
}
-function EditorView({
- tactic: { id, name, content: initialContent, courtType },
- onContentChange,
- onNameChange,
-}: EditorViewProps) {
- const isInGuestMode = id == -1
+export interface EditorViewProps {
+ stepsTree: StepInfoNode
+ name: string
+ courtType: CourtType
+ content: StepContent
+ contentSaveState: SaveState
+ stepId: number
+ courtRef: RefObject
+
+ service: EditorService
+}
+
+function EditorPage({
+ name,
+ courtType,
+ content,
+ stepId,
+ contentSaveState,
+ stepsTree,
+ courtRef,
+ service,
+}: EditorViewProps) {
const [titleStyle, setTitleStyle] = useState({})
- const [content, setContent, saveState] = useContentState(
- initialContent,
- isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
- useMemo(() => debounceAsync(onContentChange), [onContentChange]),
- )
- const [allies, setAllies] = useState(() =>
- getRackPlayers(PlayerTeam.Allies, content.components),
- )
- const [opponents, setOpponents] = useState(() =>
- getRackPlayers(PlayerTeam.Opponents, content.components),
- )
+ const allies = getRackPlayers(PlayerTeam.Allies, content.components)
+ const opponents = getRackPlayers(PlayerTeam.Opponents, content.components)
const [objects, setObjects] = useState(() =>
isBallOnCourt(content) ? [] : [{ key: "ball" }],
@@ -251,64 +321,59 @@ function EditorView({
null,
)
- const courtRef = useRef(null)
+ const [isStepsTreeVisible, setStepsTreeVisible] = useState(false)
+
+ 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) => {
- setContent((c) => ({
+ service.setContent((c) => ({
...c,
components:
typeof action == "function" ? action(c.components) : action,
}))
}
- const courtBounds = useCallback(
- () => courtRef.current!.getBoundingClientRect(),
- [courtRef],
- )
-
useEffect(() => {
setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }])
}, [setObjects, content])
const insertRackedPlayer = (player: Player) => {
- let setter
- switch (player.team) {
- case PlayerTeam.Opponents:
- setter = setOpponents
- break
- case PlayerTeam.Allies:
- setter = setAllies
- }
if (player.ballState == BallState.HOLDS_BY_PASS) {
setObjects([{ key: "ball" }])
}
- setter((players) => [
- ...players,
- {
- team: player.team,
- pos: player.role,
- key: player.role,
- },
- ])
}
const doRemovePlayer = useCallback(
(component: PlayerLike) => {
- setContent((c) => removePlayer(component, c))
+ service.setContent((c) => removePlayer(component, c))
if (component.type == "player") insertRackedPlayer(component)
},
- [setContent],
+ [service],
)
const doMoveBall = useCallback(
(newBounds: DOMRect, from?: PlayerLike) => {
- setContent((content) => {
+ service.setContent((content) => {
if (from) {
- content = changePlayerBallState(
- from,
- BallState.NONE,
- content,
- )
+ content =
+ spreadNewStateFromOriginStateChange(
+ from,
+ BallState.NONE,
+ content,
+ ) ?? content
}
content = placeBallAt(newBounds, courtBounds(), content)
@@ -316,12 +381,12 @@ function EditorView({
return content
})
},
- [courtBounds, setContent],
+ [courtBounds, service],
)
const validatePlayerPosition = useCallback(
(player: PlayerLike, info: PlayerInfo, newPos: Pos) => {
- setContent((content) =>
+ service.setContent((content) =>
moveComponent(
newPos,
player,
@@ -336,12 +401,13 @@ function EditorView({
),
)
},
- [courtBounds, setContent],
+ [courtBounds, service],
)
const renderAvailablePlayerActions = useCallback(
(info: PlayerInfo, player: PlayerLike) => {
let canPlaceArrows: boolean
+ let isFrozen: boolean = false
if (player.type == "player") {
canPlaceArrows =
@@ -349,6 +415,7 @@ function EditorView({
player.actions.findIndex(
(p) => p.type != ActionKind.SHOOT,
) == -1
+ isFrozen = player.frozen
} else {
const origin = getOrigin(player, content.components)
const path = origin.path!
@@ -376,21 +443,28 @@ function EditorView({
playerInfo={info}
content={content}
courtRef={courtRef}
- setContent={setContent}
- />
- ),
- (info.ballState === BallState.HOLDS_ORIGIN ||
- info.ballState === BallState.PASSED_ORIGIN) && (
- {
- doMoveBall(ballBounds, player)
- }}
+ setContent={service.setContent}
/>
),
+ !isFrozen &&
+ (info.ballState === BallState.HOLDS_ORIGIN ||
+ info.ballState === BallState.PASSED_ORIGIN) && (
+ {
+ doMoveBall(ballBounds, player)
+ }}
+ />
+ ),
]
},
- [content, doMoveBall, previewAction?.isInvalid, setContent],
+ [
+ content,
+ courtRef,
+ doMoveBall,
+ previewAction?.isInvalid,
+ service.setContent,
+ ],
)
const renderPlayer = useCallback(
@@ -406,16 +480,30 @@ function EditorView({
pos: computePhantomPositioning(
component,
content,
+ relativePositions,
courtBounds(),
),
ballState: component.ballState,
}
} else {
info = component
+
+ if (component.frozen) {
+ return (
+
+ renderAvailablePlayerActions(info, component)
+ }
+ />
+ )
+ }
}
return (
- {
- setContent((content) => removeAction(origin, idx, content))
+ service.setContent((content) => removeAction(origin, idx, content))
},
- [setContent],
+ [service],
)
const doUpdateAction = useCallback(
(component: TacticComponent, action: Action, actionIndex: number) => {
- setContent((content) =>
+ service.setContent((content) =>
updateComponent(
{
...component,
@@ -461,22 +552,22 @@ function EditorView({
),
)
},
- [setContent],
+ [service],
)
const renderComponent = useCallback(
(component: TacticComponent) => {
- if (component.type == "player" || component.type == "phantom") {
+ if (component.type === "player" || component.type === "phantom") {
return renderPlayer(component)
}
- if (component.type == BALL_TYPE) {
+ if (component.type === BALL_TYPE) {
return (
{
- setContent((content) => removeBall(content))
+ service.setContent((content) => removeBall(content))
setObjects((objects) => [
...objects,
{ key: "ball" },
@@ -487,7 +578,7 @@ function EditorView({
}
throw new Error("unknown tactic component " + component)
},
- [renderPlayer, doMoveBall, setContent],
+ [service, renderPlayer, doMoveBall],
)
const renderActions = useCallback(
@@ -509,14 +600,105 @@ function EditorView({
/>
)
}),
- [doDeleteAction, doUpdateAction],
+ [courtRef, doDeleteAction, doUpdateAction],
+ )
+
+ const contentNode = (
+
+
+
+
+
+ overlaps(
+ courtBounds(),
+ div.getBoundingClientRect(),
+ ),
+ [courtBounds],
+ )}
+ onElementDetached={useCallback(
+ (r, e: RackedCourtObject) =>
+ service.setContent((content) =>
+ placeObjectAt(
+ r.getBoundingClientRect(),
+ courtBounds(),
+ e,
+ content,
+ ),
+ ),
+ [courtBounds, service],
+ )}
+ render={renderCourtObject}
+ />
+
+
+
+
+
+ }
+ courtRef={courtRef}
+ previewAction={previewAction}
+ renderComponent={renderComponent}
+ renderActions={renderActions}
+ />
+
+
+
+ )
+
+ const stepsTreeNode = (
+ {
+ 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)
+ },
+ [service, stepsTree],
+ )}
+ onStepSelected={useCallback(
+ (node) => service.selectStep(node.id),
+ [service],
+ )}
+ />
)
return (
-
+
{
- onNameChange(new_name).then((success) => {
- setTitleStyle(success ? {} : ERROR_STYLE)
+ service.setName(new_name).then((state) => {
+ setTitleStyle(
+ state == SaveStates.Ok
+ ? {}
+ : ERROR_STYLE,
+ )
})
},
- [onNameChange],
+ [service],
)}
/>
-
+
+
+
-
-
-
+
+ {isStepsTreeVisible ? (
+
+ {contentNode}
+ {stepsTreeNode}
+
+ ) : (
+ contentNode
+ )}
+
+
+ )
+}
-
- overlaps(
- courtBounds(),
- div.getBoundingClientRect(),
- ),
- [courtBounds],
- )}
- onElementDetached={useCallback(
- (r, e: RackedCourtObject) =>
- setContent((content) =>
- placeObjectAt(
- r.getBoundingClientRect(),
- courtBounds(),
- e,
- content,
- ),
- ),
- [courtBounds, setContent],
- )}
- render={renderCourtObject}
- />
+interface EditorStepsTreeProps {
+ selectedStepId: number
+ root: StepInfoNode
+ onAddChildren: (parent: StepInfoNode) => void
+ onRemoveNode: (node: StepInfoNode) => void
+ onStepSelected: (node: StepInfoNode) => void
+}
-
-
-
-
- }
- courtRef={courtRef}
- previewAction={previewAction}
- renderComponent={renderComponent}
- renderActions={renderActions}
- />
-
-
-
+function EditorStepsTree({
+ selectedStepId,
+ root,
+ onAddChildren,
+ onRemoveNode,
+ onStepSelected,
+}: EditorStepsTreeProps) {
+ return (
+
+
)
}
@@ -599,7 +773,7 @@ function EditorView({
interface PlayerRackProps {
id: string
objects: RackedPlayer[]
- setObjects: (state: RackedPlayer[]) => void
+ setObjects?: (state: RackedPlayer[]) => void
setComponents: (
f: (components: TacticComponent[]) => TacticComponent[],
) => void
@@ -659,8 +833,8 @@ interface CourtPlayerArrowActionProps {
player: PlayerLike
isInvalid: boolean
- content: TacticContent
- setContent: (state: SetStateAction) => void
+ content: StepContent
+ setContent: (state: SetStateAction) => void
setPreviewAction: (state: SetStateAction) => void
courtRef: RefObject
}
@@ -768,7 +942,7 @@ function CourtPlayerArrowAction({
)
}
-function isBallOnCourt(content: TacticContent) {
+function isBallOnCourt(content: StepContent) {
return (
content.components.findIndex(
(c) =>
@@ -815,30 +989,91 @@ function debounceAsync(
function useContentState(
initialContent: S,
initialSaveState: SaveState,
- saveStateCallback: (s: S) => Promise,
-): [S, Dispatch>, SaveState] {
+ applyStateCallback: (content: S) => Promise,
+): [S, (newState: SetStateAction, runCallback: boolean) => void, SaveState] {
const [content, setContent] = useState(initialContent)
const [savingState, setSavingState] = useState(initialSaveState)
const setContentSynced = useCallback(
- (newState: SetStateAction) => {
+ (newState: SetStateAction, callSaveCallback: boolean) => {
setContent((content) => {
const state =
typeof newState === "function"
? (newState as (state: S) => S)(content)
: newState
- if (state !== content) {
+ 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,
+ )
+ }
+
+ return relativePositionsCache
+}
+
+async function updateStepContents(
+ stepId: number,
+ stepsTree: StepInfoNode,
+ getStepContent: (stepId: number) => Promise,
+ setStepContent: (stepId: number, content: StepContent) => Promise,
+) {
+ 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)
+}
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index faba0d7..38dd127 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -151,7 +151,7 @@ function TitlePersonalSpace() {
function TableData({ allTactics }: { allTactics: Tactic[] }) {
const nbRow = Math.floor(allTactics.length / 3) + 1
- let listTactic = Array(nbRow)
+ const listTactic = Array(nbRow)
for (let i = 0; i < nbRow; i++) {
listTactic[i] = Array(0)
}
diff --git a/src/pages/NewTacticPage.tsx b/src/pages/NewTacticPage.tsx
index 98568d3..35db2a4 100644
--- a/src/pages/NewTacticPage.tsx
+++ b/src/pages/NewTacticPage.tsx
@@ -66,7 +66,10 @@ function CourtKindButton({
)
if (response.status === 401) {
- saveSession({...getSession(), urlTarget: location.pathname})
+ saveSession({
+ ...getSession(),
+ urlTarget: location.pathname,
+ })
// if unauthorized
navigate("/login")
return
diff --git a/src/service/APITacticService.ts b/src/service/APITacticService.ts
new file mode 100644
index 0000000..71f775f
--- /dev/null
+++ b/src/service/APITacticService.ts
@@ -0,0 +1,84 @@
+import { TacticService, ServiceError, TacticContext } from "./TacticService.ts"
+import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
+import { fetchAPI, fetchAPIGet } from "../Fetcher.ts"
+
+export class APITacticService implements TacticService {
+ private readonly tacticId: number
+
+ constructor(tacticId: number) {
+ this.tacticId = tacticId
+ }
+
+ async getContext(): Promise {
+ const infoResponsePromise = fetchAPIGet(`tactics/${this.tacticId}`)
+ const treeResponsePromise = fetchAPIGet(`tactics/${this.tacticId}/tree`)
+
+ const infoResponse = await infoResponsePromise
+ const treeResponse = await treeResponsePromise
+
+ if (infoResponse.status == 401 || treeResponse.status == 401) {
+ return ServiceError.UNAUTHORIZED
+ }
+ const { name, courtType } = await infoResponse.json()
+ const { root } = await treeResponse.json()
+
+ return { courtType, name, stepsTree: root }
+ }
+
+ async addStep(
+ parent: StepInfoNode,
+ content: StepContent,
+ ): Promise {
+ const response = await fetchAPI(`tactics/${this.tacticId}/steps`, {
+ parentId: parent.id,
+ content,
+ })
+ if (response.status == 404) return ServiceError.NOT_FOUND
+ if (response.status == 401) return ServiceError.UNAUTHORIZED
+
+ const { stepId } = await response.json()
+ return { id: stepId, children: [] }
+ }
+
+ async removeStep(id: number): Promise {
+ const response = await fetchAPI(
+ `tactics/${this.tacticId}/steps/${id}`,
+ {},
+ "DELETE",
+ )
+ if (response.status == 404) return ServiceError.NOT_FOUND
+ if (response.status == 401) return ServiceError.UNAUTHORIZED
+ }
+
+ async setName(name: string): Promise {
+ const response = await fetchAPI(
+ `tactics/${this.tacticId}/name`,
+ { name },
+ "PUT",
+ )
+ if (response.status == 404) return ServiceError.NOT_FOUND
+ if (response.status == 401) return ServiceError.UNAUTHORIZED
+ }
+
+ async saveContent(
+ step: number,
+ content: StepContent,
+ ): Promise {
+ const response = await fetchAPI(
+ `tactics/${this.tacticId}/steps/${step}`,
+ { content },
+ "PUT",
+ )
+ if (response.status == 404) return ServiceError.NOT_FOUND
+ if (response.status == 401) return ServiceError.UNAUTHORIZED
+ }
+
+ async getContent(step: number): Promise {
+ const response = await fetchAPIGet(
+ `tactics/${this.tacticId}/steps/${step}`,
+ )
+ if (response.status == 404) return ServiceError.NOT_FOUND
+ if (response.status == 401) return ServiceError.UNAUTHORIZED
+ return await response.json()
+ }
+}
diff --git a/src/service/LocalStorageTacticService.ts b/src/service/LocalStorageTacticService.ts
new file mode 100644
index 0000000..daf05c4
--- /dev/null
+++ b/src/service/LocalStorageTacticService.ts
@@ -0,0 +1,99 @@
+import { TacticService, ServiceError, TacticContext } from "./TacticService.ts"
+import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
+import {
+ addStepNode,
+ getAvailableId,
+ removeStepNode,
+} from "../editor/StepsDomain.ts"
+
+const GUEST_MODE_STEP_CONTENT_STORAGE_KEY = "guest_mode_step"
+const GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY = "guest_mode_step_tree"
+const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
+
+export class LocalStorageTacticService implements TacticService {
+ private constructor() {}
+
+ static init(): LocalStorageTacticService {
+ const root = localStorage.getItem(
+ GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
+ )
+
+ if (root === null) {
+ localStorage.setItem(
+ GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
+ JSON.stringify({ id: 1, children: [] }),
+ )
+ }
+
+ return new LocalStorageTacticService()
+ }
+
+ async getContext(): Promise {
+ const stepsTree: StepInfoNode = JSON.parse(
+ localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!,
+ )
+ const name =
+ localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ??
+ "Nouvelle Tactique"
+ return {
+ stepsTree,
+ name,
+ courtType: "PLAIN",
+ }
+ }
+
+ async addStep(
+ parent: StepInfoNode,
+ content: StepContent,
+ ): Promise {
+ const root: StepInfoNode = JSON.parse(
+ localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!,
+ )
+
+ const nodeId = getAvailableId(root)
+ const node = { id: nodeId, children: [] }
+
+ const resultTree = addStepNode(root, parent, node)
+
+ localStorage.setItem(
+ GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
+ JSON.stringify(resultTree),
+ )
+ localStorage.setItem(
+ GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id,
+ JSON.stringify(content),
+ )
+ return node
+ }
+
+ async getContent(step: number): Promise {
+ const content = localStorage.getItem(
+ GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step,
+ )
+ return content ? JSON.parse(content) : null
+ }
+
+ async removeStep(id: number): Promise {
+ const root: StepInfoNode = JSON.parse(
+ localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!,
+ )
+ localStorage.setItem(
+ GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
+ JSON.stringify(removeStepNode(root, id)),
+ )
+ }
+
+ async saveContent(
+ step: number,
+ content: StepContent,
+ ): Promise {
+ localStorage.setItem(
+ GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step,
+ JSON.stringify(content),
+ )
+ }
+
+ async setName(name: string): Promise {
+ localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
+ }
+}
diff --git a/src/service/TacticService.ts b/src/service/TacticService.ts
new file mode 100644
index 0000000..cafa43d
--- /dev/null
+++ b/src/service/TacticService.ts
@@ -0,0 +1,32 @@
+import { CourtType, StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
+
+export interface TacticContext {
+ stepsTree: StepInfoNode
+ name: string
+ courtType: CourtType
+}
+
+export enum ServiceError {
+ UNAUTHORIZED = "UNAUTHORIZED",
+ NOT_FOUND = "NOT_FOUND",
+}
+
+export interface TacticService {
+ getContext(): Promise
+
+ addStep(
+ parent: StepInfoNode,
+ content: StepContent,
+ ): Promise
+
+ removeStep(id: number): Promise
+
+ setName(name: string): Promise
+
+ saveContent(
+ step: number,
+ content: StepContent,
+ ): Promise
+
+ getContent(step: number): Promise
+}
diff --git a/src/style/editor.css b/src/style/editor.css
index b6a8ea4..5ce1a38 100644
--- a/src/style/editor.css
+++ b/src/style/editor.css
@@ -26,13 +26,13 @@
width: 100%;
display: flex;
background-color: var(--main-color);
- margin-bottom: 3px;
justify-content: space-between;
align-items: stretch;
}
#racks {
+ margin: 3px 6px 0 6px;
display: flex;
justify-content: space-between;
align-items: center;
@@ -44,8 +44,29 @@
align-self: center;
}
-#edit-div {
+#editor-div {
+ display: flex;
+ flex-direction: row;
+}
+
+#content-div,
+#editor-div,
+#steps-div {
height: 100%;
+ width: 100%;
+}
+
+#content-div {
+ overflow: hidden;
+}
+
+.curtain {
+ width: 100%;
+}
+
+#steps-div {
+ background-color: var(--editor-tree-background);
+ overflow: scroll;
}
#allies-rack,
@@ -112,7 +133,6 @@
height: 100%;
width: 100%;
user-select: none;
- -webkit-user-drag: none;
}
#court-image * {
@@ -130,6 +150,11 @@
font-family: monospace;
}
+.save-state,
+#show-steps-button {
+ user-select: none;
+}
+
.save-state-error {
color: red;
}
diff --git a/src/style/steps_tree.css b/src/style/steps_tree.css
new file mode 100644
index 0000000..e8a92bb
--- /dev/null
+++ b/src/style/steps_tree.css
@@ -0,0 +1,91 @@
+.step-piece {
+ position: relative;
+ font-family: monospace;
+ pointer-events: all;
+
+ background-color: var(--editor-tree-step-piece);
+ color: var(--selected-team-secondarycolor);
+
+ border-radius: 100px;
+
+ width: 20px;
+ height: 20px;
+
+ display: flex;
+
+ align-items: center;
+ justify-content: center;
+
+ user-select: none;
+ cursor: pointer;
+
+ border: 2px solid var(--editor-tree-background);
+}
+
+.step-piece-selected {
+ border: 2px solid var(--selection-color-light);
+}
+
+.step-piece-selected,
+.step-piece:focus,
+.step-piece:hover {
+ background-color: var(--editor-tree-step-piece-hovered);
+}
+
+.step-piece-selected .step-piece-actions,
+.step-piece:hover .step-piece-actions,
+.step-piece:focus-within .step-piece-actions {
+ 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 {
+ margin-top: 10vh;
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ height: 100%;
+}
+
+.step-group {
+ position: relative;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ top: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.steps-tree {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding-top: 10%;
+
+ height: 100%;
+}
diff --git a/src/style/theme/default.css b/src/style/theme/default.css
index caa5162..19702b0 100644
--- a/src/style/theme/default.css
+++ b/src/style/theme/default.css
@@ -13,6 +13,7 @@
--buttons-shadow-color: #a8a8a8;
--selection-color: #3f7fc4;
+ --selection-color-light: #acd8f8;
--border-color: #ffffff;
@@ -29,4 +30,10 @@
--main-contrast-color: #e6edf3;
--font-title: Helvetica;
--font-content: Helvetica;
+ --editor-tree-background: #503636;
+ --editor-tree-step-piece: #0bd9d9;
+ --editor-tree-step-piece-hovered: #ea9b9b;
+
+ --add-icon-fill: #00a206;
+ --remove-icon-fill: #e50046;
}
diff --git a/vite.config.ts b/vite.config.ts
index 214327e..8d932e7 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -8,6 +8,9 @@ export default defineConfig({
build: {
target: "es2021",
},
+ test: {
+ environment: "jsdom",
+ },
plugins: [
react(),
cssInjectedByJsPlugin({