Drag and Drop players in the editor #11

Merged
maxime.batista merged 12 commits from editor/place-players into master 1 year ago

@ -4,7 +4,11 @@ mkdir -p /outputs/public
apt update && apt install jq -y
npm install
npm run build -- --base=/IQBall/public --mode PROD
val drone_branch = std::env("DRONE_BRANCH").unwrap()
val base = "/IQBall/$drone_branch/public"
npm run build -- --base=$base --mode PROD
// Read generated mappings from build
val result = $(jq -r 'to_entries|map(.key + " " +.value.file)|.[]' dist/manifest.json)

@ -11,8 +11,6 @@ export function renderView(Component: FunctionComponent, args: {}) {
document.getElementById('root') as HTMLElement
);
console.log(args)
root.render(
<React.StrictMode>
<Component {...args}/>

@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
width="100%"
viewBox="7.5 18.5 85.5 56"
style="enable-background:new 7.5 18.5 85.5 56;"
xml:space="preserve">
<style type="text/css">
.st0{fill:none;stroke:#000000;stroke-miterlimit:10;}
.st1{fill:none;stroke:#000000;stroke-miterlimit:10;stroke-dasharray:1.4358,1.4358;}
.st2{fill:none;stroke:#000000;stroke-width:0.5;stroke-miterlimit:10;}
.st3{fill:none;stroke:#000000;stroke-miterlimit:10;stroke-dasharray:1.4407,1.4407;}
</style>
<polygon class="st0" points="92.1,72.1 50.1,72.1 8.1,72.1 8.1,21.2 50.1,21.2 92.1,21.2 "/>
<line class="st0" x1="50.1" y1="21.2" x2="50.1" y2="72.1"/>
<circle class="st0" cx="50.1" cy="46.6" r="6.4"/>
<path class="st0" d="M8.1,66h7.2c10.1,0,18.2-8.7,18.2-19.3s-8.2-19.3-18.2-19.3H8.1"/>
<path class="st0" d="M8.1,40.2h19c3.6,0,6.4,2.9,6.4,6.4s-2.9,6.4-6.4,6.4h-19"/>
<line class="st0" x1="27.1" y1="40.2" x2="27.1" y2="53.1"/>
<g>
<g><path class="st0" d="M27.4,40.3c-0.3,0-0.5,0-0.7,0"/>
<path class="st1"
d="M25.3,40.7c-2.5,0.9-4.3,3.3-4.3,6.1c0,3,2.2,5.6,5,6.2"/>
<path
class="st0" d="M26.7,53c0.2,0,0.5,0,0.7,0"/>
</g>
</g>
<line class="st0" x1="16.2" y1="53.1" x2="16.2" y2="54.1"/>
<line class="st2" x1="19.3" y1="53.1" x2="19.3" y2="54.1"/>
<line class="st2" x1="22.4" y1="53.1" x2="22.4" y2="54.1"/>
<line class="st2" x1="25.7" y1="53.1" x2="25.7" y2="54.1"/>
<line class="st0" x1="16.1" y1="39.2" x2="16.1" y2="40.2"/>
<line class="st2" x1="19.2" y1="39.2" x2="19.2" y2="40.2"/>
<line class="st2" x1="22.3" y1="39.2" x2="22.3" y2="40.2"/>
<line class="st2" x1="25.6" y1="39.2" x2="25.6" y2="40.2"/>
<line class="st0" x1="27.1" y1="40.2" x2="27.1" y2="53.1"/>
<path class="st0" d="M92.1,66.1h-7.2c-10.1,0-18.2-8.7-18.2-19.3s8.2-19.3,18.2-19.3h7.2"/>
<path class="st0" d="M92.1,40.3h-19c-3.6,0-6.4,2.9-6.4,6.4s2.9,6.4,6.4,6.4h19"/>
<line class="st0" x1="84" y1="53.2" x2="84" y2="54.1"/>
<line class="st2" x1="80.9" y1="53.2" x2="80.9" y2="54.1"/>
<line class="st2" x1="77.9" y1="53.2" x2="77.9" y2="54.1"/>
<line class="st2" x1="74.5" y1="53.2" x2="74.5" y2="54.1"/>
<line class="st0" x1="84.1" y1="39.3" x2="84.1" y2="40.3"/>
<line class="st2" x1="81" y1="39.3" x2="81" y2="40.3"/>
<line class="st2" x1="77.9" y1="39.3" x2="77.9" y2="40.3"/>
<line class="st2" x1="74.6" y1="39.3" x2="74.6" y2="40.3"/>
<line class="st0" x1="73.1" y1="40.3" x2="73.1" y2="53.2"/>
<line class="st2" x1="36.2" y1="70" x2="36.2" y2="74.1"/>
<line class="st2" x1="63.5" y1="70" x2="63.5" y2="74.1"/>
<line class="st2" x1="36.2" y1="19.1" x2="36.2" y2="23.2"/>
<line class="st2" x1="63.5" y1="19.1" x2="63.5" y2="23.2"/>
<g xmlns="http://www.w3.org/2000/svg">
<g>
<path class="st0" d="M72.9,40.3c0.3,0,0.5,0,0.7,0"/>
<path class="st3"
d="M75,40.7c2.5,0.9,4.3,3.3,4.3,6.1c0,3-2.2,5.6-5.1,6.2"/>
<path
class="st0" d="M73.5,53.1c-0.2,0-0.5,0-0.7,0"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@ -0,0 +1,5 @@
<svg width="80" height="49" 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>

After

Width:  |  Height:  |  Size: 427 B

@ -0,0 +1,58 @@
import {ReactElement, useRef} from "react";
import Draggable from "react-draggable";
export interface RackProps<E extends {key: string | number}> {
id: string,
objects: E[],
onChange: (objects: E[]) => void,
canDetach: (ref: HTMLDivElement) => boolean,
onElementDetached: (ref: HTMLDivElement, el: E) => void,
render: (e: E) => ReactElement,
}
interface RackItemProps<E extends {key: string | number}> {
item: E,
onTryDetach: (ref: HTMLDivElement, el: E) => void,
render: (e: E) => ReactElement,
}
/**
* A container of draggable objects
* */
export function Rack<E extends {key: string | number}>({id, objects, onChange, canDetach, onElementDetached, render}: RackProps<E>) {
return (
<div id={id} style={{
display: "flex"
}}>
{objects.map(element => (
<RackItem key={element.key}
item={element}
render={render}
onTryDetach={(ref, element) => {
if (!canDetach(ref))
return
const index = objects.findIndex(o => o.key === element.key)
onChange(objects.toSpliced(index, 1))
onElementDetached(ref, element)
}}/>
))}
</div>
)
}
function RackItem<E extends {key: string | number}>({item, onTryDetach, render}: RackItemProps<E>) {
const divRef = useRef<HTMLDivElement>(null);
return (
<Draggable
position={{x: 0, y: 0}}
nodeRef={divRef}
onStop={() => onTryDetach(divRef.current!, item)}>
<div ref={divRef}>
{render(item)}
</div>
</Draggable>
)
}

@ -0,0 +1,25 @@
import CourtSvg from '../../assets/basketball_court.svg?react';
import '../../style/basket_court.css';
import {useRef} from "react";
import CourtPlayer from "./CourtPlayer";
import {Player} from "../../data/Player";
export interface BasketCourtProps {
players: Player[],
onPlayerRemove: (p: Player) => void,
}
export function BasketCourt({players, onPlayerRemove}: BasketCourtProps) {
return (
<div id="court-container" style={{position: "relative"}}>
<CourtSvg id="court-svg"/>
{players.map(player => {
return <CourtPlayer key={player.id}
player={player}
onRemove={() => onPlayerRemove(player)}
/>
})}
</div>
)
}

@ -0,0 +1,51 @@
import {useRef} from "react";
import "../../style/player.css";
import RemoveIcon from "../../assets/icon/remove.svg?react";
import Draggable from "react-draggable";
import {PlayerPiece} from "./PlayerPiece";
import {Player} from "../../data/Player";
export interface PlayerProps {
player: Player,
onRemove: () => void
}
/**
* A player that is placed on the court, which can be selected, and moved in the associated bounds
* */
export default function CourtPlayer({player, onRemove}: PlayerProps) {
const x = player.rightRatio;
const y = player.bottomRatio;
return (
<Draggable
handle={".player-piece"}
bounds="parent"
>
<div className={"player"}
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<div tabIndex={0}
className="player-content"
onKeyUp={e => {
if (e.key == "Delete")
onRemove()
}}>
<div className="player-selection-tab">
<RemoveIcon
className="player-selection-tab-remove"
onClick={onRemove}/>
</div>
<PlayerPiece team={player.team} text={player.role}/>
</div>
</div>
</Draggable>
)
}

@ -0,0 +1,12 @@
import React from "react";
import '../../style/player.css'
import {Team} from "../../data/Team";
export function PlayerPiece({team, text}: { team: Team, text: string }) {
return (
<div className={`player-piece ${team}`}>
<p>{text}</p>
</div>
)
}

@ -0,0 +1,29 @@
import {Team} from "./Team";
export interface Player {
/**
* unique identifier of the player.
* This identifier must be unique to the associated court.
*/
id: number,
/**
* the player's team
* */
team: Team,
/**
* player's position
* */
role: string,
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
bottomRatio: number
/**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
rightRatio: number,
}

@ -0,0 +1,4 @@
export enum Team {
Allies = "allies",
Opponents = "opponents"
}

@ -0,0 +1,20 @@
#court-container {
display: flex;
background-color: var(--main-color);
}
#court-svg {
margin: 5%;
user-select: none;
-webkit-user-drag: none;
}
#court-svg * {
stroke: var(--selected-team-secondarycolor);
}

@ -5,4 +5,9 @@
--second-color: #ccde54;
--background-color: #d2cdd3;
--selected-team-primarycolor: #ffffff;
--selected-team-secondarycolor: #000000;
--selection-color: #3f7fc4
}

@ -1,13 +1,16 @@
@import "colors.css";
#main {
#main-div {
display: flex;
height: 100%;
width: 100%;
background-color: var(--background-color);
flex-direction: column;
}
#topbar {
#topbar-div {
display: flex;
background-color: var(--main-color);
@ -15,6 +18,42 @@
align-items: stretch;
}
#racks {
display: flex;
justify-content: space-between;
}
.title_input {
width: 25ch;
}
#edit-div {
height: 100%;
}
#allies-rack .player-piece , #opponent-rack .player-piece {
margin-left: 5px;
}
.player-piece.opponents {
background-color: #f59264;
}
#court-div {
background-color: var(--background-color);
height: 100%;
display: flex;
align-items: center;
justify-content: center;
align-content: center;
}
#court-div-bounds {
width: 60%;
}
.react-draggable {
z-index: 2;
}

@ -0,0 +1,79 @@
/**
as the .player div content is translated,
the real .player div position is not were the user can expect.
Disable pointer events to this div as it may overlap on other components
on the court.
*/
.player {
pointer-events: none;
}
.player-content {
display: flex;
flex-direction: column;
align-content: center;
align-items: center;
outline: none;
}
.player-piece {
font-family: monospace;
pointer-events: all;
background-color: var(--selected-team-primarycolor);
color: var(--selected-team-secondarycolor);
border-width: 2px;
border-radius: 100px;
border-style: solid;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.player-selection-tab {
display: flex;
position: absolute;
margin-bottom: 10%;
justify-content: center;
visibility: hidden;
width: 100%;
transform: translateY(-20px);
}
.player-selection-tab-remove {
pointer-events: all;
height: 25%;
}
.player-selection-tab-remove * {
stroke: red;
fill: white;
}
.player-selection-tab-remove:hover * {
fill: #f1dbdb;
stroke: #ff331a;
cursor: pointer;
}
.player:focus-within .player-selection-tab {
visibility: visible;
}
.player:focus-within .player-piece {
color: var(--selection-color);
}
.player:focus-within {
z-index: 1000;
}

@ -1,19 +1,77 @@
import React, {CSSProperties, useState} from "react";
import {CSSProperties, useRef, useState} from "react";
import "../style/editor.css";
import TitleInput from "../components/TitleInput";
import {API} from "../Constants";
import {BasketCourt} from "../components/editor/BasketCourt";
import {Rack} from "../components/Rack";
import {PlayerPiece} from "../components/editor/PlayerPiece";
import {Player} from "../data/Player";
import {Team} from "../data/Team";
const ERROR_STYLE: CSSProperties = {
borderColor: "red"
}
export default function Editor({id, name}: { id: number, name: string }) {
/**
* information about a player that is into a rack
*/
interface RackedPlayer {
team: Team,
key: string,
}
export default function Editor({id, name}: { id: number, name: string }) {
const [style, setStyle] = useState<CSSProperties>({});
const positions = ["1", "2", "3", "4", "5"]
const [allies, setAllies] = useState(
positions.map(key => ({team: Team.Allies, key}))
)
const [opponents, setOpponents] = useState(
positions.map(key => ({team: Team.Opponents, key}))
)
const [players, setPlayers] = useState<Player[]>([]);
const courtDivContentRef = useRef<HTMLDivElement>(null);
const canDetach = (ref: HTMLDivElement) => {
const refBounds = ref.getBoundingClientRect();
const courtBounds = courtDivContentRef.current!.getBoundingClientRect();
// check if refBounds overlaps courtBounds
return !(
refBounds.top > courtBounds.bottom ||
refBounds.right < courtBounds.left ||
refBounds.bottom < courtBounds.top ||
refBounds.left > courtBounds.right
);
}
const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => {
const refBounds = ref.getBoundingClientRect();
const courtBounds = courtDivContentRef.current!.getBoundingClientRect();
const relativeXPixels = refBounds.x - courtBounds.x;
const relativeYPixels = refBounds.y - courtBounds.y;
const xRatio = relativeXPixels / courtBounds.width;
const yRatio = relativeYPixels / courtBounds.height;
setPlayers(players => {
return [...players, {
id: players.length,
team: element.team,
role: element.key,
rightRatio: xRatio,
bottomRatio: yRatio
}]
})
}
return (
<div id="main">
<div id="topbar">
<div id="main-div">
<div id="topbar-div">
<div>LEFT</div>
<TitleInput style={style} default_value={name} on_validated={new_name => {
fetch(`${API}/tactic/${id}/edit/name`, {
@ -35,6 +93,53 @@ export default function Editor({id, name}: { id: number, name: string }) {
}}/>
<div>RIGHT</div>
</div>
<div id="edit-div">
<div id="racks">
<Rack id="allies-rack"
objects={allies}
onChange={setAllies}
canDetach={canDetach}
onElementDetached={onPieceDetach}
render={({team, key}) => <PlayerPiece team={team} text={key} key={key}/>}/>
<Rack id="opponent-rack"
objects={opponents}
onChange={setOpponents}
canDetach={canDetach}
onElementDetached={onPieceDetach}
render={({team, key}) => <PlayerPiece team={team} text={key} key={key}/>}/>
</div>
<div id="court-div">
<div id="court-div-bounds" ref={courtDivContentRef}>
<BasketCourt
players={players}
onPlayerRemove={(player) => {
setPlayers(players => {
const idx = players.indexOf(player)
return players.toSpliced(idx, 1)
})
switch (player.team) {
case Team.Opponents:
setOpponents(opponents => (
[...opponents, {
team: player.team,
pos: player.role,
key: player.role
}]
))
break
case Team.Allies:
setAllies(allies => (
[...allies, {
team: player.team,
pos: player.role,
key: player.role
}]
))
}
}}/>
</div>
</div>
</div>
</div>
)
}

@ -12,10 +12,10 @@
"@types/react-dom": "^18.2.14",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-draggable": "^4.4.6",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vite-plugin-css-injected-by-js": "^3.3.0",
"web-vitals": "^2.1.4"
"vite-plugin-css-injected-by-js": "^3.3.0"
},
"scripts": {
"start": "vite --host",
@ -29,6 +29,7 @@
]
},
"devDependencies": {
"@vitejs/plugin-react": "^4.1.0"
"@vitejs/plugin-react": "^4.1.0",
"vite-plugin-svgr": "^4.1.0"
}
}

@ -0,0 +1 @@
../front

@ -6,7 +6,7 @@
"dom.iterable",
"esnext"
],
"types": ["vite/client"],
"types": ["vite/client", "vite-plugin-svgr/client"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,

@ -2,6 +2,7 @@ import {defineConfig} from "vite";
import react from '@vitejs/plugin-react';
import fs from "fs";
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
import svgr from "vite-plugin-svgr";
function resolve_entries(dirname: string): [string, string][] {
@ -38,6 +39,9 @@ export default defineConfig({
react(),
cssInjectedByJsPlugin({
relativeCSSInjection: true,
}),
svgr({
include: "**/*.svg?react"
})
]
})

Loading…
Cancel
Save