Merge pull request 'Add steps to the editor' (#114) from editor/steps into master
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
Reviewed-on: #114pull/116/head
commit
cca7ee1b1b
Before Width: | Height: | Size: 405 B After Width: | Height: | Size: 216 B |
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
}
|
@ -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%;
|
||||
}
|
Loading…
Reference in new issue