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/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx index 45d6492..b6443ed 100644 --- a/src/components/editor/StepsTree.tsx +++ b/src/components/editor/StepsTree.tsx @@ -1,45 +1,87 @@ -import {StepInfoNode} from "../../model/tactic/Tactic"; import "../../style/steps-tree.css" -import BendableArrow from "../arrows/BendableArrow"; -import {useRef} from "react"; +import { StepInfoNode } from "../../model/tactic/Tactic" +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 { root: StepInfoNode + onAddChildren: (parent: StepInfoNode) => void + onRemoveNode: (node: StepInfoNode) => void + onStepSelected: (node: StepInfoNode) => void } -export default function StepsTree({root}: StepsTreeProps) { - return
- -
+export default function StepsTree({ + root, + onAddChildren, + onRemoveNode, + onStepSelected, +}: StepsTreeProps) { + return ( +
+ +
+ ) } interface StepsTreeContentProps { 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(null) return ( -
- - {node.children.map(child => ( +
+ onAddChildren(node)} + onRemoveButtonClicked={ + isNodeRoot ? undefined : () => onRemoveNode(node) + } + onSelected={() => onStepSelected(node)} + /> + {node.children.map((child) => ( { - }} + segments={[{ next: "step-piece-" + child.id }]} + onSegmentsChanges={() => {}} forceStraight={true} wavy={false} - //TODO remove magic constant + //TODO remove magic constants startRadius={10} endRadius={10} /> ))}
- {node.children.map(child => )} + {node.children.map((child) => ( + + ))}
) @@ -47,11 +89,37 @@ function StepsTreeNode({node}: StepsTreeContentProps) { interface StepPieceProps { id: number + onAddButtonClicked?: () => void + onRemoveButtonClicked?: () => void + onSelected: () => void } -function StepPiece({id}: StepPieceProps) { +function StepPiece({ + id, + onAddButtonClicked, + onRemoveButtonClicked, + onSelected, +}: StepPieceProps) { return ( -
+
+
+ {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], + )} + />
) @@ -626,60 +745,30 @@ function EditorView({ interface EditorStepsTreeProps { isVisible: boolean root: StepInfoNode + onAddChildren: (parent: StepInfoNode) => void + onRemoveNode: (node: StepInfoNode) => void + onStepSelected: (node: StepInfoNode) => void } -function EditorStepsTree({isVisible, root}: EditorStepsTreeProps) { - const fakeRoot: StepInfoNode = { - id: 0, - children: [ - { - id: 1, - children: [ - { - 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: [] - } - ] - } - ] - } - ] - } +function EditorStepsTree({ + isVisible, + root, + onAddChildren, + onRemoveNode, + onStepSelected, +}: EditorStepsTreeProps) { return ( -
- +
+
) } @@ -695,12 +784,12 @@ interface PlayerRackProps { } function PlayerRack({ - id, - objects, - setObjects, - courtRef, - setComponents, - }: PlayerRackProps) { + id, + objects, + setObjects, + courtRef, + setComponents, +}: PlayerRackProps) { const courtBounds = useCallback( () => courtRef.current!.getBoundingClientRect(), [courtRef], @@ -728,7 +817,7 @@ function PlayerRack({ [courtBounds, setComponents], )} render={useCallback( - ({team, key}: { team: PlayerTeam; key: string }) => ( + ({ team, key }: { team: PlayerTeam; key: string }) => ( courtRef.current!.getBoundingClientRect(), [courtRef], @@ -817,7 +906,7 @@ function CourtPlayerArrowAction({ } setContent((content) => { - let {createdAction, newContent} = createAction( + let { createdAction, newContent } = createAction( player, courtBounds(), headRect, @@ -870,18 +959,18 @@ function isBallOnCourt(content: StepContent) { function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { - return + return } throw new Error("unknown racked court object " + courtObject.key) } -function Court({courtType}: { courtType: string }) { +function Court({ courtType }: { courtType: string }) { return (
{courtType == "PLAIN" ? ( - + ) : ( - + )}
) @@ -904,19 +993,23 @@ function useContentState( initialContent: S, initialSaveState: SaveState, saveStateCallback: (s: S) => Promise, -): [S, Dispatch>, SaveState] { +): [ + S, + (newState: SetStateAction, callSaveCallback: 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) .then(setSavingState) diff --git a/src/style/editor.css b/src/style/editor.css index 8f506b2..b66ed67 100644 --- a/src/style/editor.css +++ b/src/style/editor.css @@ -61,11 +61,15 @@ #steps-div { background-color: var(--editor-tree-background); - overflow: hidden; width: 20%; transform: translateX(100%); transition: transform 500ms; + overflow: scroll; +} + +#steps-div::-webkit-scrollbar { + display: none; } #allies-rack, diff --git a/src/style/steps-tree.css b/src/style/steps-tree.css index 28e3f4f..00ff699 100644 --- a/src/style/steps-tree.css +++ b/src/style/steps-tree.css @@ -1,4 +1,5 @@ .step-piece { + position: relative; font-family: monospace; pointer-events: all; @@ -16,6 +17,42 @@ justify-content: center; 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 { @@ -45,4 +82,4 @@ padding-top: 10%; height: 100%; -} \ No newline at end of file +} diff --git a/src/style/theme/default.css b/src/style/theme/default.css index 62bf848..950450a 100644 --- a/src/style/theme/default.css +++ b/src/style/theme/default.css @@ -31,4 +31,8 @@ --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; }