SVG를 useRef
에 넣어서 사용해야 되는 일이 생겼다. 당연히 SVGElement
타입인 줄 알고 아래같은 코드를 작성함.
const svgRef = useRef<SVGElement>(null);
...
<svg ref={svgRef}/>
알고보니 <svg/>
는 SVGElement
가 아니라 SVGSVGElement
라는 타입이다. 말장난 같아서 잠깐 당황했지만, 검색해서 재밌는 사실을 알 수 있었다.
출처 : https://developer.mozilla.org/en-US/docs/Web/API/SVGSVGElement
SVGElement
는 모든 SVG요소들이 공통적으로 상속받는 인터페이스다. 예를들어 <svg/>, <circle/>, <text/>
등 말이다.
SVGSVGElement
는 <svg/>
라는 태그의 인터페이스다. 말장난 같지만, <svg/>
태그가 SVGElement
의 하위 집합이기 때문이다.
실제로 <circle/>
태그에 대한 인터페이스를 나타내는 SVGCircleElement
도 있다.
특정노드의 좌표를 가져오는 메서드다. 이를 통해 룰렛이 멈췄을 때, 12시에 있는 영역을 알아내고 있었다.
그런데 정확히 12시에 있는 영역이 아닌, 살짝 왼쪽에 치우친 영역을 잡는 버그가 생겼다.
처음에는 부채꼴 영역을 표현하는 수식을 의심했다. 룰렛을 그릴때 정확한 부채꼴을 그리는 것이 아닌, stroke-dasharray
를 이용한 편법이었으니. 하지만 수식을 점검해도 이상은 없었다.
두번째로 의심한 게 바로 룰렛을 가리키는 화살표 노드다. 이 노드에 getBoundingClientRect()
를 사용하여 좌표를 찾고있었다.
문제는 좌표를 계산하는 방식이었다.
사용했던 메서드는 뷰포트 기준 상대 노드 좌측을 x좌표, 상단을 y좌표로 잡고있었다. (중앙이라고 착각함)
출처 : https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
화살표 노드를 검사해보면, w-10
으로인하여 노드 좌측 기준으로 잡을 시 정확한 중앙을 보장하지 못했다.
따라서 화살표 노드의 형제노드로 가로세로 1px짜리 숨겨진 노드를 만들고, 그 노드를 타겟으로 했더니 정상작동하는 모습을 볼 수 있었다.
<span
className="bg-blue-500 w-10 h-10 absolute left-1/2 translate-x-[-50%] z-10"
style={{ clipPath: "polygon(50% 100%, 25% 0, 75% 0)" }}
/>
//숨겨진 화살표 역할
<span
className="display:none size-[1px] absolute left-1/2 translate-x-[-50%]"
ref={arrowRef}
/>
문제해결!
contentEditable
을 사용했을때 flex, items-center
를 주어도 수직정렬이 되지 않았다.
정확히는 입력 전 커서 위치말이다.
이는 line-height
를 이용하여 해결할 수 있었다.
line-height
는 일반적으로 텍스트 간의 줄 사이 간격을 정하는데 사용된다. 값이 높을수록 간격이 넓어진다.
한 줄만 존재하지만, 어째선지 값이 높을수록 첫 줄의 높이가 낮아지는 현상이 있었다. 어쨌든 덕분에 높이를 맞췄으니 해결
바닐라JS를 하다가 Next(React)로 넘어오니 확실히 편하다. 다만 살짝 번잡해진 감도 있다. 상태제어나...
그래도 DOM조작이 간편해져서 굉장히 좋았다.
여담이지만 컴포넌트를 어떻게 만들어야할지 아직도 감이 잡히지 않는다.
SpinWheelSector
는 룰렛의 영역을 담당하는 컴포넌트인데, props가 꽤 많은 것 같다...
왜 이러냐면, SpinWheel
이라는 부모컴포넌트에서 useSpinWheelStore
(전역상태)를 사용해서 가공후 props로 넘겨준다. item 내부에서 전역스토어를 건들지 않고 인자로 받아오는 편이 재사용에 유리할 것으로 생각했기 때문이다.
//SpinWheel
import { RefObject } from "react";
import useSpinwheelStore from "../../store/useSpinwheelStore";
import SpinWheelSector from "./SpinWheelSector";
import { DEFAULT_VALUES } from "@/constants/SpinWheel";
interface SpinWheelProps {
diameter: number;
spinWheelRef: RefObject<SVGSVGElement>;
arrowRef: RefObject<HTMLElement>;
}
const SpinWheel = ({ diameter, spinWheelRef, arrowRef }: SpinWheelProps) => {
const { sectorData } = useSpinwheelStore();
const totalSector = sectorData.reduce((acc, cur) => acc + cur.ratio, 0);
return (
<figure
className="relative"
style={{
width: `${diameter}px`,
height: `${diameter}px`,
}}
>
<span
className="bg-blue-500 w-10 h-10 absolute left-1/2 translate-x-[-50%] z-10"
style={{ clipPath: "polygon(50% 100%, 25% 0, 75% 0)" }}
/>
<span
className="display:none size-[1px] absolute left-1/2 translate-x-[-50%]"
ref={arrowRef}
/>
<svg
ref={spinWheelRef}
viewBox={`0 0 ${diameter} ${diameter}`}
width={diameter}
height={diameter}
transform={`rotate(${DEFAULT_VALUES.DEG})`}
>
{sectorData.map((data) => (
<SpinWheelSector
key={data.id}
radius={diameter / 4}
totalSector={totalSector}
{...data}
/>
))}
</svg>
</figure>
);
};
export default SpinWheel;
//SpinWheelSector
interface SpinWheelSectorProps extends SectorData {
radius: number;
totalSector: number;
}
const SpinWheelSector = ({
ratio,
text,
style,
totalSector,
accRatio = 0,
radius,
}: SpinWheelSectorProps) => {
const sectorRatio = ratio / totalSector;
const sectorPercentage = sectorRatio * 100;
const circumference = 2 * Math.PI * radius;
const sectorRotationDeg = (360 / totalSector) * accRatio;
const sectorTextRotationDeg = ((360 * sectorRatio) / 2).toFixed(1);
return (
<g transform={`rotate(${sectorRotationDeg})`} className="origin-center">
<circle
className="origin-center"
r={radius}
strokeWidth={radius * 2}
strokeDasharray={`${
(sectorPercentage * circumference) / 100
} ${circumference}`}
stroke={style.backgroundColor}
cx="50%"
cy="50%"
fill="transparent"
/>
<text
className="origin-center"
textAnchor="middle"
fontSize="25"
strokeWidth="4"
paintOrder="stroke"
stroke="black"
x="50%"
y="50%"
fill="white"
transform={`rotate(${sectorTextRotationDeg}) translate(${radius} 10)`}
>
{text}
</text>
</g>
);
};
export default SpinWheelSector;
또 다른 컴포넌트도 헷갈린다...아래는 룰렛의 텍스트를 입력받는 컴포넌트들이다.
SpinWheelTextList
라는 컴포넌트가 부모고, 자식인 SpinWheelTextItem
를 map
으로 렌더링한다.
이때 자식컴포넌트인 SpinWheelTextItem
은 id
를 알 필요가 없다 생각하여 id
를 map
렌더링할때 인자로 넣어주어 각종 데이터 핸들링 함수를 props로 전달하였다.
export type KeyEventWithChangeEventType = React.KeyboardEvent &
React.ChangeEvent<HTMLDivElement>;
const SpinWheelTextList = ({ isLocked }: { isLocked: boolean }) => {
const {
sectorData,
totalRatio,
updateSectorText,
addSector,
deleteSector,
updateSectorRatio,
initializeSectorData,
} = useSpinwheelStore();
const onKeyDown = (id: string) => (e: KeyEventWithChangeEventType) => {
if (e.key === "Enter") {
e.preventDefault();
addSector(id);
setTimeout(() => {
const nextNode = (
e.target.parentNode as HTMLDivElement
).nextElementSibling?.querySelector(
"[contentEditable]"
) as HTMLDivElement;
nextNode.focus();
console.log(nextNode);
}, 0);
}
if (e.key === "Backspace") {
if (
sectorData.length <= DEFAULT_VALUES.TOTAL_RATIO ||
e.target.textContent !== ""
)
return;
deleteSector(id);
const prevNode = (
e.target.parentNode as HTMLDivElement
).previousElementSibling?.querySelector(
"[contentEditable]"
) as HTMLDivElement;
prevNode.focus();
}
};
const onModifyButtonClick = (id: string) => (ratio: number) =>
updateSectorRatio(id, ratio);
const onInput = (id: string) => (text: string) => updateSectorText(id, text);
const onRemoveButtonClick = (id: string) => () => deleteSector(id);
return (
<>
<button
disabled={isLocked}
className="bg-indigo-300 w-30 ml-5"
onClick={initializeSectorData}
>
룰렛 초기화
</button>
<article className="flex flex-col gap-5 max-h-[60vh] overflow-y-auto w-max p-5">
{sectorData.map(({ id, text, ratio }, index) => (
<SpinWheelTextItem
key={id}
text={text}
ratio={ratio}
index={index}
isLocked={isLocked}
percentage={`${((ratio / totalRatio) * 100).toFixed(2)}%`}
onInput={onInput(id)}
onKeyDown={onKeyDown(id)}
onModifyButtonClick={onModifyButtonClick(id)}
onRemoveButtonClick={onRemoveButtonClick(id)}
/>
))}
</article>
</>
);
};
export default SpinWheelTextList;
//-------------//
import useHover from "@/app/hooks/useHover";
import EditableDiv from "./EditableDiv";
import { KeyEventWithChangeEventType } from "./SpinWheelTextList";
import { useRef } from "react";
interface SpinWheelTextItemProps {
text: string;
ratio: number;
index: number;
isLocked: boolean;
percentage: string;
onInput: (text: string) => void;
onKeyDown: (e: KeyEventWithChangeEventType) => void;
onModifyButtonClick: (ratio: number) => void;
onRemoveButtonClick: () => void;
}
const SpinWheelTextItem = ({
text,
ratio,
index,
isLocked,
percentage,
onInput,
onKeyDown,
onModifyButtonClick,
onRemoveButtonClick,
}: SpinWheelTextItemProps) => {
const hoverRef = useRef<HTMLDivElement>(null);
const { isHover } = useHover({ hoverRef });
return (
<div
className="flex items-center justify-evenly bg-green-200 hover:bg-green-300 h-[45px] w-[470px] rounded-md text-sm"
ref={hoverRef}
>
<div className="w-5 flex justify-center relative">
{index}
{isHover && (
<button
disabled={isLocked}
className="rounded-full size-5 bg-white text-red-500 flex justify-center items-center absolute"
onClick={onRemoveButtonClick}
>
x
</button>
)}
</div>
<EditableDiv
isDisabled={isLocked}
text={text}
onInput={onInput}
onKeyDown={(e: KeyEventWithChangeEventType) => onKeyDown(e)}
/>
<div>{`x${ratio}`}</div>
<div className="flex flex-col gap-1">
<button
className="rounded-full size-4 bg-yellow-100 flex justify-center items-center hover:bg-yellow-300"
disabled={isLocked}
onClick={() => onModifyButtonClick(ratio + 1)}
>
+
</button>
<button
className="rounded-full size-4 bg-yellow-100 flex justify-center items-center hover:bg-yellow-300"
disabled={isLocked}
onClick={() => onModifyButtonClick(ratio - 1)}
>
-
</button>
</div>
<div>{percentage}</div>
</div>
);
};
export default SpinWheelTextItem;
뭐가 정답인지는 아직도 모르겠다.😥
고수님들 도와줘...!