diff --git a/admin.html b/admin.html index 8dd53a8c..52b531b8 100644 --- a/admin.html +++ b/admin.html @@ -5,9 +5,10 @@ - - - + + + + Awesome Orange - Admin diff --git a/index.html b/index.html index a9383096..5257db36 100644 --- a/index.html +++ b/index.html @@ -6,9 +6,10 @@ - - - + + + + Awesome Orange FE diff --git a/public/font/HyundaiSansTextKROTFBold.woff2 b/public/font/HyundaiSansTextKROTFBold.woff2 new file mode 100644 index 00000000..1003e927 Binary files /dev/null and b/public/font/HyundaiSansTextKROTFBold.woff2 differ diff --git a/public/font/HyundaiSansTextKROTFMedium.woff2 b/public/font/HyundaiSansTextKROTFMedium.woff2 new file mode 100644 index 00000000..d6bf231a Binary files /dev/null and b/public/font/HyundaiSansTextKROTFMedium.woff2 differ diff --git a/public/font/HyundaiSansTextKROTFRegular.woff2 b/public/font/HyundaiSansTextKROTFRegular.woff2 new file mode 100644 index 00000000..63227e6f Binary files /dev/null and b/public/font/HyundaiSansTextKROTFRegular.woff2 differ diff --git a/public/font/fonts.css b/public/font/fonts.css new file mode 100644 index 00000000..068dee82 --- /dev/null +++ b/public/font/fonts.css @@ -0,0 +1,20 @@ +@font-face { + font-family: "hdsans"; + src: url("/font/HyundaiSansTextKROTFBold.woff2") format("woff2"); + font-weight: bold; + font-display: swap; +} + +@font-face { + font-family: "hdsans"; + src: url("/font/HyundaiSansTextKROTFMedium.woff2") format("woff2"); + font-weight: 500; + font-display: swap; +} + +@font-face { + font-family: "hdsans"; + src: url("/font/HyundaiSansTextKROTFRegular.woff2") format("woff2"); + font-weight: 400; + font-display: swap; +} \ No newline at end of file diff --git a/src/adminPage/features/eventDetail/drawButton/DrawResultModal.jsx b/src/adminPage/features/eventDetail/drawButton/DrawResultModal.jsx index 7892182a..3fd30b6d 100644 --- a/src/adminPage/features/eventDetail/drawButton/DrawResultModal.jsx +++ b/src/adminPage/features/eventDetail/drawButton/DrawResultModal.jsx @@ -2,14 +2,14 @@ import { Fragment, useMemo } from "react"; import useScrollControl from "./useScrollControl.js"; import { fetchServer } from "@common/dataFetch/fetchServer.js"; import { useQuery } from "@common/dataFetch/getQuery.js"; -import { GroupMap } from "@common/utils.js"; +import { GroupMap, addHyphen } from "@common/utils.js"; function mapResultSubsetGroup({ ranking, name, phoneNumber }) { return (

{ranking}등

{name}

-

{phoneNumber}

+

{addHyphen(phoneNumber)}

); } @@ -21,7 +21,7 @@ function DrawResultModal({ eventId }) { const { hullRef, mountMap, scrollTo, intersectState } = useScrollControl(); // render logic - const maxGrade = drawResultData.at(-1).ranking; + const maxGrade = drawResultData.length === 0 ? 0 : drawResultData.at(-1).ranking; const tableStyle = "w-full grid grid-cols-[4rem_1fr_2fr] auto-rows-[minmax(2rem,auto)] gap-4 items-center justify-items-center"; @@ -63,7 +63,13 @@ function DrawResultModal({ eventId }) {

전화번호

-
{[...drawResultGroup].map(mapResultGroup)}
+ {drawResultData.length === 0 ? ( +
+ 저런! 참가자가 없군요! +
+ ) : ( +
{[...drawResultGroup].map(mapResultGroup)}
+ )}
); diff --git a/src/adminPage/features/eventDetail/drawButton/useScrollControl.js b/src/adminPage/features/eventDetail/drawButton/useScrollControl.js index e321d6b2..e85f5d39 100644 --- a/src/adminPage/features/eventDetail/drawButton/useScrollControl.js +++ b/src/adminPage/features/eventDetail/drawButton/useScrollControl.js @@ -45,7 +45,7 @@ function useScrollControl() { }, { root: hullRef.current ?? null, threshold: 0.01 }, ); - for (let [, elem] of itemRef.current) { + for (let [, elem] of getMap()) { observerRef.current.observe(elem); } diff --git a/src/adminPage/index.css b/src/adminPage/index.css index a44d84db..4cef5ee5 100644 --- a/src/adminPage/index.css +++ b/src/adminPage/index.css @@ -2,33 +2,6 @@ @tailwind components; @tailwind utilities; -@font-face { - font-family: "ds-digital"; - src: url("/font/DS-DIGI.TTF") format("truetype"); - font-display: swap; -} - -@font-face { - font-family: "hdsans"; - src: url("/font/HyundaiSansTextKROTFBold.otf") format("opentype"); - font-weight: bold; - font-display: swap; -} - -@font-face { - font-family: "hdsans"; - src: url("/font/HyundaiSansTextKROTFMedium.otf") format("opentype"); - font-weight: 500; - font-display: swap; -} - -@font-face { - font-family: "hdsans"; - src: url("/font/HyundaiSansTextKROTFRegular.otf") format("opentype"); - font-weight: 400; - font-display: swap; -} - @layer base { body { font-family: "hdsans", sans-serif; diff --git a/src/common/components/PhoneInput.jsx b/src/common/components/PhoneInput.jsx index a66f72ca..a841697a 100644 --- a/src/common/components/PhoneInput.jsx +++ b/src/common/components/PhoneInput.jsx @@ -1,14 +1,7 @@ import Input from "./Input.jsx"; +import { addHyphen } from "../utils.js"; function PhoneInput({ text, setText, ...otherProps }) { - function addHyphen(value) { - const plain = value.replace(/\D/g, ""); - - if (plain.length < 4) return plain; - if (plain.length <= 7) return plain.replace(/^(\d{3})(\d{0,4})$/, "$1-$2"); - if (plain.length <= 10) return plain.replace(/^(\d{3})(\d{3})(\d{0,4})$/, "$1-$2-$3"); - return plain.replace(/^(\d{3})(\d{4})(\d{4,})$/, "$1-$2-$3"); - } return (
- + { + if (km !== 0) interactCallback?.(); + }, [km, interactCallback]); + useImperativeHandle($ref, () => ({ reset }), [reset]); return ( @@ -36,15 +44,22 @@ function DistanceDrivenInteraction({ interactCallback, $ref }) { directive="가운데 점을 드래그하여 최대 주행거리를 예측해보세요!" shouldNotSelect={isDragging} /> + + {subtitle(x, y, km)} + + + 스페이스바를 눌러서 드래그 상태를 전환하세요. +
{ onPointerDown(e); pulseAnimation(e); - interactCallback?.(); }} style={circleStyle} + ref={handleRef} />

- - - + {km} km

diff --git a/src/mainPage/features/interactions/distanceDriven/AnswerText.jsx b/src/mainPage/features/interactions/distanceDriven/useDeviceRatio.js similarity index 71% rename from src/mainPage/features/interactions/distanceDriven/AnswerText.jsx rename to src/mainPage/features/interactions/distanceDriven/useDeviceRatio.js index 6ba857da..922436d8 100644 --- a/src/mainPage/features/interactions/distanceDriven/AnswerText.jsx +++ b/src/mainPage/features/interactions/distanceDriven/useDeviceRatio.js @@ -1,19 +1,18 @@ import { useState, useEffect } from "react"; import throttleRaf from "@common/throttleRaf.js"; -const MAX_ANSWER = 800; - -function AnswerText({ distance }) { +function useDeviceRatio() { const [ratio, setRatio] = useState(1); useEffect(() => { + setRatio(Math.hypot(window.innerWidth, window.innerHeight) / 2); const onResize = throttleRaf(() => { - setRatio(Math.hypot(window.innerWidth, window.innerHeight) / (2 * MAX_ANSWER)); + setRatio(Math.hypot(window.innerWidth, window.innerHeight) / 2); }); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); - return <>{Math.round(distance / ratio)}; + return ratio; } -export default AnswerText; +export default useDeviceRatio; diff --git a/src/mainPage/features/interactions/distanceDriven/usePointDrag.js b/src/mainPage/features/interactions/distanceDriven/usePointDrag.js index b7bd9641..98a7623a 100644 --- a/src/mainPage/features/interactions/distanceDriven/usePointDrag.js +++ b/src/mainPage/features/interactions/distanceDriven/usePointDrag.js @@ -1,10 +1,17 @@ import { useState, useRef, useCallback } from "react"; import useMountDragEvent from "@main/hooks/useMountDragEvent.js"; +import useA11yDrag from "@main/hooks/useA11yDrag.js"; -function usePointDrag() { +const grabText = (x, y, km) => + `점을 잡았습니다. 현재 좌표는 (${x}, ${y})이며, 거리는 ${km}km입니다. 방향키를 눌러 점의 위치를 조정하세요. 스페이스바를 눌러 점을 놓을 수 있습니다.`; +const moveText = (x, y, km) => `현재 좌표는 (${x}, ${y})이며, 거리는 ${km}km입니다.`; +const dropText = (x, y, km) => `점이 놓였습니다. 새 좌표는 (${x}, ${y})이며, 거리는 ${km}km입니다.`; + +function usePointDrag(enabled) { const prevState = useRef({ x: 0, y: 0, mouseX: 0, mouseY: 0 }); const [x, setX] = useState(0); const [y, setY] = useState(0); + const [subtitle, setSubtitle] = useState(() => () => ""); const onDragStart = useCallback( function ({ x: mouseX, y: mouseY }) { @@ -17,9 +24,24 @@ function usePointDrag() { setY(prevState.current.y + mouse.y - prevState.current.mouseY); }, []); + const onKeyMove = useCallback(function (x, y) { + setX((prev) => prev + x * 10); + setY((prev) => prev + y * 10); + }, []); + const { onPointerDown, dragState } = useMountDragEvent({ onDragStart, onDrag, + enabled, + }); + + const handleRef = useA11yDrag({ + grabText, + moveText, + dropText, + onKeyMove, + enabled, + setSubtitle, }); return { @@ -31,6 +53,8 @@ function usePointDrag() { }, isDragging: dragState, onPointerDown, + handleRef, + subtitle, }; } diff --git a/src/mainPage/features/interactions/fastCharge/index.jsx b/src/mainPage/features/interactions/fastCharge/index.jsx index 01a21b67..9c6024e5 100644 --- a/src/mainPage/features/interactions/fastCharge/index.jsx +++ b/src/mainPage/features/interactions/fastCharge/index.jsx @@ -1,4 +1,4 @@ -import { useImperativeHandle } from "react"; +import { useEffect, useImperativeHandle } from "react"; import InteractionDescription from "../InteractionDescription.jsx"; import BatteryProgressBar from "./BatteryProgressBar.jsx"; import dialSvg from "./assets/timer.svg"; @@ -13,18 +13,25 @@ function getProgress(angle) { return rawProgress; } -function FastChargeInteraction({ interactCallback, $ref }) { +function FastChargeInteraction({ interactCallback, $ref, disabled }) { const { angle, style: dialStyle, ref: dialRef, + keyRef, onPointerDown, resetAngle: reset, isDragging, - } = useDialDrag(0); + subtitle, + } = useDialDrag(!disabled); + + useEffect(() => { + if (angle !== 0) interactCallback?.(); + }, [angle, interactCallback]); useImperativeHandle($ref, () => ({ reset }), [reset]); const progress = getProgress(angle); + const answer = Math.round(progress * MAX_MINUTE); return (
@@ -35,6 +42,12 @@ function FastChargeInteraction({ interactCallback, $ref }) { directive="다이얼을 돌려 충전에 필요한 시간을 확인해보세요!" shouldNotSelect={isDragging} /> + + {subtitle(answer, angle)} + + + 스페이스바를 눌러서 다이얼 조작 여부를 전환하세요. +
@@ -46,15 +59,16 @@ function FastChargeInteraction({ interactCallback, $ref }) { className="w-full h-full absolute left-0 top-0 cursor-pointer touch-none select-none" style={dialStyle} ref={dialRef} - onPointerDown={(e) => { - onPointerDown(e); - interactCallback?.(); - }} + onPointerDown={onPointerDown} draggable="false" />

- - {Math.round(progress * MAX_MINUTE)} + + {answer}

diff --git a/src/mainPage/features/interactions/fastCharge/useDialDrag.js b/src/mainPage/features/interactions/fastCharge/useDialDrag.js index 4c603841..2519d887 100644 --- a/src/mainPage/features/interactions/fastCharge/useDialDrag.js +++ b/src/mainPage/features/interactions/fastCharge/useDialDrag.js @@ -1,7 +1,10 @@ import { useState, useRef, useCallback } from "react"; import useMountDragEvent from "@main/hooks/useMountDragEvent.js"; +import useA11yDrag from "@main/hooks/useA11yDrag.js"; import { clamp } from "@common/utils.js"; +const MAX_MINUTE = 30; + function getAngle(pointer, center) { const vx = pointer.x - center.x; const vy = pointer.y - center.y; @@ -14,12 +17,23 @@ function getAngleDelta(prev, current) { return current - prev; } -function useDialDrag() { +const grabText = (value) => + `다이얼 조작을 시작합니다. 현재 당신이 선택한 충전 시간은 ${value}분입니다. 왼쪽 방향키를 눌러서 충전 시간을 줄이고, 오른쪽 방향키를 눌러서 충전 시간을 늘려보세요. 최대 30분까지만 늘릴 수 있습니다.`; +const moveText = (value, angle) => { + if (angle > 0) return `다이얼을 0도 이하로 조작할 수 없습니다.`; + if (angle < -Math.PI * 2) return `다이얼을 360도 이상으로 조작할 수 없습니다.`; + return `다이얼을 돌렸습니다. 현재 각도는 ${Math.floor((-angle * 180) / Math.PI)}도이며, 당신이 선택한 충전 시간은 ${value}분입니다.`; +}; +const dropText = (value) => + `다이얼 조작을 해제했습니다. 당신이 선택한 충전 시간은 ${value}분입니다.`; + +function useDialDrag(enabled = true) { const [angle, setAngle] = useState(0); const dialRef = useRef(null); const dialCenter = useRef({ x: 0, y: 0 }); const prevAngle = useRef(0); const angleCache = useRef(0); + const [subtitle, setSubtitle] = useState(() => () => ""); const onDragStart = useCallback((cursor) => { if (dialRef.current === null) return; @@ -44,6 +58,7 @@ function useDialDrag() { onDragStart, onDrag, onDragEnd, + enabled, }); const resetAngle = useCallback(() => { @@ -52,6 +67,30 @@ function useDialDrag() { prevAngle.current = 0; }, []); + const onKeyMove = useCallback((x, y) => { + const UNIT = (Math.PI * 2) / MAX_MINUTE; + + const delta = x !== 0 ? x : -y; + function getNewAngle(angle) { + const rounded = Math.round(angle / UNIT); + if (rounded - delta > 0) return UNIT; + else if (rounded - delta < -MAX_MINUTE) return -Math.PI * 2 - UNIT; + return (rounded - delta) * UNIT; + } + + angleCache.current = getNewAngle(angleCache.current); + setAngle(angleCache.current); + }, []); + + const keyRef = useA11yDrag({ + grabText, + moveText, + dropText, + onKeyMove, + enabled, + setSubtitle, + }); + const style = { transform: `rotate(${angle}rad)`, transition: dragState ? "none" : "transform 0.5s", @@ -61,9 +100,11 @@ function useDialDrag() { angle, style, ref: dialRef, + keyRef, onPointerDown, resetAngle, isDragging: dragState, + subtitle, }; } diff --git a/src/mainPage/features/interactions/mock.js b/src/mainPage/features/interactions/mock.js index b8a8b1e2..0e637fbd 100644 --- a/src/mainPage/features/interactions/mock.js +++ b/src/mainPage/features/interactions/mock.js @@ -9,7 +9,7 @@ const handlers = [ const token = request.headers.get("authorization"); if (token === null) return HttpResponse.json({ dates: [] }); - return HttpResponse.json(eventParticipationDate); + return HttpResponse.json(eventParticipationDate, { status: 402 }); }), http.post("/api/v1/event/draw/:eventId/participation", ({ request }) => { const token = request.headers.get("authorization"); diff --git a/src/mainPage/features/interactions/univasalIsland/Phone.jsx b/src/mainPage/features/interactions/univasalIsland/Phone.jsx index 44e3b064..3ef62754 100644 --- a/src/mainPage/features/interactions/univasalIsland/Phone.jsx +++ b/src/mainPage/features/interactions/univasalIsland/Phone.jsx @@ -1,12 +1,18 @@ import style from "./style.module.css"; -function Phone({ dynamicStyle, onPointerDown, isSnapped }) { +function Phone({ dynamicStyle, onPointerDown, isSnapped, disabled, $ref }) { const staticStyle = `absolute flex justify-center items-center ${style.phone} cursor-pointer touch-none`; const phoneScreenFill = isSnapped ? "fill-green-700" : "fill-neutral-900"; const lightningOpacity = isSnapped ? "opacity-100" : "opacity-0"; return ( -
+
({ reset }), [reset]); @@ -36,6 +39,12 @@ function UnivasalIslandInteraction({ interactCallback, $ref }) { directive="유니버설 아일랜드를 드래그하여 이동시키고 스마트폰을 충전해보세요!" shouldNotSelect={isDragging} /> + + {subtitle} + + + 스페이스바를 눌러서 유니버설 아일랜드와 스마트폰을 잡으세요. +
left seat
{ islandEventListener.onPointerDown(e); - interactCallback?.(); }} > univasal island univasal island
right seat { phoneEventListener.onPointerDown(e); - interactCallback?.(); }} />
diff --git a/src/mainPage/features/interactions/univasalIsland/reducer.js b/src/mainPage/features/interactions/univasalIsland/reducer.js new file mode 100644 index 00000000..6e966703 --- /dev/null +++ b/src/mainPage/features/interactions/univasalIsland/reducer.js @@ -0,0 +1,60 @@ +import { clamp } from "@common/utils.js"; + +const PHONE_INITIAL_X = 150; +const PHONE_INITIAL_Y = 100; + +export function getDefaultState() { + return { + islandY: 0, + phoneX: PHONE_INITIAL_X, + phoneY: PHONE_INITIAL_Y, + phoneIsSnapping: false, + phoneShouldSnapped: false, + islandKeyControlled: false, + }; +} + +function islandReducer(state, action) { + switch (action.type) { + case "reset-snap": + return { ...state, phoneShouldSnapped: false, islandKeyControlled: false }; + case "grab-key-island": + return { ...state, islandKeyControlled: action.value }; + case "move-island": { + const newY = typeof action.mutate === "function" ? action.mutate(state.islandY) : action.y; + const islandY = clamp(newY, -50, 50); + return { + ...state, + phoneShouldSnapped: false, + islandY, + phoneX: state.phoneIsSnapping ? 0 : state.phoneX, + phoneY: state.phoneIsSnapping ? islandY : state.phoneY, + }; + } + case "move-phone": + if (typeof action.mutate === "function") { + const { x, y } = action.mutate({ x: state.phoneX, y: state.phoneY }); + return { ...state, phoneShouldSnapped: false, phoneX: x, phoneY: y }; + } else return { ...state, phoneX: action.x, phoneY: action.y }; + case "drop-phone": { + let snap = action.isSnapped; + if (action.valueSnap) { + snap = Math.hypot(state.islandY - state.phoneY, state.phoneX) < 75; + } + if (snap) + return { + islandY: state.islandY, + phoneX: 0, + phoneY: state.islandY, + phoneShouldSnapped: true, + phoneIsSnapping: true, + islandKeyControlled: false, + }; + else return { ...state, phoneIsSnapping: false }; + } + case "reset": + return getDefaultState(); + } +} + +export default islandReducer; diff --git a/src/mainPage/features/interactions/univasalIsland/useIslandDrag.js b/src/mainPage/features/interactions/univasalIsland/useIslandDrag.js index 6b20a6c6..66976e37 100644 --- a/src/mainPage/features/interactions/univasalIsland/useIslandDrag.js +++ b/src/mainPage/features/interactions/univasalIsland/useIslandDrag.js @@ -1,9 +1,27 @@ -import { useState, useRef, useMemo, useCallback } from "react"; +import { useState, useReducer, useRef, useMemo, useCallback } from "react"; +import islandReducer, { getDefaultState } from "./reducer.js"; import useMountDragEvent from "@main/hooks/useMountDragEvent.js"; -import { clamp } from "@common/utils.js"; +import useA11yDrag from "@main/hooks/useA11yDrag.js"; const PHONE_INITIAL_X = 150; const PHONE_INITIAL_Y = 100; +const STEP = 25; + +const assistive = { + univasal: { + grabText: () => + "유니버설 아일랜드를 잡았습니다. 위,아래 방향키로 유니버설 아일랜드의 위치를 이동하세요. 스페이스바로 유니버설 아일랜드를 놓으세요.", + moveText: ({ islandY }) => `유니버설 아일랜드를 이동했습니다. (y: ${islandY})`, + dropText: () => "유니버설 아일랜드를 놓았습니다.", + }, + phone: { + grabText: () => + "스마트폰을 잡았습니다. 방향키로 스마트폰의 위치를 이동하세요. 스페이스바로 스마트폰을 놓으세요", + moveText: ({ phoneX, phoneY }) => `스마트폰을 이동했습니다. (x: ${phoneX}, y: ${phoneY})`, + dropText: ({ phoneIsSnapping }) => + `스마트폰을 놓았습니다. ${phoneIsSnapping ? "스마트폰이 아일랜드에 스냅되었습니다." : "스마트폰이 아일랜드에서 벗어났습니다."}`, + }, +}; function aabbCheck(bound1, bound2) { if (bound1.right < bound2.left) return false; @@ -13,101 +31,160 @@ function aabbCheck(bound1, bound2) { return true; } -function useIslandDrag() { +function useIslandDrag(enabled = true, interactCallback = null) { + /**-------------------------------------------------------------------* + * * + * State - ref : 아일랜드 드래그의 상태입니다. * + * * + *--------------------------------------------------------------------*/ + // island state const islandStartMouseYPosition = useRef(0); const islandStartPosition = useRef(0); - const [islandY, setIslandY] = useState(0); // phone state const phoneStartMousePosition = useRef({ x: 0, y: 0 }); const phoneStartPosition = useRef({ x: PHONE_INITIAL_X, y: PHONE_INITIAL_Y }); - const [phoneIsSnapping, setPhoneIsSnapping] = useState(false); - const [phoneShouldSnapped, setPhoneShouldSnapped] = useState(false); - const [phoneX, setPhoneX] = useState(PHONE_INITIAL_X); - const [phoneY, setPhoneY] = useState(PHONE_INITIAL_Y); + + // reducer + const [state, dispatch] = useReducer(islandReducer, null, getDefaultState); + const { islandY, phoneX, phoneY, phoneIsSnapping, phoneShouldSnapped, islandKeyControlled } = + state; // phone snap area const phoneSnapArea = useRef(null); + // A11y subtitle + const [subtitle, setSubtitle] = useState(() => () => ""); + + /**-------------------------------------------------------------------* + * * + * 아일랜드 오브젝트를 드래그 앤 드롭할 때 호출되는 함수입니다. * + * * + *--------------------------------------------------------------------*/ + // mount island drag event const islandOnDragStart = useCallback( ({ y }) => { - setPhoneShouldSnapped(false); + dispatch({ type: "reset-snap" }); islandStartMouseYPosition.current = y; islandStartPosition.current = islandY; + interactCallback?.(); }, - [islandY], - ); - const islandOnDragging = useCallback( - function ({ y: mouseY }) { - const rawY = mouseY - islandStartMouseYPosition.current + islandStartPosition.current; - const y = clamp(rawY, -50, 50); - - setIslandY(y); - - if (phoneIsSnapping) { - setPhoneX(0); - setPhoneY(y); - } - }, - [phoneIsSnapping], + [islandY, interactCallback], ); + const islandOnDragging = useCallback(function ({ y: mouseY }) { + const rawY = mouseY - islandStartMouseYPosition.current + islandStartPosition.current; + dispatch({ type: "move-island", y: rawY }); + }, []); const { onPointerDown: islandOnPointerDown, dragState: islandIsDrag } = useMountDragEvent({ onDragStart: islandOnDragStart, onDrag: islandOnDragging, + enabled, + }); + + // a11y island keyboard event + const onKeyGrab = useCallback(() => { + dispatch({ type: "grab-key-island", value: true }); + }, []); + const onIslandKeyMove = useCallback( + (_, y) => { + dispatch({ type: "move-island", mutate: (state) => state + y * STEP }); + interactCallback?.(); + }, + [interactCallback], + ); + const onKeyRelease = useCallback(() => { + dispatch({ type: "grab-key-island", value: false }); + }, []); + const islandRef = useA11yDrag({ + ...assistive.univasal, + onKeyGrab, + onKeyMove: onIslandKeyMove, + onKeyRelease, + enabled, + setSubtitle, }); + /**-------------------------------------------------------------------* + * * + * 스마트폰 오브젝트를 드래그 앤 드롭할 때 호출되는 함수입니다. * + * * + *--------------------------------------------------------------------*/ + // mount phone drag event const phoneOnDragStart = useCallback( (position) => { - setPhoneShouldSnapped(false); + dispatch({ type: "reset-snap" }); phoneStartMousePosition.current = position; phoneStartPosition.current = { x: phoneX, y: phoneY }; + interactCallback?.(); }, - [phoneX, phoneY], + [phoneX, phoneY, interactCallback], ); const phoneOnDragging = useCallback(function ({ x: mouseX, y: mouseY }) { const x = mouseX - phoneStartMousePosition.current.x + phoneStartPosition.current.x; const y = mouseY - phoneStartMousePosition.current.y + phoneStartPosition.current.y; - - setPhoneX(x); - setPhoneY(y); + dispatch({ type: "move-phone", x, y }); + }, []); + const phoneOnDragEnd = useCallback((e) => { + const isSnapped = aabbCheck( + e.target.getBoundingClientRect(), + phoneSnapArea.current.getBoundingClientRect(), + ); + dispatch({ type: "drop-phone", isSnapped }); }, []); - const phoneOnDragEnd = useCallback( - (e) => { - const isSnapped = aabbCheck( - e.target.getBoundingClientRect(), - phoneSnapArea.current.getBoundingClientRect(), - ); - setPhoneIsSnapping(isSnapped); - if (isSnapped) { - setPhoneX(0); - setPhoneY(islandY); - setPhoneShouldSnapped(true); - } - }, - [islandY], - ); const { onPointerDown: phoneOnPointerDown, dragState: phoneIsDrag } = useMountDragEvent({ onDragStart: phoneOnDragStart, onDrag: phoneOnDragging, onDragEnd: phoneOnDragEnd, + enabled, }); + // a11y phone keyboard event + const onPhoneKeyMove = useCallback( + (x, y) => { + dispatch({ + type: "move-phone", + mutate: (state) => ({ x: state.x + x * STEP, y: state.y + y * STEP }), + }); + interactCallback?.(); + }, + [interactCallback], + ); + const onPhoneKeyUp = useCallback(() => { + dispatch({ type: "drop-phone", valueSnap: true }); + }, []); + const phoneRef = useA11yDrag({ + ...assistive.phone, + onKeyGrab, + onKeyMove: onPhoneKeyMove, + onKeyRelease: onPhoneKeyUp, + enabled, + setSubtitle, + }); + + /**-------------------------------------------------------------------* + * * + * 상위 컴포넌트에서 호출할 수 있는 reset 인터페이스입니다. * + * * + *--------------------------------------------------------------------*/ + // reset function interface const reset = useCallback(() => { islandStartMouseYPosition.current = 0; phoneStartMousePosition.current = { x: 0, y: 0 }; islandStartPosition.current = 0; phoneStartPosition.current = { x: PHONE_INITIAL_X, y: PHONE_INITIAL_Y }; - setIslandY(0); - setPhoneIsSnapping(false); - setPhoneShouldSnapped(false); - setPhoneX(PHONE_INITIAL_X); - setPhoneY(PHONE_INITIAL_Y); + dispatch({ type: "reset" }); }, []); + /**-------------------------------------------------------------------* + * * + * style - 아일랜드 오브젝트/스마트폰 오브젝트에 적용할 동적 style입니다. * + * * + *--------------------------------------------------------------------*/ + // island style const islandStyle = useMemo( () => ({ @@ -120,22 +197,28 @@ function useIslandDrag() { // phone style은 상당히 많은 state 종속성을 가지고 있으므로 useMemo가 의미가 없음 const phoneStyle = { transform: `translate(${phoneX}px, ${phoneY}px)`, - transition: phoneShouldSnapped - ? "transform 0.5s" - : !phoneIsSnapping && !phoneIsDrag - ? "transform 0.2s" - : "none", + transition: + !islandKeyControlled && phoneShouldSnapped + ? "transform 0.5s" + : islandKeyControlled || (!phoneIsSnapping && !phoneIsDrag) + ? "transform 0.2s" + : "none", }; return { reset, islandStyle, phoneStyle, + phoneIsSnapping, islandEventListener: { onPointerDown: islandOnPointerDown }, phoneEventListener: { onPointerDown: phoneOnPointerDown }, phoneSnapArea, isDragging: islandIsDrag || phoneIsDrag, + + islandRef, + phoneRef, + subtitle: subtitle({ islandY, phoneX, phoneY, phoneIsSnapping }), }; } diff --git a/src/mainPage/features/introSection/index.jsx b/src/mainPage/features/introSection/index.jsx index 18fa5c8f..ddb71a39 100644 --- a/src/mainPage/features/introSection/index.jsx +++ b/src/mainPage/features/introSection/index.jsx @@ -76,21 +76,23 @@ function IntroSection() {
-

- The new
+

The new
IONIQ 5 -

- -
- + +
+ +
+ +
-
+