프로젝트의 새로운 feature 중에 커버 이미지를 만드는 feature가 생겼다.
(대충 디자이너님이 그린 구상도)
이런 식으로 소설의 표지 이미지도 단순 우리가 이미지 삽입 뿐만 아니라 제목에 대해서 이를 웹소설과 같은 제목의 폰트로 넣어주면 어떨까 하는 제안에서 시작된 기능이었다.
나 또한 굉장히 흥미가 가는 feature라고 생각해서 덥썩 물어버렸다.
Canvas APi냐, HTML Element냐
내가 여기서 생각한 방법은 두 가지가 있었다.
- Canvas API를 사용해서 직접 그리고, 그린 이미지를 그대로 저장
- HTML Element로 편집하고, HTML Element를 이미지로 변환해주는 라이브러리를 사용해서 이미지로 변환 후 저장 이 두 가지 방법 중에 어떤 방법을 선택할지 생각해보았다
| Canvas API | HTML → image | |
|---|---|---|
| 장점 | - 이미지를 저장하는 로직까지 내가 관여가 가능하다 - canvas API에 대한 숙련도를 높일 수 있다 | - 친숙한 HTML를 사용하면 되다보니 보다 이벤트 관리 등이 쉬워진다. |
| 단점 | - 텍스트의 너비에 따라 어느정도에서 줄바꿈을 해야 하는지 등의 관리는 직접 세부적으로 조정해야 한다 - 텍스트의 높이, 너비관리를 하려면 보다 이벤트 관리의 중요성이 커지고, feature가 가지고 있는 예상 소요 시간이 크게 증가한다. | - 라이브러리에 의존하다보니 영향을 받을 가능성이 있다 - 여러 라이브러리에서 마이너한 버그가 제보되고 있고, 여기서 만약 모르는 버그가 나오게 된다면 소요 시간이 크게 늘어날 수 있다 |
| 정도가 될 것 같다. |
나같은 경우는 canvas API를 사용하는 것을 좋아하기도 해서 한번 canvas API를 사용해서 초기 버전을 만들어보고, 하면서 너무 커진다 싶으면 빠르게 HTML로도 해보는건 어떨까? 라는 생각이 들어 일단은 canvas API를 이용해 구현해보기로 했다.
"use client";
import { CreateNovelForm } from "@/app/create/_schema/createNovelSchema";
import { titleSplitter } from "@/app/create/_utils/titleSplitter";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { useFormContext } from "react-hook-form";
const editFontList = ["heiroOfLight", "bombaram"] as const;
const CANVAS_PADDING = 20;
const CANVAS_WIDTH = 210 - CANVAS_PADDING * 2;
type CoverImageContext = {
canvasRef: React.RefObject<HTMLCanvasElement>;
selectedFont: (typeof editFontList)[number];
changeImage: (image: string | File) => void;
changeTitleFont: (font: (typeof editFontList)[number]) => void;
changeFontColor: (color: FontColor) => void;
};
type FontColor = "black" | "sunset" | "ocean" | "pastel" | "rainbow" | "fire";
const CoverImageContext = createContext<CoverImageContext | undefined>(
undefined
);
function CoverImageProvider({ children }: { children: React.ReactNode }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [selectedFont, setSelectedFont] =
useState<(typeof editFontList)[number]>("bombaram");
const [fontColor, setFontColor] = useState<FontColor>("black");
const { getValues } = useFormContext<CreateNovelForm>();
useEffect(() => {
if (canvasRef.current) {
draw();
}
}, [fontColor, image, selectedFont]);
const changeImage = (image: string | File) => {
if (image instanceof File) {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.src = e.target?.result as string;
img.onload = () => {
setImage(img);
};
};
reader.readAsDataURL(image);
}
if (image instanceof String) {
const img = new Image();
img.src = image as string;
img.onload = () => {
setImage(img);
};
}
};
const drawImage = () => {
if (!image && !canvasRef.current) return;
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
ctx.drawImage(
image as HTMLImageElement,
0,
0,
canvasRef.current?.width as number,
canvasRef.current?.height as number
);
};
const draw = () => {
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
ctx.clearRect(
0,
0,
canvasRef.current?.width as number,
canvasRef.current?.height as number
);
if (image) drawImage();
drawTitle();
};
function drawTitle() {
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const title = titleSplitter(getValues("title"));
ctx.font = `30px ${selectedFont}`;
ctx.fillStyle = createFontGradient(ctx, fontColor);
title.forEach((_, idx) => {
ctx.fillText(
title[idx],
CANVAS_PADDING,
ctx.canvas.height - 60 + idx * 30,
CANVAS_WIDTH
);
});
}
function changeTitleFont(font: (typeof editFontList)[number]) {
if (font === selectedFont) return;
setSelectedFont(font);
}
function changeFontColor(color: FontColor) {
if (color === fontColor) return;
setFontColor(color);
}
return (
<CoverImageContext.Provider
value={{
canvasRef,
changeImage,
changeTitleFont,
selectedFont,
changeFontColor,
}}
>
{children}
</CoverImageContext.Provider>
);
}
const useCoverImageContext = () => {
const coverImageContext = useContext(CoverImageContext);
if (!coverImageContext) {
throw new Error(
"useCoverImageContext must be used within a CoverImageProvider"
);
}
return coverImageContext;
};
const createFontGradient = (
ctx: CanvasRenderingContext2D,
color: FontColor
): CanvasGradient | "black" => {
const gradient = ctx.createConicGradient(0, 0, CANVAS_WIDTH);
switch (color) {
case "sunset":
gradient.addColorStop(0, "#ff7e5f");
gradient.addColorStop(1, "#feb47b");
break;
case "ocean":
gradient.addColorStop(0, "#00c6ff");
gradient.addColorStop(1, "#0072ff");
break;
case "pastel":
gradient.addColorStop(0, "#fbc2eb");
gradient.addColorStop(1, "#a6c1ee");
break;
case "rainbow":
gradient.addColorStop(0, "#ff6ec4");
gradient.addColorStop(0.25, "#7873f5");
gradient.addColorStop(0.5, "#42e695");
gradient.addColorStop(0.75, "#fdd819");
gradient.addColorStop(1, "#ff6ec4");
break;
case "fire":
gradient.addColorStop(0, "#f12711");
gradient.addColorStop(1, "#f5af19");
break;
default:
return "black";
}
return gradient;
};
export { CoverImageProvider, useCoverImageContext, editFontList };
export type { FontColor };
나같은 경우 폼 내에서 제목 정보를 받아와야 하기 때문에 폼의 Provider의 자식 요소로 다시금 CoverImageProvider로 감쌀 수 있게끔 하였다. 그 다음 각각의 폰트 선택과 색상 선택에 대해서 단일책임을 가지게끔 함수로 분리하여 함수를 추가하고, 이를 각 컴포넌트에서 내려서 사용할 수 있게끔 해주었다.
function ColorSelect() {
const { changeFontColor } = useCoverImageContext();
return (
<div className="flex gap-2 self-center">
{Object.keys(fontColorStyles).map((color) => (
<Button
type="button"
variant="outline"
className={`${
fontColorStyles[color as FontColor]
} rounded-full w-5 h-5`}
onClick={() => changeFontColor(color as FontColor)}
></Button>
))}
</div>
);
}
function FontSelect() {
const { canvasRef, changeTitleFont, selectedFont } = useCoverImageContext();
return (
<div className="flex gap-2 self-center">
{editFontList.map((font) => (
<Button
type="button"
style={{ fontFamily: font }}
variant={selectedFont === font ? "default" : "outline"}
onClick={() => {
changeTitleFont(font);
}}
>
{FONT_NAME[font]}
</Button>
))}
</div>
);
}그래서 각각의 선택창을 보면 간단하게 UI 정도만 구성되어 있고 여기서 버튼을 통해 각 속성을 넣을 수 있게끔 해주었다. 해당 버전은 간단하게만 구현해놓은 버전인데, 이는 Provider에 대해서 너무 많은 책임이 과중되어 있기 때문에 이를 분리할 필요성도 있고, 막상 구현해놓으니 텍스트의 경우 보다 신경쓸 만한 부분이 많다고 생각했다. 따라서 이를 다시금 HTML로 구현해보려고 한다.