diff --git a/app/features/map-planner/components/Planner.tsx b/app/features/map-planner/components/Planner.tsx index 3f16b0622..89701cd7d 100644 --- a/app/features/map-planner/components/Planner.tsx +++ b/app/features/map-planner/components/Planner.tsx @@ -1,3 +1,13 @@ +import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; +import { + DndContext, + DragOverlay, + PointerSensor, + useDraggable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { snapCenterToCursor } from "@dnd-kit/modifiers"; import type { Editor, TLAssetId, @@ -17,8 +27,7 @@ import { import clsx from "clsx"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import * as R from "remeda"; -import { usePlannerBg } from "~/hooks/usePlannerBg"; +import { useTheme } from "~/features/theme/core/provider"; import type { LanguageCode } from "~/modules/i18n/config"; import { modesShort } from "~/modules/in-game-lists/modes"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; @@ -42,21 +51,32 @@ import { SendouButton } from "../../../components/elements/Button"; import { Image } from "../../../components/Image"; import type { StageBackgroundStyle } from "../plans-types"; +const DROPPED_IMAGE_SIZE_PX = 45; +const BACKGROUND_WIDTH = 1127; +const BACKGROUND_HEIGHT = 634; + export default function Planner() { const { i18n } = useTranslation(); - const plannerBgParams = usePlannerBg(); + const { htmlThemeClass } = useTheme(); const [editor, setEditor] = React.useState(null); const [imgOutlined, setImgOutlined] = React.useState(false); + const [activeDragItem, setActiveDragItem] = React.useState<{ + src: string; + previewPath: string; + } | null>(null); + + const sensors = useSensors(useSensor(PointerSensor)); const handleMount = React.useCallback( (mountedEditor: Editor) => { setEditor(mountedEditor); mountedEditor.user.updateUserPreferences({ locale: ourLanguageToTldrawLanguage(i18n.language), + colorScheme: htmlThemeClass === "dark" ? "dark" : "light", }); }, - [i18n], + [i18n, htmlThemeClass], ); const handleAddImage = React.useCallback( @@ -121,58 +141,48 @@ export default function Planner() { [editor, imgOutlined], ); - const handleAddWeapon = React.useCallback( - (src: string) => { - // Adjustable parameters for image spawning - const imageSizePx = 45; - const imageSpawnBoxSizeFactorX = 0.15; - const imageSpawnBoxSizeFactorY = 0.3; - const imageSpawnBoxOffsetFactorX = 0; - const imageSpawnBoxOffsetFactorY = 0.2; - - // Get positions of the background rectangle - const bgRectangleLeft = plannerBgParams.pointOffsetX; - const bgRectangleTop = plannerBgParams.pointOffsetY; - - // Subtract the size of the image here to correct the image spawn location at the right-most & bottom-most boundaries - const bgRectangleRight = - bgRectangleLeft + plannerBgParams.bgWidth - imageSizePx; - const bgRectangleBottom = - plannerBgParams.pointOffsetY + plannerBgParams.bgHeight - imageSizePx; - - // Derived values for image spawn box - const imageSpawnBoxLeft = - bgRectangleLeft + plannerBgParams.bgWidth * imageSpawnBoxOffsetFactorX; - const imageSpawnBoxRight = - imageSpawnBoxSizeFactorX * (bgRectangleRight - bgRectangleLeft) + - imageSpawnBoxLeft; - const imageSpawnBoxTop = - bgRectangleTop + plannerBgParams.bgHeight * imageSpawnBoxOffsetFactorY; - const imageSpawnBoxBottom = - imageSpawnBoxSizeFactorY * (bgRectangleBottom - bgRectangleTop) + - imageSpawnBoxTop; + const handleAddWeaponAtPosition = React.useCallback( + (src: string, point: [number, number]) => { + const centeredPoint: [number, number] = [ + point[0] - DROPPED_IMAGE_SIZE_PX / 2, + point[1] - DROPPED_IMAGE_SIZE_PX / 2, + ]; handleAddImage({ src, - size: [imageSizePx, imageSizePx], + size: [DROPPED_IMAGE_SIZE_PX, DROPPED_IMAGE_SIZE_PX], isLocked: false, - point: [ - R.randomInteger(imageSpawnBoxLeft, imageSpawnBoxRight), - R.randomInteger(imageSpawnBoxTop, imageSpawnBoxBottom), - ], + point: centeredPoint, cb: () => editor?.setCurrentTool("select"), }); }, - [ - editor, - handleAddImage, - plannerBgParams.bgHeight, - plannerBgParams.bgWidth, - plannerBgParams.pointOffsetX, - plannerBgParams.pointOffsetY, - ], + [editor, handleAddImage], ); + const handleDragStart = (event: DragStartEvent) => { + const { src, previewPath } = event.active.data.current as { + src: string; + previewPath: string; + }; + setActiveDragItem({ src, previewPath }); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveDragItem(null); + + if (!editor) return; + + const { active } = event; + const { src } = active.data.current as { src: string }; + + const pointerPosition = event.activatorEvent as PointerEvent; + const dropX = pointerPosition.clientX + (event.delta?.x ?? 0); + const dropY = pointerPosition.clientY + (event.delta?.y ?? 0); + + const pagePoint = editor.screenToPage({ x: dropX, y: dropY }); + handleAddWeaponAtPosition(src, [pagePoint.x, pagePoint.y]); + }; + const handleAddBackgroundImage = React.useCallback( (urlArgs: { stageId: StageId; @@ -192,19 +202,14 @@ export default function Planner() { handleAddImage({ src: stageMinimapImageUrlWithEnding(urlArgs), - size: [plannerBgParams.bgWidth, plannerBgParams.bgHeight], + size: [BACKGROUND_WIDTH, BACKGROUND_HEIGHT], isLocked: true, - point: [plannerBgParams.pointOffsetX, plannerBgParams.pointOffsetY], + point: [0, 0], }); + + editor.zoomToFit(); }, - [ - editor, - handleAddImage, - plannerBgParams.bgHeight, - plannerBgParams.bgWidth, - plannerBgParams.pointOffsetX, - plannerBgParams.pointOffsetY, - ], + [editor, handleAddImage], ); // removes all tldraw ui that isnt needed @@ -229,18 +234,30 @@ export default function Planner() { }; return ( - <> + - +
- +
- + + {activeDragItem ? ( + + ) : null} + +
); } @@ -291,11 +308,50 @@ function OutlineToggle({ ); } -function WeaponImageSelector({ - handleAddWeapon, +function DraggableWeaponButton({ + id, + src, + imgPath, + previewPath, + alt, + title, + size, }: { - handleAddWeapon: (src: string) => void; + id: string; + src: string; + imgPath: string; + previewPath: string; + alt: string; + title: string; + size: number; }) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id, + data: { src, previewPath }, + }); + + return ( + + ); +} + +function WeaponImageSelector() { const { t, i18n } = useTranslation(["weapons", "common", "game-misc"]); const isWide = i18n.language === "fr"; @@ -321,23 +377,16 @@ function WeaponImageSelector({
{category.weaponIds.map((weaponId) => { return ( - - handleAddWeapon( - `${outlinedMainWeaponImageUrl(weaponId)}.png`, - ) - } - > - {t(`weapons:MAIN_${weaponId}`)} - + id={`main-${weaponId}`} + src={`${outlinedMainWeaponImageUrl(weaponId)}.png`} + imgPath={mainWeaponImageUrl(weaponId)} + previewPath={outlinedMainWeaponImageUrl(weaponId)} + alt={t(`weapons:MAIN_${weaponId}`)} + title={t(`weapons:MAIN_${weaponId}`)} + size={36} + /> ); })}
@@ -352,21 +401,16 @@ function WeaponImageSelector({
{subWeaponIds.map((subWeaponId) => { return ( - - handleAddWeapon(`${subWeaponImageUrl(subWeaponId)}.png`) - } - > - {t(`weapons:SUB_${subWeaponId}`)} - + id={`sub-${subWeaponId}`} + src={`${subWeaponImageUrl(subWeaponId)}.png`} + imgPath={subWeaponImageUrl(subWeaponId)} + previewPath={subWeaponImageUrl(subWeaponId)} + alt={t(`weapons:SUB_${subWeaponId}`)} + title={t(`weapons:SUB_${subWeaponId}`)} + size={28} + /> ); })}
@@ -384,23 +428,16 @@ function WeaponImageSelector({
{specialWeaponIds.map((specialWeaponId) => { return ( - - handleAddWeapon( - `${specialWeaponImageUrl(specialWeaponId)}.png`, - ) - } - > - {t(`weapons:SPECIAL_${specialWeaponId}`)} - + id={`special-${specialWeaponId}`} + src={`${specialWeaponImageUrl(specialWeaponId)}.png`} + imgPath={specialWeaponImageUrl(specialWeaponId)} + previewPath={specialWeaponImageUrl(specialWeaponId)} + alt={t(`weapons:SPECIAL_${specialWeaponId}`)} + title={t(`weapons:SPECIAL_${specialWeaponId}`)} + size={28} + /> ); })}
@@ -413,19 +450,16 @@ function WeaponImageSelector({
{(["TC", "RM", "CB"] as const).map((mode) => { return ( - handleAddWeapon(`${modeImageUrl(mode)}.png`)} - > - {t(`game-misc:MODE_LONG_${mode}`)} - + id={`mode-${mode}`} + src={`${modeImageUrl(mode)}.png`} + imgPath={modeImageUrl(mode)} + previewPath={modeImageUrl(mode)} + alt={t(`game-misc:MODE_LONG_${mode}`)} + title={t(`game-misc:MODE_LONG_${mode}`)} + size={28} + /> ); })}
diff --git a/app/features/map-planner/plans.css b/app/features/map-planner/plans.css index 386c264af..2e27f2d8b 100644 --- a/app/features/map-planner/plans.css +++ b/app/features/map-planner/plans.css @@ -178,3 +178,37 @@ img[src$="?outline=red"] { drop-shadow(0 0 var(--outline-width) var(--outline-color)) drop-shadow(0 0 var(--outline-width) var(--outline-color)); } + +.plans__draggable-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--s-0-5); + border: none; + border-radius: var(--rounded); + background: transparent; + cursor: grab; + touch-action: none; +} + +.plans__draggable-button:hover { + background-color: var(--bg-lighter); +} + +.plans__weapon-dragging { + opacity: 0.5; +} + +.plans__drag-preview-container { + display: block; + min-width: 45px; + min-height: 45px; +} + +.plans__drag-preview { + cursor: grabbing; + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} diff --git a/app/hooks/usePlannerBg.ts b/app/hooks/usePlannerBg.ts deleted file mode 100644 index b36f6fce4..000000000 --- a/app/hooks/usePlannerBg.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useEffect, useState } from "react"; -import { useWindowSize } from "react-use"; - -type PlannerBgParams = { - bgWidth: number; - bgHeight: number; - pointOffsetX: number; - pointOffsetY: number; -}; - -// Dynamic background size. See this issue for more info: https://github.com/sendou-ink/sendou.ink/issues/1161 -const bgSizeFactor = 0.8; - -export function usePlannerBg() { - const [plannerBgParams, setPlannerBgParams] = useState({ - bgWidth: 1200, - bgHeight: 800, - pointOffsetX: 240, - pointOffsetY: 96, - }); - - // Natively available WindowSize hook: https://usehooks-ts.com/react-hook/use-window-size - const windowSize = useWindowSize(); - - useEffect(() => { - const bgWidth = windowSize.width * bgSizeFactor; - const bgHeight = windowSize.height * bgSizeFactor; - - // Point offsets that move the image closer to the center of the window - const pointOffsetX = bgWidth * (1 - bgSizeFactor); - const pointOffsetY = 0.6 * (bgHeight * (1 - bgSizeFactor)); // Removes some dead space above the image - - setPlannerBgParams({ - bgWidth, - bgHeight, - pointOffsetX, - pointOffsetY, - }); - }, [windowSize.width, windowSize.height]); - - return plannerBgParams; -} diff --git a/package-lock.json b/package-lock.json index de1c6d3eb..da54feab5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/lib-storage": "^3.797.0", "@date-fns/tz": "^1.2.0", "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@epic-web/cachified": "^5.5.2", @@ -1670,6 +1671,20 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, "node_modules/@dnd-kit/sortable": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", diff --git a/package.json b/package.json index 7827a9fda..e3ffe8f1a 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@aws-sdk/lib-storage": "^3.797.0", "@date-fns/tz": "^1.2.0", "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@epic-web/cachified": "^5.5.2",