Merge pull request 'Add steps to the editor' (#114) from editor/steps into master
continuous-integration/drone/push Build is passing Details

Reviewed-on: #114
pull/116/head
Maxime BATISTA 1 year ago committed by maxime
commit cca7ee1b1b

@ -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"

@ -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"
}
}

@ -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 (
<Suspense fallback={<p>Loading, please wait...</p>}>

@ -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
}

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

Before

Width:  |  Height:  |  Size: 405 B

After

Width:  |  Height:  |  Size: 216 B

@ -4,7 +4,7 @@ import Draggable from "react-draggable"
export interface RackProps<E extends { key: string | number }> {
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<E extends { key: string | number }>({
const index = objects.findIndex(
(o) => o.key === element.key,
)
onChange(objects.toSpliced(index, 1))
if (onChange) onChange(objects.toSpliced(index, 1))
onElementDetached(ref, element)
}}

@ -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<HTMLDivElement>(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 (
<div
className={"curtain"}
ref={curtainRef}
style={{ display: "flex" }}
onMouseMove={resizing ? resize : undefined}
onMouseUp={() => setResizing(false)}>
<div className={"curtain-left"} style={{ width: `${rightWidth}%` }}>
{children[0]}
</div>
<div
onMouseDown={() => setResizing(true)}
style={{
width: 4,
height: "100%",
backgroundColor: "grey",
cursor: "col-resize",
userSelect: "none",
}}></div>
<div
className={"curtain-right"}
style={{ width: `${100 - rightWidth}%` }}>
{children[1]}
</div>
</div>
)
}

@ -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<HTMLElement>
@ -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(() => {

@ -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 {

@ -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 {

@ -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<HTMLDivElement>(null)
const { x, y } = ball.pos
return (
<Draggable
onStop={() =>
onPosValidated(pieceRef.current!.getBoundingClientRect())
}
position={NULL_POS}
nodeRef={pieceRef}>
function courtBallPiece(
{ x, y }: Pos,
pieceRef?: RefObject<HTMLDivElement>,
onKeyUp?: KeyboardEventHandler,
) {
return (
<div
className={"ball-div"}
ref={pieceRef}
tabIndex={0}
onKeyUp={(e) => {
if (e.key == "Delete") onRemove()
}}
onKeyUp={onKeyUp}
style={{
position: "absolute",
left: `${x * 100}%`,
@ -36,6 +38,23 @@ export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) {
}}>
<BallPiece />
</div>
)
}
if (ball.frozen) {
return courtBallPiece(ball.pos)
}
return (
<Draggable
onStop={() =>
onPosValidated(pieceRef.current!.getBoundingClientRect())
}
position={NULL_POS}
nodeRef={pieceRef}>
{courtBallPiece(ball.pos, pieceRef, (e) => {
if (e.key == "Delete") onRemove()
})}
</Draggable>
)
}

@ -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<HTMLElement>
onPositionValidated: (newPos: Pos) => void
onRemove: () => void
courtRef: RefObject<HTMLElement>
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<HTMLDivElement>(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<HTMLDivElement>(null)
const { x, y } = playerInfo.pos
return (
<Draggable
handle=".player-piece"
nodeRef={pieceRef}
//The piece is positioned using top/bottom style attributes instead
position={NULL_POS}
onStop={useCallback(() => {
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])}>
<div
id={playerInfo.id}
ref={pieceRef}
className={"player " + (className ?? "")}
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<div
tabIndex={0}
className="player-content"
onKeyUp={useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key == "Delete") onRemove()
},
[onRemove],
)}>
<div className="player-actions">
{availableActions(pieceRef.current!)}
</div>
<PlayerPiece
team={playerInfo.team}
text={playerInfo.role}
hasBall={usesBall}
/>
</div>
</div>
{courtPlayerPiece({
playerInfo,
className,
pieceRef,
availableActions: () => availableActions(pieceRef.current!),
onKeyUp: useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key == "Delete") onRemove()
},
[onRemove],
),
})}
</Draggable>
)
}
interface CourtPlayerPieceProps {
playerInfo: PlayerInfo
className?: string
pieceRef?: RefObject<HTMLDivElement>
availableActions?: () => ReactNode[]
onKeyUp?: KeyboardEventHandler<HTMLDivElement>
}
function courtPlayerPiece({
playerInfo,
className,
pieceRef,
onKeyUp,
availableActions,
}: CourtPlayerPieceProps) {
const usesBall = playerInfo.ballState != BallState.NONE
const { x, y } = playerInfo.pos
return (
<div
ref={pieceRef}
id={playerInfo.id}
className={"player " + (className ?? "")}
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<div tabIndex={0} className="player-content" onKeyUp={onKeyUp}>
{availableActions && (
<div className="player-actions">{availableActions()}</div>
)}
<PlayerPiece
team={playerInfo.team}
text={playerInfo.role}
hasBall={usesBall}
/>
</div>
</div>
)
}

@ -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 (
<div className="steps-tree">
<StepsTreeNode
node={root}
rootNode={root}
selectedStepId={selectedStepId}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
onStepSelected={onStepSelected}
/>
</div>
)
}
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<HTMLDivElement>(null)
return (
<div ref={ref} className={"step-group"}>
{node.children.map((child) => (
<BendableArrow
key={child.id}
area={ref}
startPos={"step-piece-" + node.id}
segments={[
{
next: "step-piece-" + child.id,
},
]}
onSegmentsChanges={() => {}}
forceStraight={true}
wavy={false}
//TODO remove magic constants
startRadius={10}
endRadius={10}
/>
))}
<StepPiece
id={node.id}
isSelected={selectedStepId === node.id}
onAddButtonClicked={() => onAddChildren(node)}
onRemoveButtonClicked={
rootNode.id === node.id
? undefined
: () => onRemoveNode(node)
}
onSelected={() => onStepSelected(node)}>
<p>
{useMemo(
() => getStepName(rootNode, node.id),
[node.id, rootNode],
)}
</p>
</StepPiece>
<div className={"step-children"}>
{node.children.map((child) => (
<StepsTreeNode
key={child.id}
rootNode={rootNode}
selectedStepId={selectedStepId}
node={child}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
onStepSelected={onStepSelected}
/>
))}
</div>
</div>
)
}
interface StepPieceProps {
id: number
isSelected: boolean
onAddButtonClicked?: () => void
onRemoveButtonClicked?: () => void
onSelected: () => void
children?: ReactNode
}
function StepPiece({
id,
isSelected,
onAddButtonClicked,
onRemoveButtonClicked,
onSelected,
children,
}: StepPieceProps) {
return (
<div
id={"step-piece-" + id}
tabIndex={1}
className={
"step-piece " + (isSelected ? "step-piece-selected" : "")
}
onClick={onSelected}>
<div className="step-piece-actions">
{onAddButtonClicked && (
<AddSvg
onClick={onAddButtonClicked}
className={"add-icon"}
/>
)}
{onRemoveButtonClicked && (
<RemoveSvg
onClick={onRemoveButtonClicked}
className={"remove-icon"}
/>
)}
</div>
{children}
</div>
)
}

@ -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

@ -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<PlayerLike>(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<string, Pos>,
): 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<string, Pos>,
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<T extends TacticComponent>(
id: string,
components: TacticComponent[],
): T {
return components.find((c) => c.id === id)! as T
return tryGetComponent<T>(id, components)!
}
export function tryGetComponent<T extends TacticComponent>(
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)
}

@ -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
}

@ -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<string, Pos>,
): StepContent {
const nonPhantomComponents: (Player | CourtObject)[] =
content.components.filter(
(c): c is Exclude<TacticComponent, PlayerPhantom> =>
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<string, Pos>,
): 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<Player>(
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
}

@ -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 }
/**

@ -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

@ -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<typeof BALL_TYPE, Pos> & Frozable
//place here all different kinds of objects
export type CourtObject = Ball
export type Ball = Component<typeof BALL_TYPE, Pos>

@ -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
}

@ -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<T, Positioning> {
/**
@ -32,3 +41,7 @@ export interface Component<T, Positioning> {
readonly actions: Action[]
}
export interface Frozable {
readonly frozen: boolean
}

File diff suppressed because it is too large Load Diff

@ -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)
}

@ -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

@ -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<TacticContext | ServiceError> {
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<StepInfoNode | ServiceError> {
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<void | ServiceError> {
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<void | ServiceError> {
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<void | ServiceError> {
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<StepContent | ServiceError> {
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()
}
}

@ -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(<StepInfoNode>{ id: 1, children: [] }),
)
}
return new LocalStorageTacticService()
}
async getContext(): Promise<TacticContext | ServiceError> {
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<StepInfoNode | ServiceError> {
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<StepContent | ServiceError> {
const content = localStorage.getItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step,
)
return content ? JSON.parse(content) : null
}
async removeStep(id: number): Promise<void | ServiceError> {
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<void | ServiceError> {
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step,
JSON.stringify(content),
)
}
async setName(name: string): Promise<void | ServiceError> {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
}
}

@ -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<TacticContext | ServiceError>
addStep(
parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError>
removeStep(id: number): Promise<void | ServiceError>
setName(name: string): Promise<void | ServiceError>
saveContent(
step: number,
content: StepContent,
): Promise<void | ServiceError>
getContent(step: number): Promise<StepContent | ServiceError>
}

@ -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;
}

@ -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%;
}

@ -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;
}

@ -8,6 +8,9 @@ export default defineConfig({
build: {
target: "es2021",
},
test: {
environment: "jsdom",
},
plugins: [
react(),
cssInjectedByJsPlugin({

Loading…
Cancel
Save