diff --git a/src/components/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx index b6443ed..f719066 100644 --- a/src/components/editor/StepsTree.tsx +++ b/src/components/editor/StepsTree.tsx @@ -1,7 +1,7 @@ -import "../../style/steps-tree.css" +import "../../style/steps_tree.css" import { StepInfoNode } from "../../model/tactic/Tactic" import BendableArrow from "../arrows/BendableArrow" -import { useRef } from "react" +import { useRef, useState } from "react" import AddSvg from "../../assets/icon/add.svg?react" import RemoveSvg from "../../assets/icon/remove.svg?react" @@ -13,19 +13,26 @@ export interface StepsTreeProps { } export default function StepsTree({ - root, - onAddChildren, - onRemoveNode, - onStepSelected, -}: StepsTreeProps) { + root, + onAddChildren, + onRemoveNode, + onStepSelected, + }: StepsTreeProps) { + + const [selectedStepId, setSelectedStepId] = useState(root.id) + return (
{ + setSelectedStepId(step.id) + onStepSelected(step) + }} />
) @@ -34,36 +41,34 @@ export default function StepsTree({ interface StepsTreeContentProps { node: StepInfoNode isNodeRoot: boolean + selectedStepId: number, onAddChildren: (parent: StepInfoNode) => void onRemoveNode: (node: StepInfoNode) => void onStepSelected: (node: StepInfoNode) => void } function StepsTreeNode({ - node, - isNodeRoot, - onAddChildren, - onRemoveNode, - onStepSelected, -}: StepsTreeContentProps) { + node, + isNodeRoot, + selectedStepId, + onAddChildren, + onRemoveNode, + onStepSelected, + }: StepsTreeContentProps) { const ref = useRef(null) + return ( -
- 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 constants @@ -71,11 +76,19 @@ function StepsTreeNode({ endRadius={10} /> ))} + onAddChildren(node)} + onRemoveButtonClicked={isNodeRoot ? undefined : () => onRemoveNode(node)} + onSelected={() => onStepSelected(node)} + />
{node.children.map((child) => ( void onRemoveButtonClicked?: () => void onSelected: () => void } function StepPiece({ - id, - onAddButtonClicked, - onRemoveButtonClicked, - onSelected, -}: StepPieceProps) { + id, + isSelected, + onAddButtonClicked, + onRemoveButtonClicked, + onSelected, + }: StepPieceProps) { return (
{onAddButtonClicked && ( diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts index 46c7515..835b147 100644 --- a/src/editor/StepsDomain.ts +++ b/src/editor/StepsDomain.ts @@ -34,3 +34,25 @@ export function removeStepNode( }), } } + +/** + * 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 +} \ No newline at end of file diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 8d2da94..d05bcd7 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -78,22 +78,19 @@ import { } 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, removeStepNode } from "../editor/StepsDomain" +import { addStepNode, getAvailableId, getParent, removeStepNode } from "../editor/StepsDomain" const ERROR_STYLE: CSSProperties = { borderColor: "red", } -const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content" +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 interface EditorViewProps { - tactic: TacticInfo - onContentChange: (tactic: StepContent) => Promise - onNameChange: (name: string) => Promise -} +// The step identifier the editor will always open on +const DEFAULT_STEP_ID = 1 interface TacticDto { id: number @@ -103,34 +100,103 @@ interface TacticDto { root: StepInfoNode } -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, - root: { id: 1, children: [] }, - } - } - return null +export default function Editor({ guestMode }: EditorPageProps) { + return +} + + +function EditorPortal({ guestMode }: EditorPageProps) { + return guestMode ? : +} + +function GuestModeEditor() { + const storageContent = localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0") + + const stepInitialContent = ({ + ...(storageContent == null ? { components: [] } : JSON.parse(storageContent)), + stepId: 0, }) + + // initialize local storage if we launch in guest mode + if (storageContent == null) { + localStorage.setItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, JSON.stringify({ id: 0, children: [] })) + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepInitialContent.stepId, + JSON.stringify(stepInitialContent), + ) + } + + + const [stepId, setStepId] = useState(DEFAULT_STEP_ID) + const [stepContent, setStepContent, saveState] = useContentState( + stepInitialContent, + SaveStates.Guest, + useMemo(() => debounceAsync(async (content: StepContent) => { + localStorage.setItem( + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId, + JSON.stringify(content), + ) + return SaveStates.Guest + }, 250), [stepId]), + ) + + return setStepContent(content, true)} + saveState={saveState} + currentStepId={stepId} + onNameChange={useCallback(async name => { + localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) + return true //simulate that the name has been changed + }, [])} + selectStep={useCallback(step => { + setStepId(step) + setStepContent({ ...JSON.parse(localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!) }, false) + return + }, [setStepContent])} + onAddStep={useCallback(async parent => { + const root: StepInfoNode = JSON.parse(localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!) + + const nodeId = getAvailableId(root) + const node = { id: nodeId, children: [] } + + localStorage.setItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, JSON.stringify(addStepNode(root, parent, node))) + localStorage.setItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id, JSON.stringify({ + stepId: node.id, + components: [], + })) + return node + }, [])} + onRemoveStep={useCallback(async step => { + 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, step))) + return true + }, [])} + /> +} + +function UserModeEditor() { + const [tactic, setTactic] = useState(null) const { tacticId: idStr } = useParams() - const id = guestMode ? -1 : parseInt(idStr!) + const id = parseInt(idStr!) const navigation = useNavigate() useEffect(() => { - if (guestMode) return async function initialize() { const infoResponsePromise = fetchAPIGet(`tactics/${id}`) const treeResponsePromise = fetchAPIGet(`tactics/${id}/tree`) - const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/1`) + const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/${DEFAULT_STEP_ID}`) const infoResponse = await infoResponsePromise const treeResponse = await treeResponsePromise @@ -153,160 +219,81 @@ export default function EditorPage({ guestMode }: EditorPageProps) { } initialize() - }, [guestMode, id, idStr, navigation]) - - if (tactic) { - return ( - - ) - } - - return -} - -function EditorLoadingScreen() { - return
Loading Editor, please wait...
-} - -export interface EditorProps { - id: number - initialName: string - courtType: "PLAIN" | "HALF" - initialStepContent: StepContent - initialStepId: number - initialStepsNode: StepInfoNode -} - -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 stepInitialContent = { - ...(isInGuestMode && storageContent != null - ? JSON.parse(storageContent) - : initialStepContent), - } + }, [id, idStr, navigation]) - const storage_name = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) - const editorName = - isInGuestMode && storage_name != null ? storage_name : initialName - const [stepId, setStepId] = useState(initialStepId) + const [stepId, setStepId] = useState(1) 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], - ), + tactic?.content ?? { components: [] }, + SaveStates.Ok, + useMemo(() => debounceAsync(async (content: StepContent) => { + const response = await fetchAPI( + `tactics/${id}/steps/${stepId}`, + { + content: { + components: content.components, + }, + }, + "PUT", + ) + return response.ok ? SaveStates.Ok : SaveStates.Err + }, 250), [id, stepId]), ) + const onNameChange = useCallback((name: string) => + fetchAPI(`tactics/${id}/edit/name`, { name }) + .then((r) => r.ok) + , [id]) + + const selectStep = useCallback(async (step: number) => { + const response = await fetchAPIGet(`tactics/${id}/steps/${step}`) + if (!response.ok) + return + setStepId(step) + setStepContent({ ...await response.json() }, false) + }, [id, setStepContent]) + + const onAddStep = useCallback(async (parent: StepInfoNode) => { + const response = await fetchAPI(`tactics/${id}/steps`, { + parentId: parent.id, + }) + if (!response.ok) + return null + const { stepId } = await response.json() + return { id: stepId, children: [] } + }, [id]) + + const onRemoveStep = useCallback((step: StepInfoNode) => + fetchAPI( + `tactics/${id}/steps/${step.id}`, + {}, + "DELETE", + ).then(r => r.ok) + , [id]) + + + if (!tactic) + return + + return setStepContent(content, true)} + saveState={saveState} + onNameChange={onNameChange} + selectStep={selectStep} + onAddStep={onAddStep} + onRemoveStep={onRemoveStep} + /> +} - return ( - setStepContent(content, true)} - courtType={courtType} - saveState={saveState} - onContentChange={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/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 - } - - const response = await fetchAPI( - `tactics/${id}/name`, - { name }, - "PUT", - ) - if (response.status == 401) { - navigate("/login") - } - return response.ok - }} - selectStep={async (step) => { - const response = await fetchAPIGet(`tactics/${id}/steps/${step}`) - if (!response.ok) - return null - setStepId(step) - setStepContent({ ...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 - }} - /> - ) +function EditorLoadingScreen() { + return

Loading Editor, Please wait...

} export interface EditorViewProps { @@ -320,20 +307,25 @@ export interface EditorViewProps { onNameChange: (name: string) => Promise onRemoveStep: (step: StepInfoNode) => Promise onAddStep: (parent: StepInfoNode) => Promise - courtType: "PLAIN" | "HALF" } -function EditorView({ - tactic: { name, rootStepNode: initialStepsNode }, - setCurrentStepContent: setContent, - currentStepContent: content, - saveState, - onNameChange, - selectStep, - onRemoveStep, - onAddStep, - courtType, -}: EditorViewProps) { + + +function EditorPage({ + tactic: { + name, + rootStepNode: initialStepsNode, + courtType, + }, + setCurrentStepContent: setContent, + currentStepContent: content, + saveState, + onNameChange, + selectStep, + onRemoveStep, + onAddStep, + }: EditorViewProps) { + const [titleStyle, setTitleStyle] = useState({}) const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) @@ -570,10 +562,10 @@ function EditorView({ 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 ( { const isOk = await onRemoveStep(removed) - if (isOk) - setRootStepsNode( - (root) => removeStepNode(root, removed)!, - ) + selectStep(getParent(rootStepsNode, removed)!.id) + if (isOk) setRootStepsNode( + (root) => removeStepNode(root, removed)!, + ) }, - [onRemoveStep], + [rootStepsNode, onRemoveStep, selectStep], )} onStepSelected={useCallback( (node) => selectStep(node.id), @@ -879,7 +871,7 @@ function CourtPlayerArrowAction({ })) }} onHeadPicked={(headPos) => { - ;(document.activeElement as HTMLElement).blur() + (document.activeElement as HTMLElement).blur() setPreviewAction({ origin: playerInfo.id, diff --git a/src/style/steps-tree.css b/src/style/steps_tree.css similarity index 81% rename from src/style/steps-tree.css rename to src/style/steps_tree.css index 00ff699..4b58090 100644 --- a/src/style/steps-tree.css +++ b/src/style/steps_tree.css @@ -18,18 +18,21 @@ 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:focus-within .step-piece-actions { - visibility: visible; -} - -.step-piece:hover .step-piece-actions { +.step-piece-selected .step-piece-actions, .step-piece:hover .step-piece-actions, .step-piece:focus-within .step-piece-actions { visibility: visible; } diff --git a/src/style/theme/default.css b/src/style/theme/default.css index 950450a..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;